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