"""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 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 # No USB controllers on a headless CI build VM. assert 'usb.present = "FALSE"' in text assert "usb_xhci" not in text and 'ehci.present = "TRUE"' not 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_patched_iso_inputs_key_stable(tmp_path: Path) -> None: iso = tmp_path / "b.iso" iso.write_bytes(b"x" * 10) k1 = tmpl_mod._patched_iso_inputs_key(iso, "", "ps") k2 = tmpl_mod._patched_iso_inputs_key(iso, "", "ps") k3 = tmpl_mod._patched_iso_inputs_key(iso, "", "ps") assert k1 == k2 and k1 != k3 def test_deploy_win_skips_patch_when_cached( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: from ci_orchestrator.winimage import autounattend as au root = tmp_path (root / "iso").mkdir(parents=True) (root / "iso" / "VMware-tools-windows.iso").write_bytes(b"tools") base = root / "iso" / "ws.iso" base.write_bytes(b"win") _patch_deploy(monkeypatch, root) # Pre-create the patched ISO + a matching inputs sidecar. vm_dir = root / "templates" / "WinBuild2025" vm_dir.mkdir(parents=True) patched = vm_dir / "install-noprompt.iso" patched.write_bytes(b"iso") params = au.WinUnattendParams() key = tmpl_mod._patched_iso_inputs_key( base, au.render_autounattend(params), au.render_post_install(params) ) (vm_dir / "install-noprompt.iso.inputs").write_text(key, encoding="utf-8") def boom(*_a: Any, **_k: Any) -> Path: raise AssertionError("patch_iso should not run when cache is valid") monkeypatch.setattr(tmpl_mod.win_iso_patch, "patch_iso", boom) result = CliRunner().invoke( cli, ["template", "deploy-win", "--win-iso", str(base), "--nogui", "--force"] ) assert result.exit_code == 0, result.output assert "skipping repack" in result.output def test_deploy_win_uses_already_patched_iso( 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") base = root / "iso" / "ws.patched.iso" base.write_bytes(b"win") _patch_deploy(monkeypatch, root) # Pretend the input ISO already carries autounattend.xml at its root. monkeypatch.setattr(tmpl_mod.win_iso_patch, "iso_has_root_file", lambda *_a: True) def boom(*_a: Any, **_k: Any) -> Path: raise AssertionError("patch_iso must not run for an already-patched ISO") monkeypatch.setattr(tmpl_mod.win_iso_patch, "patch_iso", boom) result = CliRunner().invoke( cli, ["template", "deploy-win", "--win-iso", str(base), "--nogui"] ) assert result.exit_code == 0, result.output assert "already patched" in result.output # and boom (patch_iso) was never raised def test_deploy_win_iso_url_downloads(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = tmp_path (root / "iso").mkdir(parents=True) (root / "iso" / "VMware-tools-windows.iso").write_bytes(b"tools") seen = _patch_deploy(monkeypatch, root) downloaded: dict[str, str] = {} def fake_dl(url: str, dest: Path) -> None: downloaded["url"] = url dest.write_bytes(b"iso") monkeypatch.setattr(tmpl_mod, "_download_vmdk", fake_dl) result = CliRunner().invoke( cli, ["template", "deploy-win", "--nogui", "--iso-url", "https://example.test/win2025.iso"], ) assert result.exit_code == 0, result.output assert downloaded["url"].endswith("win2025.iso") assert (root / "iso" / "win2025.iso").is_file() # cached under by basename assert seen["patched"] def test_deploy_win_requires_one_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = tmp_path (root / "iso").mkdir(parents=True) (root / "iso" / "VMware-tools-windows.iso").write_bytes(b"t") _patch_deploy(monkeypatch, root) # neither --iso-url nor --win-iso result = CliRunner().invoke(cli, ["template", "deploy-win", "--nogui"]) assert result.exit_code != 0 assert "exactly one of --iso-url or --win-iso" in result.output def test_deploy_win_bad_computer_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: 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 # ── check-win-iso / patch-win-iso ──────────────────────────────────────────── def test_check_win_iso_not_cached(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from ci_orchestrator.winimage import massgrave as mg (tmp_path / "iso").mkdir(parents=True) monkeypatch.setattr(tmpl_mod, "load_config", lambda: _make_config(tmp_path)) info = mg.WinServerIso(build="26100.32860", filename="ws.iso", url="https://zerofs.link/f/AAA/", lang="English", arch="x64") monkeypatch.setattr(tmpl_mod.win_massgrave, "latest_ws2025", lambda **_k: info) result = CliRunner().invoke(cli, ["template", "check-win-iso"]) assert result.exit_code == 0, result.output assert "26100.32860" in result.output and "cached: no" in result.output def test_check_win_iso_cached(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from ci_orchestrator.winimage import massgrave as mg (tmp_path / "iso").mkdir(parents=True) (tmp_path / "iso" / "ws.iso").write_bytes(b"x") monkeypatch.setattr(tmpl_mod, "load_config", lambda: _make_config(tmp_path)) info = mg.WinServerIso(build="26100.32860", filename="ws.iso", url="u", lang="English", arch="x64") monkeypatch.setattr(tmpl_mod.win_massgrave, "latest_ws2025", lambda **_k: info) result = CliRunner().invoke(cli, ["template", "check-win-iso"]) assert result.exit_code == 0, result.output assert "cached: yes" in result.output def test_patch_win_iso_standalone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: src = tmp_path / "in.iso" src.write_bytes(b"iso") captured: dict[str, Any] = {} def fake_patch(base: Path, *, out_iso: Path, **_k: Any) -> Path: captured["base"] = base captured["out"] = out_iso out_iso.write_bytes(b"patched") return out_iso monkeypatch.setattr(tmpl_mod.win_iso_patch, "patch_iso", fake_patch) result = CliRunner().invoke(cli, ["template", "patch-win-iso", "--in", str(src)]) assert result.exit_code == 0, result.output assert captured["base"] == src assert str(captured["out"]).endswith("in.iso.patched.iso") def test_patch_win_iso_missing_input(tmp_path: Path) -> None: result = CliRunner().invoke( cli, ["template", "patch-win-iso", "--in", str(tmp_path / "nope.iso")] ) assert result.exit_code != 0 assert "input ISO not found" in result.output