fix(flr): 10.9 session list + download path #1

Merged
claude merged 1 commits from lab/flr-wait-and-download into main 2026-09-21 12:18:14 -04:00
4 changed files with 69 additions and 21 deletions
+1
View File
@@ -13,5 +13,6 @@ config.json
catalog.json catalog.json
*.log *.log
.mcp.json .mcp.json
recovered/
.claude/* .claude/*
!.claude/gitea-ship.json !.claude/gitea-ship.json
+14 -10
View File
@@ -216,16 +216,20 @@ class ZertoClient:
) )
async def fetch_download(self, token: str) -> bytes: async def fetch_download(self, token: str) -> bytes:
response = await self.request("GET", f"/v1/downloads/{token}") token = str(token).strip().strip('"')
if response.status_code >= 400: if token.startswith("v1/") or token.startswith("/v1/"):
# workflow also shows GET /v1/flrs/{token} paths = [token if token.startswith("/") else f"/{token}"]
response = await self.request("GET", f"/v1/flrs/{token}") else:
if response.status_code >= 400: paths = [f"/v1/downloads/{token}", f"/v1/flrs/{token}"]
raise ZertoError( last = None
f"FLR download HTTP {response.status_code}: {response.text[:300]}", for path in paths:
status_code=response.status_code, last = await self.request("GET", path)
) if last.status_code < 400:
return response.content return last.content
raise ZertoError(
f"FLR download HTTP {last.status_code if last else '?'}: {(last.text[:300] if last else '')}",
status_code=last.status_code if last else None,
)
async def end_flr(self, session_id: str) -> None: async def end_flr(self, session_id: str) -> None:
try: try:
+35 -10
View File
@@ -8,6 +8,15 @@ from typing import Any
from zerto_rewind_mcp.client import ZertoClient, ZertoError from zerto_rewind_mcp.client import ZertoClient, ZertoError
from zerto_rewind_mcp.util import pick from zerto_rewind_mcp.util import pick
READY_STATUSES = {
"ready",
"mounted",
"available",
"mountcompletedsuccessfully",
"completed",
}
FAILED_STATUSES = {"failed", "error", "mountfailed"}
def session_id_from(payload: Any) -> str: def session_id_from(payload: Any) -> str:
if payload is None: if payload is None:
@@ -17,6 +26,7 @@ def session_id_from(payload: Any) -> str:
if isinstance(payload, dict): if isinstance(payload, dict):
value = pick( value = pick(
payload, payload,
"FlrSessionIdentifier",
"sessionId", "sessionId",
"SessionId", "SessionId",
"flrSessionIdentifier", "flrSessionIdentifier",
@@ -40,6 +50,22 @@ def download_token_from(payload: Any) -> str:
raise ZertoError(f"Could not read FLR download token from {payload!r}") raise ZertoError(f"Could not read FLR download token from {payload!r}")
def flr_row(payload: Any) -> dict[str, Any]:
if isinstance(payload, list):
if not payload:
return {}
first = payload[0]
return first if isinstance(first, dict) else {}
if isinstance(payload, dict):
return payload
return {}
def flr_status(row: dict[str, Any]) -> str:
value = pick(row, "FlrSessionStatus", "Status", "status", "state", "State") or ""
return str(value)
async def wait_flr_ready( async def wait_flr_ready(
client: ZertoClient, client: ZertoClient,
session_id: str, session_id: str,
@@ -51,19 +77,18 @@ async def wait_flr_ready(
last: Any = None last: Any = None
while asyncio.get_event_loop().time() < deadline: while asyncio.get_event_loop().time() < deadline:
last = await client.get_flr(session_id) last = await client.get_flr(session_id)
status = "" row = flr_row(last)
if isinstance(last, dict): status = flr_status(row)
status = str(pick(last, "Status", "status", "state", "State") or "") low = status.lower()
if status.lower() in {"ready", "mounted", "available"}: if low in READY_STATUSES:
return last if isinstance(last, dict) else {"status": status} return row
if "mountinprogress" in status.lower() or "inprogress" in status.lower(): if "mountinprogress" in low or (low.endswith("inprogress") and "fail" not in low):
await asyncio.sleep(interval_s) await asyncio.sleep(interval_s)
continue continue
if status.lower() in {"failed", "error"}: if low in FAILED_STATUSES:
raise ZertoError(f"FLR session {session_id} failed: {last}") raise ZertoError(f"FLR session {session_id} failed: {last}")
# some appliances omit status once mounted if row and not status:
if isinstance(last, dict) and not status: return row
return last
await asyncio.sleep(interval_s) await asyncio.sleep(interval_s)
raise ZertoError( raise ZertoError(
f"FLR session {session_id} not ready within {timeout_s:.0f}s (last={last!r}). " f"FLR session {session_id} not ready within {timeout_s:.0f}s (last={last!r}). "
+19 -1
View File
@@ -1,12 +1,30 @@
from zerto_rewind_mcp.recover import download_token_from, session_id_from from zerto_rewind_mcp.recover import (
download_token_from,
flr_row,
flr_status,
session_id_from,
)
def test_session_id_shapes(): def test_session_id_shapes():
assert session_id_from("abc") == "abc" assert session_id_from("abc") == "abc"
assert session_id_from({"sessionId": "s1"}) == "s1" assert session_id_from({"sessionId": "s1"}) == "s1"
assert session_id_from({"Identifier": "s2"}) == "s2" assert session_id_from({"Identifier": "s2"}) == "s2"
assert session_id_from({"FlrSessionIdentifier": "s3"}) == "s3"
def test_download_token_shapes(): def test_download_token_shapes():
assert download_token_from("tok") == "tok" assert download_token_from("tok") == "tok"
assert download_token_from({"downloadToken": "t2"}) == "t2" assert download_token_from({"downloadToken": "t2"}) == "t2"
assert download_token_from("v1/downloads/abc").startswith("v1/")
def test_flr_list_status():
payload = [
{
"FlrSessionStatus": "MountCompletedSuccessfully",
"FlrSessionIdentifier": "sess-1",
}
]
row = flr_row(payload)
assert flr_status(row).lower() == "mountcompletedsuccessfully"