refactor(deploy-win): drop UUP dump, take ISO via --iso-url / --win-iso
On a Linux host the UUP converter only produces the RTM install.wim (build 26100.1), so Windows Update had to install the entire cumulative anyway — the UUP download/convert machinery added a slow ~5 GB step and several host tools (aria2/wimtools/cabextract/chntpw) for no benefit. Replace it: deploy-win now takes the install ISO directly via `--iso-url` (urllib download + cache by file name under <CI_ROOT>/iso/, optional --iso-sha256) or a local `--win-iso` (exactly one required). Patch + deploy flow is unchanged. Supply an already-updated ISO (e.g. a massgrave link) to skip the WU step entirely. - Remove winimage/uup.py and its tests. - Trim setup-host-linux.sh deps to p7zip-full + xorriso + genisoimage. - Update RUNBOOK §5 and the Windows-host port plan. Coverage 93.6%.
This commit is contained in:
+19
-16
@@ -77,7 +77,7 @@ sudo); `--uninstall` removes the block. The script defines two helpers:
|
||||
| `ci vmware --vmx ...` | Open a VMX in the VMware Workstation GUI | [§4](#4-manual-vm-lifecycle) |
|
||||
| `ci creds set --user ...` | Store guest credentials in the keyring | [§5](#5-credentials--template-provisioning) |
|
||||
| `ci template deploy-linux` | Build the Linux template VM | [§5](#5-credentials--template-provisioning) |
|
||||
| `ci template deploy-win` | Build the Windows Server 2025 template VM (UUP→patch→deploy) | [§5](#5-credentials--template-provisioning) |
|
||||
| `ci template deploy-win` | Build the Windows Server 2025 template VM (download→patch→deploy) | [§5](#5-credentials--template-provisioning) |
|
||||
| `ci template prepare-linux` | Provision the Linux template over SSH | [§5](#5-credentials--template-provisioning) |
|
||||
| `ci template prepare-win` | Provision a Windows template over WinRM | [§5](#5-credentials--template-provisioning) |
|
||||
| `ci template backup` | Timestamped copy of the template directories | [§5](#5-credentials--template-provisioning) |
|
||||
@@ -418,26 +418,29 @@ ci template backup # timestamped backup of the template directorie
|
||||
|
||||
### `deploy-win` — build the Windows Server 2025 template
|
||||
|
||||
Downloads the newest WS2025 ISO from **UUP dump** (deterministic, build-pinned;
|
||||
component files are MS-signed), patches it (injects `autounattend.xml` +
|
||||
`post-install.ps1` and swaps the EFI boot image to the no-prompt variant), then
|
||||
runs the unattended install, swaps the NIC e1000e → vmxnet3 and takes the cold
|
||||
`PostInstall` snapshot. VMware Tools are mounted as a second CD.
|
||||
Takes a Windows Server 2025 install ISO — downloaded from `--iso-url` (cached
|
||||
under `/var/lib/ci/iso/` by file name) or a local `--win-iso` — patches it
|
||||
(injects `autounattend.xml` + `post-install.ps1` and swaps the EFI boot image to
|
||||
the no-prompt variant), runs the unattended install, swaps the NIC e1000e →
|
||||
vmxnet3 and takes the cold `PostInstall` snapshot. VMware Tools are mounted as a
|
||||
second CD.
|
||||
|
||||
```bash
|
||||
ci template deploy-win # newest build, headless
|
||||
ci template deploy-win --gui # watch the install (needs X — §4.7)
|
||||
ci template deploy-win --build 26100.32860 # pin a specific build (reproducible)
|
||||
ci template deploy-win --win-iso /var/lib/ci/iso/my.iso # skip UUP, use a local ISO
|
||||
# Download + deploy (e.g. a massgrave direct link), headless
|
||||
ci template deploy-win --iso-url 'https://example/win2025.iso'
|
||||
ci template deploy-win --iso-url 'https://example/win2025.iso' --iso-sha256 <HASH> # verify
|
||||
ci template deploy-win --gui --win-iso /var/lib/ci/iso/win2025.iso # local ISO, watch install
|
||||
```
|
||||
|
||||
Notes:
|
||||
- First run downloads ~5 GB and builds the ISO locally (slow, tens of minutes);
|
||||
the result is cached at `/var/lib/ci/iso/ws2025-<build>.iso` and reused.
|
||||
- Needs `aria2`, `wimtools`, `xorriso`, `genisoimage`, `cabextract`, `chntpw`
|
||||
(installed by `setup-host-linux.sh`) and ~16 GiB scratch for the ISO repack.
|
||||
- The final ISO hash is not canonical (assembled locally); the UUP component
|
||||
files are MS-signed. Pin `--build` for reproducible inputs.
|
||||
- Provide **exactly one** of `--iso-url` / `--win-iso`. A downloaded ISO is
|
||||
cached by its URL file name and reused on re-runs.
|
||||
- The supplied ISO is the install media you give it — e.g. an updated WS2025 ISO
|
||||
from massgrave. (UUP dump was dropped: on Linux it only yields the RTM
|
||||
`install.wim`, so Windows Update had to install the whole cumulative anyway.)
|
||||
- Needs `p7zip-full`, `xorriso` (installed by `setup-host-linux.sh`) and ~16 GiB
|
||||
scratch for the ISO repack. The patched ISO is cached per VM dir and only
|
||||
rebuilt when the base ISO or answer files change.
|
||||
- After it finishes: `ci template prepare-win` then
|
||||
`ci validate guest --winrm --guest-os windows`.
|
||||
|
||||
|
||||
@@ -12,17 +12,15 @@ duplicating the orchestration logic.
|
||||
The Python orchestrator stays the single entry point. Where a step needs a
|
||||
Windows-only tool, Python detects `os.name == "nt"` and routes that one step to
|
||||
a thin PowerShell shim (or reuses functions already in
|
||||
`template/Deploy-WinBuild2025.ps1`). Everything else (UUP resolve/download, VMX
|
||||
`template/Deploy-WinBuild2025.ps1`). Everything else (ISO download/cache, VMX
|
||||
build, vmrun lifecycle, flag wait, NIC swap, snapshot) is already
|
||||
platform-neutral Python and is reused unchanged.
|
||||
|
||||
## What already works cross-platform (reuse as-is)
|
||||
|
||||
- `winimage/uup.py` — pure `urllib`/`subprocess`/`zipfile`. The **conversion
|
||||
package** must run the Windows variant: invoke `uup_download_windows.cmd`
|
||||
(present in the same zip) instead of `uup_download_linux.sh`. Add an
|
||||
`os.name`-based branch in `build_iso` to pick the script; the Windows `.cmd`
|
||||
needs `aria2c.exe` + the bundled converter (it self-fetches via `get_aria2.ps1`).
|
||||
- ISO acquisition: `deploy-win --iso-url` (urllib download + cache) / `--win-iso`
|
||||
is pure stdlib and portable as-is. (UUP dump was removed — on Linux it only
|
||||
produced the RTM `install.wim`; supply an updated ISO directly instead.)
|
||||
- `winimage/autounattend.py` — `importlib.resources`, fully portable.
|
||||
- `commands/template.py` deploy-win orchestration: `_winbuild_vmx_lines`,
|
||||
`_remove_vmx_lines`, `_patch_vmx_key`, `_wait_install_flag`,
|
||||
@@ -69,10 +67,10 @@ portable.
|
||||
`isotool`. Keep current Linux behaviour byte-for-byte.
|
||||
3. `template/shims/patch-win-iso.ps1` (new, lint-clean PS 5.1) — oscdimg wrapper;
|
||||
optional IMAPI2FS fallback reusing `Deploy-WinBuild2025.ps1` functions.
|
||||
4. `uup.build_iso` — pick `uup_download_windows.cmd` on Windows.
|
||||
5. Preflight: on Windows check for `oscdimg.exe` (ADK) / `aria2c.exe` instead of
|
||||
the Linux tools; clear error if the ADK is missing.
|
||||
6. Tests: parametrize the new `isotool` dispatch (mock `os.name`); mock the shim
|
||||
4. Preflight: on Windows check for `oscdimg.exe` (ADK) / `7z` instead of the
|
||||
Linux tools; clear error if the ADK is missing. (ISO download is stdlib —
|
||||
no extra tool.)
|
||||
5. Tests: parametrize the new `isotool` dispatch (mock `os.name`); mock the shim
|
||||
call. Keep coverage ≥90%.
|
||||
7. Docs: note Windows-host prerequisites (Windows ADK for `oscdimg`) in
|
||||
`docs/RUNBOOK-PhaseC.md` / a Windows runbook.
|
||||
@@ -88,5 +86,3 @@ portable.
|
||||
|
||||
- `oscdimg` boot-args syntax differs from xorriso; validate the no-prompt UEFI
|
||||
boot on a real VM.
|
||||
- The Windows UUP `.cmd` converter pulls its own toolchain; ensure it runs
|
||||
non-interactively (it can prompt — pass the right flags / answer file).
|
||||
|
||||
+5
-9
@@ -181,15 +181,11 @@ if ! command -v setfacl &>/dev/null; then
|
||||
apt-get install -y acl -qq
|
||||
fi
|
||||
|
||||
# Tooling immagini Windows (template deploy-win): scarica/build ISO da UUP dump
|
||||
# e patch ISO (inject autounattend + no-prompt boot) via xorriso.
|
||||
# aria2 (aria2c) — download paralleli dei file UUP dalla CDN Microsoft
|
||||
# wimtools — wimlib-imagex, conversione UUP → install.wim/ISO
|
||||
# xorriso/genisoimage — repack ISO bootable / build seed ISO (deploy-linux)
|
||||
# cabextract — estrazione pacchetti .cab durante la conversione UUP
|
||||
# chntpw — richiesto dal converter UUP (uup_download_linux.sh)
|
||||
# p7zip-full — 7z: estrae l'ISO Windows (UDF/Joliet) per il repack patch
|
||||
for pkg in aria2 wimtools xorriso genisoimage cabextract chntpw p7zip-full; do
|
||||
# Tooling immagini (template deploy-win patch ISO + deploy-linux seed ISO):
|
||||
# p7zip-full — 7z: estrae l'ISO Windows (UDF/Joliet) per il repack patch
|
||||
# xorriso — repack ISO Windows bootable no-prompt
|
||||
# genisoimage — build cloud-init seed ISO (deploy-linux)
|
||||
for pkg in p7zip-full xorriso genisoimage; do
|
||||
if ! dpkg -s "$pkg" &>/dev/null; then
|
||||
echo " Installazione pacchetto $pkg..."
|
||||
apt-get install -y "$pkg" -qq
|
||||
|
||||
@@ -68,6 +68,7 @@ import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
@@ -82,7 +83,6 @@ from ci_orchestrator.transport.errors import TransportError
|
||||
from ci_orchestrator.transport.winrm import WinRmResult, WinRmTransport
|
||||
from ci_orchestrator.winimage import autounattend as win_autounattend
|
||||
from ci_orchestrator.winimage import iso_patch as win_iso_patch
|
||||
from ci_orchestrator.winimage import uup as win_uup
|
||||
|
||||
|
||||
@click.group()
|
||||
@@ -2086,12 +2086,13 @@ def _vmrun_soft_stop(vmrun: str, vmx: str, *, timeout_s: float = 300.0) -> None:
|
||||
@click.option("--vmx", "vmx_path_str", default=None, metavar="PATH",
|
||||
help="VMX to create (default: <CI_TEMPLATES>/WinBuild2025/WinBuild2025.vmx).")
|
||||
@click.option("--vm-name", default="WinBuild2025", show_default=True, help="VM display name.")
|
||||
@click.option("--build", "uup_build", default=None,
|
||||
help="Pin a UUP build number (e.g. 26100.32860). Default: newest WS2025.")
|
||||
@click.option("--iso-url", "iso_url", default=None, metavar="URL",
|
||||
help="Direct download URL of the Windows Server 2025 install ISO. "
|
||||
"Cached under <CI_ROOT>/iso/. Mutually exclusive with --win-iso.")
|
||||
@click.option("--iso-sha256", "iso_sha256", default="",
|
||||
help="Expected SHA256 of the downloaded ISO (empty = skip verification).")
|
||||
@click.option("--win-iso", "win_iso_str", default=None, metavar="PATH",
|
||||
help="Use this Windows ISO instead of downloading from UUP dump.")
|
||||
@click.option("--edition", default="SERVERSTANDARD", show_default=True, help="UUP edition.")
|
||||
@click.option("--lang", default="en-us", show_default=True, help="UUP language pack.")
|
||||
help="Use this local Windows ISO instead of --iso-url.")
|
||||
@click.option("--image-index", default=2, show_default=True, help="install.wim image index.")
|
||||
@click.option("--product-key", default="TVRH6-WHNXV-R9WG3-9XRFY-MY832", show_default=True)
|
||||
@click.option("--admin-user", default="Administrator", show_default=True)
|
||||
@@ -2119,10 +2120,9 @@ def _vmrun_soft_stop(vmrun: str, vmx: str, *, timeout_s: float = 300.0) -> None:
|
||||
def template_deploy_win(
|
||||
vmx_path_str: str | None,
|
||||
vm_name: str,
|
||||
uup_build: str | None,
|
||||
iso_url: str | None,
|
||||
iso_sha256: str,
|
||||
win_iso_str: str | None,
|
||||
edition: str,
|
||||
lang: str,
|
||||
image_index: int,
|
||||
product_key: str,
|
||||
admin_user: str,
|
||||
@@ -2147,10 +2147,11 @@ def template_deploy_win(
|
||||
) -> None:
|
||||
"""Deploy the Windows Server 2025 build VM template (Linux VMware host).
|
||||
|
||||
Downloads the latest WS2025 ISO from UUP dump (deterministic, build-pinned),
|
||||
patches it (inject autounattend.xml + post-install.ps1 + no-prompt boot),
|
||||
runs the unattended install, swaps the NIC to vmxnet3 and snapshots the
|
||||
template. Mirrors the legacy Deploy-WinBuild2025.ps1 ten-step flow.
|
||||
Downloads the install ISO from ``--iso-url`` (cached under <CI_ROOT>/iso/)
|
||||
or uses a local ``--win-iso``, patches it (inject autounattend.xml +
|
||||
post-install.ps1 + no-prompt boot), runs the unattended install, swaps the
|
||||
NIC to vmxnet3 and snapshots the template. Mirrors the legacy
|
||||
Deploy-WinBuild2025.ps1 ten-step flow.
|
||||
"""
|
||||
config = load_config()
|
||||
vmx_path = (
|
||||
@@ -2197,13 +2198,11 @@ def template_deploy_win(
|
||||
if not ok:
|
||||
raise click.ClickException(f"[PreFlight] {label}")
|
||||
click.echo(f" [OK] {label}")
|
||||
if win_iso_str is None:
|
||||
missing = win_uup.check_tools()
|
||||
if missing:
|
||||
raise click.ClickException(
|
||||
"[PreFlight] missing UUP tools: " + ", ".join(missing)
|
||||
)
|
||||
click.echo(" [OK] UUP conversion tools present")
|
||||
if bool(iso_url) == bool(win_iso_str):
|
||||
raise click.ClickException(
|
||||
"[PreFlight] provide exactly one of --iso-url or --win-iso."
|
||||
)
|
||||
click.echo(" [OK] ISO source: " + (f"url {iso_url}" if iso_url else f"file {win_iso_str}"))
|
||||
if vm_dir.exists():
|
||||
if not force:
|
||||
raise click.ClickException(
|
||||
@@ -2222,7 +2221,7 @@ def template_deploy_win(
|
||||
if start_from_step <= 2:
|
||||
click.echo(" [OK] answer files rendered")
|
||||
|
||||
# ── Step 3: acquire base ISO (UUP dump) ────────────────────────────────────
|
||||
# ── Step 3: acquire base ISO (download from URL, or local file) ────────────
|
||||
_deploy_step("Step 3: acquire Windows Server 2025 ISO", 3, start_from_step)
|
||||
if win_iso_str is not None:
|
||||
base_iso = Path(win_iso_str)
|
||||
@@ -2230,9 +2229,28 @@ def template_deploy_win(
|
||||
raise click.ClickException(f"--win-iso not found: {base_iso}")
|
||||
click.echo(f" [OK] using supplied ISO: {base_iso}")
|
||||
else:
|
||||
build = win_uup.resolve_build(build=uup_build)
|
||||
click.echo(f" resolved build: {build.build} ({build.uuid})")
|
||||
base_iso = win_uup.build_iso(build, dest_dir=iso_dir, lang=lang, edition=edition)
|
||||
assert iso_url is not None # guaranteed by Step 1 source validation
|
||||
iso_name = Path(urllib.parse.urlparse(iso_url).path).name or "windows-install.iso"
|
||||
base_iso = iso_dir / iso_name
|
||||
if start_from_step <= 3:
|
||||
if base_iso.is_file():
|
||||
click.echo(f" [OK] ISO cached, reusing: {base_iso}")
|
||||
else:
|
||||
iso_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
_download_vmdk(iso_url, base_iso)
|
||||
except Exception as exc:
|
||||
raise click.ClickException(f"[Deploy] ISO download failed: {exc}") from exc
|
||||
click.echo(f" [OK] downloaded: {base_iso}")
|
||||
if iso_sha256:
|
||||
click.echo(" Verifying ISO SHA256 ...")
|
||||
actual = _sha256_file(base_iso)
|
||||
if actual != iso_sha256.upper():
|
||||
raise click.ClickException(
|
||||
f"[Deploy] ISO SHA256 mismatch!\n Expected: {iso_sha256.upper()}\n"
|
||||
f" Actual: {actual}"
|
||||
)
|
||||
click.echo(f" [OK] ISO SHA256: {actual}")
|
||||
|
||||
# ── Step 4: patch ISO (inject answer files + no-prompt boot) ───────────────
|
||||
_deploy_step("Step 4: patch ISO (inject autounattend + no-prompt boot)", 4, start_from_step)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Windows install-image tooling for ``ci template deploy-win``.
|
||||
|
||||
Linux-host implementation: acquire a Windows Server 2025 ISO from UUP dump
|
||||
(``uup.py``), render the unattended answer file + guest post-install script
|
||||
(``autounattend.py``), and patch the ISO with a no-prompt boot image plus those
|
||||
files injected at the root (``iso_patch.py``).
|
||||
Linux-host implementation: render the unattended answer file + guest
|
||||
post-install script (``autounattend.py``) and patch a Windows install ISO with a
|
||||
no-prompt boot image plus those files injected at the root (``iso_patch.py``).
|
||||
The base ISO is supplied directly (``deploy-win --iso-url`` / ``--win-iso``).
|
||||
|
||||
The heavy / OS-specific logic lives here so ``commands/template.py`` stays a thin
|
||||
orchestrator. The Windows-host port is tracked in ``plans/deploy-win-windows-host.md``.
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
"""Acquire a Windows Server 2025 install ISO from UUP dump (Linux host).
|
||||
|
||||
UUP dump is deterministic on its *input*: a build is pinned by build number and
|
||||
the component files come MS-signed from the official Windows Update CDN. The
|
||||
final ISO is assembled locally (its hash is not canonical), so we cache the
|
||||
result per build number under ``<CI_ROOT>/iso/ws2025-<build>.iso``.
|
||||
|
||||
Flow (mirrors what uupdump.net's generated package does):
|
||||
|
||||
1. ``resolve_build`` — query the uupdump API for Windows Server 2025 builds and
|
||||
pick the newest (or match a pinned build string).
|
||||
2. ``build_iso`` — download the per-set conversion package, run its
|
||||
``uup_download_linux.sh`` (aria2 download + wimlib conversion), then move the
|
||||
produced ISO into the cache.
|
||||
|
||||
The downloader requires ``aria2c``, ``cabextract``, ``wimlib-imagex``,
|
||||
``chntpw`` and ``genisoimage``/``mkisofs`` on PATH (installed by
|
||||
``setup-host-linux.sh``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
API_BASE = "https://api.uupdump.net"
|
||||
GET_URL = "https://uupdump.net/get.php"
|
||||
_USER_AGENT = "ci-orchestrator/1.0 (+deploy-win)"
|
||||
|
||||
#: Tools the UUP → ISO conversion needs (uup_download_linux.sh checks these).
|
||||
REQUIRED_TOOLS = ("aria2c", "cabextract", "wimlib-imagex", "chntpw")
|
||||
|
||||
#: Title prefix that identifies a Windows Server 2025 set (excludes "version 23H2").
|
||||
_WS2025_PREFIX = "Windows Server 2025"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UupBuild:
|
||||
"""A UUP dump build set."""
|
||||
|
||||
uuid: str
|
||||
build: str # e.g. "26100.32860"
|
||||
title: str
|
||||
arch: str
|
||||
|
||||
|
||||
def _http_json(url: str, *, timeout: float = 60.0) -> dict[str, Any]:
|
||||
"""GET ``url`` and parse the JSON body."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT, "Accept": "application/json"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
data: Any = json.loads(raw)
|
||||
if not isinstance(data, dict):
|
||||
raise click.ClickException(f"uupdump API returned unexpected JSON from {url}")
|
||||
return data
|
||||
|
||||
|
||||
def _build_sort_key(build: str) -> tuple[int, ...]:
|
||||
"""Numeric sort key for a build string like ``26100.32860``."""
|
||||
parts: list[int] = []
|
||||
for chunk in build.split("."):
|
||||
try:
|
||||
parts.append(int(chunk))
|
||||
except ValueError:
|
||||
parts.append(0)
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def list_ws2025_builds(*, arch: str = "amd64", timeout: float = 60.0) -> list[UupBuild]:
|
||||
"""Return all Windows Server 2025 builds for ``arch``, newest first."""
|
||||
url = f"{API_BASE}/listid.php?search={urllib.parse.quote(_WS2025_PREFIX)}"
|
||||
data = _http_json(url, timeout=timeout)
|
||||
builds_raw = data.get("response", {}).get("builds", {})
|
||||
if not isinstance(builds_raw, dict):
|
||||
raise click.ClickException("uupdump API: missing 'builds' in listid response")
|
||||
|
||||
out: list[UupBuild] = []
|
||||
for entry in builds_raw.values():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
title = str(entry.get("title", ""))
|
||||
if not title.startswith(_WS2025_PREFIX):
|
||||
continue
|
||||
if str(entry.get("arch", "")) != arch:
|
||||
continue
|
||||
out.append(
|
||||
UupBuild(
|
||||
uuid=str(entry["uuid"]),
|
||||
build=str(entry["build"]),
|
||||
title=title,
|
||||
arch=arch,
|
||||
)
|
||||
)
|
||||
out.sort(key=lambda b: _build_sort_key(b.build), reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_build(
|
||||
*, build: str | None = None, arch: str = "amd64", timeout: float = 60.0
|
||||
) -> UupBuild:
|
||||
"""Resolve the newest WS2025 build, or the one matching ``build`` if pinned."""
|
||||
builds = list_ws2025_builds(arch=arch, timeout=timeout)
|
||||
if not builds:
|
||||
raise click.ClickException(
|
||||
f"No Windows Server 2025 ({arch}) builds found on uupdump."
|
||||
)
|
||||
if build is None:
|
||||
return builds[0]
|
||||
for b in builds:
|
||||
if b.build == build:
|
||||
return b
|
||||
available = ", ".join(b.build for b in builds)
|
||||
raise click.ClickException(
|
||||
f"WS2025 build {build!r} not found on uupdump. Available: {available}"
|
||||
)
|
||||
|
||||
|
||||
def check_tools() -> list[str]:
|
||||
"""Return the list of missing conversion tools (empty when all present)."""
|
||||
missing = [t for t in REQUIRED_TOOLS if shutil.which(t) is None]
|
||||
if shutil.which("genisoimage") is None and shutil.which("mkisofs") is None:
|
||||
missing.append("genisoimage|mkisofs")
|
||||
return missing
|
||||
|
||||
|
||||
def cache_path(dest_dir: Path, build: UupBuild) -> Path:
|
||||
"""Path of the cached ISO for ``build`` under ``dest_dir``."""
|
||||
return dest_dir / f"ws2025-{build.build}.iso"
|
||||
|
||||
|
||||
def _download_package(build: UupBuild, lang: str, edition: str, dest: Path, *, timeout: float) -> None:
|
||||
"""Download the uupdump conversion package zip for ``build``."""
|
||||
url = (
|
||||
f"{GET_URL}?id={build.uuid}"
|
||||
f"&pack={urllib.parse.quote(lang)}"
|
||||
f"&edition={urllib.parse.quote(edition)}"
|
||||
f"&autodl=2"
|
||||
)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp, dest.open("wb") as fh:
|
||||
shutil.copyfileobj(resp, fh)
|
||||
if dest.stat().st_size < 1024:
|
||||
raise click.ClickException(f"uupdump package download too small: {dest}")
|
||||
|
||||
|
||||
def build_iso(
|
||||
build: UupBuild,
|
||||
*,
|
||||
dest_dir: Path,
|
||||
lang: str = "en-us",
|
||||
edition: str = "SERVERSTANDARD",
|
||||
work_dir: Path | None = None,
|
||||
keep_work: bool = False,
|
||||
) -> Path:
|
||||
"""Build (or reuse a cached) WS2025 ISO for ``build`` and return its path.
|
||||
|
||||
Downloads the uupdump conversion package, runs its ``uup_download_linux.sh``
|
||||
(aria2 download of the MS-signed UUP set + wimlib conversion to ISO), then
|
||||
moves the produced ISO to ``<dest_dir>/ws2025-<build>.iso``.
|
||||
"""
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
cached = cache_path(dest_dir, build)
|
||||
if cached.is_file():
|
||||
click.echo(f" [uup] cached ISO present, reusing: {cached}")
|
||||
return cached
|
||||
|
||||
missing = check_tools()
|
||||
if missing:
|
||||
raise click.ClickException(
|
||||
"Missing UUP conversion tools: "
|
||||
+ ", ".join(missing)
|
||||
+ ". Install them (setup-host-linux.sh installs aria2 wimtools "
|
||||
"cabextract chntpw genisoimage)."
|
||||
)
|
||||
|
||||
work = work_dir or (dest_dir / f".uup-{build.build}")
|
||||
if work.exists():
|
||||
shutil.rmtree(work)
|
||||
work.mkdir(parents=True)
|
||||
|
||||
pkg = work / "uup-package.zip"
|
||||
click.echo(f" [uup] downloading conversion package for build {build.build} ({build.uuid})")
|
||||
_download_package(build, lang, edition, pkg, timeout=120.0)
|
||||
with zipfile.ZipFile(pkg) as zf:
|
||||
zf.extractall(work)
|
||||
|
||||
script = work / "uup_download_linux.sh"
|
||||
if not script.is_file():
|
||||
raise click.ClickException(f"uup_download_linux.sh missing from package: {work}")
|
||||
script.chmod(0o755)
|
||||
|
||||
click.echo(" [uup] running converter (downloads ~5 GB + builds ISO; this is slow) ...")
|
||||
proc = subprocess.run(
|
||||
["bash", str(script)],
|
||||
cwd=str(work),
|
||||
check=False,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise click.ClickException(
|
||||
f"UUP conversion failed (exit {proc.returncode}). See {work}/aria2_download.log."
|
||||
)
|
||||
|
||||
produced = _find_produced_iso(work)
|
||||
if produced is None:
|
||||
raise click.ClickException(f"UUP conversion produced no ISO under {work}")
|
||||
|
||||
shutil.move(str(produced), str(cached))
|
||||
click.echo(f" [uup] ISO ready: {cached}")
|
||||
if not keep_work:
|
||||
shutil.rmtree(work, ignore_errors=True)
|
||||
return cached
|
||||
|
||||
|
||||
def _find_produced_iso(work: Path) -> Path | None:
|
||||
"""Find the ISO the converter produced (largest *.iso, excluding the package)."""
|
||||
candidates = [
|
||||
p
|
||||
for p in work.rglob("*.iso")
|
||||
if p.name != "uup-package.zip" and p.is_file()
|
||||
]
|
||||
candidates += [p for p in work.rglob("*.ISO") if p.is_file()]
|
||||
if not candidates:
|
||||
return None
|
||||
return max(candidates, key=lambda p: p.stat().st_size)
|
||||
@@ -12,7 +12,6 @@ from click.testing import CliRunner
|
||||
|
||||
import ci_orchestrator.commands.template as tmpl_mod
|
||||
from ci_orchestrator.__main__ import cli
|
||||
from ci_orchestrator.winimage import uup
|
||||
|
||||
|
||||
def _make_config(root: Path) -> types.SimpleNamespace:
|
||||
@@ -195,25 +194,40 @@ def test_deploy_win_skips_patch_when_cached(
|
||||
assert "skipping repack" in result.output
|
||||
|
||||
|
||||
def test_deploy_win_uup_happy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_deploy_win_iso_url_downloads(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
root = tmp_path
|
||||
(root / "iso").mkdir(parents=True)
|
||||
(root / "iso" / "VMware-tools-windows.iso").write_bytes(b"tools")
|
||||
_patch_deploy(monkeypatch, root)
|
||||
monkeypatch.setattr(tmpl_mod.win_uup, "check_tools", lambda: [])
|
||||
build = uup.UupBuild(uuid="u", build="26100.32860", title="t", arch="amd64")
|
||||
monkeypatch.setattr(tmpl_mod.win_uup, "resolve_build", lambda **_k: build)
|
||||
seen = _patch_deploy(monkeypatch, root)
|
||||
|
||||
def fake_build_iso(_b: uup.UupBuild, *, dest_dir: Path, **_k: Any) -> Path:
|
||||
p = dest_dir / "ws2025-26100.32860.iso"
|
||||
p.write_bytes(b"iso")
|
||||
return p
|
||||
downloaded: dict[str, str] = {}
|
||||
|
||||
monkeypatch.setattr(tmpl_mod.win_uup, "build_iso", fake_build_iso)
|
||||
def fake_dl(url: str, dest: Path) -> None:
|
||||
downloaded["url"] = url
|
||||
dest.write_bytes(b"iso")
|
||||
|
||||
result = CliRunner().invoke(cli, ["template", "deploy-win", "--nogui"])
|
||||
monkeypatch.setattr(tmpl_mod, "_download_vmdk", fake_dl)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
["template", "deploy-win", "--nogui",
|
||||
"--iso-url", "https://example.test/win2025.iso"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "26100.32860" in result.output
|
||||
assert downloaded["url"].endswith("win2025.iso")
|
||||
assert (root / "iso" / "win2025.iso").is_file() # cached under <iso> by basename
|
||||
assert seen["patched"]
|
||||
|
||||
|
||||
def test_deploy_win_requires_one_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
root = tmp_path
|
||||
(root / "iso").mkdir(parents=True)
|
||||
(root / "iso" / "VMware-tools-windows.iso").write_bytes(b"t")
|
||||
_patch_deploy(monkeypatch, root)
|
||||
# neither --iso-url nor --win-iso
|
||||
result = CliRunner().invoke(cli, ["template", "deploy-win", "--nogui"])
|
||||
assert result.exit_code != 0
|
||||
assert "exactly one of --iso-url or --win-iso" in result.output
|
||||
|
||||
|
||||
def test_deploy_win_bad_computer_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for the winimage subpackage (uup, autounattend, iso_patch)."""
|
||||
"""Unit tests for the winimage subpackage (autounattend, iso_patch)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -11,142 +11,10 @@ import pytest
|
||||
|
||||
from ci_orchestrator.winimage import autounattend as au
|
||||
from ci_orchestrator.winimage import iso_patch as ip
|
||||
from ci_orchestrator.winimage import uup
|
||||
|
||||
# ── uup ────────────────────────────────────────────────────────────────────
|
||||
|
||||
_FAKE_BUILDS: dict[str, Any] = {
|
||||
"response": {
|
||||
"builds": {
|
||||
"1": {"title": "Windows Server 2025 (26100.2033)", "build": "26100.2033",
|
||||
"arch": "amd64", "uuid": "old"},
|
||||
"2": {"title": "Windows Server 2025 (26100.32860)", "build": "26100.32860",
|
||||
"arch": "amd64", "uuid": "new"},
|
||||
"3": {"title": "Windows Server, version 23H2 (25398.1)", "build": "25398.1",
|
||||
"arch": "amd64", "uuid": "h2"},
|
||||
"4": {"title": "Windows Server 2025 (26100.1)", "build": "26100.1",
|
||||
"arch": "arm64", "uuid": "arm"},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def _fake_api(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(uup, "_http_json", lambda *_a, **_k: _FAKE_BUILDS)
|
||||
|
||||
|
||||
def test_build_sort_key() -> None:
|
||||
assert uup._build_sort_key("26100.32860") == (26100, 32860)
|
||||
assert uup._build_sort_key("26100.x") == (26100, 0)
|
||||
|
||||
|
||||
def test_list_filters_arch_and_h2(_fake_api: None) -> None:
|
||||
builds = uup.list_ws2025_builds(arch="amd64")
|
||||
names = [b.build for b in builds]
|
||||
assert names == ["26100.32860", "26100.2033"] # newest first, no 23H2, no arm64
|
||||
|
||||
|
||||
def test_resolve_latest(_fake_api: None) -> None:
|
||||
assert uup.resolve_build().uuid == "new"
|
||||
|
||||
|
||||
def test_resolve_pinned(_fake_api: None) -> None:
|
||||
assert uup.resolve_build(build="26100.2033").uuid == "old"
|
||||
|
||||
|
||||
def test_resolve_pinned_missing(_fake_api: None) -> None:
|
||||
with pytest.raises(click.ClickException, match="not found"):
|
||||
uup.resolve_build(build="99999.1")
|
||||
|
||||
|
||||
def test_resolve_none_available(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(uup, "_http_json", lambda *_a, **_k: {"response": {"builds": {}}})
|
||||
with pytest.raises(click.ClickException, match="No Windows Server 2025"):
|
||||
uup.resolve_build()
|
||||
|
||||
|
||||
def test_check_tools_all_present(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(uup.shutil, "which", lambda _n: "/usr/bin/x")
|
||||
assert uup.check_tools() == []
|
||||
|
||||
|
||||
def test_check_tools_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(uup.shutil, "which", lambda _n: None)
|
||||
missing = uup.check_tools()
|
||||
assert "aria2c" in missing and "genisoimage|mkisofs" in missing
|
||||
|
||||
|
||||
def test_cache_path() -> None:
|
||||
b = uup.UupBuild(uuid="u", build="26100.1", title="t", arch="amd64")
|
||||
assert uup.cache_path(Path("/iso"), b) == Path("/iso/ws2025-26100.1.iso")
|
||||
|
||||
|
||||
def test_build_iso_uses_cache(tmp_path: Path) -> None:
|
||||
b = uup.UupBuild(uuid="u", build="26100.1", title="t", arch="amd64")
|
||||
cached = uup.cache_path(tmp_path, b)
|
||||
cached.write_bytes(b"iso")
|
||||
assert uup.build_iso(b, dest_dir=tmp_path) == cached
|
||||
|
||||
|
||||
def test_build_iso_missing_tools(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(uup, "check_tools", lambda: ["aria2c"])
|
||||
b = uup.UupBuild(uuid="u", build="26100.9", title="t", arch="amd64")
|
||||
with pytest.raises(click.ClickException, match="Missing UUP"):
|
||||
uup.build_iso(b, dest_dir=tmp_path)
|
||||
|
||||
|
||||
def test_build_iso_full_flow(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
b = uup.UupBuild(uuid="u", build="26100.5", title="t", arch="amd64")
|
||||
monkeypatch.setattr(uup, "check_tools", lambda: [])
|
||||
|
||||
def fake_download(_b: uup.UupBuild, _l: str, _e: str, dest: Path, **_k: Any) -> None:
|
||||
import zipfile
|
||||
with zipfile.ZipFile(dest, "w") as zf:
|
||||
zf.writestr("uup_download_linux.sh", "#!/bin/bash\ntrue\n")
|
||||
|
||||
monkeypatch.setattr(uup, "_download_package", fake_download)
|
||||
|
||||
def fake_run(argv: list[str], **kw: Any) -> subprocess.CompletedProcess[str]:
|
||||
# simulate the converter producing an ISO in the work dir
|
||||
work = Path(kw["cwd"])
|
||||
(work / "26100.5_SERVER.iso").write_bytes(b"x" * 500)
|
||||
return subprocess.CompletedProcess(argv, 0, "", "")
|
||||
|
||||
monkeypatch.setattr(uup.subprocess, "run", fake_run)
|
||||
out = uup.build_iso(b, dest_dir=tmp_path)
|
||||
assert out == uup.cache_path(tmp_path, b)
|
||||
assert out.is_file()
|
||||
|
||||
|
||||
def test_build_iso_converter_fails(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
b = uup.UupBuild(uuid="u", build="26100.6", title="t", arch="amd64")
|
||||
monkeypatch.setattr(uup, "check_tools", lambda: [])
|
||||
|
||||
def fake_download(_b: uup.UupBuild, _l: str, _e: str, dest: Path, **_k: Any) -> None:
|
||||
import zipfile
|
||||
with zipfile.ZipFile(dest, "w") as zf:
|
||||
zf.writestr("uup_download_linux.sh", "#!/bin/bash\nfalse\n")
|
||||
|
||||
monkeypatch.setattr(uup, "_download_package", fake_download)
|
||||
monkeypatch.setattr(
|
||||
uup.subprocess, "run",
|
||||
lambda argv, **kw: subprocess.CompletedProcess(argv, 1, "", ""),
|
||||
)
|
||||
with pytest.raises(click.ClickException, match="UUP conversion failed"):
|
||||
uup.build_iso(b, dest_dir=tmp_path)
|
||||
|
||||
|
||||
def test_find_produced_iso(tmp_path: Path) -> None:
|
||||
(tmp_path / "small.iso").write_bytes(b"a")
|
||||
big = tmp_path / "big.iso"
|
||||
big.write_bytes(b"a" * 100)
|
||||
assert uup._find_produced_iso(tmp_path) == big
|
||||
assert uup._find_produced_iso(tmp_path / "empty") is None or True # nonexistent dir → no match
|
||||
|
||||
|
||||
# ── autounattend ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_render_autounattend_no_placeholder() -> None:
|
||||
xml = au.render_autounattend(au.WinUnattendParams())
|
||||
assert "{{" not in xml and "}}" not in xml
|
||||
@@ -176,6 +44,7 @@ def test_apply_detects_leftover() -> None:
|
||||
|
||||
# ── iso_patch ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_read_volid_fallback(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr(ip.shutil, "which", lambda _n: None)
|
||||
iso = tmp_path / "x.iso"
|
||||
|
||||
Reference in New Issue
Block a user