Files
dtf-system/local/backup.py
Cauê Faleiros 98c951d374
Some checks failed
Validate, publish and deploy / validate (push) Successful in 2m2s
Validate, publish and deploy / publish-and-deploy (push) Failing after 8s
first commit
2026-09-15 16:42:34 -03:00

129 lines
6.4 KiB
Python

"""Local PostgreSQL and clean-object backup with isolated restore verification."""
import argparse
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import subprocess
from uuid import uuid4
ROOT = Path(__file__).resolve().parent.parent
BACKUPS = ROOT / 'backups'
def docker(script, *args, **kwargs):
return subprocess.run(['docker','compose','exec','-T','db','sh','-c',script,'sh',*args],
cwd=ROOT,check=True,**kwargs)
def checksum(path):
digest=hashlib.sha256()
with path.open('rb') as stream:
for block in iter(lambda:stream.read(1048576),b''):digest.update(block)
return digest.hexdigest()
def compose_exec(service, *command, **kwargs):
return subprocess.run(['docker','compose','exec','-T',service,*command],
cwd=ROOT,check=True,**kwargs)
def create():
BACKUPS.mkdir(mode=0o700,exist_ok=True)
prefix='dtf-'+datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')+'-'+uuid4().hex[:8]
database=BACKUPS/(prefix+'.dump')
objects=BACKUPS/(prefix+'.objects.tar.gz')
manifest_path=BACKUPS/(prefix+'.manifest.json')
sidecar=BACKUPS/(prefix+'.manifest.sha256')
created=[]
try:
with database.open('xb') as stream:
created.append(database);database.chmod(0o600)
docker('pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom',stdout=stream)
with objects.open('xb') as stream:
created.append(objects);objects.chmod(0o600)
result=compose_exec('api','python','-m','local.storage_backup','export',
stdout=stream,stderr=subprocess.PIPE)
summaries=[line.removeprefix('DTF_BACKUP_SUMMARY ') for line in
result.stderr.decode().splitlines() if line.startswith('DTF_BACKUP_SUMMARY ')]
if len(summaries)!=1:
raise RuntimeError('Object backup did not return a valid summary')
manifest={'format':'dtf-local-backup-v2',
'created_at':datetime.now(timezone.utc).isoformat(),
'database':{'file':database.name,'sha256':checksum(database)},
'objects':{'file':objects.name,'sha256':checksum(objects),
**json.loads(summaries[0])}}
manifest_path.write_text(json.dumps(manifest,indent=2,sort_keys=True)+'\n')
created.append(manifest_path);manifest_path.chmod(0o600)
sidecar.write_text(checksum(manifest_path)+'\n')
created.append(sidecar);sidecar.chmod(0o600)
except Exception:
for path in reversed(created):path.unlink(missing_ok=True)
raise
print(manifest_path.relative_to(ROOT))
return manifest_path
def verify_database(path):
if checksum(path)!=path.with_suffix('.sha256').read_text().strip():
raise ValueError('Backup checksum mismatch')
database='dtf_verify_'+uuid4().hex
docker('createdb -U "$POSTGRES_USER" "$1"',database)
try:
with path.open('rb') as stream:
docker('pg_restore -U "$POSTGRES_USER" -d "$1" --exit-on-error --no-owner --no-privileges',database,stdin=stream)
result=docker('psql -U "$POSTGRES_USER" -d "$1" -At -v ON_ERROR_STOP=1 -c "SELECT json_build_object(\'orders\',(SELECT count(*) FROM dtf_local.orders),\'uploads\',(SELECT count(*) FROM dtf_local.uploads),\'accounts\',(SELECT count(*) FROM dtf_local.accounts),\'history\',(SELECT count(*) FROM dtf_local.movements));"',database,capture_output=True,text=True)
print('PASS: backup restored to isolated database; restored counts '+result.stdout.strip())
finally:
# Only the freshly created UUID-named verification database is removed.
docker('dropdb -U "$POSTGRES_USER" "$1"',database)
print('Removed temporary verification database. Active database was untouched.')
def bundle_file(manifest_path, name):
if not isinstance(name,str) or Path(name).name!=name:
raise ValueError('Backup manifest contains an invalid filename')
path=(manifest_path.parent/name).resolve()
if path.parent!=BACKUPS.resolve():
raise ValueError('Backup manifest references a file outside backups/')
return path
def verify(path):
path=path.resolve()
if path.parent!=BACKUPS.resolve():
raise ValueError('Choose a backup generated in this repository backups/ directory')
if path.suffix=='.dump':
return verify_database(path)
if not path.name.endswith('.manifest.json'):
raise ValueError('Choose a v2 .manifest.json or legacy .dump backup')
sidecar=path.with_name(path.name.removesuffix('.json')+'.sha256')
if checksum(path)!=sidecar.read_text().strip():
raise ValueError('Backup manifest checksum mismatch')
manifest=json.loads(path.read_text())
if manifest.get('format')!='dtf-local-backup-v2':
raise ValueError('Unsupported backup format')
database=bundle_file(path,manifest.get('database',{}).get('file'))
objects=bundle_file(path,manifest.get('objects',{}).get('file'))
if checksum(database)!=manifest['database'].get('sha256') or checksum(objects)!=manifest['objects'].get('sha256'):
raise ValueError('Backup bundle checksum mismatch')
# Reuse the legacy database verifier with a temporary matching sidecar.
legacy_sidecar=database.with_suffix('.sha256')
had_legacy=legacy_sidecar.exists()
previous=legacy_sidecar.read_bytes() if had_legacy else None
legacy_sidecar.write_text(manifest['database']['sha256']+'\n');legacy_sidecar.chmod(0o600)
try:
verify_database(database)
finally:
if had_legacy:legacy_sidecar.write_bytes(previous)
else:legacy_sidecar.unlink(missing_ok=True)
with objects.open('rb') as stream:
compose_exec('api','python','-m','local.storage_backup','verify',stdin=stream)
print('PASS: combined database and clean-object backup verified. Active data was untouched.')
if __name__=='__main__':
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('command',choices=['create','verify','create-and-verify'])
parser.add_argument('path',nargs='?',type=Path,
help='v2 manifest printed by create, or a legacy .dump')
args=parser.parse_args()
if args.command=='verify':
if not args.path:parser.error('verify requires the path printed by create')
verify(args.path)
else:
path=create()
if args.command=='create-and-verify':verify(path)