Files
kyleandClaude Opus 5.5 f55a44f2e6 Make manage.py use the service's database
manage.py only found the database via BACKUP_DASH_DB, which is set in
the systemd unit and not in an admin's shell, so it silently created a
new backup-dashboard.db in the current directory. API keys created
there were never seen by the service.

- Settings now come from /etc/backup-dashboard/backup-dashboard.env,
  which both the systemd unit (EnvironmentFile=) and manage.py read;
  real environment variables still take precedence
- manage.py prints the database it uses and refuses to create a new
  one unless --create is passed; --db overrides the path

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-06 19:30:48 -06:00

134 lines
5.7 KiB
Python

#!/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()