diff --git a/scripts/disk-gc-scan.py b/scripts/disk-gc-scan.py index 30d9ea3..d43555a 100644 --- a/scripts/disk-gc-scan.py +++ b/scripts/disk-gc-scan.py @@ -23,6 +23,11 @@ DESIGN PRINCIPLES (per task disk-gc-probe-false-unreachable-20260913): - If a figure comes from a different kind of measurement than the column claims, name it explicitly +4. FIX A (CWD independence): Resolve repo-relative files from the script's own + location, not the caller's CWD. + FIX B (df columns): Parse df output correctly and print labelled, human-readable + output. + Usage: disk-gc-scan.py # scan all guests disk-gc-scan.py --json # machine-readable output @@ -32,12 +37,17 @@ Exit codes: 0 ok (all guests probed), 1 probe error from __future__ import annotations import json +import pathlib import subprocess import sys import time -from dataclasses import dataclass, asdict +from dataclasses import dataclass from typing import Optional +# Resolve repo-relative files from the script's own location, not the caller's CWD +SCRIPT_DIR = pathlib.Path(__file__).resolve().parent +HELPER_PCT_RUN = SCRIPT_DIR / "pct-run.sh" + # Per-guest access method map. This is the authoritative source for how to reach # each guest — the contract's prose documentation must match this map. # @@ -78,7 +88,7 @@ class ProbeResult: usage_pct: Optional[float] usage_str: Optional[str] probe_cmd: str - failure_kind: Optional[str] # "timeout", "ssh-auth", "no-route", None + failure_kind: Optional[str] # "timeout", "ssh-auth", "no-route", "command-not-found", None @property def is_reachable(self) -> bool: @@ -167,7 +177,8 @@ def probe_guest(guest: Guest) -> ProbeResult: df_cmd = "df -P / | tail -1" if guest.access_method == "pct-run": - probe_cmd = f'bash scripts/pct-run.sh {guest.ct_id} "{df_cmd}"' + # Use absolute path to helper so CWD doesn't matter + probe_cmd = f'bash {HELPER_PCT_RUN} {guest.ct_id} "{df_cmd}"' elif guest.access_method == "ssh-host": probe_cmd = f'ssh {SSH_OPTS} root@{guest.hostname} "{df_cmd}"' elif guest.access_method == "ssh-ip": @@ -175,17 +186,38 @@ def probe_guest(guest: Guest) -> ProbeResult: else: raise ValueError(f"unknown access_method: {guest.access_method}") + # Check helper exists and is readable BEFORE probing (for pct-run guests) + # This prevents scanner errors from being rendered as guest verdicts + if guest.access_method == "pct-run": + if not HELPER_PCT_RUN.exists(): + print(f"SCANNER ERROR: helper not found: {HELPER_PCT_RUN}", file=sys.stderr) + sys.exit(1) + if not HELPER_PCT_RUN.readable(): + print(f"SCANNER ERROR: helper not readable: {HELPER_PCT_RUN}", file=sys.stderr) + sys.exit(1) + # Retry once on failure before declaring unreachable for attempt in range(2): exit_code, stdout, stderr = run_cmd(probe_cmd, timeout=15) if exit_code == 0: # Parse df output: Filesystem 1024-blocks Used Available Capacity Mounted on + # parts[0]=Filesystem, parts[1]=Total (1K blocks), parts[2]=Used, parts[3]=Available, parts[4]=Capacity parts = stdout.split() if len(parts) >= 5: capacity_str = parts[4] # e.g., "34%" usage_pct = float(capacity_str.rstrip("%")) - usage_str = f"{capacity_str} ({parts[1]}/{parts[2]})" + total_blocks = int(parts[1]) + used_blocks = int(parts[2]) + avail_blocks = int(parts[3]) + # Convert to human-readable units + def to_gb(blocks: int) -> float: + return blocks / (1024 * 1024) + total_gb = to_gb(total_blocks) + used_gb = to_gb(used_blocks) + avail_gb = to_gb(avail_blocks) + # FIX B: print labelled, unambiguous output + usage_str = f"{capacity_str} ({used_gb:.1f}G used of {total_gb:.1f}G total, {avail_gb:.1f}G free)" return ProbeResult( exit_code=0, usage_pct=usage_pct, @@ -214,6 +246,8 @@ def probe_guest(guest: Guest) -> ProbeResult: failure_kind = "no-route" elif "Connection refused" in stderr: failure_kind = "conn-refused" + elif "command not found" in stderr.lower() or "No such file" in stderr: + failure_kind = "command-not-found" else: failure_kind = f"ssh-exit-{exit_code}"