---
phase: 07-network-node-fault-injection
plan: 02
type: execute
wave: 2
depends_on: ["07-01"]
files_modified:
  - vmcluster_mcp/net/fault.py
  - vmcluster_mcp/net/__init__.py
  - vmcluster_mcp/server.py
  - tests/test_net_partition.py
autonomous: true
requirements:
  - FAULT-01

must_haves:
  truths:
    - "net_partition installs symmetric iptables DROP rules between two node groups on the libvirt bridge"
    - "Every iptables rule carries the comment tag vmcluster:<cluster>:<handle_id> (D-07)"
    - "Partition rules use topology IP addresses as source/destination match — not MAC or port"
    - "Nodes within the same partition group remain connected (only cross-partition traffic is dropped)"
    - "net_partition returns a NetFaultHandle and the handle is persisted in the FaultRegistry"
    - "Calling net_partition with the same groups a second time creates a new handle (not idempotent)"
  artifacts:
    - path: "vmcluster_mcp/net/fault.py"
      provides: "iptables rule builder, apply, and cleanup functions for partition faults"
      min_lines: 60
    - path: "vmcluster_mcp/net/__init__.py"
      provides: "net_partition tool registration with FaultRegistry and topology wiring"
      exports: ["net_partition"]
    - path: "vmcluster_mcp/server.py"
      provides: "import vmcluster_mcp.net to trigger tool registration"
      contains: "net"
    - path: "tests/test_net_partition.py"
      provides: "Unit tests for iptables rule generation, application, and tool behavior"
      min_lines: 50
  key_links:
    - from: "vmcluster_mcp/net/__init__.py"
      to: "vmcluster_mcp/net/fault_registry.py"
      via: "FaultRegistry instance for persisting partition handles"
      pattern: "FaultRegistry\\("
    - from: "vmcluster_mcp/net/__init__.py"
      to: "vmcluster_mcp/net/fault.py"
      via: "import apply_partition_rules"
      pattern: "from.*fault.*import"
    - from: "vmcluster_mcp/net/__init__.py"
      to: "vmcluster_mcp/server.py"
      via: "mcp.tool() + structured_tool_handler decorators"
      pattern: "@mcp\\.tool\\(\\)"
    - from: "vmcluster_mcp/server.py"
      to: "vmcluster_mcp/net"
      via: "import triggers tool registration"
      pattern: "import.*net"
    - from: "vmcluster_mcp/net/fault.py"
      to: "vmcluster_mcp/net/fault_registry.py"
      via: "_run_host_cmd for iptables subprocess execution"
      pattern: "_run_host_cmd"
---

<objective>
Implement the `net_partition` MCP tool — iptables-based network partitioning between two node groups on the libvirt bridge, with tagged comment rules for reliable identification and cleanup.

Purpose: Enables agents to simulate network splits between cluster node groups. Every rule is tagged with `vmcluster:<cluster>:<handle_id>` for identification by `net_heal`, `net_heal_all`, and reconciliation. This is the first fault injection tool and establishes the iptables management pattern reused by `net_heal`.

Output: `net/fault.py` with iptables helpers, `net/__init__.py` with `net_partition` tool, `server.py` wiring, 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/07-network-node-fault-injection/07-CONTEXT.md
@.planning/phases/07-network-node-fault-injection/07-01-SUMMARY.md

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

From vmcluster_mcp/models.py:
```python
class NetFaultHandle(BaseModel):
    handle_id: str
    fault_type: Literal["partition", "impairment"]
    affected_nodes: list[str]
    created_at: 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
```

From vmcluster_mcp/net/fault_registry.py (created in 07-01):
```python
class FaultRegistry:
    def __init__(self, registry_path: str) -> None: ...
    def load(self) -> None: ...
    def save(self) -> None: ...
    def register(self, handle_id, cluster_name, fault_type, affected_nodes, parameters, rule_identifiers) -> NetFaultHandle: ...
    def get(self, handle_id: str) -> dict | None: ...
    def remove(self, handle_id: str) -> bool: ...
    def list_faults(self, cluster_name: str) -> list[NetFaultInfo]: ...
    async def reconcile(self) -> int: ...

async def _run_host_cmd(cmd: list[str]) -> tuple[int, str]: ...
```

From vmcluster_mcp/topology/schema.py:
```python
class NodeSpec(BaseModel):
    name: str
    role: str
    ip: str
    # ...

class NetworkSpec(BaseModel):
    name: str
    bridge: str
    subnet: str
```

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

def structured_tool_handler(func): ...

from vmcluster_mcp import (  # noqa: E402, F401
    artifact, cluster, exec, observe, snapshot,
)
```

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: iptables rule builder and host-side apply/remove functions</name>
  <files>vmcluster_mcp/net/fault.py, tests/test_net_partition.py</files>
  <read_first>
    - vmcluster_mcp/net/fault_registry.py (Task output from 07-01 — _run_host_cmd helper)
    - vmcluster_mcp/topology/schema.py (NodeSpec.ip, NetworkSpec.bridge for rule generation)
    - .planning/phases/07-network-node-fault-injection/07-CONTEXT.md (D-06, D-07)
  </read_first>
  <behavior>
    - Test: build_partition_rules generates symmetric iptables DROP rules (A→B and B→A) for each IP pair across partition groups
    - Test: build_partition_rules tags every rule with --comment "vmcluster:{cluster}:{handle_id}" (D-07)
    - Test: build_partition_rules targets the bridge interface (-i {bridge} -o {bridge}) for FORWARD chain
    - Test: 2-node partition_a=[nodeA], partition_b=[nodeB] produces 2 rules (A→B drop, B→A drop)
    - Test: 2+2 partition produces 8 rules (each src in A paired with each dst in B, symmetric)
    - Test: apply_partition_rules calls iptables -I FORWARD for each generated rule via _run_host_cmd
    - Test: apply_partition_rules returns list of rule identifiers for registry storage
    - Test: remove_rules_by_comment runs iptables -D to remove all rules matching the comment tag
    - Test: remove_rules_by_comment handles no matching rules gracefully (returns 0 removed)
  </behavior>
  <action>
1. **Create `vmcluster_mcp/net/fault.py`:**
   - `from __future__ import annotations`
   - `logger = logging.getLogger("vmcluster_mcp.net")`
   - Import `_run_host_cmd` from `vmcluster_mcp.net.fault_registry`

2. **`def build_partition_rules(cluster_name, handle_id, bridge, ips_a, ips_b) -> list[list[str]]`:**
   - For each `(src, dst)` pair where `src ∈ ips_a, dst ∈ ips_b` AND `src ∈ ips_b, dst ∈ ips_a` (symmetric):
     - Build rule: `["iptables", "-I", "FORWARD", "-i", bridge, "-o", bridge, "-s", src, "-d", dst, "-j", "DROP", "-m", "comment", "--comment", f"vmcluster:{cluster_name}:{handle_id}"]`
   - Return list of all rule command lists.

3. **`async def apply_partition_rules(rules: list[list[str]]) -> list[str]`:**
   - For each rule, call `_run_host_cmd(rule)`.
   - If any rule fails (exit_code != 0), log error and raise RuntimeError with the failed command.
   - Return list of comment tags applied (for registry storage as rule_identifiers).

4. **`async def remove_rules_by_comment(comment_tag: str) -> int`:**
   - Run `iptables -L FORWARD -n --line-numbers -v` via `_run_host_cmd`.
   - Parse output to find all rules whose comment matches `comment_tag`.
   - Delete rules in reverse line-number order (to avoid index shifting) via `iptables -D FORWARD {line_num}`.
   - Return count of rules removed.

5. **Create `tests/test_net_partition.py`** — mock `_run_host_cmd`. Test rule generation (count, symmetry, comment tags), apply behavior, and remove behavior.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_net_partition.py -x -v && ruff check vmcluster_mcp/net/fault.py</automated>
  </verify>
  <done>iptables rule builder generates tagged symmetric DROP rules, apply/remove work via host subprocess, all tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Wire net_partition tool to MCP server</name>
  <files>vmcluster_mcp/net/__init__.py, vmcluster_mcp/server.py</files>
  <read_first>
    - vmcluster_mcp/net/__init__.py (currently empty — will host tool registrations)
    - vmcluster_mcp/net/fault.py (Task 1 output — build_partition_rules, apply_partition_rules)
    - vmcluster_mcp/net/fault_registry.py (07-01 output — FaultRegistry class)
    - vmcluster_mcp/server.py (import block at bottom — add net)
    - vmcluster_mcp/observe/__init__.py (reference for tool registration pattern)
    - vmcluster_mcp/topology/parser.py (load_topology for resolving node IPs)
    - .planning/phases/07-network-node-fault-injection/07-CONTEXT.md (D-01, D-06, D-07, D-14)
  </read_first>
  <action>
1. **Populate `vmcluster_mcp/net/__init__.py`:**
   ```python
   from __future__ import annotations
   import uuid
   from datetime import UTC, datetime
   from vmcluster_mcp.server import config, mcp, structured_tool_handler
   from vmcluster_mcp.topology.parser import load_topology
   from vmcluster_mcp.net.fault_registry import FaultRegistry
   from vmcluster_mcp.net.fault import build_partition_rules, apply_partition_rules
   from vmcluster_mcp.models import NetFaultHandle, ToolResult

   _registry = FaultRegistry(config.fault_registry)
   _registry.load()
   ```

2. **Register `net_partition` tool:**
   - `@mcp.tool()` + `@structured_tool_handler` (D-14)
   - `async def net_partition(cluster_name: str, partition_a: list[str], partition_b: list[str]) -> ToolResult[NetFaultHandle]:`
   - Load topology: `topo = load_topology(cluster_name, config.topology_dir)`
   - Resolve IPs: map node names in partition_a/partition_b to NodeSpec.ip from topology. Return error if any node name is unknown.
   - Generate handle_id: `str(uuid.uuid4())`
   - Build rules: `build_partition_rules(cluster_name, handle_id, topo.network.bridge, ips_a, ips_b)`
   - Apply rules: `await apply_partition_rules(rules)`
   - Register in fault registry: `_registry.register(handle_id, cluster_name, "partition", affected_nodes=partition_a + partition_b, parameters={"partition_a": partition_a, "partition_b": partition_b, "bridge": topo.network.bridge}, rule_identifiers=[f"vmcluster:{cluster_name}:{handle_id}"])`
   - Return `ToolResult(success=True, result=handle)`

3. **Update `vmcluster_mcp/server.py`** — add `net` to the import block:
   ```python
   from vmcluster_mcp import (
       artifact, cluster, exec, net, observe, snapshot,
   )
   ```
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_net_partition.py tests/test_fault_registry.py -x -v && ruff check vmcluster_mcp/net/__init__.py vmcluster_mcp/server.py</automated>
  </verify>
  <done>net_partition tool registered on mcp, FaultRegistry persists handle, server.py imports net package, all tests pass.</done>
</task>

</tasks>

<verification>
- `python -m pytest tests/test_net_partition.py tests/test_fault_registry.py -x -v` — all tests pass
- `ruff check vmcluster_mcp/net/` — no lint errors
- net_partition generates correct iptables commands with vmcluster comment tags
- Handle is persisted in FaultRegistry after successful rule application
</verification>

<success_criteria>
- net_partition installs symmetric iptables DROP rules tagged with vmcluster:<cluster>:<handle_id>
- Rules target the topology bridge and use IP-based matching across partition groups
- Handle is persisted in FaultRegistry immediately after rule application
- server.py imports net package to trigger tool registration
- All tests pass, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/07-network-node-fault-injection/07-02-SUMMARY.md`
</output>
