Compare commits
10 Commits
9af81e3001
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 446c01fb78 | |||
| 6338621ad4 | |||
| 9a24db2e47 | |||
| 9508b73293 | |||
| 04c2e1f102 | |||
| fe76ca7456 | |||
| d92d67ebdc | |||
| 093a7ea95d | |||
| 6e9465942a | |||
| f13d6151e3 |
@@ -0,0 +1,15 @@
|
|||||||
|
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 server/requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY server/ ./server/
|
||||||
|
COPY config.json .
|
||||||
|
|
||||||
|
EXPOSE 8888
|
||||||
|
|
||||||
|
CMD ["uvicorn", "server.main:app", "--host", "0.0.0.0", "--port", "8888"]
|
||||||
@@ -1,3 +1,87 @@
|
|||||||
# farm-manager
|
# Farm Manager
|
||||||
|
|
||||||
Web dashboard for managing Docker services across the homelab cluster
|
Web dashboard for managing Docker services across the homelab cluster. Provides full control (start/stop/restart/logs/pull) with service grouping for bulk operations.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser --> API Server (hf-pdocker-01:8888)
|
||||||
|
|
|
||||||
|
+------+------+
|
||||||
|
| | |
|
||||||
|
Agent Agent Agent
|
||||||
|
(01:8889)(02:8889)(bart:8889)
|
||||||
|
| | |
|
||||||
|
Docker Docker Docker
|
||||||
|
Socket Socket Socket
|
||||||
|
```
|
||||||
|
|
||||||
|
- **API Server** — FastAPI on :8888. Serves the dashboard, proxies commands to node agents, manages service groups.
|
||||||
|
- **Node Agent** — FastAPI on :8889 per node. Mounts Docker socket, auto-detects Swarm vs Compose containers.
|
||||||
|
- **Dashboard** — Vanilla HTML/CSS/JS. Dark theme, service cards, group management, log viewer.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build and push images
|
||||||
|
docker build -f agent/Dockerfile -t 127.0.0.1:5050/farm-agent:latest agent/
|
||||||
|
docker build -f Dockerfile.server -t 127.0.0.1:5050/farm-manager:latest .
|
||||||
|
docker push 127.0.0.1:5050/farm-agent:latest
|
||||||
|
docker push 127.0.0.1:5050/farm-manager:latest
|
||||||
|
|
||||||
|
# Deploy agent on each node
|
||||||
|
cd /mnt/docker-data/compose/{node}/farm-agent && docker compose up -d
|
||||||
|
|
||||||
|
# Deploy server on hf-pdocker-01
|
||||||
|
cd /mnt/docker-data/compose/hf-pdocker-01/farm-manager && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
`config.json` — Node definitions (name, host, agent_port)
|
||||||
|
|
||||||
|
`groups.json` — Service groups for bulk operations (10 default groups included)
|
||||||
|
|
||||||
|
Both stored at `/mnt/docker-data/configs/farm-manager/`.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### Server (`:8888`)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/api/nodes` | List nodes with health |
|
||||||
|
| GET | `/api/services` | All containers across nodes |
|
||||||
|
| POST | `/api/services/{node}/{container}/start` | Start container |
|
||||||
|
| POST | `/api/services/{node}/{container}/stop` | Stop container |
|
||||||
|
| POST | `/api/services/{node}/{container}/restart` | Restart container |
|
||||||
|
| GET | `/api/services/{node}/{container}/logs` | Get logs |
|
||||||
|
| POST | `/api/services/{node}/{container}/pull` | Pull image |
|
||||||
|
| GET/POST/PUT/DELETE | `/api/groups[/{id}]` | Group CRUD |
|
||||||
|
| POST | `/api/groups/{id}/start\|stop\|restart` | Bulk group actions |
|
||||||
|
|
||||||
|
### Agent (`:8889`)
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/health` | Agent health check |
|
||||||
|
| GET | `/containers` | List all containers |
|
||||||
|
| POST | `/containers/{id}/start\|stop\|restart` | Container actions |
|
||||||
|
| GET | `/containers/{id}/logs` | Get logs |
|
||||||
|
| POST | `/containers/{id}/pull` | Pull image |
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r agent/requirements.txt -r server/requirements.txt pytest pytest-asyncio httpx
|
||||||
|
pytest -v
|
||||||
|
```
|
||||||
|
|
||||||
|
52 tests covering agent Docker operations, agent API, server proxy routes, and group CRUD.
|
||||||
|
|
||||||
|
## Swarm Handling
|
||||||
|
|
||||||
|
The agent auto-detects Swarm containers via the `com.docker.swarm.service.name` label:
|
||||||
|
- **Start**: Scales service to 1 replica
|
||||||
|
- **Stop**: Scales service to 0 replicas
|
||||||
|
- **Restart**: Force-updates the service
|
||||||
|
|||||||
+2
-2
@@ -7,8 +7,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf
|
|||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . /app/agent/
|
||||||
|
|
||||||
EXPOSE 8889
|
EXPOSE 8889
|
||||||
|
|
||||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8889"]
|
CMD ["uvicorn", "agent.main:app", "--host", "0.0.0.0", "--port", "8889"]
|
||||||
|
|||||||
+204
-5
@@ -1,6 +1,7 @@
|
|||||||
"""Docker operations wrapper for managing Swarm and Compose containers."""
|
"""Docker operations wrapper for managing Swarm and Compose containers."""
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
@@ -77,6 +78,51 @@ class DockerOps:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_ports(container) -> list[str]:
|
||||||
|
"""Extract exposed host ports from a container's network settings.
|
||||||
|
|
||||||
|
Returns a sorted, deduplicated list of strings like '8080', '443'.
|
||||||
|
Only includes ports bound to the host. For host-network containers,
|
||||||
|
reads the exposed ports from the image config instead.
|
||||||
|
"""
|
||||||
|
ports = set()
|
||||||
|
network_mode = container.attrs.get("HostConfig", {}).get("NetworkMode", "")
|
||||||
|
|
||||||
|
if network_mode == "host":
|
||||||
|
# Host networking: use exposed ports from the image/container config
|
||||||
|
exposed = container.attrs.get("Config", {}).get("ExposedPorts", {})
|
||||||
|
for port_proto in exposed:
|
||||||
|
port = port_proto.split("/")[0]
|
||||||
|
ports.add(port)
|
||||||
|
else:
|
||||||
|
# Bridge/overlay: use published port bindings
|
||||||
|
port_bindings = container.attrs.get("NetworkSettings", {}).get("Ports", {}) or {}
|
||||||
|
for port_proto, bindings in port_bindings.items():
|
||||||
|
if bindings:
|
||||||
|
for binding in bindings:
|
||||||
|
host_port = binding.get("HostPort")
|
||||||
|
if host_port:
|
||||||
|
ports.add(host_port)
|
||||||
|
|
||||||
|
return sorted(ports, key=lambda p: int(p) if p.isdigit() else 0)
|
||||||
|
|
||||||
|
def _get_container_memory(self, container_id: str) -> tuple[str, int, int]:
|
||||||
|
"""Fetch memory usage and limit for a single running container.
|
||||||
|
|
||||||
|
Returns (container_id, usage_bytes, limit_bytes).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
stats = self.client.api.stats(container_id, stream=False, one_shot=True)
|
||||||
|
mem = stats.get("memory_stats", {})
|
||||||
|
usage = mem.get("usage", 0)
|
||||||
|
limit = mem.get("limit", 0)
|
||||||
|
# Subtract inactive file cache for accurate usage
|
||||||
|
cache = mem.get("stats", {}).get("inactive_file", 0)
|
||||||
|
return (container_id, max(usage - cache, 0), limit)
|
||||||
|
except Exception:
|
||||||
|
return (container_id, 0, 0)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public API
|
# Public API
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -84,17 +130,35 @@ class DockerOps:
|
|||||||
def list_containers(self) -> list[dict]:
|
def list_containers(self) -> list[dict]:
|
||||||
"""List all containers (running + stopped) with metadata.
|
"""List all containers (running + stopped) with metadata.
|
||||||
|
|
||||||
|
Also includes Swarm services scaled to 0 replicas (which have no
|
||||||
|
containers) so they remain visible in the dashboard.
|
||||||
|
|
||||||
Returns a list of dicts, each containing:
|
Returns a list of dicts, each containing:
|
||||||
id, name, status, image, created, uptime, is_swarm,
|
id, name, status, image, created, uptime, is_swarm,
|
||||||
swarm_service
|
swarm_service, memory_usage, memory_limit
|
||||||
|
|
||||||
|
Uses the low-level API to get container IDs to avoid NotFound errors
|
||||||
|
from containers that disappear between list and inspect (e.g. Swarm
|
||||||
|
task containers in the Dead state).
|
||||||
"""
|
"""
|
||||||
containers = self.client.containers.list(all=True)
|
raw_ids = [r["Id"] for r in self.client.api.containers(all=True)]
|
||||||
result = []
|
result = []
|
||||||
for c in containers:
|
swarm_services_with_containers = set()
|
||||||
|
running_ids = {} # full_id -> short_id for memory stats lookup
|
||||||
|
|
||||||
|
for cid in raw_ids:
|
||||||
|
try:
|
||||||
|
c = self.client.containers.get(cid)
|
||||||
|
except NotFound:
|
||||||
|
continue
|
||||||
image_tags = c.image.tags
|
image_tags = c.image.tags
|
||||||
image_name = image_tags[0] if image_tags else "<none>"
|
image_name = image_tags[0] if image_tags else "<none>"
|
||||||
swarm_service = self._swarm_service_name(c)
|
swarm_service = self._swarm_service_name(c)
|
||||||
|
if swarm_service:
|
||||||
|
swarm_services_with_containers.add(swarm_service)
|
||||||
started_at = c.attrs.get("State", {}).get("StartedAt", "")
|
started_at = c.attrs.get("State", {}).get("StartedAt", "")
|
||||||
|
if c.status == "running":
|
||||||
|
running_ids[cid] = c.short_id
|
||||||
result.append(
|
result.append(
|
||||||
{
|
{
|
||||||
"id": c.short_id,
|
"id": c.short_id,
|
||||||
@@ -105,12 +169,84 @@ class DockerOps:
|
|||||||
"uptime": self._format_uptime(started_at) if c.status == "running" else None,
|
"uptime": self._format_uptime(started_at) if c.status == "running" else None,
|
||||||
"is_swarm": self._is_swarm(c),
|
"is_swarm": self._is_swarm(c),
|
||||||
"swarm_service": swarm_service,
|
"swarm_service": swarm_service,
|
||||||
|
"ports": self._extract_ports(c),
|
||||||
|
"memory_usage": 0,
|
||||||
|
"memory_limit": 0,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Fetch memory stats for running containers in parallel
|
||||||
|
if running_ids:
|
||||||
|
mem_stats = {}
|
||||||
|
with ThreadPoolExecutor(max_workers=20) as pool:
|
||||||
|
futures = {
|
||||||
|
pool.submit(self._get_container_memory, full_id): short_id
|
||||||
|
for full_id, short_id in running_ids.items()
|
||||||
|
}
|
||||||
|
for future in futures:
|
||||||
|
full_id, usage, limit = future.result()
|
||||||
|
short_id = futures[future]
|
||||||
|
mem_stats[short_id] = (usage, limit)
|
||||||
|
for entry in result:
|
||||||
|
if entry["id"] in mem_stats:
|
||||||
|
entry["memory_usage"], entry["memory_limit"] = mem_stats[entry["id"]]
|
||||||
|
|
||||||
|
# Include Swarm services with 0 replicas (only works on manager nodes)
|
||||||
|
try:
|
||||||
|
for svc in self.client.services.list():
|
||||||
|
if svc.name not in swarm_services_with_containers:
|
||||||
|
image = (
|
||||||
|
svc.attrs.get("Spec", {})
|
||||||
|
.get("TaskTemplate", {})
|
||||||
|
.get("ContainerSpec", {})
|
||||||
|
.get("Image", "<none>")
|
||||||
|
)
|
||||||
|
if "@" in image:
|
||||||
|
image = image.split("@")[0]
|
||||||
|
# Extract published ports from Swarm service endpoint spec
|
||||||
|
svc_ports = []
|
||||||
|
endpoint_spec = svc.attrs.get("Spec", {}).get("EndpointSpec", {})
|
||||||
|
for p in endpoint_spec.get("Ports", []):
|
||||||
|
published = p.get("PublishedPort")
|
||||||
|
if published:
|
||||||
|
svc_ports.append(str(published))
|
||||||
|
svc_ports.sort(key=lambda x: int(x) if x.isdigit() else 0)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": f"swarm:{svc.name}",
|
||||||
|
"name": svc.name,
|
||||||
|
"status": "stopped",
|
||||||
|
"image": image,
|
||||||
|
"created": svc.attrs.get("CreatedAt", ""),
|
||||||
|
"uptime": None,
|
||||||
|
"is_swarm": True,
|
||||||
|
"swarm_service": svc.name,
|
||||||
|
"ports": svc_ports,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass # Not a manager node or Swarm not active
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def start_container(self, container_id: str) -> dict:
|
def start_container(self, container_id: str) -> dict:
|
||||||
"""Start a container. For Swarm services, scale to 1 replica."""
|
"""Start a container. For Swarm services, scale to 1 replica."""
|
||||||
|
# Handle 0-replica Swarm services (synthetic swarm: IDs)
|
||||||
|
if container_id.startswith("swarm:"):
|
||||||
|
svc_name = container_id[6:]
|
||||||
|
try:
|
||||||
|
service = self._get_service(svc_name)
|
||||||
|
if service:
|
||||||
|
service.scale(1)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Scaled Swarm service {svc_name} to 1 replica",
|
||||||
|
}
|
||||||
|
return {"success": False, "message": f"Swarm service {svc_name} not found"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
container = self.client.containers.get(container_id)
|
container = self.client.containers.get(container_id)
|
||||||
except NotFound:
|
except NotFound:
|
||||||
@@ -135,6 +271,20 @@ class DockerOps:
|
|||||||
|
|
||||||
def stop_container(self, container_id: str) -> dict:
|
def stop_container(self, container_id: str) -> dict:
|
||||||
"""Stop a container. For Swarm services, scale to 0 replicas."""
|
"""Stop a container. For Swarm services, scale to 0 replicas."""
|
||||||
|
if container_id.startswith("swarm:"):
|
||||||
|
svc_name = container_id[6:]
|
||||||
|
try:
|
||||||
|
service = self._get_service(svc_name)
|
||||||
|
if service:
|
||||||
|
service.scale(0)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Scaled Swarm service {svc_name} to 0 replicas",
|
||||||
|
}
|
||||||
|
return {"success": False, "message": f"Swarm service {svc_name} not found"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
container = self.client.containers.get(container_id)
|
container = self.client.containers.get(container_id)
|
||||||
except NotFound:
|
except NotFound:
|
||||||
@@ -159,6 +309,20 @@ class DockerOps:
|
|||||||
|
|
||||||
def restart_container(self, container_id: str) -> dict:
|
def restart_container(self, container_id: str) -> dict:
|
||||||
"""Restart a container. For Swarm services, force-update."""
|
"""Restart a container. For Swarm services, force-update."""
|
||||||
|
if container_id.startswith("swarm:"):
|
||||||
|
svc_name = container_id[6:]
|
||||||
|
try:
|
||||||
|
service = self._get_service(svc_name)
|
||||||
|
if service:
|
||||||
|
service.force_update()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Force-updated Swarm service {svc_name}",
|
||||||
|
}
|
||||||
|
return {"success": False, "message": f"Swarm service {svc_name} not found"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
container = self.client.containers.get(container_id)
|
container = self.client.containers.get(container_id)
|
||||||
except NotFound:
|
except NotFound:
|
||||||
@@ -187,6 +351,13 @@ class DockerOps:
|
|||||||
Returns:
|
Returns:
|
||||||
dict with keys ``container`` (name) and ``logs`` (string).
|
dict with keys ``container`` (name) and ``logs`` (string).
|
||||||
"""
|
"""
|
||||||
|
if container_id.startswith("swarm:"):
|
||||||
|
svc_name = container_id[6:]
|
||||||
|
return {
|
||||||
|
"container": svc_name,
|
||||||
|
"logs": "Service is scaled to 0 replicas — no logs available.\nStart the service to view logs.",
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
container = self.client.containers.get(container_id)
|
container = self.client.containers.get(container_id)
|
||||||
except NotFound:
|
except NotFound:
|
||||||
@@ -200,6 +371,27 @@ class DockerOps:
|
|||||||
|
|
||||||
def pull_image(self, container_id: str) -> dict:
|
def pull_image(self, container_id: str) -> dict:
|
||||||
"""Pull the latest version of the container's image."""
|
"""Pull the latest version of the container's image."""
|
||||||
|
if container_id.startswith("swarm:"):
|
||||||
|
svc_name = container_id[6:]
|
||||||
|
service = self._get_service(svc_name)
|
||||||
|
if not service:
|
||||||
|
return {"success": False, "message": f"Swarm service {svc_name} not found"}
|
||||||
|
image = (
|
||||||
|
service.attrs.get("Spec", {})
|
||||||
|
.get("TaskTemplate", {})
|
||||||
|
.get("ContainerSpec", {})
|
||||||
|
.get("Image", "")
|
||||||
|
)
|
||||||
|
if "@" in image:
|
||||||
|
image = image.split("@")[0]
|
||||||
|
if not image:
|
||||||
|
return {"success": False, "message": f"No image found for service {svc_name}"}
|
||||||
|
try:
|
||||||
|
self.client.images.pull(image)
|
||||||
|
return {"success": True, "message": f"Pulled image {image}"}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
container = self.client.containers.get(container_id)
|
container = self.client.containers.get(container_id)
|
||||||
except NotFound:
|
except NotFound:
|
||||||
@@ -217,8 +409,15 @@ class DockerOps:
|
|||||||
return {"success": False, "message": str(exc)}
|
return {"success": False, "message": str(exc)}
|
||||||
|
|
||||||
def get_health(self) -> dict:
|
def get_health(self) -> dict:
|
||||||
"""Return node health info: hostname and container count."""
|
"""Return node health info: hostname and container count.
|
||||||
containers = self.client.containers.list(all=True)
|
|
||||||
|
Uses the low-level API to avoid NotFound errors from containers
|
||||||
|
stuck in Docker's 'Dead' state.
|
||||||
|
"""
|
||||||
|
containers = [
|
||||||
|
c for c in self.client.api.containers(all=True)
|
||||||
|
if c.get("State") != "dead"
|
||||||
|
]
|
||||||
return {
|
return {
|
||||||
"status": "healthy",
|
"status": "healthy",
|
||||||
"hostname": socket.gethostname(),
|
"hostname": socket.gethostname(),
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ class ContainerInfo(BaseModel):
|
|||||||
uptime: str | None = None
|
uptime: str | None = None
|
||||||
is_swarm: bool = False
|
is_swarm: bool = False
|
||||||
swarm_service: str | None = None
|
swarm_service: str | None = None
|
||||||
|
ports: list[str] = []
|
||||||
|
memory_usage: int = 0
|
||||||
|
memory_limit: int = 0
|
||||||
|
|
||||||
|
|
||||||
class ContainerAction(BaseModel):
|
class ContainerAction(BaseModel):
|
||||||
|
|||||||
+15
-3
@@ -1,7 +1,19 @@
|
|||||||
{
|
{
|
||||||
"nodes": [
|
"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": "hf-pdocker-01",
|
||||||
{"name": "bart", "host": "192.168.86.167", "agent_port": 8889}
|
"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
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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"
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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"
|
||||||
+276
-39
@@ -4,99 +4,336 @@
|
|||||||
"id": "ai-agents",
|
"id": "ai-agents",
|
||||||
"name": "AI Agents",
|
"name": "AI Agents",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-01", "container": "ai-agents_margaret"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "ai-agents_spirit"},
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-01", "container": "ai-agents_bob"},
|
"container": "margaret"
|
||||||
{"node": "hf-pdocker-01", "container": "ai-agents_clippy"}
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "spirit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "hermes"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "smart-home",
|
"id": "smart-home",
|
||||||
"name": "Smart Home",
|
"name": "Smart Home",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-01", "container": "smarthome_homeassistant"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "smarthome_esphome"},
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-01", "container": "smarthome_mosquitto"}
|
"container": "homeassistant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "esphome"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "mosquitto"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "monitoring",
|
"id": "monitoring",
|
||||||
"name": "Monitoring",
|
"name": "Monitoring",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_grafana"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_prometheus"},
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_alertmanager"},
|
"container": "monitoring-grafana-1"
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_influxdb"},
|
},
|
||||||
{"node": "hf-pdocker-01", "container": "monitoring_blackbox-exporter"}
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "monitoring-prometheus-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "monitoring-alertmanager-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "monitoring-blackbox-exporter-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "monitoring-matrix-alertmanager-receiver-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "monitoring-cadvisor-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "influxdb"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "media",
|
"id": "media",
|
||||||
"name": "Media",
|
"name": "Media",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "bart", "container": "jellyfin"},
|
{
|
||||||
{"node": "bart", "container": "sonarr"},
|
"node": "bart",
|
||||||
{"node": "bart", "container": "radarr"},
|
"container": "jellyfin"
|
||||||
{"node": "bart", "container": "sabnzbd"},
|
},
|
||||||
{"node": "bart", "container": "nzbhydra2"},
|
{
|
||||||
{"node": "bart", "container": "jellyseerr"}
|
"node": "bart",
|
||||||
|
"container": "sonarr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "bart",
|
||||||
|
"container": "radarr"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "bart",
|
||||||
|
"container": "sabnzbd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "bart",
|
||||||
|
"container": "nzbhydra2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "bart",
|
||||||
|
"container": "seerr"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "matrix",
|
"id": "matrix",
|
||||||
"name": "Matrix",
|
"name": "Matrix",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-01", "container": "matrix_synapse"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "matrix_synapse-db"},
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-01", "container": "matrix_wellknown"}
|
"container": "synapse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "synapse-db"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "matrix-wellknown"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "mas"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "mas-db"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "synapse-admin"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "guacamole",
|
"id": "guacamole",
|
||||||
"name": "Guacamole",
|
"name": "Guacamole",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-01", "container": "guacamole_guacamole"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "guacamole_guacd"},
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-01", "container": "guacamole_guacamole-db"}
|
"container": "guacamole"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "guacd"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "guacamole-db"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "dev-tools",
|
"id": "dev-tools",
|
||||||
"name": "Dev Tools",
|
"name": "Dev Tools",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-02", "container": "gitea"},
|
{
|
||||||
{"node": "hf-pdocker-02", "container": "gitea-db"},
|
"node": "hf-pdocker-02",
|
||||||
{"node": "hf-pdocker-02", "container": "semaphore"},
|
"container": "gitea"
|
||||||
{"node": "hf-pdocker-02", "container": "semaphore-db"},
|
},
|
||||||
{"node": "hf-pdocker-02", "container": "wikijs"},
|
{
|
||||||
{"node": "hf-pdocker-02", "container": "wikijs-db"}
|
"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",
|
"id": "security",
|
||||||
"name": "Security",
|
"name": "Security",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-02", "container": "vaultwarden"},
|
{
|
||||||
{"node": "hf-pdocker-02", "container": "trivy"},
|
"node": "hf-pdocker-02",
|
||||||
{"node": "bart", "container": "openvas"}
|
"container": "vaultwarden"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-02",
|
||||||
|
"container": "trivy"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "network",
|
"id": "network",
|
||||||
"name": "Network",
|
"name": "Network",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-01", "container": "traefik_traefik"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "adguard_adguardhome"},
|
"node": "hf-pdocker-01",
|
||||||
{"node": "hf-pdocker-02", "container": "unifi"},
|
"container": "traefik"
|
||||||
{"node": "hf-pdocker-02", "container": "unifi-db"}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "frigate",
|
"id": "frigate",
|
||||||
"name": "Frigate",
|
"name": "Frigate",
|
||||||
"services": [
|
"services": [
|
||||||
{"node": "hf-pdocker-01", "container": "frigate"},
|
{
|
||||||
{"node": "hf-pdocker-01", "container": "frigate-notify"}
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "frigate"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "frigate-notify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "clip-service"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "onyx",
|
||||||
|
"name": "Onyx",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-api_server-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-background-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-cache-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-index-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-indexing_model_server-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-inference_model_server-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-nginx-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-relational_db-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-web_server-1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "onyx-opensearch-1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "llm",
|
||||||
|
"name": "LLM",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "litellm"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "litellm-db"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "automation",
|
||||||
|
"name": "Automation & Utilities",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-02",
|
||||||
|
"container": "n8n"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-02",
|
||||||
|
"container": "mcpo"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-02",
|
||||||
|
"container": "searxng"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-02",
|
||||||
|
"container": "unstructured-api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-02",
|
||||||
|
"container": "couchdb"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "home-apps",
|
||||||
|
"name": "Home Apps",
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "linkedstorm-api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "linkedstorm-pipeline"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "linkedstorm-cron"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "rongorongo-web"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "rongorongo-api"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"node": "hf-pdocker-01",
|
||||||
|
"container": "farm-manager"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Configuration loader for the Farm Manager server.
|
||||||
|
|
||||||
|
Reads node definitions from a JSON config file. The path defaults to
|
||||||
|
``/app/config.json`` but can be overridden via the ``CONFIG_PATH``
|
||||||
|
environment variable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 the list of node definitions from the config file."""
|
||||||
|
return _load_config()["nodes"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_url(node_name: str) -> str | None:
|
||||||
|
"""Return the agent base URL for *node_name*, or ``None`` if unknown."""
|
||||||
|
for node in get_nodes():
|
||||||
|
if node["name"] == node_name:
|
||||||
|
return f"http://{node['host']}:{node['agent_port']}"
|
||||||
|
return None
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Singleton store for service groups with JSON file persistence.
|
||||||
|
|
||||||
|
Groups are persisted to a JSON file specified by the ``GROUPS_PATH``
|
||||||
|
environment variable (default ``/app/groups.json``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from server.models import Group, GroupsData
|
||||||
|
|
||||||
|
|
||||||
|
class GroupStore:
|
||||||
|
"""Thread-safe singleton that manages groups and persists to disk."""
|
||||||
|
|
||||||
|
_instance: "GroupStore | None" = None
|
||||||
|
_lock = threading.Lock()
|
||||||
|
|
||||||
|
def __new__(cls) -> "GroupStore":
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
cls._instance._initialized = False
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
if self._initialized:
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
self._data_lock = threading.Lock()
|
||||||
|
self._path = os.environ.get("GROUPS_PATH", "/app/groups.json")
|
||||||
|
self._groups: list[Group] = []
|
||||||
|
self._load()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Persistence
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
"""Load groups from the JSON file on disk."""
|
||||||
|
try:
|
||||||
|
with open(self._path) as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
data = GroupsData(**raw)
|
||||||
|
self._groups = data.groups
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
self._groups = []
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
"""Persist the current groups list to the JSON file."""
|
||||||
|
data = GroupsData(groups=self._groups)
|
||||||
|
with open(self._path, "w") as f:
|
||||||
|
json.dump(data.model_dump(), f, indent=2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# CRUD operations
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def list_groups(self) -> list[Group]:
|
||||||
|
"""Return all groups."""
|
||||||
|
with self._data_lock:
|
||||||
|
return list(self._groups)
|
||||||
|
|
||||||
|
def get_group(self, group_id: str) -> Group | None:
|
||||||
|
"""Return a single group by id, or None."""
|
||||||
|
with self._data_lock:
|
||||||
|
for g in self._groups:
|
||||||
|
if g.id == group_id:
|
||||||
|
return g
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_group(self, group: Group) -> Group:
|
||||||
|
"""Add a new group. Raises ValueError if duplicate id."""
|
||||||
|
with self._data_lock:
|
||||||
|
for g in self._groups:
|
||||||
|
if g.id == group.id:
|
||||||
|
raise ValueError(f"Group '{group.id}' already exists")
|
||||||
|
self._groups.append(group)
|
||||||
|
self._save()
|
||||||
|
return group
|
||||||
|
|
||||||
|
def update_group(self, group_id: str, group: Group) -> Group | None:
|
||||||
|
"""Replace a group by id. Returns updated group or None."""
|
||||||
|
with self._data_lock:
|
||||||
|
for i, g in enumerate(self._groups):
|
||||||
|
if g.id == group_id:
|
||||||
|
self._groups[i] = group
|
||||||
|
self._save()
|
||||||
|
return group
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete_group(self, group_id: str) -> bool:
|
||||||
|
"""Remove a group by id. Returns True if found and deleted."""
|
||||||
|
with self._data_lock:
|
||||||
|
for i, g in enumerate(self._groups):
|
||||||
|
if g.id == group_id:
|
||||||
|
self._groups.pop(i)
|
||||||
|
self._save()
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Farm Manager API server — main FastAPI application."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from server.routes.groups import router as groups_router
|
||||||
|
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(groups_router)
|
||||||
|
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)
|
||||||
@@ -40,3 +40,6 @@ class ServiceInfo(BaseModel):
|
|||||||
uptime: str | None = None
|
uptime: str | None = None
|
||||||
is_swarm: bool = False
|
is_swarm: bool = False
|
||||||
swarm_service: str | None = None
|
swarm_service: str | None = None
|
||||||
|
ports: list[str] = []
|
||||||
|
memory_usage: int = 0
|
||||||
|
memory_limit: int = 0
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"""Group CRUD and bulk action endpoints for the Farm Manager API server."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from server.config import get_node_url
|
||||||
|
from server.group_store import GroupStore
|
||||||
|
from server.models import Group
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_store() -> GroupStore:
|
||||||
|
return GroupStore()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_group(group_id: str) -> Group:
|
||||||
|
"""Return the group or raise 404."""
|
||||||
|
group = _get_store().get_group(group_id)
|
||||||
|
if group is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Group not found: {group_id}")
|
||||||
|
return group
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/api/groups")
|
||||||
|
def list_groups():
|
||||||
|
"""Return all groups."""
|
||||||
|
return [g.model_dump() for g in _get_store().list_groups()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/groups")
|
||||||
|
def create_group(group: Group):
|
||||||
|
"""Create a new group. Returns 409 if duplicate id."""
|
||||||
|
try:
|
||||||
|
created = _get_store().create_group(group)
|
||||||
|
return created.model_dump()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=409, detail=f"Group '{group.id}' already exists")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/groups/{group_id}")
|
||||||
|
def update_group(group_id: str, group: Group):
|
||||||
|
"""Update an existing group by id. Returns 404 if not found."""
|
||||||
|
updated = _get_store().update_group(group_id, group)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Group not found: {group_id}")
|
||||||
|
return updated.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/groups/{group_id}")
|
||||||
|
def delete_group(group_id: str):
|
||||||
|
"""Delete a group by id. Returns 404 if not found."""
|
||||||
|
if not _get_store().delete_group(group_id):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Group not found: {group_id}")
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Bulk actions — start/stop/restart all services in a group
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _send_action(
|
||||||
|
http_client: httpx.AsyncClient, node: str, container: str, action: str
|
||||||
|
) -> dict:
|
||||||
|
"""Send a start/stop/restart action to a single container via its node agent."""
|
||||||
|
url = get_node_url(node)
|
||||||
|
if url is None:
|
||||||
|
return {"node": node, "container": container, "success": False, "error": f"Unknown node: {node}"}
|
||||||
|
try:
|
||||||
|
resp = await http_client.post(f"{url}/containers/{container}/{action}", timeout=30.0)
|
||||||
|
return {"node": node, "container": container, **resp.json()}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"node": node, "container": container, "success": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _bulk_action(group_id: str, action: str) -> list[dict]:
|
||||||
|
"""Execute *action* on every service in the group."""
|
||||||
|
group = _require_group(group_id)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
tasks = [
|
||||||
|
_send_action(client, svc.node, svc.container, action)
|
||||||
|
for svc in group.services
|
||||||
|
]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
return list(results)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/groups/{group_id}/start")
|
||||||
|
async def start_group(group_id: str):
|
||||||
|
"""Start all services in a group."""
|
||||||
|
return await _bulk_action(group_id, "start")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/groups/{group_id}/stop")
|
||||||
|
async def stop_group(group_id: str):
|
||||||
|
"""Stop all services in a group."""
|
||||||
|
return await _bulk_action(group_id, "stop")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/groups/{group_id}/restart")
|
||||||
|
async def restart_group(group_id: str):
|
||||||
|
"""Restart all services in a group."""
|
||||||
|
return await _bulk_action(group_id, "restart")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Node health endpoints for the Farm Manager API server."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from server.config import get_nodes
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_node_health(http_client: httpx.AsyncClient, node: dict) -> dict:
|
||||||
|
"""Query a single agent's /health endpoint and return a NodeStatus dict."""
|
||||||
|
url = f"http://{node['host']}:{node['agent_port']}/health"
|
||||||
|
try:
|
||||||
|
resp = await http_client.get(url, timeout=5.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return {
|
||||||
|
"name": node["name"],
|
||||||
|
"host": node["host"],
|
||||||
|
"healthy": True,
|
||||||
|
"containers_total": data.get("containers_total", 0),
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {
|
||||||
|
"name": node["name"],
|
||||||
|
"host": node["host"],
|
||||||
|
"healthy": False,
|
||||||
|
"containers_total": 0,
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/nodes")
|
||||||
|
async def list_nodes():
|
||||||
|
"""Return all configured nodes with their current health status."""
|
||||||
|
nodes = get_nodes()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
tasks = [_check_node_health(client, node) for node in nodes]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
return list(results)
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Service proxy endpoints for the Farm Manager API server.
|
||||||
|
|
||||||
|
Each endpoint proxies to the appropriate node agent based on the
|
||||||
|
``{node}`` path parameter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
from server.config import get_nodes, get_node_url
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_node_url(node: str) -> str:
|
||||||
|
"""Return the agent URL for *node* or raise 404."""
|
||||||
|
url = get_node_url(node)
|
||||||
|
if url is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown node: {node}")
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/services — aggregate containers from all nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _fetch_containers(http_client: httpx.AsyncClient, node: dict) -> list[dict]:
|
||||||
|
"""Fetch containers from a single node agent, adding the node field."""
|
||||||
|
url = f"http://{node['host']}:{node['agent_port']}/containers"
|
||||||
|
try:
|
||||||
|
resp = await http_client.get(url, timeout=30.0)
|
||||||
|
resp.raise_for_status()
|
||||||
|
containers = resp.json()
|
||||||
|
for c in containers:
|
||||||
|
c["node"] = node["name"]
|
||||||
|
return containers
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/services")
|
||||||
|
async def list_services():
|
||||||
|
"""Return containers from all nodes with a ``node`` field added."""
|
||||||
|
nodes = get_nodes()
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
tasks = [_fetch_containers(client, node) for node in nodes]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
# Flatten list of lists
|
||||||
|
all_containers = []
|
||||||
|
for node_containers in results:
|
||||||
|
all_containers.extend(node_containers)
|
||||||
|
return all_containers
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Container actions — proxy to the correct agent
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post("/api/services/{node}/{container_id}/start")
|
||||||
|
async def start_service(node: str, container_id: str):
|
||||||
|
"""Proxy start request to the agent on *node*."""
|
||||||
|
base = _require_node_url(node)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(f"{base}/containers/{container_id}/start", timeout=30.0)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/services/{node}/{container_id}/stop")
|
||||||
|
async def stop_service(node: str, container_id: str):
|
||||||
|
"""Proxy stop request to the agent on *node*."""
|
||||||
|
base = _require_node_url(node)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(f"{base}/containers/{container_id}/stop", timeout=30.0)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/services/{node}/{container_id}/restart")
|
||||||
|
async def restart_service(node: str, container_id: str):
|
||||||
|
"""Proxy restart request to the agent on *node*."""
|
||||||
|
base = _require_node_url(node)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(f"{base}/containers/{container_id}/restart", timeout=30.0)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/services/{node}/{container_id}/logs")
|
||||||
|
async def get_service_logs(node: str, container_id: str, tail: int = Query(default=200)):
|
||||||
|
"""Proxy logs request to the agent on *node*."""
|
||||||
|
base = _require_node_url(node)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{base}/containers/{container_id}/logs",
|
||||||
|
params={"tail": tail},
|
||||||
|
timeout=10.0,
|
||||||
|
)
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/services/{node}/{container_id}/pull")
|
||||||
|
async def pull_service(node: str, container_id: str):
|
||||||
|
"""Proxy pull request to the agent on *node*."""
|
||||||
|
base = _require_node_url(node)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(f"{base}/containers/{container_id}/pull", timeout=60.0)
|
||||||
|
return resp.json()
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Farm Manager</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ===== Header ===== -->
|
||||||
|
<header id="header">
|
||||||
|
<h1>Farm Manager</h1>
|
||||||
|
<div class="header-right">
|
||||||
|
<span id="refresh-indicator" class="refresh-indicator" title="Auto-refreshing every 15s">
|
||||||
|
<span class="refresh-dot"></span> Auto-refresh
|
||||||
|
</span>
|
||||||
|
<span id="last-updated" class="last-updated"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ===== Sidebar ===== -->
|
||||||
|
<nav id="sidebar">
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<a href="#" class="sidebar-item active" id="nav-all" data-view="all">
|
||||||
|
<span class="sidebar-icon">☰</span> All Services
|
||||||
|
</a>
|
||||||
|
<a href="#" class="sidebar-item" id="nav-links" data-view="links">
|
||||||
|
<span class="sidebar-icon">🔗</span> Server Links
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-heading">Nodes</div>
|
||||||
|
<div id="node-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-section">
|
||||||
|
<div class="sidebar-heading">
|
||||||
|
Groups
|
||||||
|
<button class="btn-icon" id="btn-add-group" title="Create group">✚</button>
|
||||||
|
</div>
|
||||||
|
<div id="group-list"></div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ===== Main Content ===== -->
|
||||||
|
<main id="main">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div id="toolbar" class="toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<h2 id="view-title">All Services</h2>
|
||||||
|
<span id="service-count" class="service-count"></span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-center" id="group-actions" style="display:none;">
|
||||||
|
<button class="btn btn-success btn-sm" id="btn-group-start" title="Start all in group">▶ Start All</button>
|
||||||
|
<button class="btn btn-danger btn-sm" id="btn-group-stop" title="Stop all in group">■ Stop All</button>
|
||||||
|
<button class="btn btn-warning btn-sm" id="btn-group-restart" title="Restart all in group">↻ Restart All</button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-filters" id="status-filters">
|
||||||
|
<button class="filter-pill active" data-filter="all">All</button>
|
||||||
|
<button class="filter-pill" data-filter="running">Running <span id="count-running" class="filter-count"></span></button>
|
||||||
|
<button class="filter-pill" data-filter="stopped">Stopped <span id="count-stopped" class="filter-count"></span></button>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<input type="text" id="search-input" class="search-input" placeholder="Search services...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Service Grid -->
|
||||||
|
<div id="service-grid" class="service-grid"></div>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div id="empty-state" class="empty-state" style="display:none;">
|
||||||
|
<span class="empty-icon">📦</span>
|
||||||
|
<p>No services found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Links view -->
|
||||||
|
<div id="links-view" style="display:none;"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ===== Log Viewer (slide-out panel) ===== -->
|
||||||
|
<div id="log-overlay" class="log-overlay" style="display:none;"></div>
|
||||||
|
<div id="log-panel" class="log-panel">
|
||||||
|
<div class="log-header">
|
||||||
|
<h3 id="log-title">Container Logs</h3>
|
||||||
|
<button class="btn-icon log-close" id="btn-close-logs" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<pre id="log-content" class="log-content"></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Group Editor Modal ===== -->
|
||||||
|
<div id="group-modal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 id="group-modal-title">Create Group</h3>
|
||||||
|
<button class="btn-icon" id="btn-close-group-modal" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="group-name-input">Group Name</label>
|
||||||
|
<input type="text" id="group-name-input" class="form-input" placeholder="e.g. Media Stack">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Services</label>
|
||||||
|
<input type="text" id="group-service-search" class="form-input" placeholder="Filter services...">
|
||||||
|
<div id="group-service-list" class="group-service-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="btn-cancel-group">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="btn-save-group">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Confirmation Modal ===== -->
|
||||||
|
<div id="confirm-modal" class="modal-overlay" style="display:none;">
|
||||||
|
<div class="modal modal-sm">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>Confirm Action</h3>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="confirm-message"></p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" id="btn-confirm-cancel">Cancel</button>
|
||||||
|
<button class="btn btn-danger" id="btn-confirm-ok">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Toast Container ===== -->
|
||||||
|
<div id="toast-container" class="toast-container"></div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,110 @@
|
|||||||
|
"""Tests for Group CRUD and bulk action endpoints.
|
||||||
|
|
||||||
|
Follows TDD: tests written first, then implementation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_config():
|
||||||
|
os.environ["CONFIG_PATH"] = os.path.join(os.path.dirname(__file__), "..", "config.json")
|
||||||
|
from server.config import _load_config
|
||||||
|
import server.config
|
||||||
|
server.config._config = None
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def groups_file():
|
||||||
|
f = tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False)
|
||||||
|
json.dump({"groups": []}, f)
|
||||||
|
f.close()
|
||||||
|
os.environ["GROUPS_PATH"] = f.name
|
||||||
|
yield f.name
|
||||||
|
os.unlink(f.name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(groups_file):
|
||||||
|
from server.group_store import GroupStore
|
||||||
|
GroupStore._instance = None
|
||||||
|
from server.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestListGroups:
|
||||||
|
def test_list_empty(self, client):
|
||||||
|
resp = client.get("/api/groups")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json() == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateGroup:
|
||||||
|
def test_create(self, client):
|
||||||
|
resp = client.post("/api/groups", json={
|
||||||
|
"id": "test", "name": "Test", "services": [{"node": "hf-pdocker-01", "container": "test"}]
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["id"] == "test"
|
||||||
|
|
||||||
|
def test_create_then_list(self, client):
|
||||||
|
client.post("/api/groups", json={"id": "g1", "name": "G1", "services": []})
|
||||||
|
resp = client.get("/api/groups")
|
||||||
|
assert len(resp.json()) == 1
|
||||||
|
assert resp.json()[0]["name"] == "G1"
|
||||||
|
|
||||||
|
def test_create_duplicate_409(self, client):
|
||||||
|
client.post("/api/groups", json={"id": "g1", "name": "G1", "services": []})
|
||||||
|
resp = client.post("/api/groups", json={"id": "g1", "name": "Dup", "services": []})
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateGroup:
|
||||||
|
def test_update(self, client):
|
||||||
|
client.post("/api/groups", json={"id": "g1", "name": "G1", "services": []})
|
||||||
|
resp = client.put("/api/groups/g1", json={
|
||||||
|
"id": "g1", "name": "Updated", "services": [{"node": "bart", "container": "jellyfin"}]
|
||||||
|
})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["name"] == "Updated"
|
||||||
|
assert len(resp.json()["services"]) == 1
|
||||||
|
|
||||||
|
def test_update_nonexistent_404(self, client):
|
||||||
|
resp = client.put("/api/groups/nope", json={"id": "nope", "name": "N", "services": []})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteGroup:
|
||||||
|
def test_delete(self, client):
|
||||||
|
client.post("/api/groups", json={"id": "g1", "name": "G1", "services": []})
|
||||||
|
resp = client.delete("/api/groups/g1")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert len(client.get("/api/groups").json()) == 0
|
||||||
|
|
||||||
|
def test_delete_nonexistent_404(self, client):
|
||||||
|
resp = client.delete("/api/groups/nope")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistence:
|
||||||
|
def test_persists_to_file(self, client, groups_file):
|
||||||
|
client.post("/api/groups", json={"id": "g1", "name": "G1", "services": []})
|
||||||
|
with open(groups_file) as f:
|
||||||
|
data = json.load(f)
|
||||||
|
assert len(data["groups"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestBulkActions:
|
||||||
|
def test_start_nonexistent_group_404(self, client):
|
||||||
|
assert client.post("/api/groups/nope/start").status_code == 404
|
||||||
|
|
||||||
|
def test_stop_nonexistent_group_404(self, client):
|
||||||
|
assert client.post("/api/groups/nope/stop").status_code == 404
|
||||||
|
|
||||||
|
def test_restart_nonexistent_group_404(self, client):
|
||||||
|
assert client.post("/api/groups/nope/restart").status_code == 404
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""Tests for server API proxy routes — nodes and services.
|
||||||
|
|
||||||
|
Mocks httpx.AsyncClient to avoid real network calls to agents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch, MagicMock
|
||||||
|
|
||||||
|
os.environ.setdefault("CONFIG_PATH", os.path.join(
|
||||||
|
os.path.dirname(__file__), "..", "config.json"
|
||||||
|
))
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_config():
|
||||||
|
"""Reset the config module cache between tests."""
|
||||||
|
import server.config as cfg
|
||||||
|
cfg._config = None
|
||||||
|
yield
|
||||||
|
cfg._config = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
from server.main import app
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mock helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _mock_response(json_data, status_code=200):
|
||||||
|
"""Build a MagicMock httpx.Response with sync .json() method."""
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status_code = status_code
|
||||||
|
resp.json.return_value = json_data
|
||||||
|
resp.raise_for_status = MagicMock()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _make_mock_client(**side_effects):
|
||||||
|
"""Create a mock httpx.AsyncClient context manager.
|
||||||
|
|
||||||
|
*side_effects* maps method names ('get', 'post') to lists of
|
||||||
|
return values or Exception instances for side_effect.
|
||||||
|
"""
|
||||||
|
mock_instance = AsyncMock()
|
||||||
|
for method, values in side_effects.items():
|
||||||
|
getattr(mock_instance, method).side_effect = values
|
||||||
|
return mock_instance
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_httpx(module_path, mock_instance):
|
||||||
|
"""Return a context-manager patch for httpx.AsyncClient in *module_path*."""
|
||||||
|
patcher = patch(f"{module_path}.httpx.AsyncClient")
|
||||||
|
MockClient = patcher.start()
|
||||||
|
MockClient.return_value.__aenter__ = AsyncMock(return_value=mock_instance)
|
||||||
|
MockClient.return_value.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
return patcher
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetNodes:
|
||||||
|
|
||||||
|
def test_returns_all_three_nodes(self, client):
|
||||||
|
"""GET /api/nodes returns a list of 3 nodes with health status."""
|
||||||
|
mock = _make_mock_client(get=[
|
||||||
|
_mock_response({"status": "healthy", "hostname": "hf-pdocker-01", "containers_total": 10}),
|
||||||
|
_mock_response({"status": "healthy", "hostname": "hf-pdocker-02", "containers_total": 5}),
|
||||||
|
_mock_response({"status": "healthy", "hostname": "bart", "containers_total": 8}),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.nodes", mock)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/nodes")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 3
|
||||||
|
names = [n["name"] for n in data]
|
||||||
|
assert "hf-pdocker-01" in names
|
||||||
|
assert "hf-pdocker-02" in names
|
||||||
|
assert "bart" in names
|
||||||
|
|
||||||
|
def test_node_contains_health_fields(self, client):
|
||||||
|
"""Each node has name, host, healthy, and containers_total."""
|
||||||
|
mock = _make_mock_client(get=[
|
||||||
|
_mock_response({"status": "healthy", "hostname": "hf-pdocker-01", "containers_total": 10}),
|
||||||
|
_mock_response({"status": "healthy", "hostname": "hf-pdocker-02", "containers_total": 5}),
|
||||||
|
_mock_response({"status": "healthy", "hostname": "bart", "containers_total": 8}),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.nodes", mock)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/nodes")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
node = data[0]
|
||||||
|
assert "name" in node
|
||||||
|
assert "host" in node
|
||||||
|
assert "healthy" in node
|
||||||
|
assert "containers_total" in node
|
||||||
|
|
||||||
|
def test_unhealthy_node_on_agent_error(self, client):
|
||||||
|
"""If an agent is unreachable, the node shows healthy=False."""
|
||||||
|
mock = _make_mock_client(get=[
|
||||||
|
_mock_response({"status": "healthy", "hostname": "hf-pdocker-01", "containers_total": 10}),
|
||||||
|
Exception("Connection refused"),
|
||||||
|
_mock_response({"status": "healthy", "hostname": "bart", "containers_total": 8}),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.nodes", mock)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/nodes")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
assert resp.status_code == 200
|
||||||
|
unhealthy = [n for n in data if n["name"] == "hf-pdocker-02"]
|
||||||
|
assert len(unhealthy) == 1
|
||||||
|
assert unhealthy[0]["healthy"] is False
|
||||||
|
assert unhealthy[0]["error"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/services
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetServices:
|
||||||
|
|
||||||
|
def test_returns_containers_from_all_nodes(self, client):
|
||||||
|
"""GET /api/services aggregates containers from all nodes, adding node field."""
|
||||||
|
containers_node1 = [
|
||||||
|
{"id": "abc1", "name": "app1", "status": "running",
|
||||||
|
"image": "nginx:latest", "created": "2026-03-05T00:00:00Z",
|
||||||
|
"uptime": "1h", "is_swarm": False, "swarm_service": None},
|
||||||
|
]
|
||||||
|
containers_node2 = [
|
||||||
|
{"id": "abc2", "name": "app2", "status": "running",
|
||||||
|
"image": "redis:latest", "created": "2026-03-05T00:00:00Z",
|
||||||
|
"uptime": "2h", "is_swarm": False, "swarm_service": None},
|
||||||
|
]
|
||||||
|
containers_node3 = [
|
||||||
|
{"id": "abc3", "name": "app3", "status": "exited",
|
||||||
|
"image": "postgres:15", "created": "2026-03-05T00:00:00Z",
|
||||||
|
"uptime": None, "is_swarm": False, "swarm_service": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock = _make_mock_client(get=[
|
||||||
|
_mock_response(containers_node1),
|
||||||
|
_mock_response(containers_node2),
|
||||||
|
_mock_response(containers_node3),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.services", mock)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/services")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 3
|
||||||
|
nodes = [c["node"] for c in data]
|
||||||
|
assert "hf-pdocker-01" in nodes
|
||||||
|
assert "hf-pdocker-02" in nodes
|
||||||
|
assert "bart" in nodes
|
||||||
|
|
||||||
|
def test_skips_unreachable_node(self, client):
|
||||||
|
"""If a node agent is down, its containers are omitted."""
|
||||||
|
containers_node1 = [
|
||||||
|
{"id": "abc1", "name": "app1", "status": "running",
|
||||||
|
"image": "nginx:latest", "created": "2026-03-05T00:00:00Z",
|
||||||
|
"uptime": "1h", "is_swarm": False, "swarm_service": None},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock = _make_mock_client(get=[
|
||||||
|
_mock_response(containers_node1),
|
||||||
|
Exception("Connection refused"),
|
||||||
|
_mock_response([]),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.services", mock)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/services")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert len(data) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/services/{node}/{container_id}/start
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestStartService:
|
||||||
|
|
||||||
|
def test_start_proxies_to_agent(self, client):
|
||||||
|
"""POST start sends request to correct agent and returns result."""
|
||||||
|
mock = _make_mock_client(post=[
|
||||||
|
_mock_response({"success": True, "message": "Started container app1"}),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.services", mock)
|
||||||
|
try:
|
||||||
|
resp = client.post("/api/services/hf-pdocker-01/abc123/start")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["success"] is True
|
||||||
|
|
||||||
|
def test_start_invalid_node_returns_404(self, client):
|
||||||
|
"""POST start with unknown node returns 404."""
|
||||||
|
resp = client.post("/api/services/nonexistent-node/abc123/start")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/services/{node}/{container_id}/stop
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestStopService:
|
||||||
|
|
||||||
|
def test_stop_proxies_to_agent(self, client):
|
||||||
|
"""POST stop sends request to correct agent."""
|
||||||
|
mock = _make_mock_client(post=[
|
||||||
|
_mock_response({"success": True, "message": "Stopped container app1"}),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.services", mock)
|
||||||
|
try:
|
||||||
|
resp = client.post("/api/services/bart/abc123/stop")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["success"] is True
|
||||||
|
|
||||||
|
def test_stop_invalid_node_returns_404(self, client):
|
||||||
|
resp = client.post("/api/services/nonexistent-node/abc123/stop")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/services/{node}/{container_id}/restart
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRestartService:
|
||||||
|
|
||||||
|
def test_restart_proxies_to_agent(self, client):
|
||||||
|
mock = _make_mock_client(post=[
|
||||||
|
_mock_response({"success": True, "message": "Restarted container app1"}),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.services", mock)
|
||||||
|
try:
|
||||||
|
resp = client.post("/api/services/hf-pdocker-02/abc123/restart")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["success"] is True
|
||||||
|
|
||||||
|
def test_restart_invalid_node_returns_404(self, client):
|
||||||
|
resp = client.post("/api/services/nonexistent-node/abc123/restart")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# GET /api/services/{node}/{container_id}/logs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetServiceLogs:
|
||||||
|
|
||||||
|
def test_logs_proxies_to_agent(self, client):
|
||||||
|
mock = _make_mock_client(get=[
|
||||||
|
_mock_response({"container": "test-app", "logs": "log line 1\nlog line 2"}),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.services", mock)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/services/hf-pdocker-01/abc123/logs")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "log line 1" in resp.json()["logs"]
|
||||||
|
|
||||||
|
def test_logs_passes_tail_param(self, client):
|
||||||
|
mock = _make_mock_client(get=[
|
||||||
|
_mock_response({"container": "test-app", "logs": "log data"}),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.services", mock)
|
||||||
|
try:
|
||||||
|
resp = client.get("/api/services/hf-pdocker-01/abc123/logs?tail=50")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
# Verify tail param was passed through to the agent
|
||||||
|
call_args = mock.get.call_args
|
||||||
|
assert "tail" in str(call_args)
|
||||||
|
|
||||||
|
def test_logs_invalid_node_returns_404(self, client):
|
||||||
|
resp = client.get("/api/services/nonexistent-node/abc123/logs")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/services/{node}/{container_id}/pull
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPullService:
|
||||||
|
|
||||||
|
def test_pull_proxies_to_agent(self, client):
|
||||||
|
mock = _make_mock_client(post=[
|
||||||
|
_mock_response({"success": True, "message": "Pulled nginx:latest"}),
|
||||||
|
])
|
||||||
|
|
||||||
|
p = _patch_httpx("server.routes.services", mock)
|
||||||
|
try:
|
||||||
|
resp = client.post("/api/services/bart/abc123/pull")
|
||||||
|
finally:
|
||||||
|
p.stop()
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.json()["success"] is True
|
||||||
|
|
||||||
|
def test_pull_invalid_node_returns_404(self, client):
|
||||||
|
resp = client.post("/api/services/nonexistent-node/abc123/pull")
|
||||||
|
assert resp.status_code == 404
|
||||||
Reference in New Issue
Block a user