---
phase: 05-artifact-pipeline
plan: "02"
subsystem: artifact
tags: [artifact, sync, rsync, dedup, asyncio, tdd, mcp-tools]
dependency_graph:
  requires: [artifact_register, ArtifactRegistry, SSHConnectionManager]
  provides: [artifact_sync, ArtifactSyncer]
  affects: [vmcluster_mcp.artifact.__init__]
tech_stack:
  added: [asyncio.create_subprocess_exec, rsync subprocess, SSH dedup check]
  patterns: [TDD, asyncio.gather fan-out, best-effort per-node reporting]
key_files:
  created:
    - vmcluster_mcp/artifact/syncer.py
    - tests/test_artifact_sync.py
  modified:
    - vmcluster_mcp/artifact/__init__.py
decisions:
  - ArtifactSyncer._ssh is a separate SSHConnectionManager instance from exec module — artifact module manages its own SSH connections for dedup checks per plan spec
  - sync_node uses ip as node_name placeholder; sync_all overrides with actual node.name — keeps sync_node signature clean without requiring node name
  - asyncio.gather with return_exceptions=True for best-effort fan-out — exceptions create SyncNodeResult(rsync_exit_code=-1) without aborting siblings
  - rsync uses --delete flag to ensure clean artifact destination (consistent with D-04)
metrics:
  duration: "~15m"
  completed: "2026-03-23"
  tasks_completed: 2
  files_modified: 3
requirements-completed: [ART-02]
---

# Phase 05 Plan 02: Artifact Sync Summary

rsync-based artifact distribution to cluster nodes with SSH dedup check, asyncio.gather concurrent fan-out, and best-effort per-node failure reporting.

## Tasks Completed

| Task | Description | Commit |
|------|-------------|--------|
| 1 (RED) | Failing tests for ArtifactSyncer (TDD RED) | 4e80186 |
| 1 (GREEN) | ArtifactSyncer with rsync subprocess, dedup check, fan-out | 97fc4e5 |
| 2 | Wire artifact_sync tool to MCP server | 3c8a05f |

## What Was Built

### ArtifactSyncer (`vmcluster_mcp/artifact/syncer.py`)
- `sync_node(ip, port, user, key_path, source_path, dest_path, artifact_id, force=False)`:
  - Dedup check via `ssh_manager.execute("test -d {dest_path}/{artifact_id}", timeout_s=5)` — if exit_code==0 and not force: returns `SyncNodeResult(skipped=True)`
  - rsync via `asyncio.create_subprocess_exec("rsync", "-avz", "--delete", "-e", "ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null", ...)`
  - Parses bytes_transferred from rsync verbose output
  - Returns `SyncNodeResult` with duration_s, rsync_exit_code, skipped
- `sync_all(nodes, ssh_spec, source_path, dest_base, artifact_id, force=False)`:
  - Fans out via `asyncio.gather(*tasks, return_exceptions=True)` (D-05)
  - Exception nodes get `rsync_exit_code=-1` without aborting siblings (D-08)
  - Returns `SyncStatus(per_node=dict[node_name → SyncNodeResult])`

### artifact_sync MCP Tool (`vmcluster_mcp/artifact/__init__.py`)
- Validates artifact exists in registry (ARTIFACT_NOT_FOUND)
- Validates artifact source_path is accessible (SOURCE_NOT_FOUND)
- Loads topology, resolves target nodes (all or named subset)
- NODE_NOT_FOUND error for unknown requested node names
- Calls `_syncer.sync_all(...)` and returns `ToolResult[SyncStatus]`

### Tests (`tests/test_artifact_sync.py`)
- 8 tests covering all specified behaviors:
  - dedup skip when dir exists + not forced
  - rsync runs when dir missing
  - rsync runs on force=True even if dir exists
  - non-zero exit code reported correctly
  - rsync flags verification (-avz, --delete, StrictHostKeyChecking=no, UserKnownHostsFile=/dev/null)
  - sync_all fan-out to all nodes
  - per-node failure does not abort others
  - exception in sync_node reported as rsync_exit_code=-1

## Deviations from Plan

None - plan executed exactly as written.

## Self-Check

### Created files exist:
- [x] `vmcluster_mcp/artifact/syncer.py` ✓
- [x] `tests/test_artifact_sync.py` ✓

### Modified files:
- [x] `vmcluster_mcp/artifact/__init__.py` — contains `artifact_sync`, `ArtifactSyncer(`, `load_topology(`, `_registry.get(artifact_id)` ✓

### Test results:
- [x] 215/215 tests pass ✓
- [x] ruff check vmcluster_mcp/artifact/ — no errors ✓

### Acceptance criteria:
- [x] syncer.py contains `class ArtifactSyncer:` ✓
- [x] syncer.py contains `create_subprocess_exec` ✓
- [x] syncer.py contains `asyncio.gather` ✓
- [x] syncer.py contains `test -d` ✓
- [x] syncer.py contains `StrictHostKeyChecking=no` ✓
- [x] syncer.py contains `from __future__ import annotations` ✓

## Self-Check: PASSED
