Files
hobbs-iac/docs/plans/2026-03-05-farm-manager.md
T
jeremy de1d9e1d93 infra: full IaC state after hf-pdocker-01 rebuild (2026-03-08)
Replaces initial Nomad/Consul/Terraform skeleton with current Docker-based
infrastructure across all three nodes (hf-pdocker-01, hf-pdocker-02, bart).

Key changes:
- CLAUDE.md: updated service distribution reflecting hf-pdocker-01 restored
- docs/plans/2026-03-08-hf-pdocker-01-rebuild.md: new rebuild plan doc
- compose/bart/: ai-agents, guacamole, matrix, media, openvas, smarthome, watchtower
- compose/hf-pdocker-02/: traefik, monitoring, adguard, gitea, semaphore, wikijs,
  unifi, vaultwarden, actual-budget, mcpo, netclaw, trivy, watchtower, n8n
- compose/hf-pdocker-01/: frigate, frigate-notify, farm-agent
- stacks/: all Swarm stack definitions (monitoring, traefik, smarthome, matrix,
  guacamole, media, ai-agents, frigate, gitea, semaphore, wikijs, unifi, etc.)
- .gitignore: exclude .env credential files and acme.json certs
- .env.example: placeholder credential files for all services
- .claude/agents/: gitea-backup and other infrastructure agent configs
- scripts/: frigate media cleanup and NAS sync scripts

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-08 18:51:25 -05:00

1771 lines
51 KiB
Markdown

# Farm Manager Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Build a web dashboard to manage Docker services across 3 homelab nodes with full control and service grouping.
**Architecture:** Node agents on each Docker host expose REST APIs over the local Docker socket. A central API server proxies requests and manages groups. Static HTML/CSS/JS frontend served by the API server.
**Tech Stack:** Python 3.12, FastAPI, docker-py (aiodocker), httpx, pytest, vanilla HTML/CSS/JS
**Repo:** git.hobbs.farm/jeremy/farm-manager (Gitea issues #1-#16 track backlog)
**Development:** Code is developed locally at `/mnt/c/Users/Jeremy/projects/farm-manager/`, pushed to Gitea, then deployed to nodes.
---
### Task 1: Project Scaffold + Shared Models
**Gitea issues:** Prerequisite for all
**Files:**
- Create: `agent/__init__.py`
- Create: `agent/models.py`
- Create: `agent/requirements.txt`
- Create: `server/__init__.py`
- Create: `server/models.py`
- Create: `server/requirements.txt`
- Create: `tests/__init__.py`
- Create: `tests/conftest.py`
- Create: `config.json`
- Create: `groups.json`
- Create: `.gitignore`
**Step 1: Clone repo and create project structure**
```bash
cd /mnt/c/Users/Jeremy/projects
git clone http://192.168.86.100:3003/jeremy/farm-manager.git
cd farm-manager
mkdir -p agent server/routes server/static tests
touch agent/__init__.py server/__init__.py server/routes/__init__.py tests/__init__.py
```
**Step 2: Create shared pydantic models for the agent**
`agent/models.py`:
```python
from pydantic import BaseModel
class ContainerInfo(BaseModel):
id: str
name: str
status: str # running, exited, paused, restarting
image: str
created: str
uptime: str | None = None
is_swarm: bool = False
swarm_service: str | None = None
class ContainerAction(BaseModel):
success: bool
message: str
class LogsResponse(BaseModel):
container: str
logs: str
class HealthResponse(BaseModel):
status: str
hostname: str
containers_total: int
```
**Step 3: Create shared pydantic models for the server**
`server/models.py`:
```python
from pydantic import BaseModel
class NodeConfig(BaseModel):
name: str
host: str
agent_port: int = 8889
class NodeStatus(BaseModel):
name: str
host: str
healthy: bool
containers_total: int = 0
error: str | None = None
class ServiceRef(BaseModel):
node: str
container: str
class Group(BaseModel):
id: str
name: str
services: list[ServiceRef] = []
class GroupsData(BaseModel):
groups: list[Group] = []
class ServiceInfo(BaseModel):
id: str
name: str
node: str
status: str
image: str
created: str
uptime: str | None = None
is_swarm: bool = False
swarm_service: str | None = None
```
**Step 4: Create requirements files**
`agent/requirements.txt`:
```
fastapi>=0.115.0
uvicorn>=0.34.0
docker>=7.0.0
```
`server/requirements.txt`:
```
fastapi>=0.115.0
uvicorn>=0.34.0
httpx>=0.28.0
aiofiles>=24.1.0
```
`tests/requirements.txt`:
```
pytest>=8.0.0
pytest-asyncio>=0.24.0
httpx>=0.28.0
```
**Step 5: Create config files**
`config.json`:
```json
{
"nodes": [
{"name": "hf-pdocker-01", "host": "192.168.86.192", "agent_port": 8889},
{"name": "hf-pdocker-02", "host": "192.168.86.100", "agent_port": 8889},
{"name": "bart", "host": "192.168.86.167", "agent_port": 8889}
]
}
```
`groups.json`:
```json
{
"groups": [
{
"id": "ai-agents",
"name": "AI Agents",
"services": [
{"node": "hf-pdocker-01", "container": "ai-agents_margaret"},
{"node": "hf-pdocker-01", "container": "ai-agents_spirit"},
{"node": "hf-pdocker-01", "container": "ai-agents_bob"},
{"node": "hf-pdocker-01", "container": "ai-agents_clippy"}
]
},
{
"id": "smart-home",
"name": "Smart Home",
"services": [
{"node": "hf-pdocker-01", "container": "smarthome_homeassistant"},
{"node": "hf-pdocker-01", "container": "smarthome_esphome"},
{"node": "hf-pdocker-01", "container": "smarthome_mosquitto"}
]
},
{
"id": "monitoring",
"name": "Monitoring",
"services": [
{"node": "hf-pdocker-01", "container": "monitoring_grafana"},
{"node": "hf-pdocker-01", "container": "monitoring_prometheus"},
{"node": "hf-pdocker-01", "container": "monitoring_alertmanager"},
{"node": "hf-pdocker-01", "container": "monitoring_influxdb"},
{"node": "hf-pdocker-01", "container": "monitoring_blackbox-exporter"}
]
},
{
"id": "media",
"name": "Media",
"services": [
{"node": "bart", "container": "jellyfin"},
{"node": "bart", "container": "sonarr"},
{"node": "bart", "container": "radarr"},
{"node": "bart", "container": "sabnzbd"},
{"node": "bart", "container": "nzbhydra2"},
{"node": "bart", "container": "jellyseerr"}
]
},
{
"id": "matrix",
"name": "Matrix",
"services": [
{"node": "hf-pdocker-01", "container": "matrix_synapse"},
{"node": "hf-pdocker-01", "container": "matrix_synapse-db"},
{"node": "hf-pdocker-01", "container": "matrix_wellknown"}
]
},
{
"id": "guacamole",
"name": "Guacamole",
"services": [
{"node": "hf-pdocker-01", "container": "guacamole_guacamole"},
{"node": "hf-pdocker-01", "container": "guacamole_guacd"},
{"node": "hf-pdocker-01", "container": "guacamole_guacamole-db"}
]
},
{
"id": "dev-tools",
"name": "Dev Tools",
"services": [
{"node": "hf-pdocker-02", "container": "gitea"},
{"node": "hf-pdocker-02", "container": "gitea-db"},
{"node": "hf-pdocker-02", "container": "semaphore"},
{"node": "hf-pdocker-02", "container": "semaphore-db"},
{"node": "hf-pdocker-02", "container": "wikijs"},
{"node": "hf-pdocker-02", "container": "wikijs-db"}
]
},
{
"id": "security",
"name": "Security",
"services": [
{"node": "hf-pdocker-02", "container": "vaultwarden"},
{"node": "hf-pdocker-02", "container": "trivy"},
{"node": "bart", "container": "openvas"}
]
},
{
"id": "network",
"name": "Network",
"services": [
{"node": "hf-pdocker-01", "container": "traefik_traefik"},
{"node": "hf-pdocker-01", "container": "adguard_adguardhome"},
{"node": "hf-pdocker-02", "container": "unifi"},
{"node": "hf-pdocker-02", "container": "unifi-db"}
]
},
{
"id": "frigate",
"name": "Frigate",
"services": [
{"node": "hf-pdocker-01", "container": "frigate"},
{"node": "hf-pdocker-01", "container": "frigate-notify"}
]
}
]
}
```
`.gitignore`:
```
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
venv/
.env
```
**Step 6: Create test conftest**
`tests/conftest.py`:
```python
import pytest
@pytest.fixture
def sample_container():
return {
"id": "abc123",
"name": "test-container",
"status": "running",
"image": "nginx:latest",
"created": "2026-03-05T00:00:00Z",
"uptime": "2 hours",
"is_swarm": False,
"swarm_service": None,
}
@pytest.fixture
def sample_group():
return {
"id": "test-group",
"name": "Test Group",
"services": [
{"node": "hf-pdocker-01", "container": "test-container"},
],
}
```
**Step 7: Commit scaffold**
```bash
git add -A
git commit -m "feat: project scaffold with models, config, and test fixtures"
git push origin main
```
---
### Task 2: Node Agent — Docker Operations (TDD)
**Gitea issues:** #1, #12
**Files:**
- Create: `tests/test_agent_docker_ops.py`
- Create: `agent/docker_ops.py`
**Step 1: Write failing tests for Docker operations**
`tests/test_agent_docker_ops.py`:
```python
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from agent.docker_ops import DockerOps
@pytest.fixture
def mock_docker_client():
client = MagicMock()
client.containers = MagicMock()
return client
@pytest.fixture
def docker_ops(mock_docker_client):
with patch("agent.docker_ops.docker.DockerClient", return_value=mock_docker_client):
ops = DockerOps()
ops.client = mock_docker_client
return ops
class TestListContainers:
def test_list_returns_container_info(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.id = "abc123def456"
mock_container.name = "test-app"
mock_container.status = "running"
mock_container.image.tags = ["nginx:latest"]
mock_container.attrs = {
"Created": "2026-03-05T00:00:00Z",
"State": {"StartedAt": "2026-03-05T01:00:00Z"},
}
mock_container.labels = {}
mock_docker_client.containers.list.return_value = [mock_container]
result = docker_ops.list_containers()
assert len(result) == 1
assert result[0]["name"] == "test-app"
assert result[0]["status"] == "running"
assert result[0]["image"] == "nginx:latest"
assert result[0]["is_swarm"] is False
def test_list_detects_swarm_containers(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.id = "abc123"
mock_container.name = "stack_service.1.taskid"
mock_container.status = "running"
mock_container.image.tags = ["app:latest"]
mock_container.attrs = {
"Created": "2026-03-05T00:00:00Z",
"State": {"StartedAt": "2026-03-05T01:00:00Z"},
}
mock_container.labels = {
"com.docker.swarm.service.name": "stack_service",
}
mock_docker_client.containers.list.return_value = [mock_container]
result = docker_ops.list_containers()
assert result[0]["is_swarm"] is True
assert result[0]["swarm_service"] == "stack_service"
def test_list_includes_stopped_containers(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.id = "abc123"
mock_container.name = "stopped-app"
mock_container.status = "exited"
mock_container.image.tags = ["app:latest"]
mock_container.attrs = {
"Created": "2026-03-05T00:00:00Z",
"State": {"StartedAt": "2026-03-05T01:00:00Z"},
}
mock_container.labels = {}
mock_docker_client.containers.list.return_value = [mock_container]
result = docker_ops.list_containers()
assert result[0]["status"] == "exited"
class TestContainerActions:
def test_start_container(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.name = "test-app"
mock_docker_client.containers.get.return_value = mock_container
result = docker_ops.start_container("abc123")
mock_container.start.assert_called_once()
assert result["success"] is True
def test_stop_container(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.name = "test-app"
mock_docker_client.containers.get.return_value = mock_container
result = docker_ops.stop_container("abc123")
mock_container.stop.assert_called_once()
assert result["success"] is True
def test_restart_container(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.name = "test-app"
mock_docker_client.containers.get.return_value = mock_container
result = docker_ops.restart_container("abc123")
mock_container.restart.assert_called_once()
assert result["success"] is True
def test_start_nonexistent_container(self, docker_ops, mock_docker_client):
import docker.errors
mock_docker_client.containers.get.side_effect = docker.errors.NotFound("not found")
result = docker_ops.start_container("doesntexist")
assert result["success"] is False
assert "not found" in result["message"].lower()
class TestContainerLogs:
def test_get_logs(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.name = "test-app"
mock_container.logs.return_value = b"line1\nline2\nline3"
mock_docker_client.containers.get.return_value = mock_container
result = docker_ops.get_logs("abc123", tail=100)
assert result["container"] == "test-app"
assert "line1" in result["logs"]
class TestPullImage:
def test_pull_image(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.name = "test-app"
mock_container.image.tags = ["nginx:latest"]
mock_docker_client.containers.get.return_value = mock_container
mock_docker_client.images.pull.return_value = MagicMock()
result = docker_ops.pull_image("abc123")
mock_docker_client.images.pull.assert_called_once_with("nginx:latest")
assert result["success"] is True
class TestSwarmActions:
def test_stop_swarm_service_scales_to_zero(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.name = "stack_svc.1.taskid"
mock_container.labels = {"com.docker.swarm.service.name": "stack_svc"}
mock_docker_client.containers.get.return_value = mock_container
mock_service = MagicMock()
mock_docker_client.services.get.return_value = mock_service
result = docker_ops.stop_container("abc123")
mock_service.scale.assert_called_once_with(0)
assert result["success"] is True
def test_start_swarm_service_scales_to_one(self, docker_ops, mock_docker_client):
mock_container = MagicMock()
mock_container.name = "stack_svc.1.taskid"
mock_container.labels = {"com.docker.swarm.service.name": "stack_svc"}
mock_docker_client.containers.get.return_value = mock_container
mock_service = MagicMock()
mock_docker_client.services.get.return_value = mock_service
result = docker_ops.start_container("abc123")
mock_service.scale.assert_called_once_with(1)
assert result["success"] is True
```
**Step 2: Run tests — verify they fail**
```bash
cd /mnt/c/Users/Jeremy/projects/farm-manager
pip install -r agent/requirements.txt -r tests/requirements.txt
pytest tests/test_agent_docker_ops.py -v
```
Expected: FAIL — `ModuleNotFoundError: No module named 'agent.docker_ops'`
**Step 3: Implement DockerOps**
`agent/docker_ops.py`:
```python
import socket
from datetime import datetime, timezone
import docker
import docker.errors
class DockerOps:
def __init__(self):
self.client = docker.DockerClient.from_env()
self.hostname = socket.gethostname()
def _is_swarm(self, container) -> bool:
return "com.docker.swarm.service.name" in container.labels
def _get_swarm_service_name(self, container) -> str | None:
return container.labels.get("com.docker.swarm.service.name")
def _format_uptime(self, started_at: str) -> str:
try:
start = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
delta = datetime.now(timezone.utc) - start
hours, remainder = divmod(int(delta.total_seconds()), 3600)
minutes, _ = divmod(remainder, 60)
if hours >= 24:
days = hours // 24
hours = hours % 24
return f"{days}d {hours}h {minutes}m"
return f"{hours}h {minutes}m"
except Exception:
return None
def list_containers(self) -> list[dict]:
containers = self.client.containers.list(all=True)
result = []
for c in containers:
tags = c.image.tags
image = tags[0] if tags else "unknown"
started_at = c.attrs.get("State", {}).get("StartedAt", "")
is_swarm = self._is_swarm(c)
result.append({
"id": c.id[:12],
"name": c.name,
"status": c.status,
"image": image,
"created": c.attrs.get("Created", ""),
"uptime": self._format_uptime(started_at) if c.status == "running" else None,
"is_swarm": is_swarm,
"swarm_service": self._get_swarm_service_name(c) if is_swarm else None,
})
return result
def start_container(self, container_id: str) -> dict:
try:
container = self.client.containers.get(container_id)
if self._is_swarm(container):
svc_name = self._get_swarm_service_name(container)
service = self.client.services.get(svc_name)
service.scale(1)
return {"success": True, "message": f"Scaled {svc_name} to 1"}
container.start()
return {"success": True, "message": f"Started {container.name}"}
except docker.errors.NotFound:
return {"success": False, "message": "Container not found"}
except Exception as e:
return {"success": False, "message": str(e)}
def stop_container(self, container_id: str) -> dict:
try:
container = self.client.containers.get(container_id)
if self._is_swarm(container):
svc_name = self._get_swarm_service_name(container)
service = self.client.services.get(svc_name)
service.scale(0)
return {"success": True, "message": f"Scaled {svc_name} to 0"}
container.stop()
return {"success": True, "message": f"Stopped {container.name}"}
except docker.errors.NotFound:
return {"success": False, "message": "Container not found"}
except Exception as e:
return {"success": False, "message": str(e)}
def restart_container(self, container_id: str) -> dict:
try:
container = self.client.containers.get(container_id)
if self._is_swarm(container):
svc_name = self._get_swarm_service_name(container)
service = self.client.services.get(svc_name)
service.force_update()
return {"success": True, "message": f"Force-updated {svc_name}"}
container.restart()
return {"success": True, "message": f"Restarted {container.name}"}
except docker.errors.NotFound:
return {"success": False, "message": "Container not found"}
except Exception as e:
return {"success": False, "message": str(e)}
def get_logs(self, container_id: str, tail: int = 100) -> dict:
try:
container = self.client.containers.get(container_id)
logs = container.logs(tail=tail, timestamps=True).decode("utf-8", errors="replace")
return {"container": container.name, "logs": logs}
except docker.errors.NotFound:
return {"container": container_id, "logs": "Container not found"}
except Exception as e:
return {"container": container_id, "logs": str(e)}
def pull_image(self, container_id: str) -> dict:
try:
container = self.client.containers.get(container_id)
tags = container.image.tags
if not tags:
return {"success": False, "message": "No image tag found"}
image_name = tags[0]
self.client.images.pull(image_name)
return {"success": True, "message": f"Pulled {image_name}"}
except docker.errors.NotFound:
return {"success": False, "message": "Container not found"}
except Exception as e:
return {"success": False, "message": str(e)}
def get_health(self) -> dict:
containers = self.client.containers.list(all=True)
return {
"status": "ok",
"hostname": self.hostname,
"containers_total": len(containers),
}
```
**Step 4: Run tests — verify they pass**
```bash
pytest tests/test_agent_docker_ops.py -v
```
Expected: All PASS
**Step 5: Commit**
```bash
git add agent/docker_ops.py tests/test_agent_docker_ops.py
git commit -m "feat(agent): Docker operations with Swarm/Compose detection (TDD)
Closes #1, progresses #12"
git push origin main
```
---
### Task 3: Node Agent — API Endpoints (TDD)
**Gitea issues:** #1
**Files:**
- Create: `tests/test_agent_api.py`
- Create: `agent/main.py`
**Step 1: Write failing tests for agent API**
`tests/test_agent_api.py`:
```python
import pytest
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient
@pytest.fixture
def mock_ops():
with patch("agent.main.ops") as mock:
yield mock
@pytest.fixture
def client(mock_ops):
from agent.main import app
return TestClient(app)
class TestHealthEndpoint:
def test_health_returns_ok(self, client, mock_ops):
mock_ops.get_health.return_value = {
"status": "ok",
"hostname": "test-node",
"containers_total": 5,
}
resp = client.get("/health")
assert resp.status_code == 200
assert resp.json()["status"] == "ok"
class TestListContainers:
def test_list_containers(self, client, mock_ops):
mock_ops.list_containers.return_value = [
{
"id": "abc123",
"name": "test-app",
"status": "running",
"image": "nginx:latest",
"created": "2026-03-05T00:00:00Z",
"uptime": "2h 30m",
"is_swarm": False,
"swarm_service": None,
}
]
resp = client.get("/containers")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["name"] == "test-app"
class TestContainerActions:
def test_start_container(self, client, mock_ops):
mock_ops.start_container.return_value = {"success": True, "message": "Started"}
resp = client.post("/containers/abc123/start")
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_stop_container(self, client, mock_ops):
mock_ops.stop_container.return_value = {"success": True, "message": "Stopped"}
resp = client.post("/containers/abc123/stop")
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_restart_container(self, client, mock_ops):
mock_ops.restart_container.return_value = {"success": True, "message": "Restarted"}
resp = client.post("/containers/abc123/restart")
assert resp.status_code == 200
assert resp.json()["success"] is True
class TestLogs:
def test_get_logs(self, client, mock_ops):
mock_ops.get_logs.return_value = {"container": "test-app", "logs": "log line 1\nlog line 2"}
resp = client.get("/containers/abc123/logs?tail=50")
assert resp.status_code == 200
assert "log line 1" in resp.json()["logs"]
def test_get_logs_default_tail(self, client, mock_ops):
mock_ops.get_logs.return_value = {"container": "test-app", "logs": "logs"}
resp = client.get("/containers/abc123/logs")
mock_ops.get_logs.assert_called_once_with("abc123", tail=200)
class TestPull:
def test_pull_image(self, client, mock_ops):
mock_ops.pull_image.return_value = {"success": True, "message": "Pulled nginx:latest"}
resp = client.post("/containers/abc123/pull")
assert resp.status_code == 200
assert resp.json()["success"] is True
```
**Step 2: Run tests — verify they fail**
```bash
pytest tests/test_agent_api.py -v
```
Expected: FAIL — `ModuleNotFoundError: No module named 'agent.main'`
**Step 3: Implement agent FastAPI app**
`agent/main.py`:
```python
from fastapi import FastAPI, Query
from agent.docker_ops import DockerOps
app = FastAPI(title="Farm Manager Agent")
ops = DockerOps()
@app.get("/health")
def health():
return ops.get_health()
@app.get("/containers")
def list_containers():
return ops.list_containers()
@app.post("/containers/{container_id}/start")
def start_container(container_id: str):
return ops.start_container(container_id)
@app.post("/containers/{container_id}/stop")
def stop_container(container_id: str):
return ops.stop_container(container_id)
@app.post("/containers/{container_id}/restart")
def restart_container(container_id: str):
return ops.restart_container(container_id)
@app.get("/containers/{container_id}/logs")
def get_logs(container_id: str, tail: int = Query(default=200)):
return ops.get_logs(container_id, tail=tail)
@app.post("/containers/{container_id}/pull")
def pull_image(container_id: str):
return ops.pull_image(container_id)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8889)
```
**Step 4: Run tests — verify they pass**
```bash
pytest tests/test_agent_api.py -v
```
Expected: All PASS
**Step 5: Commit**
```bash
git add agent/main.py tests/test_agent_api.py
git commit -m "feat(agent): FastAPI endpoints for container management (TDD)
Closes #1"
git push origin main
```
---
### Task 4: Node Agent — Dockerfile
**Gitea issues:** #2
**Files:**
- Create: `agent/Dockerfile`
**Step 1: Create Dockerfile**
`agent/Dockerfile`:
```dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8889
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8889"]
```
**Step 2: Commit**
```bash
git add agent/Dockerfile
git commit -m "feat(agent): Dockerfile for node agent
Closes #2"
git push origin main
```
---
### Task 5: API Server — Service Proxy (TDD)
**Gitea issues:** #3, #13
**Files:**
- Create: `tests/test_server_proxy.py`
- Create: `server/main.py`
- Create: `server/routes/services.py`
- Create: `server/routes/nodes.py`
**Step 1: Write failing tests for server proxy**
`tests/test_server_proxy.py`:
```python
import json
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from fastapi.testclient import TestClient
@pytest.fixture
def mock_httpx():
with patch("server.routes.services.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
yield mock_client
@pytest.fixture
def client():
from server.main import app
return TestClient(app)
class TestListNodes:
def test_list_nodes(self, client):
with patch("server.routes.nodes.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_resp = AsyncMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"status": "ok", "hostname": "hf-pdocker-01", "containers_total": 10}
mock_client.get.return_value = mock_resp
resp = client.get("/api/nodes")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 3
assert data[0]["name"] == "hf-pdocker-01"
class TestListServices:
def test_list_all_services(self, client):
with patch("server.routes.services.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_resp = AsyncMock()
mock_resp.status_code = 200
mock_resp.json.return_value = [
{"id": "abc", "name": "nginx", "status": "running", "image": "nginx:latest",
"created": "", "uptime": "1h", "is_swarm": False, "swarm_service": None}
]
mock_client.get.return_value = mock_resp
resp = client.get("/api/services")
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
assert data[0]["node"] in ["hf-pdocker-01", "hf-pdocker-02", "bart"]
class TestServiceActions:
def test_start_service(self, client):
with patch("server.routes.services.httpx.AsyncClient") as mock_cls:
mock_client = AsyncMock()
mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client)
mock_cls.return_value.__aexit__ = AsyncMock(return_value=False)
mock_resp = AsyncMock()
mock_resp.status_code = 200
mock_resp.json.return_value = {"success": True, "message": "Started"}
mock_client.post.return_value = mock_resp
resp = client.post("/api/services/hf-pdocker-01/abc123/start")
assert resp.status_code == 200
assert resp.json()["success"] is True
def test_invalid_node_returns_404(self, client):
resp = client.post("/api/services/nonexistent-node/abc123/start")
assert resp.status_code == 404
```
**Step 2: Run tests — verify they fail**
```bash
pytest tests/test_server_proxy.py -v
```
Expected: FAIL
**Step 3: Implement server routes**
`server/routes/nodes.py`:
```python
import httpx
from fastapi import APIRouter
from server.config import get_nodes
router = APIRouter()
@router.get("/api/nodes")
async def list_nodes():
nodes = get_nodes()
results = []
for node in nodes:
url = f"http://{node['host']}:{node['agent_port']}/health"
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url)
if resp.status_code == 200:
health = resp.json()
results.append({
"name": node["name"],
"host": node["host"],
"healthy": True,
"containers_total": health.get("containers_total", 0),
})
else:
results.append({"name": node["name"], "host": node["host"], "healthy": False, "error": f"HTTP {resp.status_code}"})
except Exception as e:
results.append({"name": node["name"], "host": node["host"], "healthy": False, "error": str(e)})
return results
```
`server/routes/services.py`:
```python
import httpx
from fastapi import APIRouter, HTTPException, Query
from server.config import get_node_url
router = APIRouter()
@router.get("/api/services")
async def list_services():
from server.config import get_nodes
all_services = []
for node in get_nodes():
url = f"http://{node['host']}:{node['agent_port']}/containers"
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url)
if resp.status_code == 200:
containers = resp.json()
for c in containers:
c["node"] = node["name"]
all_services.extend(containers)
except Exception:
pass
return all_services
@router.post("/api/services/{node}/{container_id}/start")
async def start_service(node: str, container_id: str):
base = get_node_url(node)
if not base:
raise HTTPException(status_code=404, detail=f"Node {node} not found")
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{base}/containers/{container_id}/start")
return resp.json()
@router.post("/api/services/{node}/{container_id}/stop")
async def stop_service(node: str, container_id: str):
base = get_node_url(node)
if not base:
raise HTTPException(status_code=404, detail=f"Node {node} not found")
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{base}/containers/{container_id}/stop")
return resp.json()
@router.post("/api/services/{node}/{container_id}/restart")
async def restart_service(node: str, container_id: str):
base = get_node_url(node)
if not base:
raise HTTPException(status_code=404, detail=f"Node {node} not found")
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{base}/containers/{container_id}/restart")
return resp.json()
@router.get("/api/services/{node}/{container_id}/logs")
async def get_logs(node: str, container_id: str, tail: int = Query(default=200)):
base = get_node_url(node)
if not base:
raise HTTPException(status_code=404, detail=f"Node {node} not found")
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{base}/containers/{container_id}/logs", params={"tail": tail})
return resp.json()
@router.post("/api/services/{node}/{container_id}/pull")
async def pull_image(node: str, container_id: str):
base = get_node_url(node)
if not base:
raise HTTPException(status_code=404, detail=f"Node {node} not found")
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(f"{base}/containers/{container_id}/pull")
return resp.json()
```
`server/config.py`:
```python
import json
import os
_config = None
def _load_config():
global _config
if _config is None:
config_path = os.environ.get("CONFIG_PATH", "/app/config.json")
with open(config_path) as f:
_config = json.load(f)
return _config
def get_nodes() -> list[dict]:
return _load_config()["nodes"]
def get_node_url(node_name: str) -> str | None:
for node in get_nodes():
if node["name"] == node_name:
return f"http://{node['host']}:{node['agent_port']}"
return None
```
`server/main.py`:
```python
import os
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from server.routes.nodes import router as nodes_router
from server.routes.services import router as services_router
app = FastAPI(title="Farm Manager")
app.include_router(nodes_router)
app.include_router(services_router)
@app.get("/health")
def health():
return {"status": "ok"}
static_dir = os.path.join(os.path.dirname(__file__), "static")
if os.path.isdir(static_dir):
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8888)
```
**Step 4: Run tests — verify they pass**
```bash
CONFIG_PATH=config.json pytest tests/test_server_proxy.py -v
```
Expected: All PASS
**Step 5: Commit**
```bash
git add server/ tests/test_server_proxy.py
git commit -m "feat(server): API proxy routes for services and nodes (TDD)
Closes #3, progresses #13"
git push origin main
```
---
### Task 6: API Server — Group CRUD (TDD)
**Gitea issues:** #4, #13
**Files:**
- Create: `tests/test_server_groups.py`
- Create: `server/routes/groups.py`
- Create: `server/group_store.py`
**Step 1: Write failing tests**
`tests/test_server_groups.py`:
```python
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def groups_file():
f = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
json.dump({"groups": []}, f)
f.close()
yield f.name
os.unlink(f.name)
@pytest.fixture
def client(groups_file):
os.environ["GROUPS_PATH"] = groups_file
os.environ["CONFIG_PATH"] = "config.json"
from server.group_store import GroupStore
GroupStore._instance = None
from server.main import app
return TestClient(app)
class TestGroupCRUD:
def test_list_groups_empty(self, client):
resp = client.get("/api/groups")
assert resp.status_code == 200
assert resp.json() == []
def test_create_group(self, client):
resp = client.post("/api/groups", json={
"id": "test-group",
"name": "Test Group",
"services": [{"node": "hf-pdocker-01", "container": "test"}],
})
assert resp.status_code == 200
assert resp.json()["id"] == "test-group"
def test_create_group_then_list(self, client):
client.post("/api/groups", json={
"id": "grp1", "name": "Group 1", "services": [],
})
resp = client.get("/api/groups")
assert len(resp.json()) == 1
assert resp.json()[0]["name"] == "Group 1"
def test_update_group(self, client):
client.post("/api/groups", json={
"id": "grp1", "name": "Group 1", "services": [],
})
resp = client.put("/api/groups/grp1", json={
"id": "grp1", "name": "Updated Group", "services": [{"node": "bart", "container": "jellyfin"}],
})
assert resp.status_code == 200
assert resp.json()["name"] == "Updated Group"
assert len(resp.json()["services"]) == 1
def test_delete_group(self, client):
client.post("/api/groups", json={
"id": "grp1", "name": "Group 1", "services": [],
})
resp = client.delete("/api/groups/grp1")
assert resp.status_code == 200
assert len(client.get("/api/groups").json()) == 0
def test_update_nonexistent_returns_404(self, client):
resp = client.put("/api/groups/nope", json={
"id": "nope", "name": "Nope", "services": [],
})
assert resp.status_code == 404
def test_delete_nonexistent_returns_404(self, client):
resp = client.delete("/api/groups/nope")
assert resp.status_code == 404
def test_create_duplicate_returns_409(self, client):
client.post("/api/groups", json={"id": "grp1", "name": "G1", "services": []})
resp = client.post("/api/groups", json={"id": "grp1", "name": "G1 dup", "services": []})
assert resp.status_code == 409
def test_persistence(self, client, groups_file):
client.post("/api/groups", json={"id": "grp1", "name": "G1", "services": []})
with open(groups_file) as f:
data = json.load(f)
assert len(data["groups"]) == 1
```
**Step 2: Run tests — verify they fail**
```bash
CONFIG_PATH=config.json pytest tests/test_server_groups.py -v
```
Expected: FAIL
**Step 3: Implement group store and routes**
`server/group_store.py`:
```python
import json
import os
import threading
class GroupStore:
_instance = None
_lock = threading.Lock()
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._path = os.environ.get("GROUPS_PATH", "/app/groups.json")
cls._instance._data = cls._instance._load()
return cls._instance
def _load(self) -> dict:
try:
with open(self._path) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {"groups": []}
def _save(self):
with open(self._path, "w") as f:
json.dump(self._data, f, indent=2)
def list_groups(self) -> list[dict]:
return self._data["groups"]
def get_group(self, group_id: str) -> dict | None:
for g in self._data["groups"]:
if g["id"] == group_id:
return g
return None
def create_group(self, group: dict) -> dict:
with self._lock:
self._data["groups"].append(group)
self._save()
return group
def update_group(self, group_id: str, group: dict) -> dict | None:
with self._lock:
for i, g in enumerate(self._data["groups"]):
if g["id"] == group_id:
self._data["groups"][i] = group
self._save()
return group
return None
def delete_group(self, group_id: str) -> bool:
with self._lock:
before = len(self._data["groups"])
self._data["groups"] = [g for g in self._data["groups"] if g["id"] != group_id]
if len(self._data["groups"]) < before:
self._save()
return True
return False
```
`server/routes/groups.py`:
```python
import httpx
from fastapi import APIRouter, HTTPException
from server.group_store import GroupStore
from server.config import get_node_url
from server.models import Group
router = APIRouter()
def _store():
return GroupStore()
@router.get("/api/groups")
def list_groups():
return _store().list_groups()
@router.post("/api/groups")
def create_group(group: Group):
store = _store()
if store.get_group(group.id):
raise HTTPException(status_code=409, detail=f"Group {group.id} already exists")
return store.create_group(group.model_dump())
@router.put("/api/groups/{group_id}")
def update_group(group_id: str, group: Group):
result = _store().update_group(group_id, group.model_dump())
if result is None:
raise HTTPException(status_code=404, detail=f"Group {group_id} not found")
return result
@router.delete("/api/groups/{group_id}")
def delete_group(group_id: str):
if not _store().delete_group(group_id):
raise HTTPException(status_code=404, detail=f"Group {group_id} not found")
return {"success": True}
@router.post("/api/groups/{group_id}/start")
async def start_group(group_id: str):
group = _store().get_group(group_id)
if not group:
raise HTTPException(status_code=404, detail=f"Group {group_id} not found")
results = []
for svc in group["services"]:
base = get_node_url(svc["node"])
if not base:
results.append({"container": svc["container"], "success": False, "message": f"Node {svc['node']} not found"})
continue
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{base}/containers/{svc['container']}/start")
results.append({"container": svc["container"], **resp.json()})
except Exception as e:
results.append({"container": svc["container"], "success": False, "message": str(e)})
return results
@router.post("/api/groups/{group_id}/stop")
async def stop_group(group_id: str):
group = _store().get_group(group_id)
if not group:
raise HTTPException(status_code=404, detail=f"Group {group_id} not found")
results = []
for svc in group["services"]:
base = get_node_url(svc["node"])
if not base:
results.append({"container": svc["container"], "success": False, "message": f"Node {svc['node']} not found"})
continue
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{base}/containers/{svc['container']}/stop")
results.append({"container": svc["container"], **resp.json()})
except Exception as e:
results.append({"container": svc["container"], "success": False, "message": str(e)})
return results
@router.post("/api/groups/{group_id}/restart")
async def restart_group(group_id: str):
group = _store().get_group(group_id)
if not group:
raise HTTPException(status_code=404, detail=f"Group {group_id} not found")
results = []
for svc in group["services"]:
base = get_node_url(svc["node"])
if not base:
results.append({"container": svc["container"], "success": False, "message": f"Node {svc['node']} not found"})
continue
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{base}/containers/{svc['container']}/restart")
results.append({"container": svc["container"], **resp.json()})
except Exception as e:
results.append({"container": svc["container"], "success": False, "message": str(e)})
return results
```
Update `server/main.py` — add groups router:
```python
from server.routes.groups import router as groups_router
app.include_router(groups_router)
```
**Step 4: Run tests — verify they pass**
```bash
CONFIG_PATH=config.json pytest tests/test_server_groups.py -v
```
Expected: All PASS
**Step 5: Commit**
```bash
git add server/group_store.py server/routes/groups.py tests/test_server_groups.py server/main.py
git commit -m "feat(server): Group CRUD with JSON persistence and bulk actions (TDD)
Closes #4, #5, progresses #13"
git push origin main
```
---
### Task 7: Dashboard — HTML, CSS, JavaScript
**Gitea issues:** #6, #7, #8, #9, #10
**Files:**
- Create: `server/static/index.html`
- Create: `server/static/style.css`
- Create: `server/static/app.js`
This is the frontend task. Since it's vanilla HTML/CSS/JS with no build step, TDD doesn't apply in the traditional sense. The tests are the API contract tests already written. Build the UI to consume the API endpoints.
**Step 1: Create index.html**
`server/static/index.html` — Single page layout with:
- Sidebar: nodes list, groups list, "All Services" link, "Manage Groups" button
- Main area: service card grid with status badges and action buttons
- Slide-out log panel on the right
- Group editor modal
- Confirmation modal for destructive group actions
- Toast notifications for action feedback
**Step 2: Create style.css**
`server/static/style.css` — Dark theme using CSS variables:
- `--bg-primary: #1a1a2e` (dark blue-black)
- `--bg-secondary: #16213e` (card background)
- `--bg-tertiary: #0f3460` (sidebar)
- `--accent: #00d2ff` (cyan accent)
- `--success: #00c853`, `--danger: #ff1744`, `--warning: #ffab00`
- Status badges: green (running), red (exited), yellow (restarting), gray (paused)
- Responsive grid for service cards
- Slide-out panel animation for logs
**Step 3: Create app.js**
`server/static/app.js` — Vanilla JS application:
- `loadNodes()` — Fetch `/api/nodes`, render sidebar with health dots
- `loadServices(filter)` — Fetch `/api/services`, render card grid, optionally filter by group
- `loadGroups()` — Fetch `/api/groups`, render sidebar group list
- `performAction(node, containerId, action)` — POST to `/api/services/{node}/{id}/{action}`, show toast, refresh
- `loadLogs(node, containerId)` — GET `/api/services/{node}/{id}/logs`, open slide-out panel, auto-scroll
- `groupAction(groupId, action)` — POST to `/api/groups/{id}/{action}` with confirmation modal
- `openGroupEditor(groupId?)` — Modal for create/edit group, checkboxes for services
- `deleteGroup(groupId)` — DELETE with confirmation
- Auto-refresh service status every 15 seconds
- Toast notification system for action feedback
**Step 4: Commit**
```bash
git add server/static/
git commit -m "feat(dashboard): service grid, log viewer, group management, dark theme
Closes #6, #7, #8, #9"
git push origin main
```
---
### Task 8: Dockerfiles and Compose Files
**Gitea issues:** #2, #11
**Files:**
- Create: `server/Dockerfile`
- Create: `deploy/agent/docker-compose.yml`
- Create: `deploy/server/docker-compose.yml`
**Step 1: Create server Dockerfile**
`server/Dockerfile`:
```dockerfile
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8888
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8888"]
```
**Step 2: Create agent compose file (template for all nodes)**
`deploy/agent/docker-compose.yml`:
```yaml
services:
farm-agent:
image: 127.0.0.1:5050/farm-agent:latest
container_name: farm-agent
restart: unless-stopped
ports:
- "8889:8889"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
mem_limit: 128M
memswap_limit: 128M
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8889/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
```
**Step 3: Create server compose file**
`deploy/server/docker-compose.yml`:
```yaml
services:
farm-manager:
image: 127.0.0.1:5050/farm-manager:latest
container_name: farm-manager
restart: unless-stopped
ports:
- "8888:8888"
environment:
- CONFIG_PATH=/app/config.json
- GROUPS_PATH=/data/groups.json
volumes:
- /mnt/docker-data/configs/farm-manager:/data
mem_limit: 256M
memswap_limit: 256M
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8888/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 15s
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
```
**Step 4: Commit**
```bash
git add server/Dockerfile deploy/
git commit -m "feat: Dockerfiles and compose files for agent and server
Closes #2, #11"
git push origin main
```
---
### Task 9: Build, Push, and Deploy
**Gitea issues:** #2, #11, #10
**Step 1: Build and push agent image**
On hf-pdocker-01 (has the registry at 127.0.0.1:5050):
```bash
cd /tmp/farm-manager
docker build -t 127.0.0.1:5050/farm-agent:latest -f agent/Dockerfile agent/
docker push 127.0.0.1:5050/farm-agent:latest
```
**Step 2: Build and push server image**
```bash
docker build -t 127.0.0.1:5050/farm-manager:latest -f server/Dockerfile .
docker push 127.0.0.1:5050/farm-manager:latest
```
**Step 3: Deploy agents to all 3 nodes**
Copy compose file to NAS:
```bash
mkdir -p /mnt/docker-data/compose/hf-pdocker-01/farm-agent
mkdir -p /mnt/docker-data/compose/hf-pdocker-02/farm-agent
mkdir -p /mnt/docker-data/compose/bart/farm-agent
cp deploy/agent/docker-compose.yml /mnt/docker-data/compose/hf-pdocker-01/farm-agent/
cp deploy/agent/docker-compose.yml /mnt/docker-data/compose/hf-pdocker-02/farm-agent/
cp deploy/agent/docker-compose.yml /mnt/docker-data/compose/bart/farm-agent/
```
On each node:
```bash
cd /mnt/docker-data/compose/{node}/farm-agent && docker compose up -d
```
**Step 4: Create config directory and copy default groups**
```bash
mkdir -p /mnt/docker-data/configs/farm-manager
cp groups.json /mnt/docker-data/configs/farm-manager/groups.json
```
**Step 5: Deploy server on hf-pdocker-01**
```bash
mkdir -p /mnt/docker-data/compose/hf-pdocker-01/farm-manager
cp deploy/server/docker-compose.yml /mnt/docker-data/compose/hf-pdocker-01/farm-manager/
cd /mnt/docker-data/compose/hf-pdocker-01/farm-manager && docker compose up -d
```
**Step 6: Verify all services are healthy**
```bash
curl -sf http://192.168.86.192:8888/health
curl -sf http://192.168.86.192:8889/health
curl -sf http://192.168.86.100:8889/health
curl -sf http://192.168.86.167:8889/health
```
**Step 7: Commit deploy configs to repo**
```bash
git add deploy/
git commit -m "chore: deploy configs for all nodes
Closes #10, #11"
git push origin main
```
---
### Task 10: Monitoring and Documentation
**Gitea issues:** #15, #16
**Step 1: Add Prometheus blackbox probes**
Add to `stacks/monitoring/prometheus.yml` in the appropriate job sections:
- `http://192.168.86.192:8888/health` (Farm Manager server — http-pdocker01)
- `http://192.168.86.192:8889/health` (Farm Agent — http-pdocker01)
- `http://192.168.86.100:8889/health` (Farm Agent — http-pdocker02)
- `http://192.168.86.167:8889/health` (Farm Agent — http-bart)
Hot-reload Prometheus.
**Step 2: Update CLAUDE.md**
Add Farm Manager to hf-pdocker-01 service list and port reference (8888).
Add Farm Agent to all 3 nodes.
**Step 3: Update MEMORY.md**
Add Farm Manager entry.
**Step 4: Update wiki**
Launch documentation-keeper agent to add Farm Manager page.
**Step 5: Write README.md in repo**
Cover: what it does, architecture diagram, API reference, deployment instructions.
**Step 6: Commit**
```bash
git add README.md
git commit -m "docs: README with architecture and API reference
Closes #15, #16"
git push origin main
```
**Step 7: Close all Gitea issues and milestone**
Use Gitea API to close milestone v1.0.