From a0497ffa4e4e46a310c3a777f0d4e0d595f9f40c Mon Sep 17 00:00:00 2001 From: Simone Date: Mon, 8 Jun 2026 23:53:18 +0200 Subject: [PATCH] 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. --- src/ci_orchestrator/commands/template.py | 36 +++++++++++++++---- tests/python/test_commands_deploy_win.py | 44 ++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 6 deletions(-) diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py index 77e54af..5b828f0 100644 --- a/src/ci_orchestrator/commands/template.py +++ b/src/ci_orchestrator/commands/template.py @@ -848,6 +848,19 @@ def _sha256_file(path: Path) -> str: 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: tool = shutil.which("genisoimage") or shutil.which("mkisofs") if not tool: @@ -2219,13 +2232,24 @@ def template_deploy_win( # ── 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) if start_from_step <= 4: - win_iso_patch.patch_iso( - base_iso, - autounattend_xml=autounattend_xml, - post_install_ps1=post_install_ps1, - out_iso=patched_iso, - work_dir=vm_dir, + 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( + base_iso, + autounattend_xml=autounattend_xml, + post_install_ps1=post_install_ps1, + out_iso=patched_iso, + work_dir=vm_dir, + ) + sidecar.write_text(key, encoding="utf-8") # ── Step 5: create VMDK ──────────────────────────────────────────────────── _deploy_step("Step 5: create VMDK", 5, start_from_step) diff --git a/tests/python/test_commands_deploy_win.py b/tests/python/test_commands_deploy_win.py index 6e6dc77..87c3409 100644 --- a/tests/python/test_commands_deploy_win.py +++ b/tests/python/test_commands_deploy_win.py @@ -151,6 +151,50 @@ def test_deploy_win_winiso_happy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch 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, "", "ps") + k2 = tmpl_mod._patched_iso_inputs_key(iso, "", "ps") + k3 = tmpl_mod._patched_iso_inputs_key(iso, "", "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: root = tmp_path (root / "iso").mkdir(parents=True)