railiance-platform/scripts/restore_forgejo_offsite_locally.py
codex 480d181549
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Bind isolated restore to fetched artifact and document archive integrity gates
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
2026-09-05 22:21:54 +02:00

174 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Restore a fetched Forgejo archive on a disposable, internal Docker network."""
import argparse
import configparser
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import secrets
import shutil
import stat
import subprocess
import time
import zipfile
from capture_forgejo_archive import validate_archive
FORGE='code.forgejo.org/forgejo/forgejo@sha256:e2684fd8707d486329084a695ed91999a4072a798e5409d45c1eb8a2911ff4b9'
POSTGRES='postgres@sha256:ff23cdce56cac62ada6f66013e1a50864c0abbe79d132d40b6e05bd80f378a70'
def file_digest(path):
with path.open('rb') as f: return hashlib.file_digest(f,'sha256').hexdigest()
def run(archive, receipt):
validate_archive(archive)
prefix='wp0029-'+secrets.token_hex(5)
network=prefix+'-net'; db=prefix+'-db'; app=prefix+'-app'
staging=archive.parent/(prefix+'-staging')
staging.mkdir(mode=0o700)
resources=[]
def command(args, payload=None, env=None, check=True):
r=subprocess.run(args,input=payload,capture_output=True,env=env,timeout=180)
if check and r.returncode: raise ValueError('isolated_command_failed')
return r
def docker(*args, **kw): return command(['docker',*args],**kw)
try:
receipt['stage']='archive_validation'
with zipfile.ZipFile(archive) as z:
if sum(i.file_size for i in z.infolist()) > 20*1024**3: raise ValueError('archive_too_large')
for info in z.infolist():
p=PurePosixPath(info.filename)
if p.is_absolute() or '..' in p.parts or stat.S_ISLNK(info.external_attr>>16): raise ValueError('unsafe_archive_member')
z.extractall(staging)
# Build an independent configuration; never reuse production app.ini.
password=secrets.token_urlsafe(32)
config=f'''APP_NAME = Isolated WP-0029 recovery
RUN_USER = git
RUN_MODE = prod
WORK_PATH = /data/gitea
[database]
DB_TYPE = postgres
HOST = {db}:5432
NAME = forgejo
USER = forgejo
PASSWD = {password}
SSL_MODE = disable
[repository]
ROOT = /data/git/repositories
[server]
APP_DATA_PATH = /data/gitea
DOMAIN = localhost
ROOT_URL = http://localhost:3000/
HTTP_PORT = 3000
DISABLE_SSH = true
LFS_START_SERVER = false
OFFLINE_MODE = true
[security]
INSTALL_LOCK = true
SECRET_KEY = {secrets.token_urlsafe(32)}
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
[mailer]
ENABLED = false
[actions]
ENABLED = false
[webhook]
ALLOWED_HOST_LIST = loopback
[cron]
ENABLED = false
[log]
MODE = console
LEVEL = Error
'''
ini=staging/'isolated.ini'; ini.write_text(config);ini.chmod(0o600)
receipt['stage']='isolated_database'
docker('network','create','--internal',network);resources.append(('network',network))
env=os.environ.copy();env['POSTGRES_PASSWORD']=password
docker('run','-d','--name',db,'--network',network,'--log-driver','none','--memory','1g',
'-e','POSTGRES_PASSWORD','-e','POSTGRES_USER=forgejo','-e','POSTGRES_DB=forgejo',POSTGRES,env=env)
resources.append(('container',db))
for _ in range(30):
if docker('exec',db,'pg_isready','-U','forgejo',check=False).returncode==0: break
time.sleep(2)
else: raise ValueError('database_not_ready')
sql=(staging/'forgejo-db.sql').read_bytes()
docker('exec','-i',db,'psql','-U','forgejo','-d','forgejo','-v','ON_ERROR_STOP=1',payload=sql)
receipt['database_import']=True
counts=docker('exec',db,'psql','-U','forgejo','-d','forgejo','-Atqc',
'SELECT (SELECT count(*) FROM repository),(SELECT count(*) FROM "user"),(SELECT count(*) FROM package_blob);').stdout.decode().strip()
numbers=[int(n) for n in counts.split('|')]
if min(numbers[:2]) <= 0: raise ValueError('empty_database')
receipt['database_counts']=dict(zip(['repositories','users','package_blobs'],numbers))
receipt['stage']='isolated_application'
docker('run','-d','--name',app,'--network',network,'--log-driver','none','--memory','1g',
'--entrypoint','sleep',FORGE,'7200');resources.append(('container',app))
docker('exec',app,'mkdir','-p','/data/gitea','/data/git/repositories')
docker('cp',str(staging/'data')+'/.',app+':/data/gitea/')
docker('cp',str(staging/'repos')+'/.',app+':/data/git/repositories/')
docker('cp',str(ini),app+':/data/isolated.ini')
docker('exec',app,'chown','-R','1000:1000','/data')
docker('exec','-d','--user','1000:1000',app,'forgejo','--config','/data/isolated.ini','web')
for _ in range(60):
r=docker('exec',app,'wget','-qO-','http://127.0.0.1:3000/api/healthz',check=False)
if r.returncode==0: break
time.sleep(2)
else: raise ValueError('application_not_ready')
receipt['application_health']=True
receipt['stage']='repository_recovery'
# Select restored public repositories only, keeping payloads out of evidence.
rows=docker('exec',db,'psql','-U','forgejo','-d','forgejo','-Atqc',
'SELECT u.lower_name || \'/\' || r.lower_name FROM repository r JOIN "user" u ON u.id=r.owner_id WHERE NOT r.is_private AND NOT r.is_empty LIMIT 2;').stdout.decode().splitlines()
if not rows: raise ValueError('public_repository_fixture_missing')
receipt['repositories_verified']=[]
for index,repo in enumerate(rows):
if any(c not in 'abcdefghijklmnopqrstuvwxyz0123456789-_./' for c in repo): raise ValueError('unsafe_repository_name')
response=docker('exec',app,'wget','-qO-','http://127.0.0.1:3000/api/v1/repos/'+repo).stdout
if json.loads(response)['full_name'].lower()!=repo: raise ValueError('repository_metadata_mismatch')
target='/tmp/restore-clone-'+str(index)
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')
receipt['repositories_verified'].append(repo)
receipt['stage']='package_blob_recovery'
# 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()
files=list((staging/'data'/'packages').rglob('*')) if (staging/'data'/'packages').exists() else []
available={p.name:p for p in files if p.is_file()}
verified=0
for digest in rows:
matches=[p for name,p in available.items() if digest in name]
if len(matches)!=1 or file_digest(matches[0])!=digest: raise ValueError('package_blob_missing_or_corrupt')
verified+=1
receipt['package_blobs_verified']=verified
receipt['status']='restored'
finally:
receipt['cleanup']=True
for kind,name in reversed(resources):
args=('rm','-f','-v',name) if kind=='container' else ('network','rm',name)
if docker(*args,check=False).returncode: receipt['cleanup']=False
shutil.rmtree(staging)
def main():
p=argparse.ArgumentParser(description=__doc__)
p.add_argument('--archive',required=True,type=Path);p.add_argument('--receipt',required=True)
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]}
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') != 'offsite_fetched_pending_isolated_restore'
or transfer.get('download_http_status') != 200
or transfer.get('plaintext_sha256') != file_digest(a.archive)):
raise ValueError('verified_offsite_provenance_required')
receipt['offsite_artifact']=transfer['artifact']
receipt['ciphertext_sha256']=transfer['ciphertext_sha256']
run(a.archive,receipt)
except Exception: receipt['error']='isolated_restore_failed'
finally:
with os.fdopen(fd,'w') as f: json.dump(receipt,f,indent=2)
return int(receipt['status']!='restored' or not receipt.get('cleanup'))
if __name__=='__main__': raise SystemExit(main())