POST /host/delete-template: same two-step confirm gate, CSRF check, and known-template containment as /host/backup, offloaded to a thread the same way (rmtree on a multi-GB template dir is not instant). No backup-required safety rail -- same confirm-gate as every other destructive action, nothing more; the confirm summary spells out that clones can no longer be made from the template until one is restored. Also cleaned up the per-backup Delete button it sits next to: name+meta grouped into one shrinkable unit so the row wraps as a block instead of the delete button drifting away from a name that wrapped, and each backup is now its own bordered line instead of a run-on flex list.
344 lines
12 KiB
Python
344 lines
12 KiB
Python
"""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,
|
|
TemplateDeleteResult,
|
|
backup_template,
|
|
delete_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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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)
|
|
|
|
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]
|
|
|
|
|
|
# ── delete_template ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_delete_template_missing_template_dir_errors(tmp_path: Path) -> None:
|
|
templates = tmp_path / "templates"
|
|
templates.mkdir()
|
|
config = _make_config(templates)
|
|
|
|
result = delete_template(config, "NoSuchTemplate")
|
|
|
|
assert result == TemplateDeleteResult(
|
|
template="NoSuchTemplate", ok=False, error=result.error
|
|
)
|
|
assert result.error is not None
|
|
assert "NoSuchTemplate" in result.error or "not found" in result.error.lower()
|
|
|
|
|
|
def test_delete_template_dry_run_does_not_remove_the_directory(tmp_path: Path) -> None:
|
|
templates = tmp_path / "templates"
|
|
tmpl_dir = templates / "WinBuild2025"
|
|
tmpl_dir.mkdir(parents=True)
|
|
(tmpl_dir / "WinBuild2025.vmx").write_text("config.version = \"8\"")
|
|
config = _make_config(templates)
|
|
|
|
result = delete_template(config, "WinBuild2025", dry_run=True)
|
|
|
|
assert result == TemplateDeleteResult(template="WinBuild2025", ok=True, error=None)
|
|
assert tmpl_dir.is_dir()
|
|
|
|
|
|
def test_delete_template_removes_the_whole_directory(tmp_path: Path) -> None:
|
|
templates = tmp_path / "templates"
|
|
tmpl_dir = templates / "WinBuild2025"
|
|
tmpl_dir.mkdir(parents=True)
|
|
(tmpl_dir / "WinBuild2025.vmx").write_text("config.version = \"8\"")
|
|
config = _make_config(templates)
|
|
|
|
result = delete_template(config, "WinBuild2025")
|
|
|
|
assert result == TemplateDeleteResult(template="WinBuild2025", ok=True, error=None)
|
|
assert not tmpl_dir.exists()
|
|
|
|
|
|
def test_delete_template_removal_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)
|
|
|
|
def _boom(_path: Path) -> None:
|
|
raise OSError("device busy")
|
|
|
|
monkeypatch.setattr(shutil, "rmtree", _boom)
|
|
|
|
result = delete_template(config, "WinBuild2025")
|
|
|
|
assert result.ok is False
|
|
assert result.error is not None
|
|
assert tmpl_dir.is_dir()
|