refactor: Align vocabulary — component → program / deployment

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

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

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

View File

@@ -16,7 +16,7 @@ Castle is a personal software platform — a monorepo of independent projects
Each program has a **stack** (development toolchain: python-fastapi,
python-cli, react-vite) and a **behavior** (runtime role: daemon, tool,
frontend). Scheduling, systemd management, and proxying are orthogonal
operations. Services and jobs reference a program via `component:` for
operations. Services and jobs reference a program via `program:` for
description fallthrough.
**Key principle:** Regular projects must never depend on castle. They accept standard
@@ -41,7 +41,7 @@ verb commands and scaffold, but a program stands on its own via its declared
Stack guides (for writing *new* code, AI-facing):
- @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle
- @docs/registry.md — Registry architecture, castle.yaml structure, lifecycle
- @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing)
- @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
- @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns
@@ -123,11 +123,22 @@ Core:
- `GET /health` — Health check
- `GET /stream` — SSE stream (health, service-action, mesh events)
Components:
- `GET /components` — List all (add `?include_remote=true` for cross-node)
- `GET /components/{name}`Component detail
Deployments (the unified view of services + jobs + programs):
- `GET /deployments` — List all (add `?include_remote=true` for cross-node)
- `GET /deployments/{name}`Deployment detail
- `GET /status` — Live health for all services
Programs / Services / Jobs (typed views + editing):
- `GET /programs`, `GET /programs/{name}` — Program catalog (`?behavior=tool` to filter)
- `POST /programs/{name}/{action}` — Run a program verb (install/uninstall/build/…)
- `PUT|DELETE /programs/{name}` — Edit or remove a program entry
- `GET /services`, `GET /services/{name}`, `PUT|DELETE /services/{name}`
- `GET /jobs`, `GET /jobs/{name}`, `PUT|DELETE /jobs/{name}`
Config:
- `GET /` — Full registry; `PUT /` — Save registry
- `POST /apply` — Apply registry changes; `POST /deploy` — Deploy to runtime
Gateway:
- `GET /gateway` — Gateway info with route table and hostname
- `GET /gateway/caddyfile` — Generated Caddyfile content
@@ -138,16 +149,10 @@ Mesh:
- `GET /nodes` — All known nodes (local + discovered remote)
- `GET /nodes/{hostname}` — Node detail with deployed components
Services:
Service actions:
- `POST /services/{name}/{action}` — start/stop/restart
- `GET /services/{name}/unit` — Systemd unit content
Tools:
- `GET /tools` — List all tools
- `GET /tools/{name}` — Tool detail
- `POST /tools/{name}/install` — Install tool to PATH
- `POST /tools/{name}/uninstall` — Uninstall tool
## Mesh Coordination (opt-in)
Multi-node discovery is disabled by default. Enable via env vars:

View File

@@ -6,7 +6,7 @@ A personal software platform. Castle manages independent services, tools, and fr
Historically, applications have been developed by third parties, distributed through app stores, and installed on user devices.
With the advent of AI-assisted software development, users can write the software they need directly, eliminating the need for packaging and distribution. This makes many classes of software simpler, too. Oftentimes all a user needs are simple scripts or configurations of existing tools. But no matter how simple your script or application, it needed to be tailored and packaged for specific distribution channels. Castle provides a unified environment for developing, managing, deploying, and advertising these simple applications.
With the advent of AI-assisted software development, users can write the software they need directly, eliminating the need for packaging and distribution. This makes many classes of software simpler. Oftentimes all a user needs are simple scripts or configurations of existing tools. But no matter how simple your script or application, it needed to be tailored and packaged for specific distribution channels. Castle provides a unified environment for developing, managing, deploying, and advertising these simple applications.
Castle _stacks_ are pre-configured development environments that provide a starting point for building applications. They include everything needed to get started, from the programming language and framework to the necessary dependencies and tools. This is a design intended for coding assistants to generate castle programs with a level of consistency and to ensure that they are properly configured and ready to use with Castle. If your coding assistant knows about Castle, it can help you create and manage your custom applications more efficiently.

View File

@@ -1,12 +1,12 @@
import { useState } from "react"
import { ChevronDown, ChevronRight } from "lucide-react"
import type { ComponentDetail } from "@/types"
import type { DeploymentDetail } from "@/types"
import { ComponentFields } from "./ComponentFields"
import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge"
interface ComponentEditorProps {
component: ComponentDetail
component: DeploymentDetail
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
onDelete: (name: string) => Promise<void>
}

View File

@@ -73,7 +73,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</thead>
<tbody>
{gateway.routes.map((route) => {
const health = statusMap.get(route.component)
const health = statusMap.get(route.program)
return (
<tr
key={route.path}
@@ -84,10 +84,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</td>
<td className="px-4 py-2">
<Link
to={`/component/${route.component}`}
to={`/deployment/${route.program}`}
className="hover:text-[var(--primary)] transition-colors"
>
{route.component}
{route.program}
</Link>
</td>
<td className="px-4 py-2 font-mono text-[var(--muted)]">

View File

@@ -3,7 +3,7 @@ import { Link } from "react-router-dom"
import { ArrowLeft, Check, Loader2, Zap } from "lucide-react"
import { useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/services/api/client"
import type { ComponentDetail } from "@/types"
import type { DeploymentDetail } from "@/types"
import { AddComponent } from "@/components/AddComponent"
import { ComponentEditor } from "@/components/ComponentEditor"
@@ -24,7 +24,7 @@ export function ConfigEditorPage() {
queryFn: async () => {
const list = await apiClient.get<{ id: string }[]>("/components")
const details = await Promise.all(
list.map((c) => apiClient.get<ComponentDetail>(`/components/${c.id}`))
list.map((c) => apiClient.get<DeploymentDetail>(`/components/${c.id}`))
)
return details
},

View File

@@ -77,7 +77,7 @@ export function NodeDetailPage() {
>
<td className="px-3 py-2.5">
<Link
to={`/component/${comp.id}`}
to={`/deployment/${comp.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{comp.id}

View File

@@ -24,7 +24,7 @@ export const router = createBrowserRouter([
element: <ProgramDetailPage />,
},
{
path: "/component/:name",
path: "/deployment/:name",
element: <ProgramRedirect />,
},
{

View File

@@ -2,7 +2,7 @@ import { useEffect } from "react"
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
import { apiClient } from "./client"
import type {
ComponentDetail,
DeploymentDetail,
ServiceSummary,
ServiceDetail,
JobSummary,
@@ -21,8 +21,8 @@ import type {
// Legacy compat hook — used by ConfigEditorPage and ProgramRedirect
export function useComponent(name: string) {
return useQuery({
queryKey: ["components", name],
queryFn: () => apiClient.get<ComponentDetail>(`/components/${name}`),
queryKey: ["deployments", name],
queryFn: () => apiClient.get<DeploymentDetail>(`/deployments/${name}`),
enabled: !!name,
})
}
@@ -125,7 +125,7 @@ export function useServiceAction() {
} catch (err) {
// Network error from self-restart killing the connection — expected
if (err instanceof TypeError) {
return { component: name, action, status: "accepted" } as ServiceActionResponse
return { program: name, action, status: "accepted" } as ServiceActionResponse
}
throw err
}
@@ -144,7 +144,7 @@ export function useProgramAction() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ name, action }: { name: string; action: string }) =>
apiClient.post<{ component: string; action: string; status: string; output: string }>(
apiClient.post<{ program: string; action: string; status: string; output: string }>(
`/programs/${name}/${action}`,
),
onSuccess: () => {

View File

@@ -64,7 +64,7 @@ export interface ProgramDetail extends ProgramSummary {
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
export interface ComponentSummary {
export interface DeploymentSummary {
id: string
category: "program" | "service" | "job" | null
description: string | null
@@ -88,7 +88,7 @@ export interface ComponentSummary {
node: string | null
}
export interface ComponentDetail extends ComponentSummary {
export interface DeploymentDetail extends DeploymentSummary {
manifest: Record<string, unknown>
}
@@ -105,21 +105,21 @@ export interface StatusResponse {
export interface GatewayRoute {
path: string
target_port: number
component: string
program: string
node: string
}
export interface GatewayInfo {
port: number
hostname: string
component_count: number
deployment_count: number
service_count: number
managed_count: number
routes: GatewayRoute[]
}
export interface ServiceActionResponse {
component: string
program: string
action: string
status: string
}
@@ -131,7 +131,7 @@ export interface SSEHealthEvent {
export interface SSEServiceActionEvent {
action: string
component: string
program: string
status: string
}
@@ -147,7 +147,7 @@ export interface NodeSummary {
}
export interface NodeDetail extends NodeSummary {
deployed: ComponentSummary[]
deployed: DeploymentSummary[]
}
export interface MeshStatus {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -94,7 +94,7 @@ def run_create(args: argparse.Namespace) -> int:
if behavior == "daemon":
config.services[name] = ServiceSpec(
id=name,
component=name,
program=name,
run=RunPython(runner="python", program=name),
expose=ExposeSpec(
http=HttpExposeSpec(

View File

@@ -66,10 +66,10 @@ def run_info(args: argparse.Namespace) -> int:
stack = None
if program and program.stack:
stack = program.stack
elif service and service.component and service.component in config.programs:
stack = config.programs[service.component].stack
elif job and job.component and job.component in config.programs:
stack = config.programs[job.component].stack
elif service and service.program and service.program in config.programs:
stack = config.programs[service.program].stack
elif job and job.program and job.program in config.programs:
stack = config.programs[job.program].stack
if stack:
print(f" {BOLD}stack{RESET}: {stack}")
@@ -88,12 +88,12 @@ def run_info(args: argparse.Namespace) -> int:
spec = service or job
if spec:
desc = spec.description
if not desc and spec.component and spec.component in config.programs:
desc = config.programs[spec.component].description
if not desc and spec.program and spec.program in config.programs:
desc = config.programs[spec.program].description
if desc and not (program and program.description == desc):
print(f" {BOLD}description{RESET}: {desc}")
if spec.component:
print(f" {BOLD}program{RESET}: {spec.component}")
if spec.program:
print(f" {BOLD}program{RESET}: {spec.program}")
# Run spec
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
@@ -149,8 +149,8 @@ def run_info(args: argparse.Namespace) -> int:
source_dir = None
if program and program.source_dir:
source_dir = program.source_dir
elif spec and spec.component and spec.component in config.programs:
source_dir = config.programs[spec.component].source_dir
elif spec and spec.program and spec.program in config.programs:
source_dir = config.programs[spec.program].source_dir
if source_dir:
claude_md = _find_claude_md(config.root, source_dir)
@@ -191,10 +191,10 @@ def _info_json(
stack = None
if program and program.stack:
stack = program.stack
elif service and service.component and service.component in config.programs:
stack = config.programs[service.component].stack
elif job and job.component and job.component in config.programs:
stack = config.programs[job.component].stack
elif service and service.program and service.program in config.programs:
stack = config.programs[service.program].stack
elif job and job.program and job.program in config.programs:
stack = config.programs[job.program].stack
if stack:
data["stack"] = stack

View File

@@ -54,13 +54,13 @@ def _resolve_stack(config: object, name: str) -> str | None:
# Check services for program ref
if name in config.services:
svc = config.services[name]
comp_name = svc.component
comp_name = svc.program
if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Check jobs for program ref
if name in config.jobs:
job = config.jobs[name]
comp_name = job.component
comp_name = job.program
if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack
# Direct program

View File

@@ -22,7 +22,7 @@ from castle_core.config import ( # noqa: F401 — explicit re-exports for type
)
from castle_core.registry import ( # noqa: F401
REGISTRY_PATH,
DeployedComponent,
Deployment,
NodeConfig,
NodeRegistry,
load_registry,

View File

@@ -23,7 +23,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
},
"services": {
"test-svc": {
"component": "test-svc-comp",
"program": "test-svc-comp",
"description": "Test service",
"run": {
"runner": "python",

View File

@@ -48,7 +48,7 @@ class TestCreateCommand:
assert "my-api" in config.services
svc = config.services["my-api"]
assert svc.expose.http.internal.port == 9050
assert svc.component == "my-api"
assert svc.program == "my-api"
mock_save.assert_called_once()
def test_create_tool(self, castle_root: Path, tmp_path: Path) -> None:

View File

@@ -252,7 +252,7 @@ def _expand_units(
services[name] = ServiceSpec(
id=name,
component=name,
program=name,
run=run_spec,
expose=ExposeSpec(
http=HttpExposeSpec(
@@ -272,7 +272,7 @@ def _expand_units(
assert unit.argv is not None # guaranteed by validator
jobs[name] = JobSpec(
id=name,
component=name,
program=name,
description=unit.description,
run=RunCommand(runner="command", argv=list(unit.argv)),
schedule=unit.schedule,

View File

@@ -1,7 +1,7 @@
"""Deploy logic — bridge castle.yaml spec to runtime (~/.castle/).
This module contains the core deploy logic shared by the CLI and API.
It reads castle.yaml, resolves services/jobs into DeployedComponents,
It reads castle.yaml, resolves services/jobs into Deployments,
writes the registry, generates systemd units and the Caddyfile, and
copies frontend build outputs.
"""
@@ -31,7 +31,7 @@ from castle_core.generators.systemd import (
from castle_core.manifest import JobSpec, ServiceSpec
from castle_core.registry import (
REGISTRY_PATH,
DeployedComponent,
Deployment,
NodeConfig,
NodeRegistry,
load_registry,
@@ -139,21 +139,21 @@ def _resolve_description(config: CastleConfig, spec: ServiceSpec | JobSpec) -> s
"""Get description, falling through to program if referenced."""
if spec.description:
return spec.description
if spec.component and spec.component in config.programs:
return config.programs[spec.component].description
if spec.program and spec.program in config.programs:
return config.programs[spec.program].description
return None
def _build_deployed_service(
config: CastleConfig, name: str, svc: ServiceSpec, messages: list[str]
) -> DeployedComponent:
"""Build a DeployedComponent from a ServiceSpec."""
) -> Deployment:
"""Build a Deployment from a ServiceSpec."""
run = svc.run
# Env prefix and data dir are keyed by the program (component) the service
# runs, not the service name — that's the prefix the program's settings read
# (e.g. job `protonmail-sync` runs program `protonmail` → PROTONMAIL_DATA_DIR).
# Falls back to the service name when no component is referenced.
config_key = svc.component or name
config_key = svc.program or name
prefix = _env_prefix(config_key)
env: dict[str, str] = {}
@@ -182,7 +182,7 @@ def _build_deployed_service(
env = resolve_env_vars(env)
# Ensure python tool is installed before resolving binary
_ensure_python_tool(config, svc.component, messages)
_ensure_python_tool(config, svc.program, messages)
# Build run_cmd
run_cmd = _build_run_cmd(name, run, env, messages)
@@ -194,13 +194,13 @@ def _build_deployed_service(
# Resolve stack from referenced program
stack = None
if svc.component and svc.component in config.programs:
stack = config.programs[svc.component].stack
if svc.program and svc.program in config.programs:
stack = config.programs[svc.program].stack
# Remote services proxy to an external base_url
base_url = getattr(run, "base_url", None)
return DeployedComponent(
return Deployment(
runner=run.runner,
run_cmd=run_cmd,
env=env,
@@ -217,12 +217,12 @@ def _build_deployed_service(
def _build_deployed_job(
config: CastleConfig, name: str, job: JobSpec, messages: list[str]
) -> DeployedComponent:
"""Build a DeployedComponent from a JobSpec."""
) -> Deployment:
"""Build a Deployment from a JobSpec."""
run = job.run
# Keyed by the program (component) the job runs, not the job name — see
# _build_deployed_service for rationale. Falls back to the job name.
config_key = job.component or name
config_key = job.program or name
prefix = _env_prefix(config_key)
env: dict[str, str] = {}
@@ -232,14 +232,14 @@ def _build_deployed_job(
env.update(job.defaults.env)
env = resolve_env_vars(env)
_ensure_python_tool(config, job.component, messages)
_ensure_python_tool(config, job.program, messages)
run_cmd = _build_run_cmd(name, run, env, messages)
stack = None
if job.component and job.component in config.programs:
stack = config.programs[job.component].stack
if job.program and job.program in config.programs:
stack = config.programs[job.program].stack
return DeployedComponent(
return Deployment(
runner=run.runner,
run_cmd=run_cmd,
env=env,
@@ -273,33 +273,33 @@ def _python_tool_needs_install(program: str) -> bool:
def _ensure_python_tool(
config: CastleConfig, component: str | None, messages: list[str]
config: CastleConfig, program: str | None, messages: list[str]
) -> None:
"""Ensure a Python program's editable install is current."""
if not component or component not in config.programs:
if not program or program not in config.programs:
return
comp = config.programs[component]
comp = config.programs[program]
if not comp.source or not comp.stack or not comp.stack.startswith("python"):
return
source_dir = Path(comp.source)
if not source_dir.is_dir():
messages.append(f"Warning: source not found: {source_dir}")
return
if not _python_tool_needs_install(component):
if not _python_tool_needs_install(program):
return
pkg_spec = str(source_dir)
if comp.install_extras:
pkg_spec += "[" + ",".join(comp.install_extras) + "]"
messages.append(f"Installing {component} from {source_dir}...")
messages.append(f"Installing {program} from {source_dir}...")
result = subprocess.run(
["uv", "tool", "install", "--editable", pkg_spec, "--force"],
capture_output=True,
text=True,
)
if result.returncode != 0:
messages.append(f"Error: {component} install failed:\n{result.stdout}{result.stderr}")
messages.append(f"Error: {program} install failed:\n{result.stdout}{result.stderr}")
else:
messages.append(f"Installed {component}")
messages.append(f"Installed {program}")
def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[str]) -> list[str]:
@@ -354,7 +354,7 @@ def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[s
raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr]
def _format_deployed(name: str, deployed: DeployedComponent) -> str:
def _format_deployed(name: str, deployed: Deployment) -> str:
"""Format deployment summary for a component."""
parts = [name]
if deployed.port:

View File

@@ -6,7 +6,7 @@ import shutil
from pathlib import Path
from castle_core.manifest import RestartPolicy, SystemdSpec
from castle_core.registry import DeployedComponent
from castle_core.registry import Deployment
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PREFIX = "castle-"
@@ -74,7 +74,7 @@ def cron_to_interval_sec(cron: str) -> int | None:
def generate_unit_from_deployed(
name: str,
deployed: DeployedComponent,
deployed: Deployment,
systemd_spec: SystemdSpec | None = None,
) -> str:
"""Generate a systemd unit from a deployed component (registry-based).

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from enum import Enum
from typing import Annotated, Literal, Union
from pydantic import BaseModel, ConfigDict, Field, model_validator
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
EnvMap = dict[str, str]
@@ -247,8 +247,13 @@ class ProgramSpec(BaseModel):
class ServiceSpec(BaseModel):
"""Long-running daemon deployment config."""
model_config = ConfigDict(populate_by_name=True)
id: str = ""
component: str | None = None
# The program this service deploys. (`component` accepted as a legacy alias.)
program: str | None = Field(
default=None, validation_alias=AliasChoices("program", "component")
)
description: str | None = None
run: RunSpec
@@ -274,8 +279,13 @@ class ServiceSpec(BaseModel):
class JobSpec(BaseModel):
"""Scheduled task that runs periodically and exits."""
model_config = ConfigDict(populate_by_name=True)
id: str = ""
component: str | None = None
# The program this job runs. (`component` accepted as a legacy alias.)
program: str | None = Field(
default=None, validation_alias=AliasChoices("program", "component")
)
description: str | None = None
run: RunSpec

View File

@@ -28,7 +28,7 @@ class NodeConfig:
@dataclass
class DeployedComponent:
class Deployment:
"""A component deployed on this node with resolved runtime config."""
runner: str
@@ -50,7 +50,7 @@ class NodeRegistry:
"""What's deployed on this node."""
node: NodeConfig
deployed: dict[str, DeployedComponent] = field(default_factory=dict)
deployed: dict[str, Deployment] = field(default_factory=dict)
def load_registry(path: Path | None = None) -> NodeRegistry:
@@ -77,7 +77,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
gateway_port=node_data.get("gateway_port", 9000),
)
deployed: dict[str, DeployedComponent] = {}
deployed: dict[str, Deployment] = {}
for name, comp_data in data.get("deployed", {}).items():
# Support both old "category" and new "behavior" keys for migration
behavior = comp_data.get("behavior")
@@ -92,7 +92,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
if category == "frontend"
else category
)
deployed[name] = DeployedComponent(
deployed[name] = Deployment(
runner=comp_data.get("runner", "command"),
run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}),

View File

@@ -28,9 +28,9 @@ _EXTRA_PATH_DIRS = [
@dataclass
class ActionResult:
"""Result of a stack lifecycle action."""
"""Result of a program lifecycle action."""
component: str
program: str
action: str
status: str # "ok" | "error"
output: str = ""
@@ -90,12 +90,12 @@ class StackHandler:
result = await action_fn(name, comp, root)
if result.status != "ok":
return ActionResult(
component=name,
program=name,
action="check",
status="error",
output=f"{action_name} failed:\n{result.output}",
)
return ActionResult(component=name, action="check", status="ok")
return ActionResult(program=name, action="check", status="ok")
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError
@@ -111,33 +111,33 @@ class PythonHandler(StackHandler):
src = _source_dir(comp, root)
rc, output = await _run(["uv", "sync"], src)
return ActionResult(
component=name, action="build", status="ok" if rc == 0 else "error", output=output
program=name, action="build", status="ok" if rc == 0 else "error", output=output
)
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
if not (src / "tests").exists():
return ActionResult(
component=name, action="test", status="ok",
program=name, action="test", status="ok",
output="No tests directory found, skipping.",
)
rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src)
return ActionResult(
component=name, action="test", status="ok" if rc == 0 else "error", output=output
program=name, action="test", status="ok" if rc == 0 else "error", output=output
)
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["uv", "run", "ruff", "check", "."], src)
return ActionResult(
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
program=name, action="lint", status="ok" if rc == 0 else "error", output=output
)
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["uv", "run", "pyright"], src)
return ActionResult(
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
program=name, action="type-check", status="ok" if rc == 0 else "error", output=output
)
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -149,7 +149,7 @@ class PythonHandler(StackHandler):
["uv", "tool", "install", "--editable", pkg_spec, "--force"], src
)
return ActionResult(
component=name, action="install", status="ok" if rc == 0 else "error", output=output
program=name, action="install", status="ok" if rc == 0 else "error", output=output
)
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -162,7 +162,7 @@ class PythonHandler(StackHandler):
pkg_name = data.get("project", {}).get("name", pkg_name)
rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src)
return ActionResult(
component=name, action="uninstall", status="ok" if rc == 0 else "error", output=output
program=name, action="uninstall", status="ok" if rc == 0 else "error", output=output
)
@@ -173,28 +173,28 @@ class ReactViteHandler(StackHandler):
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "build"], src)
return ActionResult(
component=name, action="build", status="ok" if rc == 0 else "error", output=output
program=name, action="build", status="ok" if rc == 0 else "error", output=output
)
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "test"], src)
return ActionResult(
component=name, action="test", status="ok" if rc == 0 else "error", output=output
program=name, action="test", status="ok" if rc == 0 else "error", output=output
)
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "lint"], src)
return ActionResult(
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
program=name, action="lint", status="ok" if rc == 0 else "error", output=output
)
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root)
rc, output = await _run(["pnpm", "type-check"], src)
return ActionResult(
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
program=name, action="type-check", status="ok" if rc == 0 else "error", output=output
)
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -203,18 +203,18 @@ class ReactViteHandler(StackHandler):
result = await self.build(name, comp, root)
if result.status != "ok":
return ActionResult(
component=name, action="install", status="error",
program=name, action="install", status="error",
output=f"Build failed:\n{result.output}",
)
outputs = comp.build.outputs if comp.build else []
if not outputs:
return ActionResult(
component=name, action="install", status="error",
program=name, action="install", status="error",
output="No build outputs configured.",
)
dist = _source_dir(comp, root) / outputs[0]
return ActionResult(
component=name, action="install", status="ok",
program=name, action="install", status="ok",
output=f"Built; served in place from {dist}",
)
@@ -224,7 +224,7 @@ class ReactViteHandler(StackHandler):
Deactivating one means dropping its gateway route — handled by removing the
program from the registry, not by deleting build output."""
return ActionResult(
component=name, action="uninstall", status="ok",
program=name, action="uninstall", status="ok",
output=f"{name}: served in place; nothing to uninstall.",
)
@@ -287,8 +287,8 @@ async def _run_declared(
rc, output = await _run(argv, src)
outputs.append(output)
if rc != 0:
return ActionResult(component=name, action=verb, status="error", output="".join(outputs))
return ActionResult(component=name, action=verb, status="ok", output="".join(outputs))
return ActionResult(program=name, action=verb, status="error", output="".join(outputs))
return ActionResult(program=name, action=verb, status="ok", output="".join(outputs))
async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
@@ -303,17 +303,17 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
subs = [s for s in ("lint", "type-check", "test") if is_available(comp, s)]
if not subs:
return ActionResult(
component=name, action="check", status="error",
program=name, action="check", status="error",
output="No checkable verbs available.",
)
for sub in subs:
result = await run_action(sub, name, comp, root)
if result.status != "ok":
return ActionResult(
component=name, action="check", status="error",
program=name, action="check", status="error",
output=f"{sub} failed:\n{result.output}",
)
return ActionResult(component=name, action="check", status="ok")
return ActionResult(program=name, action="check", status="ok")
# 1. Declared command overrides the stack default.
declared = _declared_commands(comp, verb)
@@ -321,7 +321,7 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
try:
src = _source_dir(comp, root)
except ValueError:
return ActionResult(component=name, action=verb, status="error", output="No source directory")
return ActionResult(program=name, action=verb, status="error", output="No source directory")
return await _run_declared(name, verb, declared, src)
# 2. Stack default.
@@ -333,7 +333,7 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
# 3. Unavailable.
return ActionResult(
component=name, action=verb, status="error",
program=name, action=verb, status="error",
output=f"Verb '{verb}' is not available for '{name}' "
f"(no declared command and no stack handler provides it).",
)

View File

@@ -23,7 +23,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
},
"services": {
"test-svc": {
"component": "test-svc-comp",
"program": "test-svc-comp",
"description": "Test service",
"run": {
"runner": "python",

View File

@@ -6,7 +6,7 @@ from __future__ import annotations
import pytest
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
@pytest.fixture(autouse=True)
@@ -22,7 +22,7 @@ def _isolate_config(monkeypatch: pytest.MonkeyPatch) -> None:
def _make_registry(
deployed: dict[str, DeployedComponent] | None = None,
deployed: dict[str, Deployment] | None = None,
gateway_port: int = 9000,
) -> NodeRegistry:
"""Create a test registry."""
@@ -45,7 +45,7 @@ class TestCaddyfileFromRegistry:
"""Caddyfile has reverse proxy routes for deployed services."""
registry = _make_registry(
deployed={
"test-svc": DeployedComponent(
"test-svc": Deployment(
runner="python",
run_cmd=["uv", "run", "test-svc"],
port=19000,
@@ -61,7 +61,7 @@ class TestCaddyfileFromRegistry:
"""Components without proxy_path are not in Caddyfile."""
registry = _make_registry(
deployed={
"test-tool": DeployedComponent(
"test-tool": Deployment(
runner="command",
run_cmd=["test-tool"],
),
@@ -81,7 +81,7 @@ class TestCaddyfileFromRegistry:
"""Service proxy routes appear before the dashboard catch-all."""
registry = _make_registry(
deployed={
"test-svc": DeployedComponent(
"test-svc": Deployment(
runner="python",
run_cmd=["uv", "run", "test-svc"],
port=19000,
@@ -98,13 +98,13 @@ class TestCaddyfileFromRegistry:
"""Multiple services get separate proxy routes."""
registry = _make_registry(
deployed={
"svc-a": DeployedComponent(
"svc-a": Deployment(
runner="python",
run_cmd=["uv", "run", "svc-a"],
port=9001,
proxy_path="/svc-a",
),
"svc-b": DeployedComponent(
"svc-b": Deployment(
runner="python",
run_cmd=["uv", "run", "svc-b"],
port=9002,
@@ -126,14 +126,14 @@ class TestCaddyfileRemoteRegistries:
"""Remote services get reverse_proxy entries to their hostname."""
local = _make_registry(
deployed={
"local-svc": DeployedComponent(
"local-svc": Deployment(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/local"
),
}
)
remote = _make_registry(
deployed={
"remote-svc": DeployedComponent(
"remote-svc": Deployment(
runner="python", run_cmd=["svc"], port=9050, proxy_path="/remote"
),
}
@@ -148,14 +148,14 @@ class TestCaddyfileRemoteRegistries:
"""If local and remote use the same path, local wins."""
local = _make_registry(
deployed={
"svc": DeployedComponent(
"svc": Deployment(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
remote = _make_registry(
deployed={
"svc": DeployedComponent(
"svc": Deployment(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
@@ -169,7 +169,7 @@ class TestCaddyfileRemoteRegistries:
"""No remote routes when remote_registries is None."""
local = _make_registry(
deployed={
"svc": DeployedComponent(
"svc": Deployment(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}

View File

@@ -57,7 +57,7 @@ class TestLoadConfig:
"""Service references a component."""
config = load_config(castle_root)
svc = config.services["test-svc"]
assert svc.component == "test-svc-comp"
assert svc.program == "test-svc-comp"
def test_job_schedule(self, castle_root: Path) -> None:
"""Job has correct schedule."""

View File

@@ -7,11 +7,11 @@ from unittest.mock import patch
from castle_core import deploy as deploy_mod
from castle_core.deploy import _desired_unit_files, _prune_orphans
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
def _svc(managed: bool = True, schedule: str | None = None) -> DeployedComponent:
return DeployedComponent(
def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
return Deployment(
runner="python",
run_cmd=["x"],
env={},
@@ -20,7 +20,7 @@ def _svc(managed: bool = True, schedule: str | None = None) -> DeployedComponent
)
def _registry(**deployed: DeployedComponent) -> NodeRegistry:
def _registry(**deployed: Deployment) -> NodeRegistry:
return NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000), deployed=dict(deployed))

View File

@@ -82,10 +82,10 @@ class TestServiceSpec:
"""Service can reference a component."""
s = ServiceSpec(
id="svc",
component="my-component",
program="my-component",
run=RunPython(runner="python", program="svc"),
)
assert s.component == "my-component"
assert s.program == "my-component"
def test_service_with_proxy(self) -> None:
"""Service with proxy spec."""
@@ -139,11 +139,11 @@ class TestJobSpec:
"""Job can reference a component."""
j = JobSpec(
id="sync",
component="protonmail",
program="protonmail",
run=RunCommand(runner="command", argv=["protonmail", "sync"]),
schedule="*/5 * * * *",
)
assert j.component == "protonmail"
assert j.program == "protonmail"
def test_job_requires_schedule(self) -> None:
"""Job without schedule is invalid."""

View File

@@ -7,7 +7,7 @@ from castle_core.generators.systemd import (
generate_unit_from_deployed,
unit_name,
)
from castle_core.registry import DeployedComponent
from castle_core.registry import Deployment
class TestUnitName:
@@ -24,7 +24,7 @@ class TestUnitFromDeployed:
def test_contains_description(self) -> None:
"""Unit file has service description."""
deployed = DeployedComponent(
deployed = Deployment(
runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={"TEST_SVC_PORT": "19000"},
@@ -35,7 +35,7 @@ class TestUnitFromDeployed:
def test_no_working_directory(self) -> None:
"""Unit file has no WorkingDirectory (source/runtime separation)."""
deployed = DeployedComponent(
deployed = Deployment(
runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={},
@@ -46,7 +46,7 @@ class TestUnitFromDeployed:
def test_contains_environment(self) -> None:
"""Unit file has environment variables from deployed config."""
deployed = DeployedComponent(
deployed = Deployment(
runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"},
@@ -57,7 +57,7 @@ class TestUnitFromDeployed:
def test_contains_restart_policy(self) -> None:
"""Unit file has restart configuration."""
deployed = DeployedComponent(
deployed = Deployment(
runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={},
@@ -68,7 +68,7 @@ class TestUnitFromDeployed:
def test_exec_start_from_run_cmd(self) -> None:
"""Unit file ExecStart uses resolved run_cmd."""
deployed = DeployedComponent(
deployed = Deployment(
runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={},
@@ -79,7 +79,7 @@ class TestUnitFromDeployed:
def test_basic_service(self) -> None:
"""Generate a unit from a deployed component."""
deployed = DeployedComponent(
deployed = Deployment(
runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/home/user/.castle/data/my-svc"},
@@ -95,7 +95,7 @@ class TestUnitFromDeployed:
def test_scheduled_job(self) -> None:
"""Scheduled component generates oneshot unit."""
deployed = DeployedComponent(
deployed = Deployment(
runner="command",
run_cmd=["/home/user/.local/bin/my-job"],
env={},
@@ -108,7 +108,7 @@ class TestUnitFromDeployed:
def test_no_repo_paths(self) -> None:
"""Generated units must not reference repo paths."""
deployed = DeployedComponent(
deployed = Deployment(
runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"DATA_DIR": "/home/user/.castle/data/my-svc"},

View File

@@ -76,7 +76,7 @@ class TestUnitExpansion:
assert svc.expose.http.health_path == "/health"
assert svc.proxy.caddy.path_prefix == "/my-svc"
assert svc.manage.systemd is not None
assert svc.component == "my-svc"
assert svc.program == "my-svc"
def test_tool_creates_program_only(self, units_root: Path) -> None:
config = load_config(units_root)
@@ -108,7 +108,7 @@ class TestUnitExpansion:
assert job.run.runner == "command"
assert job.run.argv == ["my-job", "run"]
assert job.defaults.env["DATA_DIR"] == "/tmp/data"
assert job.component == "my-job"
assert job.program == "my-job"
def test_service_without_path_prefix(self, tmp_path: Path) -> None:
config_data = {

View File

@@ -4,6 +4,33 @@ How castle tracks, configures, and manages programs, services, and jobs.
This is the central reference for `castle.yaml` structure and the registry
architecture.
## Vocabulary (canonical)
Use these terms consistently across code, CLI, API, and docs.
- **program** — any project castle manages, regardless of what it does. The
software catalog (`programs:`). Every program has a **behavior** and an
optional **stack**. *("component" was the old name for program — don't use it.)*
- **behavior** — what a program *is*: `tool` (a CLI you invoke), `daemon` (a
long-running server), `frontend` (a web UI). A property of the program,
independent of whether/how it's deployed.
- **stack** — a creation-time toolchain + scaffold template (`python-cli`,
`python-fastapi`, `react-vite`). Optional; seeds a program's default dev
commands but isn't required at runtime.
- **service** — a program deployed as a long-running systemd `.service`
(`services:`).
- **job** — a program deployed as a scheduled systemd `.timer` (+ oneshot)
(`jobs:`).
- **deployment** — the umbrella for "a service or a job" (a program materialized
into the runtime). The registry's deployed entries are deployments.
**Two orthogonal axes.** *behavior* (tool/daemon/frontend) is **what** a program
is; *service/job* is **how/when** it's deployed. They're independent: a program
may have neither (a tool you just install), a **service** (always-on), or a
**job** (scheduled). A `daemon`-behavior program is usually deployed as a
service; a `tool`-behavior program may back a job or just be installed for
manual use.
## castle.yaml
The single source of truth. Lives at `~/.castle/castle.yaml`. Three top-level

View File

@@ -4,7 +4,7 @@
> A stack is a template + conventions, not a runtime requirement. `castle create
> --stack python-cli` scaffolds from it and seeds the program's default dev-verb
> commands. An existing CLI adopted with `castle add` doesn't need this stack — it
> declares its own `commands:`. See @docs/component-registry.md for `commands:`,
> declares its own `commands:`. See @docs/registry.md for `commands:`,
> `stack:` (optional), and `repo:`.
How to build CLI tools following Unix philosophy.
@@ -344,4 +344,4 @@ programs:
Tools live in the `programs:` section. If a tool also runs on a schedule,
add a separate entry in the `jobs:` section referencing the component.
See @docs/component-registry.md for the full registry reference.
See @docs/registry.md for the full registry reference.

View File

@@ -4,7 +4,7 @@
> A stack is a template + conventions, not a runtime requirement. `castle create
> --stack python-fastapi` scaffolds from it and seeds the program's default
> dev-verb commands. An existing service adopted with `castle add` doesn't need
> this stack — it declares its own `commands:`. See @docs/component-registry.md
> this stack — it declares its own `commands:`. See @docs/registry.md
> for `commands:`, `stack:` (optional), and `repo:`.
How to build Python web APIs as castle service components. Based on the
@@ -448,5 +448,5 @@ uv run ruff format . # Format
castle create my-service --stack python-fastapi --description "Does something useful"
```
See @docs/component-registry.md for manifest fields, castle.yaml structure,
See @docs/registry.md for manifest fields, castle.yaml structure,
and the full service lifecycle (enable, logs, gateway reload).

View File

@@ -4,7 +4,7 @@
> A stack is a template + conventions, not a runtime requirement. `castle create
> --stack react-vite` scaffolds from it and seeds the program's default dev-verb
> commands. An existing frontend adopted with `castle add` doesn't need this
> stack — it declares its own `commands:`. See @docs/component-registry.md for
> stack — it declares its own `commands:`. See @docs/registry.md for
> `commands:`, `stack:` (optional), and `repo:`.
How to build, serve, and manage web frontends as castle components.
@@ -152,7 +152,7 @@ services:
caddy: { path_prefix: /app }
```
See @docs/component-registry.md for the full registry reference.
See @docs/registry.md for the full registry reference.
## Serving with Caddy