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:
29
CLAUDE.md
29
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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState } from "react"
|
||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||
import type { ComponentDetail } from "@/types"
|
||||
import type { DeploymentDetail } from "@/types"
|
||||
import { ComponentFields } from "./ComponentFields"
|
||||
import { BehaviorBadge } from "./BehaviorBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface ComponentEditorProps {
|
||||
component: ComponentDetail
|
||||
component: DeploymentDetail
|
||||
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
||||
onDelete: (name: string) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
||||
</thead>
|
||||
<tbody>
|
||||
{gateway.routes.map((route) => {
|
||||
const health = statusMap.get(route.component)
|
||||
const health = statusMap.get(route.program)
|
||||
return (
|
||||
<tr
|
||||
key={route.path}
|
||||
@@ -84,10 +84,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<Link
|
||||
to={`/component/${route.component}`}
|
||||
to={`/deployment/${route.program}`}
|
||||
className="hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{route.component}
|
||||
{route.program}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-[var(--muted)]">
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom"
|
||||
import { ArrowLeft, Check, Loader2, Zap } from "lucide-react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { apiClient } from "@/services/api/client"
|
||||
import type { ComponentDetail } from "@/types"
|
||||
import type { DeploymentDetail } from "@/types"
|
||||
import { AddComponent } from "@/components/AddComponent"
|
||||
import { ComponentEditor } from "@/components/ComponentEditor"
|
||||
|
||||
@@ -24,7 +24,7 @@ export function ConfigEditorPage() {
|
||||
queryFn: async () => {
|
||||
const list = await apiClient.get<{ id: string }[]>("/components")
|
||||
const details = await Promise.all(
|
||||
list.map((c) => apiClient.get<ComponentDetail>(`/components/${c.id}`))
|
||||
list.map((c) => apiClient.get<DeploymentDetail>(`/components/${c.id}`))
|
||||
)
|
||||
return details
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@ export function NodeDetailPage() {
|
||||
>
|
||||
<td className="px-3 py-2.5">
|
||||
<Link
|
||||
to={`/component/${comp.id}`}
|
||||
to={`/deployment/${comp.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{comp.id}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const router = createBrowserRouter([
|
||||
element: <ProgramDetailPage />,
|
||||
},
|
||||
{
|
||||
path: "/component/:name",
|
||||
path: "/deployment/:name",
|
||||
element: <ProgramRedirect />,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from "react"
|
||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||
import { apiClient } from "./client"
|
||||
import type {
|
||||
ComponentDetail,
|
||||
DeploymentDetail,
|
||||
ServiceSummary,
|
||||
ServiceDetail,
|
||||
JobSummary,
|
||||
@@ -21,8 +21,8 @@ import type {
|
||||
// Legacy compat hook — used by ConfigEditorPage and ProgramRedirect
|
||||
export function useComponent(name: string) {
|
||||
return useQuery({
|
||||
queryKey: ["components", name],
|
||||
queryFn: () => apiClient.get<ComponentDetail>(`/components/${name}`),
|
||||
queryKey: ["deployments", name],
|
||||
queryFn: () => apiClient.get<DeploymentDetail>(`/deployments/${name}`),
|
||||
enabled: !!name,
|
||||
})
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export function useServiceAction() {
|
||||
} catch (err) {
|
||||
// Network error from self-restart killing the connection — expected
|
||||
if (err instanceof TypeError) {
|
||||
return { component: name, action, status: "accepted" } as ServiceActionResponse
|
||||
return { program: name, action, status: "accepted" } as ServiceActionResponse
|
||||
}
|
||||
throw err
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export function useProgramAction() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ name, action }: { name: string; action: string }) =>
|
||||
apiClient.post<{ component: string; action: string; status: string; output: string }>(
|
||||
apiClient.post<{ program: string; action: string; status: string; output: string }>(
|
||||
`/programs/${name}/${action}`,
|
||||
),
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface ProgramDetail extends ProgramSummary {
|
||||
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
|
||||
|
||||
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
|
||||
export interface ComponentSummary {
|
||||
export interface DeploymentSummary {
|
||||
id: string
|
||||
category: "program" | "service" | "job" | null
|
||||
description: string | null
|
||||
@@ -88,7 +88,7 @@ export interface ComponentSummary {
|
||||
node: string | null
|
||||
}
|
||||
|
||||
export interface ComponentDetail extends ComponentSummary {
|
||||
export interface DeploymentDetail extends DeploymentSummary {
|
||||
manifest: Record<string, unknown>
|
||||
}
|
||||
|
||||
@@ -105,21 +105,21 @@ export interface StatusResponse {
|
||||
export interface GatewayRoute {
|
||||
path: string
|
||||
target_port: number
|
||||
component: string
|
||||
program: string
|
||||
node: string
|
||||
}
|
||||
|
||||
export interface GatewayInfo {
|
||||
port: number
|
||||
hostname: string
|
||||
component_count: number
|
||||
deployment_count: number
|
||||
service_count: number
|
||||
managed_count: number
|
||||
routes: GatewayRoute[]
|
||||
}
|
||||
|
||||
export interface ServiceActionResponse {
|
||||
component: string
|
||||
program: string
|
||||
action: string
|
||||
status: string
|
||||
}
|
||||
@@ -131,7 +131,7 @@ export interface SSEHealthEvent {
|
||||
|
||||
export interface SSEServiceActionEvent {
|
||||
action: string
|
||||
component: string
|
||||
program: string
|
||||
status: string
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export interface NodeSummary {
|
||||
}
|
||||
|
||||
export interface NodeDetail extends NodeSummary {
|
||||
deployed: ComponentSummary[]
|
||||
deployed: DeploymentSummary[]
|
||||
}
|
||||
|
||||
export interface MeshStatus {
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", {}),
|
||||
|
||||
@@ -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).",
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user