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, Each program has a **stack** (development toolchain: python-fastapi,
python-cli, react-vite) and a **behavior** (runtime role: daemon, tool, python-cli, react-vite) and a **behavior** (runtime role: daemon, tool,
frontend). Scheduling, systemd management, and proxying are orthogonal 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. description fallthrough.
**Key principle:** Regular projects must never depend on castle. They accept standard **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): 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-fastapi.md — FastAPI service patterns (config, routes, models, testing)
- @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing) - @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
- @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns - @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns
@@ -123,11 +123,22 @@ Core:
- `GET /health` — Health check - `GET /health` — Health check
- `GET /stream` — SSE stream (health, service-action, mesh events) - `GET /stream` — SSE stream (health, service-action, mesh events)
Components: Deployments (the unified view of services + jobs + programs):
- `GET /components` — List all (add `?include_remote=true` for cross-node) - `GET /deployments` — List all (add `?include_remote=true` for cross-node)
- `GET /components/{name}`Component detail - `GET /deployments/{name}`Deployment detail
- `GET /status` — Live health for all services - `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: Gateway:
- `GET /gateway` — Gateway info with route table and hostname - `GET /gateway` — Gateway info with route table and hostname
- `GET /gateway/caddyfile` — Generated Caddyfile content - `GET /gateway/caddyfile` — Generated Caddyfile content
@@ -138,16 +149,10 @@ Mesh:
- `GET /nodes` — All known nodes (local + discovered remote) - `GET /nodes` — All known nodes (local + discovered remote)
- `GET /nodes/{hostname}` — Node detail with deployed components - `GET /nodes/{hostname}` — Node detail with deployed components
Services: Service actions:
- `POST /services/{name}/{action}` — start/stop/restart - `POST /services/{name}/{action}` — start/stop/restart
- `GET /services/{name}/unit` — Systemd unit content - `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) ## Mesh Coordination (opt-in)
Multi-node discovery is disabled by default. Enable via env vars: 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. 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. 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 { useState } from "react"
import { ChevronDown, ChevronRight } from "lucide-react" import { ChevronDown, ChevronRight } from "lucide-react"
import type { ComponentDetail } from "@/types" import type { DeploymentDetail } from "@/types"
import { ComponentFields } from "./ComponentFields" import { ComponentFields } from "./ComponentFields"
import { BehaviorBadge } from "./BehaviorBadge" import { BehaviorBadge } from "./BehaviorBadge"
import { StackBadge } from "./StackBadge" import { StackBadge } from "./StackBadge"
interface ComponentEditorProps { interface ComponentEditorProps {
component: ComponentDetail component: DeploymentDetail
onSave: (name: string, config: Record<string, unknown>) => Promise<void> onSave: (name: string, config: Record<string, unknown>) => Promise<void>
onDelete: (name: string) => Promise<void> onDelete: (name: string) => Promise<void>
} }

View File

@@ -73,7 +73,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</thead> </thead>
<tbody> <tbody>
{gateway.routes.map((route) => { {gateway.routes.map((route) => {
const health = statusMap.get(route.component) const health = statusMap.get(route.program)
return ( return (
<tr <tr
key={route.path} key={route.path}
@@ -84,10 +84,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
</td> </td>
<td className="px-4 py-2"> <td className="px-4 py-2">
<Link <Link
to={`/component/${route.component}`} to={`/deployment/${route.program}`}
className="hover:text-[var(--primary)] transition-colors" className="hover:text-[var(--primary)] transition-colors"
> >
{route.component} {route.program}
</Link> </Link>
</td> </td>
<td className="px-4 py-2 font-mono text-[var(--muted)]"> <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 { ArrowLeft, Check, Loader2, Zap } from "lucide-react"
import { useQuery, useQueryClient } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { apiClient } from "@/services/api/client" import { apiClient } from "@/services/api/client"
import type { ComponentDetail } from "@/types" import type { DeploymentDetail } from "@/types"
import { AddComponent } from "@/components/AddComponent" import { AddComponent } from "@/components/AddComponent"
import { ComponentEditor } from "@/components/ComponentEditor" import { ComponentEditor } from "@/components/ComponentEditor"
@@ -24,7 +24,7 @@ export function ConfigEditorPage() {
queryFn: async () => { queryFn: async () => {
const list = await apiClient.get<{ id: string }[]>("/components") const list = await apiClient.get<{ id: string }[]>("/components")
const details = await Promise.all( 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 return details
}, },

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,7 +65,7 @@ async def get_logs(
) )
stdout, _ = await proc.communicate() stdout, _ = await proc.communicate()
lines = (stdout or b"").decode().splitlines() 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]: async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:

View File

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

View File

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

View File

@@ -108,7 +108,7 @@ async def _do_action(name: str, action: str) -> JSONResponse:
asyncio.create_task(_deferred_systemctl(action, unit)) asyncio.create_task(_deferred_systemctl(action, unit))
return JSONResponse( return JSONResponse(
status_code=202, status_code=202,
content={"component": name, "action": action, "status": "accepted"}, content={"program": name, "action": action, "status": "accepted"},
) )
ok, output = await _systemctl(action, unit) 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) await _broadcast_health_with_override(name, unit_status)
return JSONResponse( 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 import castle_api.config as api_config
from castle_api.main import app from castle_api.main import app
from castle_core.registry import ( from castle_core.registry import (
DeployedComponent, Deployment,
NodeConfig, NodeConfig,
NodeRegistry, NodeRegistry,
save_registry, save_registry,
@@ -50,7 +50,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
}, },
"services": { "services": {
"test-svc": { "test-svc": {
"component": "test-svc-comp", "program": "test-svc-comp",
"description": "Test service", "description": "Test service",
"run": { "run": {
"runner": "python", "runner": "python",
@@ -92,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
gateway_port=9000, gateway_port=9000,
), ),
deployed={ deployed={
"test-svc": DeployedComponent( "test-svc": Deployment(
runner="python", runner="python",
run_cmd=["uv", "run", "test-svc"], run_cmd=["uv", "run", "test-svc"],
env={ env={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,13 +54,13 @@ def _resolve_stack(config: object, name: str) -> str | None:
# Check services for program ref # Check services for program ref
if name in config.services: if name in config.services:
svc = config.services[name] svc = config.services[name]
comp_name = svc.component comp_name = svc.program
if comp_name and comp_name in config.programs: if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack return config.programs[comp_name].stack
# Check jobs for program ref # Check jobs for program ref
if name in config.jobs: if name in config.jobs:
job = config.jobs[name] job = config.jobs[name]
comp_name = job.component comp_name = job.program
if comp_name and comp_name in config.programs: if comp_name and comp_name in config.programs:
return config.programs[comp_name].stack return config.programs[comp_name].stack
# Direct program # 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 from castle_core.registry import ( # noqa: F401
REGISTRY_PATH, REGISTRY_PATH,
DeployedComponent, Deployment,
NodeConfig, NodeConfig,
NodeRegistry, NodeRegistry,
load_registry, load_registry,

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,7 @@ import shutil
from pathlib import Path from pathlib import Path
from castle_core.manifest import RestartPolicy, SystemdSpec 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" SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
UNIT_PREFIX = "castle-" UNIT_PREFIX = "castle-"
@@ -74,7 +74,7 @@ def cron_to_interval_sec(cron: str) -> int | None:
def generate_unit_from_deployed( def generate_unit_from_deployed(
name: str, name: str,
deployed: DeployedComponent, deployed: Deployment,
systemd_spec: SystemdSpec | None = None, systemd_spec: SystemdSpec | None = None,
) -> str: ) -> str:
"""Generate a systemd unit from a deployed component (registry-based). """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 enum import Enum
from typing import Annotated, Literal, Union 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] EnvMap = dict[str, str]
@@ -247,8 +247,13 @@ class ProgramSpec(BaseModel):
class ServiceSpec(BaseModel): class ServiceSpec(BaseModel):
"""Long-running daemon deployment config.""" """Long-running daemon deployment config."""
model_config = ConfigDict(populate_by_name=True)
id: str = "" 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 description: str | None = None
run: RunSpec run: RunSpec
@@ -274,8 +279,13 @@ class ServiceSpec(BaseModel):
class JobSpec(BaseModel): class JobSpec(BaseModel):
"""Scheduled task that runs periodically and exits.""" """Scheduled task that runs periodically and exits."""
model_config = ConfigDict(populate_by_name=True)
id: str = "" 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 description: str | None = None
run: RunSpec run: RunSpec

View File

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

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ class TestLoadConfig:
"""Service references a component.""" """Service references a component."""
config = load_config(castle_root) config = load_config(castle_root)
svc = config.services["test-svc"] 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: def test_job_schedule(self, castle_root: Path) -> None:
"""Job has correct schedule.""" """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 import deploy as deploy_mod
from castle_core.deploy import _desired_unit_files, _prune_orphans 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: def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
return DeployedComponent( return Deployment(
runner="python", runner="python",
run_cmd=["x"], run_cmd=["x"],
env={}, 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)) 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.""" """Service can reference a component."""
s = ServiceSpec( s = ServiceSpec(
id="svc", id="svc",
component="my-component", program="my-component",
run=RunPython(runner="python", program="svc"), run=RunPython(runner="python", program="svc"),
) )
assert s.component == "my-component" assert s.program == "my-component"
def test_service_with_proxy(self) -> None: def test_service_with_proxy(self) -> None:
"""Service with proxy spec.""" """Service with proxy spec."""
@@ -139,11 +139,11 @@ class TestJobSpec:
"""Job can reference a component.""" """Job can reference a component."""
j = JobSpec( j = JobSpec(
id="sync", id="sync",
component="protonmail", program="protonmail",
run=RunCommand(runner="command", argv=["protonmail", "sync"]), run=RunCommand(runner="command", argv=["protonmail", "sync"]),
schedule="*/5 * * * *", schedule="*/5 * * * *",
) )
assert j.component == "protonmail" assert j.program == "protonmail"
def test_job_requires_schedule(self) -> None: def test_job_requires_schedule(self) -> None:
"""Job without schedule is invalid.""" """Job without schedule is invalid."""

View File

@@ -7,7 +7,7 @@ from castle_core.generators.systemd import (
generate_unit_from_deployed, generate_unit_from_deployed,
unit_name, unit_name,
) )
from castle_core.registry import DeployedComponent from castle_core.registry import Deployment
class TestUnitName: class TestUnitName:
@@ -24,7 +24,7 @@ class TestUnitFromDeployed:
def test_contains_description(self) -> None: def test_contains_description(self) -> None:
"""Unit file has service description.""" """Unit file has service description."""
deployed = DeployedComponent( deployed = Deployment(
runner="python", runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={"TEST_SVC_PORT": "19000"}, env={"TEST_SVC_PORT": "19000"},
@@ -35,7 +35,7 @@ class TestUnitFromDeployed:
def test_no_working_directory(self) -> None: def test_no_working_directory(self) -> None:
"""Unit file has no WorkingDirectory (source/runtime separation).""" """Unit file has no WorkingDirectory (source/runtime separation)."""
deployed = DeployedComponent( deployed = Deployment(
runner="python", runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={}, env={},
@@ -46,7 +46,7 @@ class TestUnitFromDeployed:
def test_contains_environment(self) -> None: def test_contains_environment(self) -> None:
"""Unit file has environment variables from deployed config.""" """Unit file has environment variables from deployed config."""
deployed = DeployedComponent( deployed = Deployment(
runner="python", runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/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: def test_contains_restart_policy(self) -> None:
"""Unit file has restart configuration.""" """Unit file has restart configuration."""
deployed = DeployedComponent( deployed = Deployment(
runner="python", runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={}, env={},
@@ -68,7 +68,7 @@ class TestUnitFromDeployed:
def test_exec_start_from_run_cmd(self) -> None: def test_exec_start_from_run_cmd(self) -> None:
"""Unit file ExecStart uses resolved run_cmd.""" """Unit file ExecStart uses resolved run_cmd."""
deployed = DeployedComponent( deployed = Deployment(
runner="python", runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={}, env={},
@@ -79,7 +79,7 @@ class TestUnitFromDeployed:
def test_basic_service(self) -> None: def test_basic_service(self) -> None:
"""Generate a unit from a deployed component.""" """Generate a unit from a deployed component."""
deployed = DeployedComponent( deployed = Deployment(
runner="python", runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"], 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"}, 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: def test_scheduled_job(self) -> None:
"""Scheduled component generates oneshot unit.""" """Scheduled component generates oneshot unit."""
deployed = DeployedComponent( deployed = Deployment(
runner="command", runner="command",
run_cmd=["/home/user/.local/bin/my-job"], run_cmd=["/home/user/.local/bin/my-job"],
env={}, env={},
@@ -108,7 +108,7 @@ class TestUnitFromDeployed:
def test_no_repo_paths(self) -> None: def test_no_repo_paths(self) -> None:
"""Generated units must not reference repo paths.""" """Generated units must not reference repo paths."""
deployed = DeployedComponent( deployed = Deployment(
runner="python", runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"DATA_DIR": "/home/user/.castle/data/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.expose.http.health_path == "/health"
assert svc.proxy.caddy.path_prefix == "/my-svc" assert svc.proxy.caddy.path_prefix == "/my-svc"
assert svc.manage.systemd is not None 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: def test_tool_creates_program_only(self, units_root: Path) -> None:
config = load_config(units_root) config = load_config(units_root)
@@ -108,7 +108,7 @@ class TestUnitExpansion:
assert job.run.runner == "command" assert job.run.runner == "command"
assert job.run.argv == ["my-job", "run"] assert job.run.argv == ["my-job", "run"]
assert job.defaults.env["DATA_DIR"] == "/tmp/data" 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: def test_service_without_path_prefix(self, tmp_path: Path) -> None:
config_data = { 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 This is the central reference for `castle.yaml` structure and the registry
architecture. 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 ## castle.yaml
The single source of truth. Lives at `~/.castle/castle.yaml`. Three top-level 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 > 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 > --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 > 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:`. > `stack:` (optional), and `repo:`.
How to build CLI tools following Unix philosophy. 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, 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. 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 > A stack is a template + conventions, not a runtime requirement. `castle create
> --stack python-fastapi` scaffolds from it and seeds the program's default > --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 > 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:`. > for `commands:`, `stack:` (optional), and `repo:`.
How to build Python web APIs as castle service components. Based on the 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" 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). 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 > 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 > --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 > 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:`. > `commands:`, `stack:` (optional), and `repo:`.
How to build, serve, and manage web frontends as castle components. How to build, serve, and manage web frontends as castle components.
@@ -152,7 +152,7 @@ services:
caddy: { path_prefix: /app } 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 ## Serving with Caddy