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:
2026-06-09 18:42:22 +02:00
parent cd88dec15d
commit c257c931c3
8 changed files with 108 additions and 445 deletions
+19 -16
View File
@@ -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 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 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-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-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 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) | | `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 ### `deploy-win` — build the Windows Server 2025 template
Downloads the newest WS2025 ISO from **UUP dump** (deterministic, build-pinned; Takes a Windows Server 2025 install ISO — downloaded from `--iso-url` (cached
component files are MS-signed), patches it (injects `autounattend.xml` + under `/var/lib/ci/iso/` by file name) or a local `--win-iso` — patches it
`post-install.ps1` and swaps the EFI boot image to the no-prompt variant), then (injects `autounattend.xml` + `post-install.ps1` and swaps the EFI boot image to
runs the unattended install, swaps the NIC e1000e → vmxnet3 and takes the cold the no-prompt variant), runs the unattended install, swaps the NIC e1000e →
`PostInstall` snapshot. VMware Tools are mounted as a second CD. vmxnet3 and takes the cold `PostInstall` snapshot. VMware Tools are mounted as a
second CD.
```bash ```bash
ci template deploy-win # newest build, headless # Download + deploy (e.g. a massgrave direct link), headless
ci template deploy-win --gui # watch the install (needs X — §4.7) ci template deploy-win --iso-url 'https://example/win2025.iso'
ci template deploy-win --build 26100.32860 # pin a specific build (reproducible) ci template deploy-win --iso-url 'https://example/win2025.iso' --iso-sha256 <HASH> # verify
ci template deploy-win --win-iso /var/lib/ci/iso/my.iso # skip UUP, use a local ISO ci template deploy-win --gui --win-iso /var/lib/ci/iso/win2025.iso # local ISO, watch install
``` ```
Notes: Notes:
- First run downloads ~5 GB and builds the ISO locally (slow, tens of minutes); - Provide **exactly one** of `--iso-url` / `--win-iso`. A downloaded ISO is
the result is cached at `/var/lib/ci/iso/ws2025-<build>.iso` and reused. cached by its URL file name and reused on re-runs.
- Needs `aria2`, `wimtools`, `xorriso`, `genisoimage`, `cabextract`, `chntpw` - The supplied ISO is the install media you give it — e.g. an updated WS2025 ISO
(installed by `setup-host-linux.sh`) and ~16 GiB scratch for the ISO repack. from massgrave. (UUP dump was dropped: on Linux it only yields the RTM
- The final ISO hash is not canonical (assembled locally); the UUP component `install.wim`, so Windows Update had to install the whole cumulative anyway.)
files are MS-signed. Pin `--build` for reproducible inputs. - 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 - After it finishes: `ci template prepare-win` then
`ci validate guest --winrm --guest-os windows`. `ci validate guest --winrm --guest-os windows`.
+8 -12
View File
@@ -12,17 +12,15 @@ duplicating the orchestration logic.
The Python orchestrator stays the single entry point. Where a step needs a 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 Windows-only tool, Python detects `os.name == "nt"` and routes that one step to
a thin PowerShell shim (or reuses functions already in 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 build, vmrun lifecycle, flag wait, NIC swap, snapshot) is already
platform-neutral Python and is reused unchanged. platform-neutral Python and is reused unchanged.
## What already works cross-platform (reuse as-is) ## What already works cross-platform (reuse as-is)
- `winimage/uup.py` — pure `urllib`/`subprocess`/`zipfile`. The **conversion - ISO acquisition: `deploy-win --iso-url` (urllib download + cache) / `--win-iso`
package** must run the Windows variant: invoke `uup_download_windows.cmd` is pure stdlib and portable as-is. (UUP dump was removed — on Linux it only
(present in the same zip) instead of `uup_download_linux.sh`. Add an produced the RTM `install.wim`; supply an updated ISO directly instead.)
`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`).
- `winimage/autounattend.py` — `importlib.resources`, fully portable. - `winimage/autounattend.py` — `importlib.resources`, fully portable.
- `commands/template.py` deploy-win orchestration: `_winbuild_vmx_lines`, - `commands/template.py` deploy-win orchestration: `_winbuild_vmx_lines`,
`_remove_vmx_lines`, `_patch_vmx_key`, `_wait_install_flag`, `_remove_vmx_lines`, `_patch_vmx_key`, `_wait_install_flag`,
@@ -69,10 +67,10 @@ portable.
`isotool`. Keep current Linux behaviour byte-for-byte. `isotool`. Keep current Linux behaviour byte-for-byte.
3. `template/shims/patch-win-iso.ps1` (new, lint-clean PS 5.1) — oscdimg wrapper; 3. `template/shims/patch-win-iso.ps1` (new, lint-clean PS 5.1) — oscdimg wrapper;
optional IMAPI2FS fallback reusing `Deploy-WinBuild2025.ps1` functions. optional IMAPI2FS fallback reusing `Deploy-WinBuild2025.ps1` functions.
4. `uup.build_iso` — pick `uup_download_windows.cmd` on Windows. 4. Preflight: on Windows check for `oscdimg.exe` (ADK) / `7z` instead of the
5. Preflight: on Windows check for `oscdimg.exe` (ADK) / `aria2c.exe` instead of Linux tools; clear error if the ADK is missing. (ISO download is stdlib —
the Linux tools; clear error if the ADK is missing. no extra tool.)
6. Tests: parametrize the new `isotool` dispatch (mock `os.name`); mock the shim 5. Tests: parametrize the new `isotool` dispatch (mock `os.name`); mock the shim
call. Keep coverage ≥90%. call. Keep coverage ≥90%.
7. Docs: note Windows-host prerequisites (Windows ADK for `oscdimg`) in 7. Docs: note Windows-host prerequisites (Windows ADK for `oscdimg`) in
`docs/RUNBOOK-PhaseC.md` / a Windows runbook. `docs/RUNBOOK-PhaseC.md` / a Windows runbook.
@@ -88,5 +86,3 @@ portable.
- `oscdimg` boot-args syntax differs from xorriso; validate the no-prompt UEFI - `oscdimg` boot-args syntax differs from xorriso; validate the no-prompt UEFI
boot on a real VM. 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
View File
@@ -181,15 +181,11 @@ if ! command -v setfacl &>/dev/null; then
apt-get install -y acl -qq apt-get install -y acl -qq
fi fi
# Tooling immagini Windows (template deploy-win): scarica/build ISO da UUP dump # Tooling immagini (template deploy-win patch ISO + deploy-linux seed ISO):
# e patch ISO (inject autounattend + no-prompt boot) via xorriso. # p7zip-full — 7z: estrae l'ISO Windows (UDF/Joliet) per il repack patch
# aria2 (aria2c) — download paralleli dei file UUP dalla CDN Microsoft # xorriso — repack ISO Windows bootable no-prompt
# wimtools — wimlib-imagex, conversione UUP → install.wim/ISO # genisoimage — build cloud-init seed ISO (deploy-linux)
# xorriso/genisoimage — repack ISO bootable / build seed ISO (deploy-linux) for pkg in p7zip-full xorriso genisoimage; do
# 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
if ! dpkg -s "$pkg" &>/dev/null; then if ! dpkg -s "$pkg" &>/dev/null; then
echo " Installazione pacchetto $pkg..." echo " Installazione pacchetto $pkg..."
apt-get install -y "$pkg" -qq apt-get install -y "$pkg" -qq
+42 -24
View File
@@ -68,6 +68,7 @@ import sys
import tempfile import tempfile
import threading import threading
import time import time
import urllib.parse
import urllib.request import urllib.request
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path 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.transport.winrm import WinRmResult, WinRmTransport
from ci_orchestrator.winimage import autounattend as win_autounattend 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 iso_patch as win_iso_patch
from ci_orchestrator.winimage import uup as win_uup
@click.group() @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", @click.option("--vmx", "vmx_path_str", default=None, metavar="PATH",
help="VMX to create (default: <CI_TEMPLATES>/WinBuild2025/WinBuild2025.vmx).") 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("--vm-name", default="WinBuild2025", show_default=True, help="VM display name.")
@click.option("--build", "uup_build", default=None, @click.option("--iso-url", "iso_url", default=None, metavar="URL",
help="Pin a UUP build number (e.g. 26100.32860). Default: newest WS2025.") 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", @click.option("--win-iso", "win_iso_str", default=None, metavar="PATH",
help="Use this Windows ISO instead of downloading from UUP dump.") help="Use this local Windows ISO instead of --iso-url.")
@click.option("--edition", default="SERVERSTANDARD", show_default=True, help="UUP edition.")
@click.option("--lang", default="en-us", show_default=True, help="UUP language pack.")
@click.option("--image-index", default=2, show_default=True, help="install.wim image index.") @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("--product-key", default="TVRH6-WHNXV-R9WG3-9XRFY-MY832", show_default=True)
@click.option("--admin-user", default="Administrator", 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( def template_deploy_win(
vmx_path_str: str | None, vmx_path_str: str | None,
vm_name: str, vm_name: str,
uup_build: str | None, iso_url: str | None,
iso_sha256: str,
win_iso_str: str | None, win_iso_str: str | None,
edition: str,
lang: str,
image_index: int, image_index: int,
product_key: str, product_key: str,
admin_user: str, admin_user: str,
@@ -2147,10 +2147,11 @@ def template_deploy_win(
) -> None: ) -> None:
"""Deploy the Windows Server 2025 build VM template (Linux VMware host). """Deploy the Windows Server 2025 build VM template (Linux VMware host).
Downloads the latest WS2025 ISO from UUP dump (deterministic, build-pinned), Downloads the install ISO from ``--iso-url`` (cached under <CI_ROOT>/iso/)
patches it (inject autounattend.xml + post-install.ps1 + no-prompt boot), or uses a local ``--win-iso``, patches it (inject autounattend.xml +
runs the unattended install, swaps the NIC to vmxnet3 and snapshots the post-install.ps1 + no-prompt boot), runs the unattended install, swaps the
template. Mirrors the legacy Deploy-WinBuild2025.ps1 ten-step flow. NIC to vmxnet3 and snapshots the template. Mirrors the legacy
Deploy-WinBuild2025.ps1 ten-step flow.
""" """
config = load_config() config = load_config()
vmx_path = ( vmx_path = (
@@ -2197,13 +2198,11 @@ def template_deploy_win(
if not ok: if not ok:
raise click.ClickException(f"[PreFlight] {label}") raise click.ClickException(f"[PreFlight] {label}")
click.echo(f" [OK] {label}") click.echo(f" [OK] {label}")
if win_iso_str is None: if bool(iso_url) == bool(win_iso_str):
missing = win_uup.check_tools() raise click.ClickException(
if missing: "[PreFlight] provide exactly one of --iso-url or --win-iso."
raise click.ClickException( )
"[PreFlight] missing UUP tools: " + ", ".join(missing) click.echo(" [OK] ISO source: " + (f"url {iso_url}" if iso_url else f"file {win_iso_str}"))
)
click.echo(" [OK] UUP conversion tools present")
if vm_dir.exists(): if vm_dir.exists():
if not force: if not force:
raise click.ClickException( raise click.ClickException(
@@ -2222,7 +2221,7 @@ def template_deploy_win(
if start_from_step <= 2: if start_from_step <= 2:
click.echo(" [OK] answer files rendered") 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) _deploy_step("Step 3: acquire Windows Server 2025 ISO", 3, start_from_step)
if win_iso_str is not None: if win_iso_str is not None:
base_iso = Path(win_iso_str) base_iso = Path(win_iso_str)
@@ -2230,9 +2229,28 @@ def template_deploy_win(
raise click.ClickException(f"--win-iso not found: {base_iso}") raise click.ClickException(f"--win-iso not found: {base_iso}")
click.echo(f" [OK] using supplied ISO: {base_iso}") click.echo(f" [OK] using supplied ISO: {base_iso}")
else: else:
build = win_uup.resolve_build(build=uup_build) assert iso_url is not None # guaranteed by Step 1 source validation
click.echo(f" resolved build: {build.build} ({build.uuid})") iso_name = Path(urllib.parse.urlparse(iso_url).path).name or "windows-install.iso"
base_iso = win_uup.build_iso(build, dest_dir=iso_dir, lang=lang, edition=edition) 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) ─────────────── # ── 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) _deploy_step("Step 4: patch ISO (inject autounattend + no-prompt boot)", 4, start_from_step)
+4 -4
View File
@@ -1,9 +1,9 @@
"""Windows install-image tooling for ``ci template deploy-win``. """Windows install-image tooling for ``ci template deploy-win``.
Linux-host implementation: acquire a Windows Server 2025 ISO from UUP dump Linux-host implementation: render the unattended answer file + guest
(``uup.py``), render the unattended answer file + guest post-install script post-install script (``autounattend.py``) and patch a Windows install ISO with a
(``autounattend.py``), and patch the ISO with a no-prompt boot image plus those no-prompt boot image plus those files injected at the root (``iso_patch.py``).
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 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``. orchestrator. The Windows-host port is tracked in ``plans/deploy-win-windows-host.md``.
-233
View File
@@ -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)
+27 -13
View File
@@ -12,7 +12,6 @@ from click.testing import CliRunner
import ci_orchestrator.commands.template as tmpl_mod import ci_orchestrator.commands.template as tmpl_mod
from ci_orchestrator.__main__ import cli from ci_orchestrator.__main__ import cli
from ci_orchestrator.winimage import uup
def _make_config(root: Path) -> types.SimpleNamespace: 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 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 = tmp_path
(root / "iso").mkdir(parents=True) (root / "iso").mkdir(parents=True)
(root / "iso" / "VMware-tools-windows.iso").write_bytes(b"tools") (root / "iso" / "VMware-tools-windows.iso").write_bytes(b"tools")
_patch_deploy(monkeypatch, root) seen = _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)
def fake_build_iso(_b: uup.UupBuild, *, dest_dir: Path, **_k: Any) -> Path: downloaded: dict[str, str] = {}
p = dest_dir / "ws2025-26100.32860.iso"
p.write_bytes(b"iso")
return p
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 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: def test_deploy_win_bad_computer_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+3 -134
View File
@@ -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 from __future__ import annotations
@@ -11,142 +11,10 @@ import pytest
from ci_orchestrator.winimage import autounattend as au from ci_orchestrator.winimage import autounattend as au
from ci_orchestrator.winimage import iso_patch as ip 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 ─────────────────────────────────────────────────────────── # ── autounattend ───────────────────────────────────────────────────────────
def test_render_autounattend_no_placeholder() -> None: def test_render_autounattend_no_placeholder() -> None:
xml = au.render_autounattend(au.WinUnattendParams()) xml = au.render_autounattend(au.WinUnattendParams())
assert "{{" not in xml and "}}" not in xml assert "{{" not in xml and "}}" not in xml
@@ -176,6 +44,7 @@ def test_apply_detects_leftover() -> None:
# ── iso_patch ────────────────────────────────────────────────────────────── # ── iso_patch ──────────────────────────────────────────────────────────────
def test_read_volid_fallback(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: def test_read_volid_fallback(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(ip.shutil, "which", lambda _n: None) monkeypatch.setattr(ip.shutil, "which", lambda _n: None)
iso = tmp_path / "x.iso" iso = tmp_path / "x.iso"