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:
@@ -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