203 lines
6.2 KiB
Python
203 lines
6.2 KiB
Python
"""
|
|
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 <output_dir>/<slug>_<YYYYMMDD_HHMMSS>.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()
|