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.
159 lines
6.3 KiB
Python
159 lines
6.3 KiB
Python
"""Unit tests for the winimage subpackage (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 massgrave as mg
|
|
|
|
# ── massgrave resolver ───────────────────────────────────────────────────────
|
|
|
|
_SAMPLE_MD = """\
|
|
## Windows Server 2025
|
|
Build - 26100.32860 (May 2026)
|
|
| English | x64 | [en-us_windows_server_2025_updated_may_2026_x64_dvd_abc.iso](https://zerofs.link/f/AAA/)|
|
|
| Italian | x64 | [it-it_windows_server_2025_updated_may_2026_x64_dvd_abc.iso](https://zerofs.link/f/BBB/)|
|
|
Build - 26100.1742
|
|
| English | x64 | [X23-81958_26100.1742_en-us.iso](https://oemsoc.download.prss.microsoft.com/x.iso?t=z)|
|
|
## Windows Server 2022
|
|
| English | x64 | [en-us_windows_server_2022.iso](https://zerofs.link/f/CCC/)|
|
|
"""
|
|
|
|
|
|
def test_resolve_ws2025_picks_newest_english() -> None:
|
|
i = mg.resolve_ws2025(_SAMPLE_MD, lang="English", arch="x64")
|
|
assert i.build == "26100.32860"
|
|
assert i.filename.startswith("en-us_windows_server_2025_updated")
|
|
assert i.url == "https://zerofs.link/f/AAA/"
|
|
|
|
|
|
def test_resolve_ws2025_italian() -> None:
|
|
assert mg.resolve_ws2025(_SAMPLE_MD, lang="Italian").url == "https://zerofs.link/f/BBB/"
|
|
|
|
|
|
def test_resolve_ws2025_not_found() -> None:
|
|
with pytest.raises(click.ClickException, match="No Windows Server 2025"):
|
|
mg.resolve_ws2025(_SAMPLE_MD, lang="Klingon")
|
|
|
|
|
|
def test_ws2025_section_missing() -> None:
|
|
with pytest.raises(click.ClickException, match="section not found"):
|
|
mg._ws2025_section("## Windows Server 2022\nnothing here")
|
|
|
|
# ── 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, "_require_7z", lambda: "7z")
|
|
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 7z extraction creating the efi boot file in the tree
|
|
if argv[0] == "7z" and "x" in argv:
|
|
outflag = next(a for a in argv if a.startswith("-o"))
|
|
tree = Path(outflag[2:])
|
|
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_iso_has_root_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setattr(ip, "_require_7z", lambda: "7z")
|
|
iso = tmp_path / "x.iso"
|
|
iso.write_bytes(b"x")
|
|
|
|
def fake_run(argv: list[str], **_k: Any) -> subprocess.CompletedProcess[str]:
|
|
out = "2026 .... 6813 autounattend.xml\n" if "-i!autounattend.xml" in argv else ""
|
|
return subprocess.CompletedProcess(argv, 0, out, "")
|
|
|
|
monkeypatch.setattr(ip.subprocess, "run", fake_run)
|
|
assert ip.iso_has_root_file(iso, "autounattend.xml") is True
|
|
assert ip.iso_has_root_file(iso, "missing.xml") is False
|
|
|
|
|
|
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, "_require_7z", lambda: "7z")
|
|
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,
|
|
)
|