Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a06ecb-456a-71c2-b41e-0755d336e883
87 lines
4.1 KiB
Python
87 lines
4.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Capture only a completed, byte-identical and CRC-verified Forgejo ZIP."""
|
|
import argparse
|
|
import hashlib
|
|
from pathlib import Path
|
|
import secrets
|
|
import subprocess
|
|
import time
|
|
import zipfile
|
|
|
|
|
|
ESSENTIALS_FLAGS = ['--skip-package-data', '--skip-log', '--skip-index', '--skip-repo-archives']
|
|
|
|
|
|
def validate_archive(path, profile="full"):
|
|
with zipfile.ZipFile(path) as archive:
|
|
if sum(i.file_size for i in archive.infolist()) > 20*1024**3:
|
|
raise ValueError('archive exceeds recovery size bound')
|
|
names = set(archive.namelist())
|
|
if 'forgejo-db.sql' not in names or not any(n.startswith('repos/') for n in names):
|
|
raise ValueError('required archive content missing')
|
|
if profile == 'essentials' and any(n.startswith(('data/packages/', 'data/repo-archive/', 'data/indexers/')) and not n.endswith('/') for n in names):
|
|
raise ValueError('excluded data present in essentials archive')
|
|
if archive.testzip() is not None:
|
|
raise ValueError('archive checksum failure')
|
|
|
|
|
|
def capture(namespace, pod, destination, profile="full"):
|
|
if profile not in ("full", "essentials"): raise ValueError("unknown archive profile")
|
|
k = ['kubectl', 'exec', '--request-timeout=120s', '-n', namespace, pod, '-c', 'gitea', '--']
|
|
def call(args, timeout=150):
|
|
r = subprocess.run(k + args, capture_output=True, timeout=timeout)
|
|
if r.returncode: raise ValueError('capture command failed')
|
|
return r.stdout
|
|
remote = '/tmp/wp0029-backup-' + secrets.token_hex(12)
|
|
completed = False
|
|
try:
|
|
# Completion belongs to this exact process, not a global pgrep or file existence.
|
|
flags = ' '.join(ESSENTIALS_FLAGS) if profile == 'essentials' else ''
|
|
command = f'umask 077; forgejo dump {flags} -f {remote}.zip >{remote}.log 2>&1; result=$?; printf "%s" "$result" >{remote}.exit'
|
|
call(['sh','-c', 'nohup sh -c "$1" >/dev/null 2>&1 </dev/null &', 'sh', command])
|
|
for _ in range(180):
|
|
r = subprocess.run(k + ['cat',remote+'.exit'], capture_output=True, timeout=30)
|
|
if r.returncode == 0:
|
|
completed = True
|
|
if r.stdout.strip() != b'0': raise ValueError('dump process failed')
|
|
break
|
|
time.sleep(10)
|
|
else: raise ValueError('dump completion timeout')
|
|
size = int(call(['stat','-c%s',remote+'.zip']).strip())
|
|
expected = call(['sha256sum',remote+'.zip']).split()[0].decode()
|
|
if size <= 0: raise ValueError('empty archive')
|
|
chunk = 4*1024*1024
|
|
with destination.open('xb') as out:
|
|
destination.chmod(0o600)
|
|
for index in range((size+chunk-1)//chunk):
|
|
piece = call(['dd','if='+remote+'.zip','bs='+str(chunk),'skip='+str(index),'count=1'])
|
|
if len(piece) != min(chunk,size-index*chunk): raise ValueError('short archive chunk')
|
|
out.write(piece)
|
|
with destination.open('rb') as source:
|
|
actual = hashlib.file_digest(source,'sha256').hexdigest()
|
|
if actual != expected: raise ValueError('archive transfer mismatch')
|
|
validate_archive(destination, profile)
|
|
if profile == "essentials":
|
|
from forgejo_essentials_profile import seal
|
|
seal(destination)
|
|
validate_archive(destination, profile)
|
|
finally:
|
|
# Do not remove a file while a timed-out producer may still be writing it.
|
|
if completed:
|
|
call(['rm','-f',remote+'.zip',remote+'.log',remote+'.exit'])
|
|
|
|
|
|
def main():
|
|
p=argparse.ArgumentParser(description=__doc__)
|
|
p.add_argument('--namespace',required=True); p.add_argument('--pod',required=True)
|
|
p.add_argument('--output',required=True,type=Path)
|
|
p.add_argument("--profile",choices=["full","essentials"],default="full")
|
|
a=p.parse_args()
|
|
try: capture(a.namespace,a.pod,a.output,a.profile)
|
|
except Exception as error:
|
|
print('ERROR: Forgejo archive capture or integrity validation failed ('+type(error).__name__+')')
|
|
return 1
|
|
print('Forgejo archive capture and integrity verified')
|
|
return 0
|
|
|
|
if __name__=='__main__': raise SystemExit(main())
|