#!/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