---
phase: 05-artifact-pipeline
plan: 03
type: execute
wave: 3
depends_on: ["05-02"]
files_modified:
  - vmcluster_mcp/artifact/installer.py
  - vmcluster_mcp/artifact/__init__.py
  - tests/test_artifact_install.py
autonomous: true
requirements: [ART-03]

must_haves:
  truths:
    - "Agent can install a synced artifact on nodes using modprobe_path mode"
    - "Agent can install using make_install, rpm, or deb modes"
    - "Per-node failures do not abort other nodes (best-effort)"
    - "InstallStatus contains per-node exit_code, installed_module_paths, depmod_output"
  artifacts:
    - path: "vmcluster_mcp/artifact/installer.py"
      provides: "ArtifactInstaller with four install modes"
      min_lines: 60
    - path: "vmcluster_mcp/artifact/__init__.py"
      provides: "artifact_install tool registration"
      contains: "async def artifact_install"
    - path: "tests/test_artifact_install.py"
      provides: "Unit tests for all four install modes and error handling"
      min_lines: 40
  key_links:
    - from: "vmcluster_mcp/artifact/installer.py"
      to: "vmcluster_mcp/exec/ssh.py"
      via: "SSHConnectionManager.execute for remote install commands"
      pattern: "ssh_manager\\.execute"
    - from: "vmcluster_mcp/artifact/__init__.py"
      to: "vmcluster_mcp/artifact/installer.py"
      via: "ArtifactInstaller instance"
      pattern: "ArtifactInstaller\\("
---

<objective>
Implement `artifact_install` — remote artifact installation on cluster nodes with four modes: `modprobe_path` (default), `make_install`, `rpm`, and `deb`.

Purpose: Completes the artifact pipeline: register → sync → install. Agents can now push and activate kernel modules and userspace binaries on cluster nodes.
Output: `artifact/installer.py` with `ArtifactInstaller` class, `artifact_install` MCP tool, and unit 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/05-artifact-pipeline/05-CONTEXT.md
@.planning/phases/05-artifact-pipeline/05-RESEARCH.md
@.planning/phases/05-artifact-pipeline/05-01-SUMMARY.md
@.planning/phases/05-artifact-pipeline/05-02-SUMMARY.md

<interfaces>
<!-- Key types from models.py -->
From vmcluster_mcp/models.py:
```python
class InstallNodeResult(BaseModel):
    node_name: str
    exit_code: int
    installed_module_paths: list[str]
    depmod_output: str

class InstallStatus(BaseModel):
    per_node: dict[str, InstallNodeResult]

class BuildArtifact(BaseModel):
    artifact_id: str
    source_path: str
    kernel_version: str
    modules: list[str]      # relative paths to .ko files
    # ...
```

From vmcluster_mcp/exec/ssh.py:
```python
class SSHConnectionManager:
    async def execute(self, ip, port, user, key_path, command, timeout_s=30) -> ExecResult:
```

From vmcluster_mcp/artifact/__init__.py (from Plans 01-02):
```python
_registry = ArtifactRegistry(config.artifact_registry)
_ssh_manager = SSHConnectionManager()
_syncer = ArtifactSyncer(_ssh_manager)
```

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

class SshSpec(BaseModel):
    key_path: str
    user: str = "root"
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: ArtifactInstaller with four install modes</name>
  <files>vmcluster_mcp/artifact/installer.py, tests/test_artifact_install.py</files>
  <read_first>
    - vmcluster_mcp/exec/ssh.py (SSHConnectionManager.execute)
    - vmcluster_mcp/exec/__init__.py (asyncio.gather fan-out pattern)
    - vmcluster_mcp/models.py (InstallNodeResult, InstallStatus, BuildArtifact)
    - .planning/phases/05-artifact-pipeline/05-CONTEXT.md (D-09, D-10, D-11, D-12, D-13)
    - .planning/phases/05-artifact-pipeline/05-RESEARCH.md (modprobe.d config, depmod, rpm/deb install)
  </read_first>
  <behavior>
    - Test: modprobe_path mode creates /lib/modules/{kernel_version}/extra/vmcluster/ dir, copies .ko files, runs depmod -a
    - Test: modprobe_path mode returns installed_module_paths and depmod_output
    - Test: make_install mode runs "cd {dest_path}/{artifact_id} && make install" via SSH
    - Test: rpm mode runs "rpm -ivh" on .rpm files found via "find {dest} -name '*.rpm'"
    - Test: deb mode runs "dpkg -i" on .deb files found via "find {dest} -name '*.deb'"
    - Test: install_all fans out via asyncio.gather across all target nodes (D-13)
    - Test: per-node failure does not abort other nodes (D-09 best-effort)
    - Test: non-zero exit_code on SSH command returns InstallNodeResult with that exit_code
  </behavior>
  <action>
1. **Create `vmcluster_mcp/artifact/installer.py`:**
   ```python
   from __future__ import annotations
   import asyncio, logging
   from vmcluster_mcp.exec.ssh import SSHConnectionManager
   from vmcluster_mcp.models import BuildArtifact, InstallNodeResult, InstallStatus

   logger = logging.getLogger("vmcluster_mcp.artifact")
   ```

2. **`class ArtifactInstaller`:**
   - `__init__(self, ssh_manager: SSHConnectionManager)`: Store reference.

   - `async def install_node(self, ip, port, user, key_path, artifact, dest_base, install_mode) -> InstallNodeResult`:
     - `dest_path = f"{dest_base}/{artifact.artifact_id}"`
     - Branch on `install_mode`:

     **`modprobe_path` (D-10):**
     Build a multi-line SSH command (single `execute` call):
     ```
     KVER={artifact.kernel_version}
     MODDIR=/lib/modules/$KVER/extra/vmcluster
     mkdir -p $MODDIR
     cp {dest_path}/*.ko $MODDIR/ 2>/dev/null; find {dest_path} -name '*.ko' -exec cp {{}} $MODDIR/ \;
     depmod -a $KVER
     find $MODDIR -name '*.ko' -type f
     ```
     Parse `installed_module_paths` from stdout (one path per line from `find`).
     Parse `depmod_output` from stderr or stdout of the depmod command.
     Return `InstallNodeResult(node_name=..., exit_code=result.exit_code, installed_module_paths=[...], depmod_output=...)`.

     **`make_install` (D-11):**
     ```
     cd {dest_path} && make install 2>&1
     ```
     Return with stdout as depmod_output, empty installed_module_paths.

     **`rpm` (D-12):**
     ```
     PKGS=$(find {dest_path} -name '*.rpm' -type f)
     if [ -n "$PKGS" ]; then rpm -ivh $PKGS 2>&1; else echo "No .rpm files found"; exit 1; fi
     ```

     **`deb` (D-12):**
     ```
     PKGS=$(find {dest_path} -name '*.deb' -type f)
     if [ -n "$PKGS" ]; then dpkg -i $PKGS 2>&1; else echo "No .deb files found"; exit 1; fi
     ```

   - `async def install_all(self, nodes, ssh_spec, artifact, dest_base, install_mode) -> InstallStatus`:
     - Build coroutines for each node (D-13 asyncio.gather).
     - `results = await asyncio.gather(*tasks, return_exceptions=True)` (D-09 best-effort).
     - For exceptions: create `InstallNodeResult(exit_code=-1, installed_module_paths=[], depmod_output=str(exc))`.
     - Return `InstallStatus(per_node={node.name: result for ...})`.

3. **Create `tests/test_artifact_install.py`** — mock `SSHConnectionManager.execute`, verify correct SSH commands for each mode, test asyncio.gather fan-out and error handling.
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/test_artifact_install.py -x -v && ruff check vmcluster_mcp/artifact/installer.py</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/artifact/installer.py contains `class ArtifactInstaller:`
    - vmcluster_mcp/artifact/installer.py contains `modprobe_path` (string literal)
    - vmcluster_mcp/artifact/installer.py contains `make_install` and `make install`
    - vmcluster_mcp/artifact/installer.py contains `rpm -ivh` or `rpm -ivh`
    - vmcluster_mcp/artifact/installer.py contains `dpkg -i`
    - vmcluster_mcp/artifact/installer.py contains `depmod -a`
    - vmcluster_mcp/artifact/installer.py contains `asyncio.gather`
    - vmcluster_mcp/artifact/installer.py contains `from __future__ import annotations`
    - tests/test_artifact_install.py exits 0
    - ruff check exits 0
  </acceptance_criteria>
  <done>ArtifactInstaller class with modprobe_path, make_install, rpm, deb install modes; asyncio.gather fan-out; best-effort per-node; all tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: Wire artifact_install tool to MCP server</name>
  <files>vmcluster_mcp/artifact/__init__.py</files>
  <read_first>
    - vmcluster_mcp/artifact/__init__.py (current state — registry + sync tools already wired)
    - vmcluster_mcp/artifact/installer.py (Task 1 output — ArtifactInstaller API)
    - vmcluster_mcp/exec/__init__.py (reference for topology + node resolution pattern)
    - .planning/phases/05-artifact-pipeline/05-CONTEXT.md (D-09, D-14, D-15)
  </read_first>
  <action>
1. **Add to `vmcluster_mcp/artifact/__init__.py`** — import installer and create instance:
   ```python
   from vmcluster_mcp.artifact.installer import ArtifactInstaller
   _installer = ArtifactInstaller(_ssh_manager)
   ```

2. **Add `artifact_install` tool:**
   ```python
   @mcp.tool()
   @structured_tool_handler
   async def artifact_install(
       cluster_name: str,
       artifact_id: str,
       nodes: list[str] | None = None,
       install_mode: str = "modprobe_path",
       dest_base: str = "/opt/vmcluster/artifacts",
   ) -> ToolResult[InstallStatus]:
   ```
   - Validate `install_mode` is one of `"modprobe_path"`, `"make_install"`, `"rpm"`, `"deb"` — return INVALID_INSTALL_MODE error otherwise
   - Validate artifact exists in `_registry.get(artifact_id)` — return ARTIFACT_NOT_FOUND if None
   - Load topology: `topo = load_topology(cluster_name, config.topology_dir)`
   - Resolve target nodes (all or subset) — same pattern as `artifact_sync`
   - Call `_installer.install_all(target_nodes, topo.ssh, artifact, dest_base, install_mode)`
   - Return `ToolResult(success=True, result=install_status)`

3. **Verify full suite:** `python -m pytest tests/ -x -v`
  </action>
  <verify>
    <automated>cd /home/hornc/repos/vmcluster-mcp && python -m pytest tests/ -x -v && ruff check vmcluster_mcp/artifact/__init__.py</automated>
  </verify>
  <acceptance_criteria>
    - vmcluster_mcp/artifact/__init__.py contains `async def artifact_install(`
    - vmcluster_mcp/artifact/__init__.py contains `ArtifactInstaller(`
    - vmcluster_mcp/artifact/__init__.py contains `install_mode` validation
    - vmcluster_mcp/artifact/__init__.py contains all 5 tool functions: artifact_register, artifact_list, artifact_diff, artifact_sync, artifact_install
    - All tests pass including existing tests (no regressions)
    - ruff check exits 0
  </acceptance_criteria>
  <done>artifact_install MCP tool wired with install_mode validation, artifact lookup, topology resolution; full pipeline complete (register → list → diff → sync → install); all tests pass.</done>
</task>

</tasks>

<verification>
- `python -m pytest tests/ -x` — all tests pass
- `ruff check vmcluster_mcp/artifact/` — no lint errors
- `grep "artifact_install" vmcluster_mcp/artifact/__init__.py` — tool present
- `grep "@mcp.tool" vmcluster_mcp/artifact/__init__.py | wc -l` — returns 5 (register, list, diff, sync, install)
- `grep "modprobe_path\|make_install\|rpm\|deb" vmcluster_mcp/artifact/installer.py` — all four modes
- `grep "asyncio.gather" vmcluster_mcp/artifact/installer.py` — fan-out confirmed
- `python -c "from vmcluster_mcp.server import mcp"` — import clean
</verification>

<success_criteria>
- ArtifactInstaller class with four install modes (modprobe_path, make_install, rpm, deb)
- artifact_install tool validates mode and artifact, resolves nodes, installs concurrently
- Per-node failures don't abort other nodes (best-effort D-09)
- Full artifact pipeline operational: register → list → diff → sync → install
- All tests pass, ruff clean
</success_criteria>

<output>
After completion, create `.planning/phases/05-artifact-pipeline/05-03-SUMMARY.md`
</output>
