de1d9e1d93
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>
1771 lines
51 KiB
Markdown
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.
|