120 lines
5.1 KiB
Python
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)
|