railiance-platform/scripts/restore_forgejo_offsite_locally.py
codex a867ec269a
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Record archive recovery lifecycle and validate receipt provenance
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
2026-09-06 14:53:15 +02:00

225 lines
12 KiB
Python

#!/usr/bin/env python3
"""Restore a fetched Forgejo archive on a disposable, internal Docker network."""
import argparse
from datetime import datetime, timezone
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, profile="full"):
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)
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, timeout=180):
try:
r=subprocess.run(args,input=payload,capture_output=True,env=env,timeout=timeout)
except subprocess.TimeoutExpired:
receipt['failure_kind']='command_timeout'
raise ValueError('isolated_command_timeout') from None
if check and r.returncode:
receipt['failure_kind']='command_nonzero'
receipt['command_exit_code']=r.returncode
# Classify only fixed diagnostics; never publish captured output.
for marker, category in [(b'out of memory', 'memory_limit'),
(b'syntax error', 'sql_syntax'),
(b'does not exist', 'missing_database_object'),
(b'duplicate key', 'duplicate_database_key'),
(b'server closed the connection', 'database_connection_closed'),
(b'connection to server', 'database_connection_failed')]:
if marker in r.stderr.lower():
receipt['failure_kind']=category
break
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
[packages]
ENABLED = {"false" if profile == "essentials" else "true"}
[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))
# Initialization uses a temporary socket-only server; wait for final TCP.
# Then use the disposable container's trusted local socket for import.
for _ in range(30):
if docker('exec',db,'pg_isready','-h','127.0.0.1','-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','--single-transaction','-v','ON_ERROR_STOP=1',payload=sql,timeout=900)
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)
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'
# 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('--profile',choices=['full','essentials'],default='full')
p.add_argument('--transfer-receipt',required=True,type=Path)
a=p.parse_args();receipt={'schema':'platform.forgejo-isolated-restore.v1','status':'failed','started_at':datetime.now(timezone.utc).isoformat(),'images':[FORGE,POSTGRES]}
fd=os.open(a.receipt,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
try:
raw=a.transfer_receipt.read_bytes()
transfer=json.loads(raw)
receipt['transfer_receipt_sha256']=hashlib.sha256(raw).hexdigest()
nextcloud = (transfer.get('schema') == 'platform.real-offsite-recovery.v1'
and transfer.get('decrypted') is True
and transfer.get('upload_http_status') == 201
and transfer.get('status') == 'offsite_fetched_pending_isolated_restore'
and transfer.get('download_http_status') == 200)
primary = (transfer.get('schema') in ('platform.forgejo-primary-archive.v1', 'platform.forgejo-primary-decryption.v1')
and 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)):
raise ValueError('verified_offsite_provenance_required')
receipt['source_provider']='Scaleway' if primary else 'Nextcloud'
receipt['offsite_artifact']=transfer['destination'] if primary else transfer['artifact']
receipt['ciphertext_sha256']=transfer['ciphertext_sha256']
if transfer.get('archive_profile','full')!=a.profile: raise ValueError('profile_provenance_mismatch')
run(a.archive,receipt,a.profile)
if receipt.get('cleanup') is not True:
raise ValueError('restore_cleanup_incomplete')
except Exception:
receipt['status']='failed'
receipt['error']='isolated_restore_failed'
finally:
receipt['finished_at']=datetime.now(timezone.utc).isoformat()
with os.fdopen(fd,'w') as f: json.dump(receipt,f,indent=2)
return int(receipt['status'] not in ('restored','restored_essentials') or not receipt.get('cleanup'))
if __name__=='__main__': raise SystemExit(main())