refactor(ops): add operations/cache.py + operations/host.py seam (W2)

Extracts pure, click-free cargo-cache status/purge and host disk/runner/
backup-template logic into operations/, mirroring the W1 pattern. Routes
commands/retention.py::_purge_cargo_cache through operations.cache while
preserving identical CLI output. commands/monitor.py is left untouched
(its refactor is optional per the plan; the Windows/Linux polymorphic
_service_status and the disk not-found detection don't map cleanly onto
the new pure functions without risking behavioural drift).
This commit is contained in:
2026-07-23 17:22:57 +02:00
parent f0d856b644
commit 00ec8a33b0
5 changed files with 764 additions and 14 deletions
+187
View File
@@ -0,0 +1,187 @@
"""Tests for the pure ``operations.cache`` layer (W2 of the web-UI plan).
Covers ``cargo_cache_status`` and ``purge_cargo_cache`` — the seam both the
CLI (``retention run``) and the future web UI import for cargo-cache
state/maintenance. Zero ``click``/``print`` in the module under test.
"""
from __future__ import annotations
import time
from pathlib import Path
from ci_orchestrator.cargo_cache import CargoCacheStore, cache_key
from ci_orchestrator.config import BackendConfig, CargoCacheConfig, Config, Paths
from ci_orchestrator.operations.cache import (
CacheStatus,
PurgeResult,
cargo_cache_status,
purge_cargo_cache,
)
def _make_config(root: Path, *, enabled: bool = True, retention_days: int = 30) -> Config:
return Config(
paths=Paths(
root=root.parent,
templates=root.parent / "templates",
build_vms=root.parent / "build-vms",
artifacts=root.parent / "artifacts",
keys=root.parent / "keys",
),
backend=BackendConfig(),
cargo_cache=CargoCacheConfig(enabled=enabled, root=root, retention_days=retention_days),
)
def _seed_entry(root: Path, digest: str, *, age_days: int, size: int = 1024) -> Path:
store = CargoCacheStore(root)
src = root.parent / f"seed-{digest}.tar.zst"
src.parent.mkdir(parents=True, exist_ok=True)
src.write_bytes(b"x" * size)
entry = store.publish(cache_key(lock_digest=digest, rustc_version="r", host_triple="t"), src)
stamp = time.time() - age_days * 86400
import os
os.utime(entry, (stamp, stamp))
return entry
# ── cargo_cache_status ──────────────────────────────────────────────────────
def test_cargo_cache_status_missing_root(tmp_path: Path) -> None:
root = tmp_path / "cache" / "cargo"
config = _make_config(root)
status = cargo_cache_status(config)
assert status == CacheStatus(
root=root,
total_bytes=0,
entry_count=0,
oldest_age_days=None,
retention_days=30,
)
def test_cargo_cache_status_empty_root(tmp_path: Path) -> None:
root = tmp_path / "cache" / "cargo"
root.mkdir(parents=True)
config = _make_config(root)
status = cargo_cache_status(config)
assert status.entry_count == 0
assert status.total_bytes == 0
assert status.oldest_age_days is None
def test_cargo_cache_status_populated_with_aged_entries(tmp_path: Path) -> None:
root = tmp_path / "cache" / "cargo"
_seed_entry(root, "old", age_days=90, size=2048)
_seed_entry(root, "new", age_days=1, size=1024)
config = _make_config(root, retention_days=30)
status = cargo_cache_status(config)
assert status.entry_count == 2
assert status.total_bytes == 2048 + 1024
assert status.oldest_age_days is not None
assert status.oldest_age_days >= 89
assert status.retention_days == 30
assert status.root == root
def test_cargo_cache_status_ignores_incoming_staging_dir(tmp_path: Path) -> None:
"""The ``.incoming`` staging directory used by ``publish()`` is not a real entry."""
root = tmp_path / "cache" / "cargo"
_seed_entry(root, "one", age_days=1)
(root / ".incoming").mkdir(parents=True, exist_ok=True)
config = _make_config(root)
status = cargo_cache_status(config)
assert status.entry_count == 1
def test_cargo_cache_status_reports_even_when_disabled(tmp_path: Path) -> None:
"""Status is descriptive of disk state; ``enabled`` only gates purging."""
root = tmp_path / "cache" / "cargo"
_seed_entry(root, "old", age_days=90)
config = _make_config(root, enabled=False)
status = cargo_cache_status(config)
assert status.entry_count == 1
# ── purge_cargo_cache ────────────────────────────────────────────────────────
def test_purge_cargo_cache_dry_run_counts_only_no_delete(tmp_path: Path) -> None:
root = tmp_path / "cache" / "cargo"
old = _seed_entry(root, "old", age_days=90, size=3 * 1024 * 1024)
config = _make_config(root, retention_days=30)
result = purge_cargo_cache(config, dry_run=True)
assert result == PurgeResult(removed_entries=1, freed_bytes=3 * 1024 * 1024, dry_run=True)
assert old.exists(), "dry_run must not delete anything"
def test_purge_cargo_cache_real_purge_removes_only_old(tmp_path: Path) -> None:
root = tmp_path / "cache" / "cargo"
old = _seed_entry(root, "old", age_days=90)
fresh = _seed_entry(root, "new", age_days=1)
config = _make_config(root, retention_days=30)
result = purge_cargo_cache(config)
assert result.removed_entries == 1
assert result.dry_run is False
assert not old.exists()
assert fresh.exists()
def test_purge_cargo_cache_retention_override_wins_over_config(tmp_path: Path) -> None:
root = tmp_path / "cache" / "cargo"
entry = _seed_entry(root, "mid", age_days=10)
config = _make_config(root, retention_days=30) # config alone would spare this entry
result = purge_cargo_cache(config, retention_days=7)
assert result.removed_entries == 1
assert not entry.exists()
def test_purge_cargo_cache_disabled_returns_zero_and_does_not_touch_disk(tmp_path: Path) -> None:
root = tmp_path / "cache" / "cargo"
old = _seed_entry(root, "old", age_days=90)
config = _make_config(root, enabled=False, retention_days=30)
result = purge_cargo_cache(config)
assert result == PurgeResult(removed_entries=0, freed_bytes=0, dry_run=False)
assert old.exists()
def test_purge_cargo_cache_nothing_to_purge(tmp_path: Path) -> None:
root = tmp_path / "cache" / "cargo"
fresh = _seed_entry(root, "new", age_days=1)
config = _make_config(root, retention_days=30)
result = purge_cargo_cache(config)
assert result.removed_entries == 0
assert result.freed_bytes == 0
assert fresh.exists()
def test_purge_cargo_cache_missing_root_is_not_an_error(tmp_path: Path) -> None:
root = tmp_path / "cache" / "cargo"
config = _make_config(root, retention_days=30)
result = purge_cargo_cache(config)
assert result == PurgeResult(removed_entries=0, freed_bytes=0, dry_run=False)
+282
View File
@@ -0,0 +1,282 @@
"""Tests for the pure ``operations.host`` layer (W2 of the web-UI plan).
Covers ``disk_status``, ``runner_status`` and ``backup_template`` — the seam
both the CLI (``monitor disk``/``monitor runner``/``template backup``) and
the future web UI import. Zero ``click``/``print`` in the module under test;
no real ``systemctl``/``tar``/``7z`` is ever invoked — everything is
monkeypatched.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
from typing import Any
import pytest
import ci_orchestrator.operations.host as host_module
from ci_orchestrator.config import BackendConfig, Config, Paths
from ci_orchestrator.operations.host import (
BackupResult,
DiskStatus,
RunnerStatus,
backup_template,
disk_status,
runner_status,
)
def _make_config(templates_dir: Path) -> Config:
return Config(
paths=Paths(
root=templates_dir.parent,
templates=templates_dir,
build_vms=templates_dir.parent / "build-vms",
artifacts=templates_dir.parent / "artifacts",
keys=templates_dir.parent / "keys",
),
backend=BackendConfig(),
)
def _fake_usage(free_gb: float, total_gb: float = 500.0) -> shutil._ntuple_diskusage:
gb = 1024**3
return shutil._ntuple_diskusage(
int(total_gb * gb), int((total_gb - free_gb) * gb), int(free_gb * gb)
)
# ── disk_status ──────────────────────────────────────────────────────────────
def test_disk_status_normal(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
monkeypatch.setattr(host_module.shutil, "disk_usage", lambda _p: _fake_usage(free_gb=250, total_gb=500))
status = disk_status(tmp_path)
assert status.path == tmp_path
assert status.total_gb == pytest.approx(500.0, rel=1e-3)
assert status.free_gb == pytest.approx(250.0, rel=1e-3)
assert status.used_pct == pytest.approx(50.0, rel=1e-2)
def test_disk_status_oserror_degrades_to_zeros(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
def _raise(_p: Any) -> Any:
raise OSError("no such drive")
monkeypatch.setattr(host_module.shutil, "disk_usage", _raise)
status = disk_status(tmp_path)
assert status == DiskStatus(path=tmp_path, total_gb=0.0, free_gb=0.0, used_pct=0.0)
# ── runner_status ────────────────────────────────────────────────────────────
class _RunResult:
def __init__(self, stdout: str = "", returncode: int = 0) -> None:
self.stdout = stdout
self.returncode = returncode
def test_runner_status_active(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: "/usr/bin/systemctl")
def _run(cmd: list[str], **_kw: Any) -> _RunResult:
if cmd[1] == "is-active":
return _RunResult(stdout="active\n")
return _RunResult(stdout="running\n")
monkeypatch.setattr(host_module.subprocess, "run", _run)
result = runner_status(["act-runner.service"])
assert result == [RunnerStatus(name="act-runner.service", active=True, sub_state="running")]
def test_runner_status_inactive(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: "/usr/bin/systemctl")
def _run(cmd: list[str], **_kw: Any) -> _RunResult:
if cmd[1] == "is-active":
return _RunResult(stdout="inactive\n", returncode=3)
return _RunResult(stdout="dead\n")
monkeypatch.setattr(host_module.subprocess, "run", _run)
result = runner_status(["act-runner-rust.service"])
assert result == [RunnerStatus(name="act-runner-rust.service", active=False, sub_state="dead")]
def test_runner_status_multiple_services(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: "/usr/bin/systemctl")
def _run(cmd: list[str], **_kw: Any) -> _RunResult:
name = cmd[-1]
if cmd[1] == "is-active":
return _RunResult(stdout="active\n" if name == "a" else "inactive\n")
return _RunResult(stdout="running\n" if name == "a" else "dead\n")
monkeypatch.setattr(host_module.subprocess, "run", _run)
result = runner_status(["a", "b"])
assert result == [
RunnerStatus(name="a", active=True, sub_state="running"),
RunnerStatus(name="b", active=False, sub_state="dead"),
]
def test_runner_status_systemctl_absent(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: None)
result = runner_status(["act-runner.service", "act-runner-rust.service"])
assert result == [
RunnerStatus(name="act-runner.service", active=False, sub_state="unknown"),
RunnerStatus(name="act-runner-rust.service", active=False, sub_state="unknown"),
]
def test_runner_status_timeout_tolerated(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: "/usr/bin/systemctl")
def _boom(*_a: Any, **_kw: Any) -> Any:
raise subprocess.TimeoutExpired(cmd="systemctl", timeout=10.0)
monkeypatch.setattr(host_module.subprocess, "run", _boom)
result = runner_status(["act-runner.service"])
assert result == [RunnerStatus(name="act-runner.service", active=False, sub_state="unknown")]
def test_runner_status_oserror_tolerated(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: "/usr/bin/systemctl")
def _boom(*_a: Any, **_kw: Any) -> Any:
raise OSError("boom")
monkeypatch.setattr(host_module.subprocess, "run", _boom)
result = runner_status(["svc"])
assert result == [RunnerStatus(name="svc", active=False, sub_state="unknown")]
def test_runner_status_never_uses_shell_true(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(host_module.shutil, "which", lambda _n: "/usr/bin/systemctl")
seen_kwargs: list[dict[str, Any]] = []
def _run(cmd: list[str], **kw: Any) -> _RunResult:
seen_kwargs.append(kw)
return _RunResult(stdout="active\n")
monkeypatch.setattr(host_module.subprocess, "run", _run)
runner_status(["svc"])
assert all(not kw.get("shell") for kw in seen_kwargs)
# ── backup_template ─────────────────────────────────────────────────────────
def test_backup_template_missing_template_dir_errors(tmp_path: Path) -> None:
templates = tmp_path / "templates"
templates.mkdir()
config = _make_config(templates)
result = backup_template(config, "NoSuchTemplate")
assert result.ok is False
assert result.archive_path is None
assert result.error is not None
assert "NoSuchTemplate" in result.error or "not found" in result.error.lower()
def test_backup_template_dry_run_does_not_compress(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
templates = tmp_path / "templates"
tmpl_dir = templates / "WinBuild2025"
tmpl_dir.mkdir(parents=True)
config = _make_config(templates)
monkeypatch.setattr(host_module, "_BACKUP_BASE_DIR", tmp_path / "backups")
calls: list[tuple[Path, Path]] = []
monkeypatch.setattr(host_module, "_compress_7z", lambda src, dst: calls.append((src, dst)))
result = backup_template(config, "WinBuild2025", dry_run=True)
assert result.ok is True
assert result.template == "WinBuild2025"
assert result.archive_path is not None
assert result.error is None
assert calls == [], "dry_run must not compress"
assert not result.archive_path.exists()
def test_backup_template_success_mocked_archive_step(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
templates = tmp_path / "templates"
tmpl_dir = templates / "WinBuild2025"
tmpl_dir.mkdir(parents=True)
config = _make_config(templates)
monkeypatch.setattr(host_module, "_BACKUP_BASE_DIR", tmp_path / "backups")
def _fake_compress(src: Path, dst: Path) -> None:
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(b"archive")
monkeypatch.setattr(host_module, "_compress_7z", _fake_compress)
result = backup_template(config, "WinBuild2025")
assert result == BackupResult(
template="WinBuild2025", archive_path=result.archive_path, ok=True, error=None
)
assert result.archive_path is not None
assert result.archive_path.exists()
def test_backup_template_archive_failure_reported(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
templates = tmp_path / "templates"
tmpl_dir = templates / "WinBuild2025"
tmpl_dir.mkdir(parents=True)
config = _make_config(templates)
monkeypatch.setattr(host_module, "_BACKUP_BASE_DIR", tmp_path / "backups")
def _boom(_src: Path, _dst: Path) -> None:
raise subprocess.CalledProcessError(1, ["7z"])
monkeypatch.setattr(host_module, "_compress_7z", _boom)
result = backup_template(config, "WinBuild2025")
assert result.ok is False
assert result.archive_path is None
assert result.error is not None
def test_backup_template_never_calls_real_vmrun_or_7z(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""Guard: without mocking ``_compress_7z`` this would try to run real ``7z``."""
templates = tmp_path / "templates"
tmpl_dir = templates / "WinBuild2025"
tmpl_dir.mkdir(parents=True)
config = _make_config(templates)
monkeypatch.setattr(host_module, "_BACKUP_BASE_DIR", tmp_path / "backups")
called: list[bool] = []
def _fake(_src: Path, _dst: Path) -> None:
called.append(True)
monkeypatch.setattr(host_module, "_compress_7z", _fake)
backup_template(config, "WinBuild2025")
assert called == [True]