feat(server): API proxy routes for services and nodes (TDD)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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,27 @@
|
||||
"""Farm Manager API server — main FastAPI application."""
|
||||
|
||||
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)
|
||||
@@ -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=10.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()
|
||||
Reference in New Issue
Block a user