Files
claudetools/.claude/skills/msp360/scripts/msp360_client.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

120 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""MSP360 Managed Backup Service (MBS) API client.
Full CRUD over the provider REST API at https://api.mspbackups.com. Auth is a Bearer
token from POST /api/Provider/Login (login+password generated in MSP360 console
Settings > General, vaulted at msp-tools/msp360-api.sops.yaml).
Reuses `.claude/scripts/backup_common.py` for repo-root resolution, the canonical SOPS
vault read, and stdlib JSON HTTP -- same plumbing as the seafile/owncloud/datto-workplace
sibling skills. Standalone: stdlib-only transport.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Optional
# backup_common lives at <root>/.claude/scripts/ ; this file is
# <root>/.claude/skills/msp360/scripts/msp360_client.py -> parents[3] == <root>/.claude
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "scripts"))
import backup_common as bc # noqa: E402
VAULT_ENTRY = "msp-tools/msp360-api.sops.yaml"
DEFAULT_BASE = "https://api.mspbackups.com"
# Monitoring plan Status codes (observed + MBS convention).
STATUS_TEXT = {
0: "Success", 1: "Success", 2: "Warning", 3: "Failed",
4: "Running", 5: "Unknown", 6: "Interrupted", 7: "Completed/warnings",
}
# Statuses that mean "this run did NOT produce a clean backup".
BAD_STATUS = {2, 3, 6, 7}
class Msp360Error(bc.BackupError):
"""MSP360 transport / auth / API error (carries optional HTTP status)."""
class Msp360Client:
"""Thin MSP360 MBS API client: login once, then typed helpers over the endpoints."""
def __init__(self, base: Optional[str] = None):
self.base = (base or DEFAULT_BASE).rstrip("/")
self._token: Optional[str] = None
# --- auth -----------------------------------------------------------------
def login(self) -> None:
login = bc.vault_get_field(VAULT_ENTRY, "credentials.login")
pw = bc.vault_get_field(VAULT_ENTRY, "credentials.password")
st, body = bc.http_json(
"POST", f"{self.base}/api/Provider/Login",
body={"UserName": login, "Password": pw})
if st != 200 or not isinstance(body, dict) or not body.get("access_token"):
raise Msp360Error(f"MSP360 login failed (HTTP {st}): {body}", status=st)
self._token = body["access_token"]
def _auth(self) -> dict:
if not self._token:
self.login()
return {"Authorization": "Bearer " + str(self._token)}
def request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
"""One API call. Raises Msp360Error on any non-2xx (with the API's error body)."""
st, data = bc.http_json(method, f"{self.base}{path}", headers=self._auth(), body=body)
if st < 200 or st >= 300:
raise Msp360Error(f"{method} {path} -> HTTP {st}: {data}", status=st)
return data
# --- reads ----------------------------------------------------------------
def monitoring(self, user_id: Optional[str] = None) -> list:
return self.request("GET", f"/api/Monitoring/{user_id}" if user_id else "/api/Monitoring")
def companies(self) -> list:
return self.request("GET", "/api/Companies")
def users(self) -> list:
return self.request("GET", "/api/Users")
def computers(self, offset: int = 0, count: int = 1000) -> list:
return self.request("GET", f"/api/Computers/{offset}/{count}")
def licenses(self, available: Optional[bool] = None) -> list:
path = "/api/Licenses"
if available is not None:
path += f"?isAvailable={'true' if available else 'false'}"
return self.request("GET", path)
def billing(self) -> Any:
return self.request("GET", "/api/Billing")
# --- writes (companies) ---------------------------------------------------
def create_company(self, name: str, **extra: Any) -> Any:
payload = {"Name": name, "StorageLimit": -1}
payload.update(extra)
return self.request("POST", "/api/Companies", body=payload)
def delete_company(self, company_id: str) -> Any:
return self.request("DELETE", f"/api/Companies/{company_id}")
# --- writes (users) -------------------------------------------------------
# Property edits (PUT /api/Users, /api/Companies) are reachable via the CLI's
# `raw PUT ...` passthrough; not wrapped here until a typed edit command exists.
def create_user(self, payload: dict) -> Any:
return self.request("POST", "/api/Users", body=payload)
def delete_user(self, user_id: str, keep_data: bool = False) -> Any:
# /{id}/Account = drop metadata but KEEP backup data; /{id} = drop everything.
suffix = f"/api/Users/{user_id}/Account" if keep_data else f"/api/Users/{user_id}"
return self.request("DELETE", suffix)
# --- writes (licenses) ----------------------------------------------------
def grant_license(self, payload: dict) -> Any:
return self.request("POST", "/api/Licenses/Grant", body=payload)
def release_license(self, payload: dict) -> Any:
return self.request("POST", "/api/Licenses/Release", body=payload)
def revoke_license(self, payload: dict) -> Any:
return self.request("POST", "/api/Licenses/Revoke", body=payload)