Refactor component terminology to programs in config and manifest

- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications.
- Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks.
- Adjusted tests to reflect the new program structure and ensure proper functionality.
- Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
This commit is contained in:
2026-02-23 22:09:41 -08:00
parent f559fba143
commit 0d36e4f72a
48 changed files with 7512 additions and 632 deletions

View File

@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core.config import save_config
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_config, get_registry
from castle_api.stream import broadcast
@@ -28,7 +28,7 @@ class ConfigSaveRequest(BaseModel):
class ConfigSaveResponse(BaseModel):
ok: bool
component_count: int
program_count: int
service_count: int
job_count: int
errors: list[str]
@@ -40,7 +40,7 @@ class ApplyResponse(BaseModel):
errors: list[str]
class ComponentConfigRequest(BaseModel):
class ProgramConfigRequest(BaseModel):
config: dict
@@ -92,16 +92,17 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
detail="YAML must be a mapping",
)
# Validate components
comp_count = 0
for name, comp_data in data.get("components", {}).items():
# Validate programs
prog_count = 0
programs_data = data.get("programs") or data.get("components") or {}
for name, comp_data in programs_data.items():
try:
comp_data_copy = dict(comp_data) if comp_data else {}
comp_data_copy["id"] = name
ComponentSpec.model_validate(comp_data_copy)
comp_count += 1
ProgramSpec.model_validate(comp_data_copy)
prog_count += 1
except Exception as e:
errors.append(f"components.{name}: {e}")
errors.append(f"programs.{name}: {e}")
# Validate services
svc_count = 0
@@ -138,46 +139,46 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
config_path.write_text(request.yaml_content)
return ConfigSaveResponse(
ok=True, component_count=comp_count, service_count=svc_count,
ok=True, program_count=prog_count, service_count=svc_count,
job_count=job_count, errors=[],
)
@router.put("/components/{name}")
def save_component(name: str, request: ComponentConfigRequest) -> dict:
"""Update a single component's config in castle.yaml."""
@router.put("/programs/{name}")
def save_program(name: str, request: ProgramConfigRequest) -> dict:
"""Update a single program's config in castle.yaml."""
_require_repo()
try:
comp_data = dict(request.config)
comp_data["id"] = name
ComponentSpec.model_validate(comp_data)
prog_data = dict(request.config)
prog_data["id"] = name
ProgramSpec.model_validate(prog_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid component config: {e}",
detail=f"Invalid program config: {e}",
)
config = get_config()
config.components[name] = ComponentSpec.model_validate(
config.programs[name] = ProgramSpec.model_validate(
{**request.config, "id": name}
)
save_config(config)
return {"ok": True, "component": name}
return {"ok": True, "program": name}
@router.delete("/components/{name}")
def delete_component(name: str) -> dict:
"""Remove a component from castle.yaml."""
@router.delete("/programs/{name}")
def delete_program(name: str) -> dict:
"""Remove a program from castle.yaml."""
config = get_config()
if name not in config.components:
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found",
detail=f"Program '{name}' not found",
)
del config.components[name]
del config.programs[name]
save_config(config)
return {"ok": True, "component": name, "action": "deleted"}
return {"ok": True, "program": name, "action": "deleted"}
@router.put("/services/{name}")

View File

@@ -15,7 +15,7 @@ class ComponentSummary(BaseModel):
"""Summary of a single component."""
id: str
category: str | None = None # "component", "service", or "job"
category: str | None = None # "program", "service", or "job"
description: str | None = None
behavior: str | None = None
stack: str | None = None
@@ -39,6 +39,70 @@ class ComponentDetail(ComponentSummary):
manifest: dict
class ServiceSummary(BaseModel):
"""Summary of a service (long-running daemon)."""
id: str
description: str | None = None
stack: str | None = None
runner: str | None = None
port: int | None = None
health_path: str | None = None
proxy_path: str | None = None
managed: bool = False
systemd: SystemdInfo | None = None
source: str | None = None
node: str | None = None
class ServiceDetail(ServiceSummary):
"""Full detail for a service, including raw manifest."""
manifest: dict
class JobSummary(BaseModel):
"""Summary of a job (scheduled task)."""
id: str
description: str | None = None
stack: str | None = None
runner: str | None = None
schedule: str | None = None
managed: bool = False
systemd: SystemdInfo | None = None
source: str | None = None
node: str | None = None
class JobDetail(JobSummary):
"""Full detail for a job, including raw manifest."""
manifest: dict
class ProgramSummary(BaseModel):
"""Summary of a program (software catalog entry)."""
id: str
description: str | None = None
behavior: str | None = None
stack: str | None = None
runner: str | None = None
version: str | None = None
source: str | None = None
system_dependencies: list[str] = []
installed: bool | None = None
actions: list[str] = []
node: str | None = None
class ProgramDetail(ProgramSummary):
"""Full detail for a program, including raw manifest."""
manifest: dict
class HealthStatus(BaseModel):
"""Health status of a single component."""

View File

@@ -10,7 +10,8 @@ from fastapi import APIRouter, HTTPException, status
from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
from castle_core.stacks import available_actions
from castle_api.config import get_castle_root, get_registry
from castle_api.mesh import mesh_state
@@ -20,6 +21,12 @@ from castle_api.models import (
ComponentSummary,
GatewayInfo,
GatewayRoute,
JobDetail,
JobSummary,
ProgramDetail,
ProgramSummary,
ServiceDetail,
ServiceSummary,
StatusResponse,
SystemdInfo,
)
@@ -88,8 +95,8 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
description = svc.description
source = None
stack = None
if svc.component and svc.component in config.components:
comp = config.components[svc.component]
if svc.component and svc.component in config.programs:
comp = config.programs[svc.component]
if not description:
description = comp.description
source = comp.source
@@ -126,8 +133,8 @@ 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.components:
comp = config.components[job.component]
if job.component and job.component in config.programs:
comp = config.programs[job.component]
if not description:
description = comp.description
source = comp.source
@@ -147,8 +154,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
)
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ProgramSpec (tools/frontends)."""
# Determine behavior
is_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
@@ -178,7 +185,7 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
return ComponentSummary(
id=name,
category="component",
category="program",
description=comp.description,
behavior=behavior,
stack=comp.stack,
@@ -190,6 +197,454 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
)
# ---------------------------------------------------------------------------
# Typed builder functions — one per concept
# ---------------------------------------------------------------------------
def _make_systemd_info(name: str, timer: bool = False) -> SystemdInfo:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
return SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=timer)
def _backfill_source(name: str, config: object) -> str | None:
"""Resolve source path from program ref in config."""
if name in config.programs:
return config.programs[name].source
ref = None
if name in config.services:
ref = config.services[name].component
elif name in config.jobs:
ref = config.jobs[name].component
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."""
systemd_info = _make_systemd_info(name) if deployed.managed else None
return ServiceSummary(
id=name,
description=deployed.description,
stack=deployed.stack,
runner=deployed.runner,
port=deployed.port,
health_path=deployed.health_path,
proxy_path=deployed.proxy_path,
managed=deployed.managed,
systemd=systemd_info,
)
def _service_from_spec(
name: str, svc: ServiceSpec, config: object
) -> ServiceSummary:
"""Build a ServiceSummary from a ServiceSpec."""
port = None
health_path = None
proxy_path = None
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
health_path = svc.expose.http.health_path
if svc.proxy and svc.proxy.caddy:
proxy_path = svc.proxy.caddy.path_prefix
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
systemd_info = _make_systemd_info(name) if managed else None
description = svc.description
source = None
stack = None
if svc.component and svc.component in config.programs:
comp = config.programs[svc.component]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
return ServiceSummary(
id=name,
description=description,
stack=stack,
runner=svc.run.runner,
port=port,
health_path=health_path,
proxy_path=proxy_path,
managed=managed,
systemd=systemd_info,
source=source,
)
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
"""Build a JobSummary from a DeployedComponent."""
systemd_info = (
_make_systemd_info(name, timer=True) if deployed.managed else None
)
return JobSummary(
id=name,
description=deployed.description,
stack=deployed.stack,
runner=deployed.runner,
schedule=deployed.schedule,
managed=deployed.managed,
systemd=systemd_info,
)
def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
"""Build a JobSummary from a JobSpec."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info = _make_systemd_info(name, timer=True) if managed else None
description = job.description
source = None
stack = None
if job.component and job.component in config.programs:
comp = config.programs[job.component]
if not description:
description = comp.description
source = comp.source
stack = comp.stack
return JobSummary(
id=name,
description=description,
stack=stack,
runner=job.run.runner,
schedule=job.schedule,
managed=managed,
systemd=systemd_info,
source=source,
)
def _program_from_spec(
name: str, comp: ProgramSpec, root: Path, config: object | None = None
) -> ProgramSummary:
"""Build a ProgramSummary from a ProgramSpec."""
is_tool = bool((comp.install and comp.install.path) or comp.tool)
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
if is_tool:
behavior = "tool"
elif is_frontend:
behavior = "frontend"
else:
behavior = None
# Derive behavior from backing service/job
if behavior is None and config is not None:
svc_components = {
s.component for s in config.services.values() if s.component
}
job_components = {
j.component for j in config.jobs.values() if j.component
}
if name in svc_components or name in config.services:
behavior = "daemon"
elif name in job_components or name in config.jobs:
behavior = "tool"
source = comp.source
runner = None
if source:
source_dir = root / source
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if comp.install and comp.install.path:
alias = comp.install.path.alias or name
installed = shutil.which(alias) is not None
elif config is not None:
# Daemons: check if the service's run.tool binary is on PATH
svc = config.services.get(name)
if svc and hasattr(svc.run, "tool"):
installed = shutil.which(svc.run.tool) is not None
return ProgramSummary(
id=name,
description=comp.description,
behavior=behavior,
stack=comp.stack,
runner=runner,
version=comp.tool.version if comp.tool else None,
source=source,
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
installed=installed,
actions=available_actions(comp),
)
# ---------------------------------------------------------------------------
# Typed endpoints — /services, /jobs, /programs
# ---------------------------------------------------------------------------
@router.get("/services", response_model=list[ServiceSummary], tags=["services-data"])
def list_services(include_remote: bool = False) -> list[ServiceSummary]:
"""List all services — deployed from registry, non-deployed from castle.yaml."""
registry = get_registry()
hostname = registry.node.hostname
summaries: list[ServiceSummary] = []
seen: set[str] = set()
# Deployed services (non-scheduled)
for name, deployed in registry.deployed.items():
if deployed.schedule:
continue
s = _service_from_deployed(name, deployed)
s.node = hostname
# Backfill source
root = get_castle_root()
if root and s.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
s.source = _backfill_source(name, config)
except FileNotFoundError:
pass
summaries.append(s)
seen.add(name)
# Non-deployed from castle.yaml
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
for name, svc in config.services.items():
if name not in seen:
s = _service_from_spec(name, svc, config)
s.node = hostname
summaries.append(s)
seen.add(name)
except FileNotFoundError:
pass
# Remote
if include_remote:
for remote_host, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if not d.schedule and name not in seen:
s = _service_from_deployed(name, d)
s.node = remote_host
summaries.append(s)
seen.add(name)
return summaries
@router.get(
"/services/{name}",
response_model=ServiceDetail,
tags=["services-data"],
)
def get_service(name: str) -> ServiceDetail:
"""Get detailed info for a single service."""
registry = get_registry()
if name in registry.deployed and not registry.deployed[name].schedule:
deployed = registry.deployed[name]
summary = _service_from_deployed(name, deployed)
root = get_castle_root()
if root and summary.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
summary.source = _backfill_source(name, config)
except FileNotFoundError:
pass
raw = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"port": deployed.port,
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"managed": deployed.managed,
"behavior": deployed.behavior,
"stack": deployed.stack,
}
return ServiceDetail(**summary.model_dump(), manifest=raw)
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.services:
svc = config.services[name]
summary = _service_from_spec(name, svc, config)
raw = svc.model_dump(mode="json", exclude_none=True)
return ServiceDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service '{name}' not found",
)
@router.get("/jobs", response_model=list[JobSummary], tags=["jobs-data"])
def list_jobs(include_remote: bool = False) -> list[JobSummary]:
"""List all jobs — deployed from registry, non-deployed from castle.yaml."""
registry = get_registry()
hostname = registry.node.hostname
summaries: list[JobSummary] = []
seen: set[str] = set()
# Deployed jobs (scheduled)
for name, deployed in registry.deployed.items():
if not deployed.schedule:
continue
s = _job_from_deployed(name, deployed)
s.node = hostname
root = get_castle_root()
if root and s.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
s.source = _backfill_source(name, config)
except FileNotFoundError:
pass
summaries.append(s)
seen.add(name)
# Non-deployed from castle.yaml
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
for name, job in config.jobs.items():
if name not in seen:
s = _job_from_spec(name, job, config)
s.node = hostname
summaries.append(s)
seen.add(name)
except FileNotFoundError:
pass
# Remote
if include_remote:
for remote_host, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if d.schedule and name not in seen:
s = _job_from_deployed(name, d)
s.node = remote_host
summaries.append(s)
seen.add(name)
return summaries
@router.get("/jobs/{name}", response_model=JobDetail, tags=["jobs-data"])
def get_job(name: str) -> JobDetail:
"""Get detailed info for a single job."""
registry = get_registry()
if name in registry.deployed and registry.deployed[name].schedule:
deployed = registry.deployed[name]
summary = _job_from_deployed(name, deployed)
root = get_castle_root()
if root and summary.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
summary.source = _backfill_source(name, config)
except FileNotFoundError:
pass
raw = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"managed": deployed.managed,
"schedule": deployed.schedule,
"behavior": deployed.behavior,
"stack": deployed.stack,
}
return JobDetail(**summary.model_dump(), manifest=raw)
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.jobs:
job = config.jobs[name]
summary = _job_from_spec(name, job, config)
raw = job.model_dump(mode="json", exclude_none=True)
return JobDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job '{name}' not found",
)
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
def list_programs() -> list[ProgramSummary]:
"""List all programs from the software catalog (castle.yaml programs section)."""
root = get_castle_root()
if not root:
return []
try:
from castle_core.config import load_config
config = load_config(root)
except FileNotFoundError:
return []
hostname = get_registry().node.hostname
summaries: list[ProgramSummary] = []
for name, comp in config.programs.items():
summary = _program_from_spec(name, comp, root, config)
if summary.behavior is None:
continue
summary.node = hostname
summaries.append(summary)
return summaries
@router.get("/programs/{name}", response_model=ProgramDetail, tags=["programs"])
def get_program(name: str) -> ProgramDetail:
"""Get detailed info for a single program."""
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.programs:
comp = config.programs[name]
summary = _program_from_spec(name, comp, root, config)
raw = comp.model_dump(mode="json", exclude_none=True)
return ProgramDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Program '{name}' not found",
)
# ---------------------------------------------------------------------------
# Legacy /components endpoint (compat shim)
# ---------------------------------------------------------------------------
@router.get("/components", response_model=list[ComponentSummary])
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
"""List all components — deployed from registry, non-deployed from castle.yaml.
@@ -232,33 +687,37 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
summaries.append(s)
seen.add(name)
# Backfill source from component refs for deployed items
# Backfill source from program refs for deployed items
for s in summaries:
if s.source is None and s.id in config.components:
s.source = config.components[s.id].source
if s.source is None and s.id in config.programs:
s.source = config.programs[s.id].source
elif s.source is None:
# Check if a service/job references a component
# Check if a service/job references a program
ref = None
if s.id in config.services:
ref = config.services[s.id].component
elif s.id in config.jobs:
ref = config.jobs[s.id].component
if ref and ref in config.components:
s.source = config.components[ref].source
if ref and ref in config.programs:
s.source = config.programs[ref].source
# Components (tools/frontends) not already represented by a
# service or job entry. Skip if the component has no
# distinct behavior (e.g. it's just the software identity
# behind a service).
for name, comp in config.components.items():
if name in seen:
continue
summary = _summary_from_component(name, comp, root)
# Programs — always include entries from the programs
# section as catalog items. Derive behavior from backing
# service/job when the program has no install/build spec.
svc_components = {s.component for s in config.services.values() if s.component}
job_components = {j.component for j in config.jobs.values() if j.component}
for name, comp in config.programs.items():
summary = _summary_from_program(name, comp, root)
if summary.behavior is None:
continue
if name in svc_components or name in config.services:
summary.behavior = "daemon"
elif name in job_components or name in config.jobs:
summary.behavior = "tool"
else:
continue
summary.node = local_hostname
summaries.append(summary)
seen.add(name)
except FileNotFoundError:
pass
@@ -297,23 +756,23 @@ def get_component(name: str) -> ComponentDetail:
deployed = registry.deployed[name]
summary = _summary_from_deployed(name, deployed)
# Backfill source from castle.yaml component ref
# Backfill source from castle.yaml program ref
root = get_castle_root()
if root and summary.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
summary.source = config.components[name].source
if name in config.programs:
summary.source = config.programs[name].source
else:
ref = None
if name in config.services:
ref = config.services[name].component
elif name in config.jobs:
ref = config.jobs[name].component
if ref and ref in config.components:
summary.source = config.components[ref].source
if ref and ref in config.programs:
summary.source = config.programs[ref].source
except FileNotFoundError:
pass
@@ -349,9 +808,9 @@ def get_component(name: str) -> ComponentDetail:
raw = job.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
if name in config.components:
comp = config.components[name]
summary = _summary_from_component(name, comp, root)
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)

View File

@@ -1,13 +1,13 @@
"""Tools router — browse and inspect tool components."""
"""Tools router and program actions."""
from __future__ import annotations
import asyncio
from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.manifest import ComponentSpec
from castle_core.manifest import ProgramSpec
from castle_core.stacks import available_actions, get_handler
from castle_api.config import get_config
from castle_api.models import ToolDetail, ToolSummary
@@ -15,15 +15,15 @@ from castle_api.models import ToolDetail, ToolSummary
router = APIRouter(tags=["tools"])
def _is_tool(comp: ComponentSpec) -> bool:
def _is_tool(comp: ProgramSpec) -> bool:
"""Check if a component is a tool (has install.path or tool spec)."""
return bool((comp.install and comp.install.path) or comp.tool)
def _tool_summary(
name: str, comp: ComponentSpec, root: Path | None = None
name: str, comp: ProgramSpec, root: Path | None = None
) -> ToolSummary:
"""Build a ToolSummary from a ComponentSpec that is a tool."""
"""Build a ToolSummary from a ProgramSpec that is a tool."""
t = comp.tool
installed = bool(
comp.install and comp.install.path and comp.install.path.enable
@@ -54,7 +54,7 @@ def _tool_summary(
def list_tools() -> list[ToolSummary]:
"""List all registered tools (requires repo access)."""
config = get_config()
tools = {k: v for k, v in config.components.items() if _is_tool(v)}
tools = {k: v for k, v in config.programs.items() if _is_tool(v)}
return sorted(
[
@@ -70,13 +70,13 @@ def get_tool(name: str) -> ToolDetail:
"""Get detailed info for a single tool."""
config = get_config()
if name not in config.components:
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Component '{name}' not found",
)
comp = config.components[name]
comp = config.programs[name]
if not _is_tool(comp):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
@@ -87,82 +87,54 @@ def get_tool(name: str) -> ToolDetail:
return ToolDetail(**summary.model_dump())
@router.post("/tools/{name}/install")
async def install_tool(name: str) -> dict:
"""Install a tool to PATH via uv tool install."""
# ---------------------------------------------------------------------------
# Unified program action endpoint
# ---------------------------------------------------------------------------
_VALID_ACTIONS = {"build", "test", "lint", "type-check", "check", "install", "uninstall"}
@router.post("/programs/{name}/{action}")
async def program_action(name: str, action: str) -> dict:
"""Run a lifecycle action on a program via its stack handler."""
if action not in _VALID_ACTIONS:
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
config = get_config()
if name not in config.components:
if name not in config.programs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
comp = config.components[name]
comp = config.programs[name]
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
actions = available_actions(comp)
if action not in actions:
raise HTTPException(
status_code=400, detail=f"'{name}' has no source to install"
status_code=400,
detail=f"Action '{action}' not available for '{name}' (stack: {comp.stack})",
)
source_dir = config.root / comp.source
if not (source_dir / "pyproject.toml").exists():
handler = get_handler(comp.stack)
if handler is None:
raise HTTPException(
status_code=400, detail=f"No pyproject.toml in {comp.source}"
status_code=400, detail=f"No handler for stack '{comp.stack}'"
)
proc = await asyncio.create_subprocess_exec(
"uv",
"tool",
"install",
"--editable",
str(source_dir),
"--force",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
output = (stdout or stderr or b"").decode().strip()
# Map hyphenated action names to method names (type-check → type_check)
method_name = action.replace("-", "_")
method = getattr(handler, method_name)
if proc.returncode != 0:
raise HTTPException(status_code=500, detail=output or "Install failed")
result = await method(name, comp, config.root)
return {"component": name, "action": "install", "status": "ok"}
if result.status != "ok":
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
@router.post("/tools/{name}/uninstall")
async def uninstall_tool(name: str) -> dict:
"""Uninstall a tool from PATH via uv tool uninstall."""
config = get_config()
if name not in config.components:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
comp = config.components[name]
if not comp.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no source")
# uv tool uninstall uses the package name from pyproject.toml
source_dir = config.root / comp.source
pkg_name = source_dir.name
pyproject = source_dir / "pyproject.toml"
if pyproject.exists():
import tomllib
with open(pyproject, "rb") as f:
data = tomllib.load(f)
pkg_name = data.get("project", {}).get("name", pkg_name)
proc = await asyncio.create_subprocess_exec(
"uv",
"tool",
"uninstall",
pkg_name,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
output = (stdout or stderr or b"").decode().strip()
if proc.returncode != 0:
raise HTTPException(status_code=500, detail=output or "Uninstall failed")
return {"component": name, "action": "uninstall", "status": "ok"}
return {
"component": result.component,
"action": result.action,
"status": result.status,
"output": result.output,
}

View File

@@ -71,6 +71,169 @@ class TestComponentDetail:
assert response.status_code == 404
class TestServicesList:
"""GET /services endpoint tests."""
def test_returns_deployed_services(self, client: TestClient) -> None:
"""Returns deployed services from registry."""
response = client.get("/services")
assert response.status_code == 200
data = response.json()
names = [s["id"] for s in data]
assert "test-svc" in names
def test_service_has_port_and_health(self, client: TestClient) -> None:
"""Service summary includes port and health info."""
response = client.get("/services")
data = response.json()
svc = next(s for s in data if s["id"] == "test-svc")
assert svc["port"] == 19000
assert svc["health_path"] == "/health"
assert svc["proxy_path"] == "/test-svc"
assert svc["managed"] is True
def test_no_schedule_field(self, client: TestClient) -> None:
"""ServiceSummary does not have schedule field."""
response = client.get("/services")
data = response.json()
svc = next(s for s in data if s["id"] == "test-svc")
assert "schedule" not in svc
def test_no_installed_field(self, client: TestClient) -> None:
"""ServiceSummary does not have installed field."""
response = client.get("/services")
data = response.json()
svc = next(s for s in data if s["id"] == "test-svc")
assert "installed" not in svc
def test_excludes_jobs(self, client: TestClient) -> None:
"""Jobs (scheduled items) are not in the services list."""
response = client.get("/services")
data = response.json()
names = [s["id"] for s in data]
assert "test-job" not in names
class TestServiceDetail:
"""GET /services/{name} endpoint tests."""
def test_get_service(self, client: TestClient) -> None:
"""Returns detailed info for a service."""
response = client.get("/services/test-svc")
assert response.status_code == 200
data = response.json()
assert data["id"] == "test-svc"
assert "manifest" in data
assert data["manifest"]["runner"] == "python"
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown service."""
response = client.get("/services/nonexistent")
assert response.status_code == 404
class TestJobsList:
"""GET /jobs endpoint tests."""
def test_returns_jobs(self, client: TestClient) -> None:
"""Returns jobs from castle.yaml."""
response = client.get("/jobs")
assert response.status_code == 200
data = response.json()
names = [j["id"] for j in data]
assert "test-job" in names
def test_job_has_schedule(self, client: TestClient) -> None:
"""Job summary includes schedule."""
response = client.get("/jobs")
data = response.json()
job = next(j for j in data if j["id"] == "test-job")
assert job["schedule"] == "0 2 * * *"
def test_no_port_field(self, client: TestClient) -> None:
"""JobSummary does not have port field."""
response = client.get("/jobs")
data = response.json()
job = next(j for j in data if j["id"] == "test-job")
assert "port" not in job
def test_excludes_services(self, client: TestClient) -> None:
"""Services (non-scheduled) are not in the jobs list."""
response = client.get("/jobs")
data = response.json()
names = [j["id"] for j in data]
assert "test-svc" not in names
class TestJobDetail:
"""GET /jobs/{name} endpoint tests."""
def test_get_job(self, client: TestClient) -> None:
"""Returns detailed info for a job."""
response = client.get("/jobs/test-job")
assert response.status_code == 200
data = response.json()
assert data["id"] == "test-job"
assert "manifest" in data
assert data["schedule"] == "0 2 * * *"
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown job."""
response = client.get("/jobs/nonexistent")
assert response.status_code == 404
class TestProgramsList:
"""GET /programs endpoint tests."""
def test_returns_programs(self, client: TestClient) -> None:
"""Returns programs from castle.yaml."""
response = client.get("/programs")
assert response.status_code == 200
data = response.json()
names = [p["id"] for p in data]
assert "test-tool" in names
def test_program_has_behavior(self, client: TestClient) -> None:
"""Program summary includes behavior."""
response = client.get("/programs")
data = response.json()
tool = next(p for p in data if p["id"] == "test-tool")
assert tool["behavior"] == "tool"
def test_no_port_field(self, client: TestClient) -> None:
"""ProgramSummary does not have port field."""
response = client.get("/programs")
data = response.json()
tool = next(p for p in data if p["id"] == "test-tool")
assert "port" not in tool
def test_no_schedule_field(self, client: TestClient) -> None:
"""ProgramSummary does not have schedule field."""
response = client.get("/programs")
data = response.json()
tool = next(p for p in data if p["id"] == "test-tool")
assert "schedule" not in tool
class TestProgramDetail:
"""GET /programs/{name} endpoint tests."""
def test_get_program(self, client: TestClient) -> None:
"""Returns detailed info for a program."""
response = client.get("/programs/test-tool")
assert response.status_code == 200
data = response.json()
assert data["id"] == "test-tool"
assert "manifest" in data
assert data["behavior"] == "tool"
def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown program."""
response = client.get("/programs/nonexistent")
assert response.status_code == 404
class TestGateway:
"""Gateway info endpoint tests."""