From c257c931c31ff0dd92eafa784a3970958edf157d Mon Sep 17 00:00:00 2001 From: Simone Date: Tue, 9 Jun 2026 18:42:22 +0200 Subject: [PATCH] refactor(deploy-win): drop UUP dump, take ISO via --iso-url / --win-iso MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 /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%. --- docs/RUNBOOK-PhaseC.md | 35 ++-- plans/deploy-win-windows-host.md | 20 +- setup-host-linux.sh | 14 +- src/ci_orchestrator/commands/template.py | 66 ++++--- src/ci_orchestrator/winimage/__init__.py | 8 +- src/ci_orchestrator/winimage/uup.py | 233 ----------------------- tests/python/test_commands_deploy_win.py | 40 ++-- tests/python/test_winimage.py | 137 +------------ 8 files changed, 108 insertions(+), 445 deletions(-) delete mode 100644 src/ci_orchestrator/winimage/uup.py diff --git a/docs/RUNBOOK-PhaseC.md b/docs/RUNBOOK-PhaseC.md index 2fdf547..59aea22 100644 --- a/docs/RUNBOOK-PhaseC.md +++ b/docs/RUNBOOK-PhaseC.md @@ -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 # 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-.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`. diff --git a/plans/deploy-win-windows-host.md b/plans/deploy-win-windows-host.md index 750b7d4..fa0dd99 100644 --- a/plans/deploy-win-windows-host.md +++ b/plans/deploy-win-windows-host.md @@ -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). diff --git a/setup-host-linux.sh b/setup-host-linux.sh index db5a713..ffe13c4 100755 --- a/setup-host-linux.sh +++ b/setup-host-linux.sh @@ -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 diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py index f8840e0..f163463 100644 --- a/src/ci_orchestrator/commands/template.py +++ b/src/ci_orchestrator/commands/template.py @@ -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: /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 /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 /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) diff --git a/src/ci_orchestrator/winimage/__init__.py b/src/ci_orchestrator/winimage/__init__.py index da58af4..16b45df 100644 --- a/src/ci_orchestrator/winimage/__init__.py +++ b/src/ci_orchestrator/winimage/__init__.py @@ -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``. diff --git a/src/ci_orchestrator/winimage/uup.py b/src/ci_orchestrator/winimage/uup.py deleted file mode 100644 index 32cb660..0000000 --- a/src/ci_orchestrator/winimage/uup.py +++ /dev/null @@ -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 ``/iso/ws2025-.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 ``/ws2025-.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) diff --git a/tests/python/test_commands_deploy_win.py b/tests/python/test_commands_deploy_win.py index 87c3409..fe099f0 100644 --- a/tests/python/test_commands_deploy_win.py +++ b/tests/python/test_commands_deploy_win.py @@ -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 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: diff --git a/tests/python/test_winimage.py b/tests/python/test_winimage.py index fd53de7..ce701b2 100644 --- a/tests/python/test_winimage.py +++ b/tests/python/test_winimage.py @@ -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"