From 6e9465942a7b4cb3505e6c318b7410bf89fd1691 Mon Sep 17 00:00:00 2001 From: Pluto Date: Thu, 5 Mar 2026 22:26:20 -0600 Subject: [PATCH] feat(server): Group CRUD with JSON persistence and bulk actions (TDD) Co-Authored-By: Claude Opus 4.6 --- server/group_store.py | 101 +++++++++++++++++++++++++++++++++ server/main.py | 2 + server/routes/groups.py | 109 +++++++++++++++++++++++++++++++++++ tests/test_server_groups.py | 110 ++++++++++++++++++++++++++++++++++++ 4 files changed, 322 insertions(+) create mode 100644 server/group_store.py create mode 100644 server/routes/groups.py create mode 100644 tests/test_server_groups.py diff --git a/server/group_store.py b/server/group_store.py new file mode 100644 index 0000000..48a0ca3 --- /dev/null +++ b/server/group_store.py @@ -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 diff --git a/server/main.py b/server/main.py index af1f895..5e6ccf6 100644 --- a/server/main.py +++ b/server/main.py @@ -5,10 +5,12 @@ 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) diff --git a/server/routes/groups.py b/server/routes/groups.py new file mode 100644 index 0000000..4042da5 --- /dev/null +++ b/server/routes/groups.py @@ -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") diff --git a/tests/test_server_groups.py b/tests/test_server_groups.py new file mode 100644 index 0000000..4f6e5ec --- /dev/null +++ b/tests/test_server_groups.py @@ -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