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:
@@ -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}")
|
||||
|
||||
@@ -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``.
|
||||
"""
|
||||
@@ -0,0 +1,191 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Windows Server 2025 unattended install template.
|
||||
Double-brace placeholders are substituted by ci_orchestrator.winimage.autounattend.
|
||||
|
||||
Boot NIC is e1000e (broad compat). VMware Tools install brings vmxnet3 driver;
|
||||
deploy-win switches the VMX to vmxnet3 after Tools install completes.
|
||||
|
||||
post-install.ps1 is injected at the ISO root (FirstLogonCommands scans drives
|
||||
for it). VMware Tools are mounted as a 2nd CD-ROM; post-install.ps1 finds
|
||||
setup64.exe on that CD.
|
||||
|
||||
Disk layout (UEFI/GPT): EFI 100 MB FAT32 (S:), MSR 16 MB, Primary rest NTFS (C:).
|
||||
Image index {{IMAGE_INDEX}} = "Windows Server 2025 SERVERSTANDARD" (Desktop Experience).
|
||||
-->
|
||||
<unattend xmlns="urn:schemas-microsoft-com:unattend"
|
||||
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
|
||||
<settings pass="windowsPE">
|
||||
<component name="Microsoft-Windows-International-Core-WinPE"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<SetupUILanguage>
|
||||
<UILanguage>{{LOCALE_OS}}</UILanguage>
|
||||
</SetupUILanguage>
|
||||
<InputLocale>{{KEYBOARD}}</InputLocale>
|
||||
<SystemLocale>{{LOCALE_OS}}</SystemLocale>
|
||||
<UILanguage>{{LOCALE_OS}}</UILanguage>
|
||||
<UILanguageFallback>{{LOCALE_OS}}</UILanguageFallback>
|
||||
<UserLocale>{{LOCALE_USER}}</UserLocale>
|
||||
</component>
|
||||
|
||||
<component name="Microsoft-Windows-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<DiskConfiguration>
|
||||
<Disk wcm:action="add">
|
||||
<DiskID>0</DiskID>
|
||||
<WillWipeDisk>true</WillWipeDisk>
|
||||
<CreatePartitions>
|
||||
<CreatePartition wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<Type>EFI</Type>
|
||||
<Size>100</Size>
|
||||
</CreatePartition>
|
||||
<CreatePartition wcm:action="add">
|
||||
<Order>2</Order>
|
||||
<Type>MSR</Type>
|
||||
<Size>16</Size>
|
||||
</CreatePartition>
|
||||
<CreatePartition wcm:action="add">
|
||||
<Order>3</Order>
|
||||
<Type>Primary</Type>
|
||||
<Extend>true</Extend>
|
||||
</CreatePartition>
|
||||
</CreatePartitions>
|
||||
<ModifyPartitions>
|
||||
<ModifyPartition wcm:action="add">
|
||||
<Order>1</Order>
|
||||
<PartitionID>1</PartitionID>
|
||||
<Format>FAT32</Format>
|
||||
<Label>System</Label>
|
||||
<Letter>S</Letter>
|
||||
</ModifyPartition>
|
||||
<ModifyPartition wcm:action="add">
|
||||
<Order>2</Order>
|
||||
<PartitionID>2</PartitionID>
|
||||
</ModifyPartition>
|
||||
<ModifyPartition wcm:action="add">
|
||||
<Order>3</Order>
|
||||
<PartitionID>3</PartitionID>
|
||||
<Format>NTFS</Format>
|
||||
<Label>Windows</Label>
|
||||
<Letter>C</Letter>
|
||||
</ModifyPartition>
|
||||
</ModifyPartitions>
|
||||
</Disk>
|
||||
</DiskConfiguration>
|
||||
|
||||
<ImageInstall>
|
||||
<OSImage>
|
||||
<InstallFrom>
|
||||
<MetaData wcm:action="add">
|
||||
<Key>/IMAGE/INDEX</Key>
|
||||
<Value>{{IMAGE_INDEX}}</Value>
|
||||
</MetaData>
|
||||
</InstallFrom>
|
||||
<InstallTo>
|
||||
<DiskID>0</DiskID>
|
||||
<PartitionID>3</PartitionID>
|
||||
</InstallTo>
|
||||
</OSImage>
|
||||
</ImageInstall>
|
||||
|
||||
<UserData>
|
||||
<AcceptEula>true</AcceptEula>
|
||||
<FullName>CI</FullName>
|
||||
<Organization>CI Lab</Organization>
|
||||
<ProductKey>
|
||||
<Key>{{PRODUCT_KEY}}</Key>
|
||||
<WillShowUI>OnError</WillShowUI>
|
||||
</ProductKey>
|
||||
</UserData>
|
||||
|
||||
<UseConfigurationSet>false</UseConfigurationSet>
|
||||
|
||||
<DynamicUpdate>
|
||||
<Enable>false</Enable>
|
||||
<WillShowUI>Never</WillShowUI>
|
||||
</DynamicUpdate>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<settings pass="specialize">
|
||||
<component name="Microsoft-Windows-Shell-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<ComputerName>{{COMPUTER_NAME}}</ComputerName>
|
||||
<TimeZone>{{TIMEZONE}}</TimeZone>
|
||||
<RegisteredOwner>CI</RegisteredOwner>
|
||||
<RegisteredOrganization>CI Lab</RegisteredOrganization>
|
||||
</component>
|
||||
</settings>
|
||||
|
||||
<settings pass="oobeSystem">
|
||||
<component name="Microsoft-Windows-International-Core"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<InputLocale>{{KEYBOARD}}</InputLocale>
|
||||
<SystemLocale>{{LOCALE_OS}}</SystemLocale>
|
||||
<UILanguage>{{LOCALE_OS}}</UILanguage>
|
||||
<UILanguageFallback>{{LOCALE_OS}}</UILanguageFallback>
|
||||
<UserLocale>{{LOCALE_USER}}</UserLocale>
|
||||
</component>
|
||||
|
||||
<component name="Microsoft-Windows-Shell-Setup"
|
||||
processorArchitecture="amd64"
|
||||
publicKeyToken="31bf3856ad364e35"
|
||||
language="neutral"
|
||||
versionScope="nonSxS">
|
||||
<UserAccounts>
|
||||
<AdministratorPassword>
|
||||
<Value>{{ADMIN_PWD}}</Value>
|
||||
<PlainText>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 ^> C:\Windows\Temp\post-install.log 2^>^&1</CommandLine>
|
||||
<Description>Run post-install script from install ISO root</Description>
|
||||
</SynchronousCommand>
|
||||
</FirstLogonCommands>
|
||||
</component>
|
||||
</settings>
|
||||
</unattend>
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user