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:
@@ -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}")
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user