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,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)