fix(recover): return the file content, not a path on this host (#10)
This commit was merged in pull request #10.
This commit is contained in:
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -32,6 +34,20 @@ _catalog: MutatingCatalog | None = None
|
|||||||
_settings: dict[str, Any] = {}
|
_settings: dict[str, Any] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# Files above this are refused rather than streamed through a tool result.
|
||||||
|
# FLR is for a config file or a dropped directory; a disk image belongs on the
|
||||||
|
# whole-VM ladder in docs/recover-ladder.md.
|
||||||
|
MAX_RECOVER_BYTES = 1_048_576
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_recovered(blob: bytes) -> dict[str, Any]:
|
||||||
|
"""Text where it is text, base64 otherwise, so the caller can just use it."""
|
||||||
|
try:
|
||||||
|
return {"encoding": "text", "content": blob.decode("utf-8")}
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return {"encoding": "base64", "content": base64.b64encode(blob).decode("ascii")}
|
||||||
|
|
||||||
|
|
||||||
def _dump(payload: Any) -> str:
|
def _dump(payload: Any) -> str:
|
||||||
return json.dumps(payload, indent=2, default=str)
|
return json.dumps(payload, indent=2, default=str)
|
||||||
|
|
||||||
@@ -447,16 +463,20 @@ async def zerto_recover_file(
|
|||||||
checkpoint_identifier: str,
|
checkpoint_identifier: str,
|
||||||
guest_path: str,
|
guest_path: str,
|
||||||
confirmed: bool = False,
|
confirmed: bool = False,
|
||||||
dest_dir: str | None = None,
|
|
||||||
) -> str:
|
) -> str:
|
||||||
"""File-level recovery from a journal checkpoint. The VM stays up.
|
"""File-level recovery from a journal checkpoint. The VM stays up.
|
||||||
|
|
||||||
|
Returns the file CONTENT, not a path. The caller may be on another machine,
|
||||||
|
so a path on this host is of no use to them, and letting a caller choose
|
||||||
|
where bytes land is an arbitrary write once this server is shared.
|
||||||
|
|
||||||
Requires confirmed=true (human yes). Cannot run during clone/test/live/EJC.
|
Requires confirmed=true (human yes). Cannot run during clone/test/live/EJC.
|
||||||
10.9 FLR Operator role fails; use an Administrator account.
|
10.9 FLR Operator role fails; use an Administrator account.
|
||||||
|
|
||||||
Locally replicated VPGs only. FLR is performed at the VPG's recovery site,
|
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.
|
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.
|
guest_path is the path on the guest: /home/x/f.conf or C:\\Users\\x\\f.txt.
|
||||||
|
Files larger than max_recover_bytes are refused; use the whole-VM ladder.
|
||||||
"""
|
"""
|
||||||
if not confirmed:
|
if not confirmed:
|
||||||
return _dump(
|
return _dump(
|
||||||
@@ -472,7 +492,10 @@ async def zerto_recover_file(
|
|||||||
gate = await _flr_site_gate(client, vpg_identifier)
|
gate = await _flr_site_gate(client, vpg_identifier)
|
||||||
if not gate.get("ok"):
|
if not gate.get("ok"):
|
||||||
return _dump(gate)
|
return _dump(gate)
|
||||||
dest = Path(dest_dir or _settings.get("recovery_dir") or "./recovered")
|
# Server-owned, from config only. This string is also handed to the
|
||||||
|
# appliance as initialDownloadPath, so a caller-supplied value would let a
|
||||||
|
# caller point the ZVM at a path of their choosing.
|
||||||
|
dest = Path(_settings.get("recovery_dir") or "./recovered")
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
session_id: str | None = None
|
session_id: str | None = None
|
||||||
before = await _live_session_ids(client)
|
before = await _live_session_ids(client)
|
||||||
@@ -491,16 +514,30 @@ async def zerto_recover_file(
|
|||||||
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"
|
||||||
out_path = dest / name
|
limit = int(_settings.get("max_recover_bytes") or MAX_RECOVER_BYTES)
|
||||||
out_path.write_bytes(blob)
|
if len(blob) > limit:
|
||||||
payload = {
|
payload = {
|
||||||
"ok": True,
|
"ok": False,
|
||||||
"path": str(out_path.resolve()),
|
"too_large": True,
|
||||||
"bytes": len(blob),
|
"bytes": len(blob),
|
||||||
"session_id": session_id,
|
"limit": limit,
|
||||||
"flr_path": flr_path,
|
"message": (
|
||||||
"message": f"Wrote {len(blob)} bytes to {out_path}",
|
f"{name} is {len(blob)} bytes, over the {limit} byte limit for "
|
||||||
}
|
"file level recovery. Use a bounded whole-VM operation instead: "
|
||||||
|
"offsite clone or failover test."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"ok": True,
|
||||||
|
"name": name,
|
||||||
|
"bytes": len(blob),
|
||||||
|
"sha256": hashlib.sha256(blob).hexdigest(),
|
||||||
|
"session_id": session_id,
|
||||||
|
"flr_path": flr_path,
|
||||||
|
**_encode_recovered(blob),
|
||||||
|
"message": f"Recovered {len(blob)} bytes of {name} from the journal.",
|
||||||
|
}
|
||||||
except ZertoError as exc:
|
except ZertoError as exc:
|
||||||
payload = {"ok": False, "session_id": session_id, "message": str(exc)}
|
payload = {"ok": False, "session_id": session_id, "message": str(exc)}
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -256,3 +256,44 @@ def test_flr_gate_refuses_remote_recovery_site_and_names_it():
|
|||||||
# must tell the operator where the operation actually lives
|
# must tell the operator where the operation actually lives
|
||||||
assert out["recovery_site"] == "aws-zca"
|
assert out["recovery_site"] == "aws-zca"
|
||||||
assert "aws-zca" in out["message"]
|
assert "aws-zca" in out["message"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_recovered_text_and_binary():
|
||||||
|
from zerto_rewind_mcp.server import _encode_recovered
|
||||||
|
|
||||||
|
text = _encode_recovered(b"listen: 0.0.0.0:8443\n")
|
||||||
|
assert text["encoding"] == "text"
|
||||||
|
assert text["content"] == "listen: 0.0.0.0:8443\n"
|
||||||
|
|
||||||
|
binary = _encode_recovered(b"\x89PNG\r\n\x1a\n\xff\xfe")
|
||||||
|
assert binary["encoding"] == "base64"
|
||||||
|
import base64 as b64
|
||||||
|
|
||||||
|
assert b64.b64decode(binary["content"]) == b"\x89PNG\r\n\x1a\n\xff\xfe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_file_takes_no_caller_destination():
|
||||||
|
"""The caller must not choose where bytes land, nor where the ZVM mounts.
|
||||||
|
|
||||||
|
dest_dir used to be a tool parameter whose value was also passed to the
|
||||||
|
appliance as initialDownloadPath.
|
||||||
|
"""
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
from zerto_rewind_mcp.server import zerto_recover_file
|
||||||
|
|
||||||
|
params = set(inspect.signature(zerto_recover_file).parameters)
|
||||||
|
assert "dest_dir" not in params
|
||||||
|
assert params == {
|
||||||
|
"vpg_identifier",
|
||||||
|
"vm_identifier",
|
||||||
|
"checkpoint_identifier",
|
||||||
|
"guest_path",
|
||||||
|
"confirmed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_recover_byte_cap_is_configurable_and_has_a_default():
|
||||||
|
from zerto_rewind_mcp.server import MAX_RECOVER_BYTES
|
||||||
|
|
||||||
|
assert MAX_RECOVER_BYTES == 1_048_576
|
||||||
|
|||||||
Reference in New Issue
Block a user