56 lines
2.9 KiB
Python
56 lines
2.9 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Plan bounded essentials retention; never delete provider objects."""
|
||
|
|
import argparse
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
import re
|
||
|
|
|
||
|
|
NAME=re.compile(r'^forgejo-essentials-(\d{8}T\d{6}Z)\.zip\.age$')
|
||
|
|
DIGEST=re.compile(r'^[0-9a-f]{64}$')
|
||
|
|
RESERVE=2*1024**3
|
||
|
|
MAX_INCOMING=600*1024**2
|
||
|
|
|
||
|
|
|
||
|
|
def plan(inventory, protected, incoming_bytes, available_bytes, now=None):
|
||
|
|
now=now or datetime.now(timezone.utc)
|
||
|
|
if not protected or not 0<incoming_bytes<=MAX_INCOMING or available_bytes<0:
|
||
|
|
raise ValueError('invalid_retention_contract')
|
||
|
|
items={};ignored=[]
|
||
|
|
for item in inventory:
|
||
|
|
name=item['name'];match=NAME.fullmatch(name)
|
||
|
|
if not match or item.get('verified') is not True:
|
||
|
|
ignored.append(name);continue
|
||
|
|
stamp=datetime.strptime(match[1],'%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc)
|
||
|
|
etag=item.get('etag','')
|
||
|
|
if (name in items or stamp>now or not DIGEST.fullmatch(item.get('sha256',''))
|
||
|
|
or not etag.startswith('"') or not etag.endswith('"')
|
||
|
|
or any(c in etag for c in '\r\n') or not 0<item.get('bytes',0)<=MAX_INCOMING):
|
||
|
|
raise ValueError('unverified_object_identity')
|
||
|
|
items[name]=(stamp,item)
|
||
|
|
if not set(protected)<=items.keys(): raise ValueError('protected_recovery_point_missing')
|
||
|
|
ordered=sorted(items,key=lambda name:items[name][0],reverse=True)
|
||
|
|
daily={};weekly={}
|
||
|
|
for name in ordered:
|
||
|
|
stamp=items[name][0]
|
||
|
|
daily.setdefault(stamp.date(),name)
|
||
|
|
weekly.setdefault(stamp.isocalendar()[:2],name)
|
||
|
|
keep=set(protected)|set(list(daily.values())[:7])|set(list(weekly.values())[:2])
|
||
|
|
keep.add(ordered[0])
|
||
|
|
candidates=[{'name':name,'etag':items[name][1]['etag'],'bytes':items[name][1]['bytes']}
|
||
|
|
for name in ordered if name not in keep]
|
||
|
|
return {'schema':'platform.essentials-retention-plan.v1','status':'ready' if available_bytes>=incoming_bytes+RESERVE else 'insufficient_upload_headroom',
|
||
|
|
'keep':sorted(keep),'protected':sorted(protected),'delete_candidates':candidates,'ignored':sorted(ignored),
|
||
|
|
'incoming_bytes':incoming_bytes,'available_bytes':available_bytes,'reserved_bytes':RESERVE,
|
||
|
|
'upload_before_expiration':True,'provider_mutations':False,
|
||
|
|
'execution_gate':'Separate owner executor must revalidate ETags, protected recovery receipts and completed replacement before expiration.'}
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
p=argparse.ArgumentParser(description=__doc__);p.add_argument('--inventory',required=True,type=Path)
|
||
|
|
p.add_argument('--protected',required=True,action='append');p.add_argument('--incoming-bytes',required=True,type=int)
|
||
|
|
p.add_argument('--available-bytes',required=True,type=int);a=p.parse_args()
|
||
|
|
result=plan(json.loads(a.inventory.read_text()),a.protected,a.incoming_bytes,a.available_bytes)
|
||
|
|
print(json.dumps(result,indent=2));return int(result['status']!='ready')
|
||
|
|
if __name__=='__main__': raise SystemExit(main())
|