Jun 28 - Update and polish UI/UX

This commit is contained in:
2026-06-28 19:00:34 -04:00
parent 54fa5b8c87
commit a42fc31fc5
14 changed files with 943 additions and 91 deletions
+202
View File
@@ -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()