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.
1726 lines
60 KiB
Python
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]
|