feat(flr): windows paths, recovery-site gate, stable partition reads (#4)
This commit was merged in pull request #4.
This commit is contained in:
@@ -22,6 +22,7 @@ _Avoid_: recover (unqualified), fail back, restore the VPG
|
||||
|
||||
**File-level recovery (FLR)**:
|
||||
Mount a VM from a journal checkpoint and pull files. The VM stays up. 10.9 FLR Operator RBAC is broken; Administrator is the documented workaround.
|
||||
Runs at the VPG's **recovery** site, so this server supports it only for locally replicated VPGs (protected site == recovery site). Paths are partition-rooted; on Windows the drive letter is the partition.
|
||||
_Avoid_: file restore (unqualified), instant restore (local-journal VMs only, not v1)
|
||||
|
||||
**find_protection**:
|
||||
|
||||
@@ -8,7 +8,7 @@ If the loop works, these tools are the delta to add to official ZVM MCP (`ZVM.MC
|
||||
|
||||
1. `zerto_find_protection` — VM name, hostname, or vmIdentifier to exactly one VM and every VPG. Zero or two-plus VMs: stop.
|
||||
2. `zerto_create_tagged_checkpoint` / `zerto_guard_before_mutate` — same tag on every protecting VPG, wait until listed. The name records which agent and what it is doing: `ai:<agent> | <action> | vm=<vm> | change=<id> | <utc>`.
|
||||
3. `zerto_recover_file` — FLR after a human sets `confirmed=true`. Reports its own unmount; `zerto_list_flr_sessions` / `zerto_end_flr_session` find and reap a mount orphaned by a crashed recovery.
|
||||
3. `zerto_recover_file` — FLR after a human sets `confirmed=true`. Linux and Windows guest paths. Locally replicated VPGs only: FLR runs at the VPG's recovery site. Reports its own unmount; `zerto_list_flr_sessions` / `zerto_end_flr_session` find and reap a mount orphaned by a crashed recovery.
|
||||
4. Mutating catalog — opt-in list of MCP tools that must be guarded. Unlisted tools pass through. Users add entries.
|
||||
|
||||
Official ZVM MCP already has inventory and failover test. It does not insert tagged checkpoints or run FLR.
|
||||
|
||||
+23
-1
@@ -11,7 +11,29 @@ known path (config, dropped file, one directory).
|
||||
|
||||
FLR mounts a checkpoint and copies files out. The protected VM stays up.
|
||||
Official API: `POST /v1/flrs` then browse/download. This MCP writes the file
|
||||
to `recovery_dir` on the MCP host. Putting it back on the guest is a second
|
||||
to `recovery_dir` on the MCP host.
|
||||
|
||||
**FLR runs at the VPG's recovery site**, because that is where the mount is
|
||||
created. A VPG replicating to a cloud ZCA must be recovered through that ZCA's
|
||||
API, not the protected ZVM's. A production server would hold credentials for
|
||||
every ZVM/ZCA in the estate and route the call; this one does not, so
|
||||
`zerto_recover_file` is gated to **locally replicated VPGs** (protected site ==
|
||||
recovery site) and refuses anything else while naming the site that owns the
|
||||
operation.
|
||||
|
||||
Paths are rooted at partitions, and Linux and Windows are not symmetrical:
|
||||
|
||||
| | guest path | FLR path |
|
||||
|---|---|---|
|
||||
| Linux | `/home/j/app.yaml` | `Volume2-Ext4%2fhome%2fj%2fapp.yaml` |
|
||||
| Windows | `C:\Users\j\app.conf` | `C%3a%2fUsers%2fj%2fapp.conf` |
|
||||
|
||||
On Windows the drive letter **is** the partition name, so nothing is
|
||||
prepended. Browse form-encodes: `%2f` separator, `%3a` drive colon, and a
|
||||
space as `+` (`Program+Files`). A session reports mounted before volume
|
||||
enumeration settles, so the partition list must be polled until it stops
|
||||
changing -- an early read can show a restorable NTFS disk as
|
||||
`Volume4-Unknown`. Putting it back on the guest is a second
|
||||
step (scp/ssh). That copy-back is not Zerto; it is ordinary file transfer.
|
||||
|
||||
Do not use FLR when:
|
||||
|
||||
@@ -37,7 +37,10 @@ A VM can be in up to three VPGs (local backup + remote DR is common). Tag all of
|
||||
|
||||
Human must confirm. Pass `confirmed=true` only after they say yes.
|
||||
|
||||
- Bad config / dropped file: `zerto_recover_file` from **that tag**.
|
||||
- Bad config / dropped file: `zerto_recover_file` from **that tag**. Pass the guest path
|
||||
(`/home/j/app.yaml` or `C:\Users\j\app.conf`); the server maps it into the FLR
|
||||
namespace. **Locally replicated VPGs only** -- FLR happens at the recovery site, so a
|
||||
cloud-replicated VPG must be recovered from that ZCA. The tool refuses and names the site.
|
||||
- Inspect a whole VM: `zerto_offsite_clone` or `zerto_start_failover_test`.
|
||||
- Never Failover Live. Never Move. Those are DR, not rewind.
|
||||
|
||||
|
||||
@@ -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
|
||||
# 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:
|
||||
decoded = _decode_flr_path(row.get("Path"))
|
||||
if decoded.rsplit("/", 1)[-1] == name:
|
||||
return decoded
|
||||
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
|
||||
|
||||
+122
-25
@@ -46,39 +46,96 @@ def test_decode_flr_path():
|
||||
)
|
||||
|
||||
|
||||
def test_resolve_flr_path_picks_browsable_partition():
|
||||
import asyncio
|
||||
class _BrowseFake:
|
||||
"""Fake FLR mount. root is the partition list; tree maps probe path -> rows."""
|
||||
|
||||
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 = []
|
||||
def __init__(self, root, tree):
|
||||
self.root, self.tree, self.seen = root, tree, []
|
||||
|
||||
async def browse_flr(self, session_id, path="", recursive=False):
|
||||
self.seen.append(path)
|
||||
return root if path == "" else listing
|
||||
if path == "":
|
||||
return {"PathItems": self.root}
|
||||
return {"PathItems": self.tree.get(path, [])}
|
||||
|
||||
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
|
||||
|
||||
def _resolve(client, guest_path):
|
||||
import asyncio
|
||||
|
||||
from zerto_rewind_mcp import recover
|
||||
|
||||
recover.PARTITION_POLL_INTERVAL_S = 0 # do not sleep in tests
|
||||
return asyncio.run(recover.resolve_flr_path(client, "sess", guest_path))
|
||||
|
||||
|
||||
def test_resolve_flr_path_linux_prepends_partition():
|
||||
client = _BrowseFake(
|
||||
[
|
||||
{"Path": "Volume1-Unknown", "IsBrowsable": False},
|
||||
{"Path": "Volume2-Ext4", "IsBrowsable": True},
|
||||
],
|
||||
{
|
||||
"Volume2-Ext4/home/justin": [
|
||||
{"Path": "Volume2-Ext4%2fhome%2fjustin%2f.bashrc", "Type": "File"},
|
||||
{"Path": "Volume2-Ext4%2fhome%2fjustin%2fapp-config.yaml", "Type": "File"},
|
||||
]
|
||||
},
|
||||
)
|
||||
# returns the raw path browse gave us; download accepts it verbatim
|
||||
assert _resolve(client, "/home/justin/app-config.yaml") == (
|
||||
"Volume2-Ext4%2fhome%2fjustin%2fapp-config.yaml"
|
||||
)
|
||||
assert "Volume1-Unknown/home/justin" not in client.seen
|
||||
|
||||
|
||||
def test_resolve_flr_path_windows_does_not_double_the_drive_letter():
|
||||
# On Windows the drive letter IS the partition: C%3a decodes to "C:".
|
||||
client = _BrowseFake(
|
||||
[
|
||||
{"Path": "C%3a", "IsBrowsable": True},
|
||||
{"Path": "Volume2-FAT32", "IsBrowsable": False},
|
||||
],
|
||||
{"C:/Users/justin": [{"Path": "C%3a%2fUsers%2fjustin%2fapp.conf", "Type": "File"}]},
|
||||
)
|
||||
assert _resolve(client, r"C:\Users\justin\app.conf") == "C%3a%2fUsers%2fjustin%2fapp.conf"
|
||||
# the bug this guards: probing C:/C:/Users/justin
|
||||
assert not any(p.count("C:") > 1 for p in client.seen)
|
||||
|
||||
|
||||
def test_resolve_flr_path_windows_handles_spaces_encoded_as_plus():
|
||||
client = _BrowseFake(
|
||||
[{"Path": "C%3a", "IsBrowsable": True}],
|
||||
{
|
||||
"C:/Program Files/app": [
|
||||
{"Path": "C%3a%2fProgram+Files%2fapp%2fmy+config.ini", "Type": "File"}
|
||||
]
|
||||
},
|
||||
)
|
||||
got = _resolve(client, r"C:\Program Files\app\my config.ini")
|
||||
assert got == "C%3a%2fProgram+Files%2fapp%2fmy+config.ini"
|
||||
|
||||
|
||||
def test_wait_partitions_stable_ignores_the_early_mislabelled_list():
|
||||
import asyncio
|
||||
|
||||
from zerto_rewind_mcp import recover
|
||||
|
||||
recover.PARTITION_POLL_INTERVAL_S = 0
|
||||
# first poll is the pre-enumeration list, then it settles on the real one
|
||||
polls = [
|
||||
[{"Path": "Volume4-Unknown", "IsBrowsable": False}],
|
||||
[{"Path": "C%3a", "IsBrowsable": True}],
|
||||
[{"Path": "C%3a", "IsBrowsable": True}],
|
||||
]
|
||||
|
||||
class Settling:
|
||||
async def browse_flr(self, session_id, path="", recursive=False):
|
||||
return {"PathItems": polls.pop(0) if polls else [{"Path": "C%3a", "IsBrowsable": True}]}
|
||||
|
||||
got = asyncio.run(recover.wait_partitions_stable(Settling(), "sess"))
|
||||
assert got == ["C%3a"]
|
||||
|
||||
|
||||
def test_session_rows_and_id_shapes():
|
||||
from zerto_rewind_mcp.recover import session_id_of, session_rows
|
||||
|
||||
@@ -159,3 +216,43 @@ def test_teardown_leaves_pre_existing_sessions_alone():
|
||||
out = asyncio.run(_teardown_flr(client, None, {"someone-else"}))
|
||||
assert out["ended"] == []
|
||||
assert client.ended == []
|
||||
|
||||
|
||||
class _SiteFake:
|
||||
def __init__(self, protected, recovery):
|
||||
self._vpg = {
|
||||
"VpgName": "demo-vpg",
|
||||
"ProtectedSite": {"identifier": protected},
|
||||
"RecoverySite": {"identifier": recovery},
|
||||
}
|
||||
|
||||
async def get_vpg(self, vpg_identifier):
|
||||
return self._vpg
|
||||
|
||||
async def get_localsite(self):
|
||||
return {"SiteIdentifier": "site-local", "SiteName": "VMware Site"}
|
||||
|
||||
async def get_peersites(self):
|
||||
return [{"SiteIdentifier": "site-aws", "PeerSiteName": "aws-zca"}]
|
||||
|
||||
|
||||
def test_flr_gate_allows_local_replication():
|
||||
import asyncio
|
||||
|
||||
from zerto_rewind_mcp.server import _flr_site_gate
|
||||
|
||||
out = asyncio.run(_flr_site_gate(_SiteFake("site-local", "site-local"), "v1"))
|
||||
assert out["ok"] is True
|
||||
|
||||
|
||||
def test_flr_gate_refuses_remote_recovery_site_and_names_it():
|
||||
import asyncio
|
||||
|
||||
from zerto_rewind_mcp.server import _flr_site_gate
|
||||
|
||||
out = asyncio.run(_flr_site_gate(_SiteFake("site-local", "site-aws"), "v1"))
|
||||
assert out["ok"] is False
|
||||
assert out["not_local_replication"] is True
|
||||
# must tell the operator where the operation actually lives
|
||||
assert out["recovery_site"] == "aws-zca"
|
||||
assert "aws-zca" in out["message"]
|
||||
|
||||
Reference in New Issue
Block a user