Files
felhom.eu/scripts/ep0-copy-gc/felhom-ep0-copy-gc
T

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())