refactor: Align vocabulary — component → program / deployment

Canonical terms (see docs/registry.md glossary):
- program: any catalog entry (tool/daemon/frontend) — the software
- service / job: how a program is deployed (systemd .service / .timer)
- deployment: umbrella for the unified service+job+program view

Changes:
- core: ServiceSpec/JobSpec component: → program: (component accepted as
  back-compat validation_alias); DeployedComponent → Deployment.
- api: ComponentSummary/Detail → DeploymentSummary/Detail; GET /components
  → /deployments; GatewayRoute.component → program; GatewayInfo
  .component_count → deployment_count; ServiceActionResponse/action keys
  component → program.
- app: matching type renames, /components → /deployments hook, route
  /component/:name → /deployment/:name, GatewayRoute.program.
- docs: component-registry.md → registry.md (+ canonical glossary);
  CLAUDE.md endpoint list refreshed (drop removed /tools, add typed
  program/service/job + config routes).

Tests: core 92, cli 24, api 52 green; app build clean; ruff clean.
This commit is contained in:
2026-06-14 11:05:22 -07:00
parent 482524bfe5
commit 3f3b88f17b
44 changed files with 279 additions and 237 deletions

View File

@@ -65,7 +65,7 @@ async def get_logs(
)
stdout, _ = await proc.communicate()
lines = (stdout or b"").decode().splitlines()
return {"component": name, "lines": lines}
return {"name": name, "lines": lines}
async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:

View File

@@ -11,7 +11,7 @@ class SystemdInfo(BaseModel):
timer: bool = False
class ComponentSummary(BaseModel):
class DeploymentSummary(BaseModel):
"""Summary of a single component."""
id: str
@@ -37,7 +37,7 @@ class ComponentSummary(BaseModel):
node: str | None = None
class ComponentDetail(ComponentSummary):
class DeploymentDetail(DeploymentSummary):
"""Full detail for a single component, including raw manifest."""
manifest: dict
@@ -130,7 +130,7 @@ class GatewayRoute(BaseModel):
path: str
target_port: int
component: str
program: str
node: str
@@ -139,7 +139,7 @@ class GatewayInfo(BaseModel):
port: int
hostname: str
component_count: int
deployment_count: int
service_count: int
managed_count: int
routes: list[GatewayRoute] = []
@@ -161,7 +161,7 @@ class NodeSummary(BaseModel):
class NodeDetail(NodeSummary):
"""Full detail for a node, including its deployed components."""
deployed: list[ComponentSummary] = []
deployed: list[DeploymentSummary] = []
class MeshStatus(BaseModel):
@@ -179,6 +179,6 @@ class MeshStatus(BaseModel):
class ServiceActionResponse(BaseModel):
"""Response from a service management action."""
component: str
program: str
action: str
status: str

View File

@@ -16,7 +16,7 @@ import logging
import paho.mqtt.client as mqtt
from castle_core.registry import (
DeployedComponent,
Deployment,
NodeConfig,
NodeRegistry,
)
@@ -74,9 +74,9 @@ def _json_to_registry(payload: str) -> NodeRegistry:
castle_root=node_data.get("castle_root"),
gateway_port=node_data.get("gateway_port", 9000),
)
deployed: dict[str, DeployedComponent] = {}
deployed: dict[str, Deployment] = {}
for name, comp_data in data.get("deployed", {}).items():
deployed[name] = DeployedComponent(
deployed[name] = Deployment(
runner=comp_data.get("runner", "command"),
run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}),

View File

@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Request, status
from castle_api.config import get_registry, settings
from castle_api.mesh import mesh_state
from castle_api.models import ComponentSummary, MeshStatus, NodeDetail, NodeSummary
from castle_api.models import DeploymentSummary, MeshStatus, NodeDetail, NodeSummary
router = APIRouter(tags=["nodes"])
@@ -39,12 +39,12 @@ def _remote_node_summary(hostname: str, remote: object) -> NodeSummary:
)
def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSummary]:
"""Convert deployed components from a registry into ComponentSummary list."""
def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSummary]:
"""Convert deployed components from a registry into DeploymentSummary list."""
summaries = []
for name, d in registry.deployed.items():
summaries.append(
ComponentSummary(
DeploymentSummary(
id=name,
category="job" if d.schedule else "service",
description=d.description,

View File

@@ -58,7 +58,7 @@ async def program_action(name: str, action: str) -> dict:
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
return {
"component": result.component,
"program": result.program,
"action": result.action,
"status": result.status,
"output": result.output,

View File

@@ -17,8 +17,8 @@ from castle_api.config import get_castle_root, get_registry
from castle_api.mesh import mesh_state
from castle_api.health import check_all_health
from castle_api.models import (
ComponentDetail,
ComponentSummary,
DeploymentDetail,
DeploymentSummary,
GatewayInfo,
GatewayRoute,
JobDetail,
@@ -47,8 +47,8 @@ def _declared_commands_dict(comp: ProgramSpec) -> dict[str, list[list[str]]] | N
return out or None
def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
"""Build a ComponentSummary from a DeployedComponent."""
def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
"""Build a DeploymentSummary from a Deployment."""
managed = deployed.managed
systemd_info: SystemdInfo | None = None
@@ -69,7 +69,7 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
category = "job" if deployed.schedule else "service"
return ComponentSummary(
return DeploymentSummary(
id=name,
category=category,
description=deployed.description,
@@ -88,8 +88,8 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
def _summary_from_service(
name: str, svc: ServiceSpec, config: object
) -> ComponentSummary:
"""Build a ComponentSummary from a ServiceSpec (non-deployed)."""
) -> DeploymentSummary:
"""Build a DeploymentSummary from a ServiceSpec (non-deployed)."""
port = None
health_path = None
proxy_path = None
@@ -112,8 +112,8 @@ def _summary_from_service(
description = svc.description
source = None
stack = None
if svc.component and svc.component in config.programs:
comp = config.programs[svc.component]
if svc.program and svc.program in config.programs:
comp = config.programs[svc.program]
if not description:
description = comp.description
source = comp.source
@@ -121,7 +121,7 @@ def _summary_from_service(
runner = svc.run.runner
return ComponentSummary(
return DeploymentSummary(
id=name,
category="service",
description=description,
@@ -137,8 +137,8 @@ def _summary_from_service(
)
def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSummary:
"""Build a ComponentSummary from a JobSpec (non-deployed)."""
def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSummary:
"""Build a DeploymentSummary from a JobSpec (non-deployed)."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info: SystemdInfo | None = None
@@ -150,14 +150,14 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
description = job.description
source = None
stack = None
if job.component and job.component in config.programs:
comp = config.programs[job.component]
if job.program and job.program in config.programs:
comp = config.programs[job.program]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
return ComponentSummary(
return DeploymentSummary(
id=name,
category="job",
description=description,
@@ -171,8 +171,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
)
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ProgramSpec (tools/frontends)."""
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> DeploymentSummary:
"""Build a DeploymentSummary from a ProgramSpec (tools/frontends)."""
source = comp.source
# Infer runner from source directory
@@ -188,7 +188,7 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component
if comp.source and (comp.stack or comp.commands):
installed = shutil.which(name) is not None
return ComponentSummary(
return DeploymentSummary(
id=name,
category="program",
description=comp.description,
@@ -222,16 +222,16 @@ def _backfill_source(name: str, config: object) -> str | None:
return config.programs[name].source
ref = None
if name in config.services:
ref = config.services[name].component
ref = config.services[name].program
elif name in config.jobs:
ref = config.jobs[name].component
ref = config.jobs[name].program
if ref and ref in config.programs:
return config.programs[ref].source
return None
def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
"""Build a ServiceSummary from a DeployedComponent."""
"""Build a ServiceSummary from a Deployment."""
systemd_info = _make_systemd_info(name) if deployed.managed else None
return ServiceSummary(
id=name,
@@ -263,8 +263,8 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
description = svc.description
source = None
stack = None
if svc.component and svc.component in config.programs:
comp = config.programs[svc.component]
if svc.program and svc.program in config.programs:
comp = config.programs[svc.program]
if not description:
description = comp.description
source = comp.source
@@ -285,7 +285,7 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
"""Build a JobSummary from a DeployedComponent."""
"""Build a JobSummary from a Deployment."""
systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None
return JobSummary(
id=name,
@@ -306,8 +306,8 @@ def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
description = job.description
source = None
stack = None
if job.component and job.component in config.programs:
comp = config.programs[job.component]
if job.program and job.program in config.programs:
comp = config.programs[job.program]
if not description:
description = comp.description
source = comp.source
@@ -636,15 +636,15 @@ def get_program(name: str) -> ProgramDetail:
# ---------------------------------------------------------------------------
@router.get("/components", response_model=list[ComponentSummary])
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
@router.get("/deployments", response_model=list[DeploymentSummary])
def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
"""List all components — deployed from registry, non-deployed from castle.yaml.
Pass ?include_remote=true to include components from remote mesh nodes.
"""
registry = get_registry()
local_hostname = registry.node.hostname
summaries: list[ComponentSummary] = []
summaries: list[DeploymentSummary] = []
seen: set[str] = set()
# Deployed components from registry
@@ -686,9 +686,9 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
# Check if a service/job references a program
ref = None
if s.id in config.services:
ref = config.services[s.id].component
ref = config.services[s.id].program
elif s.id in config.jobs:
ref = config.jobs[s.id].component
ref = config.jobs[s.id].program
if ref and ref in config.programs:
s.source = config.programs[ref].source
@@ -708,7 +708,7 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
for name, d in remote.registry.deployed.items():
if name not in seen:
summaries.append(
ComponentSummary(
DeploymentSummary(
id=name,
category="job" if d.schedule else "service",
description=d.description,
@@ -728,8 +728,8 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
return summaries
@router.get("/components/{name}", response_model=ComponentDetail)
def get_component(name: str) -> ComponentDetail:
@router.get("/deployments/{name}", response_model=DeploymentDetail)
def get_component(name: str) -> DeploymentDetail:
"""Get detailed info for a single component."""
registry = get_registry()
@@ -749,9 +749,9 @@ def get_component(name: str) -> ComponentDetail:
else:
ref = None
if name in config.services:
ref = config.services[name].component
ref = config.services[name].program
elif name in config.jobs:
ref = config.jobs[name].component
ref = config.jobs[name].program
if ref and ref in config.programs:
summary.source = config.programs[ref].source
except FileNotFoundError:
@@ -768,7 +768,7 @@ def get_component(name: str) -> ComponentDetail:
"behavior": deployed.behavior,
"stack": deployed.stack,
}
return ComponentDetail(**summary.model_dump(), manifest=raw)
return DeploymentDetail(**summary.model_dump(), manifest=raw)
# Fall back to castle.yaml
root = get_castle_root()
@@ -781,23 +781,23 @@ def get_component(name: str) -> ComponentDetail:
svc = config.services[name]
summary = _summary_from_service(name, svc, config)
raw = svc.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
return DeploymentDetail(**summary.model_dump(), manifest=raw)
if name in config.jobs:
job = config.jobs[name]
summary = _summary_from_job(name, job, config)
raw = job.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
return DeploymentDetail(**summary.model_dump(), manifest=raw)
if name in config.programs:
comp = config.programs[name]
summary = _summary_from_program(name, comp, root)
raw = comp.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
return DeploymentDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found",
detail=f"'{name}' not found",
)
@@ -822,7 +822,7 @@ def get_gateway() -> GatewayInfo:
GatewayRoute(
path=d.proxy_path,
target_port=d.port,
component=name,
program=name,
node=registry.node.hostname,
)
for name, d in registry.deployed.items()
@@ -838,7 +838,7 @@ def get_gateway() -> GatewayInfo:
GatewayRoute(
path=d.proxy_path,
target_port=d.port,
component=name,
program=name,
node=hostname,
)
)
@@ -848,7 +848,7 @@ def get_gateway() -> GatewayInfo:
return GatewayInfo(
port=registry.node.gateway_port,
hostname=registry.node.hostname,
component_count=deployed_count,
deployment_count=deployed_count,
service_count=service_count,
managed_count=managed_count,
routes=routes,

View File

@@ -108,7 +108,7 @@ async def _do_action(name: str, action: str) -> JSONResponse:
asyncio.create_task(_deferred_systemctl(action, unit))
return JSONResponse(
status_code=202,
content={"component": name, "action": action, "status": "accepted"},
content={"program": name, "action": action, "status": "accepted"},
)
ok, output = await _systemctl(action, unit)
@@ -120,7 +120,7 @@ async def _do_action(name: str, action: str) -> JSONResponse:
await _broadcast_health_with_override(name, unit_status)
return JSONResponse(
content={"component": name, "action": action, "status": unit_status},
content={"program": name, "action": action, "status": unit_status},
)

View File

@@ -10,7 +10,7 @@ from fastapi.testclient import TestClient
import castle_api.config as api_config
from castle_api.main import app
from castle_core.registry import (
DeployedComponent,
Deployment,
NodeConfig,
NodeRegistry,
save_registry,
@@ -50,7 +50,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
},
"services": {
"test-svc": {
"component": "test-svc-comp",
"program": "test-svc-comp",
"description": "Test service",
"run": {
"runner": "python",
@@ -92,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
gateway_port=9000,
),
deployed={
"test-svc": DeployedComponent(
"test-svc": Deployment(
runner="python",
run_cmd=["uv", "run", "test-svc"],
env={

View File

@@ -18,7 +18,7 @@ class TestComponents:
def test_list_components(self, client: TestClient) -> None:
"""Returns all registered components."""
response = client.get("/components")
response = client.get("/deployments")
assert response.status_code == 200
data = response.json()
names = [c["id"] for c in data]
@@ -27,7 +27,7 @@ class TestComponents:
def test_service_has_port(self, client: TestClient) -> None:
"""Service component includes port info."""
response = client.get("/components")
response = client.get("/deployments")
data = response.json()
svc = next(c for c in data if c["id"] == "test-svc")
assert svc["port"] == 19000
@@ -38,7 +38,7 @@ class TestComponents:
def test_tool_has_no_port(self, client: TestClient) -> None:
"""Tool component has no port."""
response = client.get("/components")
response = client.get("/deployments")
data = response.json()
tool = next(c for c in data if c["id"] == "test-tool")
assert tool["port"] is None
@@ -46,19 +46,19 @@ class TestComponents:
def test_job_has_schedule(self, client: TestClient) -> None:
"""Job component has schedule."""
response = client.get("/components")
response = client.get("/deployments")
data = response.json()
job = next(c for c in data if c["id"] == "test-job")
assert job["behavior"] == "tool"
assert job["schedule"] == "0 2 * * *"
class TestComponentDetail:
class TestDeploymentDetail:
"""Component detail endpoint tests."""
def test_get_component(self, client: TestClient) -> None:
"""Returns detailed info for a component."""
response = client.get("/components/test-svc")
response = client.get("/deployments/test-svc")
assert response.status_code == 200
data = response.json()
assert data["id"] == "test-svc"
@@ -67,7 +67,7 @@ class TestComponentDetail:
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown component."""
response = client.get("/components/nonexistent")
response = client.get("/deployments/nonexistent")
assert response.status_code == 404
@@ -245,7 +245,7 @@ class TestGateway:
assert data["port"] == 9000
assert data["hostname"] == "test-node"
# Registry has 1 deployed component (test-svc)
assert data["component_count"] == 1
assert data["deployment_count"] == 1
assert data["service_count"] == 1
assert data["managed_count"] == 1
@@ -258,7 +258,7 @@ class TestGateway:
route = routes[0]
assert route["path"] == "/test-svc"
assert route["target_port"] == 19000
assert route["component"] == "test-svc"
assert route["program"] == "test-svc"
assert route["node"] == "test-node"
def test_gateway_routes_sorted(self, client: TestClient) -> None:

View File

@@ -2,7 +2,7 @@
import time
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
from castle_api.mesh import STALE_TTL_SECONDS, MeshStateManager, RemoteNode
@@ -96,7 +96,7 @@ class TestMeshStateManager:
mgr.update_node("devbox", _make_registry("devbox"))
new_reg = _make_registry(
"devbox",
{"svc": DeployedComponent(runner="python", run_cmd=["svc"])},
{"svc": Deployment(runner="python", run_cmd=["svc"])},
)
mgr.update_node("devbox", new_reg)
node = mgr.get_node("devbox")

View File

@@ -2,7 +2,7 @@
import json
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
from castle_api.mqtt_client import _json_to_registry, _registry_to_json
@@ -11,7 +11,7 @@ def _make_registry() -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000),
deployed={
"my-svc": DeployedComponent(
"my-svc": Deployment(
runner="python",
run_cmd=["uv", "run", "my-svc"],
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
@@ -23,7 +23,7 @@ def _make_registry() -> NodeRegistry:
proxy_path="/my-svc",
managed=True,
),
"my-job": DeployedComponent(
"my-job": Deployment(
runner="command",
run_cmd=["my-job"],
behavior="tool",
@@ -75,7 +75,7 @@ class TestRegistrySerialization:
reg = NodeRegistry(
node=NodeConfig(hostname="minimal"),
deployed={
"bare": DeployedComponent(runner="command", run_cmd=["bare"]),
"bare": Deployment(runner="command", run_cmd=["bare"]),
},
)
restored = _json_to_registry(_registry_to_json(reg))

View File

@@ -4,7 +4,7 @@ from pathlib import Path
from fastapi.testclient import TestClient
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
from castle_api.mesh import MeshStateManager
@@ -41,7 +41,7 @@ class TestNodesList:
remote_reg = NodeRegistry(
node=NodeConfig(hostname="devbox", gateway_port=9000),
deployed={
"remote-svc": DeployedComponent(
"remote-svc": Deployment(
runner="python",
run_cmd=["svc"],
port=9050,