From 94025b8d694aa17da73c8efe48c5be22690d7778 Mon Sep 17 00:00:00 2001 From: Justin Paul Date: Mon, 21 Sep 2026 13:04:08 -0400 Subject: [PATCH] fix(flr): resolve guest paths into the FLR partition namespace zerto_recover_file passed the guest absolute path straight to POST /v1/flrs/{session}/download, which the ZVM rejects: HTTP 400 {"Message":"Invalid path: check location exists or correct path syntax."} FLR browse/download is rooted at partitions, not the guest's /. /home/justin/app-config.yaml is Volume2-Ext4/home/justin/app-config.yaml. server.py already called browse_flr() but discarded the result, so nothing ever resolved the path. Add resolve_flr_path() and browsable_partitions() to recover.py: - browse path "" returns {MainPathItem, PathItems}, not a bare list - skip partitions with IsBrowsable false. A Linux guest reports Volume1-Unknown as "Cannot restore. Partition type Unknown is not supported." Do not assume the first partition is the right one. - browse returns child paths percent-encoded (Volume2-Ext4%2fhome%2fjustin%2fapp-config.yaml); download wants them decoded with plain slashes - raise a ZertoError naming what was searched when the file is absent, since "not replicated into that checkpoint yet" is the likely cause and is actionable zerto_recover_file now returns the resolved flr_path. Verified end to end against ZVM 10.x: guard tagged cp 1075 on VPG jp-ubuntu, FLR mounted in ~3s, 158 bytes recovered from the pre-mutation checkpoint with matching content. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016yVfC5nvZowoLFnEGWhLGn --- src/zerto_rewind_mcp/recover.py | 59 +++++++++++++++++++++++++++++++++ src/zerto_rewind_mcp/server.py | 12 +++++-- tests/test_recover.py | 49 +++++++++++++++++++++++++++ 3 files changed, 117 insertions(+), 3 deletions(-) diff --git a/src/zerto_rewind_mcp/recover.py b/src/zerto_rewind_mcp/recover.py index 0bf6920..e3e96d9 100644 --- a/src/zerto_rewind_mcp/recover.py +++ b/src/zerto_rewind_mcp/recover.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import urllib.parse from typing import Any from zerto_rewind_mcp.client import ZertoClient, ZertoError @@ -94,3 +95,61 @@ async def wait_flr_ready( f"FLR session {session_id} not ready within {timeout_s:.0f}s (last={last!r}). " "Retry browse after a minute, or check EJC is not running and no clone/test is active." ) + + +def _decode_flr_path(value: Any) -> str: + """Browse returns paths percent-encoded (%2f). Download wants them decoded.""" + return urllib.parse.unquote(str(value or "")).replace("\\", "/") + + +def path_items(payload: Any) -> list[dict[str, Any]]: + if isinstance(payload, dict): + items = payload.get("PathItems") or payload.get("pathItems") or [] + return [i for i in items if isinstance(i, dict)] + if isinstance(payload, list): + return [i for i in payload if isinstance(i, dict)] + return [] + + +async def browsable_partitions(client: ZertoClient, session_id: str) -> list[str]: + """FLR is rooted at partitions (Volume2-Ext4), not the guest's /. + + Volume1-Unknown and friends report IsBrowsable false and cannot be restored. + """ + rows = path_items(await client.browse_flr(session_id, path="")) + return [str(r.get("Path")) for r in rows if r.get("IsBrowsable")] + + +async def resolve_flr_path(client: ZertoClient, session_id: str, guest_path: str) -> str: + """Map a guest absolute path to the FLR namespace path the download API accepts. + + /home/justin/app-config.yaml -> Volume2-Ext4/home/justin/app-config.yaml + """ + rel = guest_path.replace("\\", "/").strip("/") + if not rel: + raise ZertoError("Empty guest_path") + parts = rel.split("/") + name, parent = parts[-1], "/".join(parts[:-1]) + + partitions = await browsable_partitions(client, session_id) + if not partitions: + raise ZertoError( + "FLR mounted but no browsable partition. Unsupported partition type " + "(LVM/unknown) cannot be restored by FLR." + ) + tried = [] + for vol in partitions: + probe = f"{vol}/{parent}" if parent else vol + tried.append(probe) + try: + rows = path_items(await client.browse_flr(session_id, path=probe)) + except ZertoError: + continue + for row in rows: + decoded = _decode_flr_path(row.get("Path")) + if decoded.rsplit("/", 1)[-1] == name: + return decoded + raise ZertoError( + f"{guest_path!r} not found in the FLR mount. Looked under {tried}. " + "The file may not have replicated into that checkpoint yet." + ) diff --git a/src/zerto_rewind_mcp/server.py b/src/zerto_rewind_mcp/server.py index 1a2bec0..0b09cab 100644 --- a/src/zerto_rewind_mcp/server.py +++ b/src/zerto_rewind_mcp/server.py @@ -13,7 +13,12 @@ from zerto_rewind_mcp.checkpoints import checkpoint_id, checkpoint_tag, make_tag from zerto_rewind_mcp.client import ZertoClient, ZertoError from zerto_rewind_mcp.config import load_catalog, load_config from zerto_rewind_mcp.protection import find_from_rows -from zerto_rewind_mcp.recover import download_token_from, session_id_from, wait_flr_ready +from zerto_rewind_mcp.recover import ( + download_token_from, + resolve_flr_path, + session_id_from, + wait_flr_ready, +) from zerto_rewind_mcp.util import pick mcp = FastMCP("zerto_rewind_mcp") @@ -279,8 +284,8 @@ async def zerto_recover_file( ) session_id = session_id_from(started) await wait_flr_ready(client, session_id) - await client.browse_flr(session_id, path="", recursive=False) - token_payload = await client.download_flr(session_id, [guest_path]) + flr_path = await resolve_flr_path(client, session_id, guest_path) + token_payload = await client.download_flr(session_id, [flr_path]) token = download_token_from(token_payload) blob = await client.fetch_download(token) name = Path(guest_path.replace("\\", "/")).name or "recovered.bin" @@ -292,6 +297,7 @@ async def zerto_recover_file( "path": str(out_path.resolve()), "bytes": len(blob), "session_id": session_id, + "flr_path": flr_path, "message": f"Wrote {len(blob)} bytes to {out_path}", } ) diff --git a/tests/test_recover.py b/tests/test_recover.py index 4cf9b6b..361c501 100644 --- a/tests/test_recover.py +++ b/tests/test_recover.py @@ -28,3 +28,52 @@ def test_flr_list_status(): ] row = flr_row(payload) assert flr_status(row).lower() == "mountcompletedsuccessfully" + + +def test_path_items_shapes(): + from zerto_rewind_mcp.recover import path_items + + assert path_items({"PathItems": [{"Path": "a"}]}) == [{"Path": "a"}] + assert path_items([{"Path": "b"}]) == [{"Path": "b"}] + assert path_items(None) == [] + + +def test_decode_flr_path(): + from zerto_rewind_mcp.recover import _decode_flr_path + + assert _decode_flr_path("Volume2-Ext4%2fhome%2fjustin%2fapp-config.yaml") == ( + "Volume2-Ext4/home/justin/app-config.yaml" + ) + + +def test_resolve_flr_path_picks_browsable_partition(): + import asyncio + + from zerto_rewind_mcp.recover import resolve_flr_path + + root = { + "PathItems": [ + {"Path": "Volume1-Unknown", "IsBrowsable": False}, + {"Path": "Volume2-Ext4", "IsBrowsable": True}, + ] + } + listing = { + "PathItems": [ + {"Path": "Volume2-Ext4%2fhome%2fjustin%2f.bashrc", "Type": "File"}, + {"Path": "Volume2-Ext4%2fhome%2fjustin%2fapp-config.yaml", "Type": "File"}, + ] + } + + class FakeClient: + def __init__(self): + self.seen = [] + + async def browse_flr(self, session_id, path="", recursive=False): + self.seen.append(path) + return root if path == "" else listing + + client = FakeClient() + got = asyncio.run(resolve_flr_path(client, "sess", "/home/justin/app-config.yaml")) + assert got == "Volume2-Ext4/home/justin/app-config.yaml" + # must not try the unrestorable partition + assert "Volume1-Unknown/home/justin" not in client.seen