"""Tests for ``ci_orchestrator job`` (Phase A4).""" from __future__ import annotations from pathlib import Path from typing import Any import click import pytest from click.testing import CliRunner import ci_orchestrator.commands.job as job_module from ci_orchestrator.__main__ import cli from ci_orchestrator.backends.errors import BackendOperationFailed from ci_orchestrator.backends.protocol import VmHandle # ──────────────────────────────────────────────────────────── fakes class _FakeBackend: """Records every backend call and creates the VMX file on clone.""" def __init__( self, *, clone_fail: bool = False, start_fail: bool = False, delete_fail: bool = False, ip: str | None = "10.99.0.42", running_after: int = 0, clone_guest_os: str = "ubuntu-64", ) -> None: self.clone_fail = clone_fail self.start_fail = start_fail self.delete_fail = delete_fail self.ip = ip self.running_after = running_after self.clone_guest_os = clone_guest_os self.calls: list[str] = [] self._is_running_count = 0 def clone_linked( self, template: str, snapshot: str, name: str, destination: str | None = None, ) -> VmHandle: self.calls.append("clone") if self.clone_fail: raise BackendOperationFailed("clone", 1, "boom") assert destination is not None dst = Path(destination) dst.parent.mkdir(parents=True, exist_ok=True) dst.write_text( f'config.version = "8"\nguestOS = "{self.clone_guest_os}"\n', encoding="utf-8", ) return VmHandle(identifier=str(dst), name=name) def start(self, handle: VmHandle, headless: bool = True) -> None: self.calls.append("start") if self.start_fail: raise BackendOperationFailed("start", 1, "no") def stop(self, handle: VmHandle, hard: bool = False) -> None: self.calls.append(f"stop:{'hard' if hard else 'soft'}") def delete(self, handle: VmHandle) -> None: self.calls.append("delete") if self.delete_fail: raise BackendOperationFailed("deleteVM", 1, "locked") def is_running(self, handle: VmHandle) -> bool: self._is_running_count += 1 return self._is_running_count > self.running_after def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None: return self.ip def list_snapshots(self, handle: VmHandle) -> list[str]: return [] def _patch_common( monkeypatch: pytest.MonkeyPatch, backend: _FakeBackend, *, transport_ready: bool = True, linux_build_raises: bool = False, ) -> dict[str, list[Any]]: """Patch all in-process collaborators of the ``job`` command.""" calls: dict[str, list[Any]] = { "linux_build": [], "windows_build": [], "linux_collect": [], "windows_collect": [], "manifest": [], } monkeypatch.setattr(job_module, "load_backend", lambda _cfg: backend) # Prevent tests from reading the live /var/lib/ci/config.toml which may # have ip_pool configured — that would try to lock /var/lib/ci/ip-pool.lock # (requires root) and fail before any backend call. from ci_orchestrator.config import BackendConfig, Config, Paths _test_paths = Paths(root=Path("/tmp/ci-test"), templates=Path("/tmp/ci-test/templates"), build_vms=Path("/tmp/ci-test/build-vms"), artifacts=Path("/tmp/ci-test/artifacts"), keys=Path("/tmp/ci-test/keys")) _test_cfg = Config(paths=_test_paths, backend=BackendConfig(), ip_pool=None) monkeypatch.setattr(job_module, "load_config", lambda: _test_cfg) class _ProbeOk: def __enter__(self) -> _ProbeOk: return self def __exit__(self, *_e: object) -> None: return None def is_ready(self) -> bool: return transport_ready class _Store: def get(self, _t: str) -> Any: from ci_orchestrator.credentials import Credential return Credential("ci", "pwd") monkeypatch.setattr(job_module, "WinRmTransport", lambda *a, **k: _ProbeOk()) monkeypatch.setattr(job_module, "SshTransport", lambda *a, **k: _ProbeOk()) monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _Store()) def _linux_build(**kwargs: Any) -> None: calls["linux_build"].append(kwargs) if linux_build_raises: raise RuntimeError("build exploded") def _windows_build(**kwargs: Any) -> None: calls["windows_build"].append(kwargs) def _linux_collect(**kwargs: Any) -> None: calls["linux_collect"].append(kwargs) # Simulate a transferred file so manifest writing has something to do. Path(kwargs["host_dir"], "out.txt").write_text("ok", encoding="utf-8") def _windows_collect(**kwargs: Any) -> None: calls["windows_collect"].append(kwargs) def _manifest(host_dir: Path, job_id: str, commit: str) -> int: calls["manifest"].append((str(host_dir), job_id, commit)) return 1 monkeypatch.setattr(job_module, "_linux_build", _linux_build) monkeypatch.setattr(job_module, "_windows_build", _windows_build) monkeypatch.setattr(job_module, "_linux_collect", _linux_collect) monkeypatch.setattr(job_module, "_windows_collect", _windows_collect) monkeypatch.setattr(job_module, "_write_manifest", _manifest) # Make poll loops instant. monkeypatch.setattr(job_module.time, "sleep", lambda _s: None) return calls @pytest.fixture def template_vmx(tmp_path: Path) -> Path: """Linux-flavoured VMX so auto-detect picks the SSH transport.""" vmx = tmp_path / "templates" / "LinuxBuild2404.vmx" vmx.parent.mkdir(parents=True) vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") return vmx def _common_args( template: Path, *, clone_base: Path, artifact_base: Path, extra: list[str] | None = None, ) -> list[str]: return [ "job", "--job-id", "ci-test-1", "--repo-url", "http://gitea.local/org/repo.git", "--branch", "main", "--template-path", str(template), "--clone-base-dir", str(clone_base), "--artifact-base-dir", str(artifact_base), "--ready-timeout", "30", "--poll-interval", "1", *(extra or []), ] # ──────────────────────────────────────────────────────────── tests def test_job_happy_path_linux( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: backend = _FakeBackend(running_after=1) calls = _patch_common(monkeypatch, backend) base_clone = tmp_path / "build-vms" base_artifact = tmp_path / "artifacts" result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=base_clone, artifact_base=base_artifact) ) assert result.exit_code == 0, result.output # Backend received the full lifecycle including delete in finally. assert backend.calls[0] == "clone" assert "start" in backend.calls assert backend.calls[-1] == "delete" # Linux build branch was taken (auto-detected from VMX guestOS). assert len(calls["linux_build"]) == 1 assert calls["linux_build"][0]["build_command"] == "" # Artifacts were collected into the per-job directory and manifest written. assert (base_artifact / "ci-test-1" / "out.txt").is_file() assert calls["manifest"] and calls["manifest"][0][1] == "ci-test-1" # Clone directory removed. assert not any((base_clone).iterdir()) or all(not p.exists() for p in base_clone.iterdir()) def test_job_default_transport_in_guest_with_submodules( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: backend = _FakeBackend(running_after=1) calls = _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "v", artifact_base=tmp_path / "a" ), ) assert result.exit_code == 0, result.output kw = calls["linux_build"][0] # Default: in-guest clone (clone_url set, no host source) + submodules. assert kw["host_source_dir"] is None assert kw["clone_url"] and "repo.git" in kw["clone_url"] assert kw["clone_submodules"] is True def test_job_host_clone_transport( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: backend = _FakeBackend(running_after=1) calls = _patch_common(monkeypatch, backend) root = tmp_path / "hostclone" src = root / "src" src.mkdir(parents=True) def _fake_host_clone( repo_url: str, branch: str, commit: str, submodules: bool ) -> tuple[Path, Path]: assert submodules is True return root, src monkeypatch.setattr(job_module, "_host_clone", _fake_host_clone) result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "v", artifact_base=tmp_path / "a", extra=["--host-clone"], ), ) assert result.exit_code == 0, result.output kw = calls["linux_build"][0] assert kw["host_source_dir"] == str(src) assert kw["clone_url"] is None assert kw["clone_submodules"] is True # Host-side temp clone removed after the build. assert not root.exists() def test_job_no_submodules_flag( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: backend = _FakeBackend(running_after=1) calls = _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "v", artifact_base=tmp_path / "a", extra=["--no-submodules"], ), ) assert result.exit_code == 0, result.output assert calls["linux_build"][0]["clone_submodules"] is False def test_job_skip_artifact_branch( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: backend = _FakeBackend(running_after=0) calls = _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art", extra=["--skip-artifact"], ), ) assert result.exit_code == 0, result.output assert calls["linux_collect"] == [] assert calls["windows_collect"] == [] assert calls["manifest"] == [] assert backend.calls[-1] == "delete" def test_job_cleanup_on_build_failure( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """If the build raises, the VM must still be destroyed (try/finally).""" backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend, linux_build_raises=True) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "build exploded" in result.output assert "delete" in backend.calls # Stop attempt happened before delete. assert any(c.startswith("stop:") for c in backend.calls) def test_job_cleanup_on_clone_failure( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: backend = _FakeBackend(clone_fail=True) _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "clone failed" in result.output # Backend.start/delete must not have been called: handle is None. assert backend.calls == ["clone"] def test_job_rejects_missing_template( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: backend = _FakeBackend() _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args( tmp_path / "ghost.vmx", clone_base=tmp_path / "vms", artifact_base=tmp_path / "art", ), ) assert result.exit_code != 0 assert "Template VMX not found" in result.output assert backend.calls == [] def test_job_windows_branch_with_overrides( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Windows-flavoured VMX selects the WinRM build path; CPU/RAM override applied.""" vmx = tmp_path / "templates" / "WinBuild2025.vmx" vmx.parent.mkdir(parents=True) vmx.write_text('guestOS = "windows10srv-64"\n', encoding="utf-8") backend = _FakeBackend(running_after=0, clone_guest_os="windows10srv-64") calls = _patch_common(monkeypatch, backend) extra = [ "--guest-cpu", "4", "--guest-memory-mb", "8192", "--extra-env-json", '{"BUILD_TAG":"abc"}', ] result = CliRunner().invoke( cli, _common_args( vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art", extra=extra, ), ) assert result.exit_code == 0, result.output assert calls["windows_build"], "Windows build branch not taken" assert calls["windows_build"][0]["extra_env"] == {"BUILD_TAG": "abc"} # CPU/RAM override patched the clone VMX. clone_vmx = next((tmp_path / "vms").rglob("*.vmx"), None) # After successful job the clone dir is deleted, so the file is gone. # Check that the Windows collect helper got the collect dir instead. assert calls["windows_collect"][0]["guest_path"] == "C:\\CI\\output" assert clone_vmx is None # cleaned up assert backend.calls[-1] == "delete" def test_job_invalid_extra_env_json( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: backend = _FakeBackend() _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art", extra=["--extra-env-json", '{"bad-key":"x"}'], ), ) assert result.exit_code != 0 assert "not a valid env var name" in result.output # Failure is parameter-time; clone never started. assert backend.calls == [] # ──────────────────────────────────────────────── helper-level tests def test_apply_vmx_overrides_replaces_existing_lines(tmp_path: Path) -> None: vmx = tmp_path / "x.vmx" vmx.write_text( 'config.version = "8"\nnumvcpus = "1"\nmemsize = "2048"\n', encoding="utf-8", ) job_module._apply_vmx_overrides(vmx, cpu=8, memory_mb=16384) text = vmx.read_text(encoding="utf-8") assert 'numvcpus = "8"' in text assert 'memsize = "16384"' in text def test_apply_vmx_overrides_appends_when_missing(tmp_path: Path) -> None: vmx = tmp_path / "x.vmx" vmx.write_text('config.version = "8"\n', encoding="utf-8") job_module._apply_vmx_overrides(vmx, cpu=2, memory_mb=4096) text = vmx.read_text(encoding="utf-8") assert 'numvcpus = "2"' in text assert 'memsize = "4096"' in text def test_read_guest_os_detects_linux(tmp_path: Path) -> None: vmx = tmp_path / "x.vmx" vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") assert job_module._read_guest_os_from_vmx(vmx) == "linux" def test_read_guest_os_defaults_to_windows(tmp_path: Path) -> None: vmx = tmp_path / "x.vmx" vmx.write_text("# nothing useful\n", encoding="utf-8") assert job_module._read_guest_os_from_vmx(vmx) == "windows" def test_parse_extra_env_json_empty_inputs() -> None: assert job_module._parse_extra_env_json("") == {} assert job_module._parse_extra_env_json("{}") == {} def test_parse_extra_env_json_rejects_non_object() -> None: with pytest.raises(click.exceptions.UsageError): job_module._parse_extra_env_json("[]") def test_read_guest_os_handles_oserror(tmp_path: Path) -> None: """Unreadable VMX (e.g. a directory) returns the safe ``windows`` default.""" p = tmp_path / "is_a_dir.vmx" p.mkdir() assert job_module._read_guest_os_from_vmx(p) == "windows" def test_apply_vmx_overrides_noop_when_both_zero(tmp_path: Path) -> None: vmx = tmp_path / "x.vmx" vmx.write_text('numvcpus = "1"\n', encoding="utf-8") job_module._apply_vmx_overrides(vmx, cpu=0, memory_mb=0) assert vmx.read_text(encoding="utf-8") == 'numvcpus = "1"\n' def test_parse_extra_env_json_invalid_json_raises() -> None: with pytest.raises(click.exceptions.UsageError): job_module._parse_extra_env_json("{not json") def test_parse_extra_env_json_coerces_value_types() -> None: out = job_module._parse_extra_env_json('{"A":null,"B":42,"C":"x"}') assert out == {"A": "", "B": "42", "C": "x"} def test_parse_extra_env_json_rejects_non_string_key() -> None: # JSON keys are always strings, so coerce via raw call to guarantee branch. with pytest.raises(click.exceptions.UsageError): job_module._parse_extra_env_json('{"1bad":"x"}') def test_job_wait_running_swallows_backend_error( monkeypatch: pytest.MonkeyPatch, ) -> None: from ci_orchestrator.backends.errors import BackendError from ci_orchestrator.backends.protocol import VmHandle class _Boom: def is_running(self, _h: VmHandle) -> bool: raise BackendError("nope") monkeypatch.setattr(job_module.time, "sleep", lambda _s: None) seq = iter([0.0, 1.0]) monkeypatch.setattr(job_module, "_now", lambda: next(seq)) assert ( job_module._wait_running(_Boom(), VmHandle("x"), deadline=0.5, poll=0.001) # type: ignore[arg-type] is False ) def test_job_wait_for_ip_returns_none_on_backend_error( monkeypatch: pytest.MonkeyPatch, ) -> None: from ci_orchestrator.backends.errors import BackendError from ci_orchestrator.backends.protocol import VmHandle class _Boom: def get_ip(self, _h: VmHandle, timeout: float = 0.0) -> str | None: raise BackendError("ip nope") monkeypatch.setattr(job_module.time, "sleep", lambda _s: None) seq = iter([0.0, 1.0]) monkeypatch.setattr(job_module, "_now", lambda: next(seq)) assert ( job_module._wait_for_ip(_Boom(), VmHandle("x"), deadline=0.5, poll=0.001) # type: ignore[arg-type] is None ) # ── _inject_guestinfo_ip ───────────────────────────────────────────────────── def test_inject_guestinfo_ip_appends_lines(tmp_path: Path) -> None: vmx = tmp_path / "test.vmx" vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") job_module._inject_guestinfo_ip(vmx, "192.168.79.201", "255.255.255.0", "192.168.79.2") content = vmx.read_text() assert 'guestinfo.ip-assignment = "192.168.79.201"' in content assert 'guestinfo.netmask = "255.255.255.0"' in content assert 'guestinfo.gateway = "192.168.79.2"' in content assert 'guestOS = "ubuntu-64"' in content def test_inject_guestinfo_ip_replaces_existing(tmp_path: Path) -> None: vmx = tmp_path / "test.vmx" vmx.write_text( 'guestOS = "ubuntu-64"\n' 'guestinfo.ip-assignment = "192.168.79.100"\n', encoding="utf-8", ) job_module._inject_guestinfo_ip(vmx, "192.168.79.201", "255.255.255.0", "") content = vmx.read_text() assert "192.168.79.100" not in content assert 'guestinfo.ip-assignment = "192.168.79.201"' in content def test_inject_guestinfo_ip_omits_empty_gateway(tmp_path: Path) -> None: vmx = tmp_path / "test.vmx" vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") job_module._inject_guestinfo_ip(vmx, "192.168.79.201", "255.255.255.0", "") assert "guestinfo.gateway" not in vmx.read_text() # ── job with ip_pool ───────────────────────────────────────────────────────── def test_job_with_ip_pool_uses_preassigned_ip( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """When ip_pool is configured the job skips _wait_for_ip and uses the pool IP. IP pool is Windows-only (guestinfo injection has no effect on Linux VMware Tools), so this test uses a Windows VMX. """ from ci_orchestrator.config import Config, IpPoolConfig from ci_orchestrator.ip_pool import IpSlotPool # Windows VMX — pool guard requires guest_os != "linux". win_vmx = tmp_path / "templates" / "WinBuild2025.vmx" win_vmx.parent.mkdir(parents=True) win_vmx.write_text('guestOS = "windows10srv-64"\n', encoding="utf-8") backend = _FakeBackend(running_after=1, clone_guest_os="windows10srv-64") calls = _patch_common(monkeypatch, backend) pool_addresses = ["192.168.79.201"] lease_file = tmp_path / "ip-pool.json" pool_cfg = IpPoolConfig( addresses=pool_addresses, netmask="255.255.255.0", gateway="192.168.79.2", lease_file=lease_file, ) # Patch load_config to return a config with ip_pool set. real_cfg = job_module.load_config() patched_cfg = Config( paths=real_cfg.paths, backend=real_cfg.backend, vmrun_path=real_cfg.vmrun_path, ssh_key_path=real_cfg.ssh_key_path, guest_cred_target=real_cfg.guest_cred_target, ip_pool=pool_cfg, ) monkeypatch.setattr(job_module, "load_config", lambda: patched_cfg) base_clone = tmp_path / "build-vms" base_artifact = tmp_path / "artifacts" result = CliRunner().invoke( cli, _common_args(win_vmx, clone_base=base_clone, artifact_base=base_artifact) ) assert result.exit_code == 0, result.output # backend.get_ip was NOT called — IP came from pool. assert "get_ip" not in backend.calls # The pre-assigned IP appears in output. assert "192.168.79.201" in result.output assert "pre-assigned" in result.output # Pool slot was released after job. released = IpSlotPool(pool_addresses, "255.255.255.0", "", lease_file)._read_leases() assert released["192.168.79.201"] is None # Build was invoked with the pool IP via the Windows branch. assert calls["windows_build"][0]["ip_address"] == "192.168.79.201" def test_destroy_clone_releases_pool_ip(tmp_path: Path) -> None: from ci_orchestrator.ip_pool import IpSlotPool lease = tmp_path / "pool.json" pool = IpSlotPool(["10.0.0.1"], "255.255.255.0", "", lease) ip = pool.acquire("owner") job_module._destroy_clone(None, None, None, pool=pool, pool_ip=ip) leases = pool._read_leases() assert leases["10.0.0.1"] is None # ── _probe_transport failure paths ────────────────────────────────────────── def test_probe_transport_windows_transport_error_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """WinRM raises TransportError every attempt → deadline passes → False.""" from ci_orchestrator.transport.errors import TransportError class _FailWinRm: def __enter__(self) -> _FailWinRm: raise TransportError("connection refused") def __exit__(self, *_: object) -> None: return None def is_ready(self) -> bool: return False monkeypatch.setattr(job_module, "WinRmTransport", lambda *a, **k: _FailWinRm()) monkeypatch.setattr(job_module.time, "sleep", lambda _s: None) class _Store: def get(self, _t: str) -> Any: from ci_orchestrator.credentials import Credential return Credential("ci", "pwd") monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _Store()) # Two-tick clock: first call returns time before deadline, second after. seq = iter([0.0, 2.0]) monkeypatch.setattr(job_module, "_now", lambda: next(seq)) result = job_module._probe_transport( guest_os="windows", ip_address="10.0.0.1", deadline=1.0, poll=0.001, credential_target="GiteaPAT", ssh_user="ci_build", ssh_key_path=None, ssh_known_hosts=None, ) assert result is False def test_probe_transport_linux_transport_error_returns_false( monkeypatch: pytest.MonkeyPatch, ) -> None: """SSH raises TransportError every attempt → deadline passes → False.""" from ci_orchestrator.transport.errors import TransportError class _FailSsh: def __enter__(self) -> _FailSsh: raise TransportError("ssh refused") def __exit__(self, *_: object) -> None: return None def is_ready(self) -> bool: return False monkeypatch.setattr(job_module, "SshTransport", lambda *a, **k: _FailSsh()) monkeypatch.setattr(job_module.time, "sleep", lambda _s: None) seq = iter([0.0, 2.0]) monkeypatch.setattr(job_module, "_now", lambda: next(seq)) result = job_module._probe_transport( guest_os="linux", ip_address="10.0.0.1", deadline=1.0, poll=0.001, credential_target="GiteaPAT", ssh_user="ci_build", ssh_key_path=None, ssh_known_hosts=None, ) assert result is False # ── _inject_guestinfo_ip empty netmask / gateway ──────────────────────────── def test_inject_guestinfo_ip_omits_empty_netmask(tmp_path: Path) -> None: """netmask='' skips the guestinfo.netmask line.""" vmx = tmp_path / "test.vmx" vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") job_module._inject_guestinfo_ip(vmx, "10.0.0.5", "", "10.0.0.1") content = vmx.read_text() assert "guestinfo.netmask" not in content assert 'guestinfo.ip-assignment = "10.0.0.5"' in content assert 'guestinfo.gateway = "10.0.0.1"' in content def test_inject_guestinfo_ip_omits_both_netmask_and_gateway(tmp_path: Path) -> None: """netmask='' and gateway='' → only ip-assignment line written.""" vmx = tmp_path / "test.vmx" vmx.write_text('guestOS = "ubuntu-64"\n', encoding="utf-8") job_module._inject_guestinfo_ip(vmx, "10.0.0.5", "", "") content = vmx.read_text() assert "guestinfo.netmask" not in content assert "guestinfo.gateway" not in content assert 'guestinfo.ip-assignment = "10.0.0.5"' in content # ── _redact_url_creds ─────────────────────────────────────────────────────── def test_redact_url_creds_masks_credentials() -> None: url = "https://user:secret@gitea.local/org/repo.git" redacted = job_module._redact_url_creds(url) assert "secret" not in redacted assert "***" in redacted assert "gitea.local" in redacted def test_redact_url_creds_noop_when_no_creds() -> None: url = "https://gitea.local/org/repo.git" assert job_module._redact_url_creds(url) == url # ── _host_clone ────────────────────────────────────────────────────────────── def test_host_clone_success(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """_host_clone returns (tmp_root, src_dir) on subprocess success.""" import subprocess call_log: list[list[str]] = [] def _fake_run(cmd: list[str], **kwargs: Any) -> Any: call_log.append(cmd) # Create the src_dir so the function can succeed. if cmd[0] == "git" and cmd[1] == "clone": src = Path(cmd[-1]) src.mkdir(parents=True, exist_ok=True) result = subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") return result monkeypatch.setattr(job_module.subprocess, "run", _fake_run) tmp_root, _src_dir = job_module._host_clone( repo_url="http://gitea.local/org/repo.git", branch="main", commit="", submodules=False, ) try: assert _src_dir.name == "src" assert _src_dir.parent == tmp_root assert "git" in call_log[0] assert "clone" in call_log[0] # No fetch/checkout when commit is empty. assert len(call_log) == 1 finally: import shutil shutil.rmtree(tmp_root, ignore_errors=True) def test_host_clone_with_commit(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """_host_clone issues fetch + checkout when commit is non-empty.""" import subprocess call_log: list[list[str]] = [] def _fake_run(cmd: list[str], **kwargs: Any) -> Any: call_log.append(cmd) if cmd[0] == "git" and cmd[1] == "clone": src = Path(cmd[-1]) src.mkdir(parents=True, exist_ok=True) return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") monkeypatch.setattr(job_module.subprocess, "run", _fake_run) tmp_root, _src_dir2 = job_module._host_clone( repo_url="http://gitea.local/org/repo.git", branch="main", commit="abc123", submodules=True, ) import shutil shutil.rmtree(tmp_root, ignore_errors=True) # clone + fetch + checkout = 3 calls. assert len(call_log) == 3 assert "fetch" in call_log[1] assert "checkout" in call_log[2] # submodules flag included. assert "--recurse-submodules" in call_log[0] def test_host_clone_called_process_error_raises_click_exception( monkeypatch: pytest.MonkeyPatch, ) -> None: """CalledProcessError from git → ClickException with redacted message.""" import subprocess def _fail_run(cmd: list[str], **kwargs: Any) -> Any: raise subprocess.CalledProcessError( 1, cmd, output="", stderr="fatal: repository not found" ) monkeypatch.setattr(job_module.subprocess, "run", _fail_run) with pytest.raises(click.ClickException) as exc_info: job_module._host_clone( repo_url="http://user:pass@gitea.local/org/repo.git", branch="main", commit="", submodules=False, ) assert "host-side git clone failed" in str(exc_info.value) # URL credentials must be redacted. assert "pass" not in str(exc_info.value) # ── _destroy_clone pool release raises ────────────────────────────────────── def test_destroy_clone_pool_release_raises_echoes_warning( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """pool.release raising must not propagate — warning echoed instead.""" from ci_orchestrator.ip_pool import IpSlotPool class _BrokenPool(IpSlotPool): def release(self, ip: str) -> None: raise RuntimeError("lock file busy") lease = tmp_path / "pool.json" pool = _BrokenPool(["10.0.0.1"], "255.255.255.0", "", lease) pool.acquire("owner") # mark as leased # Must not raise. job_module._destroy_clone(None, None, None, pool=pool, pool_ip="10.0.0.1") # Warning should have been echoed to stderr. captured = capsys.readouterr() assert "release failed" in captured.err or "release" in captured.err # ── CLI: BackendNotAvailable → ClickException ─────────────────────────────── def test_job_backend_not_available( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """load_backend raising BackendNotAvailable → ClickException with message.""" from ci_orchestrator.backends.errors import BackendNotAvailable backend = _FakeBackend() _patch_common(monkeypatch, backend) # Override load_backend AFTER _patch_common so it takes precedence. monkeypatch.setattr( job_module, "load_backend", lambda _cfg: (_ for _ in ()).throw(BackendNotAvailable("vmrun not found")), ) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "backend unavailable" in result.output # ── CLI: clone VMX missing after successful clone ──────────────────────────── class _FakeBackendNoVmx(_FakeBackend): """clone_linked succeeds but never writes the VMX file.""" def clone_linked( self, template: str, snapshot: str, name: str, destination: str | None = None, ) -> VmHandle: self.calls.append("clone") assert destination is not None # Intentionally do NOT write any file. return VmHandle(identifier=destination or "", name=name) def test_job_clone_vmx_missing_after_success( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """backend.clone_linked returns but VMX is absent → ClickException.""" backend = _FakeBackendNoVmx() _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "backend reported success but clone VMX is missing" in result.output # ── CLI: --vmrun-path override ─────────────────────────────────────────────── def test_job_vmrun_path_creates_workstation_backend( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """--vmrun-path bypasses load_backend and uses WorkstationVmrunBackend.""" import ci_orchestrator.backends.workstation as _ws_mod backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend) captured_vmrun_paths: list[str] = [] class _FakeWorkstation(_FakeBackend): def __init__(self, vmrun_path: str) -> None: super().__init__(running_after=0) captured_vmrun_paths.append(vmrun_path) monkeypatch.setattr(_ws_mod, "WorkstationVmrunBackend", _FakeWorkstation) fake_vmrun = tmp_path / "vmrun.exe" fake_vmrun.write_text("fake", encoding="utf-8") result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art", extra=["--vmrun-path", str(fake_vmrun)], ), ) assert result.exit_code == 0, result.output assert captured_vmrun_paths == [str(fake_vmrun)] # ── CLI: --guest-cpu / --guest-memory-mb override on Linux branch ──────────── def test_job_linux_vmx_override_cpu_memory( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """--guest-cpu and --guest-memory-mb are applied to the Linux clone VMX.""" backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend) # We capture the clone VMX path so we can check it was modified. written_vmx: list[Path] = [] real_apply = job_module._apply_vmx_overrides def _spy(vmx_path: Path, cpu: int, memory_mb: int) -> None: written_vmx.append(vmx_path) real_apply(vmx_path, cpu, memory_mb) monkeypatch.setattr(job_module, "_apply_vmx_overrides", _spy) result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art", extra=["--guest-cpu", "4", "--guest-memory-mb", "2048"], ), ) assert result.exit_code == 0, result.output assert len(written_vmx) == 1, "_apply_vmx_overrides should have been called once" # ── CLI: _wait_for_ip returns None → timeout error ─────────────────────────── def test_job_wait_for_ip_timeout( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """When _wait_for_ip returns None the job raises 'timeout waiting for guest IP'.""" backend = _FakeBackend(running_after=0, ip=None) _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "timeout waiting for guest IP" in result.output assert "delete" in backend.calls # ── CLI: transport probe times out ────────────────────────────────────────── def test_job_transport_probe_timeout( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """_probe_transport returning False → 'timeout waiting for guest transport'.""" backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend, transport_ready=False) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "timeout waiting for guest transport" in result.output assert "delete" in backend.calls # ── CLI: --host-clone flag exercises _host_clone ──────────────────────────── def test_job_host_clone_flag( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """--host-clone invokes _host_clone and passes host_source_dir to build.""" backend = _FakeBackend(running_after=0) calls = _patch_common(monkeypatch, backend) src_root = tmp_path / "hostclone" src_dir = src_root / "src" src_dir.mkdir(parents=True) def _fake_host_clone( repo_url: str, branch: str, commit: str, submodules: bool ) -> tuple[Path, Path]: return src_root, src_dir monkeypatch.setattr(job_module, "_host_clone", _fake_host_clone) result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art", extra=["--host-clone"], ), ) assert result.exit_code == 0, result.output assert calls["linux_build"][0]["host_source_dir"] == str(src_dir) assert calls["linux_build"][0]["clone_url"] is None # ── CLI: ip_pool configured but guest_os is linux → pool skipped ───────────── def test_job_ip_pool_skipped_for_linux( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """ip_pool configured but Linux guest → pool not used (line 523 guard).""" from ci_orchestrator.config import Config, IpPoolConfig backend = _FakeBackend(running_after=0) calls = _patch_common(monkeypatch, backend) pool_cfg = IpPoolConfig( addresses=["192.168.1.100"], netmask="255.255.255.0", gateway="192.168.1.1", lease_file=tmp_path / "ip-pool.json", ) real_cfg = job_module.load_config() patched_cfg = Config( paths=real_cfg.paths, backend=real_cfg.backend, vmrun_path=real_cfg.vmrun_path, ssh_key_path=real_cfg.ssh_key_path, guest_cred_target=real_cfg.guest_cred_target, ip_pool=pool_cfg, ) monkeypatch.setattr(job_module, "load_config", lambda: patched_cfg) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code == 0, result.output # Pool was not used → backend.get_ip was called normally. assert calls["linux_build"], "linux_build should have been invoked" assert "pre-assigned" not in result.output # ── CLI: KeyboardInterrupt → sys.exit(130) ────────────────────────────────── def test_job_keyboard_interrupt_triggers_cleanup( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """KeyboardInterrupt during probe → _destroy_clone called, sys.exit(130).""" backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend) def _raise_kbi(**kwargs: Any) -> bool: raise KeyboardInterrupt() monkeypatch.setattr(job_module, "_probe_transport", _raise_kbi) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code == 130 assert "delete" in backend.calls # ── CLI: ClickException → _destroy_clone called ───────────────────────────── def test_job_click_exception_triggers_cleanup( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """Any ClickException inside try → cleanup in except branch (line 557).""" backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend) def _raise_click(**kwargs: Any) -> bool: raise click.ClickException("forced error") monkeypatch.setattr(job_module, "_probe_transport", _raise_click) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "forced error" in result.output assert "delete" in backend.calls # ── CLI: generic Exception → wrapped as ClickException ────────────────────── def test_job_generic_exception_wrapped( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """Unhandled Exception inside try → ClickException (line 568).""" backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend) def _boom(**kwargs: Any) -> bool: raise RuntimeError("unexpected boom") monkeypatch.setattr(job_module, "_probe_transport", _boom) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "unexpected boom" in result.output assert "delete" in backend.calls # ── CLI: _write_manifest raises OSError → warning, job continues ───────────── def test_job_manifest_oserror_warning( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """OSError from _write_manifest → warning echoed, job still exits 0.""" backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend) def _bad_manifest(host_dir: Path, job_id: str, commit: str) -> int: raise OSError("disk full") monkeypatch.setattr(job_module, "_write_manifest", _bad_manifest) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code == 0, result.output assert "manifest write failed" in result.output # ── CLI: HTTP URL + Gitea credential → authed URL built (lines 668-669) ────── def test_job_gitea_credential_injected_into_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """Gitea credential from keyring is embedded in the clone URL.""" backend = _FakeBackend(running_after=0) calls = _patch_common(monkeypatch, backend) # _patch_common already monkeypatches KeyringCredentialStore to return # Credential("ci", "pwd"). Verify the authed URL reaches _linux_build. result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code == 0, result.output clone_url = calls["linux_build"][0]["clone_url"] assert clone_url is not None assert "ci:pwd@" in clone_url # ── _probe_transport: is_ready() returns False (no exception) ──────────────── def test_probe_transport_winrm_not_ready_then_timeout( monkeypatch: pytest.MonkeyPatch, ) -> None: """WinRM transport connects but is_ready()=False → deadline → returns False.""" class _NotReadyWinRm: def __enter__(self) -> _NotReadyWinRm: return self def __exit__(self, *_: object) -> None: return None def is_ready(self) -> bool: return False monkeypatch.setattr(job_module, "WinRmTransport", lambda *a, **k: _NotReadyWinRm()) monkeypatch.setattr(job_module.time, "sleep", lambda _s: None) class _Store: def get(self, _t: str) -> Any: from ci_orchestrator.credentials import Credential return Credential("ci", "pwd") monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _Store()) # Two-tick clock: first iteration is before deadline, second is after. seq = iter([0.0, 2.0]) monkeypatch.setattr(job_module, "_now", lambda: next(seq)) result = job_module._probe_transport( guest_os="windows", ip_address="10.0.0.1", deadline=1.0, poll=0.001, credential_target="GiteaPAT", ssh_user="ci_build", ssh_key_path=None, ssh_known_hosts=None, ) assert result is False # ── _destroy_clone: backend.delete raises BackendError ─────────────────────── def test_destroy_clone_delete_backend_error_echoes_warning( tmp_path: Path, capsys: pytest.CaptureFixture[str] ) -> None: """BackendError from backend.delete → warning echoed, no exception raised.""" backend = _FakeBackend(delete_fail=True) handle = VmHandle(identifier="/tmp/fake.vmx", name="fake") # Must not raise. job_module._destroy_clone(backend, handle, None) # type: ignore[arg-type] captured = capsys.readouterr() assert "deleteVM failed" in captured.err or "deleteVM" in captured.err # ── _destroy_clone: _try_remove_dir returns False (partial removal) ────────── def test_destroy_clone_try_remove_dir_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: """_try_remove_dir returning False → warning echoed.""" monkeypatch.setattr(job_module, "_try_remove_dir", lambda _p: False) clone_dir = tmp_path / "clone" clone_dir.mkdir() job_module._destroy_clone(None, None, clone_dir) captured = capsys.readouterr() assert "could not fully remove" in captured.err # ── CLI: explicit --guest-os linux skips auto-detection (line 523) ─────────── def test_job_explicit_guest_os_linux( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """--guest-os linux takes the else branch (no auto-detect) and lowercases.""" backend = _FakeBackend(running_after=0) calls = _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art", extra=["--guest-os", "linux"], ), ) assert result.exit_code == 0, result.output # auto-detect message must NOT appear. assert "auto-detected" not in result.output assert calls["linux_build"] # ── CLI: backend.start raises BackendError (lines 545-546) ────────────────── def test_job_start_backend_error( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """BackendError from backend.start → ClickException 'vmrun start failed'.""" backend = _FakeBackend(start_fail=True) _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "vmrun start failed" in result.output assert "delete" in backend.calls # ── CLI: _wait_running returns False → timeout (line 550) ──────────────────── def test_job_wait_running_timeout( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """_wait_running returning False → ClickException 'timeout waiting for VM to be running'.""" backend = _FakeBackend(running_after=999) # is_running always False _patch_common(monkeypatch, backend) # Make _wait_running always return False immediately. monkeypatch.setattr(job_module, "_wait_running", lambda *a, **k: False) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code != 0 assert "timeout waiting for VM to be running" in result.output assert "delete" in backend.calls # ── CLI: ssh_key_path from config used when not specified on CLI ────────────── def test_job_ssh_key_from_config( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """Config.ssh_key_path is used as key_path when --ssh-key-path is not given (line 568).""" from ci_orchestrator.config import Config backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend) ssh_key = tmp_path / "id_rsa" ssh_key.write_text("fake-key", encoding="utf-8") real_cfg = job_module.load_config() patched_cfg = Config( paths=real_cfg.paths, backend=real_cfg.backend, vmrun_path=real_cfg.vmrun_path, ssh_key_path=ssh_key, guest_cred_target=real_cfg.guest_cred_target, ip_pool=real_cfg.ip_pool, ) monkeypatch.setattr(job_module, "load_config", lambda: patched_cfg) probe_kwargs: list[dict[str, Any]] = [] def _spy_probe(**kwargs: Any) -> bool: probe_kwargs.append(kwargs) return True monkeypatch.setattr(job_module, "_probe_transport", _spy_probe) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code == 0, result.output assert probe_kwargs[0]["ssh_key_path"] == str(ssh_key) # ── CLI: Gitea credential get raises → URL used unauthenticated (line 435-436) ─ def test_job_gitea_credential_raises_uses_unauthenticated( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """KeyringCredentialStore.get raising → except pass → unauthenticated URL.""" backend = _FakeBackend(running_after=0) calls = _patch_common(monkeypatch, backend) class _FailStore: def get(self, _t: str) -> Any: raise RuntimeError("keyring unavailable") monkeypatch.setattr(job_module, "KeyringCredentialStore", lambda: _FailStore()) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code == 0, result.output # URL falls back to unauthenticated — no user:pwd@ prefix. clone_url = calls["linux_build"][0]["clone_url"] assert clone_url == "http://gitea.local/org/repo.git" # ── CLI: non-HTTP repo URL skips credential injection (line 425->438) ───────── def test_job_non_http_repo_url_skips_credential_injection( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """git:// URL skips the credential-injection block entirely.""" backend = _FakeBackend(running_after=0) calls = _patch_common(monkeypatch, backend) non_http_url = "git://gitea.local/org/repo.git" args = [ "job", "--job-id", "ci-test-git", "--repo-url", non_http_url, "--branch", "main", "--template-path", str(template_vmx), "--clone-base-dir", str(tmp_path / "vms"), "--artifact-base-dir", str(tmp_path / "art"), "--ready-timeout", "30", "--poll-interval", "1", ] result = CliRunner().invoke(cli, args) assert result.exit_code == 0, result.output # Clone URL must be the original, unmodified URL. clone_url = calls["linux_build"][0]["clone_url"] assert clone_url == non_http_url # ── CLI: missing --template-path → ClickException (line 440) ───────────────── def test_job_missing_template_path_raises( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """Omitting --template-path raises 'template-path is required'.""" backend = _FakeBackend() _patch_common(monkeypatch, backend) args = [ "job", "--job-id", "ci-test-ntp", "--repo-url", "http://gitea.local/org/repo.git", "--branch", "main", "--clone-base-dir", str(tmp_path / "vms"), "--artifact-base-dir", str(tmp_path / "art"), "--ready-timeout", "30", "--poll-interval", "1", ] result = CliRunner().invoke(cli, args) assert result.exit_code != 0 assert "--template-path is required" in result.output # ── CLI: --commit echoes commit line (line 488) ─────────────────────────────── def test_job_commit_echoed_in_header( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """When --commit is given the header echoes it (covers the if commit: branch).""" backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend) result = CliRunner().invoke( cli, _common_args( template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art", extra=["--commit", "deadbeef"], ), ) assert result.exit_code == 0, result.output assert "deadbeef" in result.output # ── CLI: SIGTERM handler fires KeyboardInterrupt (line 479) ────────────────── def test_job_sigterm_triggers_keyboard_interrupt( monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path ) -> None: """SIGTERM handler raises KeyboardInterrupt — invoke it directly to cover line 479.""" import signal as _signal backend = _FakeBackend(running_after=0) _patch_common(monkeypatch, backend) registered_handler: list[Any] = [] real_signal = _signal.signal def _capture_signal(sig: int, handler: Any) -> Any: if sig == _signal.SIGTERM: registered_handler.append(handler) return real_signal(sig, handler) monkeypatch.setattr(_signal, "signal", _capture_signal) # Also make probe raise so the job exits early (keeping test fast). monkeypatch.setattr(job_module, "_probe_transport", lambda **k: True) result = CliRunner().invoke( cli, _common_args(template_vmx, clone_base=tmp_path / "vms", artifact_base=tmp_path / "art"), ) assert result.exit_code == 0, result.output # Now directly invoke the SIGTERM handler to cover line 479. assert registered_handler, "signal.signal(SIGTERM, ...) should have been called" with pytest.raises(KeyboardInterrupt): registered_handler[-1](_signal.SIGTERM, None)