""" control/backup.py ----------------- Per-tenant MySQL database backup tool. Parses each tenant's db_uri and runs mysqldump via subprocess, writing a gzip-compressed SQL dump to /_.sql.gz. Usage: python -m control.backup --tenant all --output-dir /backups python -m control.backup --tenant acme --output-dir /backups python -m control.backup --list Requirements: mysqldump binary on PATH. Environment: source /etc/jqc/control.env first (needs CONTROL_DATABASE_URL). """ import argparse import gzip import logging import os import shutil import subprocess import sys from datetime import datetime from urllib.parse import urlparse logging.basicConfig( level=logging.INFO, format='%(asctime)s %(levelname)s %(message)s', datefmt='%Y-%m-%d %H:%M:%S', ) logger = logging.getLogger(__name__) def _parse_db_uri(uri: str) -> dict: """Return host, port, user, password, database from a SQLAlchemy DB URI.""" p = urlparse(uri) return { 'host': p.hostname or '127.0.0.1', 'port': str(p.port or 3306), 'user': p.username or '', 'password': p.password or '', 'database': p.path.lstrip('/'), } def backup_tenant(slug: str, db_uri: str, output_dir: str) -> str: """Run mysqldump for one tenant and save as a .sql.gz file. Returns the full path to the written file. Raises RuntimeError on failure. """ if not shutil.which('mysqldump'): raise RuntimeError('mysqldump not found on PATH') db = _parse_db_uri(db_uri) if not db['database']: raise RuntimeError(f'Could not determine database name from URI: {db_uri}') os.makedirs(output_dir, exist_ok=True) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = f'{slug}_{timestamp}.sql.gz' filepath = os.path.join(output_dir, filename) cmd = [ 'mysqldump', f'--host={db["host"]}', f'--port={db["port"]}', f'--user={db["user"]}', f'--password={db["password"]}', '--single-transaction', '--routines', '--triggers', '--set-gtid-purged=OFF', db['database'], ] logger.info('Backing up tenant=%s db=%s → %s', slug, db['database'], filepath) try: result = subprocess.run( cmd, capture_output=True, timeout=600, ) except subprocess.TimeoutExpired: raise RuntimeError(f'mysqldump timed out for tenant {slug}') if result.returncode != 0: stderr = result.stderr.decode(errors='replace').strip() # mysqldump prints warnings to stderr even on success; only fail on non-zero exit. raise RuntimeError( f'mysqldump exited {result.returncode} for tenant {slug}:\n{stderr}' ) with gzip.open(filepath, 'wb') as gz: gz.write(result.stdout) size_kb = os.path.getsize(filepath) // 1024 logger.info(' ✓ %s written (%d KB)', filepath, size_kb) return filepath def _select_tenants(slug_or_all: str): """Return list of (slug, db_uri) tuples from the control DB.""" from control.base import control_session from control.models import Tenant with control_session() as s: if slug_or_all == 'all': tenants = s.query(Tenant).filter( Tenant.status != 'deleted' ).order_by(Tenant.id).all() else: tenants = s.query(Tenant).filter_by(slug=slug_or_all).all() if not tenants: raise ValueError(f'Tenant "{slug_or_all}" not found in control DB.') return [(t.slug, t.db_uri) for t in tenants] def _cmd_backup(args): try: tenants = _select_tenants(args.tenant) except ValueError as exc: logger.error('%s', exc) sys.exit(1) output_dir = os.path.expanduser(args.output_dir) ok = 0 failed = 0 for slug, db_uri in tenants: try: path = backup_tenant(slug, db_uri, output_dir) ok += 1 except Exception as exc: logger.error('FAILED tenant=%s: %s', slug, exc) failed += 1 print(f'\nBackup complete: {ok} succeeded, {failed} failed.') print(f'Files written to: {output_dir}') if failed: sys.exit(1) def _cmd_list(args): """List tenants in the control DB.""" from control.base import control_session from control.models import Tenant with control_session() as s: tenants = s.query(Tenant).order_by(Tenant.id).all() print(f'{"ID":<5} {"Slug":<20} {"Status":<12} {"DB Name":<30}') print('─' * 70) for t in tenants: db_name = _parse_db_uri(t.db_uri).get('database', '?') print(f'{t.id:<5} {t.slug:<20} {t.status:<12} {db_name:<30}') def main(): parser = argparse.ArgumentParser( description='JQC per-tenant MySQL backup tool', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) sub = parser.add_subparsers(dest='command') p_backup = sub.add_parser('backup', help='Run mysqldump for one or all tenants') p_backup.add_argument('--tenant', required=True, help='Tenant slug or "all"') p_backup.add_argument('--output-dir', default='/var/backups/jqc', help='Directory for dump files (default: /var/backups/jqc)') p_backup.set_defaults(func=_cmd_backup) p_list = sub.add_parser('list', help='List tenant slugs and DB names') p_list.set_defaults(func=_cmd_list) # Allow calling without a subcommand when --tenant is given (convenience). parser.add_argument('--tenant', help='Tenant slug or "all" (shorthand — no subcommand needed)') parser.add_argument('--output-dir', default='/var/backups/jqc', help='Output directory for dump files') parser.add_argument('--list', action='store_true', help='List tenants (shorthand)') args = parser.parse_args() if args.command: args.func(args) elif getattr(args, 'list', False): _cmd_list(args) elif getattr(args, 'tenant', None): _cmd_backup(args) else: parser.print_help() sys.exit(0) if __name__ == '__main__': main()