feat(web): dashboard redesign — Gitea palette, health panels, VM/audit enrichment (stages A–C)
Stage A: swap CSS tokens to the real Gitea dark palette (accent blue #4183c4); add Jinja formatting filters (humanbytes/duration/relative_time/localtime/shorten_path/thousands), a health status-header, and an inline SVG icon sprite; fix two UI bugs — unthemed action pages (htmx swap into #action-slot with a full-page HX-Request fallback) and polling wiping form input (hx-preserve). Stage B: four read-only panels that degrade to notices (never 500) — host resources (/proc, two-sample CPU%), VMware module health, template inventory (.vmem warn + last backup), and a path-contained file browser; backup card becomes a validated template dropdown; expensive dir-size walk is TTL-cached. Adds operations/inventory.py plus host_resources()/vmware_module_health() in operations/host.py. Stage C: parse_clone_name() adds a Job column (job_id + started) to the VM table; nav gains a Gitea link-out derived from the OIDC issuer origin (no fabricated run URLs); audit page gets action icons, relative time, and a client-side filter. Gates: 989 tests pass, coverage 94.12%, ruff + mypy --strict clean. Stage D (service management) intentionally deferred.
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
"""Tests for the Stage B (B3/B4) ``operations.inventory`` module.
|
||||
|
||||
Template discovery (``list_templates``) and containment-checked directory
|
||||
listings (``list_dir``/``list_backups``/``list_artifacts``). No real ``7z``/
|
||||
``vmrun`` involved -- everything is plain files under ``tmp_path``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import ci_orchestrator.operations.inventory as inventory_module
|
||||
from ci_orchestrator.config import BackendConfig, Config, Paths
|
||||
from ci_orchestrator.operations.inventory import (
|
||||
FileEntry,
|
||||
TemplateInfo,
|
||||
list_artifacts,
|
||||
list_backups,
|
||||
list_dir,
|
||||
list_templates,
|
||||
)
|
||||
|
||||
|
||||
def _make_config(root: Path) -> Config:
|
||||
return Config(
|
||||
paths=Paths(
|
||||
root=root,
|
||||
templates=root / "templates",
|
||||
build_vms=root / "build-vms",
|
||||
artifacts=root / "artifacts",
|
||||
keys=root / "keys",
|
||||
),
|
||||
backend=BackendConfig(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_dir_bytes_cache() -> None:
|
||||
"""The B7 ``dir_bytes`` cache is module-level -- start each test clean."""
|
||||
inventory_module._dir_bytes_cache.clear()
|
||||
|
||||
|
||||
# ── list_templates ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _write_vmx(path: Path, guest_os: str) -> None:
|
||||
path.write_text(f'.encoding = "UTF-8"\nguestOS = "{guest_os}"\n', encoding="utf-8")
|
||||
|
||||
|
||||
def test_list_templates_missing_dir_is_empty(tmp_path: Path) -> None:
|
||||
config = _make_config(tmp_path) # templates dir never created
|
||||
|
||||
assert list_templates(config) == []
|
||||
|
||||
|
||||
def test_list_templates_parses_guest_os_and_transport_ssh(tmp_path: Path) -> None:
|
||||
tmpl_dir = tmp_path / "templates" / "LinuxBuild2404"
|
||||
tmpl_dir.mkdir(parents=True)
|
||||
_write_vmx(tmpl_dir / "LinuxBuild2404.vmx", "ubuntu-64")
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
[info] = list_templates(config)
|
||||
|
||||
assert info.name == "LinuxBuild2404"
|
||||
assert info.vmx == tmpl_dir / "LinuxBuild2404.vmx"
|
||||
assert info.guest_os == "ubuntu-64"
|
||||
assert info.transport == "ssh"
|
||||
|
||||
|
||||
def test_list_templates_windows_guest_os_is_winrm_transport(tmp_path: Path) -> None:
|
||||
tmpl_dir = tmp_path / "templates" / "WinBuild2025"
|
||||
tmpl_dir.mkdir(parents=True)
|
||||
_write_vmx(tmpl_dir / "WinBuild2025.vmx", "windows9srv-64")
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
[info] = list_templates(config)
|
||||
|
||||
assert info.transport == "winrm"
|
||||
|
||||
|
||||
def test_list_templates_no_vmx_is_unknown_transport(tmp_path: Path) -> None:
|
||||
tmpl_dir = tmp_path / "templates" / "Empty"
|
||||
tmpl_dir.mkdir(parents=True)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
[info] = list_templates(config)
|
||||
|
||||
assert info.vmx is None
|
||||
assert info.guest_os is None
|
||||
assert info.transport == "unknown"
|
||||
|
||||
|
||||
def test_list_templates_has_vmem_true_when_present(tmp_path: Path) -> None:
|
||||
tmpl_dir = tmp_path / "templates" / "WithVmem"
|
||||
tmpl_dir.mkdir(parents=True)
|
||||
_write_vmx(tmpl_dir / "WithVmem.vmx", "ubuntu-64")
|
||||
(tmpl_dir / "WithVmem.vmem").write_bytes(b"\x00" * 16)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
[info] = list_templates(config)
|
||||
|
||||
assert info.has_vmem is True
|
||||
|
||||
|
||||
def test_list_templates_has_vmem_false_when_absent(tmp_path: Path) -> None:
|
||||
tmpl_dir = tmp_path / "templates" / "Clean"
|
||||
tmpl_dir.mkdir(parents=True)
|
||||
_write_vmx(tmpl_dir / "Clean.vmx", "ubuntu-64")
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
[info] = list_templates(config)
|
||||
|
||||
assert info.has_vmem is False
|
||||
|
||||
|
||||
def test_list_templates_dir_bytes_sums_file_sizes(tmp_path: Path) -> None:
|
||||
tmpl_dir = tmp_path / "templates" / "Sized"
|
||||
nested = tmpl_dir / "nested"
|
||||
nested.mkdir(parents=True)
|
||||
(tmpl_dir / "a.vmdk").write_bytes(b"x" * 100)
|
||||
(nested / "b.vmdk").write_bytes(b"y" * 50)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
[info] = list_templates(config)
|
||||
|
||||
assert info.dir_bytes == 150
|
||||
|
||||
|
||||
def test_list_templates_multiple_entries_sorted_by_name(tmp_path: Path) -> None:
|
||||
for name in ("WinBuild2022", "LinuxBuild2404", "WinBuild2025"):
|
||||
(tmp_path / "templates" / name).mkdir(parents=True)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
names = [t.name for t in list_templates(config)]
|
||||
|
||||
assert names == sorted(names)
|
||||
|
||||
|
||||
def test_list_templates_last_backup_picks_newest_matching_archive(tmp_path: Path) -> None:
|
||||
tmpl_dir = tmp_path / "templates" / "LinuxBuild2404"
|
||||
tmpl_dir.mkdir(parents=True)
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
|
||||
older = backups_dir / "Template_LinuxBuild2404_20260101_000000.7z"
|
||||
newer = backups_dir / "Template_LinuxBuild2404_20260601_000000.7z"
|
||||
unrelated = backups_dir / "Template_OtherTemplate_20260701_000000.7z"
|
||||
older.write_bytes(b"old")
|
||||
unrelated.write_bytes(b"other")
|
||||
time.sleep(0.01)
|
||||
newer.write_bytes(b"new")
|
||||
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
[info] = list_templates(config)
|
||||
|
||||
assert info.last_backup is not None
|
||||
assert info.last_backup.name == "Template_LinuxBuild2404_20260601_000000.7z"
|
||||
|
||||
|
||||
def test_list_templates_last_backup_none_when_no_backups_dir(tmp_path: Path) -> None:
|
||||
(tmp_path / "templates" / "LinuxBuild2404").mkdir(parents=True)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
[info] = list_templates(config)
|
||||
|
||||
assert info.last_backup is None
|
||||
|
||||
|
||||
def test_list_templates_skips_regular_files_directly_under_templates(tmp_path: Path) -> None:
|
||||
templates_dir = tmp_path / "templates"
|
||||
templates_dir.mkdir(parents=True)
|
||||
(templates_dir / "README.txt").write_text("not a template", encoding="utf-8")
|
||||
(templates_dir / "Real").mkdir()
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
names = [t.name for t in list_templates(config)]
|
||||
|
||||
assert names == ["Real"]
|
||||
|
||||
|
||||
def test_list_templates_dir_bytes_is_cached(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||
tmpl_dir = tmp_path / "templates" / "Cached"
|
||||
tmpl_dir.mkdir(parents=True)
|
||||
(tmpl_dir / "a.vmdk").write_bytes(b"x" * 10)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
walk_calls: list[Path] = []
|
||||
real_walk = os.walk
|
||||
|
||||
def _counting_walk(path: object, *a: object, **kw: object) -> object:
|
||||
walk_calls.append(Path(str(path)))
|
||||
return real_walk(path, *a, **kw) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(inventory_module.os, "walk", _counting_walk)
|
||||
|
||||
first = list_templates(config)[0].dir_bytes
|
||||
second = list_templates(config)[0].dir_bytes
|
||||
|
||||
assert first == second == 10
|
||||
assert len(walk_calls) == 1, "second call must hit the TTL cache, not os.walk again"
|
||||
|
||||
|
||||
# ── list_dir / containment ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_dir_accepts_root_itself(tmp_path: Path) -> None:
|
||||
(tmp_path / "f1.txt").write_bytes(b"a")
|
||||
|
||||
entries = list_dir(tmp_path, root=tmp_path)
|
||||
|
||||
assert [e.name for e in entries] == ["f1.txt"]
|
||||
|
||||
|
||||
def test_list_dir_accepts_real_subdir(tmp_path: Path) -> None:
|
||||
sub = tmp_path / "backups"
|
||||
sub.mkdir()
|
||||
(sub / "f1.7z").write_bytes(b"a")
|
||||
|
||||
entries = list_dir(sub, root=tmp_path, pattern="*.7z")
|
||||
|
||||
assert [e.name for e in entries] == ["f1.7z"]
|
||||
|
||||
|
||||
def test_list_dir_rejects_base_outside_root(tmp_path: Path) -> None:
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_bytes(b"nope")
|
||||
|
||||
assert list_dir(outside, root=root) == []
|
||||
|
||||
|
||||
def test_list_dir_rejects_dotdot_escape(tmp_path: Path) -> None:
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_bytes(b"nope")
|
||||
|
||||
escaping = root / ".." / "outside"
|
||||
|
||||
assert list_dir(escaping, root=root) == []
|
||||
|
||||
|
||||
def test_list_dir_rejects_symlink_escaping_root(tmp_path: Path) -> None:
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_bytes(b"nope")
|
||||
|
||||
link = root / "escape_link"
|
||||
link.symlink_to(outside, target_is_directory=True)
|
||||
|
||||
assert list_dir(link, root=root) == []
|
||||
|
||||
|
||||
def test_list_dir_missing_base_is_empty(tmp_path: Path) -> None:
|
||||
assert list_dir(tmp_path / "does-not-exist", root=tmp_path) == []
|
||||
|
||||
|
||||
def test_list_dir_sorted_newest_first(tmp_path: Path) -> None:
|
||||
older = tmp_path / "a.txt"
|
||||
newer = tmp_path / "b.txt"
|
||||
older.write_bytes(b"old")
|
||||
time.sleep(0.01)
|
||||
newer.write_bytes(b"new")
|
||||
|
||||
entries = list_dir(tmp_path, root=tmp_path)
|
||||
|
||||
assert [e.name for e in entries] == ["b.txt", "a.txt"]
|
||||
|
||||
|
||||
def test_list_dir_pattern_filters_extension(tmp_path: Path) -> None:
|
||||
(tmp_path / "keep.7z").write_bytes(b"x")
|
||||
(tmp_path / "skip.txt").write_bytes(b"y")
|
||||
|
||||
entries = list_dir(tmp_path, root=tmp_path, pattern="*.7z")
|
||||
|
||||
assert [e.name for e in entries] == ["keep.7z"]
|
||||
|
||||
|
||||
def test_list_dir_entry_shape(tmp_path: Path) -> None:
|
||||
f = tmp_path / "x.7z"
|
||||
f.write_bytes(b"12345")
|
||||
|
||||
[entry] = list_dir(tmp_path, root=tmp_path)
|
||||
|
||||
assert isinstance(entry, FileEntry)
|
||||
assert entry.size_bytes == 5
|
||||
assert entry.mtime == pytest.approx(f.stat().st_mtime)
|
||||
|
||||
|
||||
# ── list_backups / list_artifacts ────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_list_backups_reads_root_backups_dir(tmp_path: Path) -> None:
|
||||
backups = tmp_path / "backups"
|
||||
backups.mkdir()
|
||||
(backups / "Template_X_20260101_000000.7z").write_bytes(b"x")
|
||||
(backups / "notes.txt").write_bytes(b"ignored")
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
entries = list_backups(config)
|
||||
|
||||
assert [e.name for e in entries] == ["Template_X_20260101_000000.7z"]
|
||||
|
||||
|
||||
def test_list_backups_missing_dir_is_empty(tmp_path: Path) -> None:
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
assert list_backups(config) == []
|
||||
|
||||
|
||||
def test_list_artifacts_reads_configured_artifacts_dir(tmp_path: Path) -> None:
|
||||
config = _make_config(tmp_path)
|
||||
config.paths.artifacts.mkdir(parents=True)
|
||||
(config.paths.artifacts / "build.log").write_bytes(b"log")
|
||||
|
||||
entries = list_artifacts(config)
|
||||
|
||||
assert [e.name for e in entries] == ["build.log"]
|
||||
|
||||
|
||||
def test_list_artifacts_outside_root_is_rejected(tmp_path: Path) -> None:
|
||||
"""``config.paths.artifacts`` pointed outside ``root`` must still 0-list.
|
||||
|
||||
``outside_artifacts`` sits beside ``root`` (both under ``tmp_path``) --
|
||||
a sibling directory, not a descendant -- so containment must reject it.
|
||||
"""
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
outside_artifacts = tmp_path / "artifacts-outside"
|
||||
outside_artifacts.mkdir()
|
||||
(outside_artifacts / "build.log").write_bytes(b"log")
|
||||
|
||||
config = Config(
|
||||
paths=Paths(
|
||||
root=root,
|
||||
templates=root / "templates",
|
||||
build_vms=root / "build-vms",
|
||||
artifacts=outside_artifacts,
|
||||
keys=root / "keys",
|
||||
),
|
||||
backend=BackendConfig(),
|
||||
)
|
||||
|
||||
assert list_artifacts(config) == []
|
||||
|
||||
|
||||
# ── defensive OSError degrade paths ("never raise") ─────────────────────────
|
||||
|
||||
|
||||
class _BoomIterdir:
|
||||
"""Duck-typed stand-in for a ``Path`` whose ``iterdir()`` blows up."""
|
||||
|
||||
def iterdir(self) -> list[Path]:
|
||||
raise OSError("denied")
|
||||
|
||||
|
||||
class _BoomGlob:
|
||||
"""Duck-typed stand-in for a ``Path`` whose ``glob()`` blows up."""
|
||||
|
||||
def glob(self, _pattern: str) -> list[Path]:
|
||||
raise OSError("denied")
|
||||
|
||||
|
||||
class _BoomReadText:
|
||||
"""Duck-typed stand-in for a ``Path`` whose ``read_text()`` blows up."""
|
||||
|
||||
def read_text(self, encoding: str = "utf-8", errors: str = "strict") -> str:
|
||||
raise OSError("denied")
|
||||
|
||||
|
||||
class _BoomResolve:
|
||||
"""Duck-typed stand-in for a ``Path`` whose ``resolve()`` blows up."""
|
||||
|
||||
def resolve(self) -> Path:
|
||||
raise OSError("denied")
|
||||
|
||||
|
||||
def test_find_vmx_oserror_returns_none() -> None:
|
||||
assert inventory_module._find_vmx(_BoomIterdir()) is None # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_has_vmem_oserror_returns_false() -> None:
|
||||
assert inventory_module._has_vmem(_BoomGlob()) is False # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_read_guest_os_oserror_returns_empty_string() -> None:
|
||||
assert inventory_module._read_guest_os(_BoomReadText()) == "" # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_dir_bytes_cached_resolve_oserror_returns_zero() -> None:
|
||||
assert inventory_module._dir_bytes_cached(_BoomResolve()) == 0 # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_newest_backup_glob_oserror_returns_none(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
backups_dir = tmp_path / "backups"
|
||||
backups_dir.mkdir()
|
||||
monkeypatch.setattr(Path, "glob", lambda self, pattern: (_ for _ in ()).throw(OSError("denied")))
|
||||
|
||||
assert inventory_module._newest_backup(backups_dir, "X") is None
|
||||
|
||||
|
||||
def test_dir_bytes_cached_walk_oserror_degrades_to_zero(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
tmpl_dir = tmp_path / "T"
|
||||
tmpl_dir.mkdir()
|
||||
(tmpl_dir / "a.txt").write_bytes(b"x" * 5)
|
||||
|
||||
def _boom_walk(_path: Path, *_a: object, **_kw: object) -> object:
|
||||
raise OSError("walk exploded")
|
||||
|
||||
monkeypatch.setattr(inventory_module.os, "walk", _boom_walk)
|
||||
|
||||
assert inventory_module._dir_bytes_cached(tmpl_dir) == 0
|
||||
|
||||
|
||||
def test_dir_bytes_cache_recomputes_after_ttl_expiry(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""B7: a cache entry older than the TTL must be recomputed, not reused forever."""
|
||||
tmpl_dir = tmp_path / "templates" / "Stale"
|
||||
tmpl_dir.mkdir(parents=True)
|
||||
(tmpl_dir / "a.vmdk").write_bytes(b"x" * 10)
|
||||
config = _make_config(tmp_path)
|
||||
|
||||
clock = {"t": 0.0}
|
||||
monkeypatch.setattr(inventory_module.time, "monotonic", lambda: clock["t"])
|
||||
walk_calls: list[Path] = []
|
||||
real_walk = os.walk
|
||||
|
||||
def _counting_walk(path: object, *a: object, **kw: object) -> object:
|
||||
walk_calls.append(Path(str(path)))
|
||||
return real_walk(path, *a, **kw) # type: ignore[arg-type]
|
||||
|
||||
monkeypatch.setattr(inventory_module.os, "walk", _counting_walk)
|
||||
|
||||
first = list_templates(config)[0].dir_bytes
|
||||
clock["t"] += inventory_module._DIR_BYTES_TTL_SECONDS + 1.0
|
||||
second = list_templates(config)[0].dir_bytes
|
||||
|
||||
assert first == second == 10
|
||||
assert len(walk_calls) == 2, "an expired cache entry must trigger a fresh os.walk"
|
||||
|
||||
|
||||
def test_list_templates_iterdir_oserror_returns_empty(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
templates_dir = tmp_path / "templates"
|
||||
templates_dir.mkdir()
|
||||
config = _make_config(tmp_path)
|
||||
original_iterdir = Path.iterdir
|
||||
|
||||
def _boom_iterdir(self: Path) -> object:
|
||||
if self == templates_dir:
|
||||
raise OSError("denied")
|
||||
return original_iterdir(self)
|
||||
|
||||
monkeypatch.setattr(Path, "iterdir", _boom_iterdir)
|
||||
|
||||
assert list_templates(config) == []
|
||||
|
||||
|
||||
def test_list_dir_resolve_oserror_returns_empty(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
monkeypatch.setattr(Path, "resolve", lambda self: (_ for _ in ()).throw(OSError("denied")))
|
||||
|
||||
assert list_dir(tmp_path, root=tmp_path) == []
|
||||
|
||||
|
||||
def test_list_dir_skips_subdirectories(tmp_path: Path) -> None:
|
||||
(tmp_path / "subdir").mkdir()
|
||||
(tmp_path / "file.txt").write_bytes(b"x")
|
||||
|
||||
entries = list_dir(tmp_path, root=tmp_path)
|
||||
|
||||
assert [e.name for e in entries] == ["file.txt"]
|
||||
|
||||
|
||||
def test_template_info_is_frozen_dataclass() -> None:
|
||||
info = TemplateInfo(
|
||||
name="X",
|
||||
vmx=None,
|
||||
guest_os=None,
|
||||
transport="unknown",
|
||||
dir_bytes=0,
|
||||
has_vmem=False,
|
||||
last_backup=None,
|
||||
)
|
||||
with pytest.raises(AttributeError):
|
||||
info.name = "Y" # type: ignore[misc]
|
||||
Reference in New Issue
Block a user