feat(flr): make FLR session lifecycle visible and reapable
zerto_recover_file already tore its session down in a finally block, but
three gaps meant a mount could stay up on the recovery site with nothing
tracking it. FLR cannot run during clone, test, live failover or EJC, so
a stuck session blocks the next recovery.
1. An unmount failure was swallowed (`except ZertoError: pass`). The
caller got ok=true and never learned the mount was still up. The
teardown result is now reported in the response as `unmount`, with a
`warning` when it fails. ok stays true when the bytes did land -- the
recovery genuinely succeeded -- but the caller is told.
2. If start_flr succeeded on the ZVM while its response failed to parse,
session_id stayed None and the finally block did nothing, leaking a
session the process never knew the id of. Teardown now snapshots live
session ids before starting and reaps anything new that appeared,
leaving other operators' sessions alone.
3. Nothing could see or clear an orphan left by a crashed process, since
the finally block only runs if the process survives. Two new tools:
- zerto_list_flr_sessions: every session the ZVM knows about.
live_only (default true) keeps the ones still holding a mount;
ended and failed sessions linger as history and hold nothing.
- zerto_end_flr_session: unmount one. Gated on confirmed=true,
matching the other destructive tools, because ending a session
someone else is pulling files from will interrupt them.
Verified against ZVM 10.x: listing reports 0 live / 1 known after a clean
run, the confirm gate refuses without a human yes, a real recovery from
checkpoint 1368 returned 158 bytes and reported
unmount.ok=true with the session id it ended, and 0 live sessions
remained afterwards.
pytest 27 passed (5 new, including fakes covering the swallowed-failure
and orphan-reap paths).
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_016yVfC5nvZowoLFnEGWhLGn
This commit is contained in:
@@ -77,3 +77,85 @@ def test_resolve_flr_path_picks_browsable_partition():
|
||||
assert got == "Volume2-Ext4/home/justin/app-config.yaml"
|
||||
# must not try the unrestorable partition
|
||||
assert "Volume1-Unknown/home/justin" not in client.seen
|
||||
|
||||
|
||||
def test_session_rows_and_id_shapes():
|
||||
from zerto_rewind_mcp.recover import session_id_of, session_rows
|
||||
|
||||
assert session_rows([{"a": 1}]) == [{"a": 1}]
|
||||
assert session_rows({"a": 1}) == [{"a": 1}]
|
||||
assert session_rows(None) == []
|
||||
assert session_id_of({"FlrSessionIdentifier": "s1"}) == "s1"
|
||||
assert session_id_of({"sessionId": "s2"}) == "s2"
|
||||
assert session_id_of({}) == ""
|
||||
|
||||
|
||||
def test_is_live_session():
|
||||
from zerto_rewind_mcp.recover import is_live_session
|
||||
|
||||
assert is_live_session({"FlrSessionStatus": "MountCompletedSuccessfully"})
|
||||
assert is_live_session({"FlrSessionStatus": "MountInProgress"})
|
||||
# unmounted/ended/failed sessions linger as history and hold nothing
|
||||
assert not is_live_session({"FlrSessionStatus": "UnmountCompletedSuccessfully"})
|
||||
assert not is_live_session({"FlrSessionStatus": "MountFailed"})
|
||||
assert not is_live_session({})
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
"""Stands in for ZertoClient in teardown tests."""
|
||||
|
||||
def __init__(self, sessions=None, fail_end=False):
|
||||
self._sessions = sessions or []
|
||||
self.fail_end = fail_end
|
||||
self.ended: list[str] = []
|
||||
|
||||
async def list_flrs(self):
|
||||
return self._sessions
|
||||
|
||||
async def end_flr(self, session_id):
|
||||
from zerto_rewind_mcp.client import ZertoError
|
||||
|
||||
if self.fail_end:
|
||||
raise ZertoError("unmount refused")
|
||||
self.ended.append(session_id)
|
||||
|
||||
|
||||
def test_teardown_reports_failure_instead_of_swallowing():
|
||||
import asyncio
|
||||
|
||||
from zerto_rewind_mcp.server import _teardown_flr
|
||||
|
||||
client = _FakeClient(fail_end=True)
|
||||
out = asyncio.run(_teardown_flr(client, "sess-1", set()))
|
||||
assert out["ok"] is False
|
||||
assert out["failed"][0]["session_id"] == "sess-1"
|
||||
assert "still be mounted" in out["message"]
|
||||
|
||||
|
||||
def test_teardown_reaps_orphan_when_session_id_never_parsed():
|
||||
import asyncio
|
||||
|
||||
from zerto_rewind_mcp.server import _teardown_flr
|
||||
|
||||
# start_flr succeeded on the ZVM but the response did not parse, so the
|
||||
# caller never learned the id. The new live session must still be reaped.
|
||||
live = [{"FlrSessionIdentifier": "new-1", "FlrSessionStatus": "MountCompletedSuccessfully"}]
|
||||
client = _FakeClient(sessions=live)
|
||||
out = asyncio.run(_teardown_flr(client, None, set()))
|
||||
assert out["ended"] == ["new-1"]
|
||||
assert out["orphans_reaped"] == ["new-1"]
|
||||
assert client.ended == ["new-1"]
|
||||
|
||||
|
||||
def test_teardown_leaves_pre_existing_sessions_alone():
|
||||
import asyncio
|
||||
|
||||
from zerto_rewind_mcp.server import _teardown_flr
|
||||
|
||||
live = [
|
||||
{"FlrSessionIdentifier": "someone-else", "FlrSessionStatus": "MountCompletedSuccessfully"}
|
||||
]
|
||||
client = _FakeClient(sessions=live)
|
||||
out = asyncio.run(_teardown_flr(client, None, {"someone-else"}))
|
||||
assert out["ended"] == []
|
||||
assert client.ended == []
|
||||
|
||||
Reference in New Issue
Block a user