railiance-apps/tools/vergabe_pilot_retention.py
tegwick bf77e378a8
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s
Record native recovery success and prepare attended pilot backups
Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a1abf1-7e95-7141-87c9-ee78332bf790
2026-11-06 10:55:12 +01:00

97 lines
5.1 KiB
Python

"""Contained owner retention for locally verified Vergabe provider points only."""
import base64
import json
import os
import urllib.error
import urllib.request
import vergabe_pilot_point as point
import vergabe_pilot_publish as publisher
from vergabe_pilot_recovery_store import Refused, require
BASELINE_SHA = "4915288384e3ba8a87f1427b8e10106ee7241c04184adb97f439e57846bef76f"
BASELINE_BYTES = 182521
class Objects:
def __init__(self, helper, auth):
self.root = publisher.HOST + "/remote.php/dav/files/Backup/railiance-backups/" + publisher.PREFIX + "/"
self.headers = {"Authorization": "Basic " + base64.b64encode((auth[0] + ":" + auth[1]).encode()).decode()}
self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), helper.NoRedirect())
def request(self, item, delete=False, etag=None):
point.run_time(item["run_id"])
url = self.root + "vergabe-pilot-" + item["run_id"] + ".zip.age"
headers = dict(self.headers)
if delete:
require(isinstance(etag, str) and etag.startswith('"') and etag.endswith('"')
and len(etag) < 1024 and not any(c in etag for c in "\r\n"), "strong_provider_etag_required")
headers["If-Match"] = etag
try:
with self.opener.open(urllib.request.Request(url, method="DELETE" if delete else "GET", headers=headers), timeout=60) as response:
content = response.read(1024 if delete else item["ciphertext_bytes"] + 1)
return response.status, content, response.headers.get("ETag")
except urllib.error.HTTPError as error:
return error.code, b"", None
def execute(plan, objects, removed):
import hashlib
require(0 < len(plan["keep"]) <= 10 and len(plan["candidates"]) <= 50, "retention_batch_bound")
baseline = [p for p in plan["keep"] if p["run_id"] == point.BASELINE]
require(len(baseline) == 1 and baseline[0]["ciphertext_sha256"] == BASELINE_SHA
and baseline[0]["ciphertext_bytes"] == BASELINE_BYTES, "verified_baseline_required")
require(not ({p["run_id"] for p in plan["keep"]} & {p["run_id"] for p in plan["candidates"]}),
"retention_sets_overlap")
etags = {}
# Independently recheck every retained point before allowing any deletion.
for item in [*plan["keep"], *plan["candidates"]]:
code, content, etag = objects.request(item)
require(code == 200 and len(content) == item["ciphertext_bytes"]
and hashlib.sha256(content).hexdigest() == item["ciphertext_sha256"], "retention_provider_bytes_changed")
require(isinstance(etag, str) and etag.startswith('"') and etag.endswith('"')
and not etag.startswith('W/') and not any(c in etag for c in "\r\n"), "provider_etag_required")
etags[item["run_id"]] = etag
deleted = []
for item in plan["candidates"]:
require(item["run_id"] != point.BASELINE, "baseline_delete_refused")
code, _, _ = objects.request(item, delete=True, etag=etags[item["run_id"]])
require(code == 204, "conditional_retention_delete_failed")
require(objects.request(item)[0] == 404, "retention_delete_readback_failed")
# Durable per-point tombstones make partial batches safely resumable.
removed(item)
deleted.append(item["run_id"])
return deleted
def child(root, output, inventory):
fd = os.open(output, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
receipt = {"schema": "railiance.vergabe-pilot-provider-retention.v1", "status": "failed",
"deleted": [], "application_changed": False, "local_copies_deleted": False}
try:
helper = publisher.owner_helpers()
publisher.require_owner_handoff()
credentials = publisher.custody(helper)
plan = point.retention_plan(inventory(root))
receipt["retained"] = [p["run_id"] for p in plan["keep"]]
receipt["planned_candidates"] = [p["run_id"] for p in plan["candidates"]]
receipt["quota_before"] = publisher.Provider(helper).quota(credentials["owner_auth"])
def removed(item):
path = root / item["run_id"] / "provider-removed.json"
marker = {**item, "status": "provider_removed_and_readback_absent"}
with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600), "w") as stream:
json.dump(marker, stream)
stream.flush()
os.fsync(stream.fileno())
receipt["deleted"].append(item["run_id"])
execute(plan, Objects(helper, credentials["owner_auth"]), removed)
receipt["status"] = "provider_retention_complete"
except BaseException as error: # noqa: BLE001 — preserve partial progress without private diagnostics
receipt["failure"] = str(error) if isinstance(error, Refused) else "provider_retention_failed"
finally:
with os.fdopen(fd, "w") as stream:
json.dump(receipt, stream, indent=2)
stream.flush()
os.fsync(stream.fileno())
return 0 if receipt["status"] == "provider_retention_complete" else 1