---
phase: 02-core-vm-lifecycle
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - vmcluster_mcp/cluster/libvirt_client.py
  - tests/test_libvirt_client.py
autonomous: true
requirements:
  - CLUS-01
  - CLUS-02
  - CLUS-03
  - CLUS-04
  - CLUS-05
  - CLUS-06
  - CLUS-07

must_haves:
  truths:
    - "All libvirt calls are dispatched through a single ThreadPoolExecutor(max_workers=1)"
    - "Async wrappers exist for domain define/lookup/create/shutdown/destroy/undefine/state/isActive"
    - "Async wrappers exist for network define/lookup/create/destroy/undefine/isActive/bridgeName"
    - "Domain naming convention {cluster_name}_{node_name} is a utility function"
    - "Deterministic MAC generation from cluster+node name produces 52:54:00:xx:xx:xx addresses"
    - "Connection is lazily opened and reusable across calls"
  artifacts:
    - path: "vmcluster_mcp/cluster/libvirt_client.py"
      provides: "Thread-safe async libvirt operations wrapper"
      min_lines: 120
    - path: "tests/test_libvirt_client.py"
      provides: "Unit tests with mocked libvirt module"
      min_lines: 80
  key_links:
    - from: "vmcluster_mcp/cluster/libvirt_client.py"
      to: "libvirt"
      via: "ThreadPoolExecutor.run_in_executor"
      pattern: "run_in_executor.*_executor"
    - from: "vmcluster_mcp/cluster/libvirt_client.py"
      to: "vmcluster_mcp/config.py"
      via: "Config.libvirt_uri for connection"
      pattern: "libvirt_uri"
---

<objective>
Create the thread-safe libvirt client abstraction layer that all cluster operations depend on.

Purpose: The libvirt C library is not thread-safe — all calls must go through a single ThreadPoolExecutor(max_workers=1). This plan creates that abstraction, plus utility functions for domain naming and MAC generation used by all subsequent plans.

Output: `cluster/libvirt_client.py` with async wrappers for every libvirt operation Phase 2 needs, plus comprehensive unit tests with mocked libvirt.
</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/02-core-vm-lifecycle/02-CONTEXT.md
@.planning/phases/02-core-vm-lifecycle/02-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. -->

From vmcluster_mcp/config.py:
```python
class Config(BaseModel):
    topology_dir: str = "/etc/vmcluster/topologies"
    overlay_dir: str = "/var/lib/vmcluster/overlays"
    ssh_key_path: str = "/etc/vmcluster/ssh/vmcluster_id_ed25519"
    ssh_user: str = "root"
    libvirt_uri: str = "qemu:///system"
    log_level: str = "INFO"
```

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

From vmcluster_mcp/models.py:
```python
class NodeStatus(BaseModel):
    domain_state: Literal["running", "shut off", "paused", "crashed", "unknown"]

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

libvirt domain state constants:
- VIR_DOMAIN_RUNNING = 1, VIR_DOMAIN_PAUSED = 3, VIR_DOMAIN_SHUTOFF = 5, VIR_DOMAIN_CRASHED = 6

libvirt-python key API:
```python
conn = libvirt.open(uri)                # read-write connection
dom = conn.defineXML(xml_string)        # define persistent domain
dom = conn.lookupByName(name)           # lookup existing domain
dom.create()                            # start defined domain
dom.shutdown()                          # graceful ACPI shutdown
dom.destroy()                           # force-kill
dom.undefine()                          # remove definition
dom.isActive()                          # 1 if running
dom.state()                             # [state, reason] tuple
net = conn.networkDefineXML(xml_string) # define network
net = conn.networkLookupByName(name)    # lookup network
net.create()                            # activate network
net.destroy()                           # deactivate network
net.undefine()                          # remove definition
net.isActive()                          # 1 if active
net.bridgeName()                        # bridge device name
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create cluster/libvirt_client.py — thread-safe async libvirt wrapper</name>
  <files>vmcluster_mcp/cluster/libvirt_client.py</files>
  <read_first>
    - vmcluster_mcp/config.py (Config model with libvirt_uri)
    - vmcluster_mcp/server.py (config global, patterns)
    - vmcluster_mcp/models.py (NodeStatus.domain_state literals)
    - .planning/phases/02-core-vm-lifecycle/02-CONTEXT.md (D-08: single ThreadPoolExecutor, D-10: naming)
    - .planning/phases/02-core-vm-lifecycle/02-RESEARCH.md (§1: API surface, §2: domain states)
  </read_first>
  <behavior>
    - Test: LibvirtClient opens connection lazily on first call, not at __init__
    - Test: All public async methods dispatch through ThreadPoolExecutor(max_workers=1)
    - Test: domain_name(cluster, node) returns "{cluster}_{node}"
    - Test: generate_mac("mycluster", "node1") returns a stable 52:54:00:xx:xx:xx MAC
    - Test: generate_mac with same inputs always returns same MAC (deterministic)
    - Test: generate_mac with different inputs returns different MACs
    - Test: map_domain_state(1) returns "running", map_domain_state(5) returns "shut off", map_domain_state(3) returns "paused", map_domain_state(6) returns "crashed", map_domain_state(99) returns "unknown"
    - Test: define_domain calls conn.defineXML with provided XML string
    - Test: lookup_domain calls conn.lookupByName, returns None if libvirtError raised
    - Test: start_domain calls dom.create()
    - Test: shutdown_domain calls dom.shutdown()
    - Test: destroy_domain calls dom.destroy()
    - Test: undefine_domain calls dom.undefine()
    - Test: get_domain_state returns mapped state string from dom.state()[0]
    - Test: is_domain_active returns bool from dom.isActive()
    - Test: define_network calls conn.networkDefineXML
    - Test: lookup_network calls conn.networkLookupByName, returns None if not found
    - Test: start_network calls net.create()
    - Test: destroy_network calls net.destroy()
    - Test: undefine_network calls net.undefine()
    - Test: is_network_active returns bool from net.isActive()
  </behavior>
  <action>
    Create `vmcluster_mcp/cluster/libvirt_client.py` with:

    1. **Module-level constants:**
       - `_executor = ThreadPoolExecutor(max_workers=1)` — per D-08, single-worker for thread safety
       - `_DOMAIN_STATE_MAP = {1: "running", 3: "paused", 5: "shut off", 6: "crashed"}` — maps libvirt VIR_DOMAIN_* ints to NodeStatus.domain_state literals

    2. **Utility functions (sync, no libvirt):**
       - `domain_name(cluster_name: str, node_name: str) -> str` — returns `f"{cluster_name}_{node_name}"`
       - `generate_mac(cluster_name: str, node_name: str) -> str` — SHA-256 hash of `f"{cluster_name}:{node_name}"`, take bytes [0:3], format as `f"52:54:00:{b[0]:02x}:{b[1]:02x}:{b[2]:02x}"` (deterministic, in libvirt-safe unicast range)
       - `map_domain_state(state_int: int) -> str` — lookup in _DOMAIN_STATE_MAP, default "unknown"

    3. **`class LibvirtClient:`**
       - `__init__(self, uri: str)` — stores uri, sets `self._conn = None` (lazy)
       - `_get_conn(self) -> libvirt.virConnect` — opens connection lazily via `libvirt.open(self._uri)`, caches in `self._conn`
       - `async _run(self, func, *args)` — `await asyncio.get_event_loop().run_in_executor(_executor, func, *args)`
       - All domain methods are async, dispatch sync lambda through `_run`:
         - `async def define_domain(self, xml: str) -> libvirt.virDomain`
         - `async def lookup_domain(self, name: str) -> libvirt.virDomain | None` — catches `libvirt.libvirtError`, returns None
         - `async def start_domain(self, dom: libvirt.virDomain) -> None`
         - `async def shutdown_domain(self, dom: libvirt.virDomain) -> None`
         - `async def destroy_domain(self, dom: libvirt.virDomain) -> None`
         - `async def undefine_domain(self, dom: libvirt.virDomain) -> None`
         - `async def get_domain_state(self, dom: libvirt.virDomain) -> str` — returns map_domain_state(dom.state()[0])
         - `async def is_domain_active(self, dom: libvirt.virDomain) -> bool` — returns bool(dom.isActive())
       - All network methods are async:
         - `async def define_network(self, xml: str) -> libvirt.virNetwork`
         - `async def lookup_network(self, name: str) -> libvirt.virNetwork | None` — catches error, returns None
         - `async def start_network(self, net: libvirt.virNetwork) -> None`
         - `async def destroy_network(self, net: libvirt.virNetwork) -> None`
         - `async def undefine_network(self, net: libvirt.virNetwork) -> None`
         - `async def is_network_active(self, net: libvirt.virNetwork) -> bool`
       - `async def close(self)` — closes connection if open

    4. **Logging:** `logger = logging.getLogger("vmcluster_mcp.cluster.libvirt_client")`
    5. **Imports:** `from __future__ import annotations` at top (per project convention)
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_libvirt_client.py -x -v 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/cluster/libvirt_client.py exists and contains `class LibvirtClient`
    - File contains `ThreadPoolExecutor(max_workers=1)` — exactly 1 worker per D-08
    - File contains `def domain_name(` and `def generate_mac(` and `def map_domain_state(`
    - File contains `from __future__ import annotations` as first import
    - File contains `logging.getLogger("vmcluster_mcp.cluster.libvirt_client")`
    - generate_mac returns strings matching pattern `52:54:00:[0-9a-f]{2}:[0-9a-f]{2}:[0-9a-f]{2}`
    - map_domain_state(1) == "running", map_domain_state(5) == "shut off"
    - LibvirtClient.__init__ does NOT call libvirt.open (lazy connection)
    - All public async methods (define_domain, lookup_domain, start_domain, etc.) use run_in_executor
    - lookup_domain and lookup_network return None on libvirt.libvirtError (not raise)
    - All tests pass: `pytest tests/test_libvirt_client.py` exits 0
    - `ruff check vmcluster_mcp/cluster/libvirt_client.py` exits 0
  </acceptance_criteria>
  <done>Thread-safe LibvirtClient class with async wrappers for all domain and network operations, utility functions for naming and MAC generation, all tests pass, ruff clean</done>
</task>

</tasks>

<verification>
```bash
# All tests pass
python -m pytest tests/test_libvirt_client.py -x -v

# Ruff clean
ruff check vmcluster_mcp/cluster/libvirt_client.py

# Thread safety: single worker
grep -c "max_workers=1" vmcluster_mcp/cluster/libvirt_client.py  # Should be 1

# Lazy connection: no libvirt.open in __init__
python -c "
import ast, sys
tree = ast.parse(open('vmcluster_mcp/cluster/libvirt_client.py').read())
for node in ast.walk(tree):
    if isinstance(node, ast.FunctionDef) and node.name == '__init__':
        src = ast.get_source_segment(open('vmcluster_mcp/cluster/libvirt_client.py').read(), node)
        assert 'libvirt.open' not in src, 'libvirt.open found in __init__!'
        print('PASS: lazy connection verified')
        sys.exit(0)
"

# Convention compliance
head -1 vmcluster_mcp/cluster/libvirt_client.py | grep -q "from __future__" && echo "PASS: future annotations"
```
</verification>

<success_criteria>
- LibvirtClient class exists with async wrappers for all 16+ libvirt operations
- ThreadPoolExecutor(max_workers=1) enforces single-threaded libvirt access
- domain_name() and generate_mac() utility functions produce correct, deterministic output
- map_domain_state() maps libvirt int constants to NodeStatus string literals
- All tests pass with mocked libvirt (no real hypervisor needed)
- ruff check passes
</success_criteria>

<output>
After completion, create `.planning/phases/02-core-vm-lifecycle/02-01-SUMMARY.md`
</output>
