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) <[email protected]>
Claude-Session: https://claude.ai/code/session_016yVfC5nvZowoLFnEGWhLGn
This commit is contained in:
2026-09-21 13:04:08 -04:00
co-authored by Claude Opus 5
parent 2473d22d2e
commit 94025b8d69
3 changed files with 117 additions and 3 deletions
+49
View File
@@ -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