Files
claudetools/.claude/skills/seafile/scripts/seafile.py
Howard Enos fa4867580f sync: auto-sync from HOWARD-HOME at 2026-07-05 15:29:59
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 15:29:59
2026-07-05 15:30:28 -07:00

223 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""CLI for the `seafile` skill — read-only Seafile Pro (SeaCloud/SeaDrive) inventory
for the GPS backup audit.
Commands:
status server reachable + version
users [--inactive] [--json] all accounts + storage used + last login
libraries [--json] all libraries (repos) + owner + size
usage [--json] per-user rollup: storage + library count (the audit view)
devices [--user EMAIL] [--json] desktop/sync devices the server has seen
audit [--client NAME] [--rmm] [--json]
the GPS view: users/usage, optionally cross-checked
against GuruRMM endpoints (SeaDrive/Seafile installed?)
Server-side is the source of "who has an account + how much data"; --rmm adds the
"which enrolled machine actually runs the client" half (the 'both' cross-check).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from seafile_client import SeafileClient # noqa: E402
import backup_common as bc # noqa: E402
GB = 1_000_000_000
def _gb(n) -> float:
try:
return round(int(n) / GB, 2)
except (TypeError, ValueError):
return 0.0
def _fmt_quota(total) -> str:
try:
t = int(total)
except (TypeError, ValueError):
return "?"
return "unlimited" if t < 0 else f"{_gb(t)} GB"
def cmd_status(c: SeafileClient, args) -> int:
info = c.server_info()
print(f"[OK] {c.base}")
print(json.dumps(info, indent=2))
return 0
def cmd_users(c: SeafileClient, args) -> int:
users = c.list_users()
if args.inactive:
users = [u for u in users if not u.get("is_active")]
users.sort(key=lambda u: int(u.get("quota_usage") or 0), reverse=True)
if args.json:
print(json.dumps(users, indent=2))
return 0
print(f"{'EMAIL':40} {'ACTIVE':6} {'USED':>10} {'QUOTA':>10} {'LAST LOGIN':20}")
for u in users:
print(f"{(u.get('email') or '')[:40]:40} "
f"{'yes' if u.get('is_active') else 'NO':6} "
f"{_gb(u.get('quota_usage')):>7} GB "
f"{_fmt_quota(u.get('quota_total')):>10} "
f"{(u.get('last_login') or '-')[:19]:20}")
print(f"\n{len(users)} users; total used "
f"{round(sum(_gb(u.get('quota_usage')) for u in users), 1)} GB")
return 0
def cmd_libraries(c: SeafileClient, args) -> int:
repos = c.list_libraries()
repos.sort(key=lambda r: int(r.get("size") or 0), reverse=True)
if args.json:
print(json.dumps(repos, indent=2))
return 0
print(f"{'LIBRARY':30} {'OWNER':35} {'SIZE':>10} {'FILES':>8} {'MODIFIED':20}")
for r in repos:
print(f"{(r.get('name') or '')[:30]:30} "
f"{(r.get('owner_email') or r.get('owner') or '')[:35]:35} "
f"{_gb(r.get('size')):>7} GB "
f"{str(r.get('file_count') or '?'):>8} "
f"{(r.get('last_modified') or '-')[:19]:20}")
print(f"\n{len(repos)} libraries; total "
f"{round(sum(_gb(r.get('size')) for r in repos), 1)} GB")
return 0
def _usage_rollup(c: SeafileClient) -> list[dict]:
users = {u.get("email"): u for u in c.list_users()}
repos = c.list_libraries()
by_owner: dict[str, dict] = {}
for r in repos:
owner = r.get("owner_email") or r.get("owner")
d = by_owner.setdefault(owner, {"libraries": 0, "lib_bytes": 0})
d["libraries"] += 1
d["lib_bytes"] += int(r.get("size") or 0)
rollup = []
for email, u in users.items():
o = by_owner.get(email, {"libraries": 0, "lib_bytes": 0})
rollup.append({
"email": email,
"name": u.get("name"),
"is_active": bool(u.get("is_active")),
"quota_used_bytes": int(u.get("quota_usage") or 0),
"quota_used_gb": _gb(u.get("quota_usage")),
"quota_total": u.get("quota_total"),
"libraries": o["libraries"],
"library_gb": _gb(o["lib_bytes"]),
"last_login": u.get("last_login"),
"last_access": u.get("last_access_time"),
})
rollup.sort(key=lambda x: x["quota_used_bytes"], reverse=True)
return rollup
def cmd_usage(c: SeafileClient, args) -> int:
rollup = _usage_rollup(c)
if args.json:
print(json.dumps(rollup, indent=2))
return 0
print(f"{'USER':40} {'ACT':4} {'USED':>9} {'LIBS':>5} {'LAST LOGIN':20}")
for r in rollup:
print(f"{(r['email'] or '')[:40]:40} "
f"{'yes' if r['is_active'] else 'NO':4} "
f"{r['quota_used_gb']:>6} GB "
f"{r['libraries']:>5} "
f"{(r['last_login'] or '-')[:19]:20}")
return 0
def cmd_devices(c: SeafileClient, args) -> int:
devices = c.list_devices(args.user)
if args.json:
print(json.dumps(devices, indent=2))
return 0
if not devices:
print("[INFO] no devices returned (endpoint may be unsupported on this Seafile version)")
return 0
print(f"{'USER':35} {'DEVICE':25} {'PLATFORM':12} {'LAST ACCESS':20}")
for d in devices:
print(f"{(d.get('user') or '')[:35]:35} "
f"{(d.get('device_name') or '')[:25]:25} "
f"{(d.get('platform') or '')[:12]:12} "
f"{(d.get('last_accessed') or d.get('last_login_ip') or '-')[:19]:20}")
return 0
def cmd_audit(c: SeafileClient, args) -> int:
rollup = _usage_rollup(c)
# active accounts with data are the ones actually backing up here
active = [r for r in rollup if r["is_active"] and r["quota_used_bytes"] > 0]
result = {"backend": "seafile", "server": c.base, "accounts": active}
if args.rmm:
rmm = bc.RmmClient()
rmm.login()
checks = []
agents = rmm.find_agents(client_substr=args.client) if args.client else rmm.list_agents()
for a in agents:
det = rmm.detect_client(a, ["Seafile", "SeaDrive"])
if det.get("detected"):
checks.append(det)
result["rmm_endpoints_with_client"] = checks
if args.json:
print(json.dumps(result, indent=2))
return 0
print(f"=== Seafile (SeaCloud/SeaDrive) backup accounts - {c.base} ===")
print(f"{'USER':40} {'USED':>9} {'LIBS':>5} {'LAST LOGIN':20}")
for r in active:
print(f"{(r['email'] or '')[:40]:40} {r['quota_used_gb']:>6} GB "
f"{r['libraries']:>5} {(r['last_login'] or '-')[:19]:20}")
print(f"\n{len(active)} active accounts with data; "
f"{round(sum(r['quota_used_gb'] for r in active), 1)} GB total")
if args.rmm:
eps = result.get("rmm_endpoints_with_client", [])
print(f"\n--- GuruRMM endpoints running a Seafile/SeaDrive client ({len(eps)}) ---")
for e in eps:
print(f" {e['hostname']:25} client={e.get('client_name')}")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="seafile", description="Seafile Pro backup inventory")
p.add_argument("--url", help="override server base URL (default internal Docker endpoint)")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("status")
u = sub.add_parser("users"); u.add_argument("--inactive", action="store_true"); u.add_argument("--json", action="store_true")
l = sub.add_parser("libraries"); l.add_argument("--json", action="store_true")
us = sub.add_parser("usage"); us.add_argument("--json", action="store_true")
d = sub.add_parser("devices"); d.add_argument("--user"); d.add_argument("--json", action="store_true")
a = sub.add_parser("audit"); a.add_argument("--client"); a.add_argument("--rmm", action="store_true"); a.add_argument("--json", action="store_true")
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
c = SeafileClient(args.url)
handlers = {"status": cmd_status, "users": cmd_users, "libraries": cmd_libraries,
"usage": cmd_usage, "devices": cmd_devices, "audit": cmd_audit}
try:
return handlers[args.cmd](c, args)
except bc.BackupError as exc:
print(f"[ERROR] {exc}", file=sys.stderr)
# best-effort error logging per house rule
try:
root = bc.resolve_root()
bc.subprocess.run(["bash", str(root / ".claude/scripts/log-skill-error.sh"),
"seafile", str(exc)[:200]], timeout=15)
except Exception:
pass
return 1
if __name__ == "__main__":
raise SystemExit(main())