---
phase: 01-foundation
plan: 03
type: execute
wave: 3
depends_on: [01-02]
files_modified:
  - vmcluster_mcp/config.py
  - vmcluster_mcp/topology/schema.py
  - vmcluster_mcp/topology/parser.py
  - tests/test_config.py
  - tests/test_topology.py
autonomous: true
requirements:
  - FOUND-03
  - FOUND-04

must_haves:
  truths:
    - "Config loads from /etc/vmcluster/config.yaml when the file exists"
    - "Config loads from VMCLUSTER_* environment variables as fallback"
    - "Env vars override values loaded from the config file"
    - "Valid YAML topology parses into a typed TopologySpec without error"
    - "Malformed YAML topology returns an error identifying the invalid field"
    - "yaml.safe_load is used — never yaml.load"
  artifacts:
    - path: "vmcluster_mcp/config.py"
      provides: "Dual-source config loading (YAML file + env vars)"
      exports: ["Config", "load_config"]
      min_lines: 40
    - path: "vmcluster_mcp/topology/schema.py"
      provides: "Pydantic models for topology YAML structure"
      exports: ["TopologySpec", "NodeSpec", "NetworkSpec", "SshSpec", "SnapshotDefaults", "ExtraDisk"]
      min_lines: 40
    - path: "vmcluster_mcp/topology/parser.py"
      provides: "YAML parser with Pydantic validation and clear error reporting"
      exports: ["load_topology"]
      min_lines: 20
  key_links:
    - from: "vmcluster_mcp/topology/parser.py"
      to: "vmcluster_mcp/topology/schema.py"
      via: "imports TopologySpec for validation target"
      pattern: "from .schema import"
    - from: "vmcluster_mcp/topology/parser.py"
      to: "yaml"
      via: "yaml.safe_load for YAML parsing (NEVER yaml.load)"
      pattern: "yaml\\.safe_load"
    - from: "vmcluster_mcp/config.py"
      to: "yaml"
      via: "yaml.safe_load for config file loading"
      pattern: "yaml\\.safe_load"
---

<objective>
Implement server configuration loading from YAML file and environment variables, and the topology YAML parser with full Pydantic validation and clear schema error reporting.

Purpose: Config loading (FOUND-03) and topology parsing (FOUND-04) complete the foundation that all subsequent phases depend on — every cluster operation starts by loading config and parsing a topology file. These are the last two requirements for Phase 1.
Output: config.py with dual-source loading, topology/schema.py with Pydantic models, topology/parser.py with validated YAML parsing, and tests for both.
</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/research/STACK.md
@.planning/research/ARCHITECTURE.md
@.planning/research/PITFALLS.md
@docs/vmcluster-mcp-design.md

<interfaces>
<!-- From Plan 02 — models.py exists with all shared types -->
From vmcluster_mcp/models.py:
```python
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
```

From vmcluster_mcp/server.py:
```python
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("vmcluster-mcp")
def structured_tool_handler(func): ...
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create config.py with dual-source configuration loading (YAML file + env vars)</name>
  <files>vmcluster_mcp/config.py, tests/test_config.py</files>
  <read_first>
    - docs/vmcluster-mcp-design.md (section 2.6 for config schema and field defaults, section 2.7 for VMCLUSTER_CONFIG env var)
    - .planning/research/STACK.md (PyYAML — yaml.safe_load only, never yaml.load)
    - .planning/research/ARCHITECTURE.md (config.py is a leaf node with no internal imports)
  </read_first>
  <behavior>
    - load_config() with no file and no env vars returns Config with all defaults
    - load_config() with VMCLUSTER_TOPOLOGY_DIR="/custom/path" returns Config with topology_dir="/custom/path"
    - load_config() with a valid YAML file returns Config with values from file
    - load_config() with both file and env var, env var wins (override)
    - load_config() with VMCLUSTER_CONFIG pointing to a file reads that file
    - Config.topology_dir defaults to "/etc/vmcluster/topologies"
    - Config.libvirt_uri defaults to "qemu:///system"
    - Config.ssh_user defaults to "root"
    - Config.log_level defaults to "INFO"
  </behavior>
  <action>
Create `vmcluster_mcp/config.py` as a leaf module with zero internal imports (per architecture research). Define a Pydantic `Config` model and a `load_config()` function.

**Config model fields (from design doc section 2.6):**

```python
from pydantic import BaseModel

class Config(BaseModel):
    topology_dir: str = "/etc/vmcluster/topologies"
    overlay_dir: str = "/var/lib/vmcluster/overlays"
    artifact_registry: str = "/var/lib/vmcluster/artifacts/registry.json"
    artifact_store_dir: str = "/var/lib/vmcluster/artifacts/trees"
    ssh_key_path: str = "/etc/vmcluster/ssh/vmcluster_id_ed25519"
    ssh_user: str = "root"
    libvirt_uri: str = "qemu:///system"
    log_level: str = "INFO"
```

**load_config() function logic:**

1. Determine config file path: use `os.environ.get("VMCLUSTER_CONFIG", "/etc/vmcluster/config.yaml")`
2. If file exists (`pathlib.Path(path).is_file()`), read it with `yaml.safe_load()` — result is a dict or None
3. Build a merged dict: start with file values (if loaded), then overlay any `VMCLUSTER_*` env vars that are set
4. Env var mapping: `VMCLUSTER_TOPOLOGY_DIR` → `topology_dir`, `VMCLUSTER_OVERLAY_DIR` → `overlay_dir`, `VMCLUSTER_ARTIFACT_REGISTRY` → `artifact_registry`, `VMCLUSTER_ARTIFACT_STORE_DIR` → `artifact_store_dir`, `VMCLUSTER_SSH_KEY_PATH` → `ssh_key_path`, `VMCLUSTER_SSH_USER` → `ssh_user`, `VMCLUSTER_LIBVIRT_URI` → `libvirt_uri`, `VMCLUSTER_LOG_LEVEL` → `log_level`
5. Construct `Config(**merged_dict)` — Pydantic handles validation and defaults for missing fields
6. Log which config source was used (file path or "environment variables only") at INFO level

```python
import logging
import os
from pathlib import Path
import yaml

logger = logging.getLogger("vmcluster_mcp.config")

_ENV_MAP = {
    "VMCLUSTER_TOPOLOGY_DIR": "topology_dir",
    "VMCLUSTER_OVERLAY_DIR": "overlay_dir",
    "VMCLUSTER_ARTIFACT_REGISTRY": "artifact_registry",
    "VMCLUSTER_ARTIFACT_STORE_DIR": "artifact_store_dir",
    "VMCLUSTER_SSH_KEY_PATH": "ssh_key_path",
    "VMCLUSTER_SSH_USER": "ssh_user",
    "VMCLUSTER_LIBVIRT_URI": "libvirt_uri",
    "VMCLUSTER_LOG_LEVEL": "log_level",
}

def load_config() -> Config:
    config_path = os.environ.get("VMCLUSTER_CONFIG", "/etc/vmcluster/config.yaml")
    values: dict = {}

    if Path(config_path).is_file():
        with open(config_path) as f:
            file_values = yaml.safe_load(f)
        if isinstance(file_values, dict):
            values.update(file_values)
        logger.info("Config loaded from %s", config_path)
    else:
        logger.info("Config file not found at %s, using defaults + env vars", config_path)

    for env_key, field_name in _ENV_MAP.items():
        env_val = os.environ.get(env_key)
        if env_val is not None:
            values[field_name] = env_val

    return Config(**values)
```

Create `tests/test_config.py` with tests:

- Test load_config() with no file and no env vars returns defaults (topology_dir="/etc/vmcluster/topologies", libvirt_uri="qemu:///system", ssh_user="root", log_level="INFO")
- Test load_config() with VMCLUSTER_TOPOLOGY_DIR env var overrides default
- Test load_config() with a temp YAML config file loads values from file
- Test env var overrides file value (set both file and env var for same field, assert env wins)
- Test VMCLUSTER_CONFIG env var to specify custom config file path
- Test Config model rejects invalid types (if applicable — Pydantic coercion handles most cases)

Use `tmp_path` pytest fixture for temp config files and `monkeypatch.setenv` / `monkeypatch.delenv` for env vars. Do NOT mutate actual `/etc/vmcluster/`.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && uv run pytest tests/test_config.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/config.py contains `class Config(BaseModel):`
    - vmcluster_mcp/config.py contains `def load_config() -> Config:`
    - vmcluster_mcp/config.py contains `yaml.safe_load(` (safe_load, NOT yaml.load)
    - vmcluster_mcp/config.py does NOT contain `yaml.load(` without safe_ prefix
    - vmcluster_mcp/config.py contains `topology_dir: str = "/etc/vmcluster/topologies"`
    - vmcluster_mcp/config.py contains `libvirt_uri: str = "qemu:///system"`
    - vmcluster_mcp/config.py contains `VMCLUSTER_CONFIG` for config file path env var
    - vmcluster_mcp/config.py contains `VMCLUSTER_TOPOLOGY_DIR` in the env var mapping
    - Config has no imports from vmcluster_mcp (leaf module — zero internal deps)
    - tests/test_config.py exits 0 with all tests passing
  </acceptance_criteria>
  <done>Config loads from YAML file at default or VMCLUSTER_CONFIG path, overlays VMCLUSTER_* env vars (env wins), and falls back to sensible defaults matching design doc section 2.6. Tests verify all three sources and override priority. Zero internal dependencies — config.py is a leaf node.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Create topology schema and parser with Pydantic validation and clear error reporting</name>
  <files>vmcluster_mcp/topology/schema.py, vmcluster_mcp/topology/parser.py, tests/test_topology.py</files>
  <read_first>
    - docs/vmcluster-mcp-design.md (section 2.2 for complete topology YAML schema with all fields and types)
    - vmcluster_mcp/models.py (ToolResult and ErrorDetail — could be used for error wrapping, but parser returns TopologySpec or raises)
    - .planning/research/STACK.md (PyYAML — yaml.safe_load only, Pydantic v2 validation patterns)
    - .planning/research/PITFALLS.md (Pitfall #7: cluster_define non-idempotency — topology must validate cleanly)
    - .planning/research/ARCHITECTURE.md (topology/ is strictly read-only, produces TopologySpec, consumes nothing from other domain modules)
  </read_first>
  <behavior>
    - TopologySpec validates the full example from design doc section 2.2 (example-3node.yaml)
    - TopologySpec rejects missing cluster_name with a Pydantic ValidationError mentioning "cluster_name"
    - TopologySpec rejects a node with missing "ip" field with error mentioning the field path
    - TopologySpec rejects invalid memory_mb type (string instead of int)
    - NetworkSpec defaults: dhcp=False, rdma=False
    - NodeSpec.extra_disks defaults to empty list
    - SshSpec defaults: user="root", connect_timeout_s=30
    - load_topology(name, topology_dir) loads "{topology_dir}/{name}.yaml" and returns TopologySpec
    - load_topology raises FileNotFoundError for missing topology file
    - load_topology raises ValueError with field path info for invalid YAML content
  </behavior>
  <action>
Create `vmcluster_mcp/topology/schema.py` with Pydantic v2 models matching design doc section 2.2:

```python
from pydantic import BaseModel

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 SnapshotDefaults(BaseModel):
    baseline: str | None = None

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

Create `vmcluster_mcp/topology/parser.py`:

```python
import logging
from pathlib import Path

import yaml
from pydantic import ValidationError

from .schema import TopologySpec

logger = logging.getLogger("vmcluster_mcp.topology")


def load_topology(name: str, topology_dir: str) -> TopologySpec:
    """Load and validate a topology YAML file by name.

    Args:
        name: Topology name (filename without .yaml extension)
        topology_dir: Directory containing topology YAML files

    Returns:
        Validated TopologySpec

    Raises:
        FileNotFoundError: If topology file does not exist
        ValueError: If YAML is malformed or fails schema validation (includes field path)
    """
    topology_path = Path(topology_dir) / f"{name}.yaml"
    if not topology_path.is_file():
        raise FileNotFoundError(f"Topology file not found: {topology_path}")

    with open(topology_path) as f:
        raw = yaml.safe_load(f)

    if not isinstance(raw, dict):
        raise ValueError(f"Topology file must contain a YAML mapping, got {type(raw).__name__}: {topology_path}")

    try:
        spec = TopologySpec(**raw)
    except ValidationError as e:
        # Re-raise as ValueError with field path info for clear error messages
        field_errors = []
        for err in e.errors():
            loc = " -> ".join(str(part) for part in err["loc"])
            field_errors.append(f"  {loc}: {err['msg']}")
        raise ValueError(
            f"Topology validation failed for {topology_path}:\n" + "\n".join(field_errors)
        ) from e

    logger.info("Loaded topology %s from %s (%d nodes)", name, topology_path, len(spec.nodes))
    return spec
```

Create `tests/test_topology.py` with tests:

**Schema tests:**
- Test TopologySpec validates the full example-3node topology from design doc section 2.2 (all fields populated)
- Test TopologySpec rejects missing cluster_name (ValidationError mentions "cluster_name")
- Test TopologySpec rejects missing nodes field
- Test NodeSpec rejects missing ip field (error mentions field path including node context)
- Test NodeSpec rejects invalid memory_mb type (string "abc" instead of int)
- Test NetworkSpec defaults (dhcp=False, rdma=False)
- Test NodeSpec.extra_disks defaults to empty list
- Test SshSpec defaults (user="root", connect_timeout_s=30)
- Test SnapshotDefaults defaults (baseline=None)

**Parser tests:**
- Test load_topology with a valid YAML file (use tmp_path to write a complete topology YAML, load it, verify TopologySpec fields match)
- Test load_topology raises FileNotFoundError for nonexistent file
- Test load_topology raises ValueError for YAML with missing required field — error message includes the field name
- Test load_topology raises ValueError for YAML with wrong type (e.g., memory_mb: "notanint")
- Test load_topology uses yaml.safe_load (can verify via grep acceptance criteria)

For the valid topology test, use this YAML content matching design doc section 2.2:

```yaml
cluster_name: test-cluster
base_image: /var/lib/vmcluster/images/base.qcow2
overlay_dir: /var/lib/vmcluster/overlays/
network:
  name: testnet
  bridge: virbr-test0
  subnet: 192.168.100.0/24
  dhcp: false
  rdma: false
nodes:
  - name: controller
    role: control
    vcpus: 2
    memory_mb: 2048
    ip: 192.168.100.10
  - name: worker-0
    role: worker
    vcpus: 2
    memory_mb: 2048
    ip: 192.168.100.11
ssh:
  key_path: /etc/vmcluster/ssh/test_key
  user: root
  connect_timeout_s: 30
snapshots:
  baseline: clean-boot
```
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && uv run pytest tests/test_topology.py -x -v</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/topology/schema.py contains `class TopologySpec(BaseModel):`
    - vmcluster_mcp/topology/schema.py contains `class NodeSpec(BaseModel):`
    - vmcluster_mcp/topology/schema.py contains `class NetworkSpec(BaseModel):`
    - vmcluster_mcp/topology/schema.py contains `class SshSpec(BaseModel):`
    - vmcluster_mcp/topology/schema.py contains `cluster_name: str`
    - vmcluster_mcp/topology/schema.py contains `base_image: str`
    - vmcluster_mcp/topology/schema.py contains `nodes: list[NodeSpec]`
    - vmcluster_mcp/topology/parser.py contains `def load_topology(name: str, topology_dir: str) -> TopologySpec:`
    - vmcluster_mcp/topology/parser.py contains `yaml.safe_load(` (safe_load, NEVER yaml.load)
    - vmcluster_mcp/topology/parser.py does NOT contain `yaml.load(` without safe_ prefix
    - vmcluster_mcp/topology/parser.py contains `ValidationError` handling that extracts field paths
    - vmcluster_mcp/topology/parser.py contains `raise FileNotFoundError` for missing files
    - vmcluster_mcp/topology/parser.py contains `raise ValueError` for validation failures
    - topology/schema.py has no imports from vmcluster_mcp.models (independent module)
    - tests/test_topology.py exits 0 with all tests passing
  </acceptance_criteria>
  <done>Topology YAML files are parsed via yaml.safe_load and validated through Pydantic models matching design doc section 2.2. Missing/invalid fields produce clear errors with field path information. Valid topologies return typed TopologySpec objects. Tests cover happy path, missing fields, wrong types, missing files, and default values.</done>
</task>

</tasks>

<verification>
- `uv run pytest tests/test_config.py tests/test_topology.py -x -v` — all tests pass
- `grep "yaml.safe_load" vmcluster_mcp/config.py vmcluster_mcp/topology/parser.py` — both files use safe_load
- `grep -rn "yaml\.load(" vmcluster_mcp/ | grep -v safe_load` — returns nothing (no unsafe yaml.load calls)
- `grep "class Config" vmcluster_mcp/config.py` — Config model exists
- `grep "class TopologySpec" vmcluster_mcp/topology/schema.py` — TopologySpec model exists
- `grep "def load_topology" vmcluster_mcp/topology/parser.py` — parser entry point exists
- `grep "def load_config" vmcluster_mcp/config.py` — config loader exists
</verification>

<success_criteria>
Server configuration loads from YAML file (default `/etc/vmcluster/config.yaml` or `VMCLUSTER_CONFIG` env var) with `VMCLUSTER_*` env var overrides — matching design doc section 2.6. Topology YAML files parse and validate through Pydantic models matching design doc section 2.2 — malformed input produces errors identifying the invalid field path. Both modules use `yaml.safe_load` exclusively. Tests prove all loading paths, override priority, validation, and error reporting.
</success_criteria>

<output>
After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md`
</output>
