feat(flr): windows paths, recovery-site gate, stable partition reads (#4)

This commit was merged in pull request #4.
This commit is contained in:
claude committed 2026-09-21 14:11:09 -04:00
1 parent 59d1f71617
commit 90768f17e1
8 files changed
+295 -50

No files matched your search

+9 -1
View File
@@ -151,7 +151,7 @@ class ZertoClient:
async def get_vpg(self, vpg_identifier: str) -> dict[str, Any]:
data = await self.json("GET", f"/v1/vpgs/{vpg_identifier}")
if not isinstance(data, dict):
raise ZertoError("GET /v1/vpgs/{id} did not return an object")
raise ZertoError(f"GET /v1/vpgs/{vpg_identifier} did not return an object")
return data
async def list_checkpoints(self, vpg_identifier: str) -> list[dict[str, Any]]:
@@ -231,6 +231,14 @@ class ZertoClient:
status_code=last.status_code if last else None,
)
async def get_localsite(self) -> dict[str, Any]:
data = await self.json("GET", "/v1/localsite")
return data if isinstance(data, dict) else {}
async def get_peersites(self) -> list[dict[str, Any]]:
data = await self.json("GET", "/v1/peersites")
return [r for r in data if isinstance(r, dict)] if isinstance(data, list) else []
async def list_flrs(self) -> Any:
"""GET /v1/flrs. Every FLR session the ZVM currently knows about."""
return await self.json("GET", "/v1/flrs")
+79 -21
View File
@@ -9,6 +9,9 @@ from typing import Any
from zerto_rewind_mcp.client import ZertoClient, ZertoError
from zerto_rewind_mcp.util import pick
# Seconds between partition-enumeration polls. Module level so tests can shrink it.
PARTITION_POLL_INTERVAL_S = 5.0
READY_STATUSES = {
"ready",
"mounted",
@@ -98,8 +101,15 @@ async def wait_flr_ready(
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("\\", "/")
"""Decode a browse path for display and comparison.
Browse form-encodes: '%2f' is the separator, '%3a' the drive colon, and a
SPACE comes back as '+' ("Program+Files"). unquote alone leaves the '+',
so a basename compare against "Program Files" would never match.
Download accepts the raw and the decoded form, so we send the raw one and
only decode for matching and display.
"""
return urllib.parse.unquote_plus(str(value or "")).replace("\\", "/")
def path_items(payload: Any) -> list[dict[str, Any]]:
@@ -112,46 +122,94 @@ def path_items(payload: Any) -> list[dict[str, Any]]:
async def browsable_partitions(client: ZertoClient, session_id: str) -> list[str]:
"""FLR is rooted at partitions (Volume2-Ext4), not the guest's /.
"""FLR is rooted at partitions, not the guest's /.
Volume1-Unknown and friends report IsBrowsable false and cannot be restored.
Linux: Volume2-Ext4. Windows: the drive letter is the partition name and
comes back percent-encoded, e.g. 'C%3a'. Partitions reporting IsBrowsable
false (FAT32, MicrosoftReservedPartition, Unknown) cannot be restored from.
"""
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.
async def wait_partitions_stable(
client: ZertoClient,
session_id: str,
*,
timeout_s: float = 120.0,
interval_s: float | None = None,
) -> list[str]:
"""Wait until the partition list stops changing, then return it.
/home/justin/app-config.yaml -> Volume2-Ext4/home/justin/app-config.yaml
A session reports mounted before the ZVM has finished identifying volumes.
Browsing in that window returns a partial and MIS-LABELLED list: the same
Windows VM enumerated as 'Volume4-Unknown' with no C: drive, and moments
later as a browsable 'C%3a' holding the whole filesystem. Acting on the
early list makes a restorable disk look permanently unrestorable.
"""
interval_s = PARTITION_POLL_INTERVAL_S if interval_s is None else interval_s
deadline = asyncio.get_event_loop().time() + timeout_s
previous: list[str] | None = None
while asyncio.get_event_loop().time() < deadline:
current = await browsable_partitions(client, session_id)
if current and previous is not None and current == previous:
return current
previous = current
await asyncio.sleep(interval_s)
if previous:
return previous
raise ZertoError(
"FLR mounted but no browsable partition appeared. Unsupported partition "
"type (FAT32, MicrosoftReservedPartition, LVM, unknown) cannot be restored."
)
async def resolve_flr_path(client: ZertoClient, session_id: str, guest_path: str) -> str:
"""Map a guest absolute path to the path the FLR download API accepts.
Linux /home/justin/app-config.yaml -> Volume2-Ext4%2fhome%2fjustin%2f...
Windows C:\\Users\\x\\f.txt -> C%3a%2fUsers%2fx%2ff.txt
The two are not symmetrical. On Linux the partition is a separate root that
must be prepended. On Windows the drive letter IS the partition, so the
guest path already carries it and prepending again yields 'C:/C:/Users'.
Returns the raw path exactly as browse reported it; download accepts that
verbatim, which avoids re-encoding the '+' and '%3a' back by hand.
"""
rel = guest_path.replace("\\", "/").strip("/")
if not rel:
raise ZertoError("Empty guest_path")
parts = rel.split("/")
name, parent = parts[-1], "/".join(parts[:-1])
name, parent_rel = 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 = []
partitions = await wait_partitions_stable(client, session_id)
tried: list[str] = []
for vol in partitions:
probe = f"{vol}/{parent}" if parent else vol
vol_dec = _decode_flr_path(vol).rstrip("/")
low_rel, low_vol = rel.lower(), vol_dec.lower()
if low_rel == low_vol or low_rel.startswith(low_vol + "/"):
# Windows: guest path already starts with the drive-letter partition
probe = parent_rel or vol_dec
else:
probe = f"{vol_dec}/{parent_rel}" if parent_rel else vol_dec
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
# exact match first; Windows is case-insensitive, Linux is not, so only
# fall back to a case-insensitive match when nothing matched exactly.
for want_exact in (True, False):
for row in rows:
raw = str(row.get("Path") or "")
got = _decode_flr_path(raw).rsplit("/", 1)[-1]
if got == name if want_exact else got.lower() == name.lower():
return raw
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."
f"Browsable partitions were {partitions}. Either the path is wrong, or "
"the file had not replicated into that checkpoint yet."
)
+56
View File
@@ -260,6 +260,55 @@ async def zerto_add_mutating_tool(
return _dump({"ok": True, "entry": entry.as_dict()})
async def _site_name(client: ZertoClient, identifier: str | None) -> str:
if not identifier:
return "unknown site"
try:
local = await client.get_localsite()
if str(local.get("SiteIdentifier")) == identifier:
return str(local.get("SiteName") or identifier)
for peer in await client.get_peersites():
if str(peer.get("SiteIdentifier")) == identifier:
return str(peer.get("PeerSiteName") or identifier)
except ZertoError:
pass
return identifier
async def _flr_site_gate(client: ZertoClient, vpg_identifier: str) -> dict[str, Any]:
"""Refuse FLR on a VPG whose recovery site is not this ZVM.
FLR only exists at the VPG's RECOVERY site: the mount is created there. A
VPG replicating to a cloud ZCA has to be recovered from that ZCA's API, not
this one. Until this server can hold credentials for every ZVM/ZCA in an
estate and route the call, restrict FLR to local replication (protected
site == recovery site) and say plainly where the operation actually lives,
rather than letting it fail as a confusing path or mount error.
"""
try:
vpg = await client.get_vpg(vpg_identifier)
except ZertoError as exc:
return {"ok": False, "message": f"Could not read VPG {vpg_identifier}: {exc}"}
protected = (vpg.get("ProtectedSite") or {}).get("identifier")
recovery = (vpg.get("RecoverySite") or {}).get("identifier")
if protected and recovery and str(protected) == str(recovery):
return {"ok": True}
where = await _site_name(client, str(recovery) if recovery else None)
return {
"ok": False,
"not_local_replication": True,
"vpg_name": vpg.get("VpgName"),
"recovery_site": where,
"message": (
f"FLR for VPG {vpg.get('VpgName')!r} lives at its recovery site "
f"({where}), not at this ZVM. This server only supports file "
"recovery for locally replicated VPGs (protected site == recovery "
"site). Point an MCP instance at that ZVM/ZCA, or use a bounded "
"whole-VM operation instead."
),
}
async def _live_session_ids(client: ZertoClient) -> set[str]:
try:
rows = session_rows(await client.list_flrs())
@@ -327,6 +376,10 @@ async def zerto_recover_file(
Requires confirmed=true (human yes). Cannot run during clone/test/live/EJC.
10.9 FLR Operator role fails; use an Administrator account.
Locally replicated VPGs only. FLR is performed at the VPG's recovery site,
so a VPG replicating to a cloud ZCA must be recovered from that ZCA's API.
guest_path is the path on the guest: /home/x/f.conf or C:\\Users\\x\\f.txt.
"""
if not confirmed:
return _dump(
@@ -339,6 +392,9 @@ async def zerto_recover_file(
}
)
client = get_client()
gate = await _flr_site_gate(client, vpg_identifier)
if not gate.get("ok"):
return _dump(gate)
dest = Path(dest_dir or _settings.get("recovery_dir") or "./recovered")
dest.mkdir(parents=True, exist_ok=True)
session_id: str | None = None