feat(server): Group CRUD with JSON persistence and bulk actions (TDD)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
@@ -5,10 +5,12 @@ import os
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.staticfiles import StaticFiles
|
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.nodes import router as nodes_router
|
||||||
from server.routes.services import router as services_router
|
from server.routes.services import router as services_router
|
||||||
|
|
||||||
app = FastAPI(title="Farm Manager")
|
app = FastAPI(title="Farm Manager")
|
||||||
|
app.include_router(groups_router)
|
||||||
app.include_router(nodes_router)
|
app.include_router(nodes_router)
|
||||||
app.include_router(services_router)
|
app.include_router(services_router)
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
Reference in New Issue
Block a user