83e6167145
gates / gates (push) Successful in 4m0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
145 lines
7.0 KiB
Python
Executable File
145 lines
7.0 KiB
Python
Executable File
#!/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())
|