#!/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 /.claude/scripts/ ; this file is # /.claude/skills/msp360/scripts/msp360_client.py -> parents[3] == /.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)