ep0-copy-gc: read ep0's real {"data": [...]} namespace answer; any other shape aborts (found on the first live dry run, guard held)
gates / gates (push) Successful in 4m31s

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:
2026-10-09 07:30:08 +02:00
parent ba7174012a
commit 69fa9cf784
6 changed files with 55 additions and 4 deletions
+10
View File
@@ -1,3 +1,13 @@
## 2026-10-09 — ep0-copy-gc reads ep0's real namespace answer (found on the first live dry run)
- `scripts/ep0-copy-gc/felhom-ep0-copy-gc`: `proxmox-backup-client namespace list --output-format json` answers
`{"data": [{"ns": ""}, {"ns": "demo-hp"}, …]}`; the job iterated the wrapper object and read `data` as ep0's only
namespace. On DooPlex's first dry run (operator present) all four customer namespaces read „absent" and the
mass-absence guard stopped it — nothing recorded, nothing deleted. With two or fewer customers it would have started
their 7-day clock. Now any shape but `{"data": [{"ns": str}, …]}` aborts as „could not tell". The test fake printed a
bare list (a model of the tool, not the tool) and now prints the measured shape; `test_ep0_unknown_shape_aborts`
(red-proved: 4 failures with the real shape before the fix).
## facebook — fb_probe: a deleted post answers (#10) „Object does not exist" (2026-10-08, fixed without a row)
- The first write run proved a text post gone only on code 100; Meta v26.0 answers a deleted scheduled post with
+10 -3
View File
@@ -76,10 +76,17 @@ 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:]))
# The real answer is {"data": [{"ns": ""}, {"ns": "demo-hp"}, ...]} (measured on DooPlex 2026-10-09). Any other
# shape is „could not tell": the first version iterated the wrapper object, read „data" as ep0's only namespace,
# and was stopped on its first dry run only by the mass-absence guard. Pinned by test_ep0_unknown_shape_aborts and
# by the fake, which now prints the real shape.
doc = json.loads(r.stdout or "null")
items = doc.get("data") if isinstance(doc, dict) else None
if not isinstance(items, list) or not all(isinstance(i, dict) and isinstance(i.get("ns"), str) for i in items):
raise RuntimeError("ep0 namespace list has an unexpected shape: %s" % (r.stdout or "").strip()[:120])
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]
for item in items:
top = item["ns"].split("/")[0]
if top:
out.add(top)
return out
+14 -1
View File
@@ -24,7 +24,12 @@ with open(os.environ["FAKE_LOG"], "a") as f:
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"])]))
if os.environ.get("FAKE_EP0_RAW"):
print(os.environ["FAKE_EP0_RAW"]); sys.exit(0)
# the REAL shape, measured on DooPlex 2026-10-09: {"data": [{"ns": ""}, {"ns": "demo-hp"}, ...]} -- a root entry
# and a wrapper object. The first fake printed a bare list, so the tests passed while the job read „data" as
# ep0's only namespace on the real tool (stopped live only by the mass-absence guard).
print(json.dumps({"data": [{"ns": ""}] + [{"ns": n} for n in json.loads(os.environ["FAKE_EP0_NS"])]}))
sys.exit(0)
if a[:2] == ["namespace", "delete"]:
sys.exit(0)
@@ -112,6 +117,14 @@ class EndToEnd(unittest.TestCase):
self.assertEqual(r.returncode, 2)
self.assertEqual(self.deletes(), [])
def test_ep0_unknown_shape_aborts(self):
# an answer the job cannot read is „could not tell", never „these customers are gone"
self.seed("gone-cust", "2026-01-01")
for raw in ('{"result": [{"ns": "demo-hp"}]}', '["demo-hp"]', '{"data": "x"}'):
r = self.run_gc("--apply", FAKE_EP0_RAW=raw)
self.assertEqual(r.returncode, 2, raw)
self.assertEqual(self.deletes(), [], raw)
def test_ep0_unreachable_aborts(self):
self.seed("gone-cust", "2026-01-01")
r = self.run_gc("--apply", FAKE_EP0_FAIL="1")