fix(disk-gc): hard guest-level report-only gate for CT 111/.129; correct stale fleet map

CT 111 (tdunna, 192.168.68.129) is Theo's box and is report-only per the captain
(2026-08-17, re-confirmed 2026-09-10). The contract defined AMBER as 'GC scheduled
for next run' and its Execution loop called gc-executor for EVERY threat with no
guest-level exclusion - so a single AMBER reading there would have scheduled apt
clean / journal vacuum / log+tmp deletion against someone else's box. The only
marker was frontmatter report_only_agents, which names an AGENT while the scan unit
is a GUEST.

- Gate the Execution loop on a guest/host-keyed report_only_guests block (guest id,
  hostname and IP all match); an excluded guest is alerted and skipped, so no
  gc-executor call is constructed for it at any level.
- Carry the ruling in the contract body next to the loop, not only in frontmatter.
- scripts/disk-gc-plan.py: executable planner that reads the contract's
  authoritative exclusion block and emits the action plan; tests/ covers it.
- Correct the stale fleet map against pvesh /cluster/resources: CT 105 -> amdpve
  (was minipve), CT 111 -> storepve (was amdpve), add guests 118/119/120, and fix
  the '15 CTs' counts (20 guests: 17 LXC + 3 QEMU VMs).
- scripts/pct-run.sh: same stale map (105/111 wrong node, 120 missing) - this is
  why pct-run 111/105 failed.
- Fold in disk-gc-ct100-probe-gap-20260911: CT 100 verifiably works through
  pct-run now that the map is correct; documented that the scanner must probe it
  like any other guest, never via a local-only path (the scanner runs inside CT 100).
This commit is contained in:
abiba
2026-09-12 18:33:02 +00:00
parent d9e06863d8
commit 7b8cc5f9ac
4 changed files with 358 additions and 34 deletions
+164
View File
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""disk-gc-plan — turn a fleet disk scan into the GC action plan.
This is the executable side of `disk-gc-threat-response.prose.md`. It exists so the
report-only gate is enforced by code that can be tested, rather than by prose the
executor might misread.
THE HARD GATE: guests listed in the contract's `report_only_guests` block are
DETECT-AND-REPORT-ONLY at EVERY level (AMBER, RED, CRITICAL). This tool will never
emit a `gc-executor` action for one, so no GC command can be constructed for it.
The gate is keyed on GUEST identity — guest id, hostname, or IP — never on an agent
name. An agent-name marker can silently miss the guest it lives on; a guest marker
cannot.
The authoritative exclusion list lives in the contract itself (the fenced ```yaml
block containing `report_only_guests:`). This tool reads it from there so there is
only ever one copy.
Usage:
disk-gc-plan.py --scan scan.json # [{"id":111,"usage_pct":84}, ...]
cat scan.json | disk-gc-plan.py # same, via stdin
disk-gc-plan.py --scan scan.json --json # machine-readable plan
Scan entries may carry any of: id / guest / vmid, hostname / name, ip.
Exit codes: 0 ok, 1 usage/parse error.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import sys
import yaml
REPO = pathlib.Path(__file__).resolve().parent.parent
DEFAULT_CONTRACT = REPO / "disk-gc-threat-response.prose.md"
AMBER, RED, CRITICAL = 75, 85, 95
def load_report_only_guests(contract_path: pathlib.Path) -> list[dict]:
"""Read the authoritative report_only_guests block out of the contract.
The contract carries it as a fenced ```yaml block. Parsing the declared,
machine-readable block is the intended interface — the contract owns the list.
"""
text = contract_path.read_text(encoding="utf-8")
for block in re.findall(r"```yaml\n(.*?)```", text, re.S):
if "report_only_guests:" in block:
data = yaml.safe_load(block)
guests = data.get("report_only_guests") or []
if not isinstance(guests, list):
raise SystemExit("report_only_guests must be a list")
return guests
raise SystemExit(
f"no authoritative report_only_guests block found in {contract_path}"
)
def _keys(entry: dict) -> set[str]:
"""Guest/host identity keys for an exclusion entry."""
out: set[str] = set()
for field in ("guest", "id", "vmid", "hostname", "name", "ip"):
value = entry.get(field)
if value is not None and str(value).strip():
out.add(str(value).strip().lower())
return out
def _entry_keys(scan_entry: dict) -> set[str]:
out: set[str] = set()
for field in ("id", "guest", "vmid", "hostname", "name", "ip"):
value = scan_entry.get(field)
if value is not None and str(value).strip():
out.add(str(value).strip().lower())
return out
def level_for(pct: float) -> str | None:
if pct >= CRITICAL:
return "CRITICAL"
if pct >= RED:
return "RED"
if pct >= AMBER:
return "AMBER"
return None
def build_plan(scan: list[dict], report_only: list[dict]) -> list[dict]:
excluded = [(e, _keys(e)) for e in report_only]
plan: list[dict] = []
for entry in scan:
pct = entry.get("usage_pct")
if pct is None:
continue
level = level_for(float(pct))
if level is None:
continue # GREEN: log only, no action
scan_keys = _entry_keys(entry)
match = next((e for e, keys in excluded if keys & scan_keys), None)
target = next(
(entry.get(k) for k in ("id", "guest", "vmid", "hostname", "name", "ip")
if entry.get(k) not in (None, "")),
"?",
)
if match is not None:
plan.append({
"target": target,
"level": level,
"pct": float(pct),
"action": "report-only",
"reason": match.get("reason", "").strip(),
})
else:
plan.append({
"target": target,
"level": level,
"pct": float(pct),
"action": "gc-executor",
})
plan.sort(key=lambda row: row["pct"], reverse=True)
return plan
def main() -> int:
ap = argparse.ArgumentParser(description="Plan disk GC actions with the report-only gate.")
ap.add_argument("--scan", help="JSON file: list of {id|hostname|ip, usage_pct}")
ap.add_argument("--contract", default=str(DEFAULT_CONTRACT))
ap.add_argument("--json", action="store_true", help="emit the plan as JSON")
args = ap.parse_args()
raw = pathlib.Path(args.scan).read_text() if args.scan else sys.stdin.read()
try:
scan = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"invalid scan JSON: {exc}", file=sys.stderr)
return 1
if not isinstance(scan, list):
print("scan must be a JSON list", file=sys.stderr)
return 1
report_only = load_report_only_guests(pathlib.Path(args.contract))
plan = build_plan(scan, report_only)
if args.json:
print(json.dumps(plan, indent=2))
return 0
if not plan:
print("no threats (nothing at or above 75%)")
return 0
for row in plan:
if row["action"] == "report-only":
print(f" {row['target']} {row['level']} {row['pct']}% -> REPORT-ONLY (no GC) — {row['reason']}")
else:
print(f" {row['target']} {row['level']} {row['pct']}% -> gc-executor")
return 0
if __name__ == "__main__":
sys.exit(main())
+7 -6
View File
@@ -11,11 +11,13 @@ set -euo pipefail
# ── CT ID → PVE Node mapping (maintained HERE, not in prose contracts) ──
declare -A CT_NODES=(
# amdpve (192.168.68.15)
[111]=amdpve # tdunna
[105]=amdpve # kagentz (was hwepve — corrected 2026-09-12; live per pvesh)
[112]=amdpve # tanko
[113]=amdpve # baggy
[115]=amdpve # scottdenya
[120]=amdpve # adguard2 (added 2026-09-12)
# minipve (192.168.68.12)
[100]=minipve # abiba (was hwepve)
[102]=minipve # adguard (was acerpve)
[104]=minipve # authentik
[110]=minipve # gitea
@@ -25,17 +27,16 @@ declare -A CT_NODES=(
[106]=storepve # ra-h-os
[107]=storepve # proxmox-backup
[108]=storepve # media
[111]=storepve # tdunna (was amdpve — corrected 2026-09-12; live per pvesh)
[117]=storepve # zulip
[118]=storepve # jdownloader
# acerpve (192.168.68.9) — no CTs (bare metal GPU .8)
[100]=minipve # abiba (was hwepve)
[105]=minipve # kagentz (was hwepve)
# ocupve (192.168.68.5) — no CTs (bare metal GPU .110)
#
# REMOVED CTs (migrated to bare metal, decommissioned, or VMs):
# 101 llm-gpu → bare metal 192.168.68.8 (RTX 3090)
# 103 ocu-llm → bare metal 192.168.68.110 (RTX 5070)
# 109 docker-vm → KVM VM 192.168.68.7 (use direct SSH)
# 101 llm-gpu → bare metal 192.168.68.8 (RTX 3090) [QEMU VM on acerpve]
# 103 ocu-llm → bare metal 192.168.68.110 (RTX 5070) [QEMU VM on ocupve]
# 109 docker-vm → KVM VM 192.168.68.7 (use direct SSH) [QEMU VM on storepve]
)
# Each node must be root-accessible via SSH hostname