feat(cli): port burn-in/smoke/validate/creds to Python (Phase C1-C6)

Add four native command groups so the Linux host no longer needs pwsh for
manual ops. __main__ registers all four at once, so they ship together.

- bench run     (C2): N concurrent jobs in-process via concurrent.futures,
                      per-round orphan-clone + stale-lock assertions. Replaces
                      Test-CapacityBurnIn.ps1 + Start-BurnInTest*.ps1. No
                      Start-Job/pwsh/$IsWindows.
- bench measure (C3): clone/start/IP/transport/destroy timings appended to
                      benchmark.jsonl (legacy field names kept). Replaces
                      Measure-CIBenchmark.ps1.
- smoke run     (C4): one E2E job; asserts exit 0 + artifact dir + job/success
                      event. ns7zip/nsinnounp presets. Replaces Test-Smoke.ps1
                      + Test-*Build-Linux.ps1.
- validate host (C5): vmrun/snapshot/keyring/permissions/systemd checks.
- validate guest(C6): transport probe with explicit failure cause.
- creds set     (C6): keyring writer matching credentials.py read scheme;
                      password via stdin/prompt only. Replaces
                      Set-CIGuestCredential.ps1.

All groups import backends.load_backend only (Phase D ESXi path stays open).
Suite green, coverage 95.10% (gate 90%); new modules at 100%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 20:15:10 +02:00
parent 8f1b3b7eb1
commit d63fca5967
9 changed files with 2934 additions and 0 deletions
+542
View File
@@ -0,0 +1,542 @@
"""Tests for ``validate host`` (C5) and ``validate guest`` (C6).
CLI registration smoke tests (``--help``) are kept; behavioural tests mock
the backend, keyring, systemctl, and transports.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import pytest
from click.testing import CliRunner
import ci_orchestrator.commands.validate as validate_module
from ci_orchestrator.__main__ import cli
from ci_orchestrator.backends.errors import (
BackendNotAvailable,
BackendOperationFailed,
)
from ci_orchestrator.config import BackendConfig, Config, Paths
from ci_orchestrator.credentials import Credential
from ci_orchestrator.transport.errors import (
TransportCommandError,
TransportConnectError,
)
# ── --help smoke tests (kept) ───────────────────────────────────────────────
def test_validate_help_lists_subcommands() -> None:
result = CliRunner().invoke(cli, ["validate", "--help"])
assert result.exit_code == 0, result.output
assert "host" in result.output
assert "guest" in result.output
def test_validate_guest_help() -> None:
result = CliRunner().invoke(cli, ["validate", "guest", "--help"])
assert result.exit_code == 0, result.output
assert "--host" in result.output
# ── helpers ─────────────────────────────────────────────────────────────────
def _make_config(tmp_path: Path) -> Config:
root = tmp_path / "ci"
templates = root / "templates"
build_vms = root / "build-vms"
artifacts = root / "artifacts"
keys = root / "keys"
for p in (root, templates, build_vms, artifacts, keys):
p.mkdir(parents=True, exist_ok=True)
return Config(
paths=Paths(
root=root,
templates=templates,
build_vms=build_vms,
artifacts=artifacts,
keys=keys,
),
backend=BackendConfig(type="workstation"),
guest_cred_target="BuildVMGuest",
)
class _FakeBackend:
def __init__(self, snapshots: dict[str, list[str]] | None = None) -> None:
self.vmrun_path = "/usr/bin/vmrun"
self._snapshots = snapshots or {}
def list_snapshots(self, handle: Any) -> list[str]:
return self._snapshots.get(handle.identifier, [])
def _make_template_vmx(config: Config, name: str) -> str:
vmx_dir = config.paths.templates / name
vmx_dir.mkdir(parents=True, exist_ok=True)
vmx = vmx_dir / f"{name}.vmx"
vmx.write_text('config.version = "8"', encoding="utf-8")
return str(vmx)
# ── validate host: individual checks ────────────────────────────────────────
def test_check_vmrun_ok(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
config = _make_config(tmp_path)
monkeypatch.setattr(validate_module, "load_backend", lambda _c: _FakeBackend())
result = validate_module._check_vmrun(config)
assert result.ok
assert "vmrun" in result.detail
def test_check_vmrun_no_path_attr(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
class _NoPath:
pass
monkeypatch.setattr(validate_module, "load_backend", lambda _c: _NoPath())
result = validate_module._check_vmrun(config)
assert result.ok
assert "backend loaded" in result.detail
def test_check_vmrun_unavailable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
def _raise(_c: Any) -> Any:
raise BackendNotAvailable("vmrun missing")
monkeypatch.setattr(validate_module, "load_backend", _raise)
result = validate_module._check_vmrun(config)
assert not result.ok
assert "missing" in result.detail
def test_check_snapshots_backend_unavailable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
def _raise(_c: Any) -> Any:
raise BackendNotAvailable("no vmrun")
monkeypatch.setattr(validate_module, "load_backend", _raise)
result = validate_module._check_snapshots(config)
assert not result.ok
assert "backend unavailable" in result.detail
def test_check_snapshots_list_raises(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
_make_template_vmx(config, "LinuxBuild2404")
class _Backend:
vmrun_path = "/usr/bin/vmrun"
def list_snapshots(self, _handle: Any) -> list[str]:
raise BackendOperationFailed("listSnapshots", 1, "boom")
monkeypatch.setattr(validate_module, "load_backend", lambda _c: _Backend())
result = validate_module._check_snapshots(config)
assert not result.ok
assert "listSnapshots failed" in result.detail
def test_check_keyring_runtime_error(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
class _Store:
def get(self, target: str) -> Credential:
raise RuntimeError("keyring is not installed")
monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store)
result = validate_module._check_keyring(config)
assert not result.ok
assert "keyring is not installed" in result.detail
def test_check_permissions_not_writable(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
def _no_access(_path: Any, _mode: int) -> bool:
return False
monkeypatch.setattr(validate_module.os, "access", _no_access)
result = validate_module._check_permissions(config)
assert not result.ok
assert "not read/write" in result.detail
def test_check_snapshots_ok(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
win_vmx = _make_template_vmx(config, "WinBuild2025")
backend = _FakeBackend({win_vmx: ["BaseClean"]})
monkeypatch.setattr(validate_module, "load_backend", lambda _c: backend)
result = validate_module._check_snapshots(config)
assert result.ok, result.detail
assert "WinBuild2025->BaseClean" in result.detail
def test_check_snapshots_missing_snapshot(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
lin_vmx = _make_template_vmx(config, "LinuxBuild2404")
backend = _FakeBackend({lin_vmx: ["SomethingElse"]})
monkeypatch.setattr(validate_module, "load_backend", lambda _c: backend)
result = validate_module._check_snapshots(config)
assert not result.ok
assert "BaseClean-Linux" in result.detail
def test_check_snapshots_no_templates(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
backend = _FakeBackend({})
monkeypatch.setattr(validate_module, "load_backend", lambda _c: backend)
result = validate_module._check_snapshots(config)
assert not result.ok
assert "no known template VMX found" in result.detail
def test_check_keyring_ok(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
class _Store:
def get(self, target: str) -> Credential:
return Credential(username="ci_build", password="x")
monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store)
result = validate_module._check_keyring(config)
assert result.ok
assert "ci_build" in result.detail
def test_check_keyring_missing(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
class _Store:
def get(self, target: str) -> Credential:
raise KeyError(target)
monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store)
result = validate_module._check_keyring(config)
assert not result.ok
def test_check_permissions_ok(tmp_path: Path) -> None:
config = _make_config(tmp_path)
result = validate_module._check_permissions(config)
assert result.ok, result.detail
def test_check_permissions_missing_dir(tmp_path: Path) -> None:
config = _make_config(tmp_path)
# Remove a required dir.
config.paths.build_vms.rmdir()
result = validate_module._check_permissions(config)
assert not result.ok
assert "missing" in result.detail
def test_check_units_no_systemctl(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(validate_module.shutil, "which", lambda _x: None)
result = validate_module._check_units()
assert not result.ok
assert "systemctl" in result.detail
def test_check_units_active(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl")
monkeypatch.setattr(validate_module, "_systemctl_is_active", lambda _u: "active")
result = validate_module._check_units()
assert result.ok
def test_check_units_inactive(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl")
monkeypatch.setattr(validate_module, "_systemctl_is_active", lambda _u: "inactive")
result = validate_module._check_units()
assert not result.ok
assert "inactive" in result.detail
def test_systemctl_is_active_invokes_subprocess(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl")
class _CP:
stdout = "active\n"
monkeypatch.setattr(
validate_module.subprocess, "run", lambda *a, **k: _CP()
)
assert validate_module._systemctl_is_active("act-runner.service") == "active"
def test_systemctl_is_active_no_binary(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(validate_module.shutil, "which", lambda _x: None)
assert validate_module._systemctl_is_active("x") is None
def test_systemctl_is_active_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
import subprocess as _sp
monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl")
def _boom(*_a: Any, **_k: Any) -> Any:
raise _sp.TimeoutExpired(cmd="systemctl", timeout=10.0)
monkeypatch.setattr(validate_module.subprocess, "run", _boom)
assert validate_module._systemctl_is_active("x") is None
# ── validate host: end-to-end command ───────────────────────────────────────
def test_validate_host_all_pass(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
win_vmx = _make_template_vmx(config, "WinBuild2025")
backend = _FakeBackend({win_vmx: ["BaseClean"]})
class _Store:
def get(self, target: str) -> Credential:
return Credential("ci_build", "x")
monkeypatch.setattr(validate_module, "load_config", lambda: config)
monkeypatch.setattr(validate_module, "load_backend", lambda _c: backend)
monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store)
monkeypatch.setattr(validate_module.shutil, "which", lambda _x: "/bin/systemctl")
monkeypatch.setattr(validate_module, "_systemctl_is_active", lambda _u: "active")
result = CliRunner().invoke(cli, ["validate", "host"])
assert result.exit_code == 0, result.output
assert "All host checks passed" in result.output
def test_validate_host_reports_failures_and_exits_nonzero(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
def _raise(_c: Any) -> Any:
raise BackendNotAvailable("vmrun missing")
class _Store:
def get(self, target: str) -> Credential:
raise KeyError(target)
monkeypatch.setattr(validate_module, "load_config", lambda: config)
monkeypatch.setattr(validate_module, "load_backend", _raise)
monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store)
monkeypatch.setattr(validate_module.shutil, "which", lambda _x: None)
result = CliRunner().invoke(cli, ["validate", "host"])
assert result.exit_code == 1, result.output
assert "FAIL" in result.output
assert "check(s) failed" in result.output
# ── validate guest ──────────────────────────────────────────────────────────
def test_resolve_use_winrm_explicit() -> None:
assert validate_module._resolve_use_winrm(True, None) is True
assert validate_module._resolve_use_winrm(False, "windows") is False
def test_resolve_use_winrm_inferred() -> None:
assert validate_module._resolve_use_winrm(None, "linux") is False
assert validate_module._resolve_use_winrm(None, "windows") is True
def test_resolve_use_winrm_requires_hint() -> None:
import click as _click
with pytest.raises(_click.UsageError):
validate_module._resolve_use_winrm(None, None)
class _FakeTransport:
"""Records connect/run/close and raises a configured error on run."""
def __init__(self, *args: Any, raise_on_run: Exception | None = None, **kw: Any) -> None:
self._raise = raise_on_run
self.closed = False
def run(self, *_a: Any, **_k: Any) -> Any:
if self._raise is not None:
raise self._raise
class _R:
returncode = 0
return _R()
def close(self) -> None:
self.closed = True
def _install_keyring_store(monkeypatch: pytest.MonkeyPatch) -> None:
class _Store:
def get(self, target: str) -> Credential:
return Credential("Administrator", "pw")
monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store)
def test_probe_winrm_ok(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
config = _make_config(tmp_path)
_install_keyring_store(monkeypatch)
transport = _FakeTransport()
monkeypatch.setattr(validate_module, "WinRmTransport", lambda *a, **k: transport)
result = validate_module._probe_winrm("10.0.0.5", config)
assert result.ok
assert transport.closed
def test_probe_winrm_auth_fail(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
_install_keyring_store(monkeypatch)
transport = _FakeTransport(raise_on_run=TransportConnectError("auth denied"))
monkeypatch.setattr(validate_module, "WinRmTransport", lambda *a, **k: transport)
result = validate_module._probe_winrm("10.0.0.5", config)
assert not result.ok
assert "auth denied" in result.detail
def test_probe_winrm_command_fail(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
_install_keyring_store(monkeypatch)
transport = _FakeTransport(raise_on_run=TransportCommandError(1, "", "boom"))
monkeypatch.setattr(validate_module, "WinRmTransport", lambda *a, **k: transport)
result = validate_module._probe_winrm("10.0.0.5", config)
assert not result.ok
assert "command failed" in result.detail
def test_probe_winrm_credential_missing(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
class _Store:
def get(self, target: str) -> Credential:
raise KeyError(target)
monkeypatch.setattr(validate_module, "KeyringCredentialStore", _Store)
result = validate_module._probe_winrm("10.0.0.5", config)
assert not result.ok
assert "credential lookup failed" in result.detail
def test_probe_ssh_ok(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
config = _make_config(tmp_path)
transport = _FakeTransport()
monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport)
result = validate_module._probe_ssh("10.0.0.6", config)
assert result.ok
assert transport.closed
def test_probe_ssh_connect_fail(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
transport = _FakeTransport(raise_on_run=TransportConnectError("no route"))
monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport)
result = validate_module._probe_ssh("10.0.0.6", config)
assert not result.ok
assert "no route" in result.detail
def test_probe_ssh_command_fail(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
transport = _FakeTransport(raise_on_run=TransportCommandError(2, "", "nope"))
monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport)
result = validate_module._probe_ssh("10.0.0.6", config)
assert not result.ok
assert "command failed" in result.detail
def test_validate_guest_ssh_ok(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
transport = _FakeTransport()
monkeypatch.setattr(validate_module, "load_config", lambda: config)
monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport)
result = CliRunner().invoke(
cli, ["validate", "guest", "--host", "10.0.0.6", "--ssh"]
)
assert result.exit_code == 0, result.output
assert "OK" in result.output
def test_validate_guest_winrm_fail_exits_nonzero(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
_install_keyring_store(monkeypatch)
transport = _FakeTransport(raise_on_run=TransportConnectError("bad creds"))
monkeypatch.setattr(validate_module, "load_config", lambda: config)
monkeypatch.setattr(validate_module, "WinRmTransport", lambda *a, **k: transport)
result = CliRunner().invoke(
cli, ["validate", "guest", "--host", "10.0.0.5", "--winrm"]
)
assert result.exit_code == 1, result.output
assert "FAIL" in result.output
def test_validate_guest_requires_transport_hint(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
monkeypatch.setattr(validate_module, "load_config", lambda: config)
result = CliRunner().invoke(cli, ["validate", "guest", "--host", "10.0.0.5"])
assert result.exit_code != 0
assert "transport" in result.output.lower()
def test_validate_guest_infers_from_guest_os(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
config = _make_config(tmp_path)
transport = _FakeTransport()
monkeypatch.setattr(validate_module, "load_config", lambda: config)
monkeypatch.setattr(validate_module, "SshTransport", lambda *a, **k: transport)
result = CliRunner().invoke(
cli, ["validate", "guest", "--host", "10.0.0.6", "--guest-os", "linux"]
)
assert result.exit_code == 0, result.output