---
phase: 04-snapshot-management
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - vmcluster_mcp/models.py
  - vmcluster_mcp/cluster/libvirt_client.py
  - vmcluster_mcp/snapshot/manager.py
  - vmcluster_mcp/snapshot/__init__.py
  - vmcluster_mcp/server.py
  - tests/test_snapshot_create.py
  - tests/test_snapshot_list.py
autonomous: true
requirements:
  - SNAP-01
  - SNAP-03

must_haves:
  truths:
    - "snapshot_create returns SnapshotInfo with per-node disk paths and sizes for every node"
    - "snapshot_create executes concurrently across nodes via asyncio.gather"
    - "snapshot_list returns all named snapshots with creation timestamp and per-node disk sizes"
    - "Created snapshot appears immediately in snapshot_list output"
    - "All libvirt calls route through LibvirtClient single-worker executor"
  artifacts:
    - path: "vmcluster_mcp/snapshot/manager.py"
      provides: "SnapshotManager with snapshot_create and snapshot_list logic"
      min_lines: 80
    - path: "vmcluster_mcp/snapshot/__init__.py"
      provides: "snapshot_create and snapshot_list tool registrations"
      exports: ["snapshot_create", "snapshot_list"]
    - path: "vmcluster_mcp/cluster/libvirt_client.py"
      provides: "New snapshot-related wrapper methods on LibvirtClient"
      contains: "snapshot_create_xml"
    - path: "tests/test_snapshot_create.py"
      provides: "Tests for snapshot_create tool"
      min_lines: 30
    - path: "tests/test_snapshot_list.py"
      provides: "Tests for snapshot_list tool"
      min_lines: 20
  key_links:
    - from: "vmcluster_mcp/snapshot/__init__.py"
      to: "vmcluster_mcp/server.py"
      via: "mcp.tool() + structured_tool_handler decorators"
      pattern: "@mcp\\.tool.*@structured_tool_handler"
    - from: "vmcluster_mcp/snapshot/manager.py"
      to: "vmcluster_mcp/cluster/libvirt_client.py"
      via: "LibvirtClient instance for all libvirt calls"
      pattern: "_client\\.snapshot_create_xml|_client\\.list_all_snapshots"
    - from: "vmcluster_mcp/server.py"
      to: "vmcluster_mcp/snapshot"
      via: "import to trigger tool registration"
      pattern: "import.*snapshot"
---

<objective>
Create the snapshot management infrastructure: SnapshotManager class, LibvirtClient snapshot wrapper methods, and the `snapshot_create` + `snapshot_list` tools with full test coverage.

Purpose: Establishes the snapshot foundation that `snapshot_revert` and `snapshot_delete` (Plan 04-02) build upon. After this plan, an agent can checkpoint cluster state and query available checkpoints.

Output: `snapshot/manager.py`, `snapshot/__init__.py` with two registered tools, extended `LibvirtClient`, 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

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from 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 ToolResult(BaseModel, Generic[T]):
    success: bool = True
    result: T | None = None
    error: ErrorDetail | None = None

class ErrorDetail(BaseModel):
    code: str
    message: str
    node: str | None = None
    recoverable: bool = True
```

From vmcluster_mcp/server.py:
```python
config = load_config()
mcp = FastMCP("vmcluster-mcp")

def structured_tool_handler(func):
    """Catch-all decorator ensuring every tool returns ToolResult."""
```

From vmcluster_mcp/cluster/libvirt_client.py:
```python
class LibvirtClient:
    def __init__(self, uri: str) -> None: ...
    async def _run(self, func, *args): ...  # ThreadPoolExecutor dispatch
    async def define_domain(self, xml: str) -> libvirt.virDomain: ...
    async def lookup_domain(self, name: str) -> libvirt.virDomain | None: ...
    async def start_domain(self, dom: libvirt.virDomain) -> None: ...
    async def destroy_domain(self, dom: libvirt.virDomain) -> None: ...
    async def get_domain_state(self, dom: libvirt.virDomain) -> str: ...
    async def is_domain_active(self, dom: libvirt.virDomain) -> bool: ...
```

From vmcluster_mcp/cluster/__init__.py:
```python
_client = LibvirtClient(config.libvirt_uri)

async def _wait_for_ssh(ip, port, user, key_path, timeout_s) -> tuple[bool, float]:
    """Poll SSH until the node responds or timeout."""
```

From vmcluster_mcp/topology/parser.py:
```python
def load_topology(name: str, topology_dir: str) -> TopologySpec: ...
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: LibvirtClient snapshot methods + SnapshotManager + snapshot_create</name>
  <files>
    vmcluster_mcp/cluster/libvirt_client.py,
    vmcluster_mcp/snapshot/manager.py,
    vmcluster_mcp/snapshot/__init__.py,
    vmcluster_mcp/server.py,
    tests/test_snapshot_create.py
  </files>
  <behavior>
    - Test: snapshot_create_xml calls dom.snapshotCreateXML with DISK_ONLY flag via executor
    - Test: snapshot_create returns SnapshotInfo with correct snapshot_name, cluster_name, per_node entries
    - Test: snapshot_create creates snapshot directory under {overlay_dir}/{cluster_name}/snapshots/{snapshot_name}/
    - Test: snapshot_create uses asyncio.gather for concurrent per-node execution
    - Test: snapshot_create returns success=False with per-node status when any node fails (D-06 all-or-nothing)
    - Test: snapshot_create on already-existing snapshot name returns error (idempotency guard)
    - Test: snapshot_create routes all libvirt calls through LibvirtClient._run
  </behavior>
  <action>
    1. **Extend LibvirtClient** (`cluster/libvirt_client.py`) — add these async methods, all dispatching through `self._run`:
       - `snapshot_create_xml(dom, xml, flags)` — calls `dom.snapshotCreateXML(xml, flags)`
       - `list_all_snapshots(dom, flags=0)` — calls `dom.listAllSnapshots(flags)`
       - `snapshot_lookup_by_name(dom, name)` — calls `dom.snapshotLookupByName(name, 0)`, returns None on libvirtError
       - `snapshot_get_xml_desc(snap, flags=0)` — calls `snap.getXMLDesc(flags)`
       - `get_domain_xml(dom, flags=0)` — calls `dom.XMLDesc(flags)`

    2. **Create `snapshot/manager.py`** with class `SnapshotManager`:
       - Constructor takes `LibvirtClient` instance
       - `async def create_snapshot(cluster_name, snapshot_name, nodes, overlay_dir)` — per D-01/D-03/D-04:
         a. Create snapshot dir: `{overlay_dir}/{cluster_name}/snapshots/{snapshot_name}/`
         b. For each node (via `asyncio.gather`):
            - Lookup domain via `domain_name(cluster_name, node.name)`
            - Build snapshot XML with `<disk name='vda'><source file='{snap_dir}/{node.name}.qcow2'/></disk>`
            - Call `client.snapshot_create_xml(dom, xml, libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY)`
            - Get overlay file size via `os.path.getsize`
            - Return `SnapshotNodeInfo(node_name, disk_path, size_bytes)`
         c. If any node fails, return `ToolResult(success=False)` with ErrorDetail listing failed nodes (D-06)
         d. Return `ToolResult[SnapshotInfo]` with per_node dict and `created_at` as ISO timestamp
       - Import `domain_name` from `cluster.libvirt_client` for domain name construction

    3. **Create `snapshot/__init__.py`** — register `snapshot_create` tool:
       - `from vmcluster_mcp.server import config, mcp, structured_tool_handler`
       - Create module-level `_client = LibvirtClient(config.libvirt_uri)` and `_manager = SnapshotManager(_client)`
       - `@mcp.tool()` + `@structured_tool_handler` on `async def snapshot_create(cluster_name: str, snapshot_name: str, include_memory: bool = False)`
       - Load topology via `load_topology`, pass nodes + overlay_dir to manager
       - `include_memory` parameter accepted but only disk-only supported in v1 (per D-01/D-02); if True, log warning and proceed disk-only

    4. **Update `server.py`** — add `snapshot` to the import line:
       ```python
       from vmcluster_mcp import cluster, exec, snapshot  # noqa: E402, F401
       ```

    5. **Write tests** (`tests/test_snapshot_create.py`) — mock libvirt via `LibvirtClient`, mock `os.path.getsize`, mock `os.makedirs`. Test all behaviors listed above.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_snapshot_create.py -x -v && ruff check vmcluster_mcp/snapshot/ vmcluster_mcp/cluster/libvirt_client.py</automated>
  </verify>
  <done>
    - snapshot_create tool registered on mcp
    - SnapshotManager.create_snapshot returns SnapshotInfo with per-node data
    - LibvirtClient has 5 new snapshot wrapper methods
    - All tests pass, ruff clean
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: snapshot_list tool with XML parsing</name>
  <files>
    vmcluster_mcp/snapshot/manager.py,
    vmcluster_mcp/snapshot/__init__.py,
    tests/test_snapshot_list.py
  </files>
  <behavior>
    - Test: snapshot_list returns list of SnapshotInfo for all snapshots in a cluster
    - Test: snapshot_list parses creationTime from snapshot XML as ISO timestamp
    - Test: snapshot_list reports per-node disk sizes from overlay files
    - Test: snapshot_list returns empty list when no snapshots exist
    - Test: snapshot_list groups snapshots by name across nodes (all nodes share snapshot name)
  </behavior>
  <action>
    1. **Add `list_snapshots` to SnapshotManager** (`snapshot/manager.py`):
       - For each node in topology, look up domain and call `client.list_all_snapshots(dom)`
       - For each snapshot, call `client.snapshot_get_xml_desc(snap)` and parse XML via `xml.etree.ElementTree`:
         - Extract `<creationTime>` (epoch seconds) → convert to ISO format string
         - Extract `<disk name='vda'><source file='...'/>` → disk_path
         - Get file size via `os.path.getsize(disk_path)` (catch FileNotFoundError → size_bytes=0)
       - Group by snapshot name: build `dict[str, SnapshotInfo]` keying on name, accumulating per_node entries
       - Return `ToolResult[list[SnapshotInfo]]`

    2. **Register `snapshot_list` tool** in `snapshot/__init__.py`:
       - `@mcp.tool()` + `@structured_tool_handler`
       - `async def snapshot_list(cluster_name: str) -> ToolResult`
       - Load topology, pass to manager.list_snapshots

    3. **Write tests** (`tests/test_snapshot_list.py`) — mock libvirt snapshot objects with XML strings containing `<creationTime>` and `<disk>` elements. Test empty list, single snapshot, multiple snapshots, missing files.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_snapshot_list.py tests/test_snapshot_create.py -x -v && ruff check vmcluster_mcp/snapshot/</automated>
  </verify>
  <done>
    - snapshot_list tool registered on mcp and returns list[SnapshotInfo]
    - XML parsing extracts creationTime and disk paths correctly
    - All snapshot tests pass, ruff clean
    - `from vmcluster_mcp import snapshot` in server.py triggers both tool registrations
  </done>
</task>

</tasks>

<verification>
```bash
cd /home/hornc/repos/vmcluster-mcp
python -m pytest tests/test_snapshot_create.py tests/test_snapshot_list.py -x -v
ruff check vmcluster_mcp/snapshot/ vmcluster_mcp/cluster/libvirt_client.py vmcluster_mcp/server.py
python -c "from vmcluster_mcp.snapshot import snapshot_create, snapshot_list; print('Tools registered')"
```
</verification>

<success_criteria>
- snapshot_create tool creates external disk-only snapshots for all nodes concurrently
- snapshot_list tool returns all snapshots with creation time and per-node sizes
- All libvirt calls go through LibvirtClient single-worker executor
- All tools wrapped with @structured_tool_handler
- All tests pass, ruff clean
</success_criteria>

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