Add coverage gap tests for command functionalities
- Implement tests for disk alert JSON output to include a message field when below threshold. - Add tests for runner status when Gitea is online but no webhook URL is provided. - Introduce tests for job report details to ensure no error line appears when there are no failure events. - Add tests to skip empty JSONL files in job report listing.
This commit is contained in:
@@ -653,3 +653,934 @@ def test_destroy_clone_releases_pool_ip(tmp_path: Path) -> None:
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user