From 3f3b88f17b15401cd3857c165b6b1d6f3038192b Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 14 Jun 2026 11:05:22 -0700 Subject: [PATCH] =?UTF-8?q?refactor:=20Align=20vocabulary=20=E2=80=94=20co?= =?UTF-8?q?mponent=20=E2=86=92=20program=20/=20deployment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 29 ++++--- README.md | 2 +- app/src/components/ComponentEditor.tsx | 4 +- app/src/components/GatewayPanel.tsx | 6 +- app/src/pages/ConfigEditor.tsx | 4 +- app/src/pages/NodeDetail.tsx | 2 +- app/src/router/routes.tsx | 2 +- app/src/services/api/hooks.ts | 10 +-- app/src/types/index.ts | 14 ++-- castle-api/src/castle_api/logs.py | 2 +- castle-api/src/castle_api/models.py | 12 +-- castle-api/src/castle_api/mqtt_client.py | 6 +- castle-api/src/castle_api/nodes.py | 8 +- castle-api/src/castle_api/programs.py | 2 +- castle-api/src/castle_api/routes.py | 88 ++++++++++----------- castle-api/src/castle_api/services.py | 4 +- castle-api/tests/conftest.py | 6 +- castle-api/tests/test_health.py | 18 ++--- castle-api/tests/test_mesh.py | 4 +- castle-api/tests/test_mqtt.py | 8 +- castle-api/tests/test_nodes.py | 4 +- cli/src/castle_cli/commands/create.py | 2 +- cli/src/castle_cli/commands/info.py | 28 +++---- cli/src/castle_cli/commands/list_cmd.py | 4 +- cli/src/castle_cli/config.py | 2 +- cli/tests/conftest.py | 2 +- cli/tests/test_create.py | 2 +- core/src/castle_core/config.py | 4 +- core/src/castle_core/deploy.py | 52 ++++++------ core/src/castle_core/generators/systemd.py | 4 +- core/src/castle_core/manifest.py | 16 +++- core/src/castle_core/registry.py | 8 +- core/src/castle_core/stacks.py | 52 ++++++------ core/tests/conftest.py | 2 +- core/tests/test_caddyfile.py | 24 +++--- core/tests/test_config.py | 2 +- core/tests/test_deploy_prune.py | 8 +- core/tests/test_manifest.py | 8 +- core/tests/test_systemd.py | 18 ++--- core/tests/test_units.py | 4 +- docs/{component-registry.md => registry.md} | 27 +++++++ docs/stacks/python-cli.md | 4 +- docs/stacks/python-fastapi.md | 4 +- docs/stacks/react-vite.md | 4 +- 44 files changed, 279 insertions(+), 237 deletions(-) rename docs/{component-registry.md => registry.md} (89%) diff --git a/CLAUDE.md b/CLAUDE.md index 65baba5..4e5fd5e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ Castle is a personal software platform — a monorepo of independent projects Each program has a **stack** (development toolchain: python-fastapi, python-cli, react-vite) and a **behavior** (runtime role: daemon, tool, frontend). Scheduling, systemd management, and proxying are orthogonal -operations. Services and jobs reference a program via `component:` for +operations. Services and jobs reference a program via `program:` for description fallthrough. **Key principle:** Regular projects must never depend on castle. They accept standard @@ -41,7 +41,7 @@ verb commands and scaffold, but a program stands on its own via its declared Stack guides (for writing *new* code, AI-facing): -- @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle +- @docs/registry.md — Registry architecture, castle.yaml structure, lifecycle - @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing) - @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing) - @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns @@ -123,11 +123,22 @@ Core: - `GET /health` — Health check - `GET /stream` — SSE stream (health, service-action, mesh events) -Components: -- `GET /components` — List all (add `?include_remote=true` for cross-node) -- `GET /components/{name}` — Component detail +Deployments (the unified view of services + jobs + programs): +- `GET /deployments` — List all (add `?include_remote=true` for cross-node) +- `GET /deployments/{name}` — Deployment detail - `GET /status` — Live health for all services +Programs / Services / Jobs (typed views + editing): +- `GET /programs`, `GET /programs/{name}` — Program catalog (`?behavior=tool` to filter) +- `POST /programs/{name}/{action}` — Run a program verb (install/uninstall/build/…) +- `PUT|DELETE /programs/{name}` — Edit or remove a program entry +- `GET /services`, `GET /services/{name}`, `PUT|DELETE /services/{name}` +- `GET /jobs`, `GET /jobs/{name}`, `PUT|DELETE /jobs/{name}` + +Config: +- `GET /` — Full registry; `PUT /` — Save registry +- `POST /apply` — Apply registry changes; `POST /deploy` — Deploy to runtime + Gateway: - `GET /gateway` — Gateway info with route table and hostname - `GET /gateway/caddyfile` — Generated Caddyfile content @@ -138,16 +149,10 @@ Mesh: - `GET /nodes` — All known nodes (local + discovered remote) - `GET /nodes/{hostname}` — Node detail with deployed components -Services: +Service actions: - `POST /services/{name}/{action}` — start/stop/restart - `GET /services/{name}/unit` — Systemd unit content -Tools: -- `GET /tools` — List all tools -- `GET /tools/{name}` — Tool detail -- `POST /tools/{name}/install` — Install tool to PATH -- `POST /tools/{name}/uninstall` — Uninstall tool - ## Mesh Coordination (opt-in) Multi-node discovery is disabled by default. Enable via env vars: diff --git a/README.md b/README.md index cb57906..9ad3ce5 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A personal software platform. Castle manages independent services, tools, and fr Historically, applications have been developed by third parties, distributed through app stores, and installed on user devices. -With the advent of AI-assisted software development, users can write the software they need directly, eliminating the need for packaging and distribution. This makes many classes of software simpler, too. Oftentimes all a user needs are simple scripts or configurations of existing tools. But no matter how simple your script or application, it needed to be tailored and packaged for specific distribution channels. Castle provides a unified environment for developing, managing, deploying, and advertising these simple applications. +With the advent of AI-assisted software development, users can write the software they need directly, eliminating the need for packaging and distribution. This makes many classes of software simpler. Oftentimes all a user needs are simple scripts or configurations of existing tools. But no matter how simple your script or application, it needed to be tailored and packaged for specific distribution channels. Castle provides a unified environment for developing, managing, deploying, and advertising these simple applications. Castle _stacks_ are pre-configured development environments that provide a starting point for building applications. They include everything needed to get started, from the programming language and framework to the necessary dependencies and tools. This is a design intended for coding assistants to generate castle programs with a level of consistency and to ensure that they are properly configured and ready to use with Castle. If your coding assistant knows about Castle, it can help you create and manage your custom applications more efficiently. diff --git a/app/src/components/ComponentEditor.tsx b/app/src/components/ComponentEditor.tsx index 68a171f..1aa70f2 100644 --- a/app/src/components/ComponentEditor.tsx +++ b/app/src/components/ComponentEditor.tsx @@ -1,12 +1,12 @@ import { useState } from "react" import { ChevronDown, ChevronRight } from "lucide-react" -import type { ComponentDetail } from "@/types" +import type { DeploymentDetail } from "@/types" import { ComponentFields } from "./ComponentFields" import { BehaviorBadge } from "./BehaviorBadge" import { StackBadge } from "./StackBadge" interface ComponentEditorProps { - component: ComponentDetail + component: DeploymentDetail onSave: (name: string, config: Record) => Promise onDelete: (name: string) => Promise } diff --git a/app/src/components/GatewayPanel.tsx b/app/src/components/GatewayPanel.tsx index 6dd8c4f..93b0b3b 100644 --- a/app/src/components/GatewayPanel.tsx +++ b/app/src/components/GatewayPanel.tsx @@ -73,7 +73,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { {gateway.routes.map((route) => { - const health = statusMap.get(route.component) + const health = statusMap.get(route.program) return ( - {route.component} + {route.program} diff --git a/app/src/pages/ConfigEditor.tsx b/app/src/pages/ConfigEditor.tsx index 7487d98..abdb7dc 100644 --- a/app/src/pages/ConfigEditor.tsx +++ b/app/src/pages/ConfigEditor.tsx @@ -3,7 +3,7 @@ import { Link } from "react-router-dom" import { ArrowLeft, Check, Loader2, Zap } from "lucide-react" import { useQuery, useQueryClient } from "@tanstack/react-query" import { apiClient } from "@/services/api/client" -import type { ComponentDetail } from "@/types" +import type { DeploymentDetail } from "@/types" import { AddComponent } from "@/components/AddComponent" import { ComponentEditor } from "@/components/ComponentEditor" @@ -24,7 +24,7 @@ export function ConfigEditorPage() { queryFn: async () => { const list = await apiClient.get<{ id: string }[]>("/components") const details = await Promise.all( - list.map((c) => apiClient.get(`/components/${c.id}`)) + list.map((c) => apiClient.get(`/components/${c.id}`)) ) return details }, diff --git a/app/src/pages/NodeDetail.tsx b/app/src/pages/NodeDetail.tsx index 930a600..355ee15 100644 --- a/app/src/pages/NodeDetail.tsx +++ b/app/src/pages/NodeDetail.tsx @@ -77,7 +77,7 @@ export function NodeDetailPage() { > {comp.id} diff --git a/app/src/router/routes.tsx b/app/src/router/routes.tsx index b72e471..2135340 100644 --- a/app/src/router/routes.tsx +++ b/app/src/router/routes.tsx @@ -24,7 +24,7 @@ export const router = createBrowserRouter([ element: , }, { - path: "/component/:name", + path: "/deployment/:name", element: , }, { diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index 83f78c8..5fd6eaf 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -2,7 +2,7 @@ import { useEffect } from "react" import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query" import { apiClient } from "./client" import type { - ComponentDetail, + DeploymentDetail, ServiceSummary, ServiceDetail, JobSummary, @@ -21,8 +21,8 @@ import type { // Legacy compat hook — used by ConfigEditorPage and ProgramRedirect export function useComponent(name: string) { return useQuery({ - queryKey: ["components", name], - queryFn: () => apiClient.get(`/components/${name}`), + queryKey: ["deployments", name], + queryFn: () => apiClient.get(`/deployments/${name}`), enabled: !!name, }) } @@ -125,7 +125,7 @@ export function useServiceAction() { } catch (err) { // Network error from self-restart killing the connection — expected if (err instanceof TypeError) { - return { component: name, action, status: "accepted" } as ServiceActionResponse + return { program: name, action, status: "accepted" } as ServiceActionResponse } throw err } @@ -144,7 +144,7 @@ export function useProgramAction() { const qc = useQueryClient() return useMutation({ mutationFn: ({ name, action }: { name: string; action: string }) => - apiClient.post<{ component: string; action: string; status: string; output: string }>( + apiClient.post<{ program: string; action: string; status: string; output: string }>( `/programs/${name}/${action}`, ), onSuccess: () => { diff --git a/app/src/types/index.ts b/app/src/types/index.ts index e26efda..c8d9f36 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -64,7 +64,7 @@ export interface ProgramDetail extends ProgramSummary { export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail // Legacy unified type — kept for NodeDetail.deployed and compat endpoint -export interface ComponentSummary { +export interface DeploymentSummary { id: string category: "program" | "service" | "job" | null description: string | null @@ -88,7 +88,7 @@ export interface ComponentSummary { node: string | null } -export interface ComponentDetail extends ComponentSummary { +export interface DeploymentDetail extends DeploymentSummary { manifest: Record } @@ -105,21 +105,21 @@ export interface StatusResponse { export interface GatewayRoute { path: string target_port: number - component: string + program: string node: string } export interface GatewayInfo { port: number hostname: string - component_count: number + deployment_count: number service_count: number managed_count: number routes: GatewayRoute[] } export interface ServiceActionResponse { - component: string + program: string action: string status: string } @@ -131,7 +131,7 @@ export interface SSEHealthEvent { export interface SSEServiceActionEvent { action: string - component: string + program: string status: string } @@ -147,7 +147,7 @@ export interface NodeSummary { } export interface NodeDetail extends NodeSummary { - deployed: ComponentSummary[] + deployed: DeploymentSummary[] } export interface MeshStatus { diff --git a/castle-api/src/castle_api/logs.py b/castle-api/src/castle_api/logs.py index 5286526..5a0ccf5 100644 --- a/castle-api/src/castle_api/logs.py +++ b/castle-api/src/castle_api/logs.py @@ -65,7 +65,7 @@ async def get_logs( ) stdout, _ = await proc.communicate() lines = (stdout or b"").decode().splitlines() - return {"component": name, "lines": lines} + return {"name": name, "lines": lines} async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]: diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index a954503..22d3e89 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -11,7 +11,7 @@ class SystemdInfo(BaseModel): timer: bool = False -class ComponentSummary(BaseModel): +class DeploymentSummary(BaseModel): """Summary of a single component.""" id: str @@ -37,7 +37,7 @@ class ComponentSummary(BaseModel): node: str | None = None -class ComponentDetail(ComponentSummary): +class DeploymentDetail(DeploymentSummary): """Full detail for a single component, including raw manifest.""" manifest: dict @@ -130,7 +130,7 @@ class GatewayRoute(BaseModel): path: str target_port: int - component: str + program: str node: str @@ -139,7 +139,7 @@ class GatewayInfo(BaseModel): port: int hostname: str - component_count: int + deployment_count: int service_count: int managed_count: int routes: list[GatewayRoute] = [] @@ -161,7 +161,7 @@ class NodeSummary(BaseModel): class NodeDetail(NodeSummary): """Full detail for a node, including its deployed components.""" - deployed: list[ComponentSummary] = [] + deployed: list[DeploymentSummary] = [] class MeshStatus(BaseModel): @@ -179,6 +179,6 @@ class MeshStatus(BaseModel): class ServiceActionResponse(BaseModel): """Response from a service management action.""" - component: str + program: str action: str status: str diff --git a/castle-api/src/castle_api/mqtt_client.py b/castle-api/src/castle_api/mqtt_client.py index f39179a..79a4d72 100644 --- a/castle-api/src/castle_api/mqtt_client.py +++ b/castle-api/src/castle_api/mqtt_client.py @@ -16,7 +16,7 @@ import logging import paho.mqtt.client as mqtt from castle_core.registry import ( - DeployedComponent, + Deployment, NodeConfig, NodeRegistry, ) @@ -74,9 +74,9 @@ def _json_to_registry(payload: str) -> NodeRegistry: castle_root=node_data.get("castle_root"), gateway_port=node_data.get("gateway_port", 9000), ) - deployed: dict[str, DeployedComponent] = {} + deployed: dict[str, Deployment] = {} for name, comp_data in data.get("deployed", {}).items(): - deployed[name] = DeployedComponent( + deployed[name] = Deployment( runner=comp_data.get("runner", "command"), run_cmd=comp_data.get("run_cmd", []), env=comp_data.get("env", {}), diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py index 7049f9c..2f601fe 100644 --- a/castle-api/src/castle_api/nodes.py +++ b/castle-api/src/castle_api/nodes.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Request, status from castle_api.config import get_registry, settings from castle_api.mesh import mesh_state -from castle_api.models import ComponentSummary, MeshStatus, NodeDetail, NodeSummary +from castle_api.models import DeploymentSummary, MeshStatus, NodeDetail, NodeSummary router = APIRouter(tags=["nodes"]) @@ -39,12 +39,12 @@ def _remote_node_summary(hostname: str, remote: object) -> NodeSummary: ) -def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSummary]: - """Convert deployed components from a registry into ComponentSummary list.""" +def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSummary]: + """Convert deployed components from a registry into DeploymentSummary list.""" summaries = [] for name, d in registry.deployed.items(): summaries.append( - ComponentSummary( + DeploymentSummary( id=name, category="job" if d.schedule else "service", description=d.description, diff --git a/castle-api/src/castle_api/programs.py b/castle-api/src/castle_api/programs.py index 9f0802a..9f016ec 100644 --- a/castle-api/src/castle_api/programs.py +++ b/castle-api/src/castle_api/programs.py @@ -58,7 +58,7 @@ async def program_action(name: str, action: str) -> dict: raise HTTPException(status_code=500, detail=result.output or f"{action} failed") return { - "component": result.component, + "program": result.program, "action": result.action, "status": result.status, "output": result.output, diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 4a560b7..0222e61 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -17,8 +17,8 @@ from castle_api.config import get_castle_root, get_registry from castle_api.mesh import mesh_state from castle_api.health import check_all_health from castle_api.models import ( - ComponentDetail, - ComponentSummary, + DeploymentDetail, + DeploymentSummary, GatewayInfo, GatewayRoute, JobDetail, @@ -47,8 +47,8 @@ def _declared_commands_dict(comp: ProgramSpec) -> dict[str, list[list[str]]] | N return out or None -def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary: - """Build a ComponentSummary from a DeployedComponent.""" +def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary: + """Build a DeploymentSummary from a Deployment.""" managed = deployed.managed systemd_info: SystemdInfo | None = None @@ -69,7 +69,7 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary: category = "job" if deployed.schedule else "service" - return ComponentSummary( + return DeploymentSummary( id=name, category=category, description=deployed.description, @@ -88,8 +88,8 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary: def _summary_from_service( name: str, svc: ServiceSpec, config: object -) -> ComponentSummary: - """Build a ComponentSummary from a ServiceSpec (non-deployed).""" +) -> DeploymentSummary: + """Build a DeploymentSummary from a ServiceSpec (non-deployed).""" port = None health_path = None proxy_path = None @@ -112,8 +112,8 @@ def _summary_from_service( description = svc.description source = None stack = None - if svc.component and svc.component in config.programs: - comp = config.programs[svc.component] + if svc.program and svc.program in config.programs: + comp = config.programs[svc.program] if not description: description = comp.description source = comp.source @@ -121,7 +121,7 @@ def _summary_from_service( runner = svc.run.runner - return ComponentSummary( + return DeploymentSummary( id=name, category="service", description=description, @@ -137,8 +137,8 @@ def _summary_from_service( ) -def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSummary: - """Build a ComponentSummary from a JobSpec (non-deployed).""" +def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSummary: + """Build a DeploymentSummary from a JobSpec (non-deployed).""" managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable) systemd_info: SystemdInfo | None = None @@ -150,14 +150,14 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma description = job.description source = None stack = None - if job.component and job.component in config.programs: - comp = config.programs[job.component] + if job.program and job.program in config.programs: + comp = config.programs[job.program] if not description: description = comp.description source = comp.source stack = comp.stack - return ComponentSummary( + return DeploymentSummary( id=name, category="job", description=description, @@ -171,8 +171,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma ) -def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary: - """Build a ComponentSummary from a ProgramSpec (tools/frontends).""" +def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> DeploymentSummary: + """Build a DeploymentSummary from a ProgramSpec (tools/frontends).""" source = comp.source # Infer runner from source directory @@ -188,7 +188,7 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component if comp.source and (comp.stack or comp.commands): installed = shutil.which(name) is not None - return ComponentSummary( + return DeploymentSummary( id=name, category="program", description=comp.description, @@ -222,16 +222,16 @@ def _backfill_source(name: str, config: object) -> str | None: return config.programs[name].source ref = None if name in config.services: - ref = config.services[name].component + ref = config.services[name].program elif name in config.jobs: - ref = config.jobs[name].component + ref = config.jobs[name].program if ref and ref in config.programs: return config.programs[ref].source return None def _service_from_deployed(name: str, deployed: object) -> ServiceSummary: - """Build a ServiceSummary from a DeployedComponent.""" + """Build a ServiceSummary from a Deployment.""" systemd_info = _make_systemd_info(name) if deployed.managed else None return ServiceSummary( id=name, @@ -263,8 +263,8 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu description = svc.description source = None stack = None - if svc.component and svc.component in config.programs: - comp = config.programs[svc.component] + if svc.program and svc.program in config.programs: + comp = config.programs[svc.program] if not description: description = comp.description source = comp.source @@ -285,7 +285,7 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu def _job_from_deployed(name: str, deployed: object) -> JobSummary: - """Build a JobSummary from a DeployedComponent.""" + """Build a JobSummary from a Deployment.""" systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None return JobSummary( id=name, @@ -306,8 +306,8 @@ def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary: description = job.description source = None stack = None - if job.component and job.component in config.programs: - comp = config.programs[job.component] + if job.program and job.program in config.programs: + comp = config.programs[job.program] if not description: description = comp.description source = comp.source @@ -636,15 +636,15 @@ def get_program(name: str) -> ProgramDetail: # --------------------------------------------------------------------------- -@router.get("/components", response_model=list[ComponentSummary]) -def list_components(include_remote: bool = False) -> list[ComponentSummary]: +@router.get("/deployments", response_model=list[DeploymentSummary]) +def list_components(include_remote: bool = False) -> list[DeploymentSummary]: """List all components — deployed from registry, non-deployed from castle.yaml. Pass ?include_remote=true to include components from remote mesh nodes. """ registry = get_registry() local_hostname = registry.node.hostname - summaries: list[ComponentSummary] = [] + summaries: list[DeploymentSummary] = [] seen: set[str] = set() # Deployed components from registry @@ -686,9 +686,9 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]: # Check if a service/job references a program ref = None if s.id in config.services: - ref = config.services[s.id].component + ref = config.services[s.id].program elif s.id in config.jobs: - ref = config.jobs[s.id].component + ref = config.jobs[s.id].program if ref and ref in config.programs: s.source = config.programs[ref].source @@ -708,7 +708,7 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]: for name, d in remote.registry.deployed.items(): if name not in seen: summaries.append( - ComponentSummary( + DeploymentSummary( id=name, category="job" if d.schedule else "service", description=d.description, @@ -728,8 +728,8 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]: return summaries -@router.get("/components/{name}", response_model=ComponentDetail) -def get_component(name: str) -> ComponentDetail: +@router.get("/deployments/{name}", response_model=DeploymentDetail) +def get_component(name: str) -> DeploymentDetail: """Get detailed info for a single component.""" registry = get_registry() @@ -749,9 +749,9 @@ def get_component(name: str) -> ComponentDetail: else: ref = None if name in config.services: - ref = config.services[name].component + ref = config.services[name].program elif name in config.jobs: - ref = config.jobs[name].component + ref = config.jobs[name].program if ref and ref in config.programs: summary.source = config.programs[ref].source except FileNotFoundError: @@ -768,7 +768,7 @@ def get_component(name: str) -> ComponentDetail: "behavior": deployed.behavior, "stack": deployed.stack, } - return ComponentDetail(**summary.model_dump(), manifest=raw) + return DeploymentDetail(**summary.model_dump(), manifest=raw) # Fall back to castle.yaml root = get_castle_root() @@ -781,23 +781,23 @@ def get_component(name: str) -> ComponentDetail: svc = config.services[name] summary = _summary_from_service(name, svc, config) raw = svc.model_dump(mode="json", exclude_none=True) - return ComponentDetail(**summary.model_dump(), manifest=raw) + return DeploymentDetail(**summary.model_dump(), manifest=raw) if name in config.jobs: job = config.jobs[name] summary = _summary_from_job(name, job, config) raw = job.model_dump(mode="json", exclude_none=True) - return ComponentDetail(**summary.model_dump(), manifest=raw) + return DeploymentDetail(**summary.model_dump(), manifest=raw) if name in config.programs: comp = config.programs[name] summary = _summary_from_program(name, comp, root) raw = comp.model_dump(mode="json", exclude_none=True) - return ComponentDetail(**summary.model_dump(), manifest=raw) + return DeploymentDetail(**summary.model_dump(), manifest=raw) raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"Component '{name}' not found", + detail=f"'{name}' not found", ) @@ -822,7 +822,7 @@ def get_gateway() -> GatewayInfo: GatewayRoute( path=d.proxy_path, target_port=d.port, - component=name, + program=name, node=registry.node.hostname, ) for name, d in registry.deployed.items() @@ -838,7 +838,7 @@ def get_gateway() -> GatewayInfo: GatewayRoute( path=d.proxy_path, target_port=d.port, - component=name, + program=name, node=hostname, ) ) @@ -848,7 +848,7 @@ def get_gateway() -> GatewayInfo: return GatewayInfo( port=registry.node.gateway_port, hostname=registry.node.hostname, - component_count=deployed_count, + deployment_count=deployed_count, service_count=service_count, managed_count=managed_count, routes=routes, diff --git a/castle-api/src/castle_api/services.py b/castle-api/src/castle_api/services.py index 72c3312..9dd640f 100644 --- a/castle-api/src/castle_api/services.py +++ b/castle-api/src/castle_api/services.py @@ -108,7 +108,7 @@ async def _do_action(name: str, action: str) -> JSONResponse: asyncio.create_task(_deferred_systemctl(action, unit)) return JSONResponse( status_code=202, - content={"component": name, "action": action, "status": "accepted"}, + content={"program": name, "action": action, "status": "accepted"}, ) ok, output = await _systemctl(action, unit) @@ -120,7 +120,7 @@ async def _do_action(name: str, action: str) -> JSONResponse: await _broadcast_health_with_override(name, unit_status) return JSONResponse( - content={"component": name, "action": action, "status": unit_status}, + content={"program": name, "action": action, "status": unit_status}, ) diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 26a3420..df0040d 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -10,7 +10,7 @@ from fastapi.testclient import TestClient import castle_api.config as api_config from castle_api.main import app from castle_core.registry import ( - DeployedComponent, + Deployment, NodeConfig, NodeRegistry, save_registry, @@ -50,7 +50,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: }, "services": { "test-svc": { - "component": "test-svc-comp", + "program": "test-svc-comp", "description": "Test service", "run": { "runner": "python", @@ -92,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No gateway_port=9000, ), deployed={ - "test-svc": DeployedComponent( + "test-svc": Deployment( runner="python", run_cmd=["uv", "run", "test-svc"], env={ diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 7bae32e..df1ce61 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -18,7 +18,7 @@ class TestComponents: def test_list_components(self, client: TestClient) -> None: """Returns all registered components.""" - response = client.get("/components") + response = client.get("/deployments") assert response.status_code == 200 data = response.json() names = [c["id"] for c in data] @@ -27,7 +27,7 @@ class TestComponents: def test_service_has_port(self, client: TestClient) -> None: """Service component includes port info.""" - response = client.get("/components") + response = client.get("/deployments") data = response.json() svc = next(c for c in data if c["id"] == "test-svc") assert svc["port"] == 19000 @@ -38,7 +38,7 @@ class TestComponents: def test_tool_has_no_port(self, client: TestClient) -> None: """Tool component has no port.""" - response = client.get("/components") + response = client.get("/deployments") data = response.json() tool = next(c for c in data if c["id"] == "test-tool") assert tool["port"] is None @@ -46,19 +46,19 @@ class TestComponents: def test_job_has_schedule(self, client: TestClient) -> None: """Job component has schedule.""" - response = client.get("/components") + response = client.get("/deployments") data = response.json() job = next(c for c in data if c["id"] == "test-job") assert job["behavior"] == "tool" assert job["schedule"] == "0 2 * * *" -class TestComponentDetail: +class TestDeploymentDetail: """Component detail endpoint tests.""" def test_get_component(self, client: TestClient) -> None: """Returns detailed info for a component.""" - response = client.get("/components/test-svc") + response = client.get("/deployments/test-svc") assert response.status_code == 200 data = response.json() assert data["id"] == "test-svc" @@ -67,7 +67,7 @@ class TestComponentDetail: def test_not_found(self, client: TestClient) -> None: """Returns 404 for unknown component.""" - response = client.get("/components/nonexistent") + response = client.get("/deployments/nonexistent") assert response.status_code == 404 @@ -245,7 +245,7 @@ class TestGateway: assert data["port"] == 9000 assert data["hostname"] == "test-node" # Registry has 1 deployed component (test-svc) - assert data["component_count"] == 1 + assert data["deployment_count"] == 1 assert data["service_count"] == 1 assert data["managed_count"] == 1 @@ -258,7 +258,7 @@ class TestGateway: route = routes[0] assert route["path"] == "/test-svc" assert route["target_port"] == 19000 - assert route["component"] == "test-svc" + assert route["program"] == "test-svc" assert route["node"] == "test-node" def test_gateway_routes_sorted(self, client: TestClient) -> None: diff --git a/castle-api/tests/test_mesh.py b/castle-api/tests/test_mesh.py index 3240be9..a96a234 100644 --- a/castle-api/tests/test_mesh.py +++ b/castle-api/tests/test_mesh.py @@ -2,7 +2,7 @@ import time -from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry +from castle_core.registry import Deployment, NodeConfig, NodeRegistry from castle_api.mesh import STALE_TTL_SECONDS, MeshStateManager, RemoteNode @@ -96,7 +96,7 @@ class TestMeshStateManager: mgr.update_node("devbox", _make_registry("devbox")) new_reg = _make_registry( "devbox", - {"svc": DeployedComponent(runner="python", run_cmd=["svc"])}, + {"svc": Deployment(runner="python", run_cmd=["svc"])}, ) mgr.update_node("devbox", new_reg) node = mgr.get_node("devbox") diff --git a/castle-api/tests/test_mqtt.py b/castle-api/tests/test_mqtt.py index 64f44ea..3079775 100644 --- a/castle-api/tests/test_mqtt.py +++ b/castle-api/tests/test_mqtt.py @@ -2,7 +2,7 @@ import json -from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry +from castle_core.registry import Deployment, NodeConfig, NodeRegistry from castle_api.mqtt_client import _json_to_registry, _registry_to_json @@ -11,7 +11,7 @@ def _make_registry() -> NodeRegistry: return NodeRegistry( node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000), deployed={ - "my-svc": DeployedComponent( + "my-svc": Deployment( runner="python", run_cmd=["uv", "run", "my-svc"], env={"PORT": "9001", "SECRET_KEY": "super-secret"}, @@ -23,7 +23,7 @@ def _make_registry() -> NodeRegistry: proxy_path="/my-svc", managed=True, ), - "my-job": DeployedComponent( + "my-job": Deployment( runner="command", run_cmd=["my-job"], behavior="tool", @@ -75,7 +75,7 @@ class TestRegistrySerialization: reg = NodeRegistry( node=NodeConfig(hostname="minimal"), deployed={ - "bare": DeployedComponent(runner="command", run_cmd=["bare"]), + "bare": Deployment(runner="command", run_cmd=["bare"]), }, ) restored = _json_to_registry(_registry_to_json(reg)) diff --git a/castle-api/tests/test_nodes.py b/castle-api/tests/test_nodes.py index 2d29435..0ee9b17 100644 --- a/castle-api/tests/test_nodes.py +++ b/castle-api/tests/test_nodes.py @@ -4,7 +4,7 @@ from pathlib import Path from fastapi.testclient import TestClient -from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry +from castle_core.registry import Deployment, NodeConfig, NodeRegistry from castle_api.mesh import MeshStateManager @@ -41,7 +41,7 @@ class TestNodesList: remote_reg = NodeRegistry( node=NodeConfig(hostname="devbox", gateway_port=9000), deployed={ - "remote-svc": DeployedComponent( + "remote-svc": Deployment( runner="python", run_cmd=["svc"], port=9050, diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 18142b7..4fc0f68 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -94,7 +94,7 @@ def run_create(args: argparse.Namespace) -> int: if behavior == "daemon": config.services[name] = ServiceSpec( id=name, - component=name, + program=name, run=RunPython(runner="python", program=name), expose=ExposeSpec( http=HttpExposeSpec( diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index a4f211d..18fff12 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -66,10 +66,10 @@ def run_info(args: argparse.Namespace) -> int: stack = None if program and program.stack: stack = program.stack - elif service and service.component and service.component in config.programs: - stack = config.programs[service.component].stack - elif job and job.component and job.component in config.programs: - stack = config.programs[job.component].stack + elif service and service.program and service.program in config.programs: + stack = config.programs[service.program].stack + elif job and job.program and job.program in config.programs: + stack = config.programs[job.program].stack if stack: print(f" {BOLD}stack{RESET}: {stack}") @@ -88,12 +88,12 @@ def run_info(args: argparse.Namespace) -> int: spec = service or job if spec: desc = spec.description - if not desc and spec.component and spec.component in config.programs: - desc = config.programs[spec.component].description + if not desc and spec.program and spec.program in config.programs: + desc = config.programs[spec.program].description if desc and not (program and program.description == desc): print(f" {BOLD}description{RESET}: {desc}") - if spec.component: - print(f" {BOLD}program{RESET}: {spec.component}") + if spec.program: + print(f" {BOLD}program{RESET}: {spec.program}") # Run spec print(f" {BOLD}runner{RESET}: {spec.run.runner}") @@ -149,8 +149,8 @@ def run_info(args: argparse.Namespace) -> int: source_dir = None if program and program.source_dir: source_dir = program.source_dir - elif spec and spec.component and spec.component in config.programs: - source_dir = config.programs[spec.component].source_dir + elif spec and spec.program and spec.program in config.programs: + source_dir = config.programs[spec.program].source_dir if source_dir: claude_md = _find_claude_md(config.root, source_dir) @@ -191,10 +191,10 @@ def _info_json( stack = None if program and program.stack: stack = program.stack - elif service and service.component and service.component in config.programs: - stack = config.programs[service.component].stack - elif job and job.component and job.component in config.programs: - stack = config.programs[job.component].stack + elif service and service.program and service.program in config.programs: + stack = config.programs[service.program].stack + elif job and job.program and job.program in config.programs: + stack = config.programs[job.program].stack if stack: data["stack"] = stack diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index cac5bee..16ab1b6 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -54,13 +54,13 @@ def _resolve_stack(config: object, name: str) -> str | None: # Check services for program ref if name in config.services: svc = config.services[name] - comp_name = svc.component + comp_name = svc.program if comp_name and comp_name in config.programs: return config.programs[comp_name].stack # Check jobs for program ref if name in config.jobs: job = config.jobs[name] - comp_name = job.component + comp_name = job.program if comp_name and comp_name in config.programs: return config.programs[comp_name].stack # Direct program diff --git a/cli/src/castle_cli/config.py b/cli/src/castle_cli/config.py index c1da83c..1a6665c 100644 --- a/cli/src/castle_cli/config.py +++ b/cli/src/castle_cli/config.py @@ -22,7 +22,7 @@ from castle_core.config import ( # noqa: F401 — explicit re-exports for type ) from castle_core.registry import ( # noqa: F401 REGISTRY_PATH, - DeployedComponent, + Deployment, NodeConfig, NodeRegistry, load_registry, diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py index ae88936..ac55b44 100644 --- a/cli/tests/conftest.py +++ b/cli/tests/conftest.py @@ -23,7 +23,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: }, "services": { "test-svc": { - "component": "test-svc-comp", + "program": "test-svc-comp", "description": "Test service", "run": { "runner": "python", diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index 729b0ad..e0a993d 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -48,7 +48,7 @@ class TestCreateCommand: assert "my-api" in config.services svc = config.services["my-api"] assert svc.expose.http.internal.port == 9050 - assert svc.component == "my-api" + assert svc.program == "my-api" mock_save.assert_called_once() def test_create_tool(self, castle_root: Path, tmp_path: Path) -> None: diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index ceddf8d..6d0621f 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -252,7 +252,7 @@ def _expand_units( services[name] = ServiceSpec( id=name, - component=name, + program=name, run=run_spec, expose=ExposeSpec( http=HttpExposeSpec( @@ -272,7 +272,7 @@ def _expand_units( assert unit.argv is not None # guaranteed by validator jobs[name] = JobSpec( id=name, - component=name, + program=name, description=unit.description, run=RunCommand(runner="command", argv=list(unit.argv)), schedule=unit.schedule, diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 6d66b14..c46433c 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -1,7 +1,7 @@ """Deploy logic — bridge castle.yaml spec to runtime (~/.castle/). This module contains the core deploy logic shared by the CLI and API. -It reads castle.yaml, resolves services/jobs into DeployedComponents, +It reads castle.yaml, resolves services/jobs into Deployments, writes the registry, generates systemd units and the Caddyfile, and copies frontend build outputs. """ @@ -31,7 +31,7 @@ from castle_core.generators.systemd import ( from castle_core.manifest import JobSpec, ServiceSpec from castle_core.registry import ( REGISTRY_PATH, - DeployedComponent, + Deployment, NodeConfig, NodeRegistry, load_registry, @@ -139,21 +139,21 @@ def _resolve_description(config: CastleConfig, spec: ServiceSpec | JobSpec) -> s """Get description, falling through to program if referenced.""" if spec.description: return spec.description - if spec.component and spec.component in config.programs: - return config.programs[spec.component].description + if spec.program and spec.program in config.programs: + return config.programs[spec.program].description return None def _build_deployed_service( config: CastleConfig, name: str, svc: ServiceSpec, messages: list[str] -) -> DeployedComponent: - """Build a DeployedComponent from a ServiceSpec.""" +) -> Deployment: + """Build a Deployment from a ServiceSpec.""" run = svc.run # Env prefix and data dir are keyed by the program (component) the service # runs, not the service name — that's the prefix the program's settings read # (e.g. job `protonmail-sync` runs program `protonmail` → PROTONMAIL_DATA_DIR). # Falls back to the service name when no component is referenced. - config_key = svc.component or name + config_key = svc.program or name prefix = _env_prefix(config_key) env: dict[str, str] = {} @@ -182,7 +182,7 @@ def _build_deployed_service( env = resolve_env_vars(env) # Ensure python tool is installed before resolving binary - _ensure_python_tool(config, svc.component, messages) + _ensure_python_tool(config, svc.program, messages) # Build run_cmd run_cmd = _build_run_cmd(name, run, env, messages) @@ -194,13 +194,13 @@ def _build_deployed_service( # Resolve stack from referenced program stack = None - if svc.component and svc.component in config.programs: - stack = config.programs[svc.component].stack + if svc.program and svc.program in config.programs: + stack = config.programs[svc.program].stack # Remote services proxy to an external base_url base_url = getattr(run, "base_url", None) - return DeployedComponent( + return Deployment( runner=run.runner, run_cmd=run_cmd, env=env, @@ -217,12 +217,12 @@ def _build_deployed_service( def _build_deployed_job( config: CastleConfig, name: str, job: JobSpec, messages: list[str] -) -> DeployedComponent: - """Build a DeployedComponent from a JobSpec.""" +) -> Deployment: + """Build a Deployment from a JobSpec.""" run = job.run # Keyed by the program (component) the job runs, not the job name — see # _build_deployed_service for rationale. Falls back to the job name. - config_key = job.component or name + config_key = job.program or name prefix = _env_prefix(config_key) env: dict[str, str] = {} @@ -232,14 +232,14 @@ def _build_deployed_job( env.update(job.defaults.env) env = resolve_env_vars(env) - _ensure_python_tool(config, job.component, messages) + _ensure_python_tool(config, job.program, messages) run_cmd = _build_run_cmd(name, run, env, messages) stack = None - if job.component and job.component in config.programs: - stack = config.programs[job.component].stack + if job.program and job.program in config.programs: + stack = config.programs[job.program].stack - return DeployedComponent( + return Deployment( runner=run.runner, run_cmd=run_cmd, env=env, @@ -273,33 +273,33 @@ def _python_tool_needs_install(program: str) -> bool: def _ensure_python_tool( - config: CastleConfig, component: str | None, messages: list[str] + config: CastleConfig, program: str | None, messages: list[str] ) -> None: """Ensure a Python program's editable install is current.""" - if not component or component not in config.programs: + if not program or program not in config.programs: return - comp = config.programs[component] + comp = config.programs[program] if not comp.source or not comp.stack or not comp.stack.startswith("python"): return source_dir = Path(comp.source) if not source_dir.is_dir(): messages.append(f"Warning: source not found: {source_dir}") return - if not _python_tool_needs_install(component): + if not _python_tool_needs_install(program): return pkg_spec = str(source_dir) if comp.install_extras: pkg_spec += "[" + ",".join(comp.install_extras) + "]" - messages.append(f"Installing {component} from {source_dir}...") + messages.append(f"Installing {program} from {source_dir}...") result = subprocess.run( ["uv", "tool", "install", "--editable", pkg_spec, "--force"], capture_output=True, text=True, ) if result.returncode != 0: - messages.append(f"Error: {component} install failed:\n{result.stdout}{result.stderr}") + messages.append(f"Error: {program} install failed:\n{result.stdout}{result.stderr}") else: - messages.append(f"Installed {component}") + messages.append(f"Installed {program}") def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[str]) -> list[str]: @@ -354,7 +354,7 @@ def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[s raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr] -def _format_deployed(name: str, deployed: DeployedComponent) -> str: +def _format_deployed(name: str, deployed: Deployment) -> str: """Format deployment summary for a component.""" parts = [name] if deployed.port: diff --git a/core/src/castle_core/generators/systemd.py b/core/src/castle_core/generators/systemd.py index ff47461..08a1a7e 100644 --- a/core/src/castle_core/generators/systemd.py +++ b/core/src/castle_core/generators/systemd.py @@ -6,7 +6,7 @@ import shutil from pathlib import Path from castle_core.manifest import RestartPolicy, SystemdSpec -from castle_core.registry import DeployedComponent +from castle_core.registry import Deployment SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" UNIT_PREFIX = "castle-" @@ -74,7 +74,7 @@ def cron_to_interval_sec(cron: str) -> int | None: def generate_unit_from_deployed( name: str, - deployed: DeployedComponent, + deployed: Deployment, systemd_spec: SystemdSpec | None = None, ) -> str: """Generate a systemd unit from a deployed component (registry-based). diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 0ce2002..fce12d0 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -5,7 +5,7 @@ from __future__ import annotations from enum import Enum from typing import Annotated, Literal, Union -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator EnvMap = dict[str, str] @@ -247,8 +247,13 @@ class ProgramSpec(BaseModel): class ServiceSpec(BaseModel): """Long-running daemon deployment config.""" + model_config = ConfigDict(populate_by_name=True) + id: str = "" - component: str | None = None + # The program this service deploys. (`component` accepted as a legacy alias.) + program: str | None = Field( + default=None, validation_alias=AliasChoices("program", "component") + ) description: str | None = None run: RunSpec @@ -274,8 +279,13 @@ class ServiceSpec(BaseModel): class JobSpec(BaseModel): """Scheduled task that runs periodically and exits.""" + model_config = ConfigDict(populate_by_name=True) + id: str = "" - component: str | None = None + # The program this job runs. (`component` accepted as a legacy alias.) + program: str | None = Field( + default=None, validation_alias=AliasChoices("program", "component") + ) description: str | None = None run: RunSpec diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 7178ff0..80eef5f 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -28,7 +28,7 @@ class NodeConfig: @dataclass -class DeployedComponent: +class Deployment: """A component deployed on this node with resolved runtime config.""" runner: str @@ -50,7 +50,7 @@ class NodeRegistry: """What's deployed on this node.""" node: NodeConfig - deployed: dict[str, DeployedComponent] = field(default_factory=dict) + deployed: dict[str, Deployment] = field(default_factory=dict) def load_registry(path: Path | None = None) -> NodeRegistry: @@ -77,7 +77,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry: gateway_port=node_data.get("gateway_port", 9000), ) - deployed: dict[str, DeployedComponent] = {} + deployed: dict[str, Deployment] = {} for name, comp_data in data.get("deployed", {}).items(): # Support both old "category" and new "behavior" keys for migration behavior = comp_data.get("behavior") @@ -92,7 +92,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry: if category == "frontend" else category ) - deployed[name] = DeployedComponent( + deployed[name] = Deployment( runner=comp_data.get("runner", "command"), run_cmd=comp_data.get("run_cmd", []), env=comp_data.get("env", {}), diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 7731b2a..1e0323d 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -28,9 +28,9 @@ _EXTRA_PATH_DIRS = [ @dataclass class ActionResult: - """Result of a stack lifecycle action.""" + """Result of a program lifecycle action.""" - component: str + program: str action: str status: str # "ok" | "error" output: str = "" @@ -90,12 +90,12 @@ class StackHandler: result = await action_fn(name, comp, root) if result.status != "ok": return ActionResult( - component=name, + program=name, action="check", status="error", output=f"{action_name} failed:\n{result.output}", ) - return ActionResult(component=name, action="check", status="ok") + return ActionResult(program=name, action="check", status="ok") async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: raise NotImplementedError @@ -111,33 +111,33 @@ class PythonHandler(StackHandler): src = _source_dir(comp, root) rc, output = await _run(["uv", "sync"], src) return ActionResult( - component=name, action="build", status="ok" if rc == 0 else "error", output=output + program=name, action="build", status="ok" if rc == 0 else "error", output=output ) async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: src = _source_dir(comp, root) if not (src / "tests").exists(): return ActionResult( - component=name, action="test", status="ok", + program=name, action="test", status="ok", output="No tests directory found, skipping.", ) rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src) return ActionResult( - component=name, action="test", status="ok" if rc == 0 else "error", output=output + program=name, action="test", status="ok" if rc == 0 else "error", output=output ) async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: src = _source_dir(comp, root) rc, output = await _run(["uv", "run", "ruff", "check", "."], src) return ActionResult( - component=name, action="lint", status="ok" if rc == 0 else "error", output=output + program=name, action="lint", status="ok" if rc == 0 else "error", output=output ) async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: src = _source_dir(comp, root) rc, output = await _run(["uv", "run", "pyright"], src) return ActionResult( - component=name, action="type-check", status="ok" if rc == 0 else "error", output=output + program=name, action="type-check", status="ok" if rc == 0 else "error", output=output ) async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: @@ -149,7 +149,7 @@ class PythonHandler(StackHandler): ["uv", "tool", "install", "--editable", pkg_spec, "--force"], src ) return ActionResult( - component=name, action="install", status="ok" if rc == 0 else "error", output=output + program=name, action="install", status="ok" if rc == 0 else "error", output=output ) async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: @@ -162,7 +162,7 @@ class PythonHandler(StackHandler): pkg_name = data.get("project", {}).get("name", pkg_name) rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src) return ActionResult( - component=name, action="uninstall", status="ok" if rc == 0 else "error", output=output + program=name, action="uninstall", status="ok" if rc == 0 else "error", output=output ) @@ -173,28 +173,28 @@ class ReactViteHandler(StackHandler): src = _source_dir(comp, root) rc, output = await _run(["pnpm", "build"], src) return ActionResult( - component=name, action="build", status="ok" if rc == 0 else "error", output=output + program=name, action="build", status="ok" if rc == 0 else "error", output=output ) async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: src = _source_dir(comp, root) rc, output = await _run(["pnpm", "test"], src) return ActionResult( - component=name, action="test", status="ok" if rc == 0 else "error", output=output + program=name, action="test", status="ok" if rc == 0 else "error", output=output ) async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: src = _source_dir(comp, root) rc, output = await _run(["pnpm", "lint"], src) return ActionResult( - component=name, action="lint", status="ok" if rc == 0 else "error", output=output + program=name, action="lint", status="ok" if rc == 0 else "error", output=output ) async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: src = _source_dir(comp, root) rc, output = await _run(["pnpm", "type-check"], src) return ActionResult( - component=name, action="type-check", status="ok" if rc == 0 else "error", output=output + program=name, action="type-check", status="ok" if rc == 0 else "error", output=output ) async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: @@ -203,18 +203,18 @@ class ReactViteHandler(StackHandler): result = await self.build(name, comp, root) if result.status != "ok": return ActionResult( - component=name, action="install", status="error", + program=name, action="install", status="error", output=f"Build failed:\n{result.output}", ) outputs = comp.build.outputs if comp.build else [] if not outputs: return ActionResult( - component=name, action="install", status="error", + program=name, action="install", status="error", output="No build outputs configured.", ) dist = _source_dir(comp, root) / outputs[0] return ActionResult( - component=name, action="install", status="ok", + program=name, action="install", status="ok", output=f"Built; served in place from {dist}", ) @@ -224,7 +224,7 @@ class ReactViteHandler(StackHandler): Deactivating one means dropping its gateway route — handled by removing the program from the registry, not by deleting build output.""" return ActionResult( - component=name, action="uninstall", status="ok", + program=name, action="uninstall", status="ok", output=f"{name}: served in place; nothing to uninstall.", ) @@ -287,8 +287,8 @@ async def _run_declared( rc, output = await _run(argv, src) outputs.append(output) if rc != 0: - return ActionResult(component=name, action=verb, status="error", output="".join(outputs)) - return ActionResult(component=name, action=verb, status="ok", output="".join(outputs)) + return ActionResult(program=name, action=verb, status="error", output="".join(outputs)) + return ActionResult(program=name, action=verb, status="ok", output="".join(outputs)) async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> ActionResult: @@ -303,17 +303,17 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act subs = [s for s in ("lint", "type-check", "test") if is_available(comp, s)] if not subs: return ActionResult( - component=name, action="check", status="error", + program=name, action="check", status="error", output="No checkable verbs available.", ) for sub in subs: result = await run_action(sub, name, comp, root) if result.status != "ok": return ActionResult( - component=name, action="check", status="error", + program=name, action="check", status="error", output=f"{sub} failed:\n{result.output}", ) - return ActionResult(component=name, action="check", status="ok") + return ActionResult(program=name, action="check", status="ok") # 1. Declared command overrides the stack default. declared = _declared_commands(comp, verb) @@ -321,7 +321,7 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act try: src = _source_dir(comp, root) except ValueError: - return ActionResult(component=name, action=verb, status="error", output="No source directory") + return ActionResult(program=name, action=verb, status="error", output="No source directory") return await _run_declared(name, verb, declared, src) # 2. Stack default. @@ -333,7 +333,7 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act # 3. Unavailable. return ActionResult( - component=name, action=verb, status="error", + program=name, action=verb, status="error", output=f"Verb '{verb}' is not available for '{name}' " f"(no declared command and no stack handler provides it).", ) diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 2a8a360..1eb9b75 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -23,7 +23,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: }, "services": { "test-svc": { - "component": "test-svc-comp", + "program": "test-svc-comp", "description": "Test service", "run": { "runner": "python", diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index cd0a0a4..0519ba7 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -6,7 +6,7 @@ from __future__ import annotations import pytest from castle_core.generators.caddyfile import generate_caddyfile_from_registry -from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry +from castle_core.registry import Deployment, NodeConfig, NodeRegistry @pytest.fixture(autouse=True) @@ -22,7 +22,7 @@ def _isolate_config(monkeypatch: pytest.MonkeyPatch) -> None: def _make_registry( - deployed: dict[str, DeployedComponent] | None = None, + deployed: dict[str, Deployment] | None = None, gateway_port: int = 9000, ) -> NodeRegistry: """Create a test registry.""" @@ -45,7 +45,7 @@ class TestCaddyfileFromRegistry: """Caddyfile has reverse proxy routes for deployed services.""" registry = _make_registry( deployed={ - "test-svc": DeployedComponent( + "test-svc": Deployment( runner="python", run_cmd=["uv", "run", "test-svc"], port=19000, @@ -61,7 +61,7 @@ class TestCaddyfileFromRegistry: """Components without proxy_path are not in Caddyfile.""" registry = _make_registry( deployed={ - "test-tool": DeployedComponent( + "test-tool": Deployment( runner="command", run_cmd=["test-tool"], ), @@ -81,7 +81,7 @@ class TestCaddyfileFromRegistry: """Service proxy routes appear before the dashboard catch-all.""" registry = _make_registry( deployed={ - "test-svc": DeployedComponent( + "test-svc": Deployment( runner="python", run_cmd=["uv", "run", "test-svc"], port=19000, @@ -98,13 +98,13 @@ class TestCaddyfileFromRegistry: """Multiple services get separate proxy routes.""" registry = _make_registry( deployed={ - "svc-a": DeployedComponent( + "svc-a": Deployment( runner="python", run_cmd=["uv", "run", "svc-a"], port=9001, proxy_path="/svc-a", ), - "svc-b": DeployedComponent( + "svc-b": Deployment( runner="python", run_cmd=["uv", "run", "svc-b"], port=9002, @@ -126,14 +126,14 @@ class TestCaddyfileRemoteRegistries: """Remote services get reverse_proxy entries to their hostname.""" local = _make_registry( deployed={ - "local-svc": DeployedComponent( + "local-svc": Deployment( runner="python", run_cmd=["svc"], port=9001, proxy_path="/local" ), } ) remote = _make_registry( deployed={ - "remote-svc": DeployedComponent( + "remote-svc": Deployment( runner="python", run_cmd=["svc"], port=9050, proxy_path="/remote" ), } @@ -148,14 +148,14 @@ class TestCaddyfileRemoteRegistries: """If local and remote use the same path, local wins.""" local = _make_registry( deployed={ - "svc": DeployedComponent( + "svc": Deployment( runner="python", run_cmd=["svc"], port=9001, proxy_path="/api" ), } ) remote = _make_registry( deployed={ - "svc": DeployedComponent( + "svc": Deployment( runner="python", run_cmd=["svc"], port=9001, proxy_path="/api" ), } @@ -169,7 +169,7 @@ class TestCaddyfileRemoteRegistries: """No remote routes when remote_registries is None.""" local = _make_registry( deployed={ - "svc": DeployedComponent( + "svc": Deployment( runner="python", run_cmd=["svc"], port=9001, proxy_path="/api" ), } diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 1538748..3ac61fb 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -57,7 +57,7 @@ class TestLoadConfig: """Service references a component.""" config = load_config(castle_root) svc = config.services["test-svc"] - assert svc.component == "test-svc-comp" + assert svc.program == "test-svc-comp" def test_job_schedule(self, castle_root: Path) -> None: """Job has correct schedule.""" diff --git a/core/tests/test_deploy_prune.py b/core/tests/test_deploy_prune.py index a0414dc..024f986 100644 --- a/core/tests/test_deploy_prune.py +++ b/core/tests/test_deploy_prune.py @@ -7,11 +7,11 @@ from unittest.mock import patch from castle_core import deploy as deploy_mod from castle_core.deploy import _desired_unit_files, _prune_orphans -from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry +from castle_core.registry import Deployment, NodeConfig, NodeRegistry -def _svc(managed: bool = True, schedule: str | None = None) -> DeployedComponent: - return DeployedComponent( +def _svc(managed: bool = True, schedule: str | None = None) -> Deployment: + return Deployment( runner="python", run_cmd=["x"], env={}, @@ -20,7 +20,7 @@ def _svc(managed: bool = True, schedule: str | None = None) -> DeployedComponent ) -def _registry(**deployed: DeployedComponent) -> NodeRegistry: +def _registry(**deployed: Deployment) -> NodeRegistry: return NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000), deployed=dict(deployed)) diff --git a/core/tests/test_manifest.py b/core/tests/test_manifest.py index 75b0c53..9c2850d 100644 --- a/core/tests/test_manifest.py +++ b/core/tests/test_manifest.py @@ -82,10 +82,10 @@ class TestServiceSpec: """Service can reference a component.""" s = ServiceSpec( id="svc", - component="my-component", + program="my-component", run=RunPython(runner="python", program="svc"), ) - assert s.component == "my-component" + assert s.program == "my-component" def test_service_with_proxy(self) -> None: """Service with proxy spec.""" @@ -139,11 +139,11 @@ class TestJobSpec: """Job can reference a component.""" j = JobSpec( id="sync", - component="protonmail", + program="protonmail", run=RunCommand(runner="command", argv=["protonmail", "sync"]), schedule="*/5 * * * *", ) - assert j.component == "protonmail" + assert j.program == "protonmail" def test_job_requires_schedule(self) -> None: """Job without schedule is invalid.""" diff --git a/core/tests/test_systemd.py b/core/tests/test_systemd.py index 0d8de2b..5e9a0c8 100644 --- a/core/tests/test_systemd.py +++ b/core/tests/test_systemd.py @@ -7,7 +7,7 @@ from castle_core.generators.systemd import ( generate_unit_from_deployed, unit_name, ) -from castle_core.registry import DeployedComponent +from castle_core.registry import Deployment class TestUnitName: @@ -24,7 +24,7 @@ class TestUnitFromDeployed: def test_contains_description(self) -> None: """Unit file has service description.""" - deployed = DeployedComponent( + deployed = Deployment( runner="python", run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], env={"TEST_SVC_PORT": "19000"}, @@ -35,7 +35,7 @@ class TestUnitFromDeployed: def test_no_working_directory(self) -> None: """Unit file has no WorkingDirectory (source/runtime separation).""" - deployed = DeployedComponent( + deployed = Deployment( runner="python", run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], env={}, @@ -46,7 +46,7 @@ class TestUnitFromDeployed: def test_contains_environment(self) -> None: """Unit file has environment variables from deployed config.""" - deployed = DeployedComponent( + deployed = Deployment( runner="python", run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"}, @@ -57,7 +57,7 @@ class TestUnitFromDeployed: def test_contains_restart_policy(self) -> None: """Unit file has restart configuration.""" - deployed = DeployedComponent( + deployed = Deployment( runner="python", run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], env={}, @@ -68,7 +68,7 @@ class TestUnitFromDeployed: def test_exec_start_from_run_cmd(self) -> None: """Unit file ExecStart uses resolved run_cmd.""" - deployed = DeployedComponent( + deployed = Deployment( runner="python", run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], env={}, @@ -79,7 +79,7 @@ class TestUnitFromDeployed: def test_basic_service(self) -> None: """Generate a unit from a deployed component.""" - deployed = DeployedComponent( + deployed = Deployment( runner="python", run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"], env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/home/user/.castle/data/my-svc"}, @@ -95,7 +95,7 @@ class TestUnitFromDeployed: def test_scheduled_job(self) -> None: """Scheduled component generates oneshot unit.""" - deployed = DeployedComponent( + deployed = Deployment( runner="command", run_cmd=["/home/user/.local/bin/my-job"], env={}, @@ -108,7 +108,7 @@ class TestUnitFromDeployed: def test_no_repo_paths(self) -> None: """Generated units must not reference repo paths.""" - deployed = DeployedComponent( + deployed = Deployment( runner="python", run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"], env={"DATA_DIR": "/home/user/.castle/data/my-svc"}, diff --git a/core/tests/test_units.py b/core/tests/test_units.py index 8933f69..02414bb 100644 --- a/core/tests/test_units.py +++ b/core/tests/test_units.py @@ -76,7 +76,7 @@ class TestUnitExpansion: assert svc.expose.http.health_path == "/health" assert svc.proxy.caddy.path_prefix == "/my-svc" assert svc.manage.systemd is not None - assert svc.component == "my-svc" + assert svc.program == "my-svc" def test_tool_creates_program_only(self, units_root: Path) -> None: config = load_config(units_root) @@ -108,7 +108,7 @@ class TestUnitExpansion: assert job.run.runner == "command" assert job.run.argv == ["my-job", "run"] assert job.defaults.env["DATA_DIR"] == "/tmp/data" - assert job.component == "my-job" + assert job.program == "my-job" def test_service_without_path_prefix(self, tmp_path: Path) -> None: config_data = { diff --git a/docs/component-registry.md b/docs/registry.md similarity index 89% rename from docs/component-registry.md rename to docs/registry.md index 1e86c94..a6df551 100644 --- a/docs/component-registry.md +++ b/docs/registry.md @@ -4,6 +4,33 @@ How castle tracks, configures, and manages programs, services, and jobs. This is the central reference for `castle.yaml` structure and the registry architecture. +## Vocabulary (canonical) + +Use these terms consistently across code, CLI, API, and docs. + +- **program** — any project castle manages, regardless of what it does. The + software catalog (`programs:`). Every program has a **behavior** and an + optional **stack**. *("component" was the old name for program — don't use it.)* +- **behavior** — what a program *is*: `tool` (a CLI you invoke), `daemon` (a + long-running server), `frontend` (a web UI). A property of the program, + independent of whether/how it's deployed. +- **stack** — a creation-time toolchain + scaffold template (`python-cli`, + `python-fastapi`, `react-vite`). Optional; seeds a program's default dev + commands but isn't required at runtime. +- **service** — a program deployed as a long-running systemd `.service` + (`services:`). +- **job** — a program deployed as a scheduled systemd `.timer` (+ oneshot) + (`jobs:`). +- **deployment** — the umbrella for "a service or a job" (a program materialized + into the runtime). The registry's deployed entries are deployments. + +**Two orthogonal axes.** *behavior* (tool/daemon/frontend) is **what** a program +is; *service/job* is **how/when** it's deployed. They're independent: a program +may have neither (a tool you just install), a **service** (always-on), or a +**job** (scheduled). A `daemon`-behavior program is usually deployed as a +service; a `tool`-behavior program may back a job or just be installed for +manual use. + ## castle.yaml The single source of truth. Lives at `~/.castle/castle.yaml`. Three top-level diff --git a/docs/stacks/python-cli.md b/docs/stacks/python-cli.md index ee20b0e..3fc33dc 100644 --- a/docs/stacks/python-cli.md +++ b/docs/stacks/python-cli.md @@ -4,7 +4,7 @@ > A stack is a template + conventions, not a runtime requirement. `castle create > --stack python-cli` scaffolds from it and seeds the program's default dev-verb > commands. An existing CLI adopted with `castle add` doesn't need this stack — it -> declares its own `commands:`. See @docs/component-registry.md for `commands:`, +> declares its own `commands:`. See @docs/registry.md for `commands:`, > `stack:` (optional), and `repo:`. How to build CLI tools following Unix philosophy. @@ -344,4 +344,4 @@ programs: Tools live in the `programs:` section. If a tool also runs on a schedule, add a separate entry in the `jobs:` section referencing the component. -See @docs/component-registry.md for the full registry reference. +See @docs/registry.md for the full registry reference. diff --git a/docs/stacks/python-fastapi.md b/docs/stacks/python-fastapi.md index d862f0a..125e9cd 100644 --- a/docs/stacks/python-fastapi.md +++ b/docs/stacks/python-fastapi.md @@ -4,7 +4,7 @@ > A stack is a template + conventions, not a runtime requirement. `castle create > --stack python-fastapi` scaffolds from it and seeds the program's default > dev-verb commands. An existing service adopted with `castle add` doesn't need -> this stack — it declares its own `commands:`. See @docs/component-registry.md +> this stack — it declares its own `commands:`. See @docs/registry.md > for `commands:`, `stack:` (optional), and `repo:`. How to build Python web APIs as castle service components. Based on the @@ -448,5 +448,5 @@ uv run ruff format . # Format castle create my-service --stack python-fastapi --description "Does something useful" ``` -See @docs/component-registry.md for manifest fields, castle.yaml structure, +See @docs/registry.md for manifest fields, castle.yaml structure, and the full service lifecycle (enable, logs, gateway reload). diff --git a/docs/stacks/react-vite.md b/docs/stacks/react-vite.md index b72cafd..0cda840 100644 --- a/docs/stacks/react-vite.md +++ b/docs/stacks/react-vite.md @@ -4,7 +4,7 @@ > A stack is a template + conventions, not a runtime requirement. `castle create > --stack react-vite` scaffolds from it and seeds the program's default dev-verb > commands. An existing frontend adopted with `castle add` doesn't need this -> stack — it declares its own `commands:`. See @docs/component-registry.md for +> stack — it declares its own `commands:`. See @docs/registry.md for > `commands:`, `stack:` (optional), and `repo:`. How to build, serve, and manage web frontends as castle components. @@ -152,7 +152,7 @@ services: caddy: { path_prefix: /app } ``` -See @docs/component-registry.md for the full registry reference. +See @docs/registry.md for the full registry reference. ## Serving with Caddy