134 lines
5.7 KiB
Python
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()
|