---
phase: 02-core-vm-lifecycle
plan: 02
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
  - vmcluster_mcp/cluster/domain_builder.py
  - vmcluster_mcp/cluster/network_builder.py
  - vmcluster_mcp/cluster/cloud_init.py
  - vmcluster_mcp/cluster/__init__.py
  - vmcluster_mcp/server.py
  - tests/test_builders.py
  - tests/test_cluster_define.py
autonomous: true
requirements:
  - CLUS-01
  - CLUS-07

must_haves:
  truths:
    - "cluster_define creates libvirt domain definitions for all topology nodes"
    - "cluster_define creates cluster-namespaced overlay qcow2 disks via qemu-img"
    - "cluster_define creates a libvirt network from topology NetworkSpec"
    - "cluster_define generates per-node cloud-init NoCloud ISOs with static IP config"
    - "cluster_define is idempotent — second call on defined cluster returns success without modification"
    - "Domain XML is built programmatically with xml.etree.ElementTree, not templates"
  artifacts:
    - path: "vmcluster_mcp/cluster/domain_builder.py"
      provides: "Programmatic domain XML generation via ElementTree"
      min_lines: 60
    - path: "vmcluster_mcp/cluster/network_builder.py"
      provides: "Network XML generation via ElementTree"
      min_lines: 25
    - path: "vmcluster_mcp/cluster/cloud_init.py"
      provides: "NoCloud ISO generation with meta-data and network-config"
      min_lines: 50
    - path: "vmcluster_mcp/cluster/__init__.py"
      provides: "cluster_define tool registration"
      contains: "cluster_define"
    - path: "tests/test_builders.py"
      provides: "XML builder unit tests"
      min_lines: 60
    - path: "tests/test_cluster_define.py"
      provides: "cluster_define integration tests with mocked libvirt"
      min_lines: 40
  key_links:
    - from: "vmcluster_mcp/cluster/domain_builder.py"
      to: "vmcluster_mcp/topology/schema.py"
      via: "NodeSpec, NetworkSpec input types"
      pattern: "NodeSpec|NetworkSpec"
    - from: "vmcluster_mcp/cluster/cloud_init.py"
      to: "subprocess"
      via: "genisoimage/mkisofs call"
      pattern: "subprocess\\.run"
    - from: "vmcluster_mcp/cluster/__init__.py"
      to: "vmcluster_mcp/cluster/libvirt_client.py"
      via: "LibvirtClient for domain/network define"
      pattern: "libvirt_client"
    - from: "vmcluster_mcp/cluster/__init__.py"
      to: "vmcluster_mcp/topology/parser.py"
      via: "load_topology to get TopologySpec"
      pattern: "load_topology"
---

<objective>
Create XML builders, cloud-init ISO generator, and the cluster_define tool — the first user-facing tool in Phase 2.

Purpose: cluster_define is the entry point for cluster provisioning. Given a topology name, it loads the YAML, creates overlay disks, generates cloud-init ISOs, defines a libvirt network and domain for each node. It must be idempotent (CLUS-07).

Output: domain_builder.py, network_builder.py, cloud_init.py, cluster_define tool registered on FastMCP, plus 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/02-core-vm-lifecycle/02-CONTEXT.md
@.planning/phases/02-core-vm-lifecycle/02-RESEARCH.md
@.planning/phases/02-core-vm-lifecycle/02-01-SUMMARY.md

<interfaces>
<!-- From Phase 1 and Plan 01 -->

From vmcluster_mcp/topology/schema.py:
```python
class ExtraDisk(BaseModel):
    path: str; size_gb: int; bus: str = "virtio"

class NodeSpec(BaseModel):
    name: str; role: str; vcpus: int; memory_mb: int; ip: str
    extra_disks: list[ExtraDisk] = []

class NetworkSpec(BaseModel):
    name: str; bridge: str; subnet: str; dhcp: bool = False; rdma: bool = False

class SshSpec(BaseModel):
    key_path: str; user: str = "root"; connect_timeout_s: int = 30

class TopologySpec(BaseModel):
    cluster_name: str; base_image: str; overlay_dir: str
    network: NetworkSpec; nodes: list[NodeSpec]; ssh: SshSpec
```

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

From vmcluster_mcp/cluster/libvirt_client.py (Plan 01 output):
```python
def domain_name(cluster_name: str, node_name: str) -> str: ...
def generate_mac(cluster_name: str, node_name: str) -> str: ...

class LibvirtClient:
    def __init__(self, uri: str): ...
    async def define_domain(self, xml: str) -> libvirt.virDomain: ...
    async def lookup_domain(self, name: str) -> libvirt.virDomain | None: ...
    async def define_network(self, xml: str) -> libvirt.virNetwork: ...
    async def lookup_network(self, name: str) -> libvirt.virNetwork | None: ...
    async def start_network(self, net) -> None: ...
    async def is_network_active(self, net) -> bool: ...
```

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 ClusterStatus(BaseModel):
    cluster_name: str; network_active: bool; nodes: list[NodeStatus]
class NodeStatus(BaseModel):
    name: str; role: str; ip: str
    domain_state: Literal["running", "shut off", "paused", "crashed", "unknown"]
    ssh_reachable: bool; artifact_id_installed: str | None = None
    loaded_modules: list[str] = []
class ToolResult(BaseModel, Generic[T]):
    success: bool; result: T | None = None; error: ErrorDetail | None = None
class ErrorDetail(BaseModel):
    code: str; message: str; node: str | None = None; recoverable: bool = False
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create domain_builder.py and network_builder.py — XML generation with ElementTree</name>
  <files>vmcluster_mcp/cluster/domain_builder.py, vmcluster_mcp/cluster/network_builder.py, tests/test_builders.py</files>
  <read_first>
    - vmcluster_mcp/topology/schema.py (NodeSpec, NetworkSpec, ExtraDisk — the input types)
    - vmcluster_mcp/cluster/libvirt_client.py (domain_name, generate_mac utilities)
    - .planning/phases/02-core-vm-lifecycle/02-CONTEXT.md (D-06: ElementTree, D-07: qemu-img, D-10: overlay paths)
    - .planning/phases/02-core-vm-lifecycle/02-RESEARCH.md (§2: domain XML structure, §3: network XML structure)
  </read_first>
  <behavior>
    - Test: build_domain_xml returns valid XML string containing domain type='kvm'
    - Test: build_domain_xml sets name to domain_name(cluster, node)
    - Test: build_domain_xml sets memory from NodeSpec.memory_mb (in MiB units)
    - Test: build_domain_xml sets vcpu count from NodeSpec.vcpus
    - Test: build_domain_xml includes overlay disk as virtio vda
    - Test: build_domain_xml includes cloud-init ISO as sata cdrom readonly
    - Test: build_domain_xml includes bridge interface with correct bridge name and MAC
    - Test: build_domain_xml includes extra_disks from NodeSpec starting at vdb
    - Test: build_domain_xml includes serial console device
    - Test: build_network_xml returns valid XML with network name from NetworkSpec
    - Test: build_network_xml sets bridge name from NetworkSpec.bridge
    - Test: build_network_xml sets forward mode='nat'
    - Test: build_network_xml sets gateway IP (first host in subnet) with correct netmask
    - Test: build_network_xml does NOT include dhcp block (per D-03)
  </behavior>
  <action>
    **domain_builder.py:**

    Create `vmcluster_mcp/cluster/domain_builder.py` with `from __future__ import annotations`, using `xml.etree.ElementTree as ET` (per D-06, no Jinja2):

    `def build_domain_xml(cluster_name: str, node: NodeSpec, overlay_path: str, cidata_iso_path: str, bridge_name: str, mac_address: str) -> str:`

    Build the XML tree programmatically:
    - Root: `<domain type='kvm'>`
    - `<name>` = `domain_name(cluster_name, node.name)` (import from libvirt_client)
    - `<memory unit='MiB'>` = `str(node.memory_mb)`
    - `<vcpu>` = `str(node.vcpus)`
    - `<os>`: `<type arch='x86_64'>hvm</type>`, `<boot dev='hd'/>`
    - `<features>`: `<acpi/>`
    - `<devices>`:
      - `<emulator>/usr/bin/qemu-system-x86_64</emulator>`
      - Primary disk: `<disk type='file' device='disk'>` with `<driver name='qemu' type='qcow2'/>`, `<source file='{overlay_path}'/>`, `<target dev='vda' bus='virtio'/>`
      - Cloud-init ISO: `<disk type='file' device='cdrom'>` with `<driver name='qemu' type='raw'/>`, `<source file='{cidata_iso_path}'/>`, `<target dev='sda' bus='sata'/>`, `<readonly/>`
      - For each `node.extra_disks` (index i): `<disk type='file' device='disk'>` with target dev='vd{chr(98+i)}' bus=disk.bus
      - Network: `<interface type='bridge'>` with `<source bridge='{bridge_name}'/>`, `<mac address='{mac_address}'/>`, `<model type='virtio'/>`
      - Serial: `<serial type='pty'><target port='0'/></serial>`
      - Console: `<console type='pty'><target type='serial' port='0'/></console>`
    - Return `ET.tostring(root, encoding='unicode')`

    **network_builder.py:**

    Create `vmcluster_mcp/cluster/network_builder.py` with `from __future__ import annotations`, using `xml.etree.ElementTree as ET` and `ipaddress` stdlib:

    `def build_network_xml(network: NetworkSpec) -> str:`

    - Parse subnet with `ipaddress.IPv4Network(network.subnet, strict=False)`
    - Gateway IP = first host address: `str(list(net.hosts())[0])` or `str(net.network_address + 1)`
    - Netmask = `str(net.netmask)`
    - Root: `<network>`
    - `<name>` = network.name
    - `<bridge name='{network.bridge}' stp='on' delay='0'/>`
    - `<forward mode='nat'/>`
    - `<ip address='{gateway}' netmask='{netmask}'>` — NO dhcp block (per D-03, cloud-init handles IPs)
    - Return `ET.tostring(root, encoding='unicode')`

    Also add: `def get_gateway_ip(subnet: str) -> str:` — extracted for reuse by cloud_init.py.

    **tests/test_builders.py:**
    Test both builders with fixture NodeSpec/NetworkSpec values. Parse returned XML with ET.fromstring() and assert element presence/attributes.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_builders.py -x -v 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/cluster/domain_builder.py contains `def build_domain_xml(`
    - domain_builder.py imports `xml.etree.ElementTree as ET` (not jinja2, not string templates)
    - domain_builder.py contains `domain type='kvm'` or `domain`, `type`, `kvm` in ElementTree calls
    - domain_builder.py includes overlay disk (vda virtio), cidata ISO (sda sata readonly), bridge interface, serial console
    - network_builder.py contains `def build_network_xml(` and `def get_gateway_ip(`
    - network_builder.py contains `forward mode='nat'` or equivalent ET construction
    - network_builder.py does NOT contain `dhcp` (per D-03)
    - network_builder.py uses `ipaddress.IPv4Network` for subnet parsing
    - tests/test_builders.py exits 0
    - ruff check passes on both builder files
  </acceptance_criteria>
  <done>Domain and network XML builders generate correct libvirt XML from topology specs, all tests passing, no DHCP in network XML</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create cloud_init.py and cluster_define tool</name>
  <files>vmcluster_mcp/cluster/cloud_init.py, vmcluster_mcp/cluster/__init__.py, vmcluster_mcp/server.py, tests/test_cloud_init.py, tests/test_cluster_define.py</files>
  <read_first>
    - vmcluster_mcp/cluster/libvirt_client.py (LibvirtClient, domain_name, generate_mac)
    - vmcluster_mcp/cluster/domain_builder.py (build_domain_xml — just created in Task 1)
    - vmcluster_mcp/cluster/network_builder.py (build_network_xml, get_gateway_ip — just created in Task 1)
    - vmcluster_mcp/topology/schema.py (TopologySpec, NodeSpec)
    - vmcluster_mcp/topology/parser.py (load_topology)
    - vmcluster_mcp/server.py (mcp, structured_tool_handler, config)
    - vmcluster_mcp/config.py (Config fields: overlay_dir, topology_dir, libvirt_uri)
    - .planning/phases/02-core-vm-lifecycle/02-CONTEXT.md (D-03: cloud-init, D-05: partial failure, D-07: qemu-img, D-10: overlay paths)
    - .planning/phases/02-core-vm-lifecycle/02-RESEARCH.md (§4: cloud-init NoCloud, §5: overlay creation, §6: idempotency)
  </read_first>
  <behavior>
    - Test: generate_cloud_init_iso creates meta-data with instance-id and local-hostname
    - Test: generate_cloud_init_iso creates network-config v2 with MAC match and static IP
    - Test: generate_cloud_init_iso calls genisoimage subprocess (or mkisofs fallback)
    - Test: generate_cloud_init_iso output file path is {overlay_dir}/{cluster}/{node}-cidata.iso
    - Test: cluster_define with new topology creates network, overlays, ISOs, and domains for all nodes
    - Test: cluster_define on already-defined cluster returns success without re-creating (idempotent)
    - Test: cluster_define creates overlay dir {overlay_dir}/{cluster_name}/ if not exists
    - Test: cluster_define asserts base_image path != overlay path (per D-10)
    - Test: cluster_define partial failure reports which nodes were/weren't created (per D-05)
  </behavior>
  <action>
    **cloud_init.py:**

    Create `vmcluster_mcp/cluster/cloud_init.py` with `from __future__ import annotations`:

    `def generate_cloud_init_iso(cluster_name: str, node_name: str, ip: str, mac_address: str, gateway_ip: str, prefix_len: int, output_dir: str) -> str:`

    1. Create a temporary directory
    2. Write `meta-data` YAML with `instance-id: {cluster_name}-{node_name}` and `local-hostname: {node_name}`
    3. Write `network-config` YAML (v2 format):
       ```yaml
       version: 2
       ethernets:
         id0:
           match:
             macaddress: "{mac_address}"
           addresses:
             - {ip}/{prefix_len}
           routes:
             - to: default
               via: {gateway_ip}
           nameservers:
             addresses:
               - 8.8.8.8
       ```
    4. ISO output path: `os.path.join(output_dir, f"{node_name}-cidata.iso")`
    5. Run `subprocess.run(["genisoimage", "-output", iso_path, "-volid", "cidata", "-joliet", "-rock", "meta-data", "network-config"], cwd=tmpdir, capture_output=True, check=True)`
    6. If genisoimage not found (FileNotFoundError), try `mkisofs` with same args
    7. Clean up tmpdir
    8. Return iso_path
    9. Use `logging.getLogger("vmcluster_mcp.cluster.cloud_init")`

    **cluster/__init__.py — cluster_define tool:**

    ```python
    from __future__ import annotations
    import logging
    import os
    import subprocess
    from vmcluster_mcp.server import mcp, structured_tool_handler, config
    from vmcluster_mcp.models import ToolResult, ErrorDetail, ClusterStatus, NodeStatus
    from vmcluster_mcp.topology.parser import load_topology
    from vmcluster_mcp.cluster.libvirt_client import LibvirtClient, domain_name, generate_mac
    from vmcluster_mcp.cluster.domain_builder import build_domain_xml
    from vmcluster_mcp.cluster.network_builder import build_network_xml, get_gateway_ip
    from vmcluster_mcp.cluster.cloud_init import generate_cloud_init_iso
    import ipaddress

    logger = logging.getLogger("vmcluster_mcp.cluster")
    _client = LibvirtClient(config.libvirt_uri)
    ```

    `@mcp.tool()` + `@structured_tool_handler`
    `async def cluster_define(topology_name: str) -> ToolResult[ClusterStatus]:`

    Steps:
    1. `topo = load_topology(topology_name, config.topology_dir)`
    2. Check idempotency (CLUS-07): lookup first domain via `_client.lookup_domain(domain_name(topo.cluster_name, topo.nodes[0].name))`. If found, build current ClusterStatus and return `ToolResult(success=True, result=status)`.
    3. Create overlay directory: `os.makedirs(os.path.join(config.overlay_dir, topo.cluster_name), exist_ok=True)`
    4. Define network: `net_xml = build_network_xml(topo.network)`, `net = await _client.define_network(net_xml)`, `await _client.start_network(net)`
    5. Compute gateway and prefix: `net_obj = ipaddress.IPv4Network(topo.network.subnet, strict=False)`, `gateway = get_gateway_ip(topo.network.subnet)`, `prefix_len = net_obj.prefixlen`
    6. For each node in `topo.nodes`:
       a. `mac = generate_mac(topo.cluster_name, node.name)`
       b. `overlay_path = os.path.join(config.overlay_dir, topo.cluster_name, f"{node.name}.qcow2")`
       c. `assert os.path.abspath(topo.base_image) != os.path.abspath(overlay_path)` — safety per D-10
       d. Create overlay: `subprocess.run(["qemu-img", "create", "-b", topo.base_image, "-F", "qcow2", "-f", "qcow2", overlay_path], capture_output=True, check=True)`
       e. Create cloud-init ISO: `cidata_path = generate_cloud_init_iso(topo.cluster_name, node.name, node.ip, mac, gateway, prefix_len, os.path.join(config.overlay_dir, topo.cluster_name))`
       f. Build domain XML: `dom_xml = build_domain_xml(topo.cluster_name, node, overlay_path, cidata_path, topo.network.bridge, mac)`
       g. Define domain: `await _client.define_domain(dom_xml)`
    7. Build `ClusterStatus` with all nodes as `domain_state="shut off"`, `ssh_reachable=False`
    8. Return `ToolResult(success=True, result=status)`
    9. On exception per node (D-05): catch, log, continue to remaining nodes, collect errors, return `ToolResult(success=False, error=ErrorDetail(code="PARTIAL_DEFINE", message=...))` with partial status

    **server.py modification:**
    Add `from vmcluster_mcp import cluster  # noqa: F401 — triggers tool registration` after the `mcp` definition, before `if __name__`.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_cloud_init.py tests/test_cluster_define.py -x -v 2>&1 | tail -40</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/cluster/cloud_init.py contains `def generate_cloud_init_iso(`
    - cloud_init.py writes `meta-data` with `instance-id` and `network-config` with `version: 2`
    - cloud_init.py calls `subprocess.run` with `genisoimage` or `mkisofs`
    - vmcluster_mcp/cluster/__init__.py contains `async def cluster_define(`
    - cluster/__init__.py contains `@mcp.tool()` and `@structured_tool_handler`
    - cluster/__init__.py contains `_client = LibvirtClient(`
    - cluster/__init__.py contains `load_topology(` call
    - cluster/__init__.py contains `qemu-img` subprocess call for overlay creation
    - cluster/__init__.py contains idempotency check (lookupByName before define)
    - cluster/__init__.py contains `assert` for base_image != overlay_path safety
    - server.py contains `from vmcluster_mcp import cluster` import
    - All tests pass: `pytest tests/test_cloud_init.py tests/test_cluster_define.py` exits 0
    - ruff check passes on all modified files
  </acceptance_criteria>
  <done>cluster_define tool registered on FastMCP, creates network + overlays + cloud-init ISOs + domains from topology, idempotent, all tests pass</done>
</task>

</tasks>

<verification>
```bash
# All plan tests pass
python -m pytest tests/test_builders.py tests/test_cloud_init.py tests/test_cluster_define.py -x -v

# Ruff clean
ruff check vmcluster_mcp/cluster/

# Tool is registered
python -c "from vmcluster_mcp.cluster import cluster_define; print('cluster_define registered')"

# No jinja2 dependency
grep -r "jinja2\|Jinja2" vmcluster_mcp/cluster/ && echo "FAIL: jinja2 found" || echo "PASS: no jinja2"

# ElementTree used for XML
grep -l "ElementTree" vmcluster_mcp/cluster/domain_builder.py vmcluster_mcp/cluster/network_builder.py
```
</verification>

<success_criteria>
- domain_builder.py generates correct KVM domain XML with overlay disk, cloud-init ISO, bridge interface, serial console
- network_builder.py generates NAT network XML with no DHCP block
- cloud_init.py generates NoCloud ISOs with MAC-matched static IP network-config
- cluster_define tool creates all resources from topology, is idempotent (CLUS-07)
- Partial failure semantics per D-05 — reports what was and wasn't created
- All tests pass, ruff clean
</success_criteria>

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