Files
claudetools/.claude/skills/msp360/scripts/msp360.py
Howard Enos 976bfe7957 sync: auto-sync from HOWARD-HOME at 2026-07-05 19:59:52
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-05 19:59:52
2026-07-05 20:00:20 -07:00

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())