#!/usr/bin/env python3 """Admin CLI. Finds the database the same way the API does: BACKUP_DASH_DB from the environment, else from /etc/backup-dashboard/backup-dashboard.env, else ./backup-dashboard.db (dev). Run it as the service user so files it creates stay writable by the API: sudo -u backupdash /opt/backup-dashboard/venv/bin/python manage.py create-key veeam-acme python manage.py create-key veeam-acme # prints the key once python manage.py list-keys python manage.py revoke-key veeam-acme python manage.py list-jobs python manage.py delete-job 12 # e.g. a decommissioned job stuck as Overdue python manage.py seed-demo # 30 days of fake history for trying the UI python manage.py --db ./dev.db --create seed-demo """ import argparse import getpass import random import sys from datetime import timedelta from pathlib import Path from app import db, keys, repo from app.models import IngestIn from app.settings import DEFAULT_ENV_FILE, load_settings from app.timeutil import utcnow def cli_user() -> str: # Recorded as who created/revoked a key; the admin page records the signed-in user instead try: return f"manage.py ({getpass.getuser()})" except Exception: return "manage.py" def create_key(conn, args): if not keys.valid_name(args.name): sys.exit("Key names use letters, digits, '.', '_' and '-', starting with a letter or digit") try: _, secret = keys.create_key(conn, args.name, created_by=cli_user()) except keys.KeyNameTaken: sys.exit(f"A key named {args.name!r} already exists") print(f"API key for {args.name!r} (shown once, store it in the collector's config):\n{secret}") def list_keys(conn, args): for k in keys.list_keys(conn): state = f"revoked {k['revoked_at']}" if k["revoked_at"] else "active" tenant = f" ({k['last_used_tenant']})" if k["last_used_tenant"] else "" print(f"{k['name']:<30} {state:<30} created {k['created_at']} last used {k['last_used_at'] or 'never'}{tenant}") def revoke_key(conn, args): row = conn.execute("SELECT id FROM api_keys WHERE name = ? AND revoked_at IS NULL", (args.name,)).fetchone() if row is None: sys.exit(f"No active key named {args.name!r}") keys.revoke_key(conn, row["id"], revoked_by=cli_user()) def list_jobs(conn, args): for j in repo.list_jobs(conn): print(f"{j['id']:>5} {j['company_name']:<28} {j['job_name']:<40} {j['status'] or '-':<8} last {j['last_run_at'] or 'never'}") def delete_job(conn, args): cur = conn.execute("DELETE FROM jobs WHERE id = ?", (args.job_id,)) sys.exit(0 if cur.rowcount else f"No job with id {args.job_id}") DEMO = [ ("Acme Corp", "Veeam B&R", "File Server - Nightly", 24), ("Acme Corp", "Veeam B&R", "SQL01 - Transaction Logs", 4), ("Bluewater Dental", "Datto", "Workstations - Daily", 24), ("Bluewater Dental", "Datto", "Imaging Server - Hourly", 1), ("Northfield Logistics", "Acronis", "Exchange - Nightly", 24), ] def seed_demo(conn, args): rng = random.Random(42) now = utcnow() for tenant, source, name, every_h in DEMO: ext = name.lower().replace(" ", "-") # Offset so runs don't all line up exactly on the current time start = now - timedelta(days=30) + timedelta(minutes=rng.randint(5, every_h * 60 - 5)) while start < now: dur = timedelta(minutes=rng.randint(2, 50 if every_h > 1 else 15)) end = start + dur status = "running" if end > now else rng.choices(["success", "warning", "failed"], [90, 7, 3])[0] run = {"status": status, "started_at": start.isoformat()} if status != "running": run |= {"finished_at": end.isoformat(), "size_bytes": rng.randint(10**8, 10**11), "message": {"success": "Job completed successfully.", "warning": "Completed with warnings: 1 file skipped (locked).", "failed": "Error: target repository unreachable."}[status]} repo.ingest(conn, IngestIn.model_validate({ "tenant": tenant, "source_system": source, "job": {"external_id": ext, "name": name, "next_run_at": (start + timedelta(hours=every_h)).isoformat()}, "run": run, })) start += timedelta(hours=every_h) print(f"Seeded {len(DEMO)} demo jobs") def main(): p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("--db", type=Path, help="database file (overrides BACKUP_DASH_DB and the env file)") p.add_argument("--create", action="store_true", help="create the database if it doesn't exist") sub = p.add_subparsers(required=True) s = sub.add_parser("create-key"); s.add_argument("name"); s.set_defaults(fn=create_key) s = sub.add_parser("list-keys"); s.set_defaults(fn=list_keys) s = sub.add_parser("revoke-key"); s.add_argument("name"); s.set_defaults(fn=revoke_key) s = sub.add_parser("list-jobs"); s.set_defaults(fn=list_jobs) s = sub.add_parser("delete-job"); s.add_argument("job_id", type=int); s.set_defaults(fn=delete_job) s = sub.add_parser("seed-demo"); s.set_defaults(fn=seed_demo) args = p.parse_args() db_path = args.db or load_settings().db_path # Refuse to silently create a fresh DB: that almost always means we're looking in the wrong # place, and anything written there (like a new API key) would be invisible to the service. if not db_path.exists() and not args.create: sys.exit( f"Database not found: {db_path.resolve()}\n" f"Set BACKUP_DASH_DB in {DEFAULT_ENV_FILE} (or pass --db), " "or add --create to start a new database there." ) print(f"Using database {db_path.resolve()}", file=sys.stderr) db.init_db(db_path) conn = db.connect(db_path) try: args.fn(conn, args) finally: conn.close() if __name__ == "__main__": main()