104 lines
5.8 KiB
Python
104 lines
5.8 KiB
Python
|
|
#!/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_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()
|