431 lines
16 KiB
Python
431 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""MSP360 Managed Backup Service (MBS) CLI.
|
|
|
|
Read the fleet backup state and manage users / companies / licenses via the MBS
|
|
provider API. Reads run freely; every WRITE requires --confirm (a dry-run preview is
|
|
printed without it). See SKILL.md for the trigger list and examples.
|
|
|
|
Reads: status | monitoring | companies | users | computers | licenses | billing
|
|
Writes: create-company | delete-company | create-user | delete-user
|
|
grant-license | release-license | revoke-license | raw
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as _dt
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
from msp360_client import Msp360Client, Msp360Error, STATUS_TEXT, BAD_STATUS # noqa: E402
|
|
import backup_common as bc # noqa: E402 (msp360_client put .claude/scripts on sys.path)
|
|
|
|
|
|
# --- helpers ------------------------------------------------------------------
|
|
def _root() -> Path:
|
|
# honor CLAUDETOOLS_ROOT / identity.json, same as the vault/HTTP plumbing.
|
|
return bc.resolve_root()
|
|
|
|
|
|
def _as_list(data, what: str) -> list:
|
|
"""A read endpoint promised a JSON array; a non-list 2xx body is anomalous."""
|
|
if isinstance(data, list):
|
|
return data
|
|
raise Msp360Error(f"expected a list from {what}, got {type(data).__name__}: {str(data)[:160]}")
|
|
|
|
|
|
def log_error(brief: str, context: str = "") -> None:
|
|
"""Best-effort fleet error log; never breaks the caller (per CLAUDE.md)."""
|
|
try:
|
|
script = _root() / ".claude" / "scripts" / "log-skill-error.sh"
|
|
if not script.exists():
|
|
return
|
|
cmd = ["bash", str(script), "msp360", brief]
|
|
if context:
|
|
cmd += ["--context", context]
|
|
subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def out(obj, as_json: bool) -> None:
|
|
if as_json:
|
|
print(json.dumps(obj, indent=2, default=str))
|
|
|
|
|
|
def human(n) -> str:
|
|
try:
|
|
n = float(n)
|
|
except (TypeError, ValueError):
|
|
return str(n)
|
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
if abs(n) < 1024:
|
|
return f"{n:.1f}{unit}"
|
|
n /= 1024
|
|
return f"{n:.1f}PB"
|
|
|
|
|
|
def _days_since(ts: str):
|
|
"""Whole days since an ISO timestamp. None if empty OR unparseable (callers must
|
|
distinguish 'never ran' from 'could not parse' via the raw value themselves)."""
|
|
if not ts:
|
|
return None
|
|
try:
|
|
dt = _dt.datetime.fromisoformat(ts.strip().replace("Z", "+00:00"))
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=_dt.timezone.utc)
|
|
return (_dt.datetime.now(_dt.timezone.utc) - dt).days
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def require_confirm(args, action: str) -> bool:
|
|
"""Return True to proceed; if --confirm absent, print the dry-run and return False."""
|
|
if getattr(args, "confirm", False):
|
|
return True
|
|
print(f"[DRY-RUN] Would: {action}")
|
|
print("[DRY-RUN] Re-run with --confirm to execute. Nothing was changed.")
|
|
return False
|
|
|
|
|
|
# --- read commands ------------------------------------------------------------
|
|
def cmd_status(c: Msp360Client, args) -> int:
|
|
comps = c.companies()
|
|
users = c.users()
|
|
mon = c.monitoring()
|
|
if args.json:
|
|
out({"companies": len(comps), "users": len(users), "plans": len(mon)}, True)
|
|
return 0
|
|
bad = sum(1 for m in mon if m.get("Status") in BAD_STATUS)
|
|
print(f"[OK] MSP360 MBS reachable at {c.base}")
|
|
print(f"[INFO] companies={len(comps)} users={len(users)} backup plans={len(mon)} "
|
|
f"plans needing attention={bad}")
|
|
return 0
|
|
|
|
|
|
def cmd_monitoring(c: Msp360Client, args) -> int:
|
|
mon = _as_list(c.monitoring(), "monitoring")
|
|
rows = []
|
|
for m in mon:
|
|
if args.company and args.company.lower() not in (m.get("CompanyName") or "").lower():
|
|
continue
|
|
status = m.get("Status")
|
|
last_raw = m.get("LastStart") or ""
|
|
never = not last_raw
|
|
age = _days_since(last_raw)
|
|
# --failed = triage: anything that is NOT a clean completed run and is not
|
|
# in-progress. Includes never-run, Unknown(5) and null status (dead plans).
|
|
if args.failed:
|
|
clean_success = status in (0, 1) and not never
|
|
if clean_success or status == 4: # 4 = Running
|
|
continue
|
|
# --stale = last run older than N days, OR never ran. An unparseable-but-present
|
|
# timestamp is NOT claimed stale (avoid a false "not backing up").
|
|
if args.stale is not None:
|
|
if never:
|
|
pass
|
|
elif age is not None and age >= args.stale:
|
|
pass
|
|
else:
|
|
continue
|
|
rows.append((m, age))
|
|
if args.json:
|
|
out([r[0] for r in rows], True)
|
|
return 0
|
|
if not rows:
|
|
print("[INFO] no plans match the filter.")
|
|
return 0
|
|
print(f"{'COMPANY':<26} {'COMPUTER':<20} {'PLAN':<26} {'STATUS':<12} {'LAST START':<19} DATA")
|
|
for m, age in sorted(rows, key=lambda r: ((r[0].get('CompanyName') or '').lower(),
|
|
r[0].get('ComputerName') or '')):
|
|
st = STATUS_TEXT.get(m.get("Status"), str(m.get("Status")))
|
|
last = m.get("LastStart") or "never"
|
|
agestr = f" ({age}d)" if age is not None and age >= 30 else ""
|
|
print(f"{(m.get('CompanyName') or '')[:25]:<26} {(m.get('ComputerName') or '')[:19]:<20} "
|
|
f"{(m.get('PlanName') or '')[:25]:<26} {st:<12} {last[:19]:<19}{agestr} "
|
|
f"{human(m.get('DataCopied', 0))}")
|
|
print(f"\n[INFO] {len(rows)} plan(s). Filters: "
|
|
f"company={args.company or '-'} failed={args.failed} stale>={args.stale}")
|
|
return 0
|
|
|
|
|
|
def cmd_companies(c: Msp360Client, args) -> int:
|
|
comps = _as_list(c.companies(), "companies")
|
|
if args.json:
|
|
out(comps, True)
|
|
return 0
|
|
for co in sorted(comps, key=lambda x: (x.get("Name") or "").lower()):
|
|
lim = co.get("StorageLimit")
|
|
limstr = "unlimited" if lim in (-1, None) else human(lim)
|
|
print(f" {(co.get('Name') or ''):<34} id={co.get('Id')} storage_limit={limstr}")
|
|
print(f"\n[INFO] {len(comps)} companies")
|
|
return 0
|
|
|
|
|
|
def cmd_users(c: Msp360Client, args) -> int:
|
|
users = _as_list(c.users(), "users")
|
|
if args.company:
|
|
users = [u for u in users if args.company.lower() in (u.get("Company") or "").lower()]
|
|
if args.json:
|
|
out(users, True)
|
|
return 0
|
|
for u in sorted(users, key=lambda x: (x.get("Company") or "", x.get("Email") or "")):
|
|
en = "enabled" if u.get("Enabled") else "DISABLED"
|
|
print(f" {(u.get('Email') or ''):<34} {(u.get('Company') or ''):<26} "
|
|
f"{en:<9} used={human(u.get('SpaceUsed', 0))} id={u.get('ID')}")
|
|
print(f"\n[INFO] {len(users)} users" + (f" in '{args.company}'" if args.company else ""))
|
|
return 0
|
|
|
|
|
|
def cmd_computers(c: Msp360Client, args) -> int:
|
|
try:
|
|
comps = _as_list(c.computers(), "computers")
|
|
except Msp360Error as exc:
|
|
msg = str(exc).lower()
|
|
if "remote management" in msg or "not enabled" in msg or "not available" in msg:
|
|
print("[INFO] /api/Computers is gated behind the Remote Management API feature, "
|
|
"which is not enabled on this MSP360 account. Use `users` + `monitoring` "
|
|
"for endpoint/backup data instead.")
|
|
return 0
|
|
raise
|
|
if args.json:
|
|
out(comps, True)
|
|
return 0
|
|
for m in comps:
|
|
print(f" {(m.get('Name') or m.get('ComputerName') or ''):<24} "
|
|
f"user={m.get('User') or m.get('UserEmail') or ''} hid={m.get('Hid') or m.get('ID')}")
|
|
print(f"\n[INFO] {len(comps) if isinstance(comps, list) else 0} computers")
|
|
return 0
|
|
|
|
|
|
def cmd_licenses(c: Msp360Client, args) -> int:
|
|
lic = _as_list(c.licenses(available=True if args.available else None), "licenses")
|
|
if args.json:
|
|
out(lic, True)
|
|
return 0
|
|
taken = sum(1 for x in lic if x.get("IsTaken"))
|
|
for x in lic:
|
|
state = "TAKEN" if x.get("IsTaken") else "free"
|
|
num = x.get("Number")
|
|
num = "" if num is None else num
|
|
print(f" #{str(num):<5} {(x.get('LicenseType') or ''):<22} {state:<6} "
|
|
f"exp={(x.get('DateExpired') or '')[:10]} user={x.get('User') or '-'}")
|
|
print(f"\n[INFO] {len(lic)} licenses ({taken} taken, {len(lic) - taken} free)")
|
|
return 0
|
|
|
|
|
|
def cmd_billing(c: Msp360Client, args) -> int:
|
|
out(c.billing(), True) # billing has no table form; always JSON
|
|
return 0
|
|
|
|
|
|
# --- write commands -----------------------------------------------------------
|
|
def cmd_create_company(c: Msp360Client, args) -> int:
|
|
if not require_confirm(args, f"create company '{args.name}'"):
|
|
return 0
|
|
res = c.create_company(args.name)
|
|
print(f"[OK] created company '{args.name}'")
|
|
out(res, True)
|
|
return 0
|
|
|
|
|
|
def cmd_delete_company(c: Msp360Client, args) -> int:
|
|
print(f"[WARNING] Deleting company id={args.id} removes the company record.")
|
|
if not require_confirm(args, f"DELETE company id={args.id}"):
|
|
return 0
|
|
res = c.delete_company(args.id)
|
|
print(f"[OK] deleted company id={args.id}")
|
|
out(res, True)
|
|
return 0
|
|
|
|
|
|
def cmd_create_user(c: Msp360Client, args) -> int:
|
|
payload = {
|
|
"Email": args.email,
|
|
"FirstName": args.first or "",
|
|
"LastName": args.last or "",
|
|
"Company": args.company,
|
|
"Enabled": True,
|
|
"NotificationEmails": [args.notify] if args.notify else [],
|
|
}
|
|
if args.password:
|
|
payload["Password"] = args.password
|
|
if not require_confirm(args, f"create user '{args.email}' in company '{args.company}'"):
|
|
preview = dict(payload)
|
|
if "Password" in preview:
|
|
preview["Password"] = "***redacted***" # never echo a secret to the transcript
|
|
out(preview, True)
|
|
return 0
|
|
res = c.create_user(payload)
|
|
print(f"[OK] created user '{args.email}'")
|
|
out(res, True)
|
|
return 0
|
|
|
|
|
|
def cmd_delete_user(c: Msp360Client, args) -> int:
|
|
scope = "metadata only (backup data KEPT)" if args.keep_data else "metadata AND all backup data"
|
|
print(f"[WARNING] Deleting user id={args.id}: {scope}.")
|
|
if not require_confirm(args, f"DELETE user id={args.id} ({scope})"):
|
|
return 0
|
|
res = c.delete_user(args.id, keep_data=args.keep_data)
|
|
print(f"[OK] deleted user id={args.id}")
|
|
out(res, True)
|
|
return 0
|
|
|
|
|
|
def cmd_grant_license(c: Msp360Client, args) -> int:
|
|
payload = {"UserID": args.user_id}
|
|
if args.license_id:
|
|
payload["LicenseID"] = args.license_id
|
|
if not require_confirm(args, f"grant license {args.license_id or '(auto)'} to user {args.user_id}"):
|
|
out(payload, True)
|
|
return 0
|
|
res = c.grant_license(payload)
|
|
print("[OK] license granted")
|
|
out(res, True)
|
|
return 0
|
|
|
|
|
|
def cmd_release_license(c: Msp360Client, args) -> int:
|
|
payload = {"UserID": args.user_id}
|
|
if args.license_id:
|
|
payload["LicenseID"] = args.license_id
|
|
if not require_confirm(args, f"release license from user {args.user_id}"):
|
|
return 0
|
|
res = c.release_license(payload)
|
|
print("[OK] license released")
|
|
out(res, True)
|
|
return 0
|
|
|
|
|
|
def cmd_revoke_license(c: Msp360Client, args) -> int:
|
|
payload = {"UserID": args.user_id}
|
|
if args.license_id:
|
|
payload["LicenseID"] = args.license_id
|
|
if not require_confirm(args, f"revoke license from user {args.user_id}"):
|
|
return 0
|
|
res = c.revoke_license(payload)
|
|
print("[OK] license revoked")
|
|
out(res, True)
|
|
return 0
|
|
|
|
|
|
def cmd_raw(c: Msp360Client, args) -> int:
|
|
method = args.method.upper()
|
|
body = json.loads(args.data) if args.data else None
|
|
# Git-Bash rewrites a leading-slash arg into a Windows path (e.g.
|
|
# '/api/Companies' -> 'C:/Program Files/Git/api/Companies'). Recover the /api/... tail.
|
|
path = args.path
|
|
if "/api/" in path and not path.startswith("/api"):
|
|
path = path[path.index("/api/"):]
|
|
elif not path.startswith("/"):
|
|
path = "/" + path
|
|
if method != "GET" and not require_confirm(args, f"{method} {path} {args.data or ''}"):
|
|
return 0
|
|
res = c.request(method, path, body=body)
|
|
out(res, True)
|
|
return 0
|
|
|
|
|
|
# --- argparse -----------------------------------------------------------------
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(prog="msp360", description="MSP360 Managed Backup Service CLI")
|
|
p.add_argument("--base", help="override API base URL")
|
|
sub = p.add_subparsers(dest="command", required=True)
|
|
|
|
def add_json(sp):
|
|
sp.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
return sp
|
|
|
|
add_json(sub.add_parser("status", help="reachability + fleet counts")).set_defaults(func=cmd_status)
|
|
|
|
sp = sub.add_parser("monitoring", help="fleet backup status (the primary read)")
|
|
sp.add_argument("--company", help="filter by company name substring")
|
|
sp.add_argument("--failed", action="store_true", help="only plans not in a clean state")
|
|
sp.add_argument("--stale", type=int, metavar="DAYS",
|
|
help="only plans whose last run is >= DAYS old (or never)")
|
|
add_json(sp)
|
|
sp.set_defaults(func=cmd_monitoring)
|
|
|
|
add_json(sub.add_parser("companies", help="list companies")).set_defaults(func=cmd_companies)
|
|
|
|
sp = sub.add_parser("users", help="list backup users")
|
|
sp.add_argument("--company", help="filter by company name substring")
|
|
add_json(sp)
|
|
sp.set_defaults(func=cmd_users)
|
|
|
|
add_json(sub.add_parser("computers", help="list managed endpoints")).set_defaults(func=cmd_computers)
|
|
|
|
sp = sub.add_parser("licenses", help="list licenses")
|
|
sp.add_argument("--available", action="store_true", help="only free/available licenses")
|
|
add_json(sp)
|
|
sp.set_defaults(func=cmd_licenses)
|
|
|
|
sub.add_parser("billing", help="billing info (JSON)").set_defaults(func=cmd_billing)
|
|
|
|
# writes
|
|
sp = sub.add_parser("create-company", help="create a company [--confirm]")
|
|
sp.add_argument("--name", required=True)
|
|
sp.add_argument("--confirm", action="store_true")
|
|
sp.set_defaults(func=cmd_create_company)
|
|
|
|
sp = sub.add_parser("delete-company", help="delete a company by id [--confirm]")
|
|
sp.add_argument("--id", required=True)
|
|
sp.add_argument("--confirm", action="store_true")
|
|
sp.set_defaults(func=cmd_delete_company)
|
|
|
|
sp = sub.add_parser("create-user", help="create a backup user [--confirm]")
|
|
sp.add_argument("--email", required=True)
|
|
sp.add_argument("--company", required=True)
|
|
sp.add_argument("--first")
|
|
sp.add_argument("--last")
|
|
sp.add_argument("--password")
|
|
sp.add_argument("--notify", help="notification email")
|
|
sp.add_argument("--confirm", action="store_true")
|
|
sp.set_defaults(func=cmd_create_user)
|
|
|
|
sp = sub.add_parser("delete-user", help="delete a user by id [--confirm]")
|
|
sp.add_argument("--id", required=True)
|
|
sp.add_argument("--keep-data", action="store_true", help="keep backup data (metadata only)")
|
|
sp.add_argument("--confirm", action="store_true")
|
|
sp.set_defaults(func=cmd_delete_user)
|
|
|
|
for name, fn, verb in (("grant-license", cmd_grant_license, "grant"),
|
|
("release-license", cmd_release_license, "release"),
|
|
("revoke-license", cmd_revoke_license, "revoke")):
|
|
sp = sub.add_parser(name, help=f"{verb} a license [--confirm]")
|
|
sp.add_argument("--user-id", required=True)
|
|
sp.add_argument("--license-id")
|
|
sp.add_argument("--confirm", action="store_true")
|
|
sp.set_defaults(func=fn)
|
|
|
|
sp = sub.add_parser("raw", help="raw API call (writes need --confirm)")
|
|
sp.add_argument("method")
|
|
sp.add_argument("path", help="e.g. /api/Users/{id}")
|
|
sp.add_argument("--data", help="JSON request body")
|
|
sp.add_argument("--confirm", action="store_true")
|
|
sp.set_defaults(func=cmd_raw)
|
|
|
|
return p
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
args = build_parser().parse_args(argv)
|
|
client = Msp360Client(base=args.base)
|
|
try:
|
|
return args.func(client, args)
|
|
except bc.BackupError as exc: # includes Msp360Error + vault/HTTP transport errors
|
|
print(f"[ERROR] {exc}", file=sys.stderr)
|
|
log_error(str(exc)[:200], context=f"command={args.command}")
|
|
return 1
|
|
except Exception as exc: # noqa: BLE001
|
|
print(f"[ERROR] unexpected: {exc}", file=sys.stderr)
|
|
log_error(f"unexpected: {exc}"[:200], context=f"command={args.command}")
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|