diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py index 51e4e1f..c02cc3d 100644 --- a/src/ci_orchestrator/commands/template.py +++ b/src/ci_orchestrator/commands/template.py @@ -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: /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: /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}") diff --git a/src/ci_orchestrator/winimage/__init__.py b/src/ci_orchestrator/winimage/__init__.py new file mode 100644 index 0000000..da58af4 --- /dev/null +++ b/src/ci_orchestrator/winimage/__init__.py @@ -0,0 +1,10 @@ +"""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``). + +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/assets/autounattend.win2025.xml b/src/ci_orchestrator/winimage/assets/autounattend.win2025.xml new file mode 100644 index 0000000..b02fd12 --- /dev/null +++ b/src/ci_orchestrator/winimage/assets/autounattend.win2025.xml @@ -0,0 +1,191 @@ + + + + + + + + {{LOCALE_OS}} + + {{KEYBOARD}} + {{LOCALE_OS}} + {{LOCALE_OS}} + {{LOCALE_OS}} + {{LOCALE_USER}} + + + + + + 0 + true + + + 1 + EFI + 100 + + + 2 + MSR + 16 + + + 3 + Primary + true + + + + + 1 + 1 + FAT32 + + S + + + 2 + 2 + + + 3 + 3 + NTFS + + C + + + + + + + + + + /IMAGE/INDEX + {{IMAGE_INDEX}} + + + + 0 + 3 + + + + + + true + CI + CI Lab + + {{PRODUCT_KEY}} + OnError + + + + false + + + false + Never + + + + + + + {{COMPUTER_NAME}} + {{TIMEZONE}} + CI + CI Lab + + + + + + {{KEYBOARD}} + {{LOCALE_OS}} + {{LOCALE_OS}} + {{LOCALE_OS}} + {{LOCALE_USER}} + + + + + + {{ADMIN_PWD}} + true</PlainText> + </AdministratorPassword> + </UserAccounts> + + <AutoLogon> + <Username>{{ADMIN_USER}}</Username> + <Password> + <Value>{{ADMIN_PWD}}</Value> + <PlainText>true</PlainText> + </Password> + <Enabled>true</Enabled> + <LogonCount>5</LogonCount> + </AutoLogon> + + <OOBE> + <HideEULAPage>true</HideEULAPage> + <HideOEMRegistrationScreen>true</HideOEMRegistrationScreen> + <HideOnlineAccountScreens>true</HideOnlineAccountScreens> + <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE> + <NetworkLocation>Work</NetworkLocation> + <ProtectYourPC>3</ProtectYourPC> + <SkipMachineOOBE>true</SkipMachineOOBE> + <SkipUserOOBE>true</SkipUserOOBE> + </OOBE> + + <TimeZone>{{TIMEZONE}}</TimeZone> + <RegisteredOwner>CI</RegisteredOwner> + <RegisteredOrganization>CI Lab</RegisteredOrganization> + + <FirstLogonCommands> + <SynchronousCommand wcm:action="add"> + <Order>1</Order> + <CommandLine>cmd /c for %d in (C D E F G H I J K L M N O P Q R S T U V W X Y Z) do if exist %d:\post-install.ps1 powershell.exe -NoProfile -ExecutionPolicy Bypass -File %d:\post-install.ps1 ^&gt; C:\Windows\Temp\post-install.log 2^&gt;^&amp;1</CommandLine> + <Description>Run post-install script from install ISO root</Description> + </SynchronousCommand> + </FirstLogonCommands> + </component> + </settings> +</unattend> diff --git a/src/ci_orchestrator/winimage/assets/post-install.win.ps1.tmpl b/src/ci_orchestrator/winimage/assets/post-install.win.ps1.tmpl new file mode 100644 index 0000000..2f7bd3b --- /dev/null +++ b/src/ci_orchestrator/winimage/assets/post-install.win.ps1.tmpl @@ -0,0 +1,308 @@ +# Guest post-install for WinBuild2025 — rendered to post-install.ps1 and injected +# at the root of the patched Windows install ISO. Runs once at FirstLogon as +# Administrator (autounattend FirstLogonCommands). Hardening + WinRM + VMware +# Tools install + completion flag + shutdown. +# +# Ported from the heredoc in template/Deploy-WinBuild2025.ps1 (Step 2). Rendered +# by ci_orchestrator.winimage.autounattend; the only deploy-time placeholders are +# {{ADMIN_USER}} and {{ADMIN_PWD}}. StrictMode is intentionally omitted — this is +# a deliberately tolerant best-effort hardening script (ErrorActionPreference=Continue). +$ErrorActionPreference = 'Continue' +$log = 'C:\Windows\Temp\post-install.log' +function L($m){ "$(Get-Date -f s) $m" | Tee-Object -FilePath $log -Append } + +L 'post-install start' + +# ── KICK OFF VMware Tools install (async — wrapper detaches MSI child) ──── +# Started first so the MSI runs in the background while hardening tweaks +# below execute. Settled state verified at the very end before reboot. +# VMware Tools are mounted as the 2nd CD-ROM (no bundled Tools\ folder): the +# scan below resolves setup64.exe/setup.exe on the Tools CD. +$selfDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$toolsExe = $null +foreach ($name in 'setup64.exe','setup.exe') { + $cand = Join-Path $selfDir "Tools\$name" + if (Test-Path $cand) { $toolsExe = $cand; break } +} +if (-not $toolsExe) { + L 'Bundled Tools not found, scanning CD drives...' + foreach ($drv in (Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=5')) { + foreach ($name in 'setup64.exe','setup.exe') { + $cand = Join-Path ($drv.DeviceID + '\') $name + if (Test-Path $cand) { $toolsExe = $cand; break } + } + if ($toolsExe) { break } + } +} +L "Tools setup resolved to: $toolsExe" + +$localRoot = 'C:\Windows\Temp\VMwareTools' +$msiLog = 'C:\Windows\Temp\vmtools-msi.log' +$exitCode = -1 +if ($toolsExe -and (Test-Path $toolsExe)) { + # Copy payload to local writable disk — wrapper extract from CD fails at + # FirstLogon phase due to user profile %TEMP% ACL bootstrap timing. + $srcRoot = Split-Path -Parent $toolsExe + if (Test-Path $localRoot) { Remove-Item $localRoot -Recurse -Force } + L "Copying Tools payload $srcRoot -> $localRoot" + Copy-Item -Path $srcRoot -Destination $localRoot -Recurse -Force + + # PS argument parser strips inner quotes from /v"..."; --% stop-parsing + # token passes the tail literally to the EXE. + L "Tools cmd: .\setup.exe /s /v`"/qn REBOOT=R /l*v $msiLog`"" + Push-Location $localRoot + try { + & .\setup.exe --% /s /v"/qn REBOOT=R /l*v C:\Windows\Temp\vmtools-msi.log" + $exitCode = $LASTEXITCODE + } finally { Pop-Location } + L "VMware Tools wrapper exit code: $exitCode (MSI continues async)" +} else { + L 'ERROR: VMware Tools setup not found (bundled or on any CD-ROM)' +} + +# ── UAC off (admin script automation) ───────────────────────────────────── +reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v EnableLUA /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v LocalAccountTokenFilterPolicy /t REG_DWORD /d 1 /f | Out-Null +L 'UAC disabled' + +# ── Defender disabled (lab only) — Install-CIToolchain-WinBuild2025 skips Defender step ─ +# RTP off + tamper-protect GPO. With Defender fully off, exclusion paths +# become moot (no scanner to exclude from), so Install-CIToolchain-WinBuild2025 has been +# stripped of its Defender step. +try { Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction Stop; L 'Defender RTP off' } +catch { L "Defender RTP off failed: $($_.Exception.Message)" } +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows Defender' /v DisableAntiSpyware /t REG_DWORD /d 1 /f | Out-Null + +# ── Telemetry / DiagTrack off ───────────────────────────────────────────── +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\DataCollection' /v AllowTelemetry /t REG_DWORD /d 0 /f | Out-Null +sc.exe config DiagTrack start= disabled | Out-Null +sc.exe stop DiagTrack | Out-Null +L 'Telemetry disabled' + +# ── Windows Update GPO locks — services left Manual (§7.2 Strategy B) ─── +# Set both WU services to Manual so they can be invoked on-demand by Setup +# Step 6 (WU COM API via SYSTEM task) but won't auto-start between Deploy and Setup. +# UsoSvc defaults to Automatic on Windows Server 2025; must be explicit here. +# GPO keys below also block background triggers. Step 6b permanently disables both. +Set-Service -Name wuauserv -StartupType Manual -ErrorAction SilentlyContinue +Set-Service -Name UsoSvc -StartupType Manual -ErrorAction SilentlyContinue +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' /v DisableWindowsUpdateAccess /t REG_DWORD /d 1 /f | Out-Null +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' /v NoAutoUpdate /t REG_DWORD /d 1 /f | Out-Null +L 'Windows Update GPO locks set + wuauserv/UsoSvc Manual (Setup Step 6b permanently disables)' + +# ── Firewall: all profiles disabled + allow RDP + ICMP + WinRM HTTPS ───── +# Disabling all profiles removes any block on subsequent WinRM config and WU +# downloads. This VM lives on VMnet8 NAT behind the VMware NAT gateway — no +# inbound exposure from outside the host. Install-CIToolchain-WinBuild2025 Step 1 validates. +Set-NetFirewallProfile -Profile Domain,Private,Public -Enabled False +L 'Windows Firewall disabled on all profiles' +reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server' /v fDenyTSConnections /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKLM\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' /v UserAuthentication /t REG_DWORD /d 0 /f | Out-Null +Enable-NetFirewallRule -DisplayGroup 'Remote Desktop' -ErrorAction SilentlyContinue +New-NetFirewallRule -DisplayName 'ICMPv4-In' -Protocol ICMPv4 -IcmpType 8 -Direction Inbound -Action Allow -Profile Any -ErrorAction SilentlyContinue | Out-Null +L 'RDP + ICMP allowed' + +# ── WinRM HTTPS-only/5986, Basic auth over HTTPS, TrustedHosts=* (lab only) ─ +# AllowUnencrypted intentionally left false — all WinRM traffic uses HTTPS/5986. +Enable-PSRemoting -Force -SkipNetworkProfileCheck | Out-Null +winrm set winrm/config/service/auth '@{Basic="true"}' | Out-Null +winrm set winrm/config/client/auth '@{Basic="true"}' | Out-Null +winrm set winrm/config/client '@{TrustedHosts="*"}' | Out-Null +try { + $cert = New-SelfSignedCertificate -DnsName $env:COMPUTERNAME -CertStoreLocation Cert:\LocalMachine\My + New-Item -Path WSMan:\localhost\Listener -Transport HTTPS -Address * -CertificateThumbprint $cert.Thumbprint -Force | Out-Null + New-NetFirewallRule -DisplayName 'WinRM-HTTPS' -Direction Inbound -Protocol TCP -LocalPort 5986 -Action Allow -Profile Any -RemoteAddress '192.168.79.0/24' | Out-Null + L 'WinRM HTTPS listener configured (port 5986, restricted to 192.168.79.0/24)' +} catch { L "WinRM HTTPS failed: $($_.Exception.Message)" } +winrm set winrm/config/winrs '@{MaxProcessesPerShell="100"}' | Out-Null +Set-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB 2048 -Force 3>$null +Set-Item WSMan:\localhost\Shell\IdleTimeOut 7200000 -Force 3>$null +# Plugin-level quota: pypsrp (Linux->Win) uses Microsoft.PowerShell endpoint, +# which has its own MaxMemoryPerShellMB independent of the Shell quota above. +# Without this, parallel native processes (e.g. 3x cl.exe in MSBuild fan-out) +# fail with MSB6003 / Win32 1816 'Not enough quota'. +Set-Item 'WSMan:\localhost\Plugin\Microsoft.PowerShell\Quotas\MaxMemoryPerShellMB' 2048 -Force 3>$null +Set-Service -Name WinRM -StartupType Automatic 3>$null +Restart-Service -Name WinRM -Force 3>$null +L 'WinRM limits: Shell+Plugin MaxMemory=2048MB, IdleTimeout=2h, MaxProcesses=100; StartType=Automatic' + +# ── Non-interactive desktop heap (csrss SharedSection) ─────────────────── +# WinRM sessions run in Session 0 non-interactive window station; default +# desktop heap is 768 KB. CreateProcess fails with ERROR_NOT_ENOUGH_QUOTA +# (Win32 1816 -> MSB6003) when multiple compiler processes spawn in parallel. +# Bump the third SharedSection field to 4096 KB. Change is read by csrss only +# at boot — the post-install shutdown at the end of this script applies it. +$subsysKey = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\SubSystems' +$subsysCur = (Get-ItemProperty -Path $subsysKey -Name Windows).Windows +$subsysNew = $subsysCur -replace 'SharedSection=(\d+),(\d+),\d+', 'SharedSection=$1,$2,4096' +Set-ItemProperty -Path $subsysKey -Name Windows -Value $subsysNew +L "Desktop heap (non-interactive) SharedSection 3rd field set to 4096 KB (takes effect after reboot)" + +# ── Server Manager / WAC popup / first-logon UX ────────────────────────── +# Server Manager auto-start off — machine-wide + current Administrator + Default +# profile hive (so future cloned/sysprep'd users inherit). +reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null +reg add 'HKCU\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null +reg load HKU\DefaultUser 'C:\Users\Default\NTUSER.DAT' 2>&1 | Out-Null +reg add 'HKU\DefaultUser\SOFTWARE\Microsoft\ServerManager' /v DoNotOpenServerManagerAtLogon /t REG_DWORD /d 1 /f | Out-Null +reg unload HKU\DefaultUser 2>&1 | Out-Null +# Windows Admin Center prompt at SM launch off +reg add 'HKLM\SOFTWARE\Microsoft\ServerManager' /v DoNotPopWACConsoleAtSMLaunch /t REG_DWORD /d 1 /f | Out-Null +# DisableCAD — no Ctrl+Alt+Del at logon (CI VMs never need it) +reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null +# OOBE privacy experience suppressed +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null +L 'Server Manager + WAC + CAD + OOBE prompts disabled' +# Supplemental UX hardening validated by Install-CIToolchain-WinBuild2025 Step 5c: +# Lock screen: suppress at CI console sessions (no interactive user ever logs in) +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows\Personalization' /v NoLockScreen /t REG_DWORD /d 1 /f | Out-Null +# Server Manager GPO policy key (authoritative path on WS2025; HKLM key above is not always honoured) +reg add 'HKLM\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' /v DoNotOpenAtLogon /t REG_DWORD /d 1 /f | Out-Null +# Server Manager scheduled task — the actual launcher on WS2025; registry alone insufficient +schtasks /Change /TN '\Microsoft\Windows\Server Manager\ServerManager' /Disable 2>&1 | Out-Null +# DisableCAD in Winlogon (belt-and-suspenders with Policies\System key set above) +reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DisableCAD /t REG_DWORD /d 1 /f | Out-Null +# Autologin — Administrator auto-signs in after every boot (CI template convenience). +# Password stored plaintext in registry; acceptable for isolated VMnet8 NAT lab VM. +reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v AutoAdminLogon /t REG_SZ /d 1 /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultUserName /t REG_SZ /d "{{ADMIN_USER}}" /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultPassword /t REG_SZ /d "{{ADMIN_PWD}}" /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' /v DefaultDomainName /t REG_SZ /d '.' /f | Out-Null +# OOBE non-policy key (supplements the Policies\Windows\OOBE key set above) +reg add 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\OOBE' /v DisablePrivacyExperience /t REG_DWORD /d 1 /f | Out-Null +L 'Lock screen, SM GPO policy + task, CAD Winlogon, autologin Administrator, OOBE non-policy key set' + +# ── IE Enhanced Security Configuration off (Admin) ─────────────────────── +# Long-known CI papercut: ESC blocks every URL in admin sessions. +reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A7-37EF-4b3f-8CFC-4F3A74704073}' /v IsInstalled /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKLM\SOFTWARE\Microsoft\Active Setup\Installed Components\{A509B1A8-37EF-4b3f-8CFC-4F3A74704073}' /v IsInstalled /t REG_DWORD /d 0 /f | Out-Null +Stop-Process -Name iexplore -ErrorAction SilentlyContinue +L 'IE ESC disabled' + +# ── Remove drive letter from EFI System Partition ──────────────────────── +# Windows setup sometimes assigns 'S:' to the ESP making it visible in Explorer. +Get-Partition | Where-Object { $_.DriveLetter -eq 'S' } | ForEach-Object { + $_ | Remove-PartitionAccessPath -AccessPath 'S:\' -ErrorAction SilentlyContinue +} +L 'EFI partition drive letter removed (if any)' + +# ── Hibernate off (frees ~RAM-size on C:, no need on VMs) ──────────────── +powercfg /h off 2>&1 | Out-Null +# Power plan High Performance — never sleep, never throttle CPU +powercfg /setactive 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c 2>&1 | Out-Null +powercfg /change monitor-timeout-ac 0 | Out-Null +powercfg /change disk-timeout-ac 0 | Out-Null +powercfg /change standby-timeout-ac 0 | Out-Null +powercfg /change hibernate-timeout-ac 0 | Out-Null +L 'Hibernate off, power plan High Performance' + +# ── Unneeded services off (CI build VM, not a print/search/file server) ── +foreach ($svc in 'WSearch','Spooler','SysMain','WerSvc','RemoteRegistry','Fax','MapsBroker','RetailDemo','XblAuthManager','XblGameSave','XboxNetApiSvc','XboxGipSvc') { + $s = Get-Service -Name $svc -ErrorAction SilentlyContinue + if ($s) { + sc.exe config $svc start= disabled | Out-Null + sc.exe stop $svc | Out-Null + L " service disabled: $svc" + } +} + +# ── Windows Customer Experience tasks off ──────────────────────────────── +foreach ($task in @( + '\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser', + '\Microsoft\Windows\Application Experience\ProgramDataUpdater', + '\Microsoft\Windows\Autochk\Proxy', + '\Microsoft\Windows\Customer Experience Improvement Program\Consolidator', + '\Microsoft\Windows\Customer Experience Improvement Program\UsbCeip', + '\Microsoft\Windows\Feedback\Siuf\DmClient', + '\Microsoft\Windows\Feedback\Siuf\DmClientOnScenarioDownload' +)) { + schtasks /Change /TN $task /Disable 2>&1 | Out-Null +} +L 'CEIP / feedback scheduled tasks disabled' + +# ── Azure Arc auto-start / tray icon off ──────────────────────────────── +# Server 2025 ships with Azure Arc integration enabled; suppress for CI VMs. +foreach ($arcTask in @( + '\Microsoft\Azure Arc\Onboarding\EnableAzureArcSetup', + '\Microsoft\Azure Arc\Onboarding\ArcScan', + '\Microsoft\AzureArcSeamlessOnboarding\ArcSetup' +)) { + schtasks /Change /TN $arcTask /Disable 2>&1 | Out-Null +} +foreach ($arcSvc in 'himds','ArcProxyAgent') { + $s = Get-Service -Name $arcSvc -ErrorAction SilentlyContinue + if ($s) { + sc.exe config $arcSvc start= disabled | Out-Null + sc.exe stop $arcSvc 2>&1 | Out-Null + } +} +reg add 'HKLM\SOFTWARE\Microsoft\AzureArc' /v OnboardingState /t REG_DWORD /d 4 /f 2>&1 | Out-Null +reg delete 'HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' /v AzureArcSetup /f 2>&1 | Out-Null +L 'Azure Arc auto-start disabled' + +# ── Explorer UX: show file extensions + open to This PC ────────────────── +reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v HideFileExt /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v LaunchTo /t REG_DWORD /d 1 /f | Out-Null +L 'Explorer UX tweaks applied (HKCU)' + +# ── Taskbar / terminal UX tweaks ───────────────────────────────────────── +# SearchboxTaskbarMode=0 hide search bar +# ShowTaskViewButton=0 hide Task View button +# DelegationConsole/Terminal Windows Terminal as default terminal +# HKCU (SYSTEM context at FirstLogon) +reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Search' /v SearchboxTaskbarMode /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKCU\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v ShowTaskViewButton /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKCU\Console\%Startup' /v DelegationConsole /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null +reg add 'HKCU\Console\%Startup' /v DelegationTerminal /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null +# Default hive — Administrator + any future user inherits on first interactive logon +reg load HKU\DefaultUser2 'C:\Users\Default\NTUSER.DAT' 2>&1 | Out-Null +reg add 'HKU\DefaultUser2\Software\Microsoft\Windows\CurrentVersion\Search' /v SearchboxTaskbarMode /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKU\DefaultUser2\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced' /v ShowTaskViewButton /t REG_DWORD /d 0 /f | Out-Null +reg add 'HKU\DefaultUser2\Console\%Startup' /v DelegationConsole /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null +reg add 'HKU\DefaultUser2\Console\%Startup' /v DelegationTerminal /t REG_SZ /d '{E12CFF52-A866-4C77-9A90-F570A7AA2C6B}' /f | Out-Null +reg unload HKU\DefaultUser2 2>&1 | Out-Null +L 'Taskbar: search hidden, task view hidden, WT default terminal' + +# ── Wait for VMware Tools install to settle (kicked off at top) ────────── +# Wrapper returned immediately while msiexec runs async. Poll until: +# (a) no setup/setup64/vminst wrapper process (excludes system msiexec /V) +# (b) vmtoolsd.exe on disk +# (c) VMTools service Running (not just registered — install finalized) +# Hard timeout 15 min. +if ($toolsExe) { + $installDeadline = (Get-Date).AddMinutes(15) + while ((Get-Date) -lt $installDeadline) { + Start-Sleep -Seconds 5 + $busy = Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.ProcessName -in 'setup','setup64','VMware-Tools','VMware-Tools-Installer','vminst' } + $vmtoolsd = Test-Path 'C:\Program Files\VMware\VMware Tools\vmtoolsd.exe' + $svc = Get-Service -Name VMTools -ErrorAction SilentlyContinue + $svcRunning = ($svc -and $svc.Status -eq 'Running') + L " install poll: busy=$($busy.Count) vmtoolsd=$vmtoolsd svc=$(if ($svc) { $svc.Status } else { 'absent' })" + if (-not $busy -and $vmtoolsd -and $svcRunning) { L 'Tools install settled.'; break } + } + + if (Test-Path $msiLog) { + L "---- MSI log tail (last 30 lines) ----" + Get-Content $msiLog -Tail 30 | ForEach-Object { L $_ } + L '---- end MSI log ----' + } else { + L "MSI log NOT created at $msiLog (wrapper failed before MSI launched)" + } + + if (Test-Path 'C:\Program Files\VMware\VMware Tools\vmtoolsd.exe') { + L 'vmtoolsd.exe present -> Tools installed OK' + } else { + L "WARNING: vmtoolsd.exe NOT present -> install failed (wrapper exit $exitCode)" + } +} + +# ── Drop completion flag (host polls for this via vmrun) ────────────────── +New-Item -ItemType File -Path 'C:\Windows\Temp\install_complete.flag' -Force | Out-Null +L 'install_complete.flag dropped' + +# ── Shut down (not restart) so host Step 8 can swap NIC and snapshot ────── +# Delay 60s: host polls every 30s, needs one cycle after flag appears. +# Step 8 sends vmrun stop soft on top of this — Windows handles both. +L 'shutting down in 60s' +shutdown /s /t 60 /f /c "post-install complete" diff --git a/src/ci_orchestrator/winimage/autounattend.py b/src/ci_orchestrator/winimage/autounattend.py new file mode 100644 index 0000000..824959e --- /dev/null +++ b/src/ci_orchestrator/winimage/autounattend.py @@ -0,0 +1,80 @@ +"""Render the WS2025 unattended answer file and guest post-install script. + +Both templates ship inside the package (``winimage/assets/``) so the production +venv (installed non-editable) can find them. Placeholders are ``{{NAME}}``. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from importlib import resources + +_ASSETS_PKG = "ci_orchestrator.winimage.assets" +_AUTOUNATTEND_ASSET = "autounattend.win2025.xml" +_POST_INSTALL_ASSET = "post-install.win.ps1.tmpl" + +_COMPUTER_NAME_RE = re.compile(r"^[A-Za-z0-9-]{1,15}$") +_PLACEHOLDER_RE = re.compile(r"\{\{[^}]+\}\}") + + +@dataclass(frozen=True) +class WinUnattendParams: + """Values substituted into the autounattend + post-install templates.""" + + admin_user: str = "Administrator" + admin_password: str = "WinBuild!" + computer_name: str = "WINBUILD-2025" + product_key: str = "TVRH6-WHNXV-R9WG3-9XRFY-MY832" + image_index: int = 2 + locale_os: str = "en-US" + locale_user: str = "it-IT" + keyboard: str = "0410:00000410" + timezone: str = "W. Europe Standard Time" + + +def validate_computer_name(name: str) -> None: + """Raise ValueError if ``name`` is not a valid NetBIOS computer name.""" + if not _COMPUTER_NAME_RE.match(name): + raise ValueError( + f"invalid computer name {name!r}: must match {_COMPUTER_NAME_RE.pattern} " + "(1-15 chars, letters/digits/hyphen)" + ) + + +def _read_asset(name: str) -> str: + return resources.files(_ASSETS_PKG).joinpath(name).read_text(encoding="utf-8") + + +def _apply(text: str, subs: dict[str, str]) -> str: + for key, value in subs.items(): + text = text.replace(key, value) + leftover = _PLACEHOLDER_RE.search(text) + if leftover: + raise ValueError(f"unsubstituted placeholder remains: {leftover.group(0)}") + return text + + +def render_autounattend(params: WinUnattendParams) -> str: + """Render the autounattend.xml answer file from ``params``.""" + subs = { + "{{LOCALE_OS}}": params.locale_os, + "{{LOCALE_USER}}": params.locale_user, + "{{KEYBOARD}}": params.keyboard, + "{{IMAGE_INDEX}}": str(params.image_index), + "{{PRODUCT_KEY}}": params.product_key, + "{{COMPUTER_NAME}}": params.computer_name, + "{{TIMEZONE}}": params.timezone, + "{{ADMIN_USER}}": params.admin_user, + "{{ADMIN_PWD}}": params.admin_password, + } + return _apply(_read_asset(_AUTOUNATTEND_ASSET), subs) + + +def render_post_install(params: WinUnattendParams) -> str: + """Render the guest post-install.ps1 from ``params``.""" + subs = { + "{{ADMIN_USER}}": params.admin_user, + "{{ADMIN_PWD}}": params.admin_password, + } + return _apply(_read_asset(_POST_INSTALL_ASSET), subs) diff --git a/src/ci_orchestrator/winimage/iso_patch.py b/src/ci_orchestrator/winimage/iso_patch.py new file mode 100644 index 0000000..f065d7a --- /dev/null +++ b/src/ci_orchestrator/winimage/iso_patch.py @@ -0,0 +1,125 @@ +"""Patch a Windows install ISO: inject answer files + no-prompt boot (xorriso). + +Produces a single bootable ISO that (1) carries ``autounattend.xml`` and +``post-install.ps1`` at its root and (2) boots without the "Press any key to +boot from CD" prompt — essential for an unattended install, since a keypress +prompt would hang the headless VM. + +Method: extract the base ISO, drop the two files at the root, then repack with +``xorriso -as mkisofs`` using ``efi/microsoft/boot/efisys_noprompt.bin`` as the +EFI El Torito boot image (the no-prompt variant Microsoft ships on the ISO). +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import click + +# Boot images present on a Windows Server install ISO. +_BIOS_BOOT = "boot/etfsboot.com" +_EFI_BOOT_NOPROMPT = "efi/microsoft/boot/efisys_noprompt.bin" + + +def _require_xorriso() -> str: + exe = shutil.which("xorriso") + if exe is None: + raise click.ClickException("xorriso not found. Install xorriso.") + return exe + + +def _read_volid(base_iso: Path) -> str: + """Read the ISO volume id (label); fall back to a sane default.""" + label = "" + blkid = shutil.which("blkid") + if blkid is not None: + proc = subprocess.run( + [blkid, "-s", "LABEL", "-o", "value", str(base_iso)], + capture_output=True, + text=True, + check=False, + ) + label = proc.stdout.strip() + return (label or "WINSERVER")[:32] + + +def _check_free_space(base_iso: Path, work_parent: Path) -> None: + """Ensure ~2.2x the base ISO size is free (extract tree + output ISO).""" + needed = int(base_iso.stat().st_size * 2.2) + free = shutil.disk_usage(work_parent).free + if free < needed: + raise click.ClickException( + f"insufficient space to patch ISO: need ~{needed // 2**30} GiB free " + f"under {work_parent}, have {free // 2**30} GiB." + ) + + +def patch_iso( + base_iso: Path, + *, + autounattend_xml: str, + post_install_ps1: str, + out_iso: Path, + work_dir: Path | None = None, +) -> Path: + """Inject answer files + no-prompt boot into ``base_iso`` → ``out_iso``.""" + xorriso = _require_xorriso() + if not base_iso.is_file(): + raise click.ClickException(f"base ISO not found: {base_iso}") + + work_parent = (work_dir or out_iso.parent) + work_parent.mkdir(parents=True, exist_ok=True) + _check_free_space(base_iso, work_parent) + + tree = work_parent / f".isopatch-{out_iso.stem}" + if tree.exists(): + shutil.rmtree(tree) + tree.mkdir(parents=True) + + try: + click.echo(f" [iso] extracting base ISO: {base_iso.name}") + subprocess.run( + [xorriso, "-osirrox", "on", "-indev", str(base_iso), "-extract", "/", str(tree)], + check=True, + ) + # Extracted files are read-only; make the tree writable so we can add files. + subprocess.run(["chmod", "-R", "u+w", str(tree)], check=True) + + efi_boot = tree / _EFI_BOOT_NOPROMPT + if not efi_boot.is_file(): + raise click.ClickException( + f"{_EFI_BOOT_NOPROMPT} missing from ISO — cannot build a no-prompt " + "boot image (is this a Windows Server install ISO?)." + ) + + (tree / "autounattend.xml").write_text(autounattend_xml, encoding="utf-8") + (tree / "post-install.ps1").write_text(post_install_ps1, encoding="utf-8") + + volid = _read_volid(base_iso) + if out_iso.exists(): + out_iso.unlink() + + click.echo(f" [iso] repacking no-prompt ISO: {out_iso.name} (volid {volid})") + argv = [ + xorriso, + "-as", "mkisofs", + "-iso-level", "4", + "-J", "-joliet-long", "-D", "-R", + "-V", volid, + "-b", _BIOS_BOOT, + "-no-emul-boot", "-boot-load-size", "8", "-boot-info-table", + "-eltorito-alt-boot", + "-e", _EFI_BOOT_NOPROMPT, "-no-emul-boot", + "-o", str(out_iso), + str(tree), + ] + subprocess.run(argv, check=True) + finally: + shutil.rmtree(tree, ignore_errors=True) + + if not out_iso.is_file(): + raise click.ClickException(f"xorriso produced no output ISO: {out_iso}") + click.echo(f" [iso] patched ISO ready: {out_iso}") + return out_iso diff --git a/src/ci_orchestrator/winimage/uup.py b/src/ci_orchestrator/winimage/uup.py new file mode 100644 index 0000000..32cb660 --- /dev/null +++ b/src/ci_orchestrator/winimage/uup.py @@ -0,0 +1,233 @@ +"""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) diff --git a/tests/python/test_commands_deploy_win.py b/tests/python/test_commands_deploy_win.py new file mode 100644 index 0000000..e2f4143 --- /dev/null +++ b/tests/python/test_commands_deploy_win.py @@ -0,0 +1,212 @@ +"""Tests for ``template deploy-win`` (Windows Server 2025, Linux host).""" + +from __future__ import annotations + +import subprocess +import types +from pathlib import Path +from typing import Any + +import pytest +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: + paths = types.SimpleNamespace( + root=root, + templates=root / "templates", + build_vms=root / "build-vms", + artifacts=root / "artifacts", + keys=root / "keys", + ) + return types.SimpleNamespace(vmrun_path=None, paths=paths, ssh_key_path=None) + + +# ── helper unit tests ─────────────────────────────────────────────────────── + +def test_winbuild_vmx_lines() -> None: + lines = tmpl_mod._winbuild_vmx_lines( + vm_name="WinBuild2025", mem_mb=6144, vcpu_count=4, cores_per_socket=2, + vmdk_name="WinBuild2025.vmdk", install_iso="/iso/in.iso", tools_iso="/iso/tools.iso", + ) + text = "\n".join(lines) + assert 'guestOS = "windows2019srvnext-64"' in text + assert 'firmware = "efi"' in text + assert 'sata0:0.fileName = "/iso/in.iso"' in text + assert 'sata0:1.fileName = "/iso/tools.iso"' in text + assert 'ethernet0.virtualDev = "e1000e"' in text + assert 'nvme0:0.fileName = "WinBuild2025.vmdk"' in text + + +def test_remove_vmx_lines(tmp_path: Path) -> None: + vmx = tmp_path / "x.vmx" + vmx.write_text( + 'nvme0:0.present = "TRUE"\n' + 'sata0:0.present = "TRUE"\n' + 'sata0:1.fileName = "t.iso"\n' + 'usb.present = "TRUE"\n' + 'ethernet0.virtualDev = "e1000e"\n', + encoding="ascii", + ) + assert tmpl_mod._remove_vmx_lines(vmx, "sata0") == 2 + assert tmpl_mod._remove_vmx_lines(vmx, "usb") == 1 + remaining = vmx.read_text(encoding="ascii") + assert "sata0" not in remaining and "usb" not in remaining + assert "nvme0:0" in remaining and "ethernet0" in remaining + + +def test_wait_install_flag_ok(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _s: None) + + def fake_run(argv: list[str], **_kw: Any) -> subprocess.CompletedProcess[str]: + if "checkToolsState" in argv: + return subprocess.CompletedProcess(argv, 0, "running\n", "") + if "fileExistsInGuest" in argv: + return subprocess.CompletedProcess(argv, 0, "The file exists.\n", "") + return subprocess.CompletedProcess(argv, 0, "", "") + + monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run) + assert tmpl_mod._wait_install_flag( + "vmrun", "x.vmx", admin_user="a", admin_password="p", + flag_path="C:\\f.flag", timeout_s=10.0, poll_s=0.0, + ) is True + + +def test_wait_install_flag_timeout(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _s: None) + monkeypatch.setattr( + tmpl_mod.subprocess, "run", + lambda argv, **_k: subprocess.CompletedProcess(argv, 0, "not-running", ""), + ) + assert tmpl_mod._wait_install_flag( + "vmrun", "x.vmx", admin_user="a", admin_password="p", + flag_path="C:\\f.flag", timeout_s=-1.0, poll_s=0.0, + ) is False + + +# ── CLI flow ──────────────────────────────────────────────────────────────── + +def _patch_deploy( + monkeypatch: pytest.MonkeyPatch, root: Path, *, snapshot: str = "PostInstall" +) -> dict[str, Any]: + """Stub all external effects; return a dict capturing key calls.""" + seen: dict[str, Any] = {"patched": False, "vmdk": False} + vdisk = root / "vdiskmgr" + vdisk.write_bytes(b"") + monkeypatch.setattr(tmpl_mod, "load_config", lambda: _make_config(root)) + monkeypatch.setattr(tmpl_mod, "_find_vdisk_manager", lambda _e: str(vdisk)) + monkeypatch.setattr(tmpl_mod.shutil, "which", lambda name: f"/usr/bin/{name}") + monkeypatch.setattr(tmpl_mod.time, "sleep", lambda _s: None) + monkeypatch.setattr(tmpl_mod, "_wait_install_flag", lambda *a, **k: True) + monkeypatch.setattr(tmpl_mod, "_poll_guest_ip_deploy", lambda *a, **k: "192.168.79.50") + monkeypatch.setattr(tmpl_mod, "_vmrun_soft_stop", lambda *a, **k: None) + + def fake_patch_iso(base: Path, *, out_iso: Path, **_k: Any) -> Path: + out_iso.write_bytes(b"iso") + seen["patched"] = True + return out_iso + + monkeypatch.setattr(tmpl_mod.win_iso_patch, "patch_iso", fake_patch_iso) + + def fake_run(argv: list[str], **_kw: Any) -> subprocess.CompletedProcess[str]: + if "listSnapshots" in argv: + return subprocess.CompletedProcess(argv, 0, f"Total: 1\n{snapshot}\n", "") + if "vdiskmgr" in str(argv[0]): + seen["vmdk"] = True + return subprocess.CompletedProcess(argv, 0, "", "") + + monkeypatch.setattr(tmpl_mod.subprocess, "run", fake_run) + monkeypatch.setattr(tmpl_mod.subprocess, "Popen", lambda *a, **k: types.SimpleNamespace()) + return seen + + +def test_deploy_win_winiso_happy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path + (root / "iso").mkdir(parents=True) + tools = root / "iso" / "VMware-tools-windows.iso" + tools.write_bytes(b"tools") + base = root / "iso" / "ws.iso" + base.write_bytes(b"win") + seen = _patch_deploy(monkeypatch, root) + + result = CliRunner().invoke( + cli, + ["template", "deploy-win", "--win-iso", str(base), "--nogui"], + ) + assert result.exit_code == 0, result.output + assert seen["patched"] and seen["vmdk"] + assert "deploy-win complete" in result.output + vmx = root / "templates" / "WinBuild2025" / "WinBuild2025.vmx" + assert vmx.is_file() + text = vmx.read_text(encoding="ascii") + # Step 9 stripped install media + swapped NIC + assert "sata0" not in text + assert 'ethernet0.virtualDev = "vmxnet3"' in text + + +def test_deploy_win_uup_happy(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) + + 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 + + monkeypatch.setattr(tmpl_mod.win_uup, "build_iso", fake_build_iso) + + result = CliRunner().invoke(cli, ["template", "deploy-win", "--nogui"]) + assert result.exit_code == 0, result.output + assert "26100.32860" in result.output + + +def test_deploy_win_bad_computer_name(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") + base = root / "iso" / "ws.iso" + base.write_bytes(b"w") + _patch_deploy(monkeypatch, root) + result = CliRunner().invoke( + cli, + ["template", "deploy-win", "--win-iso", str(base), "--computer-name", "bad name!"], + ) + assert result.exit_code != 0 + assert "invalid computer name" in result.output + + +def test_deploy_win_missing_tools_iso(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = tmp_path + (root / "iso").mkdir(parents=True) + base = root / "iso" / "ws.iso" + base.write_bytes(b"w") + _patch_deploy(monkeypatch, root) # tools iso intentionally absent + result = CliRunner().invoke( + cli, ["template", "deploy-win", "--win-iso", str(base)] + ) + assert result.exit_code != 0 + assert "VMware Tools ISO" in result.output + + +def test_deploy_win_flag_timeout(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") + base = root / "iso" / "ws.iso" + base.write_bytes(b"w") + _patch_deploy(monkeypatch, root) + monkeypatch.setattr(tmpl_mod, "_wait_install_flag", lambda *a, **k: False) + result = CliRunner().invoke( + cli, ["template", "deploy-win", "--win-iso", str(base), "--nogui"] + ) + assert result.exit_code != 0 + assert "install_complete.flag not seen" in result.output diff --git a/tests/python/test_winimage.py b/tests/python/test_winimage.py new file mode 100644 index 0000000..79b0588 --- /dev/null +++ b/tests/python/test_winimage.py @@ -0,0 +1,237 @@ +"""Unit tests for the winimage subpackage (uup, autounattend, iso_patch).""" + +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any + +import click +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 + assert "TVRH6-WHNXV-R9WG3-9XRFY-MY832" in xml + + +def test_render_post_install_substitutes() -> None: + ps = au.render_post_install(au.WinUnattendParams(admin_password="Secret123")) + assert "Secret123" in ps and "{{" not in ps + assert "install_complete.flag" in ps + + +def test_validate_computer_name_ok() -> None: + au.validate_computer_name("WINBUILD-2025") + + +@pytest.mark.parametrize("bad", ["bad name", "toolongcomputername01", "has_underscore"]) +def test_validate_computer_name_bad(bad: str) -> None: + with pytest.raises(ValueError, match="invalid computer name"): + au.validate_computer_name(bad) + + +def test_apply_detects_leftover() -> None: + with pytest.raises(ValueError, match="unsubstituted placeholder"): + au._apply("hello {{MISSING}}", {}) + + +# ── 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" + iso.write_bytes(b"x") + assert ip._read_volid(iso) == "WINSERVER" + + +def test_check_free_space_raises(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + iso = tmp_path / "x.iso" + iso.write_bytes(b"x" * 1000) + monkeypatch.setattr( + ip.shutil, "disk_usage", + lambda _p: type("DU", (), {"free": 10})(), + ) + with pytest.raises(click.ClickException, match="insufficient space"): + ip._check_free_space(iso, tmp_path) + + +def test_patch_iso_happy(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + base = tmp_path / "base.iso" + base.write_bytes(b"x" * 1000) + out = tmp_path / "out.iso" + monkeypatch.setattr(ip, "_require_xorriso", lambda: "xorriso") + monkeypatch.setattr(ip, "_read_volid", lambda _p: "WINSERVER") + monkeypatch.setattr(ip, "_check_free_space", lambda *_a: None) + + def fake_run(argv: list[str], **_kw: Any) -> subprocess.CompletedProcess[str]: + # simulate xorriso -extract creating the efi boot file in the tree + if "-extract" in argv: + tree = Path(argv[-1]) + boot = tree / ip._EFI_BOOT_NOPROMPT + boot.parent.mkdir(parents=True, exist_ok=True) + boot.write_bytes(b"boot") + elif "mkisofs" in argv: + out.write_bytes(b"iso") + return subprocess.CompletedProcess(argv, 0, "", "") + + monkeypatch.setattr(ip.subprocess, "run", fake_run) + result = ip.patch_iso( + base, autounattend_xml="<x/>", post_install_ps1="echo hi", out_iso=out, + work_dir=tmp_path, + ) + assert result == out and out.is_file() + + +def test_patch_iso_missing_efi_boot(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + base = tmp_path / "base.iso" + base.write_bytes(b"x" * 1000) + monkeypatch.setattr(ip, "_require_xorriso", lambda: "xorriso") + monkeypatch.setattr(ip, "_check_free_space", lambda *_a: None) + monkeypatch.setattr( + ip.subprocess, "run", + lambda argv, **_k: subprocess.CompletedProcess(argv, 0, "", ""), + ) + with pytest.raises(click.ClickException, match="no-prompt"): + ip.patch_iso( + base, autounattend_xml="<x/>", post_install_ps1="x", + out_iso=tmp_path / "out.iso", work_dir=tmp_path, + )