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>
51 KiB
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
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:
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:
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:
{
"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:
{
"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:
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
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:
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
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:
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
pytest tests/test_agent_docker_ops.py -v
Expected: All PASS
Step 5: Commit
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:
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
pytest tests/test_agent_api.py -v
Expected: FAIL — ModuleNotFoundError: No module named 'agent.main'
Step 3: Implement agent FastAPI app
agent/main.py:
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
pytest tests/test_agent_api.py -v
Expected: All PASS
Step 5: Commit
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:
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
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:
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
pytest tests/test_server_proxy.py -v
Expected: FAIL
Step 3: Implement server routes
server/routes/nodes.py:
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:
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:
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:
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
CONFIG_PATH=config.json pytest tests/test_server_proxy.py -v
Expected: All PASS
Step 5: Commit
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:
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
CONFIG_PATH=config.json pytest tests/test_server_groups.py -v
Expected: FAIL
Step 3: Implement group store and routes
server/group_store.py:
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:
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:
from server.routes.groups import router as groups_router
app.include_router(groups_router)
Step 4: Run tests — verify they pass
CONFIG_PATH=config.json pytest tests/test_server_groups.py -v
Expected: All PASS
Step 5: Commit
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 dotsloadServices(filter)— Fetch/api/services, render card grid, optionally filter by grouploadGroups()— Fetch/api/groups, render sidebar group listperformAction(node, containerId, action)— POST to/api/services/{node}/{id}/{action}, show toast, refreshloadLogs(node, containerId)— GET/api/services/{node}/{id}/logs, open slide-out panel, auto-scrollgroupAction(groupId, action)— POST to/api/groups/{id}/{action}with confirmation modalopenGroupEditor(groupId?)— Modal for create/edit group, checkboxes for servicesdeleteGroup(groupId)— DELETE with confirmation- Auto-refresh service status every 15 seconds
- Toast notification system for action feedback
Step 4: Commit
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:
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:
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:
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
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):
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
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:
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:
cd /mnt/docker-data/compose/{node}/farm-agent && docker compose up -d
Step 4: Create config directory and copy default groups
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
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
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
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
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.