feat(flr): windows paths, recovery-site gate, stable partition reads (#4)
This commit was merged in pull request #4.
This commit is contained in:
1 parent
59d1f71617
commit
90768f17e1
8 files changed
+295
-50
No files matched your search
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user