diff --git a/.gitignore b/.gitignore index 9421fda..321c656 100644 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,6 @@ config.json catalog.json *.log .mcp.json +recovered/ .claude/* !.claude/gitea-ship.json diff --git a/src/zerto_rewind_mcp/client.py b/src/zerto_rewind_mcp/client.py index 3d73849..f8d7981 100644 --- a/src/zerto_rewind_mcp/client.py +++ b/src/zerto_rewind_mcp/client.py @@ -216,16 +216,20 @@ class ZertoClient: ) 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 + token = str(token).strip().strip('"') + if token.startswith("v1/") or token.startswith("/v1/"): + paths = [token if token.startswith("/") else f"/{token}"] + else: + paths = [f"/v1/downloads/{token}", f"/v1/flrs/{token}"] + last = None + for path in paths: + last = await self.request("GET", path) + if last.status_code < 400: + 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: try: diff --git a/src/zerto_rewind_mcp/recover.py b/src/zerto_rewind_mcp/recover.py index be80b84..0bf6920 100644 --- a/src/zerto_rewind_mcp/recover.py +++ b/src/zerto_rewind_mcp/recover.py @@ -8,6 +8,15 @@ from typing import Any from zerto_rewind_mcp.client import ZertoClient, ZertoError 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: if payload is None: @@ -17,6 +26,7 @@ def session_id_from(payload: Any) -> str: if isinstance(payload, dict): value = pick( payload, + "FlrSessionIdentifier", "sessionId", "SessionId", "flrSessionIdentifier", @@ -40,6 +50,22 @@ def download_token_from(payload: Any) -> str: 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( client: ZertoClient, session_id: str, @@ -51,19 +77,18 @@ async def wait_flr_ready( last: Any = None while asyncio.get_event_loop().time() < deadline: last = await client.get_flr(session_id) - status = "" - if isinstance(last, dict): - status = str(pick(last, "Status", "status", "state", "State") or "") - if status.lower() in {"ready", "mounted", "available"}: - return last if isinstance(last, dict) else {"status": status} - if "mountinprogress" in status.lower() or "inprogress" in status.lower(): + row = flr_row(last) + status = flr_status(row) + low = status.lower() + if low in READY_STATUSES: + return row + if "mountinprogress" in low or (low.endswith("inprogress") and "fail" not in low): await asyncio.sleep(interval_s) continue - if status.lower() in {"failed", "error"}: + if low in FAILED_STATUSES: raise ZertoError(f"FLR session {session_id} failed: {last}") - # some appliances omit status once mounted - if isinstance(last, dict) and not status: - return last + if row and not status: + return row await asyncio.sleep(interval_s) raise ZertoError( f"FLR session {session_id} not ready within {timeout_s:.0f}s (last={last!r}). " diff --git a/tests/test_recover.py b/tests/test_recover.py index c2d52cc..4cf9b6b 100644 --- a/tests/test_recover.py +++ b/tests/test_recover.py @@ -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(): assert session_id_from("abc") == "abc" assert session_id_from({"sessionId": "s1"}) == "s1" assert session_id_from({"Identifier": "s2"}) == "s2" + assert session_id_from({"FlrSessionIdentifier": "s3"}) == "s3" def test_download_token_shapes(): assert download_token_from("tok") == "tok" 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"