feat(poc): rewind MCP, skill, and recover-ladder docs

Initial PoC: find_protection, tagged checkpoints, FLR, mutating catalog.
Lab 10.9 status enums (0=Initializing, 1=MeetingSLA). Credentials stay in gitignored config.json.
This commit is contained in:
2026-09-21 12:09:11 -04:00
commit 38ba9c1b50
24 changed files with 1811 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
"""ZVM / ZCA REST client. Same paths on both (zerto_api_lessons)."""
from __future__ import annotations
import time
from typing import Any
from urllib.parse import urljoin
import httpx
from zerto_rewind_mcp.util import pick
class ZertoError(Exception):
def __init__(self, message: str, status_code: int | None = None, body: str | None = None):
super().__init__(message)
self.status_code = status_code
self.body = body
class ZertoClient:
def __init__(
self,
base_url: str,
username: str,
password: str,
client_id: str = "zerto-client",
verify_tls: bool = False,
timeout: float = 60.0,
):
self.base_url = base_url.rstrip("/")
self.username = username
self.password = password
self.client_id = client_id
self._token: str | None = None
self._token_exp = 0.0
self._http = httpx.AsyncClient(verify=verify_tls, timeout=timeout)
async def aclose(self) -> None:
await self._http.aclose()
def _url(self, path: str) -> str:
if path.startswith("http"):
return path
return urljoin(self.base_url + "/", path.lstrip("/"))
async def ensure_token(self) -> str:
if self._token and time.time() < self._token_exp - 30:
return self._token
url = self._url("/auth/realms/zerto/protocol/openid-connect/token")
response = await self._http.post(
url,
data={
"grant_type": "password",
"username": self.username,
"password": self.password,
"client_id": self.client_id,
"scope": "openid",
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if response.status_code >= 400:
raise ZertoError(
f"Keycloak token failed HTTP {response.status_code}. "
"Check username/password/client_id "
"(10.x uses zerto-client; 9.x may use zerto-api).",
status_code=response.status_code,
body=response.text[:500],
)
payload = response.json()
self._token = payload["access_token"]
self._token_exp = time.time() + float(payload.get("expires_in") or 60)
return self._token
async def request(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: Any = None,
) -> httpx.Response:
token = await self.ensure_token()
headers = {"Authorization": f"Bearer {token}"}
if json_body is not None:
headers["Content-Type"] = "application/json"
response = await self._http.request(
method,
self._url(path),
params=params,
json=json_body,
headers=headers,
)
if response.status_code == 401:
self._token = None
token = await self.ensure_token()
headers["Authorization"] = f"Bearer {token}"
response = await self._http.request(
method,
self._url(path),
params=params,
json=json_body,
headers=headers,
)
return response
async def json(
self,
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
json_body: Any = None,
) -> Any:
response = await self.request(method, path, params=params, json_body=json_body)
if response.status_code >= 400:
raise ZertoError(
f"{method} {path} HTTP {response.status_code}: {response.text[:400]}",
status_code=response.status_code,
body=response.text[:1000],
)
if response.status_code == 204 or not response.content:
return None
try:
return response.json()
except ValueError:
return response.text
async def get_vms(
self,
*,
vm_name: str | None = None,
vm_identifier: str | None = None,
) -> list[dict[str, Any]]:
params: dict[str, Any] = {}
if vm_identifier:
params["vmIdentifier"] = vm_identifier
if vm_name:
params["vmName"] = vm_name
data = await self.json("GET", "/v1/vms", params=params or None)
if data is None:
return []
if isinstance(data, list):
return data
if isinstance(data, dict):
inner = pick(data, "value", "items", "vms")
if isinstance(inner, list):
return inner
raise ZertoError(f"GET /v1/vms returned unexpected shape: {type(data).__name__}")
async def get_vpg(self, vpg_identifier: str) -> dict[str, Any]:
data = await self.json("GET", f"/v1/vpgs/{vpg_identifier}")
if not isinstance(data, dict):
raise ZertoError("GET /v1/vpgs/{id} did not return an object")
return data
async def list_checkpoints(self, vpg_identifier: str) -> list[dict[str, Any]]:
data = await self.json("GET", f"/v1/vpgs/{vpg_identifier}/checkpoints")
if data is None:
return []
if isinstance(data, list):
return data
raise ZertoError("GET checkpoints returned unexpected shape")
async def insert_checkpoint(self, vpg_identifier: str, tag: str) -> Any:
"""POST tagged checkpoint. Body key is CheckpointName on documented 9.x API."""
path = f"/v1/vpgs/{vpg_identifier}/checkpoints"
try:
return await self.json("POST", path, json_body={"CheckpointName": tag})
except ZertoError as exc:
if exc.status_code != 400:
raise
return await self.json("POST", path, json_body={"checkpointName": tag})
async def get_task(self, task_id: str) -> dict[str, Any]:
data = await self.json("GET", f"/v1/tasks/{task_id}")
if not isinstance(data, dict):
raise ZertoError("GET /v1/tasks/{id} did not return an object")
return data
async def start_flr(
self,
vpg_identifier: str,
vm_identifier: str,
checkpoint_identifier: str,
initial_download_path: str,
) -> Any:
return await self.json(
"POST",
"/v1/flrs",
json_body={
"jflr": {
"vpgIdentifier": vpg_identifier,
"vmIdentifier": vm_identifier,
"CheckpointIdentifier": checkpoint_identifier,
"initialDownloadPath": initial_download_path,
}
},
)
async def get_flr(self, session_id: str) -> Any:
return await self.json("GET", f"/v1/flrs/{session_id}")
async def browse_flr(self, session_id: str, path: str = "", recursive: bool = False) -> Any:
return await self.json(
"POST",
f"/v1/flrs/{session_id}/browse",
json_body={"path": path, "recursive": recursive},
)
async def download_flr(self, session_id: str, path_list: list[str]) -> Any:
return await self.json(
"POST",
f"/v1/flrs/{session_id}/download",
json_body={"pathList": path_list},
)
async def fetch_download(self, token: str) -> bytes:
response = await self.request("GET", f"/v1/downloads/{token}")
if response.status_code >= 400:
# workflow also shows GET /v1/flrs/{token}
response = await self.request("GET", f"/v1/flrs/{token}")
if response.status_code >= 400:
raise ZertoError(
f"FLR download HTTP {response.status_code}: {response.text[:300]}",
status_code=response.status_code,
)
return response.content
async def end_flr(self, session_id: str) -> None:
try:
await self.json("DELETE", f"/v1/flrs/{session_id}")
except ZertoError:
await self.json("POST", f"/v1/flrs/{session_id}")
async def start_failover_test(
self,
vpg_identifier: str,
checkpoint_identifier: str | None = None,
vm_identifiers: list[str] | None = None,
) -> Any:
body: dict[str, Any] = {}
if checkpoint_identifier:
body["checkpointIdentifier"] = checkpoint_identifier
if vm_identifiers:
body["vmIdentifiers"] = vm_identifiers
return await self.json(
"POST",
f"/v1/vpgs/{vpg_identifier}/FailoverTest",
json_body=body or None,
)
async def stop_failover_test(self, vpg_identifier: str, success: bool, summary: str) -> Any:
return await self.json(
"POST",
f"/v1/vpgs/{vpg_identifier}/FailoverTestStop",
json_body={"failoverTestSuccess": success, "failoverTestSummary": summary},
)
async def start_clone(
self,
vpg_identifier: str,
checkpoint_identifier: str,
datastore_identifier: str | None = None,
vm_identifiers: list[str] | None = None,
) -> Any:
body: dict[str, Any] = {"checkpointIdentifier": checkpoint_identifier}
if datastore_identifier:
body["datastoreIdentifier"] = datastore_identifier
if vm_identifiers:
body["vmIdentifiers"] = vm_identifiers
return await self.json(
"POST",
f"/v1/vpgs/{vpg_identifier}/CloneStart",
json_body=body,
)