# Phase 4: Snapshot Management — Research

**Researched:** 2026-03-23
**Level:** 1 (Quick Verification)

## Domain: libvirt External Disk-Only Snapshots

### Snapshot Creation

External disk-only snapshots use `virDomainSnapshotCreateXML` with the `VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY` flag. This creates a new qcow2 overlay on top of the current disk — the VM transparently switches to writing to the new overlay while the previous overlay becomes read-only (the snapshot point).

**Python API:**
```python
snap_xml = """<domainsnapshot>
  <name>{snapshot_name}</name>
  <disks>
    <disk name='vda'>
      <source file='{new_overlay_path}'/>
    </disk>
  </disks>
</domainsnapshot>"""
snap = dom.snapshotCreateXML(snap_xml, libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY)
```

Key points:
- Can run on a **live domain** (no shutdown required) — consistent with D-03
- The `--no-metadata` flag (`VIR_DOMAIN_SNAPSHOT_CREATE_NO_METADATA`) skips libvirt internal tracking; we should NOT use this since we want `listAllSnapshots()` to work
- The `<disk name='vda'>` must match the target device name in the domain XML
- New overlay path specified in `<source file='...'/>` — use D-04 layout: `{overlay_dir}/{cluster_name}/snapshots/{snapshot_name}/{node_name}.qcow2`

### Snapshot Listing

**Python API:**
```python
snapshots = dom.listAllSnapshots(flags=0)
for snap in snapshots:
    xml = snap.getXMLDesc(0)
    # Parse <creationTime> and disk <source file='...'/>
```

- `snap.getName()` returns the snapshot name
- `snap.getXMLDesc(0)` returns full XML with `<creationTime>` (epoch seconds) and disk paths
- File sizes obtained via `os.path.getsize()` on the overlay files

### Snapshot Revert (Destroy + Revert)

For external disk-only snapshots, the recommended approach per D-11:

1. **Destroy all domains** — `virDomainDestroy` (force stop) for each active domain
2. **Revert each domain** — `virDomainRevertToSnapshot(snap, VIR_DOMAIN_SNAPSHOT_REVERT_FORCE)` while domain is inactive
   - The `REVERT_FORCE` flag is needed when the current domain state doesn't match the snapshot state
   - For external disk-only snapshots, this reconfigures the domain's disk to point to the snapshot overlay
3. **Restart** (if `restart_after=True`) — `virDomainCreate` for each domain, then wait for SSH

Fallback if `revertToSnapshot` doesn't work for external disk-only (older libvirt):
- Manually rebase: `qemu-img create -f qcow2 -F qcow2 -b <snapshot_overlay> <new_active_overlay>`
- Update domain XML to point to new overlay
- Redefine domain

**Decision:** Use `revertToSnapshot` with `VIR_DOMAIN_SNAPSHOT_REVERT_FORCE` as primary approach. This is supported in modern libvirt (9.0+) for QEMU external snapshots.

### Snapshot Delete

**Python API:**
```python
snap = dom.snapshotLookupByName(snapshot_name)
snap.delete(0)  # flags=0 for standard delete
```

- `virDomainSnapshotDelete()` now supports external snapshots (modern libvirt)
- Removes both metadata and overlay files
- **Active backing layer guard:** Cannot delete a snapshot whose overlay is the currently-active disk for the domain. Must check `dom.XMLDesc()` to see which overlay is active before allowing delete.

### LibvirtClient Integration

All libvirt calls must go through the single-worker `ThreadPoolExecutor` in `LibvirtClient._run()`. We need new methods on `LibvirtClient`:

- `snapshot_create_xml(dom, xml, flags)` — wrapper for `dom.snapshotCreateXML`
- `list_all_snapshots(dom)` — wrapper for `dom.listAllSnapshots`
- `snapshot_lookup_by_name(dom, name)` — wrapper for `dom.snapshotLookupByName`
- `snapshot_get_xml_desc(snap)` — wrapper for `snap.getXMLDesc`
- `revert_to_snapshot(dom, snap, flags)` — wrapper for `dom.revertToSnapshot`
- `delete_snapshot(snap, flags)` — wrapper for `snap.delete`
- `get_domain_xml(dom)` — wrapper for `dom.XMLDesc` (to check active disk)

### Concurrency Model

Per D-09, parallel per-node operations via `asyncio.gather`. Each node's snapshot operation dispatches to LibvirtClient (serialized through the single-worker executor). This means:
- `snapshot_create`: all nodes run concurrently via gather, but libvirt calls are serialized
- `snapshot_revert`: destroy-all in gather, then revert-all in gather, then start-all in gather
- `snapshot_delete`: all nodes delete concurrently via gather

### Missing Model: SnapshotCreateResult

D-12 notes that `SnapshotStatus` from the design doc is not in `models.py`. Need to define:

```python
class SnapshotCreateResult(BaseModel):
    snapshot_name: str
    cluster_name: str
    created_at: str
    per_node: dict[str, SnapshotNodeInfo]
```

This is essentially `SnapshotInfo` — could reuse it directly, or create a thin alias. Since the fields match, just use `SnapshotInfo` as the snapshot_create return type.

### Validation Architecture

**Verifiable behaviors:**
1. snapshot_create returns SnapshotInfo with correct per-node paths and sizes
2. snapshot_list includes just-created snapshot
3. snapshot_revert destroys all nodes before reverting
4. snapshot_revert with restart_after waits for SSH
5. snapshot_delete removes overlay files from disk
6. Active backing layer delete returns error

---
*Research completed: 2026-03-23*
