Bind essentials recovery to explicit manifest and verified offsite provenance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
This commit is contained in:
codex 2026-09-06 09:25:26 +02:00
parent 4707bf08d7
commit 11322a5256
7 changed files with 151 additions and 14 deletions

View file

@ -61,8 +61,10 @@ def capture(namespace, pod, destination, profile="full"):
actual = hashlib.file_digest(source,'sha256').hexdigest() actual = hashlib.file_digest(source,'sha256').hexdigest()
if actual != expected: raise ValueError('archive transfer mismatch') if actual != expected: raise ValueError('archive transfer mismatch')
validate_archive(destination, profile) validate_archive(destination, profile)
if profile == "essentials" and destination.stat().st_size > 600*1024**2: if profile == "essentials":
raise ValueError("essentials archive exceeds budget") from forgejo_essentials_profile import seal
seal(destination)
validate_archive(destination, profile)
finally: finally:
# Do not remove a file while a timed-out producer may still be writing it. # Do not remove a file while a timed-out producer may still be writing it.
if completed: if completed:
@ -76,8 +78,8 @@ def main():
p.add_argument("--profile",choices=["full","essentials"],default="full") p.add_argument("--profile",choices=["full","essentials"],default="full")
a=p.parse_args() a=p.parse_args()
try: capture(a.namespace,a.pod,a.output,a.profile) try: capture(a.namespace,a.pod,a.output,a.profile)
except Exception: except Exception as error:
print('ERROR: Forgejo archive capture or integrity validation failed') print('ERROR: Forgejo archive capture or integrity validation failed ('+type(error).__name__+')')
return 1 return 1
print('Forgejo archive capture and integrity verified') print('Forgejo archive capture and integrity verified')
return 0 return 0

View file

@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Silent attended decryption of the hash-verified primary archive download."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
from state_hub_preflight_lane import bao, data
def digest(path):
with path.open('rb') as source: return hashlib.file_digest(source,'sha256').hexdigest()
def main():
p=argparse.ArgumentParser(description=__doc__)
for name in ['source','output','transfer-receipt','receipt']: p.add_argument('--'+name,required=True,type=Path)
a=p.parse_args();receipt={'schema':'platform.forgejo-primary-decryption.v1','status':'failed'}
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
try:
transfer=json.loads(a.transfer_receipt.read_text())
if (transfer.get('status')!='primary_fetched_pending_application_restore'
or not transfer.get('download_hash_matches')
or digest(a.source)!=transfer.get('ciphertext_sha256')):
raise ValueError('verified_primary_download_required')
value=data(bao(['read','-format=json','platform/data/workloads/railiance/backup/offsite-lane']))['data']['data']['AGE_PRIVATE_KEY']
with a.output.open('xb') as target:
a.output.chmod(0o600)
result=subprocess.run(['age','-d','-i','/dev/stdin',str(a.source)],input=(value.strip()+'\n').encode(),stdout=target,stderr=subprocess.PIPE,timeout=1200)
if result.returncode: raise ValueError('primary_decryption_failed')
receipt.update(transfer,decrypted=True,plaintext_sha256=digest(a.output))
except Exception:
receipt['error']='bounded_primary_decryption_failed'
finally:
with os.fdopen(fd,'w') as output: json.dump(receipt,output,indent=2)
return int(receipt['status']=='failed')
if __name__=='__main__': raise SystemExit(main())

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Seal a measured essentials archive with an explicit recovery contract."""
from datetime import datetime, timezone
import json
import os
from pathlib import PurePosixPath
import shutil
import stat
import zipfile
MANIFEST='backup-manifest.json'
OMITTED=('data/packages/','data/repo-archive/','data/indexers/','data/actions_log/','log/')
BUDGET=600*1024**2
def seal(path):
temporary=path.with_name(path.name+'.sealing')
fd=os.open(temporary,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600);os.close(fd)
try:
with zipfile.ZipFile(path) as source, zipfile.ZipFile(temporary,'w') as target:
kept=0
for member in source.infolist():
name=PurePosixPath(member.filename)
if name.is_absolute() or '..' in name.parts or stat.S_ISLNK(member.external_attr>>16):
raise ValueError('unsafe_archive_member')
if member.filename==MANIFEST: raise ValueError('archive_already_sealed')
if member.filename.startswith(OMITTED): continue
with source.open(member) as incoming, target.open(member,'w') as outgoing:
shutil.copyfileobj(incoming,outgoing,1024*1024)
kept+=1
target.writestr(MANIFEST,json.dumps({'schema':'platform.forgejo-backup-profile.v1',
'profile':'essentials','created_at':datetime.now(timezone.utc).isoformat(),
'complete_application_backup':False,'omitted_prefixes':list(OMITTED),
'kept_members':kept,'package_registry_available':False,
'recovery_contract':'Git and collaboration recovery; packages require full primary or an independent artifact source.',
'unique_data_policy':'Keep repositories, database, configuration, attachments, LFS and Actions artifacts when present.'},sort_keys=True),compress_type=zipfile.ZIP_DEFLATED)
if temporary.stat().st_size>BUDGET: raise ValueError('essentials_budget_exceeded')
os.replace(temporary,path)
finally:
temporary.unlink(missing_ok=True)

View file

@ -22,8 +22,14 @@ def file_digest(path):
with path.open('rb') as f: return hashlib.file_digest(f,'sha256').hexdigest() with path.open('rb') as f: return hashlib.file_digest(f,'sha256').hexdigest()
def run(archive, receipt): def run(archive, receipt, profile="full"):
validate_archive(archive) validate_archive(archive,profile)
if profile == "essentials":
with zipfile.ZipFile(archive) as bundle:
manifest=json.loads(bundle.read("backup-manifest.json"))
if manifest.get("profile")!="essentials" or manifest.get("package_registry_available") is not False:
raise ValueError("essentials_contract_required")
receipt["archive_profile"]=profile
prefix='wp0029-'+secrets.token_hex(5) prefix='wp0029-'+secrets.token_hex(5)
network=prefix+'-net'; db=prefix+'-db'; app=prefix+'-app' network=prefix+'-net'; db=prefix+'-db'; app=prefix+'-app'
staging=archive.parent/(prefix+'-staging') staging=archive.parent/(prefix+'-staging')
@ -90,6 +96,8 @@ DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false REQUIRE_SIGNIN_VIEW = false
[mailer] [mailer]
ENABLED = false ENABLED = false
[packages]
ENABLED = {"false" if profile == "essentials" else "true"}
[actions] [actions]
ENABLED = false ENABLED = false
[webhook] [webhook]
@ -150,6 +158,12 @@ LEVEL = Error
docker('exec','--user','1000:1000',app,'git','clone','--quiet','http://127.0.0.1:3000/'+repo+'.git',target) docker('exec','--user','1000:1000',app,'git','clone','--quiet','http://127.0.0.1:3000/'+repo+'.git',target)
docker('exec','--user','1000:1000',app,'git','-C',target,'fsck','--full') docker('exec','--user','1000:1000',app,'git','-C',target,'fsck','--full')
receipt['repositories_verified'].append(repo) receipt['repositories_verified'].append(repo)
if profile == 'essentials':
if any(p.is_file() for p in (staging/'data'/'packages').rglob('*')):
raise ValueError('unexpected_package_payload')
receipt.update(status='restored_essentials',package_registry_available=False,
primary_storage_accessed=False)
return
receipt['stage']='package_blob_recovery' receipt['stage']='package_blob_recovery'
# Each stored blob must survive extraction and match its database digest. # Each stored blob must survive extraction and match its database digest.
rows=docker('exec',db,'psql','-U','forgejo','-d','forgejo','-Atqc','SELECT hash_sha256 FROM package_blob;').stdout.decode().splitlines() rows=docker('exec',db,'psql','-U','forgejo','-d','forgejo','-Atqc','SELECT hash_sha256 FROM package_blob;').stdout.decode().splitlines()
@ -173,21 +187,29 @@ LEVEL = Error
def main(): def main():
p=argparse.ArgumentParser(description=__doc__) p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--archive',required=True,type=Path);p.add_argument('--receipt',required=True) p.add_argument('--archive',required=True,type=Path);p.add_argument('--receipt',required=True)
p.add_argument('--profile',choices=['full','essentials'],default='full')
p.add_argument('--transfer-receipt',required=True,type=Path) p.add_argument('--transfer-receipt',required=True,type=Path)
a=p.parse_args();receipt={'schema':'platform.forgejo-isolated-restore.v1','status':'failed','images':[FORGE,POSTGRES]} a=p.parse_args();receipt={'schema':'platform.forgejo-isolated-restore.v1','status':'failed','images':[FORGE,POSTGRES]}
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600) fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
try: try:
transfer=json.loads(a.transfer_receipt.read_text()) transfer=json.loads(a.transfer_receipt.read_text())
if (transfer.get('status') != 'offsite_fetched_pending_isolated_restore' nextcloud = (transfer.get('status') == 'offsite_fetched_pending_isolated_restore'
or transfer.get('download_http_status') != 200 and transfer.get('download_http_status') == 200)
primary = (transfer.get('status') == 'primary_fetched_pending_application_restore'
and transfer.get('download_hash_matches') is True and transfer.get('decrypted') is True)
if (not (nextcloud or primary)
or transfer.get('plaintext_sha256') != file_digest(a.archive)): or transfer.get('plaintext_sha256') != file_digest(a.archive)):
raise ValueError('verified_offsite_provenance_required') raise ValueError('verified_offsite_provenance_required')
receipt['offsite_artifact']=transfer['artifact'] receipt['source_provider']='Scaleway' if primary else 'Nextcloud'
receipt['offsite_artifact']=transfer['destination'] if primary else transfer['artifact']
receipt['ciphertext_sha256']=transfer['ciphertext_sha256'] receipt['ciphertext_sha256']=transfer['ciphertext_sha256']
run(a.archive,receipt) if transfer.get('archive_profile','full')!=a.profile: raise ValueError('profile_provenance_mismatch')
except Exception: receipt['error']='isolated_restore_failed' run(a.archive,receipt,a.profile)
except Exception:
receipt['status']='failed'
receipt['error']='isolated_restore_failed'
finally: finally:
with os.fdopen(fd,'w') as f: json.dump(receipt,f,indent=2) with os.fdopen(fd,'w') as f: json.dump(receipt,f,indent=2)
return int(receipt['status']!='restored' or not receipt.get('cleanup')) return int(receipt['status'] not in ('restored','restored_essentials') or not receipt.get('cleanup'))
if __name__=='__main__': raise SystemExit(main()) if __name__=='__main__': raise SystemExit(main())

View file

@ -74,16 +74,20 @@ def main():
fd=os.open(a.receipt,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(fd) fd=os.open(a.receipt,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600);os.close(fd)
def checkpoint(): a.receipt.write_text(json.dumps(receipt,indent=2)+'\n') def checkpoint(): a.receipt.write_text(json.dumps(receipt,indent=2)+'\n')
try: try:
receipt['stage']='source_validation';checkpoint()
source=json.loads(a.source_receipt.read_text()) source=json.loads(a.source_receipt.read_text())
if (source.get('status')!='offsite_fetched_pending_isolated_restore' if (source.get('status')!='offsite_fetched_pending_isolated_restore'
or not a.source.name.endswith('.zip.age') or a.output.exists() or not a.source.name.endswith('.zip.age') or a.output.exists()
or not 0<a.source.stat().st_size<=20*1024**3 or not 0<a.source.stat().st_size<=20*1024**3
or source.get('ciphertext_sha256')!=digest(a.source)): or source.get('ciphertext_sha256')!=digest(a.source)):
raise ValueError('verified_source_required') raise ValueError('verified_source_required')
receipt['stage']='cluster_identity';checkpoint()
k=['kubectl','--kubeconfig',a.kubeconfig];assert_cluster(k) k=['kubectl','--kubeconfig',a.kubeconfig];assert_cluster(k)
# Existing governed databases delivery, captured only in process memory. # Existing governed databases delivery, captured only in process memory.
receipt['stage']='existing_credential_delivery';checkpoint()
r=subprocess.run(k+['-n','databases','get','secret','platform-pg-backup-s3','-o','json'],capture_output=True,timeout=30,check=True) r=subprocess.run(k+['-n','databases','get','secret','platform-pg-backup-s3','-o','json'],capture_output=True,timeout=30,check=True)
values=json.loads(r.stdout)['data'] values=json.loads(r.stdout)['data']
receipt['stage']='client_setup';checkpoint()
import boto3 import boto3
from botocore.config import Config from botocore.config import Config
client=boto3.client('s3',endpoint_url=ENDPOINT,region_name='nl-ams', client=boto3.client('s3',endpoint_url=ENDPOINT,region_name='nl-ams',
@ -91,7 +95,8 @@ def main():
aws_secret_access_key=base64.b64decode(values['ACCESS_SECRET_KEY']).decode(), aws_secret_access_key=base64.b64decode(values['ACCESS_SECRET_KEY']).decode(),
config=Config(request_checksum_calculation='when_required',response_checksum_validation='when_required',signature_version='s3v4',connect_timeout=15,read_timeout=180,retries={'max_attempts':3},s3={'addressing_style':'path'})) config=Config(request_checksum_calculation='when_required',response_checksum_validation='when_required',signature_version='s3v4',connect_timeout=15,read_timeout=180,retries={'max_attempts':3},s3={'addressing_style':'path'}))
transfer(client,a.source,a.output,receipt,checkpoint) transfer(client,a.source,a.output,receipt,checkpoint)
except Exception: except Exception as error:
receipt['exception_type']=type(error).__name__
receipt.update(status='failed',error='bounded_primary_archive_transfer_failed');checkpoint() receipt.update(status='failed',error='bounded_primary_archive_transfer_failed');checkpoint()
return 1 return 1
return 0 return 0

View file

@ -6,6 +6,7 @@ import urllib.request
import shutil import shutil
import hashlib import hashlib
import json import json
import zipfile
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
@ -56,7 +57,10 @@ def run(source, directory, receipt):
result = subprocess.run(['age','-d','-i','/dev/stdin',str(encrypted)], result = subprocess.run(['age','-d','-i','/dev/stdin',str(encrypted)],
input=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode(), stdout=output, stderr=subprocess.PIPE, timeout=1200) input=(values['AGE_PRIVATE_KEY'].strip()+'\n').encode(), stdout=output, stderr=subprocess.PIPE, timeout=1200)
require(result.returncode == 0, 'offsite_decryption_failed') require(result.returncode == 0, 'offsite_decryption_failed')
receipt.update(decrypted=True, plaintext_sha256=sha(plain), quota_after=quota(auth), status='offsite_fetched_pending_isolated_restore') with zipfile.ZipFile(plain) as archive:
profile=json.loads(archive.read('backup-manifest.json'))['profile'] if 'backup-manifest.json' in archive.namelist() else 'full'
require(profile in ('full','essentials'), 'unknown_archive_profile')
receipt.update(archive_profile=profile,decrypted=True, plaintext_sha256=sha(plain), quota_after=quota(auth), status='offsite_fetched_pending_isolated_restore')
def main(): def main():

View file

@ -47,3 +47,29 @@ class BackupTiers(unittest.TestCase):
c.get_object.return_value={'ContentLength':3,'Body':io.BytesIO(b'xyz')} c.get_object.return_value={'ContentLength':3,'Body':io.BytesIO(b'xyz')}
with self.assertRaises(ValueError): primary.transfer(c,p,Path(d)/'out',{}) with self.assertRaises(ValueError): primary.transfer(c,p,Path(d)/'out',{})
c.delete_object.assert_not_called() c.delete_object.assert_not_called()
class EssentialsSealing(unittest.TestCase):
def test_manifest_and_unique_data_survive_bulk_exclusion(self):
from forgejo_essentials_profile import seal,MANIFEST
import json
with tempfile.TemporaryDirectory() as d:
path=Path(d)/'archive.zip'
with zipfile.ZipFile(path,'w') as z:
for name in ['forgejo-db.sql','repos/a.git/HEAD','data/lfs/original','data/attachments/original','data/actions_artifacts/original','data/packages/bulk','data/actions_log/build']:
z.writestr(name,b'fixture')
seal(path)
with zipfile.ZipFile(path) as z:
self.assertEqual(z.read('data/lfs/original'),b'fixture')
self.assertEqual(z.read('data/attachments/original'),b'fixture')
self.assertEqual(z.read('data/actions_artifacts/original'),b'fixture')
self.assertNotIn('data/packages/bulk',z.namelist())
self.assertNotIn('data/actions_log/build',z.namelist())
self.assertFalse(json.loads(z.read(MANIFEST))['package_registry_available'])
def test_over_budget_preserves_original_candidate(self):
import forgejo_essentials_profile as profile
with tempfile.TemporaryDirectory() as d:
path=Path(d)/'archive.zip'
with zipfile.ZipFile(path,'w') as z: z.writestr('repos/a.git/HEAD',b'fixture')
before=path.read_bytes()
with patch.object(profile,'BUDGET',1), self.assertRaises(ValueError): profile.seal(path)
self.assertEqual(path.read_bytes(),before)