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
This commit is contained in:
119
.claude/skills/msp360/scripts/msp360_client.py
Normal file
119
.claude/skills/msp360/scripts/msp360_client.py
Normal file
@@ -0,0 +1,119 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user