Jun 28 - Update and polish UI/UX
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user