feat(poc): rewind MCP, skill, and recover-ladder docs

Initial PoC: find_protection, tagged checkpoints, FLR, mutating catalog.
Lab 10.9 status enums (0=Initializing, 1=MeetingSLA). Credentials stay in gitignored config.json.
This commit is contained in:
2026-09-21 12:09:11 -04:00
commit 38ba9c1b50
24 changed files with 1811 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
"""Tagged checkpoint insert and wait-until-listed."""
from __future__ import annotations
import asyncio
import re
from datetime import UTC, datetime
from typing import Any
from zerto_rewind_mcp.client import ZertoClient, ZertoError
from zerto_rewind_mcp.protection import FindResult
from zerto_rewind_mcp.util import pick
_SAFE = re.compile(r"[^A-Za-z0-9._:-]+")
def make_tag(agent: str, change_id: str, when: datetime | None = None) -> str:
stamp = (when or datetime.now(UTC)).strftime("%Y%m%dT%H%M%SZ")
agent_s = _SAFE.sub("-", agent.strip())[:40] or "agent"
change_s = _SAFE.sub("-", change_id.strip())[:80] or "change"
return f"ai:{agent_s}:{change_s}:{stamp}"
def checkpoint_tag(row: dict[str, Any]) -> str:
value = pick(row, "Tag", "tag", "CheckpointName", "checkpointName") or ""
return str(value)
def checkpoint_id(row: dict[str, Any]) -> str:
value = pick(
row, "CheckpointId", "checkpointId", "checkpointIdentifier", "CheckpointIdentifier"
)
return str(value) if value is not None else ""
async def wait_for_tag(
client: ZertoClient,
vpg_identifier: str,
tag: str,
*,
timeout_s: float = 45.0,
interval_s: float = 1.5,
) -> dict[str, Any]:
deadline = asyncio.get_event_loop().time() + timeout_s
last: list[dict[str, Any]] = []
while asyncio.get_event_loop().time() < deadline:
last = await client.list_checkpoints(vpg_identifier)
for row in last:
if checkpoint_tag(row) == tag:
return row
await asyncio.sleep(interval_s)
raise ZertoError(
f"Tagged checkpoint {tag!r} did not appear on VPG {vpg_identifier} "
f"within {timeout_s:.0f}s. Do not mutate. "
"If the protected site is Azure or AWS, tagged checkpoints are not supported."
)
async def tag_vpgs(
client: ZertoClient,
result: FindResult,
tag: str,
) -> dict[str, Any]:
if result.outcome != "ok" or result.vm is None:
return {
"ok": False,
"tag": tag,
"message": result.message,
"find": result.as_dict(),
}
tagged: list[dict[str, Any]] = []
skipped = [v.as_dict() for v in result.vm.vpgs if not v.can_tag]
errors: list[str] = []
for vpg in result.taggable_vpgs:
try:
insert = await client.insert_checkpoint(vpg.vpg_identifier, tag)
row = await wait_for_tag(client, vpg.vpg_identifier, tag)
tagged.append(
{
"vpg_identifier": vpg.vpg_identifier,
"vpg_name": vpg.vpg_name,
"checkpoint_id": checkpoint_id(row),
"tag": checkpoint_tag(row) or tag,
"insert_result": insert if not isinstance(insert, dict) else "ok",
}
)
except ZertoError as exc:
errors.append(f"{vpg.vpg_name} ({vpg.vpg_identifier}): {exc}")
ok = bool(tagged) and not errors
message = (
f"Tagged {len(tagged)} VPG(s) with {tag!r}."
if ok
else f"Checkpoint failed on {len(errors)} VPG(s). Refuse the change. " + "; ".join(errors)
)
if skipped and ok:
message += " Some VPGs were skipped (syncing or not Protecting)."
return {
"ok": ok,
"tag": tag,
"vm": result.vm.as_dict(),
"tagged": tagged,
"skipped": skipped,
"errors": errors,
"message": message,
}