Files
local-ci-cd-system/tests/python/test_commands_job.py
T
Simone dc89427164 feat(cache): --cargo-cache flag and [cargo_cache] config section
Default on, because the cache is safe by construction rather than by
convention: it engages only where a Cargo.lock exists, and refuses to publish
anything the prune assertion cannot vouch for. Two independent off switches --
the flag for one job, `enabled = false` for the host.

The store root is resolved after the environment overrides are applied, so
CI_ROOT relocates it along with everything else, while an explicit root in the
config still wins. Both directions are covered by tests.

Also asserts that the Windows branch never receives the argument at all: the
cache is Linux-only and the two branches stay separate, per AGENTS.md.
2026-07-22 21:02:35 +02:00

1726 lines
60 KiB
Python

"""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:[email protected]/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:[email protected]/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)
# Bypass the real polling loop — we test the consequence, not the loop.
monkeypatch.setattr(job_module, "_wait_for_ip", lambda *_a, **_k: None)
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)
# Bypass the real probe loop — we test the consequence, not the loop.
monkeypatch.setattr(job_module, "_probe_transport", lambda **_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 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)
# ── _probe_transport: SSH connects but is_ready()=False ──────────────────────
def test_probe_transport_ssh_not_ready_returns_false(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""SSH transport __enter__ succeeds but is_ready()=False → deadline passes → False.
Covers the ``time.sleep(poll)`` at line 189 in the SSH branch of
_probe_transport (branch 185->189).
"""
class _NotReadySsh:
def __enter__(self) -> _NotReadySsh:
return self
def __exit__(self, *_: object) -> None:
return None
def is_ready(self) -> bool:
return False
monkeypatch.setattr(job_module, "SshTransport", lambda *a, **k: _NotReadySsh())
monkeypatch.setattr(job_module.time, "sleep", lambda _s: None)
# Two-tick clock: first iteration inside deadline, second outside.
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="",
ssh_user="ci_build",
ssh_key_path=None,
ssh_known_hosts=None,
)
assert result is False
# ── cargo cache plumbing ─────────────────────────────────────────────────────
def test_job_passes_cache_root_to_linux_build(
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 / "build-vms",
artifact_base=tmp_path / "artifacts",
),
)
assert result.exit_code == 0, result.output
assert calls["linux_build"][0]["cargo_cache_root"] is not None
def test_job_no_cargo_cache_disables_it(
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 / "build-vms",
artifact_base=tmp_path / "artifacts",
extra=["--no-cargo-cache"],
),
)
assert result.exit_code == 0, result.output
assert calls["linux_build"][0]["cargo_cache_root"] is None
def test_job_respects_config_disabling_the_cache(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, template_vmx: Path
) -> None:
"""`enabled = false` in the config wins even without --no-cargo-cache."""
from ci_orchestrator.config import CargoCacheConfig, Config
backend = _FakeBackend(running_after=1)
calls = _patch_common(monkeypatch, backend)
# _patch_common already replaced load_config, so override its return value
# rather than the environment.
base = job_module.load_config()
patched = Config(
paths=base.paths,
backend=base.backend,
vmrun_path=base.vmrun_path,
ssh_key_path=base.ssh_key_path,
guest_cred_target=base.guest_cred_target,
ip_pool=base.ip_pool,
cargo_cache=CargoCacheConfig(enabled=False),
)
monkeypatch.setattr(job_module, "load_config", lambda: patched)
result = CliRunner().invoke(
cli,
_common_args(
template_vmx,
clone_base=tmp_path / "build-vms",
artifact_base=tmp_path / "artifacts",
),
)
assert result.exit_code == 0, result.output
assert calls["linux_build"][0]["cargo_cache_root"] is None
def test_job_windows_build_gets_no_cargo_cache_argument(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The Windows branch must be untouched by the Linux-only cache."""
# Guest OS comes from the *clone* VMX, which the fake backend writes.
backend = _FakeBackend(running_after=1, clone_guest_os="windows2019srvNext-64")
calls = _patch_common(monkeypatch, backend)
vmx = tmp_path / "templates" / "WinBuild2025.vmx"
vmx.parent.mkdir(parents=True)
vmx.write_text('guestOS = "windows2019srvNext-64"\n', encoding="utf-8")
result = CliRunner().invoke(
cli,
_common_args(
vmx,
clone_base=tmp_path / "build-vms",
artifact_base=tmp_path / "artifacts",
),
)
assert result.exit_code == 0, result.output
assert "cargo_cache_root" not in calls["windows_build"][0]