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
+8 -14
View File
@@ -14,8 +14,8 @@ from pathlib import Path
import click
from ci_orchestrator.cargo_cache import CargoCacheStore
from ci_orchestrator.config import Config, load_config
from ci_orchestrator.operations import cache as ops_cache
@click.group()
@@ -137,7 +137,7 @@ def _purge_old_dirs(base: Path, label: str, cutoff: datetime, *, what_if: bool)
def _purge_cargo_cache(config: Config, effective_days: int, *, what_if: bool) -> None:
"""Purge stale cargo cache entries.
"""Purge stale cargo cache entries via :mod:`operations.cache`.
Entries are the heaviest thing this policy governs -- hundreds of megabytes
each, one per Cargo.lock change -- so leaving them out would repeat TODO
@@ -153,25 +153,19 @@ def _purge_cargo_cache(config: Config, effective_days: int, *, what_if: bool) ->
days = min(effective_days, config.cargo_cache.retention_days)
cutoff = datetime.now(tz=UTC) - timedelta(days=days)
store = CargoCacheStore(config.cargo_cache.root)
# Size the victims first: after a real prune the files are gone and their
# bytes are unreadable, which is how a policy ends up reporting 0.0 GB
# freed while doing real work (TODO 2.9).
doomed = store.prune_older_than(cutoff, what_if=True)
if not doomed:
result = ops_cache.purge_cargo_cache(config, retention_days=days, dry_run=what_if)
if result.removed_entries == 0:
click.echo(
f"[RetentionPolicy] cargo cache: nothing to purge "
f"(cutoff: {cutoff.strftime('%Y-%m-%d')})."
)
return
freed_mb = sum(p.stat().st_size for p in doomed if p.exists()) / 1_048_576
if not what_if:
store.prune_older_than(cutoff)
freed_mb = result.freed_bytes / 1_048_576
verb = "WhatIf: would purge" if what_if else "Purged"
click.echo(
f"[RetentionPolicy] {verb} {len(doomed)} cargo cache "
f"{'entry' if len(doomed) == 1 else 'entries'} ({freed_mb:.0f} MB)"
f"[RetentionPolicy] {verb} {result.removed_entries} cargo cache "
f"{'entry' if result.removed_entries == 1 else 'entries'} ({freed_mb:.0f} MB)"
)
+121
View File
@@ -0,0 +1,121 @@
"""Pure cargo-cache operations shared by the CLI and (future) web UI.
Wraps :class:`~ci_orchestrator.cargo_cache.CargoCacheStore` for status and
purge. Zero ``click``, zero ``print`` — everything here returns a dataclass.
Presentation stays in the CLI command wrappers (``commands/retention.py``)
and, later, the web routes.
Scope note: this module wraps only the cargo cache's own status/purge. It is
deliberately orthogonal to the disk-retention *policy* (artifact/log purge,
aggressive-mode threshold) which lives in ``commands/retention.py`` — see
TODO §2.9 for that separate, still-open gap.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from ci_orchestrator.cargo_cache import CargoCacheStore
if TYPE_CHECKING: # pragma: no cover
from ci_orchestrator.config import Config
@dataclass(frozen=True)
class CacheStatus:
"""Point-in-time snapshot of the cargo cache store."""
root: Path
total_bytes: int
entry_count: int
oldest_age_days: int | None
retention_days: int
@dataclass(frozen=True)
class PurgeResult:
"""Outcome of a (real or dry-run) cargo cache purge."""
removed_entries: int
freed_bytes: int
dry_run: bool
def cargo_cache_status(config: Config) -> CacheStatus:
"""Scan the cargo cache store and report size/age. Never raises.
Reports the store's real on-disk state regardless of
``config.cargo_cache.enabled`` — only :func:`purge_cargo_cache` respects
that flag. An absent store root reports an empty, zeroed status.
"""
root = config.cargo_cache.root
retention_days = config.cargo_cache.retention_days
if not root.is_dir():
return CacheStatus(
root=root,
total_bytes=0,
entry_count=0,
oldest_age_days=None,
retention_days=retention_days,
)
store = CargoCacheStore(root)
now = datetime.now(tz=UTC)
total_bytes = 0
entry_count = 0
oldest_mtime: datetime | None = None
for entry in sorted(store.root.glob(f"*{store.suffix}")):
if not entry.is_file():
continue
try:
st = entry.stat()
except OSError:
continue
total_bytes += st.st_size
entry_count += 1
mtime = datetime.fromtimestamp(st.st_mtime, tz=UTC)
if oldest_mtime is None or mtime < oldest_mtime:
oldest_mtime = mtime
oldest_age_days = (now - oldest_mtime).days if oldest_mtime is not None else None
return CacheStatus(
root=root,
total_bytes=total_bytes,
entry_count=entry_count,
oldest_age_days=oldest_age_days,
retention_days=retention_days,
)
def purge_cargo_cache(
config: Config, *, retention_days: int | None = None, dry_run: bool = False
) -> PurgeResult:
"""Remove cache entries older than ``retention_days`` (or config's).
Sizes the victims before deleting (a real prune makes their bytes
unreadable), so ``freed_bytes`` is accurate even for a real purge.
A disabled cache is left untouched and reports an all-zero result.
"""
if not config.cargo_cache.enabled:
return PurgeResult(removed_entries=0, freed_bytes=0, dry_run=dry_run)
days = retention_days if retention_days is not None else config.cargo_cache.retention_days
cutoff = datetime.now(tz=UTC) - timedelta(days=days)
store = CargoCacheStore(config.cargo_cache.root)
doomed = store.prune_older_than(cutoff, what_if=True)
freed_bytes = sum(p.stat().st_size for p in doomed if p.exists())
if not dry_run:
store.prune_older_than(cutoff)
return PurgeResult(removed_entries=len(doomed), freed_bytes=freed_bytes, dry_run=dry_run)
__all__ = ["CacheStatus", "PurgeResult", "cargo_cache_status", "purge_cargo_cache"]
+166
View File
@@ -0,0 +1,166 @@
"""Pure host operations shared by the CLI and (future) web UI.
Disk usage, runner (systemd unit) status, and template backup. Zero
``click``, zero ``print`` — everything here returns a dataclass.
Presentation stays in the CLI command wrappers and, later, the web routes.
``runner_status`` is Linux/systemd-only (the Linux host runs no other
service manager per Phase C) and never shells out with ``shell=True`` —
every ``systemctl`` invocation is an argument list, read-only.
``backup_template`` reuses the archive-step shape from
``commands/template.py::template_backup`` (timestamped ``7z`` archive under
``/var/lib/ci/backups``) but is a fresh, click-free implementation so this
module has no dependency on the CLI layer.
"""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING: # pragma: no cover
from ci_orchestrator.config import Config
_SYSTEMCTL_TIMEOUT = 10.0
_BACKUP_BASE_DIR = Path("/var/lib/ci/backups")
@dataclass(frozen=True)
class DiskStatus:
"""Filesystem usage for a single path/mount."""
path: Path
total_gb: float
free_gb: float
used_pct: float
@dataclass(frozen=True)
class RunnerStatus:
"""``systemctl`` status for a single service."""
name: str
active: bool
sub_state: str
@dataclass(frozen=True)
class BackupResult:
"""Outcome of a (real or dry-run) template backup."""
template: str
archive_path: Path | None
ok: bool
error: str | None
def disk_status(path: Path) -> DiskStatus:
"""Report usage for ``path``. Degrades to zeros on ``OSError`` — never raises."""
try:
usage = shutil.disk_usage(path)
except OSError:
return DiskStatus(path=path, total_gb=0.0, free_gb=0.0, used_pct=0.0)
gb = 1024.0**3
total_gb = usage.total / gb
free_gb = usage.free / gb
used_pct = ((usage.total - usage.free) / usage.total * 100.0) if usage.total > 0 else 0.0
return DiskStatus(path=path, total_gb=total_gb, free_gb=free_gb, used_pct=used_pct)
def _systemctl_query(systemctl: str, args: list[str]) -> str | None:
"""Run a read-only ``systemctl`` query. Returns stripped stdout, or ``None`` on failure."""
try:
result = subprocess.run(
[systemctl, *args],
capture_output=True,
text=True,
check=False,
timeout=_SYSTEMCTL_TIMEOUT,
)
except (subprocess.TimeoutExpired, OSError):
return None
return result.stdout.strip()
def runner_status(service_names: list[str]) -> list[RunnerStatus]:
"""Query ``systemctl is-active`` / ``show -p SubState`` for each service.
Never raises: a missing ``systemctl``, a timeout, or an ``OSError`` all
degrade a service to ``active=False, sub_state="unknown"`` rather than
aborting the whole batch.
"""
systemctl = shutil.which("systemctl")
if systemctl is None:
return [RunnerStatus(name=name, active=False, sub_state="unknown") for name in service_names]
out: list[RunnerStatus] = []
for name in service_names:
active_raw = _systemctl_query(systemctl, ["is-active", name])
sub_state_raw = _systemctl_query(systemctl, ["show", "-p", "SubState", "--value", name])
active = active_raw == "active"
sub_state = sub_state_raw if sub_state_raw else "unknown"
out.append(RunnerStatus(name=name, active=active, sub_state=sub_state))
return out
def _compress_7z(source: Path, archive: Path) -> None:
"""Create a 7z archive of ``source`` at ``archive`` using compression level 1.
Split out so tests can monkeypatch the actual compression step and never
invoke a real ``7z`` subprocess.
"""
subprocess.run(
["7z", "a", "-mx=1", "-mmt=on", str(archive), str(source)],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
def backup_template(config: Config, template: str, *, dry_run: bool = False) -> BackupResult:
"""Archive a template directory to a timestamped ``7z`` file.
``dry_run`` computes and returns the would-be archive path without
compressing anything. On failure (missing template dir, or the
compression step raising), returns ``ok=False`` with ``archive_path=None``
and never raises.
"""
tmpl_dir = config.paths.templates / template
if not tmpl_dir.is_dir():
return BackupResult(
template=template,
archive_path=None,
ok=False,
error=f"Template directory not found: {tmpl_dir}",
)
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
archive_path = _BACKUP_BASE_DIR / f"Template_{template}_{timestamp}.7z"
if dry_run:
return BackupResult(template=template, archive_path=archive_path, ok=True, error=None)
try:
_BACKUP_BASE_DIR.mkdir(parents=True, exist_ok=True)
_compress_7z(tmpl_dir, archive_path)
except (OSError, subprocess.CalledProcessError) as exc:
return BackupResult(template=template, archive_path=None, ok=False, error=str(exc))
return BackupResult(template=template, archive_path=archive_path, ok=True, error=None)
__all__ = [
"BackupResult",
"DiskStatus",
"RunnerStatus",
"backup_template",
"disk_status",
"runner_status",
]