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
This commit is contained in:
parent
cbe8c0f03e
commit
95afddb772
4 changed files with 173 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
117
app/nodes.py
Normal file
117
app/nodes.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
49
tests/test_nodes.py
Normal file
49
tests/test_nodes.py
Normal file
|
|
@ -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() == []
|
||||
Loading…
Reference in a new issue