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:
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import urllib.parse
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from zerto_rewind_mcp.client import ZertoClient, ZertoError
|
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}). "
|
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."
|
"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."
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.client import ZertoClient, ZertoError
|
||||||
from zerto_rewind_mcp.config import load_catalog, load_config
|
from zerto_rewind_mcp.config import load_catalog, load_config
|
||||||
from zerto_rewind_mcp.protection import find_from_rows
|
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
|
from zerto_rewind_mcp.util import pick
|
||||||
|
|
||||||
mcp = FastMCP("zerto_rewind_mcp")
|
mcp = FastMCP("zerto_rewind_mcp")
|
||||||
@@ -279,8 +284,8 @@ async def zerto_recover_file(
|
|||||||
)
|
)
|
||||||
session_id = session_id_from(started)
|
session_id = session_id_from(started)
|
||||||
await wait_flr_ready(client, session_id)
|
await wait_flr_ready(client, session_id)
|
||||||
await client.browse_flr(session_id, path="", recursive=False)
|
flr_path = await resolve_flr_path(client, session_id, guest_path)
|
||||||
token_payload = await client.download_flr(session_id, [guest_path])
|
token_payload = await client.download_flr(session_id, [flr_path])
|
||||||
token = download_token_from(token_payload)
|
token = download_token_from(token_payload)
|
||||||
blob = await client.fetch_download(token)
|
blob = await client.fetch_download(token)
|
||||||
name = Path(guest_path.replace("\\", "/")).name or "recovered.bin"
|
name = Path(guest_path.replace("\\", "/")).name or "recovered.bin"
|
||||||
@@ -292,6 +297,7 @@ async def zerto_recover_file(
|
|||||||
"path": str(out_path.resolve()),
|
"path": str(out_path.resolve()),
|
||||||
"bytes": len(blob),
|
"bytes": len(blob),
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
|
"flr_path": flr_path,
|
||||||
"message": f"Wrote {len(blob)} bytes to {out_path}",
|
"message": f"Wrote {len(blob)} bytes to {out_path}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,3 +28,52 @@ def test_flr_list_status():
|
|||||||
]
|
]
|
||||||
row = flr_row(payload)
|
row = flr_row(payload)
|
||||||
assert flr_status(row).lower() == "mountcompletedsuccessfully"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user