railiance-platform/scripts/execute_nextcloud_retention.py
codex a3ca4b708f
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Verify full and essentials recovery and implement bounded retention tooling
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
2026-09-06 11:10:50 +02:00

86 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Attended owner expiration of reviewed essentials candidates; no workload deletion grant."""
import argparse
import base64
import json
import hashlib
import fcntl
import os
from pathlib import Path
import urllib.request
from backup_retention_plan import NAME, RESERVE
from migrate_nextcloud_backup_account import HOST, OPERATOR_PATH, NoRedirect, quota, require
from state_hub_preflight_lane import bao,data
def validate(plan, restore):
require(plan.get('schema')=='platform.essentials-retention-plan.v1' and plan.get('status')=='ready','ready_plan_required')
keep=set(plan['keep']); protected=set(plan['protected'])
require(bool(protected) and protected<=keep,'protected_anchor_required')
require(restore.get('status')=='restored_essentials' and restore.get('cleanup') is True
and restore.get('offsite_artifact') in protected,'verified_recovery_anchor_required')
candidates=plan['delete_candidates']; names=[item['name'] for item in candidates]
require(len(names)==len(set(names)) and not keep.intersection(names),'ambiguous_expiration_plan')
for name in list(keep)+names: require(NAME.fullmatch(name) is not None,'managed_essentials_name_required')
for item in candidates:
etag=item['etag']
require(etag.startswith('"') and etag.endswith('"') and not any(c in etag for c in '\r\n'),'strong_etag_required')
return candidates
def execute(plan,restore,receipt):
candidates=validate(plan,restore)
if not candidates:
receipt['status']='no_expiration_needed';return
entry=data(bao(['read','-format=json',OPERATOR_PATH]))['data']['data']
require(entry['BACKUP_USERNAME']=='Backup','backup_owner_required')
auth=(entry['BACKUP_USERNAME'],entry['BACKUP_PASSWORD'])
headers={'Authorization':'Basic '+base64.b64encode((auth[0]+':'+auth[1]).encode()).decode()}
opener=urllib.request.build_opener(NoRedirect())
root=HOST+'/remote.php/dav/files/Backup/railiance-backups/forgejo/'
receipt['quota_before']=quota(auth)
require(receipt['quota_before']['available_bytes']>=RESERVE,'headroom_recheck_failed')
# Verify every retained recovery point is present before any expiration.
for name in plan['keep']:
with opener.open(urllib.request.Request(root+name,method='HEAD',headers=headers),timeout=60) as response:
require(response.status==200 and int(response.headers['Content-Length'])>0,'retained_point_missing')
# Revalidate the exact recovered ciphertext, not merely a nonempty name.
with opener.open(urllib.request.Request(root+restore['offsite_artifact'],headers=headers),timeout=60) as response:
require(0<int(response.headers['Content-Length'])<=600*1024**2,'anchor_size_outside_budget')
digest=hashlib.sha256()
while block:=response.read(1024*1024): digest.update(block)
require(digest.hexdigest()==restore['ciphertext_sha256'],'recovery_anchor_changed')
receipt['removed']=[]
for item in candidates:
with opener.open(urllib.request.Request(root+item['name'],method='HEAD',headers=headers),timeout=60) as response:
require(response.headers.get('ETag')==item['etag'] and int(response.headers['Content-Length'])==item['bytes'],'candidate_changed')
conditional=dict(headers,**{'If-Match':item['etag']})
with opener.open(urllib.request.Request(root+item['name'],method='DELETE',headers=conditional),timeout=60) as response:
require(response.status==204,'conditional_expiration_failed')
receipt['removed'].append(item['name'])
receipt.update(status='expired',quota_after=quota(auth))
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--plan',required=True,type=Path);p.add_argument('--restore-receipt',required=True,type=Path)
p.add_argument('--receipt',required=True);p.add_argument('--apply',action='store_true')
args=p.parse_args();result={'schema':'platform.essentials-retention-execution.v1','status':'failed'}
fd=os.open(args.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
try:
plan=json.loads(args.plan.read_text());restore=json.loads(args.restore_receipt.read_text())
candidates=validate(plan,restore)
if args.apply:
lock=os.open('/tmp/railiance-platform-essentials-retention-'+str(os.getuid())+'.lock',
os.O_WRONLY|os.O_CREAT|os.O_NOFOLLOW,0o600)
try:
require(os.fstat(lock).st_uid==os.getuid(),'retention_lock_owner_mismatch')
fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
execute(plan,restore,result)
finally: os.close(lock)
else: result.update(status='preview',candidate_count=len(candidates),provider_mutations=False)
except Exception: result['error']='bounded_essentials_expiration_refused'
finally:
with os.fdopen(fd,'w') as output: json.dump(result,output,indent=2)
return int(result['status']=='failed')
if __name__=='__main__': raise SystemExit(main())