Add `ci template check-win-iso`: parse the massgrave windows-server-links markdown source on GitHub for the newest Windows Server 2025 build, file name and link, and report whether it is already in <CI_ROOT>/iso/. The download itself stays manual — the massgrave link is behind a Cloudflare Turnstile file host, not scriptable. Add `ci template patch-win-iso --in ISO [--out ISO]`: standalone equivalent of deploy-win Step 4 — inject autounattend.xml + post-install.ps1 and a no-prompt boot image into a manually downloaded ISO, producing a ready-to-deploy `.patched.iso`. winimage/massgrave.py does the parse (no auto-download). RUNBOOK §5 documents the check → download → patch → deploy workflow. Coverage 93.6%.
145 lines
5.7 KiB
Python
145 lines
5.7 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_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,
|
|
)
|