feat(template): add deploy-win — UUP download + ISO patch + deploy WS2025

New `ci template deploy-win` builds the Windows Server 2025 template VM on
the Linux host, mirroring the legacy Deploy-WinBuild2025.ps1 ten-step flow
in Python:

- winimage/uup.py: resolve the newest (or pinned) WS2025 build via the
  uupdump API and build a cached, MS-signed ISO via the uup converter.
- winimage/autounattend.py + assets/: render autounattend.xml and the
  guest post-install.ps1 (ported from the PS heredoc; VMware Tools now
  mounted as a 2nd CD, scanned at first logon).
- winimage/iso_patch.py: xorriso repack injecting the answer files at the
  ISO root and swapping the EFI boot image to efisys_noprompt.bin
  (no "press any key" prompt — essential for an unattended headless install).
- commands/template.py: the deploy-win command + _winbuild_vmx_lines
  (the 82-key VMX), _remove_vmx_lines, _wait_install_flag, _vmrun_soft_stop.
  Acquire → patch → VMDK → VMX → install → flag wait → strip media +
  NIC e1000e→vmxnet3 → verify IP → cold snapshot.

Deterministic by build (--build), with --win-iso to bypass UUP. Tests
mirror the prepare-win mocking style; total coverage 93%.
This commit is contained in:
2026-06-08 23:21:02 +02:00
parent 976c4b1cae
commit b83a7fa624
9 changed files with 1813 additions and 0 deletions
+417
View File
@@ -80,6 +80,9 @@ from ci_orchestrator.backends.workstation import WorkstationVmrunBackend
from ci_orchestrator.config import load_config
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()
@@ -1895,3 +1898,417 @@ def template_prepare_linux(
else:
click.echo(f" Snapshot: not taken — name it '{snapshot}' when done manually.")
click.echo("[prepare-linux] Provisioning complete.")
# ═══════════════════════════════════════════════════════════════════════════
# template deploy-win command (Windows Server 2025, Linux host)
# ═══════════════════════════════════════════════════════════════════════════
#: Forward-compatible Workstation guestOS id for Windows Server 2025.
_WIN2025_GUEST_OS = "windows2019srvnext-64"
def _remove_vmx_lines(vmx_path: Path, prefix: str) -> int:
"""Remove every VMX line whose key starts with ``prefix``. Returns count."""
text = vmx_path.read_text(encoding="ascii", errors="replace")
kept: list[str] = []
removed = 0
pattern = re.compile(rf"^\s*{re.escape(prefix)}", re.IGNORECASE)
for line in text.splitlines():
if pattern.match(line):
removed += 1
else:
kept.append(line)
vmx_path.write_text("\n".join(kept) + "\n", encoding="ascii")
return removed
def _winbuild_vmx_lines(
*,
vm_name: str,
mem_mb: int,
vcpu_count: int,
cores_per_socket: int,
vmdk_name: str,
install_iso: str,
tools_iso: str,
) -> list[str]:
"""Build the WinBuild2025 VMX (ports Deploy-WinBuild2025.ps1 Step 5).
Boot NIC is e1000e; sata0:0 = patched install ISO, sata0:1 = VMware Tools
ISO. ``deploy-win`` removes the SATA/USB controllers and switches the NIC to
vmxnet3 after the unattended install drops install_complete.flag.
"""
return [
'.encoding = "windows-1252"',
'config.version = "8"',
'virtualHW.version = "21"',
f'displayName = "{vm_name}"',
f'guestOS = "{_WIN2025_GUEST_OS}"',
'firmware = "efi"',
'uefi.secureBoot.enabled = "FALSE"',
f'memSize = "{mem_mb}"',
f'numvcpus = "{vcpu_count}"',
f'cpuid.coresPerSocket = "{cores_per_socket}"',
'tools.syncTime = "TRUE"',
'tools.upgrade.policy = "manual"',
'powerType.powerOff = "soft"',
'powerType.powerOn = "soft"',
'powerType.suspend = "soft"',
'powerType.reset = "soft"',
'uuid.action = "create"',
'pciBridge0.present = "TRUE"',
'pciBridge4.present = "TRUE"',
'pciBridge4.virtualDev = "pcieRootPort"',
'pciBridge4.functions = "8"',
'pciBridge5.present = "TRUE"',
'pciBridge5.virtualDev = "pcieRootPort"',
'pciBridge5.functions = "8"',
'pciBridge6.present = "TRUE"',
'pciBridge6.virtualDev = "pcieRootPort"',
'pciBridge6.functions = "8"',
'pciBridge7.present = "TRUE"',
'pciBridge7.virtualDev = "pcieRootPort"',
'pciBridge7.functions = "8"',
'nvme0.present = "TRUE"',
'nvme0.pciSlotNumber = "224"',
'nvme0:0.present = "TRUE"',
f'nvme0:0.fileName = "{vmdk_name}"',
'nvme0:0.deviceType = "disk"',
'sata0.present = "TRUE"',
'sata0.pciSlotNumber = "32"',
'sata0:0.present = "TRUE"',
'sata0:0.deviceType = "cdrom-image"',
f'sata0:0.fileName = "{install_iso}"',
'sata0:0.startConnected = "TRUE"',
'sata0:1.present = "TRUE"',
'sata0:1.deviceType = "cdrom-image"',
f'sata0:1.fileName = "{tools_iso}"',
'sata0:1.startConnected = "TRUE"',
'ethernet0.present = "TRUE"',
'ethernet0.virtualDev = "e1000e"',
'ethernet0.connectionType = "nat"',
'ethernet0.addressType = "generated"',
'ethernet0.startConnected = "TRUE"',
'ethernet0.pciSlotNumber = "192"',
'usb.present = "TRUE"',
'usb.pciSlotNumber = "34"',
'ehci.present = "TRUE"',
'ehci.pciSlotNumber = "35"',
'usb_xhci.present = "TRUE"',
'usb_xhci.pciSlotNumber = "33"',
'svga.present = "TRUE"',
'svga.graphicsMemoryKB = "262144"',
'floppy0.present = "FALSE"',
'sound.present = "FALSE"',
'vmci0.present = "TRUE"',
'vmci0.pciSlotNumber = "36"',
'hpet0.present = "TRUE"',
'mks.enable3d = "FALSE"',
'snapshot.action = "keep"',
]
def _vmrun_tools_running(vmrun: str, vmx: str) -> bool:
"""True when VMware Tools report 'running' inside the guest."""
proc = subprocess.run(
[vmrun, "-T", "ws", "checkToolsState", str(vmx)],
capture_output=True, text=True, timeout=30, check=False,
)
return proc.stdout.strip().lower() == "running"
def _wait_install_flag(
vmrun: str,
vmx: str,
*,
admin_user: str,
admin_password: str,
flag_path: str,
timeout_s: float,
poll_s: float,
) -> bool:
"""Poll until the guest drops ``flag_path`` (Tools must be running first)."""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if _vmrun_tools_running(vmrun, vmx):
proc = subprocess.run(
[vmrun, "-gu", admin_user, "-gp", admin_password,
"fileExistsInGuest", str(vmx), flag_path],
capture_output=True, text=True, timeout=60, check=False,
)
if "the file exists" in proc.stdout.strip().lower():
return True
click.echo(" ... Tools up; waiting for install_complete.flag ...")
else:
click.echo(" ... waiting for VMware Tools to report running ...")
time.sleep(poll_s)
return False
def _vmrun_soft_stop(vmrun: str, vmx: str, *, timeout_s: float = 300.0) -> None:
"""Soft-stop the VM, falling back to hard stop after ``timeout_s``."""
subprocess.run(
[vmrun, "-T", "ws", "stop", str(vmx), "soft"],
capture_output=True, text=True, check=False,
)
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
listing = subprocess.run(
[vmrun, "-T", "ws", "list"], capture_output=True, text=True, check=False,
)
if str(vmx) not in listing.stdout:
return
time.sleep(5)
subprocess.run(
[vmrun, "-T", "ws", "stop", str(vmx), "hard"],
capture_output=True, text=True, check=False,
)
@template.command("deploy-win")
@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("--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.")
@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)
@click.option("--admin-password", default="WinBuild!", show_default=True)
@click.option("--computer-name", default="WINBUILD-2025", show_default=True)
@click.option("--locale-os", default="en-US", show_default=True)
@click.option("--locale-user", default="it-IT", show_default=True)
@click.option("--keyboard", default="0410:00000410", show_default=True)
@click.option("--timezone", default="W. Europe Standard Time", show_default=True)
@click.option("--disk-size-gb", default=80, show_default=True)
@click.option("--memory-mb", default=6144, show_default=True)
@click.option("--vcpu-count", default=4, show_default=True)
@click.option("--cores-per-socket", default=2, show_default=True)
@click.option("--tools-iso", "tools_iso_str", default=None, metavar="PATH",
help="VMware Tools ISO (default: <CI_ROOT>/iso/VMware-tools-windows.iso).")
@click.option("--snapshot-name", default="PostInstall", show_default=True)
@click.option("--gui/--nogui", "gui", default=False, show_default=True,
help="Start the VM with the Workstation window (needs an X display).")
@click.option("--flag-timeout", default=5400, show_default=True,
help="Seconds to wait for install_complete.flag.")
@click.option("--flag-poll", default=30, show_default=True, help="Flag poll interval (s).")
@click.option("--vdisk-manager", "vdisk_manager_str", default=None)
@click.option("--force", is_flag=True, default=False, help="Overwrite an existing VM directory.")
@click.option("--start-from-step", default=1, type=click.IntRange(1, 10), show_default=True)
def template_deploy_win(
vmx_path_str: str | None,
vm_name: str,
uup_build: str | None,
win_iso_str: str | None,
edition: str,
lang: str,
image_index: int,
product_key: str,
admin_user: str,
admin_password: str,
computer_name: str,
locale_os: str,
locale_user: str,
keyboard: str,
timezone: str,
disk_size_gb: int,
memory_mb: int,
vcpu_count: int,
cores_per_socket: int,
tools_iso_str: str | None,
snapshot_name: str,
gui: bool,
flag_timeout: int,
flag_poll: int,
vdisk_manager_str: str | None,
force: bool,
start_from_step: int,
) -> 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.
"""
config = load_config()
vmx_path = (
Path(vmx_path_str).resolve()
if vmx_path_str
else config.paths.templates / "WinBuild2025" / "WinBuild2025.vmx"
)
vm_dir = vmx_path.parent
iso_dir = config.paths.root / "iso"
tools_iso = Path(tools_iso_str) if tools_iso_str else iso_dir / "VMware-tools-windows.iso"
vmdk_name = vmx_path.stem + ".vmdk"
vmdk_path = vm_dir / vmdk_name
patched_iso = vm_dir / "install-noprompt.iso"
flag_path = r"C:\Windows\Temp\install_complete.flag"
vmrun = config.vmrun_path or shutil.which("vmrun") or "vmrun"
vdisk_mgr = _find_vdisk_manager(vdisk_manager_str)
params = win_autounattend.WinUnattendParams(
admin_user=admin_user, admin_password=admin_password,
computer_name=computer_name, product_key=product_key,
image_index=image_index, locale_os=locale_os, locale_user=locale_user,
keyboard=keyboard, timezone=timezone,
)
if start_from_step > 1:
click.echo(f" [SKIP] Steps 1..{start_from_step - 1} -- starting at Step {start_from_step}")
# ── Step 1: preflight ──────────────────────────────────────────────────────
_deploy_step("Step 1: preflight", 1, start_from_step)
if start_from_step <= 1:
try:
win_autounattend.validate_computer_name(computer_name)
except ValueError as exc:
raise click.ClickException(f"[PreFlight] {exc}") from exc
checks: list[tuple[str, bool]] = [
(f"vmrun found: {vmrun}", bool(shutil.which(vmrun) or Path(vmrun).is_file())),
(f"vmware-vdiskmanager found: {vdisk_mgr}", Path(vdisk_mgr).is_file()),
("xorriso in PATH", shutil.which("xorriso") is not None),
(f"VMware Tools ISO: {tools_iso}", tools_iso.is_file()),
]
for label, ok in checks:
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 vm_dir.exists():
if not force:
raise click.ClickException(
f"[PreFlight] VM directory exists: {vm_dir}\n Use --force to overwrite."
)
click.echo(f" [WARN] --force -- reusing VM directory: {vm_dir}")
_clean_force_artifacts(vm_dir, vmx_path.stem)
else:
vm_dir.mkdir(parents=True, exist_ok=True)
click.echo(f" [OK] VM directory ready: {vm_dir}")
# ── Step 2: render answer files ────────────────────────────────────────────
_deploy_step("Step 2: render autounattend.xml + post-install.ps1", 2, start_from_step)
autounattend_xml = win_autounattend.render_autounattend(params)
post_install_ps1 = win_autounattend.render_post_install(params)
if start_from_step <= 2:
click.echo(" [OK] answer files rendered")
# ── Step 3: acquire base ISO (UUP dump) ────────────────────────────────────
_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)
if start_from_step <= 3 and not base_iso.is_file():
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)
# ── 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)
if start_from_step <= 4:
win_iso_patch.patch_iso(
base_iso,
autounattend_xml=autounattend_xml,
post_install_ps1=post_install_ps1,
out_iso=patched_iso,
work_dir=vm_dir,
)
# ── Step 5: create VMDK ────────────────────────────────────────────────────
_deploy_step("Step 5: create VMDK", 5, start_from_step)
if start_from_step <= 5:
if vmdk_path.exists():
vmdk_path.unlink()
subprocess.run(
[vdisk_mgr, "-c", "-s", f"{disk_size_gb}GB", "-a", "lsilogic", "-t", "0",
str(vmdk_path)],
check=True,
)
click.echo(f" [OK] VMDK created: {vmdk_path}")
# ── Step 6: write VMX ──────────────────────────────────────────────────────
_deploy_step("Step 6: write VMX", 6, start_from_step)
if start_from_step <= 6:
lines = _winbuild_vmx_lines(
vm_name=vm_name, mem_mb=memory_mb, vcpu_count=vcpu_count,
cores_per_socket=cores_per_socket, vmdk_name=vmdk_name,
install_iso=str(patched_iso), tools_iso=str(tools_iso),
)
vmx_path.write_text("\n".join(lines) + "\n", encoding="ascii")
click.echo(f" [OK] VMX written: {vmx_path}")
# ── Step 7: power on for unattended install ────────────────────────────────
_deploy_step("Step 7: power on (unattended install)", 7, start_from_step)
if start_from_step <= 7:
gui_mode = "gui" if gui else "nogui"
subprocess.Popen(
[vmrun, "-T", "ws", "start", str(vmx_path), gui_mode]
)
time.sleep(10)
click.echo(" [OK] VM powered on")
# ── Step 8: wait for install_complete.flag ─────────────────────────────────
_deploy_step("Step 8: wait for install_complete.flag", 8, start_from_step)
if start_from_step <= 8:
ok = _wait_install_flag(
vmrun, str(vmx_path), admin_user=admin_user, admin_password=admin_password,
flag_path=flag_path, timeout_s=float(flag_timeout), poll_s=float(flag_poll),
)
if not ok:
raise click.ClickException(
f"[Deploy Step 8] install_complete.flag not seen within {flag_timeout}s."
)
click.echo(" [OK] install_complete.flag detected")
# ── Step 9: stop, strip install media, swap NIC to vmxnet3 ─────────────────
_deploy_step("Step 9: strip install media + swap NIC to vmxnet3", 9, start_from_step)
if start_from_step <= 9:
_vmrun_soft_stop(vmrun, str(vmx_path))
for prefix in ("sata0", "usb", "ehci", "usb_xhci"):
_remove_vmx_lines(vmx_path, prefix)
_patch_vmx_key(vmx_path, "ethernet0.virtualDev", "vmxnet3")
click.echo(" [OK] SATA/USB removed, NIC -> vmxnet3")
# ── Step 10: verify vmxnet3 IP, then cold snapshot ─────────────────────────
_deploy_step("Step 10: verify network + snapshot", 10, start_from_step)
if start_from_step <= 10:
gui_mode = "gui" if gui else "nogui"
subprocess.Popen(
[vmrun, "-T", "ws", "start", str(vmx_path), gui_mode]
)
guest_ip = _poll_guest_ip_deploy(vmrun, str(vmx_path), timeout=600.0)
if not guest_ip:
raise click.ClickException("[Deploy Step 10] no guest IP after vmxnet3 swap.")
click.echo(f" [OK] vmxnet3 guest IP: {guest_ip}")
_vmrun_soft_stop(vmrun, str(vmx_path))
subprocess.run(
[vmrun, "-T", "ws", "snapshot", str(vmx_path), snapshot_name], check=True,
)
listing = subprocess.run(
[vmrun, "-T", "ws", "listSnapshots", str(vmx_path)],
capture_output=True, text=True, check=False,
)
if snapshot_name not in listing.stdout:
raise click.ClickException(
f"[Deploy Step 10] snapshot '{snapshot_name}' not found after creation."
)
click.echo(f" [OK] snapshot '{snapshot_name}' created (cold)")
click.echo("\n=== deploy-win complete ===")
click.echo(f" VMX: {vmx_path}")
click.echo(f" Snapshot: {snapshot_name}")
click.echo(f" Next: ci template prepare-win --vmx {vmx_path}")