diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py index e5b7c0c..aabbe40 100644 --- a/src/ci_orchestrator/commands/template.py +++ b/src/ci_orchestrator/commands/template.py @@ -2256,24 +2256,29 @@ 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: - 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}") + if win_iso_patch.iso_has_root_file(base_iso, "autounattend.xml"): + # Already patched (e.g. produced by `template patch-win-iso`) — use as-is. + click.echo(f" [OK] ISO already patched (autounattend.xml present) — using as-is: {base_iso}") + patched_iso = base_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, + 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 ) - sidecar.write_text(key, encoding="utf-8") + 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/src/ci_orchestrator/winimage/iso_patch.py b/src/ci_orchestrator/winimage/iso_patch.py index 1d5a490..3ba2f6f 100644 --- a/src/ci_orchestrator/winimage/iso_patch.py +++ b/src/ci_orchestrator/winimage/iso_patch.py @@ -38,6 +38,18 @@ def _require_7z() -> str: raise click.ClickException("7z not found. Install p7zip-full.") +def iso_has_root_file(iso: Path, name: str) -> bool: + """True if ``name`` exists at the root of ``iso`` (used to detect a patched ISO).""" + sevenz = _require_7z() + proc = subprocess.run( + [sevenz, "l", str(iso), f"-i!{name}"], + capture_output=True, + text=True, + check=False, + ) + return proc.returncode == 0 and name.lower() in proc.stdout.lower() + + def _read_volid(base_iso: Path) -> str: """Read the ISO volume id (label); fall back to a sane default.""" label = "" diff --git a/tests/python/test_commands_deploy_win.py b/tests/python/test_commands_deploy_win.py index 02ccc6d..95edfac 100644 --- a/tests/python/test_commands_deploy_win.py +++ b/tests/python/test_commands_deploy_win.py @@ -194,6 +194,30 @@ def test_deploy_win_skips_patch_when_cached( assert "skipping repack" in result.output +def test_deploy_win_uses_already_patched_iso( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + root = tmp_path + (root / "iso").mkdir(parents=True) + (root / "iso" / "VMware-tools-windows.iso").write_bytes(b"tools") + base = root / "iso" / "ws.patched.iso" + base.write_bytes(b"win") + _patch_deploy(monkeypatch, root) + # Pretend the input ISO already carries autounattend.xml at its root. + monkeypatch.setattr(tmpl_mod.win_iso_patch, "iso_has_root_file", lambda *_a: True) + + def boom(*_a: Any, **_k: Any) -> Path: + raise AssertionError("patch_iso must not run for an already-patched ISO") + + monkeypatch.setattr(tmpl_mod.win_iso_patch, "patch_iso", boom) + + result = CliRunner().invoke( + cli, ["template", "deploy-win", "--win-iso", str(base), "--nogui"] + ) + assert result.exit_code == 0, result.output + assert "already patched" in result.output # and boom (patch_iso) was never raised + + def test_deploy_win_iso_url_downloads(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: root = tmp_path (root / "iso").mkdir(parents=True) diff --git a/tests/python/test_winimage.py b/tests/python/test_winimage.py index aa01d63..f4df50e 100644 --- a/tests/python/test_winimage.py +++ b/tests/python/test_winimage.py @@ -127,6 +127,20 @@ def test_patch_iso_happy(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Non 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)