#!/usr/bin/env python3 """felhom-ep0-copy-gc — remove a DELETED customer's namespace from DooPlex's ep0-copy (R-901, `09` §3 decision 181). DooPlex pulls ep0's `felhom-offsite` into `ep0-copy` with `remove-vanished false`, so a namespace the customer delete cascade destroyed on ep0 stays on DooPlex for ever. The ruling: it is removed within 30 days. The rule, in one place (`decide`): * a top-level namespace present in the copy and ABSENT from ep0's own list is recorded with the day it was first seen absent (state file); * one that is absent for GRACE_DAYS (7) is deleted from the copy, groups and all; * one that reappears on ep0 is forgotten (a re-created customer, or a listing hiccup); * KEEP (`operator`, the hub database's copies) is never deleted. With the daily timer: deletion on ep0 → seen absent within a day → deleted 7 days later, inside the 30-day line. Fail-safe: if ep0's list cannot be read, or reads EMPTY, or more than MAX_ABSENT (2) copy namespaces are absent at once (a partial list — a rebuilt ep0 — not deletions), nothing is recorded and nothing is deleted; at most one deletion per run; a HOLD file (/etc/felhom/ep0-copy-gc/HOLD) stops it during any ep0 recovery. Default mode is a DRY RUN that only prints; `--apply` deletes. Secrets: the two PBS token secrets are read from root-only files into the child's environment (PBS_PASSWORD); never printed. Runbook: documentation/runbooks/ep0-datastore-copy.md, „Removing a deleted customer's copy". Tests: test_ep0_copy_gc.py (fake proxmox-backup-client on PATH). """ import argparse import datetime as dt import json import os import subprocess import sys COPY_NS_DIR = os.environ.get("EP0_COPY_NS_DIR", "/mnt/5_hdd/backup/ep0-copy/ns") STATE = os.environ.get("EP0_COPY_GC_STATE", "/var/lib/felhom-ep0-copy-gc/absent.json") EP0_REPO = os.environ.get("EP0_REPO", "root@pam!dooplex-sync@127.0.0.1:18007:felhom-offsite") EP0_TOKEN_FILE = os.environ.get("EP0_TOKEN_FILE", "/etc/felhom/ep0-copy-gc/ep0-reader.secret") EP0_FINGERPRINT_FILE = os.environ.get("EP0_FINGERPRINT_FILE", "/etc/felhom/ep0-copy-gc/ep0.fingerprint") LOCAL_REPO = os.environ.get("LOCAL_REPO", "root@pam!ep0-copy-gc@localhost:ep0-copy") LOCAL_TOKEN_FILE = os.environ.get("LOCAL_TOKEN_FILE", "/etc/felhom/ep0-copy-gc/local-gc.secret") GRACE_DAYS = int(os.environ.get("EP0_COPY_GC_GRACE_DAYS", "7")) KEEP = {"operator"} # Fail-safe guards (security review 2026-10-08): a PARTIAL ep0 list (a rebuilt or half-restored ep0, a token that sees # less) would read as „these customers were deleted". So: more than MAX_ABSENT absent at once → ABORT, nothing recorded; # at most MAX_DELETE deletions per run; and a HOLD file stops everything (create it during any ep0 recovery). MAX_ABSENT = int(os.environ.get("EP0_COPY_GC_MAX_ABSENT", "2")) MAX_DELETE = 1 HOLD_FILE = os.environ.get("EP0_COPY_GC_HOLD", "/etc/felhom/ep0-copy-gc/HOLD") def log(msg): print("ep0-copy-gc: " + msg, flush=True) def decide(copy_ns, ep0_ns, state, today, grace_days=GRACE_DAYS, keep=KEEP): """Pure rule. Returns (to_delete, new_state). state: {ns: 'YYYY-MM-DD' first seen absent}.""" new_state = {} to_delete = [] for ns in sorted(copy_ns): if ns in keep or ns in ep0_ns: continue first = state.get(ns, today.isoformat()) new_state[ns] = first if (today - dt.date.fromisoformat(first)).days >= grace_days: to_delete.append(ns) return to_delete, new_state def pbc(args, token_file, fingerprint_file=None): env = dict(os.environ) with open(token_file) as f: env["PBS_PASSWORD"] = f.read().strip() if fingerprint_file: with open(fingerprint_file) as f: env["PBS_FINGERPRINT"] = f.read().strip() return subprocess.run(["proxmox-backup-client"] + args, env=env, capture_output=True, text=True, timeout=300) def ep0_namespaces(): r = pbc(["namespace", "list", "--repository", EP0_REPO, "--output-format", "json"], EP0_TOKEN_FILE, EP0_FINGERPRINT_FILE) if r.returncode != 0: raise RuntimeError("ep0 namespace list failed (rc %d): %s" % (r.returncode, r.stderr.strip()[-200:])) out = set() for item in json.loads(r.stdout or "[]"): name = item.get("ns", "") if isinstance(item, dict) else str(item) top = name.split("/")[0] if top: out.add(top) return out def main(argv=None): ap = argparse.ArgumentParser() ap.add_argument("--apply", action="store_true", help="delete; without it, only print what would be deleted") a = ap.parse_args(argv) today = dt.date.today() if os.path.exists(HOLD_FILE): log("HOLD — %s exists (an ep0 recovery in progress?); nothing recorded, nothing deleted" % HOLD_FILE) return 0 copy_ns = {d for d in os.listdir(COPY_NS_DIR) if os.path.isdir(os.path.join(COPY_NS_DIR, d))} try: ep0 = ep0_namespaces() except Exception as e: # noqa: BLE001 — any failure means „could not tell" log("ABORT — %s; nothing recorded, nothing deleted" % e) return 2 if not ep0: log("ABORT — ep0 lists NO namespace (read as „could not tell\", never as „all deleted\"); nothing changed") return 2 try: with open(STATE) as f: state = json.load(f) except FileNotFoundError: state = {} to_delete, new_state = decide(copy_ns, ep0, state, today) if len(new_state) > MAX_ABSENT: log("ABORT — %d namespaces absent on ep0 at once (limit %d): ep0's list looks partial (a rebuild?), not like " "customer deletions; nothing recorded, nothing deleted. Absent: %s" % (len(new_state), MAX_ABSENT, ", ".join(sorted(new_state)))) return 2 if len(to_delete) > MAX_DELETE: log("limit: %d due, deleting %d this run (the rest stay due)" % (len(to_delete), MAX_DELETE)) to_delete = to_delete[:MAX_DELETE] for ns, first in sorted(new_state.items()): log("absent on ep0 since %s: %s" % (first, ns)) failed = 0 for ns in to_delete: if not a.apply: log("DRY RUN — would delete namespace %s from ep0-copy (absent on ep0 since %s)" % (ns, new_state[ns])) continue r = pbc(["namespace", "delete", ns, "--delete-groups", "true", "--repository", LOCAL_REPO], LOCAL_TOKEN_FILE) if r.returncode != 0: failed += 1 log("FAILED to delete namespace %s (rc %d): %s" % (ns, r.returncode, r.stderr.strip()[-200:])) continue log("DELETED namespace %s from ep0-copy (absent on ep0 since %s)" % (ns, new_state[ns])) new_state.pop(ns, None) os.makedirs(os.path.dirname(STATE), exist_ok=True) tmp = STATE + ".tmp" with open(tmp, "w") as f: json.dump(new_state, f, indent=1, sort_keys=True) os.replace(tmp, STATE) log("done: %d copy namespace(s), %d on ep0, %d absent, %d to delete%s, %d failed" % (len(copy_ns), len(ep0), len(new_state) + (len(to_delete) if a.apply else 0), len(to_delete), "" if a.apply else " (dry run)", failed)) return 1 if failed else 0 if __name__ == "__main__": sys.exit(main())