R-901: a deleted customer's audit rows go 1 year after the deletion (hub, unreleased); ep0-copy removal job written, not installed (decision 181); both times in the privacy draft
gates / gates (push) Successful in 4m9s
gates / gates (push) Successful in 4m9s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
Executable
+127
@@ -0,0 +1,127 @@
|
||||
#!/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, nothing is recorded and nothing is deleted (an empty answer
|
||||
is „could not tell", never „everything was deleted"). 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"}
|
||||
|
||||
|
||||
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()
|
||||
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)
|
||||
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())
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Felhom: remove a deleted customer's namespace from ep0-copy (R-901, decision 181)
|
||||
Documentation=https://gitea.dooplex.hu/admin/felhom.eu/src/branch/main/documentation/runbooks/ep0-datastore-copy.md
|
||||
After=felhom-ep0-pbs-tunnel.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
ExecStart=/usr/local/sbin/felhom-ep0-copy-gc --apply
|
||||
@@ -0,0 +1,9 @@
|
||||
[Unit]
|
||||
Description=Felhom: ep0-copy deleted-customer removal — daily, after the 05:00 pull and the 07:30 prune
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 08:00:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for felhom-ep0-copy-gc (R-901). No PBS is reached: `proxmox-backup-client` is a fake on PATH that answers
|
||||
`namespace list` from FAKE_EP0_NS and records every `namespace delete`. Each test asserts the CONSEQUENCE: which
|
||||
namespace was deleted from the copy, and that nothing is deleted when ep0's answer cannot be trusted.
|
||||
Run: python3 scripts/ep0-copy-gc/test_ep0_copy_gc.py"""
|
||||
import datetime as dt
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
SCRIPT = os.path.join(HERE, "felhom-ep0-copy-gc")
|
||||
|
||||
FAKE = r'''#!/usr/bin/env python3
|
||||
import json, os, sys
|
||||
a = sys.argv[1:]
|
||||
with open(os.environ["FAKE_LOG"], "a") as f:
|
||||
f.write(" ".join(a) + " pw=" + ("set" if os.environ.get("PBS_PASSWORD") else "missing") + "\n")
|
||||
if a[:2] == ["namespace", "list"]:
|
||||
if os.environ.get("FAKE_EP0_FAIL"):
|
||||
sys.stderr.write("connection refused\n"); sys.exit(255)
|
||||
print(json.dumps([{"ns": n} for n in json.loads(os.environ["FAKE_EP0_NS"])]))
|
||||
sys.exit(0)
|
||||
if a[:2] == ["namespace", "delete"]:
|
||||
sys.exit(0)
|
||||
sys.exit(9)
|
||||
'''
|
||||
|
||||
|
||||
def load():
|
||||
loader = importlib.machinery.SourceFileLoader("gc", SCRIPT)
|
||||
spec = importlib.util.spec_from_loader("gc", loader)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
class Decide(unittest.TestCase):
|
||||
def test_rule(self):
|
||||
m = load()
|
||||
d = dt.date(2026, 10, 8)
|
||||
state = {"old-cust": "2026-09-30", "fresh": "2026-10-07", "back": "2026-09-01"}
|
||||
dele, new = m.decide({"old-cust", "fresh", "new-gone", "live", "operator", "back"},
|
||||
{"live", "back"}, state, d)
|
||||
self.assertEqual(dele, ["old-cust"]) # absent 8 days → delete
|
||||
self.assertEqual(new.get("fresh"), "2026-10-07") # absent 1 day → kept, remembered
|
||||
self.assertEqual(new.get("new-gone"), "2026-10-08") # first seen absent today
|
||||
self.assertNotIn("back", new) # reappeared on ep0 → forgotten
|
||||
self.assertNotIn("operator", new) # KEEP is never a candidate
|
||||
self.assertNotIn("live", new)
|
||||
|
||||
|
||||
class EndToEnd(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.t = tempfile.mkdtemp()
|
||||
bindir = os.path.join(self.t, "bin"); os.makedirs(bindir)
|
||||
p = os.path.join(bindir, "proxmox-backup-client")
|
||||
open(p, "w").write(FAKE); os.chmod(p, os.stat(p).st_mode | stat.S_IEXEC)
|
||||
self.ns = os.path.join(self.t, "ns")
|
||||
for n in ("demo-hp", "gone-cust", "operator"):
|
||||
os.makedirs(os.path.join(self.ns, n))
|
||||
for f in ("ep0.secret", "local.secret", "fp"):
|
||||
open(os.path.join(self.t, f), "w").write("x")
|
||||
self.state = os.path.join(self.t, "state", "absent.json")
|
||||
self.log = os.path.join(self.t, "calls.log")
|
||||
self.env = dict(os.environ, PATH=bindir + ":" + os.environ["PATH"], EP0_COPY_NS_DIR=self.ns,
|
||||
EP0_COPY_GC_STATE=self.state, EP0_TOKEN_FILE=os.path.join(self.t, "ep0.secret"),
|
||||
EP0_FINGERPRINT_FILE=os.path.join(self.t, "fp"), LOCAL_TOKEN_FILE=os.path.join(self.t, "local.secret"),
|
||||
FAKE_LOG=self.log, FAKE_EP0_NS=json.dumps(["demo-hp", "operator"]))
|
||||
|
||||
def run_gc(self, *args, **env):
|
||||
e = dict(self.env, **env)
|
||||
return subprocess.run([SCRIPT] + list(args), env=e, capture_output=True, text=True)
|
||||
|
||||
def deletes(self):
|
||||
if not os.path.exists(self.log):
|
||||
return []
|
||||
return [l.split()[2] for l in open(self.log) if l.startswith("namespace delete")]
|
||||
|
||||
def seed(self, ns, first):
|
||||
os.makedirs(os.path.dirname(self.state), exist_ok=True)
|
||||
json.dump({ns: first}, open(self.state, "w"))
|
||||
|
||||
def test_apply_deletes_after_grace(self):
|
||||
self.seed("gone-cust", (dt.date.today() - dt.timedelta(days=8)).isoformat())
|
||||
r = self.run_gc("--apply")
|
||||
self.assertEqual(r.returncode, 0, r.stdout + r.stderr)
|
||||
self.assertEqual(self.deletes(), ["gone-cust"])
|
||||
self.assertIn("pw=set", open(self.log).read())
|
||||
self.assertEqual(json.load(open(self.state)), {})
|
||||
|
||||
def test_inside_grace_nothing_deleted(self):
|
||||
r = self.run_gc("--apply")
|
||||
self.assertEqual(r.returncode, 0)
|
||||
self.assertEqual(self.deletes(), [])
|
||||
self.assertIn("gone-cust", json.load(open(self.state)))
|
||||
|
||||
def test_dry_run_never_deletes(self):
|
||||
self.seed("gone-cust", "2026-01-01")
|
||||
r = self.run_gc()
|
||||
self.assertEqual(self.deletes(), [])
|
||||
self.assertIn("DRY RUN", r.stdout)
|
||||
|
||||
def test_ep0_empty_list_aborts(self):
|
||||
self.seed("gone-cust", "2026-01-01")
|
||||
r = self.run_gc("--apply", FAKE_EP0_NS="[]")
|
||||
self.assertEqual(r.returncode, 2)
|
||||
self.assertEqual(self.deletes(), [])
|
||||
|
||||
def test_ep0_unreachable_aborts(self):
|
||||
self.seed("gone-cust", "2026-01-01")
|
||||
r = self.run_gc("--apply", FAKE_EP0_FAIL="1")
|
||||
self.assertEqual(r.returncode, 2)
|
||||
self.assertEqual(self.deletes(), [])
|
||||
self.assertEqual(json.load(open(self.state)), {"gone-cust": "2026-01-01"}) # state untouched
|
||||
|
||||
def test_secret_never_printed(self):
|
||||
open(os.path.join(self.t, "ep0.secret"), "w").write("SEKRIT-VALUE")
|
||||
self.seed("gone-cust", "2026-01-01")
|
||||
r = self.run_gc("--apply")
|
||||
self.assertNotIn("SEKRIT", r.stdout + r.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user