---
phase: 04-snapshot-management
plan: 02
type: execute
wave: 2
depends_on:
  - "04-01"
files_modified:
  - vmcluster_mcp/snapshot/manager.py
  - vmcluster_mcp/snapshot/__init__.py
  - vmcluster_mcp/cluster/libvirt_client.py
  - tests/test_snapshot_revert.py
  - tests/test_snapshot_delete.py
autonomous: true
requirements:
  - SNAP-02
  - SNAP-04

must_haves:
  truths:
    - "snapshot_revert destroys all nodes before reverting any disk"
    - "snapshot_revert with restart_after=True boots all nodes and waits for SSH reachability"
    - "snapshot_revert returns success=False with per-node status on any failure (no silent partial revert)"
    - "snapshot_delete removes snapshot from all nodes with per-node confirmation"
    - "snapshot_delete returns error when attempting to delete the currently-active backing layer"
    - "snapshot_delete is best-effort — failure on one node does not abort others"
  artifacts:
    - path: "vmcluster_mcp/snapshot/manager.py"
      provides: "revert_snapshot and delete_snapshot methods on SnapshotManager"
      contains: "revert_snapshot"
    - path: "vmcluster_mcp/snapshot/__init__.py"
      provides: "snapshot_revert and snapshot_delete tool registrations"
      exports: ["snapshot_revert", "snapshot_delete"]
    - path: "vmcluster_mcp/cluster/libvirt_client.py"
      provides: "revert_to_snapshot and delete_snapshot wrapper methods"
      contains: "revert_to_snapshot"
    - path: "tests/test_snapshot_revert.py"
      provides: "Tests for snapshot_revert including destroy-all-first discipline"
      min_lines: 40
    - path: "tests/test_snapshot_delete.py"
      provides: "Tests for snapshot_delete including active-backing guard"
      min_lines: 30
  key_links:
    - from: "vmcluster_mcp/snapshot/manager.py"
      to: "vmcluster_mcp/cluster/__init__.py"
      via: "_wait_for_ssh reuse for SSH wait after revert restart"
      pattern: "_wait_for_ssh"
    - from: "vmcluster_mcp/snapshot/manager.py"
      to: "vmcluster_mcp/cluster/libvirt_client.py"
      via: "LibvirtClient for destroy, revert, start, snapshot delete"
      pattern: "_client\\.destroy_domain|_client\\.revert_to_snapshot|_client\\.start_domain|_client\\.delete_snapshot"
    - from: "vmcluster_mcp/snapshot/__init__.py"
      to: "vmcluster_mcp/server.py"
      via: "mcp.tool() + structured_tool_handler decorators"
      pattern: "@mcp\\.tool.*@structured_tool_handler"
---

<objective>
Implement `snapshot_revert` (destroy-all-before-revert with optional restart + SSH wait) and `snapshot_delete` (best-effort per-node with active-backing guard) tools.

Purpose: Completes the snapshot management phase — agents can now checkpoint, query, restore, and clean up cluster snapshots.

Output: Extended `snapshot/manager.py` with revert and delete methods, two new tool registrations, comprehensive tests.
</objective>

<execution_context>
@~/.copilot/get-shit-done/workflows/execute-plan.md
@~/.copilot/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/04-snapshot-management/04-CONTEXT.md
@.planning/phases/04-snapshot-management/04-RESEARCH.md
@.planning/phases/04-snapshot-management/04-01-SUMMARY.md

<interfaces>
<!-- Key types and contracts from Plan 04-01 output + existing codebase -->

From vmcluster_mcp/models.py:
```python
class SnapshotNodeInfo(BaseModel):
    node_name: str
    disk_path: str
    size_bytes: int

class SnapshotInfo(BaseModel):
    snapshot_name: str
    cluster_name: str
    created_at: str
    per_node: dict[str, SnapshotNodeInfo]

class ClusterStatus(BaseModel):
    cluster_name: str
    network_active: bool
    nodes: list[NodeStatus]

class NodeStatus(BaseModel):
    name: str
    role: str
    ip: str
    domain_state: str
    ssh_reachable: bool
```

From vmcluster_mcp/snapshot/manager.py (created in 04-01):
```python
class SnapshotManager:
    def __init__(self, client: LibvirtClient) -> None: ...
    async def create_snapshot(self, cluster_name, snapshot_name, nodes, overlay_dir) -> ToolResult[SnapshotInfo]: ...
    async def list_snapshots(self, cluster_name, nodes) -> ToolResult[list[SnapshotInfo]]: ...
```

From vmcluster_mcp/cluster/libvirt_client.py (extended in 04-01):
```python
class LibvirtClient:
    async def destroy_domain(self, dom) -> None: ...
    async def start_domain(self, dom) -> None: ...
    async def lookup_domain(self, name) -> virDomain | None: ...
    async def is_domain_active(self, dom) -> bool: ...
    async def snapshot_create_xml(self, dom, xml, flags): ...
    async def list_all_snapshots(self, dom, flags=0): ...
    async def snapshot_lookup_by_name(self, dom, name): ...
    async def snapshot_get_xml_desc(self, snap, flags=0): ...
    async def get_domain_xml(self, dom, flags=0): ...
```

From vmcluster_mcp/cluster/__init__.py:
```python
async def _wait_for_ssh(ip, port, user, key_path, timeout_s) -> tuple[bool, float]:
    """Poll SSH until node responds or timeout. Never raises."""
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: snapshot_revert with destroy-all-before-revert and SSH wait</name>
  <files>
    vmcluster_mcp/cluster/libvirt_client.py,
    vmcluster_mcp/snapshot/manager.py,
    vmcluster_mcp/snapshot/__init__.py,
    tests/test_snapshot_revert.py
  </files>
  <behavior>
    - Test: revert_snapshot destroys ALL active domains before any revert begins
    - Test: revert_snapshot calls revert_to_snapshot for each node after destroy
    - Test: revert_snapshot with restart_after=True starts all domains after revert
    - Test: revert_snapshot with wait_for_ssh=True blocks until all nodes are SSH-reachable
    - Test: revert_snapshot SSH timeout on any node returns success=False (D-07 strict all-or-nothing)
    - Test: revert_snapshot returns per-node status in result
    - Test: revert_snapshot with restart_after=False leaves domains shut off
    - Test: revert_snapshot for nonexistent snapshot returns error
    - Test: all libvirt calls route through LibvirtClient._run
  </behavior>
  <action>
    1. **Add LibvirtClient methods** (`cluster/libvirt_client.py`):
       - `async def revert_to_snapshot(self, dom, snap, flags=0)` — calls `dom.revertToSnapshot(snap, flags)` via `self._run`
       - `async def start_domain_by_name(self, name)` — lookup + create (for restarting after revert when dom handle may be stale)

    2. **Add `revert_snapshot` to SnapshotManager** (`snapshot/manager.py`):
       - Signature: `async def revert_snapshot(self, cluster_name, snapshot_name, nodes, overlay_dir, topo, restart_after=True, wait_for_ssh=True, ssh_timeout_s=120)`
       - Implementation per D-07/D-11:
         a. **Phase 1 — Destroy all active domains** (via `asyncio.gather`):
            - For each node: lookup domain, if active call `client.destroy_domain(dom)`
            - Log each destruction
         b. **Phase 2 — Revert each domain to snapshot** (via `asyncio.gather`):
            - For each node: `client.snapshot_lookup_by_name(dom, snapshot_name)`
            - If snapshot not found: mark node as failed, continue gathering
            - Call `client.revert_to_snapshot(dom, snap, libvirt.VIR_DOMAIN_SNAPSHOT_REVERT_FORCE)`
         c. **Phase 3 — Restart** (if `restart_after=True`, via `asyncio.gather`):
            - For each node: `client.start_domain(dom)`
         d. **Phase 4 — SSH wait** (if `restart_after=True` and `wait_for_ssh=True`):
            - Import `_wait_for_ssh` from `vmcluster_mcp.cluster`
            - `asyncio.gather` all SSH waits using topology SSH settings
            - If any node fails SSH: return `ToolResult(success=False)` with SSH_TIMEOUT code (D-07)
         e. Return `ToolResult[ClusterStatus]` with per-node domain state and SSH reachability

    3. **Register `snapshot_revert` tool** in `snapshot/__init__.py`:
       - `@mcp.tool()` + `@structured_tool_handler`
       - `async def snapshot_revert(cluster_name: str, snapshot_name: str, restart_after: bool = True, wait_for_ssh: bool = True, ssh_timeout_s: int = 120)`
       - Load topology, delegate to `_manager.revert_snapshot`

    4. **Write tests** (`tests/test_snapshot_revert.py`) — mock LibvirtClient + _wait_for_ssh. Key assertions:
       - Verify destroy is called on ALL nodes BEFORE any revert call (use mock call_args_list ordering)
       - Verify SSH wait fires only when restart_after=True and wait_for_ssh=True
       - Verify failure propagation per D-07
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_snapshot_revert.py -x -v && ruff check vmcluster_mcp/snapshot/ vmcluster_mcp/cluster/libvirt_client.py</automated>
  </verify>
  <done>
    - snapshot_revert tool registered on mcp
    - Destroy-all-before-revert discipline enforced (Phase 1 completes before Phase 2)
    - SSH wait reuses _wait_for_ssh from cluster module
    - All-or-nothing error semantics per D-07
    - All tests pass, ruff clean
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: snapshot_delete with best-effort per-node and active-backing guard</name>
  <files>
    vmcluster_mcp/snapshot/manager.py,
    vmcluster_mcp/snapshot/__init__.py,
    tests/test_snapshot_delete.py
  </files>
  <behavior>
    - Test: delete_snapshot removes snapshot from each node via client.delete_snapshot
    - Test: delete_snapshot returns per-node confirmation (node_name → success/error)
    - Test: delete_snapshot continues on individual node failure (D-08 best-effort)
    - Test: delete_snapshot returns error when snapshot is the currently-active backing layer (D-05)
    - Test: delete_snapshot for nonexistent snapshot returns error
    - Test: delete_snapshot removes overlay files from disk
  </behavior>
  <action>
    1. **Add LibvirtClient method** (`cluster/libvirt_client.py`) if not already present:
       - `async def delete_snapshot(self, snap, flags=0)` — calls `snap.delete(flags)` via `self._run`

    2. **Add `delete_snapshot` to SnapshotManager** (`snapshot/manager.py`):
       - Signature: `async def delete_snapshot(self, cluster_name, snapshot_name, nodes)`
       - Implementation per D-05/D-08:
         a. For each node (via `asyncio.gather` with `return_exceptions=True`):
            - Lookup domain, then `client.snapshot_lookup_by_name(dom, snapshot_name)`
            - If not found: record error for this node, continue
            - **Active-backing guard (D-05):** Get `client.get_domain_xml(dom)` and check if the snapshot's overlay file is the currently-active `<source file='...'/>` for vda. If so: record error "Cannot delete active backing layer", skip deletion
            - Call `client.delete_snapshot(snap, 0)` to remove metadata
            - Attempt `os.remove(overlay_path)` for the snapshot overlay file; `FileNotFoundError` is not an error
         b. Build result dict: `{node_name: {"deleted": bool, "error": str | None}}`
         c. Overall `success = all(node["deleted"] for node in results)` but individual failures don't abort (D-08)
         d. Return `ToolResult` with per-node results

    3. **Define `SnapshotDeleteResult` model** in `models.py` if needed, or return a simple dict wrapped in ToolResult. Prefer a model:
       ```python
       class SnapshotDeleteNodeResult(BaseModel):
           node_name: str
           deleted: bool
           error: str | None = None

       class SnapshotDeleteResult(BaseModel):
           snapshot_name: str
           cluster_name: str
           per_node: dict[str, SnapshotDeleteNodeResult]
       ```

    4. **Register `snapshot_delete` tool** in `snapshot/__init__.py`:
       - `@mcp.tool()` + `@structured_tool_handler`
       - `async def snapshot_delete(cluster_name: str, snapshot_name: str)`
       - Load topology, delegate to `_manager.delete_snapshot`

    5. **Write tests** (`tests/test_snapshot_delete.py`) — mock LibvirtClient, mock dom.XMLDesc to return XML with specific active disk path. Test active-backing guard, successful deletion, partial failures, nonexistent snapshot.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_snapshot_delete.py tests/test_snapshot_revert.py -x -v && ruff check vmcluster_mcp/snapshot/ vmcluster_mcp/models.py</automated>
  </verify>
  <done>
    - snapshot_delete tool registered on mcp
    - Best-effort per-node deletion — individual failures don't abort
    - Active-backing-layer guard prevents deleting in-use snapshot overlay
    - SnapshotDeleteResult model with per-node status
    - All snapshot tests pass, ruff clean
  </done>
</task>

</tasks>

<verification>
```bash
cd /home/hornc/repos/vmcluster-mcp
# Run all snapshot tests
python -m pytest tests/test_snapshot_create.py tests/test_snapshot_list.py tests/test_snapshot_revert.py tests/test_snapshot_delete.py -x -v
# Run full test suite to check no regressions
python -m pytest -x
# Lint all modified files
ruff check vmcluster_mcp/snapshot/ vmcluster_mcp/cluster/libvirt_client.py vmcluster_mcp/models.py vmcluster_mcp/server.py
# Verify all 4 tools registered
python -c "from vmcluster_mcp.snapshot import snapshot_create, snapshot_list, snapshot_revert, snapshot_delete; print('All 4 snapshot tools registered')"
```
</verification>

<success_criteria>
- snapshot_revert enforces destroy-all-before-revert discipline
- snapshot_revert with restart_after=True waits for SSH reachability
- snapshot_revert returns success=False on any node failure (no silent partial revert)
- snapshot_delete is best-effort per node with full reporting
- snapshot_delete guards against deleting active backing layer
- All 4 snapshot tools registered and wrapped with @structured_tool_handler
- Full test suite passes, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/04-snapshot-management/04-02-SUMMARY.md`
</output>
