3eb29b404f
- Introduced a new test to verify behavior when SSH transport connects but is not ready, ensuring it returns False after the deadline. - Added comprehensive tests for the `deploy-linux` template, covering various scenarios including missing public keys, VM directory existence, SHA256 mismatches, and failures during ISO creation and VM conversion. - Implemented tests for SSH-related functions, including connection retries and streaming command execution, ensuring proper handling of exit statuses and progress indications. - Enhanced existing tests to cover edge cases and ensure robustness in the deployment process.
1633 lines
56 KiB
Python
1633 lines
56 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: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)
|
|
# 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
|