bmc_hub/scripts/workflow_backup.py

104 lines
5.9 KiB
Python
Raw Normal View History

#!/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()