feat(deploy-win): cache the patched ISO, skip repack when inputs unchanged
The ISO repack (extract + xorriso, ~minutes) ran on every deploy. Cache it per VM dir: store an inputs key (base ISO size+mtime + rendered autounattend.xml + post-install.ps1) in a `.iso.inputs` sidecar next to install-noprompt.iso. Step 4 skips the repack when the patched ISO and a matching sidecar already exist, and re-patches automatically when the base ISO or either answer file changes.
This commit is contained in:
@@ -848,6 +848,19 @@ def _sha256_file(path: Path) -> str:
|
|||||||
return h.hexdigest().upper()
|
return h.hexdigest().upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_iso_inputs_key(
|
||||||
|
base_iso: Path, autounattend_xml: str, post_install_ps1: str
|
||||||
|
) -> str:
|
||||||
|
"""Cache key for a patched ISO — changes if the base ISO or answer files do."""
|
||||||
|
st = base_iso.stat()
|
||||||
|
h = hashlib.sha256()
|
||||||
|
h.update(f"{base_iso.name}|{st.st_size}|{int(st.st_mtime)}\n".encode())
|
||||||
|
h.update(autounattend_xml.encode("utf-8"))
|
||||||
|
h.update(b"\x00")
|
||||||
|
h.update(post_install_ps1.encode("utf-8"))
|
||||||
|
return h.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def _build_seed_iso(staging_dir: Path, iso_path: Path) -> None:
|
def _build_seed_iso(staging_dir: Path, iso_path: Path) -> None:
|
||||||
tool = shutil.which("genisoimage") or shutil.which("mkisofs")
|
tool = shutil.which("genisoimage") or shutil.which("mkisofs")
|
||||||
if not tool:
|
if not tool:
|
||||||
@@ -2219,6 +2232,16 @@ def template_deploy_win(
|
|||||||
# ── Step 4: patch ISO (inject answer files + no-prompt boot) ───────────────
|
# ── Step 4: patch ISO (inject answer files + no-prompt boot) ───────────────
|
||||||
_deploy_step("Step 4: patch ISO (inject autounattend + no-prompt boot)", 4, start_from_step)
|
_deploy_step("Step 4: patch ISO (inject autounattend + no-prompt boot)", 4, start_from_step)
|
||||||
if start_from_step <= 4:
|
if start_from_step <= 4:
|
||||||
|
key = _patched_iso_inputs_key(base_iso, autounattend_xml, post_install_ps1)
|
||||||
|
sidecar = patched_iso.with_suffix(patched_iso.suffix + ".inputs")
|
||||||
|
cached = (
|
||||||
|
patched_iso.is_file()
|
||||||
|
and sidecar.is_file()
|
||||||
|
and sidecar.read_text(encoding="utf-8").strip() == key
|
||||||
|
)
|
||||||
|
if cached:
|
||||||
|
click.echo(f" [OK] patched ISO up to date — skipping repack: {patched_iso}")
|
||||||
|
else:
|
||||||
win_iso_patch.patch_iso(
|
win_iso_patch.patch_iso(
|
||||||
base_iso,
|
base_iso,
|
||||||
autounattend_xml=autounattend_xml,
|
autounattend_xml=autounattend_xml,
|
||||||
@@ -2226,6 +2249,7 @@ def template_deploy_win(
|
|||||||
out_iso=patched_iso,
|
out_iso=patched_iso,
|
||||||
work_dir=vm_dir,
|
work_dir=vm_dir,
|
||||||
)
|
)
|
||||||
|
sidecar.write_text(key, encoding="utf-8")
|
||||||
|
|
||||||
# ── Step 5: create VMDK ────────────────────────────────────────────────────
|
# ── Step 5: create VMDK ────────────────────────────────────────────────────
|
||||||
_deploy_step("Step 5: create VMDK", 5, start_from_step)
|
_deploy_step("Step 5: create VMDK", 5, start_from_step)
|
||||||
|
|||||||
@@ -151,6 +151,50 @@ def test_deploy_win_winiso_happy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|||||||
assert 'ethernet0.virtualDev = "vmxnet3"' 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_uup_happy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_deploy_win_uup_happy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
root = tmp_path
|
root = tmp_path
|
||||||
(root / "iso").mkdir(parents=True)
|
(root / "iso").mkdir(parents=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user