#!/usr/bin/env python3 """Local-only backup/uninstall tooling. Default is a read-only dry run.""" import argparse import hashlib import json import os from pathlib import Path import subprocess import tempfile import zipfile ROOT=Path(__file__).resolve().parents[1] CONTAINER='bmc-hub-postgres' DB='bmc_hub' USER='bmc_hub' SQL=ROOT/'scripts/workflow_uninstall.sql' def command(args,**kwargs): return subprocess.run(args,check=True,**kwargs) def verify_local(): if os.environ.get('DOCKER_HOST') or os.environ.get('DOCKER_CONTEXT'): raise SystemExit('Unset DOCKER_HOST/DOCKER_CONTEXT: remote targets are forbidden.') endpoint=command(['docker','context','inspect','--format','{{.Endpoints.docker.Host}}'],capture_output=True,text=True).stdout.strip() if not endpoint.startswith('unix://'): raise SystemExit('Only a local Unix Docker endpoint is allowed.') query='SELECT current_database(),current_user;' identity=command(['docker','exec',CONTAINER,'psql','-U',USER,'-d',DB,'-At','-c',query],capture_output=True,text=True).stdout.strip() if identity!=f'{DB}|{USER}': raise SystemExit('Unexpected database identity.') def safe_source(path): name=path.name.lower() return not (name.startswith('.env') or any(part in {'.git','.aws','.codex','.agents','backups','node_modules','.venv'} for part in path.parts) or any(word in name for word in ('secret','credential','password','token','private_key')) or path.suffix.lower() in {'.pem','.key','.p12','.pfx','.dump','.sql.gz'}) def backup(): os.umask(0o077) folder=Path(tempfile.mkdtemp(prefix='bmc-workflow-backup-',dir='/private/tmp')) with (folder/'database.dump').open('wb') as stream: command(['docker','exec',CONTAINER,'pg_dump','-U',USER,'-d',DB,'--format=custom','--no-owner','--no-acl'],stdout=stream) # Confirm readable dump before advertising a successful checkpoint. command(['docker','exec','-i',CONTAINER,'pg_restore','--list'],stdin=(folder/'database.dump').open('rb'),stdout=subprocess.DEVNULL) tracked=command(['git','ls-files','-z'],cwd=ROOT,capture_output=True).stdout.split(b'\0') additions=command(['git','ls-files','--others','--exclude-standard','-z','app/workflows','app/tags','migrations','scripts/workflow_backup.py','scripts/workflow_uninstall.sql','docs/workflow-rollback.md','tests/test_workflow*.py'],cwd=ROOT,capture_output=True).stdout.split(b'\0') with zipfile.ZipFile(folder/'source.zip','w',compression=zipfile.ZIP_DEFLATED) as archive: for name in sorted(set(tracked+additions)): if not name: continue rel=Path(os.fsdecode(name)) target=ROOT/rel if safe_source(rel) and target.is_file() and not target.is_symlink(): archive.write(target,str(rel)) head=command(['git','rev-parse','HEAD'],cwd=ROOT,capture_output=True,text=True).stdout.strip() metadata={'container':CONTAINER,'database':DB,'user':USER,'git_head':head,'source':'working copy; sensitive filenames excluded','files':{}} for path in folder.iterdir(): if path.is_file(): metadata['files'][path.name]=hashlib.sha256(path.read_bytes()).hexdigest() (folder/'manifest.json').write_text(json.dumps(metadata,indent=2)+'\n') print(folder) return folder def verify_backup(folder): metadata=json.loads((folder/'manifest.json').read_text()) if metadata.get('database')!=DB or metadata.get('container')!=CONTAINER: raise SystemExit('Backup target mismatch.') for name,digest in metadata['files'].items(): path=folder/name if path.parent!=folder or hashlib.sha256(path.read_bytes()).hexdigest()!=digest: raise SystemExit('Backup checksum mismatch.') if 'database.dump' not in metadata['files']: raise SystemExit('Database dump missing.') def main(): parser=argparse.ArgumentParser(description=__doc__) parser.add_argument('action',choices=['backup','uninstall']) parser.add_argument('--apply',action='store_true') parser.add_argument('--confirm',default='') parser.add_argument('--backup-dir',type=Path) args=parser.parse_args() if not args.apply: print(f'DRY RUN: {args.action}; fixed local container {CONTAINER}, database {DB}. No changes made.') if args.action=='uninstall': print(SQL.read_text()) return expected='BACKUP-LOCAL-BMC' if args.action=='backup' else 'UNINSTALL-LOCAL-BMC' if args.confirm!=expected: raise SystemExit(f'Explicit confirmation token required: {expected}') verify_local() if args.action=='backup': backup();return if not args.backup_dir: raise SystemExit('--backup-dir with verified pre-uninstall checkpoint required.') verify_backup(args.backup_dir.resolve()) # Take fresh full checkpoint and standalone audit export immediately before removal. fresh=backup() audit_tables=['bmc_workflow_exports','bmc_workflow_customer_activation','bmc_workflow_customer_activation_history','bmc_workflow_resource_history','bmc_workflow_activation_history','bmc_workflow_versions','bmc_workflows','bmc_workflow_events','bmc_workflow_effects','bmc_workflow_executions','bmc_billing_decisions','bmc_workflow_settlements','workflow_tag_history','workflow_tag_proposals','workflow_tag_keywords','workflow_tag_ai_jobs','bmc_workflow_runtime'] with (fresh/'workflow-audit.sql').open('wb') as stream: command(['docker','exec',CONTAINER,'pg_dump','-U',USER,'-d',DB,'--data-only','--no-owner','--no-acl',*[f'--table={table}' for table in audit_tables]],stdout=stream) metadata=json.loads((fresh/'manifest.json').read_text()) metadata['files']['workflow-audit.sql']=hashlib.sha256((fresh/'workflow-audit.sql').read_bytes()).hexdigest() (fresh/'manifest.json').write_text(json.dumps(metadata,indent=2)+'\n') with SQL.open('rb') as stream: command(['docker','exec','-i',CONTAINER,'psql','-U',USER,'-d',DB,'--set','ON_ERROR_STOP=1'],stdin=stream) print(f'Workflow objects removed. Fresh checkpoint and audit: {fresh}') if __name__=='__main__': main()