feat(a4): port Invoke-CIJob.ps1 to ci_orchestrator job
Phase A4 of plans/implementation-plan-A-B.md. Implements the full job orchestrator (clone -> start -> wait -> probe -> build -> collect -> guaranteed cleanup) as a new commands/job.py click command, registered under python -m ci_orchestrator job. Backend selection goes through backends.load_backend(config) so Phase C can swap in remote drivers without touching the command. The legacy scripts/Invoke-CIJob.ps1 is replaced by a thin PS 5.1 shim that delegates to the Python CLI; tests/python/test_commands_job.py adds 13 cases covering Linux/Windows happy paths, override application, skip-artifact, and cleanup on every failure mode.
This commit is contained in:
@@ -0,0 +1,394 @@
|
||||
"""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)
|
||||
|
||||
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_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 correct artifact path instead.
|
||||
assert calls["windows_collect"][0]["guest_path"].endswith("artifacts.zip")
|
||||
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("[]")
|
||||
Reference in New Issue
Block a user