From 95afddb7720b835ee0282f103045dde770db2d18 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 5 May 2026 19:35:28 -0700 Subject: [PATCH] feat: add nodes.py scaffold with set_config_dir and router mount - Create app/nodes.py with _CONFIG_DIR testability seam, _load_config, _profiles_dir, _profile_path, _load_profile, _get_ollama_url helpers, and stub list_nodes endpoint returning [] when no coordinator_url is set - Mount nodes router at /api/nodes-mgmt in app/api.py - Add profiles_dir comment to config/label_tool.yaml.example cforch section - Create tests/test_nodes.py with autouse fixture and two passing tests --- app/api.py | 3 + app/nodes.py | 117 +++++++++++++++++++++++++++++++++ config/label_tool.yaml.example | 4 ++ tests/test_nodes.py | 49 ++++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 app/nodes.py create mode 100644 tests/test_nodes.py diff --git a/app/api.py b/app/api.py index eb2e151..a1334fb 100644 --- a/app/api.py +++ b/app/api.py @@ -46,6 +46,9 @@ app.include_router(dashboard_router, prefix="/api") from app.models import router as models_router app.include_router(models_router, prefix="/api/models") +from app.nodes import router as nodes_router +app.include_router(nodes_router, prefix="/api/nodes-mgmt") + # -- Static SPA -- MUST be last (catches all unmatched paths) --------------- _ROOT = Path(__file__).parent.parent diff --git a/app/nodes.py b/app/nodes.py new file mode 100644 index 0000000..a009bce --- /dev/null +++ b/app/nodes.py @@ -0,0 +1,117 @@ +"""Avocet — Node Management API. + +Proxies cf-orch coordinator and agent APIs to expose per-node GPU state, +service affinity management, and Ollama model management. + +Config is read from label_tool.yaml under the `cforch:` key. +The `profiles_dir` key (new) points to the cf-orch node profile YAML directory. + +Module-level globals follow the set_config_dir() testability pattern from cforch.py. +""" +from __future__ import annotations + +import logging +from pathlib import Path +from urllib.parse import urlparse + +import yaml +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse + +logger = logging.getLogger(__name__) + +_ROOT = Path(__file__).parent.parent +_CONFIG_DIR: Path | None = None # override in tests + +router = APIRouter() + + +# ── Testability seams ────────────────────────────────────────────────────────── + +def set_config_dir(path: Path | None) -> None: + global _CONFIG_DIR + _CONFIG_DIR = path + + +# ── Internal helpers ─────────────────────────────────────────────────────────── + +def _config_file() -> Path: + if _CONFIG_DIR is not None: + return _CONFIG_DIR / "label_tool.yaml" + return _ROOT / "config" / "label_tool.yaml" + + +def _load_config() -> dict: + """Read label_tool.yaml cforch section. Returns empty dict on missing or parse error.""" + f = _config_file() + if not f.exists(): + return {} + try: + raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + return raw.get("cforch", {}) or {} + except yaml.YAMLError as exc: + logger.warning("Failed to parse config %s: %s", f, exc) + return {} + + +def _profiles_dir() -> Path | None: + """Return the cf-orch node profiles directory, or None if not configured.""" + cfg = _load_config() + pd = cfg.get("profiles_dir", "") or "" + if pd: + return Path(pd) + bench = cfg.get("bench_script", "") or "" + if bench: + return Path(bench).parent.parent / "profiles" / "nodes" + return None + + +def _profile_path(node_id: str) -> Path | None: + """Return the path to a node's profile YAML, or None if profiles_dir is unknown.""" + pd = _profiles_dir() + if pd is None: + return None + return pd / f"{node_id}.yaml" + + +def _load_profile(node_id: str) -> dict | None: + """Load and parse a node profile YAML. Returns None if not found or malformed.""" + p = _profile_path(node_id) + if p is None or not p.exists(): + return None + try: + return yaml.safe_load(p.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as exc: + logger.warning("Malformed profile YAML %s: %s", p, exc) + return None + + +def _get_ollama_url(node_id: str) -> str: + """Derive Ollama URL from the node profile's agent_url (same host, port 11434).""" + profile = _load_profile(node_id) + if profile: + nodes_section = profile.get("nodes", {}) or {} + node_entry = nodes_section.get(node_id, {}) or {} + agent_url = node_entry.get("agent_url", "") or "" + if agent_url: + parsed = urlparse(agent_url) + return f"{parsed.scheme}://{parsed.hostname}:11434" + raise HTTPException( + status_code=404, + detail=f"Cannot determine Ollama URL for node {node_id} — no agent_url in profile", + ) + + +# ── Endpoints ────────────────────────────────────────────────────────────────── + +@router.get("/nodes") +def list_nodes() -> list: + """List all nodes visible to the cf-orch coordinator. + + Returns an empty list if no coordinator_url is configured. + Full implementation arrives in Task 2 (live coordinator proxy). + """ + cfg = _load_config() + if not cfg.get("coordinator_url"): + return [] + return [] # full implementation in Task 2 diff --git a/config/label_tool.yaml.example b/config/label_tool.yaml.example index 16ee12c..ac41aa8 100644 --- a/config/label_tool.yaml.example +++ b/config/label_tool.yaml.example @@ -52,6 +52,10 @@ cforch: # Or set CF_JUDGE_URL. Populates the Judge URL field in the LLM Eval UI automatically. # hf_token: hf_xxxxxxxxxxxxxxxxxxxx # HuggingFace token — required for gated/terms-restricted models + # Directory containing per-node profile YAMLs (cf-orch node profiles). + # Default: derived from bench_script location (../../profiles/nodes). + # profiles_dir: /Library/Development/CircuitForge/circuitforge-orch/circuitforge_orch/profiles/nodes + # Imitate tab — pull real samples from sibling CF product APIs and run them # through local LLMs to build a corrections dataset. # ollama_url defaults to cforch.ollama_url if omitted here. diff --git a/tests/test_nodes.py b/tests/test_nodes.py new file mode 100644 index 0000000..b55a146 --- /dev/null +++ b/tests/test_nodes.py @@ -0,0 +1,49 @@ +"""Tests for app/nodes.py — /api/nodes-mgmt/* endpoints.""" +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +import yaml +from fastapi.testclient import TestClient + + +@pytest.fixture(autouse=True) +def reset_nodes_globals(tmp_path): + """Redirect _CONFIG_DIR to tmp_path so tests never read the real config.""" + from app import nodes as nodes_module + prev = nodes_module._CONFIG_DIR + nodes_module.set_config_dir(tmp_path) + yield tmp_path + nodes_module.set_config_dir(prev) + + +@pytest.fixture +def client(): + from app.api import app + return TestClient(app) + + +def _write_config(config_dir: Path, cforch_cfg: dict) -> None: + cfg = {"cforch": cforch_cfg} + (config_dir / "label_tool.yaml").write_text(yaml.dump(cfg), encoding="utf-8") + + +def _write_profile(profiles_dir: Path, node_id: str, profile: dict) -> None: + profiles_dir.mkdir(parents=True, exist_ok=True) + (profiles_dir / f"{node_id}.yaml").write_text(yaml.dump(profile), encoding="utf-8") + + +def test_nodes_module_imports(): + from app import nodes + assert hasattr(nodes, "router") + assert hasattr(nodes, "set_config_dir") + + +def test_list_nodes_returns_empty_when_no_coordinator(client): + """No cforch config — endpoint returns empty list, not 500.""" + r = client.get("/api/nodes-mgmt/nodes") + assert r.status_code == 200 + assert r.json() == []