Files
local-ci-cd-system/tests/python/test_commands_deploy_win.py
Simone 6a57c1d815 fix(deploy-win): don't re-patch an already-patched ISO
deploy-win patched its input into vm_dir/install-noprompt.iso and only its
own sidecar cache could skip the repack — so feeding an already-patched ISO
(e.g. from `template patch-win-iso`, or via --win-iso) triggered a wasteful
second extract+repack that double-injected the answer files.

deploy-win now detects a patched input (autounattend.xml present at the ISO
root via `iso_patch.iso_has_root_file`) and uses it as-is, skipping Step 4.
Raw ISOs are still patched once and cached as before.
2026-06-09 20:15:30 +02:00

351 lines
14 KiB
Python

"""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, "<x/>", "ps")
k2 = tmpl_mod._patched_iso_inputs_key(iso, "<x/>", "ps")
k3 = tmpl_mod._patched_iso_inputs_key(iso, "<y/>", "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 <iso> by basename
assert seen["patched"]
def test_deploy_win_requires_one_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path
(root / "iso").mkdir(parents=True)
(root / "iso" / "VMware-tools-windows.iso").write_bytes(b"t")
_patch_deploy(monkeypatch, root)
# neither --iso-url nor --win-iso
result = CliRunner().invoke(cli, ["template", "deploy-win", "--nogui"])
assert result.exit_code != 0
assert "exactly one of --iso-url or --win-iso" in result.output
def test_deploy_win_bad_computer_name(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
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