Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
174 lines
8.2 KiB
Python
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())
|