#!/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 random import sys from datetime import timedelta from pathlib import Path from app import db, repo from app.auth import generate_key, hash_key from app.models import IngestIn from app.settings import DEFAULT_ENV_FILE, load_settings from app.timeutil import to_db, utcnow def create_key(conn, args): key = generate_key() try: conn.execute( "INSERT INTO api_keys (name, key_hash, created_at) VALUES (?, ?, ?)", (args.name, hash_key(key), to_db(utcnow())), ) except db.sqlite3.IntegrityError: 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{key}") def list_keys(conn, args): for r in conn.execute("SELECT name, created_at, last_used_at, revoked_at FROM api_keys ORDER BY name"): state = f"revoked {r['revoked_at']}" if r["revoked_at"] else "active" print(f"{r['name']:<30} {state:<30} created {r['created_at']} last used {r['last_used_at'] or 'never'}") def revoke_key(conn, args): cur = conn.execute( "UPDATE api_keys SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL", (to_db(utcnow()), args.name) ) sys.exit(0 if cur.rowcount else f"No active key named {args.name!r}") 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()