Manager-first deployment model: split runner, merge service/job, frontend→static

Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.

- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
  normalizes old runner shapes; CastleConfig.deployments with derived
  services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
  create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
  derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
  per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.

Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
This commit is contained in:
2026-07-01 10:23:03 -07:00
parent 00e8d58c6a
commit 317232ca6a
73 changed files with 1525 additions and 1442 deletions

View File

@@ -6,17 +6,18 @@ with code in this repository.
## Overview ## Overview
Castle is a personal software platform — a monorepo of independent projects Castle is a personal software platform — a monorepo of independent projects
(services, tools, libraries) managed by the `castle` CLI. The registry config is split into three directories under your config root: (services, tools, libraries) managed by the `castle` CLI. The registry config is split into two directories under your config root:
- **`programs/`** — Software catalog (source, behavior, stack, system_dependencies, build) - **`programs/`** — Software catalog (source, stack, system_dependencies, build)
- **`services/`** — Long-running daemons (run, expose, proxy, systemd) - **`deployments/`** — How a program is realized on this node (manager, run, expose, proxy, schedule, systemd)
- **`jobs/`** — Scheduled tasks (run, cron schedule, systemd timer)
Each program has a **stack** (development toolchain: python-fastapi, Each program has a **stack** (development toolchain: python-fastapi,
python-cli, react-vite) and a **behavior** (runtime role: daemon, tool, python-cli, react-vite). Each deployment is discriminated on its **`manager`**
frontend). Scheduling, systemd management, and proxying are orthogonal (`systemd` | `caddy` | `path` | `none`) — who supervises or realizes it. The
operations. Services and jobs reference a program via `program:` for human-facing **kind** (service, job, tool, static, reference) is *derived* from
description fallthrough. the manager (+ `schedule`), never stored. Scheduling, systemd management, and
proxying are orthogonal operations. A deployment references a program via
`program:` for description fallthrough.
**Key principle:** Regular projects must never depend on castle. They accept standard **Key principle:** Regular projects must never depend on castle. They accept standard
configuration (data dir, port, URLs) via env vars. Only castle programs (CLI, gateway) configuration (data dir, port, URLs) via env vars. Only castle programs (CLI, gateway)
@@ -53,7 +54,7 @@ Stack guides (for writing *new* code, AI-facing):
# New daemon, scaffolded from a stack # New daemon, scaffolded from a stack
castle program create my-service --stack python-fastapi --description "Does something" castle program create my-service --stack python-fastapi --description "Does something"
cd /data/repos/my-service && uv sync cd /data/repos/my-service && uv sync
castle service create my-service --program my-service --port 9001 # declare the service castle service create my-service --program my-service --port 9001 # declare the deployment (manager: systemd)
castle service deploy my-service && castle service enable my-service # unit + start castle service deploy my-service && castle service enable my-service # unit + start
castle gateway reload # update reverse proxy routes castle gateway reload # update reverse proxy routes
@@ -68,7 +69,8 @@ castle program add https://github.com/me/widget.git --name widget
`castle program create` scaffolds under `/data/repos/` (override with `castle program create` scaffolds under `/data/repos/` (override with
`CASTLE_REPOS_DIR`) and registers the program under `programs/<name>.yaml` with an absolute `CASTLE_REPOS_DIR`) and registers the program under `programs/<name>.yaml` with an absolute
`source:`. `castle program add` registers an existing repo in place under `programs/<name>.yaml` (or records `source:`. `castle program add` registers an existing repo in place under `programs/<name>.yaml` (or records
its `repo:` URL for `castle program clone`). its `repo:` URL for `castle program clone`). A program's deployment (if any) is
recorded separately under `deployments/<name>.yaml`.
## Castle CLI ## Castle CLI
@@ -81,33 +83,33 @@ Platform-wide lifecycle and the cross-resource overview are top-level.
```bash ```bash
# Programs — the software catalog # Programs — the software catalog
castle program list [--behavior daemon] [--stack python-cli] [--json] castle program list [--kind service] [--stack python-cli] [--json]
castle program info <name> [--json] castle program info <name> [--json]
castle program create <name> [--stack ...] [--description ...] # scaffold new castle program create <name> [--stack ...] [--description ...] # scaffold new
castle program add <path|git-url> [--name ...] # adopt existing repo castle program add <path|git-url> [--name ...] # adopt existing repo
castle program clone [name] # clone repo: source castle program clone [name] # clone repo: source
castle program delete <name> [--source] [-y] castle program delete <name> [--source] [-y]
castle program run <name> [args...] # declared run command castle program run <name> [args...] # declared run command
castle program install|uninstall [name] # activate tools/frontends castle program install|uninstall [name] # activate tools/statics
castle program build|test|lint|format|type-check|check [name] # dev verbs castle program build|test|lint|format|type-check|check [name] # dev verbs
# Services — long-running daemons # Services — long-running daemons (deployments with manager: systemd, no schedule)
castle service list [--json] castle service list [--json]
castle service info <name> [--json] castle service info <name> [--json]
castle service create <name> [--program P] [--port N] [--health ...] \ castle service create <name> [--program P] [--port N] [--health ...] \
[--path ...] [--host ...] [--port-env ...] [--runner ...] [--path ...] [--host ...] [--port-env ...] [--launcher ...]
castle service delete <name> [-y] castle service delete <name> [-y]
castle service deploy <name> # generate unit + route castle service deploy <name> # generate unit + route
castle service enable|disable <name> # systemd enable/disable castle service enable|disable <name> # systemd enable/disable
castle service start|stop|restart <name> # systemd lifecycle (one) castle service start|stop|restart <name> # systemd lifecycle (one)
castle service logs <name> [-f] [-n 50] castle service logs <name> [-f] [-n 50]
# Jobs — scheduled tasks (same verbs; create takes --schedule) # Jobs — scheduled tasks (manager: systemd + schedule; same verbs, create takes --schedule)
castle job create <name> [--program P] --schedule "0 2 * * *" [--runner ...] castle job create <name> [--program P] --schedule "0 2 * * *" [--launcher ...]
castle job <list|info|delete|deploy|enable|disable|start|stop|restart|logs> ... castle job <list|info|delete|deploy|enable|disable|start|stop|restart|logs> ...
# Platform-wide (top-level) # Platform-wide (top-level)
castle list [--behavior ...] [--stack ...] [--json] # programs + services + jobs castle list [--kind ...] [--stack ...] [--json] # all deployments (services, jobs, tools, statics)
castle status # unified status castle status # unified status
castle deploy [name] # apply config → units + Caddyfile castle deploy [name] # apply config → units + Caddyfile
castle start | stop | restart # all services (+ gateway) castle start | stop | restart # all services (+ gateway)
@@ -117,10 +119,15 @@ castle gateway start|stop|reload|status # the Caddy gateway
Bringing everything online is the two honest steps `castle deploy && castle Bringing everything online is the two honest steps `castle deploy && castle
start` (apply config, then start) — there is no bundled `up`. start` (apply config, then start) — there is no bundled `up`.
`castle service` and `castle job` are **views** over the single deployment set,
filtered by derived kind (systemd-no-schedule → service, systemd+schedule → job).
Tools (`manager: path`) and statics (`manager: caddy`) are deployments too —
reach them via `castle list --kind tool` / `--kind static`.
**Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`) **Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`)
overrides the stack default, falling back to the program's stack handler, else overrides the stack default, falling back to the program's stack handler, else
the verb is unavailable. So a wired-in repo with **no `stack`** works as long as the verb is unavailable. So a wired-in repo with **no `stack`** works as long as
it declares its commands. Tools are reached via `castle program list --behavior tool`. it declares its commands. Tools are reached via `castle list --kind tool`.
## Infrastructure ## Infrastructure
@@ -138,9 +145,9 @@ data I/O on a dedicated volume; default `/data/castle`). Paths below use
- **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`. - **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`.
Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy` Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy`
shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`). shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`).
- **Containers**: `runner: container` services use Docker (preferred on this system - **Containers**: `manager: systemd` deployments with `run: { launcher: container }`
due to rootless podman UID mapping issues). Deploy resolves the runtime via use Docker (preferred on this system due to rootless podman UID mapping issues).
`shutil.which("docker")`. Deploy resolves the runtime via `shutil.which("docker")`.
- **MQTT**: Mosquitto broker runs as `castle-mqtt` (Docker container on port 1883). - **MQTT**: Mosquitto broker runs as `castle-mqtt` (Docker container on port 1883).
Data in `$CASTLE_DATA_DIR/castle-mqtt/`, config in `$CASTLE_DATA_DIR/castle-mqtt/config/`. Data in `$CASTLE_DATA_DIR/castle-mqtt/`, config in `$CASTLE_DATA_DIR/castle-mqtt/config/`.
- **Data**: Service data lives in `$CASTLE_DATA_DIR/<service-name>/` (default - **Data**: Service data lives in `$CASTLE_DATA_DIR/<service-name>/` (default
@@ -159,7 +166,7 @@ Deployments (the unified view of services + jobs + programs):
- `GET /status` — Live health for all services - `GET /status` — Live health for all services
Programs / Services / Jobs (typed views + editing): Programs / Services / Jobs (typed views + editing):
- `GET /programs`, `GET /programs/{name}` — Program catalog (`?behavior=tool` to filter) - `GET /programs`, `GET /programs/{name}` — Program catalog (`?kind=tool` to filter)
- `POST /programs/{name}/{action}` — Run a program verb (install/uninstall/build/…) - `POST /programs/{name}/{action}` — Run a program verb (install/uninstall/build/…)
- `PUT|DELETE /programs/{name}` — Edit or remove a program entry - `PUT|DELETE /programs/{name}` — Edit or remove a program entry
- `GET /services`, `GET /services/{name}`, `PUT|DELETE /services/{name}` - `GET /services`, `GET /services/{name}`, `PUT|DELETE /services/{name}`
@@ -219,8 +226,8 @@ Services also support: `uv run <service-name>` to start.
## Key Files ## Key Files
- `castle.yaml`Registry (three sections: programs, services, jobs) - `castle.yaml`Global settings (gateway, repo); resources live under `programs/` and `deployments/`
- `core/src/castle_core/manifest.py` — Pydantic models (ProgramSpec, ServiceSpec, JobSpec, RunSpec) - `core/src/castle_core/manifest.py` — Pydantic models (ProgramSpec, DeploymentSpec, LaunchSpec)
- `core/src/castle_core/config.py` — Config loader (castle.yaml → CastleConfig) - `core/src/castle_core/config.py` — Config loader (castle.yaml → CastleConfig)
- `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation - `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation
- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates - `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates

View File

@@ -20,7 +20,7 @@ cd cli && uv tool install --editable . && cd ..
./install.sh ./install.sh
# Initialize the global castle.yaml (the registry that tracks everything) # Initialize the global castle.yaml (the registry that tracks everything)
mkdir -p ~/.castle/programs ~/.castle/services ~/.castle/jobs mkdir -p ~/.castle/programs ~/.castle/deployments
cat > ~/.castle/castle.yaml << 'EOF' cat > ~/.castle/castle.yaml << 'EOF'
gateway: gateway:
port: 9000 port: 9000
@@ -58,7 +58,7 @@ castle deploy my-app
## CLI Reference ## CLI Reference
``` ```
castle list [--behavior B] [--stack S] [--json] List all programs, services, and jobs castle list [--kind K] [--stack S] [--json] List all programs and deployments
castle info NAME [--json] Show program details castle info NAME [--json] Show program details
castle create NAME [--stack STACK] Scaffold a new project (bare if no stack) castle create NAME [--stack STACK] Scaffold a new project (bare if no stack)
castle add PATH|GIT-URL [--name N] Adopt an existing repo as a program castle add PATH|GIT-URL [--name N] Adopt an existing repo as a program
@@ -74,21 +74,22 @@ castle service status Show all service statuses
castle services start|stop Start/stop everything castle services start|stop Start/stop everything
``` ```
Tools are programs with `behavior: tool` — list them with Tools are deployments with `manager: path` (derived **kind: tool**) — list them
`castle list --behavior tool`. with `castle list --kind tool`.
## Registry ## Registry
The registry lives under `~/.castle/` and is the single source of truth, split The registry lives under `~/.castle/` and is the single source of truth, split
into a global `castle.yaml` plus one file per resource under `programs/`, into a global `castle.yaml` plus one file per resource under `programs/` and
`services/`, and `jobs/`: `deployments/`:
- **`castle.yaml`** — Global settings (`gateway`, `repo`) - **`castle.yaml`** — Global settings (`gateway`, `repo`)
- **`programs/<name>.yaml`** — Software catalog (source, stack, behavior, build config) - **`programs/<name>.yaml`** — Software catalog (source, stack, build config)
- **`services/<name>.yaml`** — Long-running daemons (run, expose, proxy, systemd) - **`deployments/<name>.yaml`** — How a program is realized on this node
- **`jobs/<name>.yaml`** — Scheduled tasks (run, cron schedule, systemd timer) (`manager` + run/expose/proxy/schedule/systemd). The **kind**
(service/job/tool/static/reference) is derived from `manager` (+ `schedule`).
Services and jobs can reference a program via `program:` for description fallthrough. A deployment can reference a program via `program:` for description fallthrough.
```yaml ```yaml
# ~/.castle/castle.yaml # ~/.castle/castle.yaml
@@ -100,32 +101,32 @@ repo: /path/to/castle
```yaml ```yaml
# ~/.castle/programs/central-context.yaml # ~/.castle/programs/central-context.yaml
description: Content storage API description: Content storage API
behavior: daemon
source: code/central-context source: code/central-context
stack: python-fastapi stack: python-fastapi
``` ```
```yaml ```yaml
# ~/.castle/services/central-context.yaml # ~/.castle/deployments/central-context.yaml (manager: systemd → kind: service)
program: central-context program: central-context
manager: systemd
run: run:
runner: python launcher: python
program: central-context program: central-context
expose: expose:
http: http:
internal: { port: 9001 } internal: { port: 9001 }
health_path: /health health_path: /health
proxy: proxy: true # expose at central-context.<gateway.domain>
caddy: { path_prefix: /central-context }
manage: manage:
systemd: {} systemd: {}
``` ```
```yaml ```yaml
# ~/.castle/jobs/backup-collect.yaml # ~/.castle/deployments/backup-collect.yaml (manager: systemd + schedule → kind: job)
program: backup-collect program: backup-collect
manager: systemd
run: run:
runner: command launcher: command
argv: [backup-collect] argv: [backup-collect]
schedule: "0 2 * * *" schedule: "0 2 * * *"
manage: manage:
@@ -189,7 +190,7 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist
| `GET /health` | Health check | | `GET /health` | Health check |
| `GET /stream` | SSE stream (health, service-action, mesh events) | | `GET /stream` | SSE stream (health, service-action, mesh events) |
| **Programs** | | | **Programs** | |
| `GET /programs` | List all programs (`?behavior=tool\|daemon\|frontend` to filter) | | `GET /programs` | List all programs (`?kind=tool\|service\|job\|static` to filter) |
| `GET /programs/{name}` | Program detail | | `GET /programs/{name}` | Program detail |
| `POST /programs/{name}/{action}` | Run a lifecycle action (build, test, lint, install, etc.) | | `POST /programs/{name}/{action}` | Run a lifecycle action (build, test, lint, install, etc.) |
| `GET /components` | Unified view across nodes (`?include_remote=true` for cross-node) | | `GET /components` | Unified view across nodes (`?include_remote=true` for cross-node) |
@@ -215,8 +216,7 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist
| `GET /config` | Read castle.yaml | | `GET /config` | Read castle.yaml |
| `PUT /config` | Write castle.yaml | | `PUT /config` | Write castle.yaml |
| `PUT /config/programs/{name}` | Update a program entry | | `PUT /config/programs/{name}` | Update a program entry |
| `PUT /config/services/{name}` | Update a service entry | | `PUT /config/deployments/{name}` | Update a deployment entry (service/job/tool/static) |
| `PUT /config/jobs/{name}` | Update a job entry |
| `POST /config/apply` | Apply config changes (deploy + reload) | | `POST /config/apply` | Apply config changes (deploy + reload) |
| **Secrets** | | | **Secrets** | |
| `GET /secrets` | List secrets | | `GET /secrets` | List secrets |

View File

@@ -1,24 +0,0 @@
import { cn } from "@/lib/utils"
import { BEHAVIOR_DESCRIPTIONS, behaviorLabel } from "@/lib/labels"
const behaviorColors: Record<string, string> = {
daemon: "bg-green-700 text-white",
tool: "bg-blue-700 text-white",
frontend: "bg-yellow-600 text-black",
}
export function BehaviorBadge({ behavior }: { behavior: string | null }) {
if (!behavior) return null
return (
<span
className={cn(
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
behaviorColors[behavior] ?? "bg-gray-600 text-gray-200",
)}
title={BEHAVIOR_DESCRIPTIONS[behavior]}
>
{behaviorLabel(behavior)}
</span>
)
}

View File

@@ -2,7 +2,7 @@ import { Clock, Play, RefreshCw, Square, Terminal } from "lucide-react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import type { JobSummary, HealthStatus } from "@/types" import type { JobSummary, HealthStatus } from "@/types"
import { useServiceAction } from "@/services/api/hooks" import { useServiceAction } from "@/services/api/hooks"
import { runnerLabel } from "@/lib/labels" import { launcherLabel } from "@/lib/labels"
import { StackBadge } from "./StackBadge" import { StackBadge } from "./StackBadge"
interface JobCardProps { interface JobCardProps {
@@ -50,10 +50,10 @@ export function JobCard({ job, health }: JobCardProps) {
{job.schedule} {job.schedule}
</span> </span>
)} )}
{job.runner && ( {job.launcher && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Terminal size={12} /> <Terminal size={12} />
{runnerLabel(job.runner)} {launcherLabel(job.launcher)}
</span> </span>
)} )}
</div> </div>

View File

@@ -0,0 +1,27 @@
import { cn } from "@/lib/utils"
import { KIND_DESCRIPTIONS, kindLabel } from "@/lib/labels"
// Derived deployment kind → badge color.
const kindColors: Record<string, string> = {
service: "bg-green-700 text-white",
job: "bg-purple-700 text-white",
tool: "bg-blue-700 text-white",
static: "bg-cyan-700 text-white",
reference: "bg-gray-600 text-gray-200",
}
export function KindBadge({ kind }: { kind: string | null }) {
if (!kind) return null
return (
<span
className={cn(
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
kindColors[kind] ?? "bg-gray-600 text-gray-200",
)}
title={KIND_DESCRIPTIONS[kind]}
>
{kindLabel(kind)}
</span>
)
}

View File

@@ -42,20 +42,16 @@ interface ProgramActionsProps {
name: string name: string
actions: string[] actions: string[]
active?: boolean | null active?: boolean | null
behavior?: string | null kind?: string | null
/** Names of services/jobs deploying this program — daemons activate via these. */
deployedAs?: string[]
compact?: boolean compact?: boolean
onOutput?: (output: ActionOutput) => void onOutput?: (output: ActionOutput) => void
} }
/** install/uninstall (activate) is meaningful only for tools (PATH) and static /** install/uninstall (activate) is meaningful only for a tool (a PATH deployment).
* frontends (served). A daemon activates through a service/job, so it never * Services, jobs, and static (caddy) deployments are managed through their
* shows install/uninstall here — its run controls live on the deployment page. */ * deployment — never install/uninstall here. */
function showsActivation(behavior: string | null | undefined, deployedAs: string[]): boolean { function showsActivation(kind: string | null | undefined): boolean {
if (behavior === "daemon") return false return kind === "tool"
if (behavior === "frontend") return deployedAs.length === 0 // self-serving frontend → its service
return true // tools (and unspecified)
} }
function visibleActions( function visibleActions(
@@ -94,16 +90,15 @@ export function ProgramActions({
name, name,
actions, actions,
active, active,
behavior, kind,
deployedAs = [],
compact, compact,
onOutput, onOutput,
}: ProgramActionsProps) { }: ProgramActionsProps) {
const { mutate, isPending } = useProgramAction() const { mutate, isPending } = useProgramAction()
const [runningAction, setRunningAction] = useState<string | null>(null) const [runningAction, setRunningAction] = useState<string | null>(null)
// Drop install/uninstall for behaviors that activate via a deployment. // Drop install/uninstall for kinds that activate via a deployment.
const allowed = showsActivation(behavior, deployedAs) const allowed = showsActivation(kind)
? actions ? actions
: actions.filter((a) => a !== "install" && a !== "uninstall") : actions.filter((a) => a !== "install" && a !== "uninstall")
const visible = visibleActions(allowed, active, !!compact) const visible = visibleActions(allowed, active, !!compact)

View File

@@ -1,6 +1,6 @@
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import type { ProgramSummary } from "@/types" import type { ProgramSummary } from "@/types"
import { BehaviorBadge } from "./BehaviorBadge" import { KindBadge } from "./KindBadge"
import { StackBadge } from "./StackBadge" import { StackBadge } from "./StackBadge"
import { ProgramActions } from "./ProgramActions" import { ProgramActions } from "./ProgramActions"
@@ -21,7 +21,7 @@ export function ProgramCard({ program }: ProgramCardProps) {
</div> </div>
<div className="flex flex-wrap gap-1.5 mb-2"> <div className="flex flex-wrap gap-1.5 mb-2">
<BehaviorBadge behavior={program.behavior} /> <KindBadge kind={program.kind} />
<StackBadge stack={program.stack} /> <StackBadge stack={program.stack} />
</div> </div>
@@ -34,8 +34,7 @@ export function ProgramCard({ program }: ProgramCardProps) {
name={program.id} name={program.id}
actions={program.actions} actions={program.actions}
active={program.active} active={program.active}
behavior={program.behavior} kind={program.kind}
deployedAs={[...program.services, ...program.jobs]}
compact compact
/> />
</div> </div>

View File

@@ -2,7 +2,7 @@ import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import type { ServiceSummary, HealthStatus } from "@/types" import type { ServiceSummary, HealthStatus } from "@/types"
import { useServiceAction } from "@/services/api/hooks" import { useServiceAction } from "@/services/api/hooks"
import { runnerLabel, subdomainUrl } from "@/lib/labels" import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "./HealthBadge" import { HealthBadge } from "./HealthBadge"
import { StackBadge } from "./StackBadge" import { StackBadge } from "./StackBadge"
@@ -52,10 +52,10 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
<Server size={12} />:{service.port} <Server size={12} />:{service.port}
</span> </span>
)} )}
{service.runner && ( {service.launcher && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Terminal size={12} /> <Terminal size={12} />
{runnerLabel(service.runner)} {launcherLabel(service.launcher)}
</span> </span>
)} )}
{service.subdomain && ( {service.subdomain && (

View File

@@ -23,11 +23,14 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
const [pendingApply, setPendingApply] = useState(false) const [pendingApply, setPendingApply] = useState(false)
const [applying, setApplying] = useState(false) const [applying, setApplying] = useState(false)
const isDeployment = configSection !== "programs" const isDeployment = configSection !== "programs"
// Programs are their own catalog; every deployment kind (service/job/tool/
// static) lives in the single deployments/ collection.
const writeSection = isDeployment ? "deployments" : "programs"
const handleSave = async (compName: string, config: Record<string, unknown>) => { const handleSave = async (compName: string, config: Record<string, unknown>) => {
setMessage(null) setMessage(null)
try { try {
await apiClient.put(`/config/${configSection}/${compName}`, { config }) await apiClient.put(`/config/${writeSection}/${compName}`, { config })
setMessage({ setMessage({
type: "ok", type: "ok",
text: isDeployment text: isDeployment
@@ -71,7 +74,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
const handleDelete = async (compName: string) => { const handleDelete = async (compName: string) => {
try { try {
await apiClient.delete(`/config/${configSection}/${compName}`) await apiClient.delete(`/config/${writeSection}/${compName}`)
qc.invalidateQueries({ queryKey: [configSection] }) qc.invalidateQueries({ queryKey: [configSection] })
navigate("/") navigate("/")
} catch (e: unknown) { } catch (e: unknown) {

View File

@@ -8,23 +8,32 @@ import { Field, TextField } from "./fields"
const SELECT = const SELECT =
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]" "bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
export type DeploymentKind = "service" | "job" | "tool" | "static"
export interface CreatePrefill { export interface CreatePrefill {
name?: string name?: string
program?: string program?: string
runTarget?: string runTarget?: string
runner?: string launcher?: string
} }
/** Create a service or job in castle.yaml, then deploy (and start, for a const KIND_INFO: Record<DeploymentKind, { label: string; hint: string }> = {
* service). The UI twin of `castle expose`. Reachable standalone or prefilled service: { label: "Service", hint: "Long-running process (systemd)" },
* from a program page. */ job: { label: "Job", hint: "Scheduled task (systemd timer)" },
tool: { label: "Tool", hint: "CLI installed on PATH" },
static: { label: "Static", hint: "Static site served by the gateway" },
}
/** Create a deployment in castle.yaml, then deploy (and start, for a service).
* A pick-a-kind wizard: the chosen kind sets the manager and shows only its
* relevant fields. Reachable standalone or prefilled from a program page. */
export function CreateDeploymentForm({ export function CreateDeploymentForm({
kind, kind: initialKind,
prefill, prefill,
existingNames, existingNames,
onCancel, onCancel,
}: { }: {
kind: "service" | "job" kind?: DeploymentKind
prefill?: CreatePrefill prefill?: CreatePrefill
existingNames: string[] existingNames: string[]
onCancel: () => void onCancel: () => void
@@ -32,18 +41,23 @@ export function CreateDeploymentForm({
const qc = useQueryClient() const qc = useQueryClient()
const navigate = useNavigate() const navigate = useNavigate()
const [kind, setKind] = useState<DeploymentKind>(initialKind ?? "service")
const [name, setName] = useState(prefill?.name ?? "") const [name, setName] = useState(prefill?.name ?? "")
const [program] = useState(prefill?.program ?? "") const [program] = useState(prefill?.program ?? "")
const [description, setDescription] = useState("") const [description, setDescription] = useState("")
const [runner, setRunner] = useState(prefill?.runner ?? "python") const [launcher, setLauncher] = useState(prefill?.launcher ?? "python")
const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "") const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "")
const [root, setRoot] = useState("dist")
const [port, setPort] = useState("") const [port, setPort] = useState("")
const [health, setHealth] = useState("/health") const [health, setHealth] = useState("/health")
const [expose, setExpose] = useState(true) const [proxy, setProxy] = useState(true)
const [isPublic, setIsPublic] = useState(false)
const [schedule, setSchedule] = useState("0 2 * * *") const [schedule, setSchedule] = useState("0 2 * * *")
const [busy, setBusy] = useState<string | null>(null) const [busy, setBusy] = useState<string | null>(null)
const [error, setError] = useState("") const [error, setError] = useState("")
const isSystemd = kind === "service" || kind === "job"
const nameError = const nameError =
name && !/^[a-z0-9][a-z0-9-]*$/.test(name) name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
? "lowercase letters, numbers, hyphens" ? "lowercase letters, numbers, hyphens"
@@ -51,31 +65,41 @@ export function CreateDeploymentForm({
? "already exists" ? "already exists"
: "" : ""
const buildRun = () =>
launcher === "command"
? { launcher: "command", argv: runTarget.split(" ").filter(Boolean) }
: { launcher: "python", program: runTarget || name }
const buildConfig = (): Record<string, unknown> => { const buildConfig = (): Record<string, unknown> => {
const run =
runner === "command"
? { runner: "command", argv: runTarget.split(" ").filter(Boolean) }
: { runner: "python", program: runTarget || name }
const base: Record<string, unknown> = { const base: Record<string, unknown> = {
...(program ? { program } : {}), ...(program ? { program } : {}),
...(description ? { description } : {}), ...(description ? { description } : {}),
run, }
if (kind === "tool") return { ...base, manager: "path" }
if (kind === "static") return { ...base, manager: "caddy", root }
// systemd (service or job)
const cfg: Record<string, unknown> = {
...base,
manager: "systemd",
run: buildRun(),
manage: { systemd: {} }, manage: { systemd: {} },
} }
if (kind === "job") { if (kind === "job") {
base.schedule = schedule cfg.schedule = schedule
return base return cfg
} }
if (port) { if (port) {
base.expose = { cfg.expose = {
http: { http: {
internal: { port: parseInt(port, 10) }, internal: { port: parseInt(port, 10) },
...(health ? { health_path: health } : {}), ...(health ? { health_path: health } : {}),
}, },
} }
} }
if (port && expose) base.proxy = true if (proxy) cfg.proxy = true
return base if (proxy && isPublic) cfg.public = true
return cfg
} }
const submit = async () => { const submit = async () => {
@@ -83,17 +107,20 @@ export function CreateDeploymentForm({
setError("") setError("")
try { try {
setBusy("Saving…") setBusy("Saving…")
await apiClient.put(`/config/${kind}s/${name}`, { config: buildConfig() }) await apiClient.put(`/config/deployments/${name}`, { config: buildConfig() })
setBusy("Deploying…") setBusy("Deploying…")
await apiClient.post(`/deploy`, { name }) await apiClient.post(`/deploy`, { name })
if (kind === "service") { if (kind === "service") {
setBusy("Starting…") setBusy("Starting…")
await apiClient.post(`/services/${name}/start`, {}) await apiClient.post(`/services/${name}/start`, {})
} }
qc.invalidateQueries({ queryKey: [`${kind}s`] }) qc.invalidateQueries({ queryKey: ["services"] })
qc.invalidateQueries({ queryKey: ["jobs"] })
qc.invalidateQueries({ queryKey: ["programs"] }) qc.invalidateQueries({ queryKey: ["programs"] })
qc.invalidateQueries({ queryKey: ["status"] }) qc.invalidateQueries({ queryKey: ["status"] })
navigate(kind === "service" ? `/services/${name}` : `/jobs/${name}`) if (kind === "service") navigate(`/services/${name}`)
else if (kind === "job") navigate(`/jobs/${name}`)
else navigate(`/programs/${program || name}`)
} catch (e: unknown) { } catch (e: unknown) {
let msg = e instanceof Error ? e.message : String(e) let msg = e instanceof Error ? e.message : String(e)
try { try {
@@ -109,7 +136,7 @@ export function CreateDeploymentForm({
return ( return (
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4 mt-2"> <div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4 mt-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="font-semibold">New {kind}{program ? ` for ${program}` : ""}</h3> <h3 className="font-semibold">New deployment{program ? ` for ${program}` : ""}</h3>
<button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]"> <button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]">
<X size={16} /> <X size={16} />
</button> </button>
@@ -121,11 +148,31 @@ export function CreateDeploymentForm({
</div> </div>
)} )}
{/* Kind picker */}
<Field label="Kind" hint={KIND_INFO[kind].hint}>
<div className="flex flex-wrap gap-1.5">
{(Object.keys(KIND_INFO) as DeploymentKind[]).map((k) => (
<button
key={k}
type="button"
onClick={() => setKind(k)}
className={`px-3 py-1 text-sm rounded border transition-colors ${
kind === k
? "bg-[var(--primary)] text-white border-[var(--primary)]"
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{KIND_INFO[k].label}
</button>
))}
</div>
</Field>
<Field label="Name"> <Field label="Name">
<input <input
value={name} value={name}
onChange={(e) => setName(e.target.value.toLowerCase())} onChange={(e) => setName(e.target.value.toLowerCase())}
placeholder={kind === "service" ? "my-service" : "my-job"} placeholder="my-deployment"
autoFocus autoFocus
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]" className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
/> />
@@ -134,8 +181,17 @@ export function CreateDeploymentForm({
<TextField label="Description" value={description} onChange={setDescription} /> <TextField label="Description" value={description} onChange={setDescription} />
<Field label="Runner"> {/* Program ref — informational; tool/static require it, systemd optional. */}
<select value={runner} onChange={(e) => setRunner(e.target.value)} className={`w-40 ${SELECT}`}> {program && (
<Field label="Program">
<span className="text-sm font-mono text-[var(--muted)]">{program}</span>
</Field>
)}
{isSystemd && (
<>
<Field label="Launcher">
<select value={launcher} onChange={(e) => setLauncher(e.target.value)} className={`w-40 ${SELECT}`}>
<option value="python">python</option> <option value="python">python</option>
<option value="command">command</option> <option value="command">command</option>
</select> </select>
@@ -145,26 +201,49 @@ export function CreateDeploymentForm({
value={runTarget} value={runTarget}
onChange={setRunTarget} onChange={setRunTarget}
mono mono
placeholder={runner === "command" ? "my-cmd --flag" : "console-script"} placeholder={launcher === "command" ? "my-cmd --flag" : "console-script"}
/> />
</>
)}
{kind === "service" ? ( {kind === "service" && (
<> <>
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" /> <TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono /> <TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono />
<Field label="Expose" hint="Route through the gateway at <name>.<gateway.domain>. Off: reachable only at host:port."> <Field label="Expose" hint="Route through the gateway at <name>.<gateway.domain>. Off: reachable only at host:port.">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={expose} onChange={(e) => setExpose(e.target.checked)} /> <input type="checkbox" checked={proxy} onChange={(e) => setProxy(e.target.checked)} />
<span className="font-mono text-[var(--muted)]"> <span className="font-mono text-[var(--muted)]">
{expose ? `${name || "name"}.<gateway.domain>` : "off (host:port only)"} {proxy ? `${name || "name"}.<gateway.domain>` : "off (host:port only)"}
</span> </span>
</label> </label>
</Field> </Field>
{proxy && (
<Field label="Public" hint="Also publish to the internet via the Cloudflare tunnel at <name>.<gateway.public_domain>.">
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<span className="font-mono text-[var(--muted)]">{isPublic ? "public (via tunnel)" : "internal only"}</span>
</label>
</Field>
)}
</> </>
) : ( )}
{kind === "job" && (
<TextField label="Schedule" value={schedule} onChange={setSchedule} width="w-48" mono placeholder="0 2 * * *" /> <TextField label="Schedule" value={schedule} onChange={setSchedule} width="w-48" mono placeholder="0 2 * * *" />
)} )}
{kind === "static" && (
<TextField
label="Root"
value={root}
onChange={setRoot}
width="w-48"
mono
placeholder="dist"
/>
)}
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"> <button onClick={onCancel} className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]">
Cancel Cancel
@@ -174,7 +253,7 @@ export function CreateDeploymentForm({
disabled={!name || !!nameError || !!busy} disabled={!name || !!nameError || !!busy}
className="px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40" className="px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
> >
{busy ?? `Create ${kind}`} {busy ?? `Create ${KIND_INFO[kind].label.toLowerCase()}`}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -5,27 +5,26 @@ import type { ProgramDetail } from "@/types"
import { useServices, useJobs } from "@/services/api/hooks" import { useServices, useJobs } from "@/services/api/hooks"
import { CreateDeploymentForm, type CreatePrefill } from "./CreateDeploymentForm" import { CreateDeploymentForm, type CreatePrefill } from "./CreateDeploymentForm"
/** The services and jobs that deploy a program. A program → 0-N services and /** The services and jobs that deploy a program. A program → 0-N deployments;
* 0-N jobs; these are convenience links, not ownership (a deployment can run * these are convenience links, not ownership (a deployment can run anything,
* anything, program-backed or not). The Create buttons just prefill the * program-backed or not). The Create button prefills the kind-aware wizard. */
* standalone create form with sensible values. */
export function DeploymentsSection({ program }: { program: ProgramDetail }) { export function DeploymentsSection({ program }: { program: ProgramDetail }) {
const { services, jobs, behavior } = program const { services, jobs, kind } = program
const none = services.length === 0 && jobs.length === 0 const none = services.length === 0 && jobs.length === 0
const [creating, setCreating] = useState<"service" | "job" | null>(null) const [creating, setCreating] = useState(false)
const { data: allServices } = useServices() const { data: allServices } = useServices()
const { data: allJobs } = useJobs() const { data: allJobs } = useJobs()
const existing = const existing = [
creating === "service" ...(allServices ?? []).map((s) => s.id),
? (allServices ?? []).map((s) => s.id) ...(allJobs ?? []).map((j) => j.id),
: (allJobs ?? []).map((j) => j.id) ]
const prefill: CreatePrefill = { const prefill: CreatePrefill = {
name: program.id, name: program.id,
program: program.id, program: program.id,
runTarget: program.id, runTarget: program.id,
runner: program.stack?.startsWith("python") || !program.stack ? "python" : "command", launcher: program.stack?.startsWith("python") || !program.stack ? "python" : "command",
} }
return ( return (
@@ -34,20 +33,12 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider"> <h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
Deployments Deployments
</h2> </h2>
<div className="flex gap-2">
<button <button
onClick={() => setCreating(creating === "service" ? null : "service")} onClick={() => setCreating((c) => !c)}
className="flex items-center gap-1 text-xs text-[var(--primary)] hover:underline" className="flex items-center gap-1 text-xs text-[var(--primary)] hover:underline"
> >
<Plus size={12} /> Create service <Plus size={12} /> Add deployment
</button> </button>
<button
onClick={() => setCreating(creating === "job" ? null : "job")}
className="flex items-center gap-1 text-xs text-[var(--primary)] hover:underline"
>
<Plus size={12} /> Create job
</button>
</div>
</div> </div>
<p className="text-xs text-[var(--muted)] mb-4"> <p className="text-xs text-[var(--muted)] mb-4">
Services and jobs that run this program. Services and jobs that run this program.
@@ -55,19 +46,18 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
{creating && ( {creating && (
<CreateDeploymentForm <CreateDeploymentForm
kind={creating}
prefill={prefill} prefill={prefill}
existingNames={existing} existingNames={existing}
onCancel={() => setCreating(null)} onCancel={() => setCreating(false)}
/> />
)} )}
{none && !creating ? ( {none && !creating ? (
<p className="text-sm text-[var(--muted)]"> <p className="text-sm text-[var(--muted)]">
{behavior === "daemon" {kind === "service"
? "No service yet — this daemon isn't deployed." ? "No service yet — this program isn't deployed."
: behavior === "tool" : kind === "tool"
? "Not scheduled — add a job to run it on a timer." ? "Installed on PATH. Add a job to also run it on a timer."
: "None."} : "None."}
</p> </p>
) : ( ) : (

View File

@@ -1,19 +1,19 @@
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { ArrowLeft } from "lucide-react" import { ArrowLeft } from "lucide-react"
import { BehaviorBadge } from "@/components/BehaviorBadge" import { KindBadge } from "@/components/KindBadge"
import { StackBadge } from "@/components/StackBadge" import { StackBadge } from "@/components/StackBadge"
interface DetailHeaderProps { interface DetailHeaderProps {
backTo: string backTo: string
backLabel: string backLabel: string
name: string name: string
behavior?: string | null kind?: string | null
stack?: string | null stack?: string | null
source?: string | null source?: string | null
children?: React.ReactNode children?: React.ReactNode
} }
export function DetailHeader({ backTo, backLabel, name, behavior, stack, source, children }: DetailHeaderProps) { export function DetailHeader({ backTo, backLabel, name, kind, stack, source, children }: DetailHeaderProps) {
return ( return (
<> <>
<Link to={backTo} className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-6"> <Link to={backTo} className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-6">
@@ -26,7 +26,7 @@ export function DetailHeader({ backTo, backLabel, name, behavior, stack, source,
</div> </div>
<div className="flex items-center gap-3 flex-wrap mb-6"> <div className="flex items-center gap-3 flex-wrap mb-6">
<BehaviorBadge behavior={behavior ?? null} /> <KindBadge kind={kind ?? null} />
<StackBadge stack={stack ?? null} /> <StackBadge stack={stack ?? null} />
{source && ( {source && (
<span className="text-sm text-[var(--muted)] font-mono break-all min-w-0">{source}</span> <span className="text-sm text-[var(--muted)] font-mono break-all min-w-0">{source}</span>

View File

@@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import type { JobDetail } from "@/types" import type { JobDetail } from "@/types"
import { runnerLabel } from "@/lib/labels" import { launcherLabel } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields" import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props { interface Props {
@@ -27,7 +27,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
) )
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>) const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const runner = (run.runner as string) ?? "?" const launcher = (run.launcher as string) ?? "?"
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true)
@@ -39,8 +39,8 @@ export function JobFields({ job, onSave, onDelete }: Props) {
config.schedule = schedule || undefined config.schedule = schedule || undefined
const runOut = obj(config.run) const runOut = obj(config.run)
if (runner === "command") runOut.argv = runTarget.split(" ").filter(Boolean) if (launcher === "command") runOut.argv = runTarget.split(" ").filter(Boolean)
else if (runner === "python") runOut.program = runTarget else if (launcher === "python") runOut.program = runTarget
config.run = runOut config.run = runOut
const env = merged() const env = merged()
@@ -68,7 +68,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
hint="Cron expression — castle generates a systemd timer that runs the job on this schedule." hint="Cron expression — castle generates a systemd timer that runs the job on this schedule."
/> />
<Field label="Runs" hint="The console script or command the job runs on each tick, then exits."> <Field label="Runs" hint="The console script or command the job runs on each tick, then exits.">
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} &middot; </span> <span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} &middot; </span>
<input <input
value={runTarget} value={runTarget}
onChange={(e) => setRunTarget(e.target.value)} onChange={(e) => setRunTarget(e.target.value)}

View File

@@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import type { ServiceDetail } from "@/types" import type { ServiceDetail } from "@/types"
import { runnerLabel } from "@/lib/labels" import { launcherLabel } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields" import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
interface Props { interface Props {
@@ -33,7 +33,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>) const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
const runner = (run.runner as string) ?? "?" const launcher = (run.launcher as string) ?? "?"
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true)
@@ -43,11 +43,11 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
delete config.id delete config.id
config.description = description || undefined config.description = description || undefined
// Only python/command run specs are edited here; other runners // Only python/command launchers are edited here; other launchers
// (container/node/remote) keep their original run block untouched. // (container/node/compose) keep their original run block untouched.
const runOut = obj(config.run) const runOut = obj(config.run)
if (runner === "command") runOut.argv = runProgram.split(" ").filter(Boolean) if (launcher === "command") runOut.argv = runProgram.split(" ").filter(Boolean)
else if (runner === "python") runOut.program = runProgram else if (launcher === "python") runOut.program = runProgram
config.run = runOut config.run = runOut
if (port) { if (port) {
@@ -83,7 +83,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
label="Runs" label="Runs"
hint="The console script (python runner) or command this service executes." hint="The console script (python runner) or command this service executes."
> >
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} &middot; </span> <span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} &middot; </span>
<input <input
value={runProgram} value={runProgram}
onChange={(e) => setRunProgram(e.target.value)} onChange={(e) => setRunProgram(e.target.value)}

View File

@@ -1,21 +1,26 @@
export const RUNNER_LABELS: Record<string, string> = { export const LAUNCHER_LABELS: Record<string, string> = {
python: "Python", python: "Python",
command: "Command", command: "Command",
container: "Container", container: "Container",
compose: "Compose",
node: "Node.js", node: "Node.js",
remote: "Remote",
} }
export const BEHAVIOR_LABELS: Record<string, string> = { // Derived deployment kinds (service | job | tool | static | reference).
daemon: "Daemon", export const KIND_LABELS: Record<string, string> = {
service: "Service",
job: "Job",
tool: "Tool", tool: "Tool",
frontend: "Frontend", static: "Static",
reference: "Reference",
} }
export const BEHAVIOR_DESCRIPTIONS: Record<string, string> = { export const KIND_DESCRIPTIONS: Record<string, string> = {
daemon: "Long-running process that exposes ports", service: "Long-running process (systemd)",
tool: "CLI utility or scheduled task", job: "Scheduled task (timer)",
frontend: "Built web application", tool: "CLI installed on PATH",
static: "Static site served by the gateway",
reference: "External service on another node",
} }
export const STACK_LABELS: Record<string, string> = { export const STACK_LABELS: Record<string, string> = {
@@ -31,12 +36,12 @@ export const STACK_LABELS: Record<string, string> = {
remote: "Remote", remote: "Remote",
} }
export function runnerLabel(runner: string): string { export function launcherLabel(launcher: string): string {
return RUNNER_LABELS[runner] ?? runner return LAUNCHER_LABELS[launcher] ?? launcher
} }
export function behaviorLabel(behavior: string): string { export function kindLabel(kind: string): string {
return BEHAVIOR_LABELS[behavior] ?? behavior return KIND_LABELS[kind] ?? kind
} }
export function stackLabel(stack: string): string { export function stackLabel(stack: string): string {

View File

@@ -1,7 +1,7 @@
import { useParams, Link } from "react-router-dom" import { useParams, Link } from "react-router-dom"
import { ArrowLeft, Server } from "lucide-react" import { ArrowLeft, Server } from "lucide-react"
import { useNode } from "@/services/api/hooks" import { useNode } from "@/services/api/hooks"
import { BehaviorBadge } from "@/components/BehaviorBadge" import { KindBadge } from "@/components/KindBadge"
import { StackBadge } from "@/components/StackBadge" import { StackBadge } from "@/components/StackBadge"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -63,9 +63,9 @@ export function NodeDetailPage() {
<thead> <thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left"> <tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<th className="px-3 py-2 font-medium text-[var(--muted)]">Deployment</th> <th className="px-3 py-2 font-medium text-[var(--muted)]">Deployment</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Behavior</th> <th className="px-3 py-2 font-medium text-[var(--muted)]">Kind</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th> <th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Runner</th> <th className="px-3 py-2 font-medium text-[var(--muted)]">Manager</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th> <th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th>
</tr> </tr>
</thead> </thead>
@@ -87,13 +87,13 @@ export function NodeDetailPage() {
)} )}
</td> </td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<BehaviorBadge behavior={comp.behavior} /> <KindBadge kind={comp.kind} />
</td> </td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<StackBadge stack={comp.stack} /> <StackBadge stack={comp.stack} />
</td> </td>
<td className="px-3 py-2.5 text-[var(--muted)]"> <td className="px-3 py-2.5 text-[var(--muted)]">
{comp.runner ?? "—"} {comp.manager ?? "—"}
</td> </td>
<td className="px-3 py-2.5 font-mono text-[var(--muted)]"> <td className="px-3 py-2.5 font-mono text-[var(--muted)]">
{comp.port ?? "—"} {comp.port ?? "—"}

View File

@@ -1,7 +1,7 @@
import { useState } from "react" import { useState } from "react"
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { useProgram } from "@/services/api/hooks" import { useProgram } from "@/services/api/hooks"
import { runnerLabel, subdomainUrl } from "@/lib/labels" import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { DetailHeader } from "@/components/detail/DetailHeader" import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel" import { ConfigPanel } from "@/components/detail/ConfigPanel"
import { DeploymentsSection } from "@/components/detail/DeploymentsSection" import { DeploymentsSection } from "@/components/detail/DeploymentsSection"
@@ -27,11 +27,11 @@ export function ProgramDetailPage() {
) )
} }
// A static frontend (frontend behavior, build outputs, no service) is served by // A static (caddy) deployment with build outputs is served by the gateway in
// the gateway in place at its own subdomain — show where. // place at its own subdomain — show where.
const buildOutputs = (deployment.manifest.build as { outputs?: string[] } | undefined)?.outputs const buildOutputs = (deployment.manifest.build as { outputs?: string[] } | undefined)?.outputs
const servedAt = const servedAt =
deployment.behavior === "frontend" && deployment.services.length === 0 && buildOutputs?.length deployment.kind === "static" && buildOutputs?.length
? (subdomainUrl(deployment.id) ?? `${deployment.id}.<gateway.domain>`) ? (subdomainUrl(deployment.id) ?? `${deployment.id}.<gateway.domain>`)
: null : null
@@ -41,7 +41,7 @@ export function ProgramDetailPage() {
backTo="/programs" backTo="/programs"
backLabel="Back to Programs" backLabel="Back to Programs"
name={deployment.id} name={deployment.id}
behavior={deployment.behavior} kind={deployment.kind}
stack={deployment.stack} stack={deployment.stack}
source={deployment.source} source={deployment.source}
> >
@@ -49,8 +49,7 @@ export function ProgramDetailPage() {
name={deployment.id} name={deployment.id}
actions={deployment.actions} actions={deployment.actions}
active={deployment.active} active={deployment.active}
behavior={deployment.behavior} kind={deployment.kind}
deployedAs={[...deployment.services, ...deployment.jobs]}
onOutput={setActionOutput} onOutput={setActionOutput}
/> />
</DetailHeader> </DetailHeader>
@@ -96,8 +95,8 @@ export function ProgramDetailPage() {
)} )}
{deployment.runner && ( {deployment.runner && (
<> <>
<span className="text-[var(--muted)]">Runner</span> <span className="text-[var(--muted)]">Launcher</span>
<span>{runnerLabel(deployment.runner)}</span> <span>{launcherLabel(deployment.runner)}</span>
</> </>
)} )}
{deployment.active !== null && ( {deployment.active !== null && (

View File

@@ -34,6 +34,7 @@ export function ScheduledDetailPage() {
backTo="/scheduled" backTo="/scheduled"
backLabel="Back to Jobs" backLabel="Back to Jobs"
name={deployment.id} name={deployment.id}
kind="job"
stack={deployment.stack} stack={deployment.stack}
source={deployment.source} source={deployment.source}
> >

View File

@@ -1,7 +1,7 @@
import { useParams, Link } from "react-router-dom" import { useParams, Link } from "react-router-dom"
import { Server, ExternalLink, Terminal } from "lucide-react" import { Server, ExternalLink, Terminal } from "lucide-react"
import { useService, useStatus, useCaddyfile } from "@/services/api/hooks" import { useService, useStatus, useCaddyfile } from "@/services/api/hooks"
import { runnerLabel, subdomainUrl } from "@/lib/labels" import { launcherLabel, subdomainUrl } from "@/lib/labels"
import { HealthBadge } from "@/components/HealthBadge" import { HealthBadge } from "@/components/HealthBadge"
import { LogViewer } from "@/components/LogViewer" import { LogViewer } from "@/components/LogViewer"
import { DetailHeader } from "@/components/detail/DetailHeader" import { DetailHeader } from "@/components/detail/DetailHeader"
@@ -38,7 +38,7 @@ export function ServiceDetailPage() {
backTo="/services" backTo="/services"
backLabel="Back to Services" backLabel="Back to Services"
name={deployment.id} name={deployment.id}
behavior="daemon" kind="service"
stack={deployment.stack} stack={deployment.stack}
source={deployment.source} source={deployment.source}
> >
@@ -78,12 +78,12 @@ export function ServiceDetailPage() {
</a> </a>
</> </>
)} )}
{deployment.runner && ( {deployment.launcher && (
<> <>
<span className="text-[var(--muted)]">Runs</span> <span className="text-[var(--muted)]">Runs</span>
<span className="flex items-center gap-1 min-w-0"> <span className="flex items-center gap-1 min-w-0">
<Terminal size={12} className="shrink-0" /> <Terminal size={12} className="shrink-0" />
{runnerLabel(deployment.runner)} {launcherLabel(deployment.launcher)}
{deployment.run_target && <> &middot; <span className="font-mono break-all">{deployment.run_target}</span></>} {deployment.run_target && <> &middot; <span className="font-mono break-all">{deployment.run_target}</span></>}
</span> </span>
</> </>

View File

@@ -68,10 +68,10 @@ export function useJob(name: string) {
}) })
} }
export function usePrograms(behavior?: string) { export function usePrograms(kind?: string) {
const params = behavior ? `?behavior=${behavior}` : "" const params = kind ? `?kind=${kind}` : ""
return useQuery({ return useQuery({
queryKey: ["programs", behavior ?? "all"], queryKey: ["programs", kind ?? "all"],
queryFn: () => apiClient.get<ProgramSummary[]>(`/programs${params}`), queryFn: () => apiClient.get<ProgramSummary[]>(`/programs${params}`),
}) })
} }

View File

@@ -8,7 +8,8 @@ export interface ServiceSummary {
id: string id: string
description: string | null description: string | null
stack: string | null stack: string | null
runner: string | null manager?: string | null // systemd for a service
launcher: string | null // python | command | container | compose | node
run_target: string | null run_target: string | null
port: number | null port: number | null
health_path: string | null health_path: string | null
@@ -28,7 +29,7 @@ export interface JobSummary {
id: string id: string
description: string | null description: string | null
stack: string | null stack: string | null
runner: string | null launcher: string | null // python | command | container | compose | node
run_target: string | null run_target: string | null
schedule: string | null schedule: string | null
managed: boolean managed: boolean
@@ -45,9 +46,9 @@ export interface JobDetail extends JobSummary {
export interface ProgramSummary { export interface ProgramSummary {
id: string id: string
description: string | null description: string | null
behavior: string | null kind: string | null // derived: service | job | tool | static | reference
stack: string | null stack: string | null
runner: string | null runner: string | null // inferred launch hint (python | command)
version: string | null version: string | null
source: string | null source: string | null
repo: string | null repo: string | null
@@ -74,9 +75,10 @@ export interface DeploymentSummary {
id: string id: string
category: "program" | "service" | "job" | null category: "program" | "service" | "job" | null
description: string | null description: string | null
behavior: string | null kind: string | null // derived: service | job | tool | static | reference
stack: string | null stack: string | null
runner: string | null manager: string | null // systemd | caddy | path | none
launcher: string | null // python | command | container | compose | node (systemd only)
port: number | null port: number | null
health_path: string | null health_path: string | null
subdomain: string | null // exposed at <subdomain>.<gateway.domain>, else null subdomain: string | null // exposed at <subdomain>.<gateway.domain>, else null

View File

@@ -12,12 +12,14 @@ from pydantic import BaseModel
from castle_core.config import ( from castle_core.config import (
CastleConfig, CastleConfig,
GatewayConfig, GatewayConfig,
_DEPLOYMENT_ADAPTER,
_normalize_deployment_dict,
_program_to_yaml_dict, _program_to_yaml_dict,
_spec_to_yaml_dict, _spec_to_yaml_dict,
load_config, load_config,
save_config, save_config,
) )
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec from castle_core.manifest import ProgramSpec, kind_for
from castle_api.config import get_castle_root, get_config, get_registry from castle_api.config import get_castle_root, get_config, get_registry
from castle_api.stream import broadcast from castle_api.stream import broadcast
@@ -79,12 +81,10 @@ def _aggregate_yaml(config: CastleConfig) -> str:
data["programs"] = { data["programs"] = {
n: _program_to_yaml_dict(s, config) for n, s in config.programs.items() n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()
} }
if config.services: if config.deployments:
data["services"] = { data["deployments"] = {
n: _spec_to_yaml_dict(s) for n, s in config.services.items() n: _spec_to_yaml_dict(s) for n, s in config.deployments.items()
} }
if config.jobs:
data["jobs"] = {n: _spec_to_yaml_dict(s) for n, s in config.jobs.items()}
return yaml.dump(data, default_flow_style=False, sort_keys=False) return yaml.dump(data, default_flow_style=False, sort_keys=False)
@@ -148,25 +148,20 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
except Exception as e: except Exception as e:
errors.append(f"programs.{name}: {e}") errors.append(f"programs.{name}: {e}")
# Validate services # Validate deployments (accepting a legacy services:/jobs: split too, which
services: dict[str, ServiceSpec] = {} # the normalizer folds into the single manager-discriminated collection).
for name, svc_data in data.get("services", {}).items(): deployments = {}
raw_deps: dict = dict(data.get("deployments") or {})
for legacy in ("services", "jobs"):
raw_deps.update(data.get(legacy) or {})
for name, dep_data in raw_deps.items():
try: try:
svc_data_copy = dict(svc_data) if svc_data else {} dep_copy = _normalize_deployment_dict(dict(dep_data) if dep_data else {})
svc_data_copy["id"] = name dep_copy = dict(dep_copy)
services[name] = ServiceSpec.model_validate(svc_data_copy) dep_copy["id"] = name
deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(dep_copy)
except Exception as e: except Exception as e:
errors.append(f"services.{name}: {e}") errors.append(f"deployments.{name}: {e}")
# Validate jobs
jobs: dict[str, JobSpec] = {}
for name, job_data in data.get("jobs", {}).items():
try:
job_data_copy = dict(job_data) if job_data else {}
job_data_copy["id"] = name
jobs[name] = JobSpec.model_validate(job_data_copy)
except Exception as e:
errors.append(f"jobs.{name}: {e}")
if errors: if errors:
raise HTTPException( raise HTTPException(
@@ -175,8 +170,8 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
) )
prog_count = len(programs) prog_count = len(programs)
svc_count = len(services) svc_count = sum(1 for d in deployments.values() if kind_for(d) == "service")
job_count = len(jobs) job_count = sum(1 for d in deployments.values() if kind_for(d) == "job")
gateway_data = data.get("gateway", {}) gateway_data = data.get("gateway", {})
config = CastleConfig( config = CastleConfig(
@@ -184,8 +179,7 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
repo=repo_path, repo=repo_path,
gateway=GatewayConfig(port=gateway_data.get("port", 9000)), gateway=GatewayConfig(port=gateway_data.get("port", 9000)),
programs=programs, programs=programs,
services=services, deployments=deployments,
jobs=jobs,
) )
save_config(config) save_config(config)
@@ -232,8 +226,7 @@ def delete_program(name: str) -> dict:
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"Program '{name}' not found", detail=f"Program '{name}' not found",
) )
refs = [s for s, spec in config.services.items() if spec.program == name] refs = [d for d, spec in config.deployments.items() if spec.program == name]
refs += [j for j, spec in config.jobs.items() if spec.program == name]
if refs: if refs:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
@@ -247,74 +240,70 @@ def delete_program(name: str) -> dict:
return {"ok": True, "program": name, "action": "deleted"} return {"ok": True, "program": name, "action": "deleted"}
@router.put("/services/{name}") def _save_deployment(name: str, config_dict: dict) -> dict:
def save_service(name: str, request: ServiceConfigRequest) -> dict: """Validate a deployment (any manager) and persist it to config.deployments."""
"""Update a single service's config in castle.yaml."""
_require_repo() _require_repo()
try: try:
svc_data = dict(request.config) dep_data = _normalize_deployment_dict({**config_dict, "id": name})
svc_data["id"] = name _DEPLOYMENT_ADAPTER.validate_python(dep_data)
ServiceSpec.model_validate(svc_data)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid service config: {e}", detail=f"Invalid deployment config: {e}",
) )
config = get_config() config = get_config()
config.services[name] = ServiceSpec.model_validate({**request.config, "id": name}) config.deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(
_normalize_deployment_dict({**config_dict, "id": name})
)
save_config(config) save_config(config)
return {"ok": True, "service": name} return {"ok": True, "deployment": name}
def _delete_deployment(name: str) -> dict:
config = get_config()
if name not in config.deployments:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Deployment '{name}' not found",
)
del config.deployments[name]
save_config(config)
return {"ok": True, "deployment": name, "action": "deleted"}
# The deployment endpoints — `deployments` is canonical; `services`/`jobs` remain
# as aliases (the kind is derived, so all three target the one collection).
@router.put("/deployments/{name}")
def save_deployment(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update a deployment of any kind (service/job/tool/static)."""
return _save_deployment(name, request.config)
@router.delete("/deployments/{name}")
def delete_deployment(name: str) -> dict:
return _delete_deployment(name)
@router.put("/services/{name}")
def save_service(name: str, request: ServiceConfigRequest) -> dict:
"""Alias of PUT /deployments/{name} (kept for the existing dashboard)."""
return _save_deployment(name, request.config)
@router.delete("/services/{name}") @router.delete("/services/{name}")
def delete_service(name: str) -> dict: def delete_service(name: str) -> dict:
"""Remove a service from castle.yaml.""" return _delete_deployment(name)
config = get_config()
if name not in config.services:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service '{name}' not found",
)
del config.services[name]
save_config(config)
return {"ok": True, "service": name, "action": "deleted"}
@router.put("/jobs/{name}") @router.put("/jobs/{name}")
def save_job(name: str, request: JobConfigRequest) -> dict: def save_job(name: str, request: JobConfigRequest) -> dict:
"""Update a single job's config in castle.yaml.""" """Alias of PUT /deployments/{name} (kept for the existing dashboard)."""
_require_repo() return _save_deployment(name, request.config)
try:
job_data = dict(request.config)
job_data["id"] = name
JobSpec.model_validate(job_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid job config: {e}",
)
config = get_config()
config.jobs[name] = JobSpec.model_validate({**request.config, "id": name})
save_config(config)
return {"ok": True, "job": name}
@router.delete("/jobs/{name}") @router.delete("/jobs/{name}")
def delete_job(name: str) -> dict: def delete_job(name: str) -> dict:
"""Remove a job from castle.yaml.""" return _delete_deployment(name)
config = get_config()
if name not in config.jobs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job '{name}' not found",
)
del config.jobs[name]
save_config(config)
return {"ok": True, "job": name, "action": "deleted"}
@router.post("/apply", response_model=ApplyResponse) @router.post("/apply", response_model=ApplyResponse)

View File

@@ -27,10 +27,8 @@ async def get_logs(
from castle_core.config import load_config from castle_core.config import load_config
config = load_config(root) config = load_config(root)
is_managed = ( dep = config.deployments.get(name)
(name in config.services and config.services[name].manage is not None) is_managed = dep is not None and getattr(dep, "manage", None) is not None
or (name in config.jobs and config.jobs[name].manage is not None)
)
if not is_managed: if not is_managed:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,

View File

@@ -17,9 +17,10 @@ class DeploymentSummary(BaseModel):
id: str id: str
category: str | None = None # "program", "service", or "job" category: str | None = None # "program", "service", or "job"
description: str | None = None description: str | None = None
behavior: str | None = None kind: str | None = None # derived: service|job|tool|static|reference
stack: str | None = None stack: str | None = None
runner: str | None = None manager: str | None = None # systemd|caddy|path|none
launcher: str | None = None # python|command|container|compose|node (systemd only)
port: int | None = None port: int | None = None
health_path: str | None = None health_path: str | None = None
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
@@ -49,7 +50,7 @@ class ServiceSummary(BaseModel):
id: str id: str
description: str | None = None description: str | None = None
stack: str | None = None stack: str | None = None
runner: str | None = None launcher: str | None = None # python|command|container|compose|node
run_target: str | None = None # what it runs: program name, argv, image, … run_target: str | None = None # what it runs: program name, argv, image, …
port: int | None = None port: int | None = None
health_path: str | None = None health_path: str | None = None
@@ -73,7 +74,7 @@ class JobSummary(BaseModel):
id: str id: str
description: str | None = None description: str | None = None
stack: str | None = None stack: str | None = None
runner: str | None = None launcher: str | None = None # python|command|container|compose|node
run_target: str | None = None # what it runs: program name, argv, … run_target: str | None = None # what it runs: program name, argv, …
schedule: str | None = None schedule: str | None = None
managed: bool = False managed: bool = False
@@ -94,9 +95,8 @@ class ProgramSummary(BaseModel):
id: str id: str
description: str | None = None description: str | None = None
behavior: str | None = None kind: str | None = None # derived: service|job|tool|static|reference
stack: str | None = None stack: str | None = None
runner: str | None = None
version: str | None = None version: str | None = None
source: str | None = None source: str | None = None
repo: str | None = None repo: str | None = None

View File

@@ -43,8 +43,9 @@ def _registry_to_json(registry: NodeRegistry) -> str:
for name, comp in registry.deployed.items(): for name, comp in registry.deployed.items():
entry: dict = { entry: dict = {
"runner": comp.runner, "manager": comp.manager,
"behavior": comp.behavior, "launcher": comp.launcher,
"kind": comp.kind,
} }
if comp.stack: if comp.stack:
entry["stack"] = comp.stack entry["stack"] = comp.stack
@@ -77,11 +78,12 @@ def _json_to_registry(payload: str) -> NodeRegistry:
deployed: dict[str, Deployment] = {} deployed: dict[str, Deployment] = {}
for name, comp_data in data.get("deployed", {}).items(): for name, comp_data in data.get("deployed", {}).items():
deployed[name] = Deployment( deployed[name] = Deployment(
runner=comp_data.get("runner", "command"), manager=comp_data.get("manager", "systemd"),
launcher=comp_data.get("launcher"),
run_cmd=comp_data.get("run_cmd", []), run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}), env=comp_data.get("env", {}),
description=comp_data.get("description"), description=comp_data.get("description"),
behavior=comp_data.get("behavior", "daemon"), kind=comp_data.get("kind", "service"),
stack=comp_data.get("stack"), stack=comp_data.get("stack"),
port=comp_data.get("port"), port=comp_data.get("port"),
health_path=comp_data.get("health_path"), health_path=comp_data.get("health_path"),

View File

@@ -48,9 +48,10 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSu
id=name, id=name,
category="job" if d.schedule else "service", category="job" if d.schedule else "service",
description=d.description, description=d.description,
behavior=d.behavior, kind=d.kind,
stack=d.stack, stack=d.stack,
runner=d.runner, manager=d.manager,
launcher=d.launcher,
port=d.port, port=d.port,
health_path=d.health_path, health_path=d.health_path,
subdomain=d.subdomain, subdomain=d.subdomain,

View File

@@ -13,10 +13,8 @@ from castle_core.config import SPECS_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ( from castle_core.manifest import (
ProgramSpec, ProgramSpec,
JobSpec, SystemdDeployment,
ServiceSpec, kind_for,
behavior_for_runner,
manager_for,
) )
from castle_core.stacks import available_actions from castle_core.stacks import available_actions
@@ -80,7 +78,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
# A PATH-managed deployment (a tool) is "installed" when it's on PATH. # A PATH-managed deployment (a tool) is "installed" when it's on PATH.
installed: bool | None = None installed: bool | None = None
if manager_for(deployed.runner) == "path": if deployed.manager == "path":
installed = shutil.which(name) is not None installed = shutil.which(name) is not None
category = "job" if deployed.schedule else "service" category = "job" if deployed.schedule else "service"
@@ -89,9 +87,10 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
id=name, id=name,
category=category, category=category,
description=deployed.description, description=deployed.description,
behavior=deployed.behavior, kind=deployed.kind,
stack=deployed.stack, stack=deployed.stack,
runner=deployed.runner, manager=deployed.manager,
launcher=deployed.launcher,
port=deployed.port, port=deployed.port,
health_path=deployed.health_path, health_path=deployed.health_path,
subdomain=deployed.subdomain, subdomain=deployed.subdomain,
@@ -103,9 +102,9 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
def _summary_from_service( def _summary_from_service(
name: str, svc: ServiceSpec, config: object name: str, svc: SystemdDeployment, config: object
) -> DeploymentSummary: ) -> DeploymentSummary:
"""Build a DeploymentSummary from a ServiceSpec (non-deployed).""" """Build a DeploymentSummary from a systemd deployment (service, non-deployed)."""
port = None port = None
health_path = None health_path = None
if svc.expose and svc.expose.http: if svc.expose and svc.expose.http:
@@ -133,15 +132,14 @@ def _summary_from_service(
source = comp.source source = comp.source
stack = comp.stack stack = comp.stack
runner = svc.run.runner
return DeploymentSummary( return DeploymentSummary(
id=name, id=name,
category="service", category="service",
description=description, description=description,
behavior=behavior_for_runner(runner), kind=kind_for(svc),
stack=stack, stack=stack,
runner=runner, manager="systemd",
launcher=svc.run.launcher,
port=port, port=port,
health_path=health_path, health_path=health_path,
subdomain=subdomain, subdomain=subdomain,
@@ -151,8 +149,8 @@ def _summary_from_service(
) )
def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSummary: def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> DeploymentSummary:
"""Build a DeploymentSummary from a JobSpec (non-deployed).""" """Build a DeploymentSummary from a systemd deployment (job, non-deployed)."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable) managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info: SystemdInfo | None = None systemd_info: SystemdInfo | None = None
@@ -175,9 +173,10 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSumm
id=name, id=name,
category="job", category="job",
description=description, description=description,
behavior="tool", kind="job",
stack=stack, stack=stack,
runner=job.run.runner, manager="systemd",
launcher=job.run.launcher,
managed=managed, managed=managed,
systemd=systemd_info, systemd=systemd_info,
schedule=job.schedule, schedule=job.schedule,
@@ -188,18 +187,9 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSumm
def _summary_from_program( def _summary_from_program(
name: str, comp: ProgramSpec, root: Path name: str, comp: ProgramSpec, root: Path
) -> DeploymentSummary: ) -> DeploymentSummary:
"""Build a DeploymentSummary from a ProgramSpec (tools/frontends).""" """Build a DeploymentSummary from a ProgramSpec (its derived kind)."""
source = comp.source source = comp.source
# Infer runner from source directory
runner = None
if source:
source_dir = Path(source)
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None installed: bool | None = None
if comp.source and (comp.stack or comp.commands): if comp.source and (comp.stack or comp.commands):
installed = shutil.which(name) is not None installed = shutil.which(name) is not None
@@ -208,9 +198,8 @@ def _summary_from_program(
id=name, id=name,
category="program", category="program",
description=comp.description, description=comp.description,
behavior=comp.behavior, kind=comp.kind,
stack=comp.stack, stack=comp.stack,
runner=runner,
version=comp.version, version=comp.version,
source=source, source=source,
repo=comp.repo, repo=comp.repo,
@@ -268,7 +257,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
id=name, id=name,
description=deployed.description, description=deployed.description,
stack=deployed.stack, stack=deployed.stack,
runner=deployed.runner, launcher=deployed.launcher,
run_target=run_target, run_target=run_target,
port=deployed.port, port=deployed.port,
health_path=deployed.health_path, health_path=deployed.health_path,
@@ -278,8 +267,8 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
) )
def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSummary: def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> ServiceSummary:
"""Build a ServiceSummary from a ServiceSpec.""" """Build a ServiceSummary from a systemd deployment."""
port = None port = None
health_path = None health_path = None
if svc.expose and svc.expose.http: if svc.expose and svc.expose.http:
@@ -305,7 +294,7 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
id=name, id=name,
description=description, description=description,
stack=stack, stack=stack,
runner=svc.run.runner, launcher=svc.run.launcher,
run_target=_run_target(svc.run), run_target=_run_target(svc.run),
port=port, port=port,
health_path=health_path, health_path=health_path,
@@ -325,7 +314,7 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary:
id=name, id=name,
description=deployed.description, description=deployed.description,
stack=deployed.stack, stack=deployed.stack,
runner=deployed.runner, launcher=deployed.launcher,
run_target=run_target, run_target=run_target,
schedule=deployed.schedule, schedule=deployed.schedule,
managed=deployed.managed, managed=deployed.managed,
@@ -333,8 +322,8 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary:
) )
def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary: def _job_from_spec(name: str, job: SystemdDeployment, config: object) -> JobSummary:
"""Build a JobSummary from a JobSpec.""" """Build a JobSummary from a systemd deployment (job)."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable) managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info = _make_systemd_info(name, timer=True) if managed else None systemd_info = _make_systemd_info(name, timer=True) if managed else None
@@ -352,7 +341,7 @@ def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
id=name, id=name,
description=description, description=description,
stack=stack, stack=stack,
runner=job.run.runner, launcher=job.run.launcher,
run_target=_run_target(job.run), run_target=_run_target(job.run),
schedule=job.schedule, schedule=job.schedule,
managed=managed, managed=managed,
@@ -367,13 +356,6 @@ def _program_from_spec(
) -> ProgramSummary: ) -> ProgramSummary:
"""Build a ProgramSummary from a ProgramSpec.""" """Build a ProgramSummary from a ProgramSpec."""
source = comp.source source = comp.source
runner = None
if source:
source_dir = Path(source)
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None installed: bool | None = None
if comp.source and (comp.stack or comp.commands): if comp.source and (comp.stack or comp.commands):
@@ -394,9 +376,8 @@ def _program_from_spec(
return ProgramSummary( return ProgramSummary(
id=name, id=name,
description=comp.description, description=comp.description,
behavior=comp.behavior, kind=comp.kind,
stack=comp.stack, stack=comp.stack,
runner=runner,
version=comp.version, version=comp.version,
source=source, source=source,
repo=comp.repo, repo=comp.repo,
@@ -508,7 +489,8 @@ def get_service(name: str) -> ServiceDetail:
if config is not None and summary.source is None: if config is not None and summary.source is None:
summary.source = _backfill_source(name, config) summary.source = _backfill_source(name, config)
manifest = { manifest = {
"runner": deployed.runner, "manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd, "run_cmd": deployed.run_cmd,
"env": deployed.env, "env": deployed.env,
"secret_env_keys": deployed.secret_env_keys, "secret_env_keys": deployed.secret_env_keys,
@@ -516,7 +498,7 @@ def get_service(name: str) -> ServiceDetail:
"health_path": deployed.health_path, "health_path": deployed.health_path,
"subdomain": deployed.subdomain, "subdomain": deployed.subdomain,
"managed": deployed.managed, "managed": deployed.managed,
"behavior": deployed.behavior, "kind": deployed.kind,
"stack": deployed.stack, "stack": deployed.stack,
} }
return ServiceDetail(**summary.model_dump(), manifest=manifest) return ServiceDetail(**summary.model_dump(), manifest=manifest)
@@ -609,13 +591,14 @@ def get_job(name: str) -> JobDetail:
if config is not None and summary.source is None: if config is not None and summary.source is None:
summary.source = _backfill_source(name, config) summary.source = _backfill_source(name, config)
manifest = { manifest = {
"runner": deployed.runner, "manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd, "run_cmd": deployed.run_cmd,
"env": deployed.env, "env": deployed.env,
"secret_env_keys": deployed.secret_env_keys, "secret_env_keys": deployed.secret_env_keys,
"managed": deployed.managed, "managed": deployed.managed,
"schedule": deployed.schedule, "schedule": deployed.schedule,
"behavior": deployed.behavior, "kind": deployed.kind,
"stack": deployed.stack, "stack": deployed.stack,
} }
return JobDetail(**summary.model_dump(), manifest=manifest) return JobDetail(**summary.model_dump(), manifest=manifest)
@@ -627,10 +610,10 @@ def get_job(name: str) -> JobDetail:
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"]) @router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
def list_programs(behavior: str | None = None) -> list[ProgramSummary]: def list_programs(kind: str | None = None) -> list[ProgramSummary]:
"""List all programs from the software catalog (castle.yaml programs section). """List all programs from the software catalog (castle.yaml programs section).
Optionally filter by behavior: daemon, tool, or frontend. Optionally filter by derived kind: service, job, tool, static, or reference.
""" """
root = get_castle_root() root = get_castle_root()
if not root: if not root:
@@ -648,9 +631,9 @@ def list_programs(behavior: str | None = None) -> list[ProgramSummary]:
for name, comp in config.programs.items(): for name, comp in config.programs.items():
summary = _program_from_spec(name, comp, root, config) summary = _program_from_spec(name, comp, root, config)
if summary.behavior is None: if summary.kind is None:
continue continue
if behavior and summary.behavior != behavior: if kind and summary.kind != kind:
continue continue
summary.node = hostname summary.node = hostname
summaries.append(summary) summaries.append(summary)
@@ -742,7 +725,7 @@ def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
# Programs from the software catalog # Programs from the software catalog
for name, comp in config.programs.items(): for name, comp in config.programs.items():
summary = _summary_from_program(name, comp, root) summary = _summary_from_program(name, comp, root)
if summary.behavior is None: if summary.kind is None:
continue continue
summary.node = local_hostname summary.node = local_hostname
summaries.append(summary) summaries.append(summary)
@@ -759,9 +742,10 @@ def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
id=name, id=name,
category="job" if d.schedule else "service", category="job" if d.schedule else "service",
description=d.description, description=d.description,
behavior=d.behavior, kind=d.kind,
stack=d.stack, stack=d.stack,
runner=d.runner, manager=d.manager,
launcher=d.launcher,
port=d.port, port=d.port,
health_path=d.health_path, health_path=d.health_path,
subdomain=d.subdomain, subdomain=d.subdomain,
@@ -805,7 +789,8 @@ def get_component(name: str) -> DeploymentDetail:
pass pass
raw = { raw = {
"runner": deployed.runner, "manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd, "run_cmd": deployed.run_cmd,
"env": deployed.env, "env": deployed.env,
"secret_env_keys": deployed.secret_env_keys, "secret_env_keys": deployed.secret_env_keys,
@@ -813,7 +798,7 @@ def get_component(name: str) -> DeploymentDetail:
"health_path": deployed.health_path, "health_path": deployed.health_path,
"subdomain": deployed.subdomain, "subdomain": deployed.subdomain,
"managed": deployed.managed, "managed": deployed.managed,
"behavior": deployed.behavior, "kind": deployed.kind,
"stack": deployed.stack, "stack": deployed.stack,
} }
return DeploymentDetail(**summary.model_dump(), manifest=raw) return DeploymentDetail(**summary.model_dump(), manifest=raw)

View File

@@ -140,17 +140,13 @@ def get_unit(name: str) -> dict[str, str | None]:
from castle_core.config import load_config from castle_core.config import load_config
config = load_config(root) config = load_config(root)
if name in config.services: dep = config.deployments.get(name)
svc = config.services[name] if dep is not None:
if svc.manage and svc.manage.systemd: manage = getattr(dep, "manage", None)
systemd_spec = svc.manage.systemd if manage and manage.systemd:
description = svc.description systemd_spec = manage.systemd
elif name in config.jobs: description = dep.description
job = config.jobs[name] schedule = getattr(dep, "schedule", None)
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
schedule = job.schedule
description = job.description
unit = generate_unit_from_deployed(name, deployed, systemd_spec) unit = generate_unit_from_deployed(name, deployed, systemd_spec)
timer = generate_timer(name, schedule, description) if schedule else None timer = generate_timer(name, schedule, description) if schedule else None

View File

@@ -110,14 +110,15 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
), ),
deployed={ deployed={
"test-svc": Deployment( "test-svc": Deployment(
runner="python", manager="systemd",
launcher="python",
run_cmd=["uv", "run", "test-svc"], run_cmd=["uv", "run", "test-svc"],
env={ env={
"TEST_SVC_PORT": "19000", "TEST_SVC_PORT": "19000",
"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc", "TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc",
}, },
description="Test service", description="Test service",
behavior="daemon", kind="service",
port=19000, port=19000,
health_path="/health", health_path="/health",
subdomain="test-svc", subdomain="test-svc",

View File

@@ -34,7 +34,7 @@ class TestComponents:
assert svc["health_path"] == "/health" assert svc["health_path"] == "/health"
assert svc["subdomain"] == "test-svc" assert svc["subdomain"] == "test-svc"
assert svc["managed"] is True assert svc["managed"] is True
assert svc["behavior"] == "daemon" assert svc["kind"] == "service"
def test_tool_has_no_port(self, client: TestClient) -> None: def test_tool_has_no_port(self, client: TestClient) -> None:
"""Tool component has no port.""" """Tool component has no port."""
@@ -42,14 +42,14 @@ class TestComponents:
data = response.json() data = response.json()
tool = next(c for c in data if c["id"] == "test-tool") tool = next(c for c in data if c["id"] == "test-tool")
assert tool["port"] is None assert tool["port"] is None
assert tool["behavior"] == "tool" assert tool["kind"] == "tool"
def test_job_has_schedule(self, client: TestClient) -> None: def test_job_has_schedule(self, client: TestClient) -> None:
"""Job component has schedule.""" """Job component has schedule."""
response = client.get("/deployments") response = client.get("/deployments")
data = response.json() data = response.json()
job = next(c for c in data if c["id"] == "test-job") job = next(c for c in data if c["id"] == "test-job")
assert job["behavior"] == "tool" assert job["kind"] == "job"
assert job["schedule"] == "0 2 * * *" assert job["schedule"] == "0 2 * * *"
@@ -63,7 +63,7 @@ class TestDeploymentDetail:
data = response.json() data = response.json()
assert data["id"] == "test-svc" assert data["id"] == "test-svc"
assert "manifest" in data assert "manifest" in data
assert data["manifest"]["runner"] == "python" assert data["manifest"]["launcher"] == "python"
def test_not_found(self, client: TestClient) -> None: def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown component.""" """Returns 404 for unknown component."""
@@ -125,7 +125,7 @@ class TestServiceDetail:
assert data["id"] == "test-svc" assert data["id"] == "test-svc"
assert "manifest" in data assert "manifest" in data
# manifest is the editable castle.yaml ServiceSpec (nested run spec) # manifest is the editable castle.yaml ServiceSpec (nested run spec)
assert data["manifest"]["run"]["runner"] == "python" assert data["manifest"]["run"]["launcher"] == "python"
assert data["run_target"] == "test-svc" assert data["run_target"] == "test-svc"
def test_not_found(self, client: TestClient) -> None: def test_not_found(self, client: TestClient) -> None:
@@ -196,12 +196,12 @@ class TestProgramsList:
names = [p["id"] for p in data] names = [p["id"] for p in data]
assert "test-tool" in names assert "test-tool" in names
def test_program_has_behavior(self, client: TestClient) -> None: def test_program_has_kind(self, client: TestClient) -> None:
"""Program summary includes behavior.""" """Program summary includes the derived kind."""
response = client.get("/programs") response = client.get("/programs")
data = response.json() data = response.json()
tool = next(p for p in data if p["id"] == "test-tool") tool = next(p for p in data if p["id"] == "test-tool")
assert tool["behavior"] == "tool" assert tool["kind"] == "tool"
def test_no_port_field(self, client: TestClient) -> None: def test_no_port_field(self, client: TestClient) -> None:
"""ProgramSummary does not have port field.""" """ProgramSummary does not have port field."""
@@ -228,7 +228,7 @@ class TestProgramDetail:
data = response.json() data = response.json()
assert data["id"] == "test-tool" assert data["id"] == "test-tool"
assert "manifest" in data assert "manifest" in data
assert data["behavior"] == "tool" assert data["kind"] == "tool"
def test_not_found(self, client: TestClient) -> None: def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown program.""" """Returns 404 for unknown program."""
@@ -287,23 +287,23 @@ class TestConfigEditor:
assert response.status_code == 200 assert response.status_code == 200
data = yaml.safe_load(response.json()["yaml_content"]) data = yaml.safe_load(response.json()["yaml_content"])
assert "test-tool" in data["programs"] assert "test-tool" in data["programs"]
assert "test-svc" in data["services"] # service, job, and tool all live under the single deployments section now.
assert "test-job" in data["jobs"] assert "test-svc" in data["deployments"]
assert "test-job" in data["deployments"]
def test_put_scatters_and_prunes(self, client: TestClient, castle_root) -> None: def test_put_scatters_and_prunes(self, client: TestClient, castle_root) -> None:
"""PUT /config writes resource files and prunes removed ones.""" """PUT /config writes resource files and prunes removed ones."""
import yaml import yaml
current = yaml.safe_load(client.get("/config").json()["yaml_content"]) current = yaml.safe_load(client.get("/config").json()["yaml_content"])
current["services"].pop("test-svc") current["deployments"].pop("test-svc")
current["programs"]["new-tool"] = { current["programs"]["new-tool"] = {
"description": "Brand new", "description": "Brand new",
"behavior": "tool",
} }
resp = client.put("/config", json={"yaml_content": yaml.dump(current)}) resp = client.put("/config", json={"yaml_content": yaml.dump(current)})
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert not (castle_root / "services" / "test-svc.yaml").exists() assert not (castle_root / "deployments" / "test-svc.yaml").exists()
assert (castle_root / "programs" / "new-tool.yaml").exists() assert (castle_root / "programs" / "new-tool.yaml").exists()
after = yaml.safe_load(client.get("/config").json()["yaml_content"]) after = yaml.safe_load(client.get("/config").json()["yaml_content"])
assert "new-tool" in after["programs"] assert "new-tool" in after["programs"]
assert "test-svc" not in (after.get("services") or {}) assert "test-svc" not in (after.get("deployments") or {})

View File

@@ -96,7 +96,7 @@ class TestMeshStateManager:
mgr.update_node("devbox", _make_registry("devbox")) mgr.update_node("devbox", _make_registry("devbox"))
new_reg = _make_registry( new_reg = _make_registry(
"devbox", "devbox",
{"svc": Deployment(runner="python", run_cmd=["svc"])}, {"svc": Deployment(manager="systemd", launcher="python", run_cmd=["svc"])},
) )
mgr.update_node("devbox", new_reg) mgr.update_node("devbox", new_reg)
node = mgr.get_node("devbox") node = mgr.get_node("devbox")

View File

@@ -12,11 +12,12 @@ def _make_registry() -> NodeRegistry:
node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000), node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000),
deployed={ deployed={
"my-svc": Deployment( "my-svc": Deployment(
runner="python", manager="systemd",
launcher="python",
run_cmd=["uv", "run", "my-svc"], run_cmd=["uv", "run", "my-svc"],
env={"PORT": "9001", "SECRET_KEY": "super-secret"}, env={"PORT": "9001", "SECRET_KEY": "super-secret"},
description="My service", description="My service",
behavior="daemon", kind="service",
stack="python-fastapi", stack="python-fastapi",
port=9001, port=9001,
health_path="/health", health_path="/health",
@@ -24,9 +25,10 @@ def _make_registry() -> NodeRegistry:
managed=True, managed=True,
), ),
"my-job": Deployment( "my-job": Deployment(
runner="command", manager="systemd",
launcher="command",
run_cmd=["my-job"], run_cmd=["my-job"],
behavior="tool", kind="job",
stack="python-cli", stack="python-cli",
schedule="0 2 * * *", schedule="0 2 * * *",
), ),
@@ -51,12 +53,13 @@ class TestRegistrySerialization:
assert "my-svc" in restored.deployed assert "my-svc" in restored.deployed
svc = restored.deployed["my-svc"] svc = restored.deployed["my-svc"]
assert svc.runner == "python" assert svc.manager == "systemd"
assert svc.launcher == "python"
assert svc.port == 9001 assert svc.port == 9001
assert svc.health_path == "/health" assert svc.health_path == "/health"
assert svc.subdomain == "my-svc" assert svc.subdomain == "my-svc"
assert svc.managed is True assert svc.managed is True
assert svc.behavior == "daemon" assert svc.kind == "service"
assert svc.stack == "python-fastapi" assert svc.stack == "python-fastapi"
def test_job_fields_preserved(self) -> None: def test_job_fields_preserved(self) -> None:
@@ -65,9 +68,9 @@ class TestRegistrySerialization:
assert "my-job" in restored.deployed assert "my-job" in restored.deployed
job = restored.deployed["my-job"] job = restored.deployed["my-job"]
assert job.runner == "command" assert job.launcher == "command"
assert job.schedule == "0 2 * * *" assert job.schedule == "0 2 * * *"
assert job.behavior == "tool" assert job.kind == "job"
assert job.stack == "python-cli" assert job.stack == "python-cli"
def test_optional_fields_omitted(self) -> None: def test_optional_fields_omitted(self) -> None:
@@ -75,7 +78,7 @@ class TestRegistrySerialization:
reg = NodeRegistry( reg = NodeRegistry(
node=NodeConfig(hostname="minimal"), node=NodeConfig(hostname="minimal"),
deployed={ deployed={
"bare": Deployment(runner="command", run_cmd=["bare"]), "bare": Deployment(manager="systemd", launcher="command", run_cmd=["bare"]),
}, },
) )
restored = _json_to_registry(_registry_to_json(reg)) restored = _json_to_registry(_registry_to_json(reg))

View File

@@ -42,10 +42,10 @@ class TestNodesList:
node=NodeConfig(hostname="devbox", gateway_port=9000), node=NodeConfig(hostname="devbox", gateway_port=9000),
deployed={ deployed={
"remote-svc": Deployment( "remote-svc": Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["svc"], run_cmd=["svc"],
port=9050, port=9050,
behavior="daemon", kind="service",
), ),
}, },
) )

View File

@@ -24,9 +24,9 @@ class TestProgramCommands:
assert set(w["actions"]) >= {"lint", "test", "run"} assert set(w["actions"]) >= {"lint", "test", "run"}
assert "build" not in w["actions"] # not declared, no stack assert "build" not in w["actions"] # not declared, no stack
def test_tools_via_behavior_filter(self, client: TestClient) -> None: def test_tools_via_kind_filter(self, client: TestClient) -> None:
"""Tools are reached via /programs?behavior=tool (no dedicated /tools).""" """Tools are reached via /programs?kind=tool (no dedicated /tools)."""
resp = client.get("/programs", params={"behavior": "tool"}) resp = client.get("/programs", params={"kind": "tool"})
assert resp.status_code == 200 assert resp.status_code == 200
ids = [p["id"] for p in resp.json()] ids = [p["id"] for p in resp.json()]
assert "wired-in" in ids and "test-tool" in ids assert "wired-in" in ids and "test-tool" in ids

View File

@@ -91,16 +91,17 @@ def run_add(args: argparse.Namespace) -> int:
source = str(src_path) source = str(src_path)
name = args.name or src_path.name name = args.name or src_path.name
if name in config.programs or name in config.services or name in config.jobs: if name in config.programs or name in config.deployments:
print(f"Error: '{name}' already exists in castle.yaml") print(f"Error: '{name}' already exists in castle.yaml")
return 1 return 1
# Detect verbs from the working copy if we have one on disk. # Detect verbs from the working copy if we have one on disk. `kind` is derived
# from a deployment, not stored on the program — so `castle add` adopts the
# source only; declare a deployment separately (castle service/job create).
stack: str | None = None stack: str | None = None
detected_commands: dict[str, list[list[str]]] = {} detected_commands: dict[str, list[list[str]]] = {}
behavior = "tool"
if src_path.exists(): if src_path.exists():
stack, detected_commands, behavior = _detect(src_path) stack, detected_commands, _ = _detect(src_path)
prog = ProgramSpec( prog = ProgramSpec(
id=name, id=name,
@@ -108,7 +109,6 @@ def run_add(args: argparse.Namespace) -> int:
source=source, source=source,
stack=stack, stack=stack,
repo=repo_url, repo=repo_url,
behavior=behavior,
) )
# `build` is declared via BuildSpec; every other verb via CommandsSpec. # `build` is declared via BuildSpec; every other verb via CommandsSpec.
if detected_commands: if detected_commands:

View File

@@ -8,31 +8,31 @@ import subprocess
from castle_cli.config import REPOS_DIR, load_config, save_config from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import ( from castle_cli.manifest import (
BuildSpec, BuildSpec,
CaddyDeployment,
DefaultsSpec, DefaultsSpec,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
ManageSpec, ManageSpec,
PathDeployment,
ProgramSpec, ProgramSpec,
RunPath,
RunPython, RunPython,
RunStatic, SystemdDeployment,
ServiceSpec,
SystemdSpec, SystemdSpec,
) )
from castle_cli.templates.scaffold import scaffold_project from castle_cli.templates.scaffold import scaffold_project
# Stack determines default behavior and scaffold template # Stack determines the default deployment kind + scaffold template.
STACK_DEFAULTS: dict[str, str] = { STACK_DEFAULTS: dict[str, str] = {
"python-fastapi": "daemon", "python-fastapi": "service",
"python-cli": "tool", "python-cli": "tool",
"react-vite": "frontend", "react-vite": "static",
"supabase": "frontend", "supabase": "static",
} }
# Static build output per stack, for `behavior: frontend` programs. The gateway # Static build output per stack, for `static` (caddy) deployments. The gateway
# serves this dir in place at /<name>/ (no service, no process). A supabase app # serves this dir in place at <name>.<gateway.domain> (no service, no process).
# ships a raw `public/`; react-vite builds to `dist/`. # A supabase app ships a raw `public/`; react-vite builds to `dist/`.
STACK_BUILD_OUTPUTS: dict[str, str] = { STACK_BUILD_OUTPUTS: dict[str, str] = {
"supabase": "public", "supabase": "public",
"react-vite": "dist", "react-vite": "dist",
@@ -42,9 +42,10 @@ STACK_BUILD_OUTPUTS: dict[str, str] = {
def next_available_port(config: object) -> int: def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway).""" """Find the next available port starting from 9001 (9000 is reserved for gateway)."""
used_ports = set() used_ports = set()
for svc in config.services.values(): for dep in config.deployments.values():
if svc.expose and svc.expose.http: expose = getattr(dep, "expose", None)
used_ports.add(svc.expose.http.internal.port) if expose and expose.http:
used_ports.add(expose.http.internal.port)
# Also reserve gateway port # Also reserve gateway port
used_ports.add(config.gateway.port) used_ports.add(config.gateway.port)
@@ -59,9 +60,9 @@ def run_create(args: argparse.Namespace) -> int:
config = load_config() config = load_config()
name = args.name name = args.name
stack = args.stack stack = args.stack
behavior = STACK_DEFAULTS.get(stack) if stack else None kind = STACK_DEFAULTS.get(stack) if stack else None
if name in config.programs or name in config.services or name in config.jobs: if name in config.programs or name in config.deployments:
print(f"Error: '{name}' already exists in castle.yaml") print(f"Error: '{name}' already exists in castle.yaml")
return 1 return 1
@@ -71,9 +72,9 @@ def run_create(args: argparse.Namespace) -> int:
print(f"Error: directory already exists: {project_dir}") print(f"Error: directory already exists: {project_dir}")
return 1 return 1
# Determine port for daemons # Determine port for service (daemon) deployments
port = args.port port = args.port
if behavior == "daemon" and port is None: if kind == "service" and port is None:
port = next_available_port(config) port = next_available_port(config)
package_name = name.replace("-", "_") package_name = name.replace("-", "_")
@@ -102,32 +103,32 @@ def run_create(args: argparse.Namespace) -> int:
if static_root: if static_root:
build = BuildSpec(outputs=[static_root]) build = BuildSpec(outputs=[static_root])
# `kind` (and thus behavior) is derived from the deployment below — never
# stored on the program.
config.programs[name] = ProgramSpec( config.programs[name] = ProgramSpec(
id=name, id=name,
description=description, description=description,
source=str(project_dir), source=str(project_dir),
stack=stack, stack=stack,
behavior=behavior,
build=build, build=build,
) )
if behavior == "tool": if kind == "tool":
# A PATH-managed deployment: installed via `uv tool install`, no unit/route. # A PATH-managed deployment: installed via `uv tool install`, no unit/route.
config.services[name] = ServiceSpec( config.deployments[name] = PathDeployment(
id=name, program=name, run=RunPath(runner="path") id=name, manager="path", program=name
) )
elif behavior == "frontend": elif kind == "static":
# A caddy-managed static service: no systemd unit, served from the build dir. # A caddy-managed static deployment: no systemd unit, served from the build dir.
config.services[name] = ServiceSpec( config.deployments[name] = CaddyDeployment(
id=name, id=name, manager="caddy", program=name, root=static_root or "dist"
program=name,
run=RunStatic(runner="static", root=static_root or "dist"),
) )
elif behavior == "daemon": elif kind == "service":
prefix = name.replace("-", "_").upper() prefix = name.replace("-", "_").upper()
config.services[name] = ServiceSpec( config.deployments[name] = SystemdDeployment(
id=name, id=name,
manager="systemd",
program=name, program=name,
run=RunPython(runner="python", program=name), run=RunPython(launcher="python", program=name),
expose=ExposeSpec( expose=ExposeSpec(
http=HttpExposeSpec( http=HttpExposeSpec(
internal=HttpInternal(port=port), internal=HttpInternal(port=port),
@@ -143,6 +144,10 @@ def run_create(args: argparse.Namespace) -> int:
), ),
) )
# Populate the derived kind on the in-memory program so readers see the live
# value immediately (it's excluded from disk — kind_of recomputes on load).
config.programs[name].kind = config.kind_of(name)
save_config(config) save_config(config)
label = f"{stack} program" if stack else "bare program" label = f"{stack} program" if stack else "bare program"
@@ -158,7 +163,7 @@ def run_create(args: argparse.Namespace) -> int:
print(f" castle deploy && castle gateway reload # serve at /{name}/") print(f" castle deploy && castle gateway reload # serve at /{name}/")
elif stack: elif stack:
print(" uv sync") print(" uv sync")
if behavior == "daemon": if kind == "service":
print(f" uv run {name} # starts on port {port}") print(f" uv run {name} # starts on port {port}")
print(f" castle deploy {name}") print(f" castle deploy {name}")
print(f" castle test {name}") print(f" castle test {name}")

View File

@@ -18,29 +18,27 @@ def run_delete(args: argparse.Namespace) -> int:
name = args.name name = args.name
resource = getattr(args, "resource", None) # "program" | "service" | "job" resource = getattr(args, "resource", None) # "program" | "service" | "job"
# Resolve which sections this delete touches (scoped to one resource). # Resolve which sections this delete touches (scoped to one resource). Any
# deployment resource name (service/job/tool/static/deployment) targets the
# single deployments/ collection — the kind is derived, not a separate section.
_DEPLOY_RESOURCES = (None, "service", "job", "tool", "static", "deployment")
in_programs = name in config.programs and resource in (None, "program") in_programs = name in config.programs and resource in (None, "program")
in_services = name in config.services and resource in (None, "service") in_deployment = name in config.deployments and resource in _DEPLOY_RESOURCES
in_jobs = name in config.jobs and resource in (None, "job") if not (in_programs or in_deployment):
if not (in_programs or in_services or in_jobs):
where = f" {resource}" if resource else "" where = f" {resource}" if resource else ""
print(f"Error: no{where} '{name}' in castle.yaml") print(f"Error: no{where} '{name}' in castle.yaml")
return 1 return 1
where = [s for s, present in where = [s for s, present in
(("program", in_programs), ("service", in_services), ("job", in_jobs)) if present] (("program", in_programs), ("deployment", in_deployment)) if present]
# A program can't be removed while a deployment still references it. Refs # A program can't be removed while a deployment still references it. A ref
# named the same are removed in this call; any other referencing deployment # named the same is removed in this call; any other referencing deployment
# would be left dangling, so refuse. # would be left dangling, so refuse.
if in_programs: if in_programs:
dangling = [ dangling = [
s for s, spec in config.services.items() d for d, spec in config.deployments.items()
if spec.program == name and not (s == name and in_services) if spec.program == name and not (d == name and in_deployment)
]
dangling += [
j for j, spec in config.jobs.items()
if spec.program == name and not (j == name and in_jobs)
] ]
if dangling: if dangling:
print( print(
@@ -72,10 +70,8 @@ def run_delete(args: argparse.Namespace) -> int:
# Remove registry entries. # Remove registry entries.
if in_programs: if in_programs:
del config.programs[name] del config.programs[name]
if in_services: if in_deployment:
del config.services[name] del config.deployments[name]
if in_jobs:
del config.jobs[name]
save_config(config) save_config(config)
print(f"Removed '{name}' from castle.yaml ({', '.join(where)}).") print(f"Removed '{name}' from castle.yaml ({', '.join(where)}).")
@@ -88,7 +84,7 @@ def run_delete(args: argparse.Namespace) -> int:
print(f"Source directory not found (already gone): {source_dir}") print(f"Source directory not found (already gone): {source_dir}")
# Warn about runtime artifacts we did NOT touch. # Warn about runtime artifacts we did NOT touch.
if in_services or in_jobs: if in_deployment:
print( print(
f"\nNote: the systemd unit for '{name}' (if deployed) was NOT removed.\n" f"\nNote: the systemd unit for '{name}' (if deployed) was NOT removed.\n"
f" Run: castle service disable {name}" f" Run: castle service disable {name}"

View File

@@ -15,11 +15,10 @@ from castle_cli.manifest import (
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
JobSpec,
ManageSpec, ManageSpec,
RunCommand, RunCommand,
RunPython, RunPython,
ServiceSpec, SystemdDeployment,
SystemdSpec, SystemdSpec,
) )
@@ -35,17 +34,16 @@ def _defaults(env_args: list[str] | None) -> DefaultsSpec | None:
return DefaultsSpec(env=env) return DefaultsSpec(env=env)
def _run_spec(runner: str, target: str, name: str) -> RunPython | RunCommand: def _run_spec(launcher: str, target: str, name: str) -> RunPython | RunCommand:
if runner == "command": if launcher == "command":
return RunCommand(runner="command", argv=target.split() or [name]) return RunCommand(launcher="command", argv=target.split() or [name])
return RunPython(runner="python", program=target or name) return RunPython(launcher="python", program=target or name)
def _check_new(config: object, name: str, section: str) -> str | None: def _check_new(config: object, name: str, label: str) -> str | None:
"""Return an error message if the name can't be created, else None.""" """Return an error message if the deployment name is taken, else None."""
existing = getattr(config, section) if name in config.deployments: # type: ignore[attr-defined]
if name in existing: return f"Error: {label} '{name}' already exists."
return f"Error: {section[:-1]} '{name}' already exists."
return None return None
@@ -53,11 +51,11 @@ def run_service_create(args: argparse.Namespace) -> int:
"""Create a service entry in castle.yaml.""" """Create a service entry in castle.yaml."""
config = load_config() config = load_config()
name = args.name name = args.name
if err := _check_new(config, name, "services"): if err := _check_new(config, name, "service"):
print(err) print(err)
return 1 return 1
run = _run_spec(args.runner, args.run or args.program or name, name) run = _run_spec(args.launcher, args.run or args.program or name, name)
expose = None expose = None
proxy = False proxy = False
@@ -71,8 +69,9 @@ def run_service_create(args: argparse.Namespace) -> int:
# Expose at <name>.<gateway.domain> (the subdomain is the service name). # Expose at <name>.<gateway.domain> (the subdomain is the service name).
proxy = not args.no_proxy proxy = not args.no_proxy
config.services[name] = ServiceSpec( config.deployments[name] = SystemdDeployment(
id=name, id=name,
manager="systemd",
program=args.program, program=args.program,
description=args.description or None, description=args.description or None,
run=run, run=run,
@@ -84,7 +83,7 @@ def run_service_create(args: argparse.Namespace) -> int:
save_config(config) save_config(config)
print(f"Created service '{name}'.") print(f"Created service '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})") print(f" runs: {args.launcher} ({args.run or args.program or name})")
if expose: if expose:
print(f" port: {args.port}") print(f" port: {args.port}")
if proxy: if proxy:
@@ -97,14 +96,16 @@ def run_job_create(args: argparse.Namespace) -> int:
"""Create a job entry in castle.yaml.""" """Create a job entry in castle.yaml."""
config = load_config() config = load_config()
name = args.name name = args.name
if err := _check_new(config, name, "jobs"): if err := _check_new(config, name, "job"):
print(err) print(err)
return 1 return 1
run = _run_spec(args.runner, args.run or args.program or name, name) run = _run_spec(args.launcher, args.run or args.program or name, name)
config.jobs[name] = JobSpec( # A job is a systemd deployment with a schedule (→ a .timer).
config.deployments[name] = SystemdDeployment(
id=name, id=name,
manager="systemd",
program=args.program, program=args.program,
description=args.description or None, description=args.description or None,
run=run, run=run,
@@ -115,7 +116,7 @@ def run_job_create(args: argparse.Namespace) -> int:
save_config(config) save_config(config)
print(f"Created job '{name}'.") print(f"Created job '{name}'.")
print(f" runs: {args.runner} ({args.run or args.program or name})") print(f" runs: {args.launcher} ({args.run or args.program or name})")
print(f" schedule: {args.schedule}") print(f" schedule: {args.schedule}")
print(f"\nNext: castle job deploy {name} && castle job enable {name}") print(f"\nNext: castle job deploy {name} && castle job enable {name}")
return 0 return 0

View File

@@ -53,16 +53,16 @@ def run_info(args: argparse.Namespace) -> int:
print(f"\n{BOLD}{name}{RESET}") print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}") print(f"{'' * 40}")
# Determine behavior # Determine kind (derived)
behavior = None kind = None
if program and program.behavior: if program and program.kind:
behavior = program.behavior kind = program.kind
elif service: elif service:
behavior = "daemon" kind = "service"
elif job: elif job:
behavior = "tool" kind = "job"
if behavior: if kind:
print(f" {BOLD}behavior{RESET}: {behavior}") print(f" {BOLD}kind{RESET}: {kind}")
# Show stack # Show stack
stack = None stack = None
@@ -97,8 +97,8 @@ def run_info(args: argparse.Namespace) -> int:
if spec.program: if spec.program:
print(f" {BOLD}program{RESET}: {spec.program}") print(f" {BOLD}program{RESET}: {spec.program}")
# Run spec # Launch spec
print(f" {BOLD}runner{RESET}: {spec.run.runner}") print(f" {BOLD}launcher{RESET}: {spec.run.launcher}")
if hasattr(spec.run, "program"): if hasattr(spec.run, "program"):
print(f" {BOLD}program{RESET}: {spec.run.program}") print(f" {BOLD}program{RESET}: {spec.run.program}")
elif hasattr(spec.run, "argv"): elif hasattr(spec.run, "argv"):
@@ -182,12 +182,12 @@ def _info_json(
data["service"] = service.model_dump(exclude_none=True, exclude={"id"}) data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
if job: if job:
data["job"] = job.model_dump(exclude_none=True, exclude={"id"}) data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
if program and program.behavior: if program and program.kind:
data["behavior"] = program.behavior data["kind"] = program.kind
elif service: elif service:
data["behavior"] = "daemon" data["kind"] = "service"
elif job: elif job:
data["behavior"] = "tool" data["kind"] = "job"
# Resolve stack # Resolve stack
stack = None stack = None
@@ -202,7 +202,8 @@ def _info_json(
if deployed: if deployed:
data["deployed"] = { data["deployed"] = {
"runner": deployed.runner, "manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd, "run_cmd": deployed.run_cmd,
"env": deployed.env, "env": deployed.env,
"secret_env_keys": deployed.secret_env_keys, "secret_env_keys": deployed.secret_env_keys,

View File

@@ -20,10 +20,12 @@ CYAN = "\033[96m"
MAGENTA = "\033[95m" MAGENTA = "\033[95m"
YELLOW = "\033[93m" YELLOW = "\033[93m"
BEHAVIOR_COLORS: dict[str, str] = { KIND_COLORS: dict[str, str] = {
"daemon": GREEN, "service": GREEN,
"job": MAGENTA,
"tool": CYAN, "tool": CYAN,
"frontend": YELLOW, "static": YELLOW,
"reference": DIM,
} }
STACK_DISPLAY: dict[str, str] = { STACK_DISPLAY: dict[str, str] = {
@@ -62,20 +64,20 @@ def _resolve_stack(config: object, name: str) -> str | None:
def run_list(args: argparse.Namespace) -> int: def run_list(args: argparse.Namespace) -> int:
"""List all programs, services, and jobs. """List all programs, services, and jobs.
Two orthogonal axes: the **Programs** catalog (filtered by real `behavior`) Two orthogonal axes: the **Programs** catalog (filtered by derived `kind`)
and the **Services**/**Jobs** deployment views. `--behavior` filters the and the **Services**/**Jobs** deployment views. `--kind` filters the catalog
catalog only — it's a property of a program, not of a deployment. by a program's derived kind (service/job/tool/static/reference).
""" """
from castle_core.lifecycle import is_active from castle_core.lifecycle import is_active
config = load_config() config = load_config()
filter_behavior = getattr(args, "behavior", None) filter_kind = getattr(args, "kind", None)
filter_stack = getattr(args, "stack", None) filter_stack = getattr(args, "stack", None)
resource = getattr(args, "resource", None) # scope to one section, or all resource = getattr(args, "resource", None) # scope to one section, or all
if getattr(args, "json", False): if getattr(args, "json", False):
return _list_json(config, filter_behavior, filter_stack) return _list_json(config, filter_kind, filter_stack)
def dot(name: str) -> str: def dot(name: str) -> str:
return f"{GREEN}{RESET}" if is_active(name, config) else f"{RED}{RESET}" return f"{GREEN}{RESET}" if is_active(name, config) else f"{RED}{RESET}"
@@ -87,7 +89,7 @@ def run_list(args: argparse.Namespace) -> int:
{ {
name: comp name: comp
for name, comp in config.programs.items() for name, comp in config.programs.items()
if (not filter_behavior or comp.behavior == filter_behavior) if (not filter_kind or comp.kind == filter_kind)
and (not filter_stack or comp.stack == filter_stack) and (not filter_stack or comp.stack == filter_stack)
} }
if resource in (None, "program") if resource in (None, "program")
@@ -98,20 +100,20 @@ def run_list(args: argparse.Namespace) -> int:
print(f"\n{BOLD}{CYAN}Programs{RESET}") print(f"\n{BOLD}{CYAN}Programs{RESET}")
print(f"{CYAN}{'' * 40}{RESET}") print(f"{CYAN}{'' * 40}{RESET}")
for name, comp in progs.items(): for name, comp in progs.items():
behavior = comp.behavior or "program" kind = comp.kind or "program"
bcolor = BEHAVIOR_COLORS.get(behavior, "") bcolor = KIND_COLORS.get(kind, "")
behavior_str = f" {bcolor}{behavior}{RESET}" behavior_str = f" {bcolor}{kind}{RESET}"
stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else "" stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else ""
desc = f" {DIM}{comp.description}{RESET}" if comp.description else "" desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{behavior_str}{stack_str}{desc}") print(f" {dot(name)} {BOLD}{name}{RESET}{behavior_str}{stack_str}{desc}")
# Services + Jobs (deployment views) — independent of behavior, so only shown # Services + Jobs (deployment views) — independent of behavior, so only shown
# when no behavior filter is applied. Each gated by its own resource scope. # when no behavior filter is applied. Each gated by its own resource scope.
if not filter_behavior and resource in (None, "service"): if not filter_kind and resource in (None, "service"):
services = _filter_by_stack(config.services, config, filter_stack) services = _filter_by_stack(config.services, config, filter_stack)
if services: if services:
any_output = True any_output = True
color = BEHAVIOR_COLORS["daemon"] color = KIND_COLORS["service"]
print(f"\n{BOLD}{color}Services{RESET}") print(f"\n{BOLD}{color}Services{RESET}")
print(f"{color}{'' * 40}{RESET}") print(f"{color}{'' * 40}{RESET}")
for name, svc in services.items(): for name, svc in services.items():
@@ -123,7 +125,7 @@ def run_list(args: argparse.Namespace) -> int:
desc = f" {DIM}{svc.description}{RESET}" if svc.description else "" desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
print(f" {dot(name)} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}") print(f" {dot(name)} {BOLD}{name}{RESET}{port_str}{stack_str}{desc}")
if not filter_behavior and resource in (None, "job"): if not filter_kind and resource in (None, "job"):
jobs = _filter_by_stack(config.jobs, config, filter_stack) jobs = _filter_by_stack(config.jobs, config, filter_stack)
if jobs: if jobs:
any_output = True any_output = True
@@ -158,24 +160,23 @@ def _filter_by_stack(
def _list_json( def _list_json(
config: object, config: object,
filter_behavior: str | None, filter_kind: str | None,
filter_stack: str | None, filter_stack: str | None,
) -> int: ) -> int:
"""Output JSON: the program catalog (behavior-filterable) plus deployments.""" """Output JSON: the program catalog (kind-filterable) plus deployments."""
from castle_core.lifecycle import is_active from castle_core.lifecycle import is_active
output = [] output = []
# Programs (catalog) — filtered by real behavior + stack # Programs (catalog) — filtered by derived kind + stack
for name, comp in config.programs.items(): for name, comp in config.programs.items():
if filter_behavior and comp.behavior != filter_behavior: if filter_kind and comp.kind != filter_kind:
continue continue
if filter_stack and comp.stack != filter_stack: if filter_stack and comp.stack != filter_stack:
continue continue
entry: dict = { entry: dict = {
"name": name, "name": name,
"kind": "program", "kind": comp.kind,
"behavior": comp.behavior,
"active": is_active(name, config), "active": is_active(name, config),
} }
if comp.stack: if comp.stack:
@@ -184,8 +185,8 @@ def _list_json(
entry["description"] = comp.description entry["description"] = comp.description
output.append(entry) output.append(entry)
# Services + Jobs (deployments) — only when not filtering by behavior # Services + Jobs (deployments) — only when not filtering by kind
if not filter_behavior: if not filter_kind:
for name, svc in config.services.items(): for name, svc in config.services.items():
stack = _resolve_stack(config, name) stack = _resolve_stack(config, name)
if filter_stack and stack != filter_stack: if filter_stack and stack != filter_stack:

View File

@@ -14,20 +14,19 @@ def run_logs(args: argparse.Namespace) -> int:
config = load_config() config = load_config()
name = args.name name = args.name
# Check services dep = config.deployments.get(name)
if name in config.services: if dep is not None and dep.manager == "systemd":
svc = config.services[name] if dep.run.launcher == "container":
if svc.run.runner == "container":
return _container_logs(name, args) return _container_logs(name, args)
if svc.run.runner == "compose": if dep.run.launcher == "compose":
return _compose_logs(name, svc, args) return _compose_logs(name, dep, args)
return _systemd_logs(name, args) return _systemd_logs(name, args)
# Check jobs if dep is not None:
if name in config.jobs: print(f"Error: '{name}' has no logs (manager: {dep.manager}).")
return _systemd_logs(name, args) return 1
print(f"Error: '{name}' not found in services or jobs") print(f"Error: '{name}' not found in deployments")
return 1 return 1

View File

@@ -12,7 +12,7 @@ from castle_core.generators.systemd import (
timer_name, timer_name,
unit_name, unit_name,
) )
from castle_core.manifest import manager_for from castle_core.manifest import kind_for
from castle_core.registry import REGISTRY_PATH, load_registry from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import ( from castle_cli.config import (
@@ -94,14 +94,15 @@ def _unit_action(config: CastleConfig, name: str, action: str, is_job: bool) ->
systemd (a process/timer) → `systemctl`; caddy (static) → reload the gateway; systemd (a process/timer) → `systemctl`; caddy (static) → reload the gateway;
path (a tool) → install/uninstall; none (remote) → nothing to do. path (a tool) → install/uninstall; none (remote) → nothing to do.
""" """
section = config.jobs if is_job else config.services dep = config.deployments.get(name)
if name not in section: if dep is None:
print(f"Error: no {'job' if is_job else 'service'} '{name}'.") print(f"Error: no deployment '{name}'.")
return 1 return 1
manager = "systemd" if is_job else manager_for(section[name].run.runner) manager = dep.manager
if manager != "systemd": if manager != "systemd":
return _managed_lifecycle(config, name, action, manager) return _managed_lifecycle(config, name, action, manager)
unit = timer_name(name) if is_job else unit_name(name) # A scheduled systemd deployment (a job) is driven by its .timer.
unit = timer_name(name) if kind_for(dep) == "job" else unit_name(name)
result = subprocess.run(["systemctl", "--user", action, unit], check=False) result = subprocess.run(["systemctl", "--user", action, unit], check=False)
if result.returncode != 0: if result.returncode != 0:
print(f"Error: failed to {action} {unit}") print(f"Error: failed to {action} {unit}")
@@ -143,17 +144,18 @@ def _path_lifecycle(config: CastleConfig, name: str, action: str) -> int:
def _services_restart(config: CastleConfig) -> int: def _services_restart(config: CastleConfig) -> int:
"""Restart every systemd-managed service and job unit. """Restart every systemd-managed deployment (service or job) unit.
caddy/path/none runners have no unit — they ride along with the gateway caddy/path/none deployments have no unit — they ride along with the gateway
restart (static) or are stateless (remote), so we don't systemctl them here. restart (static) or are stateless (remote), so we don't systemctl them here.
""" """
for name in config.jobs: for name, dep in config.deployments.items():
if dep.manager != "systemd":
continue
if kind_for(dep) == "job":
subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False) subprocess.run(["systemctl", "--user", "restart", timer_name(name)], check=False)
print(f" {name}: restarted (timer)") print(f" {name}: restarted (timer)")
for name, svc in config.services.items(): else:
if manager_for(svc.run.runner) != "systemd":
continue
subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False) subprocess.run(["systemctl", "--user", "restart", unit_name(name)], check=False)
print(f" {name}: restarted") print(f" {name}: restarted")
return 0 return 0
@@ -181,7 +183,7 @@ def run_status(args: argparse.Namespace) -> int:
on = is_active(name, config) on = is_active(name, config)
color = "\033[92m" if on else "\033[90m" color = "\033[92m" if on else "\033[90m"
label = "active" if on else "inactive" label = "active" if on else "inactive"
print(f" {color}{label:10s}\033[0m {name} ({comp.behavior or 'program'})") print(f" {color}{label:10s}\033[0m {name} ({comp.kind or 'program'})")
print() print()
return 0 return 0
@@ -219,7 +221,7 @@ def _service_status(config: CastleConfig) -> int:
for name, svc in config.services.items(): for name, svc in config.services.items():
active = is_active(name, config) # manager-aware (systemd/caddy/path/none) active = is_active(name, config) # manager-aware (systemd/caddy/path/none)
manager = manager_for(svc.run.runner) manager = svc.manager
color = "\033[92m" if active else "\033[90m" color = "\033[92m" if active else "\033[90m"
reset = "\033[0m" reset = "\033[0m"
label = "active" if active else "inactive" label = "active" if active else "inactive"
@@ -260,14 +262,10 @@ def _service_dry_run(config: CastleConfig, name: str) -> int:
if name in registry.deployed: if name in registry.deployed:
deployed = registry.deployed[name] deployed = registry.deployed[name]
systemd_spec = None systemd_spec = None
if name in config.services: dep = config.deployments.get(name)
svc = config.services[name] manage = getattr(dep, "manage", None)
if svc.manage and svc.manage.systemd: if manage and manage.systemd:
systemd_spec = svc.manage.systemd systemd_spec = manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
svc_unit = unit_name(name) svc_unit = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec) svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
@@ -304,13 +302,9 @@ def _services_start(config: CastleConfig) -> int:
caddyfile_path.write_text(generate_caddyfile_from_registry(registry)) caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f"Generated {caddyfile_path}") print(f"Generated {caddyfile_path}")
for name in config.services: # Activate every deployment in its mode: systemd unit / timer, gateway route
if name not in registry.deployed: # (static), or PATH install (tool). activate() dispatches by manager.
print(f" {name}: skipped (not in registry, run 'castle deploy')") for name in config.deployments:
continue
_service_enable(config, name)
for name in config.jobs:
if name not in registry.deployed: if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')") print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue continue

View File

@@ -29,7 +29,7 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
sub = prog.add_subparsers(dest="program_command") sub = prog.add_subparsers(dest="program_command")
p = sub.add_parser("list", help="List programs") p = sub.add_parser("list", help="List programs")
p.add_argument("--behavior", choices=["daemon", "tool", "frontend"], help="Filter by behavior") p.add_argument("--kind", choices=["service", "job", "tool", "static", "reference"], help="Filter by derived kind")
p.add_argument("--stack", help="Filter by stack") p.add_argument("--stack", help="Filter by stack")
p.add_argument("--json", action="store_true", help="Output as JSON") p.add_argument("--json", action="store_true", help="Output as JSON")
@@ -78,7 +78,7 @@ def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p.add_argument("--program", help="Program this deployment runs (convenience ref)") p.add_argument("--program", help="Program this deployment runs (convenience ref)")
p.add_argument("--description", default="", help="Description") p.add_argument("--description", default="", help="Description")
p.add_argument("--run", help="Console script / command to run (default: --program or name)") p.add_argument("--run", help="Console script / command to run (default: --program or name)")
p.add_argument("--runner", choices=["python", "command"], default="python") p.add_argument("--launcher", choices=["python", "command"], default="python")
p.add_argument( p.add_argument(
"--env", "--env",
action="append", action="append",
@@ -167,7 +167,7 @@ def build_parser() -> argparse.ArgumentParser:
# Cross-resource overview # Cross-resource overview
p = subparsers.add_parser("list", help="List programs, services, and jobs") p = subparsers.add_parser("list", help="List programs, services, and jobs")
p.add_argument("--behavior", choices=["daemon", "tool", "frontend"], help="Filter by behavior") p.add_argument("--kind", choices=["service", "job", "tool", "static", "reference"], help="Filter by derived kind")
p.add_argument("--stack", help="Filter by stack") p.add_argument("--stack", help="Filter by stack")
p.add_argument("--json", action="store_true", help="Output as JSON") p.add_argument("--json", action="store_true", help="Output as JSON")

View File

@@ -3,26 +3,30 @@
from castle_core.manifest import * # noqa: F401, F403 from castle_core.manifest import * # noqa: F401, F403
from castle_core.manifest import ( # noqa: F401 — explicit re-exports for type checkers from castle_core.manifest import ( # noqa: F401 — explicit re-exports for type checkers
BuildSpec, BuildSpec,
CaddyDeployment,
Capability, Capability,
CommandsSpec, CommandsSpec,
DefaultsSpec, DefaultsSpec,
DeploymentBase,
DeploymentSpec,
EnvMap, EnvMap,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
JobSpec, LaunchBase,
LaunchSpec,
ManageSpec, ManageSpec,
PathDeployment,
ProgramSpec, ProgramSpec,
ReadinessHttpGet, ReadinessHttpGet,
RemoteDeployment,
RestartPolicy, RestartPolicy,
RunBase,
RunCommand, RunCommand,
RunCompose, RunCompose,
RunContainer, RunContainer,
RunNode, RunNode,
RunPython, RunPython,
RunRemote, SystemdDeployment,
RunSpec,
ServiceSpec,
SystemdSpec, SystemdSpec,
kind_for,
) )

View File

@@ -42,8 +42,10 @@ class TestAdd:
) )
rc, config = _run_add(castle_root, target=str(repo)) rc, config = _run_add(castle_root, target=str(repo))
assert rc == 0 assert rc == 0
# `add` adopts source only (kind is derived from a deployment declared
# later); a fastapi project is detected as the python-fastapi stack.
assert config.programs["svc"].stack == "python-fastapi" assert config.programs["svc"].stack == "python-fastapi"
assert config.programs["svc"].behavior == "daemon" assert config.programs["svc"].kind is None
def test_adopt_rust_declares_commands(self, castle_root: Path, tmp_path: Path) -> None: def test_adopt_rust_declares_commands(self, castle_root: Path, tmp_path: Path) -> None:
repo = tmp_path / "rusty" repo = tmp_path / "rusty"

View File

@@ -74,9 +74,9 @@ class TestCreateCommand:
assert (project_dir / "CLAUDE.md").exists() assert (project_dir / "CLAUDE.md").exists()
assert "my-tool2" in config.programs assert "my-tool2" in config.programs
comp = config.programs["my-tool2"] comp = config.programs["my-tool2"]
assert comp.behavior == "tool" assert comp.kind == "tool"
# A tool is a PATH deployment: a `runner: path` service. # A tool is a PATH deployment: manager=path.
assert config.services["my-tool2"].run.runner == "path" assert config.deployments["my-tool2"].manager == "path"
def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None: def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None:
"""A supabase app scaffolds a Patch-shaped project registered as a static """A supabase app scaffolds a Patch-shaped project registered as a static
@@ -104,14 +104,14 @@ class TestCreateCommand:
assert (project_dir / "public" / "index.html").exists() assert (project_dir / "public" / "index.html").exists()
assert (project_dir / "supabase.app.yaml").exists() assert (project_dir / "supabase.app.yaml").exists()
# Registered as a program + a `static` service serving public/ # Registered as a program + a caddy (static) deployment serving public/
comp = config.programs["guestbook"] comp = config.programs["guestbook"]
assert comp.behavior == "frontend" assert comp.kind == "static"
assert comp.stack == "supabase" assert comp.stack == "supabase"
assert comp.build is not None and comp.build.outputs == ["public"] assert comp.build is not None and comp.build.outputs == ["public"]
svc = config.services["guestbook"] dep = config.deployments["guestbook"]
assert svc.run.runner == "static" assert dep.manager == "caddy"
assert svc.run.root == "public" assert dep.root == "public"
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None: def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
"""Creating a project with existing name fails.""" """Creating a project with existing name fails."""

View File

@@ -25,7 +25,7 @@ class TestInfoCommand:
assert result == 0 assert result == 0
output = capsys.readouterr().out output = capsys.readouterr().out
assert "test-svc" in output assert "test-svc" in output
assert "daemon" in output assert "service" in output
assert "19000" in output assert "19000" in output
def test_info_tool(self, castle_root: Path, capsys) -> None: def test_info_tool(self, castle_root: Path, capsys) -> None:
@@ -73,7 +73,7 @@ class TestInfoCommand:
assert result == 0 assert result == 0
output = capsys.readouterr().out output = capsys.readouterr().out
data = json.loads(output) data = json.loads(output)
assert data["behavior"] == "daemon" assert data["kind"] == "service"
assert data["service"]["expose"]["http"]["internal"]["port"] == 19000 assert data["service"]["expose"]["http"]["internal"]["port"] == 19000
def test_info_shows_env(self, castle_root: Path, capsys) -> None: def test_info_shows_env(self, castle_root: Path, capsys) -> None:
@@ -91,8 +91,8 @@ class TestInfoCommand:
output = capsys.readouterr().out output = capsys.readouterr().out
assert "TEST_SVC_DATA_DIR" in output assert "TEST_SVC_DATA_DIR" in output
def test_info_shows_behavior(self, castle_root: Path, capsys) -> None: def test_info_shows_kind(self, castle_root: Path, capsys) -> None:
"""Info displays behavior.""" """Info displays the derived kind."""
from castle_cli.config import load_config from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("castle_cli.commands.info.load_config") as mock_load:
@@ -104,5 +104,5 @@ class TestInfoCommand:
assert result == 0 assert result == 0
output = capsys.readouterr().out output = capsys.readouterr().out
assert "behavior" in output assert "kind" in output
assert "daemon" in output assert "service" in output

View File

@@ -19,7 +19,7 @@ class TestListCommand:
from castle_cli.config import load_config from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(castle_root)
args = Namespace(behavior=None, stack=None, json=False) args = Namespace(kind=None, stack=None, json=False)
result = run_list(args) result = run_list(args)
assert result == 0 assert result == 0
@@ -33,7 +33,7 @@ class TestListCommand:
from castle_cli.config import load_config from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(castle_root)
args = Namespace(behavior="daemon", stack=None, json=False) args = Namespace(kind="service", stack=None, json=False)
result = run_list(args) result = run_list(args)
assert result == 0 assert result == 0
@@ -49,7 +49,7 @@ class TestListCommand:
from castle_cli.config import load_config from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(castle_root)
args = Namespace(behavior="tool", stack=None, json=False) args = Namespace(kind="tool", stack=None, json=False)
result = run_list(args) result = run_list(args)
assert result == 0 assert result == 0
@@ -64,22 +64,22 @@ class TestListCommand:
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(castle_root)
# Unfiltered: the job appears. # Unfiltered: the job appears.
run_list(Namespace(behavior=None, stack=None, json=False)) run_list(Namespace(kind=None, stack=None, json=False))
assert "test-job" in capsys.readouterr().out # type: ignore[attr-defined] assert "test-job" in capsys.readouterr().out # type: ignore[attr-defined]
# Behavior filter targets the catalog, so jobs drop out. # Behavior filter targets the catalog, so jobs drop out.
run_list(Namespace(behavior="tool", stack=None, json=False)) run_list(Namespace(kind="tool", stack=None, json=False))
out = capsys.readouterr().out # type: ignore[attr-defined] out = capsys.readouterr().out # type: ignore[attr-defined]
assert "test-job" not in out assert "test-job" not in out
assert "test-svc" not in out assert "test-svc" not in out
assert "test-tool" in out assert "test-tool" in out
def test_list_json(self, castle_root: Path, capsys: object) -> None: def test_list_json(self, castle_root: Path, capsys: object) -> None:
"""JSON output tags each entry with its kind; behavior lives on programs.""" """JSON output tags each entry with its derived kind."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load: with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root) mock_load.return_value = load_config(castle_root)
args = Namespace(behavior=None, stack=None, json=True) args = Namespace(kind=None, stack=None, json=True)
result = run_list(args) result = run_list(args)
assert result == 0 assert result == 0
@@ -90,6 +90,6 @@ class TestListCommand:
assert "test-tool" in names assert "test-tool" in names
svc = next(p for p in data if p["name"] == "test-svc") svc = next(p for p in data if p["name"] == "test-svc")
assert svc["kind"] == "service" assert svc["kind"] == "service"
# test-tool is a program deployed on PATH → its derived kind is `tool`.
tool = next(p for p in data if p["name"] == "test-tool") tool = next(p for p in data if p["name"] == "test-tool")
assert tool["kind"] == "program" assert tool["kind"] == "tool"
assert tool["behavior"] == "tool"

View File

@@ -8,13 +8,18 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import yaml import yaml
from pydantic import BaseModel, TypeAdapter
from castle_core.manifest import ( from castle_core.manifest import (
JobSpec, DeploymentSpec,
ProgramSpec, ProgramSpec,
ServiceSpec, kind_for,
) )
# Validator for the manager-discriminated deployment union (it's an Annotated
# Union, not a BaseModel, so it needs a TypeAdapter to parse a dict).
_DEPLOYMENT_ADAPTER: TypeAdapter[DeploymentSpec] = TypeAdapter(DeploymentSpec)
def _resolve_castle_home() -> Path: def _resolve_castle_home() -> Path:
"""Resolve the castle home directory (config, code, artifacts, secrets). """Resolve the castle home directory (config, code, artifacts, secrets).
@@ -134,27 +139,32 @@ class CastleConfig:
gateway: GatewayConfig gateway: GatewayConfig
repo: Path | None repo: Path | None
programs: dict[str, ProgramSpec] programs: dict[str, ProgramSpec]
services: dict[str, ServiceSpec] # The one deployment concept (manager-discriminated). service/job/tool/static/
jobs: dict[str, JobSpec] # reference are *derived views* over this, filtered by kind_for — see below.
deployments: dict[str, DeploymentSpec]
def behavior_of(self, name: str) -> str | None: def kind_of(self, name: str) -> str | None:
"""A program's *derived* behavior label, from how it's deployed: """A program's *derived* kind, from its deployment (service|job|tool|
static service → frontend, path service → tool, process service → daemon, static|reference); None if it has no deployment. Never a stored value."""
job-only → tool, no deployment → None. Never a stored value.""" for dname, dep in self.deployments.items():
from castle_core.manifest import behavior_for_runner if dname == name or dep.program == name:
return kind_for(dep)
for svc_name, svc in self.services.items():
if svc_name == name or svc.program == name:
return behavior_for_runner(svc.run.runner)
for job_name, job in self.jobs.items():
if job_name == name or job.program == name:
return "tool" # a program a timer invokes is a tool
return None return None
@property
def services(self) -> dict[str, DeploymentSpec]:
"""Deployments whose derived kind is `service` (a continuous systemd process)."""
return {n: d for n, d in self.deployments.items() if kind_for(d) == "service"}
@property
def jobs(self) -> dict[str, DeploymentSpec]:
"""Deployments whose derived kind is `job` (a scheduled systemd timer)."""
return {n: d for n, d in self.deployments.items() if kind_for(d) == "job"}
@property @property
def tools(self) -> dict[str, ProgramSpec]: def tools(self) -> dict[str, ProgramSpec]:
"""Programs deployed as a PATH tool — derived, not a stored label.""" """Programs deployed as a PATH tool — derived, not a stored label."""
return {k: v for k, v in self.programs.items() if self.behavior_of(k) == "tool"} return {k: v for k, v in self.programs.items() if self.kind_of(k) == "tool"}
@property @property
def frontends(self) -> dict[str, ProgramSpec]: def frontends(self) -> dict[str, ProgramSpec]:
@@ -234,18 +244,44 @@ def _parse_program(name: str, data: dict) -> ProgramSpec:
return ProgramSpec.model_validate(data_copy) return ProgramSpec.model_validate(data_copy)
def _parse_service(name: str, data: dict) -> ServiceSpec: def _normalize_deployment_dict(data: dict) -> dict:
"""Parse a services: entry into a ServiceSpec.""" """Map a legacy service/job entry to the manager-discriminated shape.
data_copy = dict(data)
data_copy["id"] = name Legacy entries carry `run.runner` (including static/path/remote); new entries
return ServiceSpec.model_validate(data_copy) carry `manager` and (for systemd) `run.launcher`. New-shape entries pass through.
"""
if "manager" in data:
return data
d = dict(data)
run = dict(d.pop("run", None) or {})
runner = run.get("runner")
if runner == "static":
d["manager"] = "caddy"
if run.get("root"):
d["root"] = run["root"]
elif runner == "path":
d["manager"] = "path"
elif runner == "remote":
d["manager"] = "none"
if run.get("base_url"):
d["base_url"] = run["base_url"]
if run.get("health_url"):
d["health_url"] = run["health_url"]
else:
# A process launcher (python/command/container/compose/node) → systemd.
d["manager"] = "systemd"
launch = {k: v for k, v in run.items() if k != "runner"}
launch["launcher"] = runner
d["run"] = launch
return d
def _parse_job(name: str, data: dict) -> JobSpec: def _parse_deployment(name: str, data: dict) -> DeploymentSpec:
"""Parse a jobs: entry into a JobSpec.""" """Parse a deployment entry (new or legacy shape) into a DeploymentSpec."""
data_copy = dict(data) data_copy = _normalize_deployment_dict(data)
data_copy = dict(data_copy)
data_copy["id"] = name data_copy["id"] = name
return JobSpec.model_validate(data_copy) return _DEPLOYMENT_ADAPTER.validate_python(data_copy)
def _load_resource_dir(directory: Path) -> dict[str, dict]: def _load_resource_dir(directory: Path) -> dict[str, dict]:
@@ -267,7 +303,7 @@ def _load_resource_dir(directory: Path) -> dict[str, dict]:
def load_config(root: Path | None = None) -> CastleConfig: def load_config(root: Path | None = None) -> CastleConfig:
"""Load castle config: global castle.yaml + programs/, services/, jobs/ dirs.""" """Load castle config: global castle.yaml + programs/ and deployments/ dirs."""
if root is None: if root is None:
root = find_castle_root() root = find_castle_root()
@@ -306,26 +342,29 @@ def load_config(root: Path | None = None) -> CastleConfig:
prog.source = str(root / prog.source) prog.source = str(root / prog.source)
programs[name] = prog programs[name] = prog
services: dict[str, ServiceSpec] = {} # New layout: one deployments/ dir. Legacy: services/ + jobs/ (normalized on
for name, svc_data in _load_resource_dir(root / "services").items(): # read) — used only until the one-shot migration rewrites everything.
services[name] = _parse_service(name, svc_data) raw = _load_resource_dir(root / "deployments")
if not raw:
jobs: dict[str, JobSpec] = {} raw = {
for name, job_data in _load_resource_dir(root / "jobs").items(): **_load_resource_dir(root / "services"),
jobs[name] = _parse_job(name, job_data) **_load_resource_dir(root / "jobs"),
}
deployments: dict[str, DeploymentSpec] = {
name: _parse_deployment(name, dep_data) for name, dep_data in raw.items()
}
config = CastleConfig( config = CastleConfig(
root=root, root=root,
repo=repo_path, repo=repo_path,
gateway=gateway, gateway=gateway,
programs=programs, programs=programs,
services=services, deployments=deployments,
jobs=jobs,
) )
# `behavior` is derived from deployments, never stored — populate it so every # `kind` is derived from deployments, never stored — populate it so every
# reader of `program.behavior` gets the live, accurate label. # reader of `program.kind` gets the live, accurate label.
for pname, prog in config.programs.items(): for pname, prog in config.programs.items():
prog.behavior = config.behavior_of(pname) prog.kind = config.kind_of(pname)
return config return config
@@ -361,10 +400,10 @@ _STRUCTURAL_KEYS = {
} }
def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict: def _spec_to_yaml_dict(spec: BaseModel) -> dict:
"""Serialize a spec to a YAML-friendly dict, preserving structural presence.""" """Serialize a ProgramSpec or DeploymentSpec to a YAML-friendly dict."""
# `behavior` is derived at load time from deployments — never persisted. # `kind` is derived at load time from deployments — never persisted.
exclude_fields = {"id", "behavior"} if isinstance(spec, ProgramSpec) else {"id"} exclude_fields = {"id", "kind"} if isinstance(spec, ProgramSpec) else {"id"}
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields) full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
minimal = spec.model_dump( minimal = spec.model_dump(
mode="json", exclude_none=True, exclude=exclude_fields, exclude_defaults=True mode="json", exclude_none=True, exclude=exclude_fields, exclude_defaults=True
@@ -433,7 +472,7 @@ def _write_resource_dir(directory: Path, specs: dict[str, dict]) -> None:
def save_config(config: CastleConfig) -> None: def save_config(config: CastleConfig) -> None:
"""Save castle config: global castle.yaml + programs/, services/, jobs/ dirs.""" """Save castle config: global castle.yaml + programs/ and deployments/ dirs."""
gateway_data: dict = {"port": config.gateway.port} gateway_data: dict = {"port": config.gateway.port}
if config.gateway.tls: if config.gateway.tls:
gateway_data["tls"] = config.gateway.tls gateway_data["tls"] = config.gateway.tls
@@ -461,12 +500,8 @@ def save_config(config: CastleConfig) -> None:
{n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()}, {n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()},
) )
_write_resource_dir( _write_resource_dir(
config.root / "services", config.root / "deployments",
{n: _spec_to_yaml_dict(s) for n, s in config.services.items()}, {n: _spec_to_yaml_dict(d) for n, d in config.deployments.items()},
)
_write_resource_dir(
config.root / "jobs",
{n: _spec_to_yaml_dict(s) for n, s in config.jobs.items()},
) )

View File

@@ -26,7 +26,6 @@ from castle_core.config import (
from castle_core.generators.caddyfile import ( from castle_core.generators.caddyfile import (
_DNS_TOKEN_ENV, _DNS_TOKEN_ENV,
generate_caddyfile_from_registry, generate_caddyfile_from_registry,
service_proxy_targets,
) )
from castle_core.generators.tunnel import ( from castle_core.generators.tunnel import (
generate_tunnel_config, generate_tunnel_config,
@@ -41,7 +40,14 @@ from castle_core.generators.systemd import (
unit_env_file, unit_env_file,
unit_name, unit_name,
) )
from castle_core.manifest import JobSpec, ServiceSpec, behavior_for_runner, manager_for from castle_core.manifest import (
CaddyDeployment,
DeploymentBase,
DeploymentSpec,
PathDeployment,
RemoteDeployment,
kind_for,
)
from castle_core.registry import ( from castle_core.registry import (
REGISTRY_PATH, REGISTRY_PATH,
Deployment, Deployment,
@@ -101,20 +107,11 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
else: else:
registry = NodeRegistry(node=node) registry = NodeRegistry(node=node)
# Deploy services # Deploy every deployment, dispatched by its manager (systemd/caddy/path/none).
for name, svc in config.services.items(): for name, dep in config.deployments.items():
if target_name and name != target_name: if target_name and name != target_name:
continue continue
deployed = _build_deployed_service(config, name, svc, result.messages) deployed = _build_deployed(config, name, dep, result.messages)
registry.deployed[name] = deployed
result.deployed_count += 1
result.messages.append(_format_deployed(name, deployed))
# Deploy jobs
for name, job in config.jobs.items():
if target_name and name != target_name:
continue
deployed = _build_deployed_job(config, name, job, result.messages)
registry.deployed[name] = deployed registry.deployed[name] = deployed
result.deployed_count += 1 result.deployed_count += 1
result.messages.append(_format_deployed(name, deployed)) result.messages.append(_format_deployed(name, deployed))
@@ -316,7 +313,7 @@ def _write_secret_env_file(name: str, secret_env: dict[str, str]) -> Path | None
def _resolve_description( def _resolve_description(
config: CastleConfig, spec: ServiceSpec | JobSpec config: CastleConfig, spec: DeploymentBase
) -> str | None: ) -> str | None:
"""Get description, falling through to program if referenced.""" """Get description, falling through to program if referenced."""
if spec.description: if spec.description:
@@ -326,132 +323,112 @@ def _resolve_description(
return None return None
def _build_deployed_service( def _build_deployed(
config: CastleConfig, name: str, svc: ServiceSpec, messages: list[str] config: CastleConfig, name: str, dep: DeploymentSpec, messages: list[str]
) -> Deployment: ) -> Deployment:
"""Build a Deployment from a ServiceSpec.""" """Build a runtime Deployment from a DeploymentSpec, dispatched by its manager."""
run = svc.run description = _resolve_description(config, dep)
# The data-dir placeholder is keyed by the program the service runs, not the kind = kind_for(dep)
# service name (e.g. job `protonmail-sync` runs program `protonmail` → stack = None
# /data/castle/protonmail). Falls back to the service name. if dep.program and dep.program in config.programs:
config_key = svc.program or name stack = config.programs[dep.program].stack
source_dir = _program_source_dir(config, dep.program)
# Only systemd-managed runners get a unit. caddy (static), path (tools), and # Non-process managers (caddy/path/none) have no unit and no run_cmd — the
# none (remote) have no local process — the manager mapping is the single # gateway, PATH, or another node is their runtime.
# source of truth, so there's no runner special-casing here. if isinstance(dep, CaddyDeployment):
managed = manager_for(run.runner) == "systemd" # Serves <program-source>/<root> via the gateway; inherently exposed.
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable: static_root = str(source_dir / dep.root) if source_dir is not None else None
return Deployment(
manager="caddy",
run_cmd=[],
description=description,
kind=kind,
stack=stack,
subdomain=name,
public=bool(dep.public),
static_root=static_root,
managed=False,
)
if isinstance(dep, PathDeployment):
return Deployment(
manager="path",
run_cmd=[],
description=description,
kind=kind,
stack=stack,
managed=False,
)
if isinstance(dep, RemoteDeployment):
return Deployment(
manager="none",
run_cmd=[],
description=description,
kind=kind,
stack=stack,
base_url=dep.base_url,
managed=False,
)
# systemd: a supervised process (a service, or a job when scheduled).
run = dep.run
# ${data_dir} is keyed by the program the deployment runs, not the deployment
# name (e.g. job `protonmail-sync` runs program `protonmail` →
# /data/castle/protonmail). Falls back to the deployment name.
config_key = dep.program or name
managed = True
if dep.manage and dep.manage.systemd and not dep.manage.systemd.enable:
managed = False managed = False
# Routing comes from the shared deriver, so the registry written here and the # `proxy` is the exposure checkbox; the subdomain is the deployment name.
# Caddyfile computed from castle.yaml stay in lockstep. `expose` is the expose = bool(dep.proxy)
# checkbox; the subdomain is the service name. A `static` service is inherently port = None
# served (that's its whole purpose), so it's always exposed.
expose, port, base_url = service_proxy_targets(name, svc)
if run.runner == "static":
expose = True
health_path = None health_path = None
if svc.expose and svc.expose.http: if dep.expose and dep.expose.http:
health_path = svc.expose.http.health_path port = dep.expose.http.internal.port
health_path = dep.expose.http.health_path
# Env is exactly what's declared in defaults.env — no hidden convention # Env is exactly what's in defaults.env — no hidden convention injection.
# injection. ${port}/${data_dir}/${name}/${public_url} let the program's own # ${port}/${data_dir}/${name}/${public_url} map the program's own env var
# env var names map to castle's computed values without hardcoding them. # names to castle's computed values. Secret-bearing vars split out to a
# Secret-bearing vars # mode-0600 file (never in the unit or argv).
# are split out so they never land in the unit file or process argv — they're raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
# written to a mode-0600 env file referenced via EnvironmentFile=/--env-file.
raw_env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {}
public_url = _public_url(config, name, expose, port) public_url = _public_url(config, name, expose, port)
env, secret_env = resolve_env_split( env, secret_env = resolve_env_split(
raw_env, _env_context(name, config_key, port, public_url) raw_env, _env_context(name, config_key, port, public_url)
) )
secret_env_file = _write_secret_env_file(name, secret_env) secret_env_file = _write_secret_env_file(name, secret_env)
# `command`-runner services resolve a tool on PATH → ensure it's installed. # `command` launchers resolve a tool on PATH → ensure it's installed.
# `python`-runner services run in place via `uv run` (below) — no tool venv. # `python` launchers run in place via `uv run` (below) — no tool venv.
if run.runner == "command": if run.launcher == "command":
_ensure_python_tool(config, svc.program, messages) _ensure_python_tool(config, dep.program, messages)
# Build run_cmd (container runners get --env-file for the secrets).
source_dir = _program_source_dir(config, svc.program)
run_cmd = _build_run_cmd( run_cmd = _build_run_cmd(
name, name, run, env, messages, source_dir, secret_env_file=secret_env_file
run,
env,
messages,
source_dir,
secret_env_file=secret_env_file,
) )
stop_cmd = _build_stop_cmd(name, run, source_dir) stop_cmd = _build_stop_cmd(name, run, source_dir)
# A static service serves <program-source>/<run.root> via the gateway.
static_root = None
if run.runner == "static" and source_dir is not None:
static_root = str(source_dir / run.root) # type: ignore[union-attr]
# Resolve stack from referenced program
stack = None
if svc.program and svc.program in config.programs:
stack = config.programs[svc.program].stack
return Deployment( return Deployment(
runner=run.runner, manager="systemd",
launcher=run.launcher,
run_cmd=run_cmd, run_cmd=run_cmd,
stop_cmd=stop_cmd, stop_cmd=stop_cmd,
env=env, env=env,
secret_env_keys=sorted(secret_env), secret_env_keys=sorted(secret_env),
description=_resolve_description(config, svc), description=description,
behavior=behavior_for_runner(run.runner), kind=kind,
stack=stack, stack=stack,
port=port, port=port,
health_path=health_path, health_path=health_path,
subdomain=(name if expose else None), subdomain=(name if expose else None),
public=bool(getattr(svc, "public", False) and expose), public=bool(dep.public and expose),
static_root=static_root, schedule=getattr(dep, "schedule", None),
base_url=base_url,
managed=managed, managed=managed,
) )
def _build_deployed_job(
config: CastleConfig, name: str, job: JobSpec, messages: list[str]
) -> Deployment:
"""Build a Deployment from a JobSpec."""
run = job.run
# ${data_dir} is keyed by the program the job runs, not the job name — see
# _build_deployed_service. Falls back to the job name.
config_key = job.program or name
raw_env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {}
env, secret_env = resolve_env_split(raw_env, _env_context(name, config_key, None))
secret_env_file = _write_secret_env_file(name, secret_env)
if run.runner == "command":
_ensure_python_tool(config, job.program, messages)
run_cmd = _build_run_cmd(
name,
run,
env,
messages,
_program_source_dir(config, job.program),
secret_env_file=secret_env_file,
)
stack = None
if job.program and job.program in config.programs:
stack = config.programs[job.program].stack
return Deployment(
runner=run.runner,
run_cmd=run_cmd,
env=env,
secret_env_keys=sorted(secret_env),
description=_resolve_description(config, job),
behavior="tool",
stack=stack,
schedule=job.schedule,
managed=True,
)
def _python_tool_needs_install(program: str) -> bool: def _python_tool_needs_install(program: str) -> bool:
"""Check if a Python tool's editable install is broken.""" """Check if a Python tool's editable install is broken."""
if not shutil.which(program): if not shutil.which(program):
@@ -528,7 +505,7 @@ def _build_run_cmd(
source_dir: Path | None = None, source_dir: Path | None = None,
secret_env_file: Path | None = None, secret_env_file: Path | None = None,
) -> list[str]: ) -> list[str]:
"""Build a run command list from a RunSpec. """Build a run command list from a LaunchSpec (a systemd deployment's `run`).
``env`` holds plain (non-secret) vars only; ``secret_env_file`` is the ``env`` holds plain (non-secret) vars only; ``secret_env_file`` is the
mode-0600 file holding the deployment's secrets. For container runners the mode-0600 file holding the deployment's secrets. For container runners the
@@ -536,7 +513,7 @@ def _build_run_cmd(
systemd-launched runners get them via ``EnvironmentFile=`` on the unit, so systemd-launched runners get them via ``EnvironmentFile=`` on the unit, so
``secret_env_file`` is unused here for those. ``secret_env_file`` is unused here for those.
""" """
match run.runner: # type: ignore[union-attr] match run.launcher: # type: ignore[union-attr]
case "python": case "python":
# Run the program in place from its own project venv via `uv run`, # Run the program in place from its own project venv via `uv run`,
# which syncs the env to the lockfile before launching. One venv per # which syncs the env to the lockfile before launching. One venv per
@@ -609,16 +586,8 @@ def _build_run_cmd(
if run.args: # type: ignore[union-attr] if run.args: # type: ignore[union-attr]
cmd.extend(run.args) # type: ignore[union-attr] cmd.extend(run.args) # type: ignore[union-attr]
return cmd return cmd
case "remote":
return []
case "static":
# No process — the gateway file_servers this service's root.
return []
case "path":
# No process — installed on PATH via `uv tool install` at enable time.
return []
case _: case _:
raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr] raise ValueError(f"Unsupported launcher: {run.launcher}") # type: ignore[union-attr]
def _compose_base(name: str, run: object, source_dir: Path | None) -> list[str]: def _compose_base(name: str, run: object, source_dir: Path | None) -> list[str]:
@@ -643,7 +612,7 @@ def _build_stop_cmd(name: str, run: object, source_dir: Path | None) -> list[str
Compose stacks need an explicit ``down`` so networks/anonymous volumes are Compose stacks need an explicit ``down`` so networks/anonymous volumes are
reclaimed rather than left dangling when the unit stops. reclaimed rather than left dangling when the unit stops.
""" """
if run.runner == "compose": # type: ignore[union-attr] if run.launcher == "compose": # type: ignore[union-attr]
return [*_compose_base(name, run, source_dir), "down"] return [*_compose_base(name, run, source_dir), "down"]
return [] return []
@@ -712,14 +681,10 @@ def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> Non
continue continue
systemd_spec = None systemd_spec = None
if name in config.services: dep = config.deployments.get(name)
svc = config.services[name] manage = getattr(dep, "manage", None)
if svc.manage and svc.manage.systemd: if manage and manage.systemd:
systemd_spec = svc.manage.systemd systemd_spec = manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
svc_name = unit_name(name) svc_name = unit_name(name)
svc_content = generate_unit_from_deployed( svc_content = generate_unit_from_deployed(

View File

@@ -19,7 +19,7 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from castle_core.config import SPECS_DIR, CastleConfig from castle_core.config import SPECS_DIR, CastleConfig
from castle_core.manifest import ServiceSpec from castle_core.manifest import CaddyDeployment, SystemdDeployment
from castle_core.registry import NodeRegistry from castle_core.registry import NodeRegistry
# DNS-01 provider → the env var the Caddyfile reads its API token from. The token # DNS-01 provider → the env var the Caddyfile reads its API token from. The token
@@ -50,42 +50,39 @@ class GatewayRoute:
ProxyTargets = tuple[bool, int | None, str | None] ProxyTargets = tuple[bool, int | None, str | None]
def service_proxy_targets(name: str, svc: ServiceSpec) -> ProxyTargets: def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
"""Derive a service's gateway exposure from its spec. """Derive a systemd deployment's gateway exposure from its spec.
The single source of truth shared by the registry build (``deploy``) and The single source of truth shared by the registry build (``deploy``) and
route computation (``compute_routes``), so they never disagree. ``expose`` is route computation (``compute_routes``), so they never disagree. ``expose`` is
the checkbox (``proxy: true``); the subdomain is always the service name, so the checkbox (``proxy: true``); the subdomain is always the deployment name.
there's nothing else to derive.
""" """
port = None port = None
if svc.expose and svc.expose.http: if dep.expose and dep.expose.http:
port = svc.expose.http.internal.port port = dep.expose.http.internal.port
expose = bool(svc.proxy) return bool(dep.proxy), port, None
base_url = getattr(svc.run, "base_url", None)
return expose, port, base_url
def _local_routes( def _local_routes(
config: CastleConfig | None, registry: NodeRegistry config: CastleConfig | None, registry: NodeRegistry
) -> list[tuple[str, str, str]]: ) -> list[tuple[str, str, str]]:
"""Each local service's route as ``(name, kind, target)``, name-sorted. """Each local deployment's route as ``(name, kind, target)``, name-sorted.
``kind`` is ``static`` (file-serve a built dir) or ``proxy`` (reverse-proxy a ``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or
port/base_url). Prefers ``castle.yaml`` (``config.services``) as the source of ``proxy`` (a proxied systemd process). Prefers ``castle.yaml``
truth so a regenerated Caddyfile always reflects the current spec; falls back to (``config.deployments``) so a regenerated Caddyfile reflects the current spec;
the deployed registry snapshot when config isn't available. falls back to the deployed registry snapshot when config isn't available.
""" """
out: list[tuple[str, str, str]] = [] out: list[tuple[str, str, str]] = []
services = getattr(config, "services", None) deployments = getattr(config, "deployments", None)
if services is not None: if deployments is not None:
for name, svc in sorted(services.items()): for name, dep in sorted(deployments.items()):
if svc.run.runner == "static": if isinstance(dep, CaddyDeployment):
src = _program_source(config, svc.program) src = _program_source(config, dep.program)
if src is not None: if src is not None:
out.append((name, "static", str(src / svc.run.root))) out.append((name, "static", str(src / dep.root)))
continue elif isinstance(dep, SystemdDeployment):
expose, port, base_url = service_proxy_targets(name, svc) expose, port, base_url = service_proxy_targets(name, dep)
if expose and (port or base_url): if expose and (port or base_url):
out.append((name, "proxy", base_url or f"localhost:{port}")) out.append((name, "proxy", base_url or f"localhost:{port}"))
return out return out

View File

@@ -34,7 +34,7 @@ def unit_env_file(deployed: Deployment, name: str) -> Path | None:
so systemd must not also read them — return None there. Only deployments that so systemd must not also read them — return None there. Only deployments that
actually have secrets get a file. actually have secrets get a file.
""" """
if deployed.runner == "container" or not deployed.secret_env_keys: if deployed.launcher == "container" or not deployed.secret_env_keys:
return None return None
return secret_env_path(name) return secret_env_path(name)

View File

@@ -25,7 +25,7 @@ from castle_core.generators.systemd import (
unit_env_file, unit_env_file,
unit_name, unit_name,
) )
from castle_core.manifest import manager_for from castle_core.manifest import CaddyDeployment
from castle_core.registry import REGISTRY_PATH, load_registry from castle_core.registry import REGISTRY_PATH, load_registry
from castle_core.stacks import ActionResult, run_action from castle_core.stacks import ActionResult, run_action
@@ -49,22 +49,18 @@ def _on_path(name: str) -> bool:
def _svc_manager(name: str, config: CastleConfig) -> str | None: def _svc_manager(name: str, config: CastleConfig) -> str | None:
"""The manager for a deployed name (service/job), or None if not deployed.""" """The manager for a deployed name, or None if not deployed."""
if name in config.services: dep = config.deployments.get(name)
return manager_for(config.services[name].run.runner) return dep.manager if dep is not None else None
if name in config.jobs:
return "systemd"
return None
def _static_built(name: str, config: CastleConfig) -> bool: def _static_built(name: str, config: CastleConfig) -> bool:
"""Whether a static service's served dir exists (assets are built).""" """Whether a static (caddy) deployment's served dir exists (assets are built)."""
svc = config.services.get(name) dep = config.deployments.get(name)
if svc is None: if not isinstance(dep, CaddyDeployment):
return False return False
comp = config.programs.get(svc.program or name) comp = config.programs.get(dep.program or name)
root = getattr(svc.run, "root", "dist") return bool(comp and comp.source and (Path(comp.source) / dep.root).is_dir())
return bool(comp and comp.source and (Path(comp.source) / root).is_dir())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -114,10 +110,10 @@ def enable_service(name: str, config: CastleConfig) -> ActionResult:
) )
systemd_spec = None systemd_spec = None
if name in config.services and config.services[name].manage: dep = config.deployments.get(name)
systemd_spec = config.services[name].manage.systemd manage = getattr(dep, "manage", None)
elif name in config.jobs and config.jobs[name].manage: if manage:
systemd_spec = config.jobs[name].manage.systemd systemd_spec = manage.systemd
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
svc_unit = unit_name(name) svc_unit = unit_name(name)
@@ -166,9 +162,8 @@ def disable_service(name: str) -> ActionResult:
def _program_for(name: str, config: CastleConfig): def _program_for(name: str, config: CastleConfig):
"""The program a deployment runs (its `program` ref, defaulting to the name).""" """The program a deployment runs (its `program` ref, defaulting to the name)."""
prog = name dep = config.deployments.get(name)
if name in config.services: prog = (dep.program if dep else None) or name
prog = config.services[name].program or name
return prog, config.programs.get(prog) return prog, config.programs.get(prog)

View File

@@ -17,27 +17,33 @@ class RestartPolicy(str, Enum):
# --------------------- # ---------------------
# Run specs (discriminated union) # Launch specs — how systemd starts a process (discriminated union on `launcher`)
# --------------------- # ---------------------
#
# A *launcher* is the process-launch mechanism for a systemd-managed deployment.
# It is orthogonal to the deployment's `manager`: only systemd deployments have a
# launcher, and it says only how the process starts — not how it's supervised.
# The non-process managers (caddy/path/none) have no launcher; their fields live
# on the deployment variant itself.
class RunBase(BaseModel): class LaunchBase(BaseModel):
runner: str launcher: str
class RunCommand(RunBase): class RunCommand(LaunchBase):
runner: Literal["command"] launcher: Literal["command"]
argv: list[str] = Field(min_length=1) argv: list[str] = Field(min_length=1)
class RunPython(RunBase): class RunPython(LaunchBase):
runner: Literal["python"] launcher: Literal["python"]
program: str program: str
args: list[str] = Field(default_factory=list) args: list[str] = Field(default_factory=list)
class RunContainer(RunBase): class RunContainer(LaunchBase):
runner: Literal["container"] launcher: Literal["container"]
image: str image: str
command: list[str] | None = None command: list[str] | None = None
args: list[str] = Field(default_factory=list) args: list[str] = Field(default_factory=list)
@@ -47,14 +53,14 @@ class RunContainer(RunBase):
workdir: str | None = None workdir: str | None = None
class RunNode(RunBase): class RunNode(LaunchBase):
runner: Literal["node"] launcher: Literal["node"]
script: str script: str
package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm" package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm"
args: list[str] = Field(default_factory=list) args: list[str] = Field(default_factory=list)
class RunCompose(RunBase): class RunCompose(LaunchBase):
"""A multi-container stack supervised as one unit via ``docker compose``. """A multi-container stack supervised as one unit via ``docker compose``.
Unlike ``container`` (a single ``docker run``), compose owns the stack's own Unlike ``container`` (a single ``docker run``), compose owns the stack's own
@@ -65,91 +71,23 @@ class RunCompose(RunBase):
``defaults.env``); compose interpolates them from the process environment. ``defaults.env``); compose interpolates them from the process environment.
""" """
runner: Literal["compose"] launcher: Literal["compose"]
file: str = "docker-compose.yml" # resolved relative to the program source file: str = "docker-compose.yml" # resolved relative to the program source
project_name: str | None = None # ``-p``; defaults to ``castle-<name>`` project_name: str | None = None # ``-p``; defaults to ``castle-<name>``
class RunRemote(RunBase): LaunchSpec = Annotated[
runner: Literal["remote"]
base_url: str
health_url: str | None = None
class RunStatic(RunBase):
"""A static site served by the gateway (Caddy ``file_server``), no process.
Like ``remote``, this is a service with no local process and no systemd unit —
the gateway *is* its runtime. ``root`` is the built directory to serve, resolved
relative to the referenced program's source (e.g. ``dist`` or ``public``).
Building that directory is the program's concern; this only serves it.
"""
runner: Literal["static"]
root: str = "dist" # served dir, relative to the program source
class RunPath(RunBase):
"""A CLI installed on the user's PATH via ``uv tool install`` — no process.
Like ``remote``/``static`` it has no systemd unit; its manager is **PATH**.
The referenced program is what gets installed; its lifecycle is
install/uninstall (which is what start/stop/enable/disable map to).
"""
runner: Literal["path"]
RunSpec = Annotated[
Union[ Union[
RunCommand, RunCommand,
RunPython, RunPython,
RunContainer, RunContainer,
RunNode, RunNode,
RunCompose, RunCompose,
RunRemote,
RunStatic,
RunPath,
], ],
Field(discriminator="runner"), Field(discriminator="launcher"),
] ]
# A deployment's *manager* — who makes the program available and supervises it —
# is determined by its runner. This is the single source of truth; lifecycle,
# deploy, and status all dispatch on it rather than special-casing runners.
# systemd — a long-running process (or a timer, for jobs)
# caddy — a gateway route (file_server for static; reverse_proxy for a port)
# path — an installed CLI on PATH (via `uv tool install`)
# none — external; we only reference/route it (remote)
_RUNNER_MANAGER: dict[str, str] = {
"python": "systemd",
"command": "systemd",
"container": "systemd",
"compose": "systemd",
"node": "systemd",
"static": "caddy",
"path": "path",
"remote": "none",
}
def manager_for(runner: str) -> str:
"""The manager (`systemd`|`caddy`|`path`|`none`) that supervises a runner."""
return _RUNNER_MANAGER.get(runner, "systemd")
# `behavior` (tool/daemon/frontend) is a *derived* descriptive label, computed
# from how a program is deployed — never stored/edited. A static service → a
# frontend; a path install → a tool; anything else running a process → a daemon.
_RUNNER_BEHAVIOR: dict[str, str] = {"static": "frontend", "path": "tool"}
def behavior_for_runner(runner: str) -> str:
"""The display behavior implied by a service's runner."""
return _RUNNER_BEHAVIOR.get(runner, "daemon")
# --------------------- # ---------------------
# Systemd management # Systemd management
# --------------------- # ---------------------
@@ -269,7 +207,9 @@ class ProgramSpec(BaseModel):
id: str = "" id: str = ""
description: str | None = None description: str | None = None
behavior: str | None = None # Derived at load time from how the program is deployed (its deployment's
# kind: service|job|tool|static|reference) — never stored. See kind_for.
kind: str | None = None
source: str | None = None source: str | None = None
stack: str | None = None stack: str | None = None
@@ -301,68 +241,93 @@ class ProgramSpec(BaseModel):
# --------------------- # ---------------------
# Service spec — long-running daemon # Deployment specsa program materialized into the runtime
# --------------------- # ---------------------
#
# A deployment is discriminated on its `manager` — who supervises/realizes it:
# systemd → a process (or, with a `schedule`, a `.timer`)
# caddy → a gateway route serving a static dir (file_server)
# path → a CLI installed on PATH (uv tool install)
# none → an external reference we only point at (remote)
# The human "kind" (service/job/tool/static/reference) is *derived* from the
# manager (+ schedule presence), never stored — see `kind_for`.
class ServiceSpec(BaseModel): class DeploymentBase(BaseModel):
"""Long-running daemon deployment config.""" """Fields common to every deployment, regardless of manager."""
model_config = ConfigDict(populate_by_name=True) model_config = ConfigDict(populate_by_name=True)
id: str = "" id: str = ""
# The program this service deploys. (`component` accepted as a legacy alias.) # The program this deployment materializes. (`component` = legacy alias.)
program: str | None = Field( program: str | None = Field(
default=None, validation_alias=AliasChoices("program", "component") default=None, validation_alias=AliasChoices("program", "component")
) )
description: str | None = None description: str | None = None
run: RunSpec
expose: ExposeSpec | None = None
# Expose this service at <service-name>.<gateway.domain> through the gateway
# (the subdomain is the service name). False → reachable only at its host:port.
proxy: bool = False
# Also expose this service to the public internet via the Cloudflare tunnel, at
# <service-name>.<gateway.public_domain>. Default False — public is opt-in and
# explicit. Requires proxy (the tunnel projects an already-routed subdomain).
public: bool = False
manage: ManageSpec | None = None
defaults: DefaultsSpec | None = None defaults: DefaultsSpec | None = None
class SystemdDeployment(DeploymentBase):
"""A process supervised by systemd — a *service*, or a *job* when scheduled."""
manager: Literal["systemd"]
run: LaunchSpec
# Present → a `.timer` (job); absent → a continuous `.service` (service).
schedule: str | None = None
timezone: str = "America/Los_Angeles"
expose: ExposeSpec | None = None
# Route <name>.<gateway.domain> to this process. False → host:port only.
proxy: bool = False
# Also publish to the public internet via the Cloudflare tunnel, at
# <name>.<gateway.public_domain>. Opt-in; requires `proxy`.
public: bool = False
manage: ManageSpec | None = None
@model_validator(mode="after") @model_validator(mode="after")
def _validate_consistency(self) -> ServiceSpec: def _validate(self) -> SystemdDeployment:
if self.manage and self.manage.systemd and self.manage.systemd.enable: if self.public and not self.proxy:
if self.run.runner == "remote": raise ValueError("public requires proxy (an exposed process).")
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
# A static service is inherently exposed (that's its purpose); other
# runners need the proxy checkbox to be routed. Public requires exposure.
exposed = self.proxy or self.run.runner == "static"
if self.public and not exposed:
raise ValueError("public requires the service to be exposed (proxy or static).")
return self return self
# --------------------- class CaddyDeployment(DeploymentBase):
# Job spec — scheduled task """A static site served by the gateway (Caddy ``file_server``) — no process.
# ---------------------
The gateway *is* its runtime; it's inherently exposed at its subdomain. ``root``
is the built dir to serve, relative to the program source (e.g. ``dist``/``public``).
"""
manager: Literal["caddy"]
root: str = "dist"
# Inherently exposed at its subdomain; `public` = also project via the tunnel.
public: bool = False
class JobSpec(BaseModel): class PathDeployment(DeploymentBase):
"""Scheduled task that runs periodically and exits.""" """A CLI installed on PATH via ``uv tool install`` — no process, no route.
model_config = ConfigDict(populate_by_name=True) Lifecycle is install/uninstall (what start/stop/enable/disable map to).
"""
id: str = "" manager: Literal["path"]
# 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
schedule: str
timezone: str = "America/Los_Angeles"
manage: ManageSpec | None = None class RemoteDeployment(DeploymentBase):
defaults: DefaultsSpec | None = None """An external service (another node) — we only reference/route it, never run it."""
manager: Literal["none"]
base_url: str
health_url: str | None = None
DeploymentSpec = Annotated[
Union[SystemdDeployment, CaddyDeployment, PathDeployment, RemoteDeployment],
Field(discriminator="manager"),
]
def kind_for(spec: DeploymentSpec) -> str:
"""The derived kind of a deployment: service|job|tool|static|reference."""
if spec.manager == "systemd":
return "job" if getattr(spec, "schedule", None) else "service"
return {"caddy": "static", "path": "tool", "none": "reference"}[spec.manager]

View File

@@ -40,10 +40,14 @@ class NodeConfig:
class Deployment: class Deployment:
"""A component deployed on this node with resolved runtime config.""" """A component deployed on this node with resolved runtime config."""
runner: str # Who supervises/realizes this deployment: systemd | caddy | path | none.
manager: str
run_cmd: list[str] run_cmd: list[str]
# The systemd launch mechanism (python|command|container|compose|node), or
# None for the non-process managers (caddy/path/none).
launcher: str | None = None
# Optional teardown command emitted as systemd ``ExecStop=`` (e.g. compose # Optional teardown command emitted as systemd ``ExecStop=`` (e.g. compose
# ``down``). Empty for runners whose stop is just SIGTERM to the ExecStart pid. # ``down``). Empty for launchers whose stop is just SIGTERM to the ExecStart pid.
stop_cmd: list[str] = field(default_factory=list) stop_cmd: list[str] = field(default_factory=list)
env: dict[str, str] = field(default_factory=dict) env: dict[str, str] = field(default_factory=dict)
# Names (never values) of secret-bearing env vars. Their resolved values live # Names (never values) of secret-bearing env vars. Their resolved values live
@@ -51,7 +55,8 @@ class Deployment:
# visibility (which secrets a deployment expects). # visibility (which secrets a deployment expects).
secret_env_keys: list[str] = field(default_factory=list) secret_env_keys: list[str] = field(default_factory=list)
description: str | None = None description: str | None = None
behavior: str = "daemon" # Derived kind: service | job | tool | static | reference.
kind: str = "service"
stack: str | None = None stack: str | None = None
port: int | None = None port: int | None = None
health_path: str | None = None health_path: str | None = None
@@ -109,27 +114,38 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
deployed: dict[str, Deployment] = {} deployed: dict[str, Deployment] = {}
for name, comp_data in data.get("deployed", {}).items(): for name, comp_data in data.get("deployed", {}).items():
# Support both old "category" and new "behavior" keys for migration # New shape carries manager/launcher/kind; legacy carries runner/behavior.
behavior = comp_data.get("behavior") manager = comp_data.get("manager")
if behavior is None: launcher = comp_data.get("launcher")
category = comp_data.get("category", "service") if manager is None:
behavior = ( runner = comp_data.get("runner", "command")
"daemon" manager = {"static": "caddy", "path": "path", "remote": "none"}.get(
if category == "service" runner, "systemd"
else "tool"
if category in ("job", "tool")
else "frontend"
if category == "frontend"
else category
) )
if manager == "systemd":
launcher = runner
kind = comp_data.get("kind")
if kind is None:
behavior = comp_data.get("behavior")
if comp_data.get("schedule"):
kind = "job"
elif manager == "caddy" or behavior == "frontend":
kind = "static"
elif manager == "path" or behavior == "tool":
kind = "tool"
elif manager == "none":
kind = "reference"
else:
kind = "service"
deployed[name] = Deployment( deployed[name] = Deployment(
runner=comp_data.get("runner", "command"), manager=manager,
launcher=launcher,
run_cmd=comp_data.get("run_cmd", []), run_cmd=comp_data.get("run_cmd", []),
stop_cmd=comp_data.get("stop_cmd", []), stop_cmd=comp_data.get("stop_cmd", []),
env=comp_data.get("env", {}), env=comp_data.get("env", {}),
secret_env_keys=comp_data.get("secret_env_keys", []), secret_env_keys=comp_data.get("secret_env_keys", []),
description=comp_data.get("description"), description=comp_data.get("description"),
behavior=behavior, kind=kind,
stack=comp_data.get("stack"), stack=comp_data.get("stack"),
port=comp_data.get("port"), port=comp_data.get("port"),
health_path=comp_data.get("health_path"), health_path=comp_data.get("health_path"),
@@ -177,9 +193,11 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
for name, comp in registry.deployed.items(): for name, comp in registry.deployed.items():
entry: dict = { entry: dict = {
"runner": comp.runner, "manager": comp.manager,
"run_cmd": comp.run_cmd, "run_cmd": comp.run_cmd,
} }
if comp.launcher:
entry["launcher"] = comp.launcher
if comp.stop_cmd: if comp.stop_cmd:
entry["stop_cmd"] = comp.stop_cmd entry["stop_cmd"] = comp.stop_cmd
if comp.env: if comp.env:
@@ -188,7 +206,7 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["secret_env_keys"] = comp.secret_env_keys entry["secret_env_keys"] = comp.secret_env_keys
if comp.description: if comp.description:
entry["description"] = comp.description entry["description"] = comp.description
entry["behavior"] = comp.behavior entry["kind"] = comp.kind
if comp.stack: if comp.stack:
entry["stack"] = comp.stack entry["stack"] = comp.stack
if comp.port is not None: if comp.port is not None:

View File

@@ -38,7 +38,6 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"programs": { "programs": {
"test-tool": { "test-tool": {
"description": "Test tool", "description": "Test tool",
"behavior": "tool",
}, },
}, },
"services": { "services": {

View File

@@ -10,13 +10,14 @@ from castle_core.generators.caddyfile import (
generate_caddyfile_from_registry, generate_caddyfile_from_registry,
) )
from castle_core.manifest import ( from castle_core.manifest import (
CaddyDeployment,
DeploymentSpec,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
ProgramSpec, ProgramSpec,
RunPython, RunPython,
RunStatic, SystemdDeployment,
ServiceSpec,
) )
from castle_core.registry import Deployment, NodeConfig, NodeRegistry from castle_core.registry import Deployment, NodeConfig, NodeRegistry
@@ -52,10 +53,14 @@ def _make_registry(
) )
def _dep(port: int, *, expose: bool, name: str | None = None, runner: str = "python") -> Deployment: def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "python") -> Deployment:
"""A deployed service; exposed at <name>.<domain> when expose=True.""" """A deployed service; exposed at <name>.<domain> when expose=True."""
return Deployment( return Deployment(
runner=runner, run_cmd=["x"], port=port, subdomain=(name if expose else None) manager="systemd",
launcher=launcher,
run_cmd=["x"],
port=port,
subdomain=(name if expose else None),
) )
@@ -109,9 +114,9 @@ class TestAcmeMode:
import castle_core.config as config_mod import castle_core.config as config_mod
cfg = _config( cfg = _config(
services={ deployments={
"castle": ServiceSpec( "castle": CaddyDeployment(
program="castle", run=RunStatic(runner="static", root="dist") manager="caddy", program="castle", root="dist"
) )
}, },
programs={"castle": ProgramSpec(source="/data/repos/castle/app")}, programs={"castle": ProgramSpec(source="/data/repos/castle/app")},
@@ -144,24 +149,24 @@ class TestOffMode:
assert "litellm" not in cf # no subdomains without a domain assert "litellm" not in cf # no subdomains without a domain
def _service(port: int, *, expose: bool) -> ServiceSpec: def _service(port: int, *, expose: bool) -> SystemdDeployment:
return ServiceSpec( return SystemdDeployment(
run=RunPython(runner="python", program="svc"), manager="systemd",
run=RunPython(launcher="python", program="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))), expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))),
proxy=expose, proxy=expose,
) )
def _config( def _config(
services: dict[str, ServiceSpec], programs: dict[str, ProgramSpec] | None = None deployments: dict[str, DeploymentSpec], programs: dict[str, ProgramSpec] | None = None
) -> CastleConfig: ) -> CastleConfig:
return CastleConfig( return CastleConfig(
root=None, # type: ignore[arg-type] root=None, # type: ignore[arg-type]
gateway=GatewayConfig(port=9000), gateway=GatewayConfig(port=9000),
repo=None, repo=None,
programs=programs or {}, programs=programs or {},
services=services, deployments=deployments,
jobs={},
) )

View File

@@ -13,7 +13,7 @@ from castle_core.config import (
resolve_env_vars, resolve_env_vars,
save_config, save_config,
) )
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec from castle_core.manifest import ProgramSpec, SystemdDeployment
class TestLoadConfig: class TestLoadConfig:
@@ -32,8 +32,10 @@ class TestLoadConfig:
"""Each section produces the correct spec type.""" """Each section produces the correct spec type."""
config = load_config(castle_root) config = load_config(castle_root)
assert isinstance(config.programs["test-tool"], ProgramSpec) assert isinstance(config.programs["test-tool"], ProgramSpec)
assert isinstance(config.services["test-svc"], ServiceSpec) # Both a service and a job are systemd deployments; the kind (service/job)
assert isinstance(config.jobs["test-job"], JobSpec) # is derived from whether a schedule is present.
assert isinstance(config.services["test-svc"], SystemdDeployment)
assert isinstance(config.jobs["test-job"], SystemdDeployment)
def test_service_expose(self, castle_root: Path) -> None: def test_service_expose(self, castle_root: Path) -> None:
"""Service has correct expose spec.""" """Service has correct expose spec."""
@@ -49,10 +51,10 @@ class TestLoadConfig:
assert svc.proxy is True # exposed at <name>.<gateway.domain> assert svc.proxy is True # exposed at <name>.<gateway.domain>
def test_service_run_spec(self, castle_root: Path) -> None: def test_service_run_spec(self, castle_root: Path) -> None:
"""Service has correct RunSpec.""" """Service has correct launch spec (legacy runner normalized to launcher)."""
config = load_config(castle_root) config = load_config(castle_root)
svc = config.services["test-svc"] svc = config.services["test-svc"]
assert svc.run.runner == "python" assert svc.run.launcher == "python"
assert svc.run.program == "test-svc" assert svc.run.program == "test-svc"
def test_service_component_ref(self, castle_root: Path) -> None: def test_service_component_ref(self, castle_root: Path) -> None:
@@ -114,25 +116,26 @@ class TestSaveConfig:
assert svc.manage.systemd is not None assert svc.manage.systemd is not None
def test_writes_directory_layout(self, castle_root: Path) -> None: def test_writes_directory_layout(self, castle_root: Path) -> None:
"""Save writes one file per resource under programs/services/jobs dirs.""" """Save writes one file per resource under programs/ and one deployments/ dir."""
config = load_config(castle_root) config = load_config(castle_root)
save_config(config) save_config(config)
assert (castle_root / "programs" / "test-tool.yaml").exists() assert (castle_root / "programs" / "test-tool.yaml").exists()
assert (castle_root / "services" / "test-svc.yaml").exists() # service, job, and tool all live under the single deployments/ dir now.
assert (castle_root / "jobs" / "test-job.yaml").exists() assert (castle_root / "deployments" / "test-svc.yaml").exists()
assert (castle_root / "deployments" / "test-job.yaml").exists()
assert (castle_root / "deployments" / "test-tool.yaml").exists()
# Global file holds gateway only, no resource sections # Global file holds gateway only, no resource sections
global_data = yaml.safe_load((castle_root / "castle.yaml").read_text()) global_data = yaml.safe_load((castle_root / "castle.yaml").read_text())
assert global_data["gateway"]["port"] == 18000 assert global_data["gateway"]["port"] == 18000
assert "programs" not in global_data assert "programs" not in global_data
assert "services" not in global_data assert "deployments" not in global_data
assert "jobs" not in global_data
def test_delete_prunes_file(self, castle_root: Path) -> None: def test_delete_prunes_file(self, castle_root: Path) -> None:
"""Removing a resource and saving deletes its on-disk file.""" """Removing a deployment and saving deletes its on-disk file."""
config = load_config(castle_root) config = load_config(castle_root)
del config.services["test-svc"] del config.deployments["test-svc"]
save_config(config) save_config(config)
assert not (castle_root / "services" / "test-svc.yaml").exists() assert not (castle_root / "deployments" / "test-svc.yaml").exists()
config2 = load_config(castle_root) config2 = load_config(castle_root)
assert "test-svc" not in config2.services assert "test-svc" not in config2.services
assert "test-tool" in config2.programs assert "test-tool" in config2.programs

View File

@@ -12,7 +12,8 @@ from castle_core.registry import Deployment, NodeConfig, NodeRegistry
def _svc(managed: bool = True, schedule: str | None = None) -> Deployment: def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
return Deployment( return Deployment(
runner="python", manager="systemd",
launcher="python",
run_cmd=["x"], run_cmd=["x"],
env={}, env={},
managed=managed, managed=managed,

View File

@@ -11,7 +11,7 @@ from castle_core.manifest import RunCompose, RunContainer, RunNode, RunPython
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None: def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
"""A python service with a real source dir launches via `uv run --project`.""" """A python service with a real source dir launches via `uv run --project`."""
run = RunPython(runner="python", program="my-svc") run = RunPython(launcher="python", program="my-svc")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path) cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd == [ assert cmd == [
@@ -25,7 +25,7 @@ def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
def test_python_runner_appends_args(tmp_path: Path) -> None: def test_python_runner_appends_args(tmp_path: Path) -> None:
run = RunPython(runner="python", program="my-svc", args=["--flag", "x"]) run = RunPython(launcher="python", program="my-svc", args=["--flag", "x"])
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path) cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
assert cmd[-2:] == ["--flag", "x"] assert cmd[-2:] == ["--flag", "x"]
@@ -34,7 +34,7 @@ def test_python_runner_appends_args(tmp_path: Path) -> None:
def test_python_runner_falls_back_to_path_without_source() -> None: def test_python_runner_falls_back_to_path_without_source() -> None:
"""No resolvable source → PATH lookup of the script (no uv run).""" """No resolvable source → PATH lookup of the script (no uv run)."""
run = RunPython(runner="python", program="my-svc") run = RunPython(launcher="python", program="my-svc")
with patch( with patch(
"castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc" "castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"
): ):
@@ -44,7 +44,7 @@ def test_python_runner_falls_back_to_path_without_source() -> None:
def test_python_runner_warns_when_unresolvable() -> None: def test_python_runner_warns_when_unresolvable() -> None:
"""No source and not on PATH → a warning, bare program name as last resort.""" """No source and not on PATH → a warning, bare program name as last resort."""
run = RunPython(runner="python", program="my-svc") run = RunPython(launcher="python", program="my-svc")
messages: list[str] = [] messages: list[str] = []
with patch("castle_core.deploy.shutil.which", return_value=None): with patch("castle_core.deploy.shutil.which", return_value=None):
cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None) cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None)
@@ -54,7 +54,7 @@ def test_python_runner_warns_when_unresolvable() -> None:
def test_node_runner_bakes_source_dir(tmp_path: Path) -> None: def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
"""A node service runs the script in its source dir via `--dir` (no unit cwd).""" """A node service runs the script in its source dir via `--dir` (no unit cwd)."""
run = RunNode(runner="node", script="gateway:watch:raw", package_manager="pnpm") run = RunNode(launcher="node", script="gateway:watch:raw", package_manager="pnpm")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path) cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
assert cmd == ["/usr/bin/pnpm", "--dir", str(tmp_path), "run", "gateway:watch:raw"] assert cmd == ["/usr/bin/pnpm", "--dir", str(tmp_path), "run", "gateway:watch:raw"]
@@ -62,7 +62,7 @@ def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
def test_node_runner_appends_args(tmp_path: Path) -> None: def test_node_runner_appends_args(tmp_path: Path) -> None:
run = RunNode( run = RunNode(
runner="node", script="start", package_manager="pnpm", args=["--port", "18789"] launcher="node", script="start", package_manager="pnpm", args=["--port", "18789"]
) )
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path) cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
@@ -79,7 +79,7 @@ def test_node_runner_appends_args(tmp_path: Path) -> None:
def test_node_runner_without_source_omits_dir() -> None: def test_node_runner_without_source_omits_dir() -> None:
"""No resolvable source → bare `pnpm run` (package manager still PATH-resolved).""" """No resolvable source → bare `pnpm run` (package manager still PATH-resolved)."""
run = RunNode(runner="node", script="start", package_manager="pnpm") run = RunNode(launcher="node", script="start", package_manager="pnpm")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
cmd = _build_run_cmd("oc", run, {}, [], source_dir=None) cmd = _build_run_cmd("oc", run, {}, [], source_dir=None)
assert cmd == ["/usr/bin/pnpm", "run", "start"] assert cmd == ["/usr/bin/pnpm", "run", "start"]
@@ -87,7 +87,7 @@ def test_node_runner_without_source_omits_dir() -> None:
def test_container_secrets_use_env_file_not_argv() -> None: def test_container_secrets_use_env_file_not_argv() -> None:
"""Secrets go through --env-file; plain vars stay as -e; no secret in argv.""" """Secrets go through --env-file; plain vars stay as -e; no secret in argv."""
run = RunContainer(runner="container", image="img:latest", env={"PLAIN": "1"}) run = RunContainer(launcher="container", image="img:latest", env={"PLAIN": "1"})
env_file = Path("/home/u/.castle/secrets/env/castle-svc.service.env") env_file = Path("/home/u/.castle/secrets/env/castle-svc.service.env")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {"PORT": "9001"}, [], secret_env_file=env_file) cmd = _build_run_cmd("svc", run, {"PORT": "9001"}, [], secret_env_file=env_file)
@@ -101,7 +101,7 @@ def test_container_secrets_use_env_file_not_argv() -> None:
def test_container_without_secrets_has_no_env_file() -> None: def test_container_without_secrets_has_no_env_file() -> None:
run = RunContainer(runner="container", image="img:latest") run = RunContainer(launcher="container", image="img:latest")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None) cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None)
assert "--env-file" not in cmd assert "--env-file" not in cmd
@@ -109,7 +109,7 @@ def test_container_without_secrets_has_no_env_file() -> None:
def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None: def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
"""A compose service runs `docker compose -p <project> -f <abs-file> up`.""" """A compose service runs `docker compose -p <project> -f <abs-file> up`."""
run = RunCompose(runner="compose", file="docker-compose.yml") run = RunCompose(launcher="compose", file="docker-compose.yml")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("supabase", run, {}, [], source_dir=tmp_path) cmd = _build_run_cmd("supabase", run, {}, [], source_dir=tmp_path)
assert cmd == [ assert cmd == [
@@ -125,7 +125,7 @@ def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None: def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
"""Compose reads env from the process (systemd), not --env-file/-e — argv is clean.""" """Compose reads env from the process (systemd), not --env-file/-e — argv is clean."""
run = RunCompose(runner="compose", file="docker-compose.yml") run = RunCompose(launcher="compose", file="docker-compose.yml")
env_file = Path("/home/u/.castle/secrets/env/castle-supabase.service.env") env_file = Path("/home/u/.castle/secrets/env/castle-supabase.service.env")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd( cmd = _build_run_cmd(
@@ -139,7 +139,7 @@ def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
def test_compose_project_name_override(tmp_path: Path) -> None: def test_compose_project_name_override(tmp_path: Path) -> None:
run = RunCompose(runner="compose", file="stack.yml", project_name="myproj") run = RunCompose(launcher="compose", file="stack.yml", project_name="myproj")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("s", run, {}, [], source_dir=tmp_path) cmd = _build_run_cmd("s", run, {}, [], source_dir=tmp_path)
assert cmd[:6] == [ assert cmd[:6] == [
@@ -149,7 +149,7 @@ def test_compose_project_name_override(tmp_path: Path) -> None:
def test_compose_stop_cmd_is_down(tmp_path: Path) -> None: def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
"""The teardown command mirrors up but ends in `down` (same project + file).""" """The teardown command mirrors up but ends in `down` (same project + file)."""
run = RunCompose(runner="compose", file="docker-compose.yml") run = RunCompose(launcher="compose", file="docker-compose.yml")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
stop = _build_stop_cmd("supabase", run, tmp_path) stop = _build_stop_cmd("supabase", run, tmp_path)
assert stop == [ assert stop == [
@@ -164,5 +164,5 @@ def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
def test_non_compose_runner_has_no_stop_cmd(tmp_path: Path) -> None: def test_non_compose_runner_has_no_stop_cmd(tmp_path: Path) -> None:
run = RunPython(runner="python", program="svc") run = RunPython(launcher="python", program="svc")
assert _build_stop_cmd("svc", run, tmp_path) == [] assert _build_stop_cmd("svc", run, tmp_path) == []

View File

@@ -65,7 +65,7 @@ class TestRegistrySecretKeys:
node=NodeConfig(hostname="h"), node=NodeConfig(hostname="h"),
deployed={ deployed={
"svc": Deployment( "svc": Deployment(
runner="container", manager="systemd", launcher="container",
run_cmd=["docker", "run"], run_cmd=["docker", "run"],
env={"PORT": "9001"}, env={"PORT": "9001"},
secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"], secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],

View File

@@ -34,37 +34,38 @@ class TestIsActive:
config = load_config(castle_root) config = load_config(castle_root)
assert lifecycle.is_active("does-not-exist", config) is False assert lifecycle.is_active("does-not-exist", config) is False
def test_path_service_checks_path(self, castle_root: Path) -> None: def test_path_deployment_checks_path(self, castle_root: Path) -> None:
# A `runner: path` service (a tool) is active when on PATH. # A `manager: path` deployment (a tool) is active when on PATH.
from castle_core.manifest import ProgramSpec, RunPath, ServiceSpec, manager_for from castle_core.manifest import PathDeployment, ProgramSpec
assert manager_for("path") == "path"
config = load_config(castle_root) config = load_config(castle_root)
config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool") config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool")
config.services["mytool"] = ServiceSpec(program="mytool", run=RunPath(runner="path")) config.deployments["mytool"] = PathDeployment(manager="path", program="mytool")
with patch.object(lifecycle, "_on_path", return_value=True) as mock: with patch.object(lifecycle, "_on_path", return_value=True) as mock:
assert lifecycle.is_active("mytool", config) is True assert lifecycle.is_active("mytool", config) is True
mock.assert_called_once_with("mytool") mock.assert_called_once_with("mytool")
def test_remote_service_is_active(self, castle_root: Path) -> None: def test_remote_deployment_is_active(self, castle_root: Path) -> None:
# A remote service has no local process; the manager is `none` → available. # A remote deployment has no local process; the manager is `none` → available.
from castle_core.manifest import RunRemote, ServiceSpec from castle_core.manifest import RemoteDeployment
config = load_config(castle_root) config = load_config(castle_root)
config.services["ext"] = ServiceSpec( config.deployments["ext"] = RemoteDeployment(
program="ext", run=RunRemote(runner="remote", base_url="http://x") manager="none", program="ext", base_url="http://x"
) )
assert lifecycle.is_active("ext", config) is True assert lifecycle.is_active("ext", config) is True
def test_static_service_active_when_dist_built(self, castle_root: Path, tmp_path: Path) -> None: def test_static_deployment_active_when_dist_built(
# A frontend is a `runner: static` service; active once its served dir exists. self, castle_root: Path, tmp_path: Path
from castle_core.manifest import ProgramSpec, RunStatic, ServiceSpec ) -> None:
# A static (caddy) deployment is active once its served dir exists.
from castle_core.manifest import CaddyDeployment, ProgramSpec
config = load_config(castle_root) config = load_config(castle_root)
repo = tmp_path / "fe" repo = tmp_path / "fe"
config.programs["fe"] = ProgramSpec(id="fe", source=str(repo)) config.programs["fe"] = ProgramSpec(id="fe", source=str(repo))
config.services["fe"] = ServiceSpec( config.deployments["fe"] = CaddyDeployment(
program="fe", run=RunStatic(runner="static", root="dist") manager="caddy", program="fe", root="dist"
) )
# No dist yet → inactive (caddy manager checks the served dir) # No dist yet → inactive (caddy manager checks the served dir)
assert lifecycle.is_active("fe", config) is False assert lifecycle.is_active("fe", config) is False

View File

@@ -5,49 +5,49 @@ from __future__ import annotations
import pytest import pytest
from castle_core.manifest import ( from castle_core.manifest import (
BuildSpec, BuildSpec,
ProgramSpec, CaddyDeployment,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
JobSpec,
ManageSpec, ManageSpec,
PathDeployment,
ProgramSpec,
RunCommand, RunCommand,
RunPython, RunPython,
RunRemote, RemoteDeployment,
ServiceSpec, SystemdDeployment,
SystemdSpec, SystemdSpec,
kind_for,
) )
class TestProgramSpec: class TestProgramSpec:
"""Tests for component (software catalog) model.""" """Tests for program (software catalog) model."""
def test_minimal(self) -> None: def test_minimal(self) -> None:
"""Minimal component just needs an id.""" """Minimal program just needs an id."""
c = ProgramSpec(id="bare") c = ProgramSpec(id="bare")
assert c.description is None assert c.description is None
assert c.source is None assert c.source is None
assert c.behavior is None # `kind` is derived at load time; a bare spec has none.
assert c.kind is None
assert c.build is None assert c.build is None
def test_tool_component(self) -> None: def test_tool_program(self) -> None:
"""Component with tool behavior and system_dependencies.""" """Program with source and system_dependencies."""
c = ProgramSpec( c = ProgramSpec(
id="my-tool", id="my-tool",
description="A tool", description="A tool",
source="my-tool/", source="my-tool/",
behavior="tool",
system_dependencies=["pandoc"], system_dependencies=["pandoc"],
) )
assert c.source == "my-tool/" assert c.source == "my-tool/"
assert c.behavior == "tool"
assert c.system_dependencies == ["pandoc"] assert c.system_dependencies == ["pandoc"]
def test_frontend_component(self) -> None: def test_frontend_program(self) -> None:
"""Component with build spec.""" """Program with build spec."""
c = ProgramSpec( c = ProgramSpec(
id="my-app", id="my-app",
behavior="frontend",
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]), build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
) )
assert c.build.outputs == ["dist/"] assert c.build.outputs == ["dist/"]
@@ -63,109 +63,91 @@ class TestProgramSpec:
assert c.source_dir is None assert c.source_dir is None
class TestServiceSpec: class TestSystemdDeployment:
"""Tests for service (long-running daemon) model.""" """Tests for the systemd deployment (service or job)."""
def test_basic_service(self) -> None: def test_basic_service(self) -> None:
"""Service with run and expose.""" """A systemd deployment with a launcher and expose is a service."""
s = ServiceSpec( s = SystemdDeployment(
id="svc", id="svc",
run=RunPython(runner="python", program="svc"), manager="systemd",
run=RunPython(launcher="python", program="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))), expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
) )
assert s.run.runner == "python" assert s.run.launcher == "python"
assert s.expose.http.internal.port == 8000 assert s.expose.http.internal.port == 8000
assert kind_for(s) == "service"
def test_service_with_component_ref(self) -> None: def test_scheduled_is_a_job(self) -> None:
"""Service can reference a component.""" """A systemd deployment with a schedule derives kind `job`."""
s = ServiceSpec( j = SystemdDeployment(
id="svc",
program="my-component",
run=RunPython(runner="python", program="svc"),
)
assert s.program == "my-component"
def test_service_with_proxy(self) -> None:
"""Service with proxy spec."""
s = ServiceSpec(
id="svc",
run=RunPython(runner="python", program="svc"),
proxy=True,
)
assert s.proxy is True
def test_service_with_manage(self) -> None:
"""Service with systemd management."""
s = ServiceSpec(
id="svc",
run=RunCommand(runner="command", argv=["bin"]),
manage=ManageSpec(systemd=SystemdSpec()),
)
assert s.manage.systemd.enable is True
def test_remote_with_systemd_raises(self) -> None:
"""Remote runner + systemd management is invalid."""
with pytest.raises(
ValueError, match="manage.systemd cannot be enabled for runner=remote"
):
ServiceSpec(
id="bad",
run=RunRemote(runner="remote", base_url="http://example.com"),
manage=ManageSpec(systemd=SystemdSpec()),
)
def test_no_run_is_invalid(self) -> None:
"""Service requires a run spec."""
with pytest.raises(Exception):
ServiceSpec(id="bad")
class TestJobSpec:
"""Tests for job (scheduled task) model."""
def test_basic_job(self) -> None:
"""Job with run and schedule."""
j = JobSpec(
id="my-job", id="my-job",
run=RunCommand(runner="command", argv=["backup"]), manager="systemd",
run=RunCommand(launcher="command", argv=["backup"]),
schedule="0 2 * * *", schedule="0 2 * * *",
) )
assert j.schedule == "0 2 * * *" assert j.schedule == "0 2 * * *"
assert j.timezone == "America/Los_Angeles" assert j.timezone == "America/Los_Angeles"
assert kind_for(j) == "job"
def test_job_with_component_ref(self) -> None: def test_program_ref(self) -> None:
"""Job can reference a component.""" """A deployment can reference a program."""
j = JobSpec( s = SystemdDeployment(
id="sync", id="svc",
program="protonmail", manager="systemd",
run=RunCommand(runner="command", argv=["protonmail", "sync"]), program="my-program",
schedule="*/5 * * * *", run=RunPython(launcher="python", program="svc"),
) )
assert j.program == "protonmail" assert s.program == "my-program"
def test_job_requires_schedule(self) -> None: def test_with_manage(self) -> None:
"""Job without schedule is invalid.""" """A deployment with systemd management."""
with pytest.raises(Exception): s = SystemdDeployment(
JobSpec( id="svc",
manager="systemd",
run=RunCommand(launcher="command", argv=["bin"]),
manage=ManageSpec(systemd=SystemdSpec()),
)
assert s.manage.systemd.enable is True
def test_public_requires_proxy(self) -> None:
"""public without proxy is invalid (public needs an exposed process)."""
with pytest.raises(ValueError, match="public requires proxy"):
SystemdDeployment(
id="bad", id="bad",
run=RunCommand(runner="command", argv=["x"]), manager="systemd",
run=RunPython(launcher="python", program="svc"),
public=True,
) )
def test_job_custom_timezone(self) -> None: def test_no_run_is_invalid(self) -> None:
"""Job with custom timezone.""" """A systemd deployment requires a run (launch) spec."""
j = JobSpec( with pytest.raises(Exception):
id="job", SystemdDeployment(id="bad", manager="systemd")
run=RunCommand(runner="command", argv=["x"]),
schedule="0 0 * * *",
timezone="UTC", class TestOtherManagers:
) """Tests for the non-systemd managers and derived kinds."""
assert j.timezone == "UTC"
def test_caddy_is_static(self) -> None:
c = CaddyDeployment(id="fe", manager="caddy", program="fe", root="dist")
assert c.root == "dist"
assert kind_for(c) == "static"
def test_path_is_tool(self) -> None:
p = PathDeployment(id="cli", manager="path", program="cli")
assert kind_for(p) == "tool"
def test_none_is_reference(self) -> None:
r = RemoteDeployment(id="ext", manager="none", base_url="http://example.com")
assert r.base_url == "http://example.com"
assert kind_for(r) == "reference"
class TestModelSerialization: class TestModelSerialization:
"""Tests for model_dump behavior.""" """Tests for model_dump behavior."""
def test_dump_component_excludes_none(self) -> None: def test_dump_program_excludes_none(self) -> None:
"""model_dump with exclude_none drops None fields.""" """model_dump with exclude_none drops None fields."""
c = ProgramSpec(id="test", description="Test") c = ProgramSpec(id="test", description="Test")
data = c.model_dump(exclude_none=True, exclude={"id"}) data = c.model_dump(exclude_none=True, exclude={"id"})
@@ -173,11 +155,12 @@ class TestModelSerialization:
assert "build" not in data assert "build" not in data
def test_dump_service(self) -> None: def test_dump_service(self) -> None:
"""Full service serializes correctly.""" """Full systemd deployment serializes correctly."""
s = ServiceSpec( s = SystemdDeployment(
id="svc", id="svc",
manager="systemd",
description="A service", description="A service",
run=RunPython(runner="python", program="svc"), run=RunPython(launcher="python", program="svc"),
expose=ExposeSpec( expose=ExposeSpec(
http=HttpExposeSpec( http=HttpExposeSpec(
internal=HttpInternal(port=9001), health_path="/health" internal=HttpInternal(port=9001), health_path="/health"
@@ -187,6 +170,7 @@ class TestModelSerialization:
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
) )
data = s.model_dump(exclude_none=True, exclude={"id"}) data = s.model_dump(exclude_none=True, exclude={"id"})
assert data["run"]["runner"] == "python" assert data["manager"] == "systemd"
assert data["run"]["launcher"] == "python"
assert data["expose"]["http"]["internal"]["port"] == 9001 assert data["expose"]["http"]["internal"]["port"] == 9001
assert data["proxy"] is True assert data["proxy"] is True

View File

@@ -29,7 +29,7 @@ class TestUnitFromDeployed:
def test_contains_description(self) -> None: def test_contains_description(self) -> None:
"""Unit file has service description.""" """Unit file has service description."""
deployed = Deployment( deployed = Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={"TEST_SVC_PORT": "19000"}, env={"TEST_SVC_PORT": "19000"},
description="Test service", description="Test service",
@@ -40,7 +40,7 @@ class TestUnitFromDeployed:
def test_no_working_directory(self) -> None: def test_no_working_directory(self) -> None:
"""Unit file has no WorkingDirectory (source/runtime separation).""" """Unit file has no WorkingDirectory (source/runtime separation)."""
deployed = Deployment( deployed = Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={}, env={},
description="Test service", description="Test service",
@@ -51,7 +51,7 @@ class TestUnitFromDeployed:
def test_contains_environment(self) -> None: def test_contains_environment(self) -> None:
"""Unit file has environment variables from deployed config.""" """Unit file has environment variables from deployed config."""
deployed = Deployment( deployed = Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"}, env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"},
description="Test service", description="Test service",
@@ -62,7 +62,7 @@ class TestUnitFromDeployed:
def test_contains_restart_policy(self) -> None: def test_contains_restart_policy(self) -> None:
"""Unit file has restart configuration.""" """Unit file has restart configuration."""
deployed = Deployment( deployed = Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={}, env={},
description="Test service", description="Test service",
@@ -73,7 +73,7 @@ class TestUnitFromDeployed:
def test_exec_start_from_run_cmd(self) -> None: def test_exec_start_from_run_cmd(self) -> None:
"""Unit file ExecStart uses resolved run_cmd.""" """Unit file ExecStart uses resolved run_cmd."""
deployed = Deployment( deployed = Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
env={}, env={},
description="Test service", description="Test service",
@@ -84,7 +84,7 @@ class TestUnitFromDeployed:
def test_basic_service(self) -> None: def test_basic_service(self) -> None:
"""Generate a unit from a deployed component.""" """Generate a unit from a deployed component."""
deployed = Deployment( deployed = Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={ env={
"MY_SVC_PORT": "9001", "MY_SVC_PORT": "9001",
@@ -103,7 +103,7 @@ class TestUnitFromDeployed:
def test_scheduled_job(self) -> None: def test_scheduled_job(self) -> None:
"""Scheduled component generates oneshot unit.""" """Scheduled component generates oneshot unit."""
deployed = Deployment( deployed = Deployment(
runner="command", manager="systemd", launcher="command",
run_cmd=["/home/user/.local/bin/my-job"], run_cmd=["/home/user/.local/bin/my-job"],
env={}, env={},
description="Nightly job", description="Nightly job",
@@ -116,7 +116,7 @@ class TestUnitFromDeployed:
def test_no_repo_paths(self) -> None: def test_no_repo_paths(self) -> None:
"""Generated units must not reference repo paths.""" """Generated units must not reference repo paths."""
deployed = Deployment( deployed = Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"DATA_DIR": "/home/user/.castle/data/my-svc"}, env={"DATA_DIR": "/home/user/.castle/data/my-svc"},
description="Test", description="Test",
@@ -127,7 +127,7 @@ class TestUnitFromDeployed:
def test_exec_stop_emitted_for_compose(self) -> None: def test_exec_stop_emitted_for_compose(self) -> None:
"""A compose deployment's stop_cmd becomes ExecStop= (clean teardown).""" """A compose deployment's stop_cmd becomes ExecStop= (clean teardown)."""
deployed = Deployment( deployed = Deployment(
runner="compose", manager="systemd", launcher="compose",
run_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "up"], run_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "up"],
stop_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "down"], stop_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "down"],
description="Stack", description="Stack",
@@ -138,7 +138,7 @@ class TestUnitFromDeployed:
def test_no_exec_stop_without_stop_cmd(self) -> None: def test_no_exec_stop_without_stop_cmd(self) -> None:
"""Runners without a stop_cmd rely on SIGTERM — no ExecStop line.""" """Runners without a stop_cmd rely on SIGTERM — no ExecStop line."""
deployed = Deployment( deployed = Deployment(
runner="python", run_cmd=["/uv", "run", "x"], description="X" manager="systemd", launcher="python", run_cmd=["/uv", "run", "x"], description="X"
) )
unit = generate_unit_from_deployed("x", deployed) unit = generate_unit_from_deployed("x", deployed)
assert "ExecStop=" not in unit assert "ExecStop=" not in unit
@@ -149,7 +149,7 @@ class TestSecretEnvFile:
def test_environment_file_added_for_simple_unit(self) -> None: def test_environment_file_added_for_simple_unit(self) -> None:
deployed = Deployment( deployed = Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["/uv", "run", "my-svc"], run_cmd=["/uv", "run", "my-svc"],
env={"PORT": "9001"}, env={"PORT": "9001"},
secret_env_keys=["API_KEY"], secret_env_keys=["API_KEY"],
@@ -162,7 +162,7 @@ class TestSecretEnvFile:
def test_environment_file_added_for_oneshot_job(self) -> None: def test_environment_file_added_for_oneshot_job(self) -> None:
deployed = Deployment( deployed = Deployment(
runner="command", manager="systemd", launcher="command",
run_cmd=["/bin/job"], run_cmd=["/bin/job"],
env={}, env={},
schedule="0 2 * * *", schedule="0 2 * * *",
@@ -174,14 +174,14 @@ class TestSecretEnvFile:
assert f"EnvironmentFile={path}" in unit assert f"EnvironmentFile={path}" in unit
def test_no_environment_file_when_none(self) -> None: def test_no_environment_file_when_none(self) -> None:
deployed = Deployment(runner="python", run_cmd=["/uv", "run", "x"], env={}) deployed = Deployment(manager="systemd", launcher="python", run_cmd=["/uv", "run", "x"], env={})
unit = generate_unit_from_deployed("x", deployed, env_file=None) unit = generate_unit_from_deployed("x", deployed, env_file=None)
assert "EnvironmentFile" not in unit assert "EnvironmentFile" not in unit
def test_secret_values_never_in_unit(self) -> None: def test_secret_values_never_in_unit(self) -> None:
"""The unit references the file path; resolved secret values never appear.""" """The unit references the file path; resolved secret values never appear."""
deployed = Deployment( deployed = Deployment(
runner="python", manager="systemd", launcher="python",
run_cmd=["/uv", "run", "x"], run_cmd=["/uv", "run", "x"],
env={"PORT": "9001"}, env={"PORT": "9001"},
secret_env_keys=["API_KEY"], secret_env_keys=["API_KEY"],
@@ -195,16 +195,16 @@ class TestUnitEnvFile:
"""unit_env_file decides which runners get an EnvironmentFile= path.""" """unit_env_file decides which runners get an EnvironmentFile= path."""
def test_none_without_secrets(self) -> None: def test_none_without_secrets(self) -> None:
d = Deployment(runner="python", run_cmd=[], env={}, secret_env_keys=[]) d = Deployment(manager="systemd", launcher="python", run_cmd=[], env={}, secret_env_keys=[])
assert unit_env_file(d, "x") is None assert unit_env_file(d, "x") is None
def test_path_for_python_with_secrets(self) -> None: def test_path_for_python_with_secrets(self) -> None:
d = Deployment(runner="python", run_cmd=[], env={}, secret_env_keys=["K"]) d = Deployment(manager="systemd", launcher="python", run_cmd=[], env={}, secret_env_keys=["K"])
assert unit_env_file(d, "x") == secret_env_path("x") assert unit_env_file(d, "x") == secret_env_path("x")
def test_none_for_container(self) -> None: def test_none_for_container(self) -> None:
"""Containers load secrets via docker --env-file, not systemd.""" """Containers load secrets via docker --env-file, not systemd."""
d = Deployment(runner="container", run_cmd=[], env={}, secret_env_keys=["K"]) d = Deployment(manager="systemd", launcher="container", run_cmd=[], env={}, secret_env_keys=["K"])
assert unit_env_file(d, "x") is None assert unit_env_file(d, "x") is None

View File

@@ -28,9 +28,9 @@ def _registry(
), ),
deployed=deployed deployed=deployed
or { or {
"app": Deployment(runner="python", run_cmd=["x"], port=9001, "app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
subdomain="app", public=True), subdomain="app", public=True),
"private": Deployment(runner="python", run_cmd=["y"], port=9002, "private": Deployment(manager="systemd", launcher="python", run_cmd=["y"], port=9002,
subdomain="private", public=False), subdomain="private", public=False),
}, },
) )
@@ -60,7 +60,7 @@ def test_private_service_not_in_public_dns() -> None:
def test_none_when_no_public_services() -> None: def test_none_when_no_public_services() -> None:
only_private = { only_private = {
"x": Deployment(runner="python", run_cmd=["x"], subdomain="x", public=False) "x": Deployment(manager="systemd", launcher="python", run_cmd=["x"], subdomain="x", public=False)
} }
assert generate_tunnel_config(_registry(deployed=only_private)) is None assert generate_tunnel_config(_registry(deployed=only_private)) is None
@@ -74,7 +74,7 @@ def test_public_static_frontend_gets_ingress() -> None:
# A `static` (frontend) service can be public too — the toggle composes for # A `static` (frontend) service can be public too — the toggle composes for
# any exposed deployment, process-backed or not. # any exposed deployment, process-backed or not.
reg = _registry(deployed={ reg = _registry(deployed={
"guestbook": Deployment(runner="static", run_cmd=[], subdomain="guestbook", "guestbook": Deployment(manager="caddy", run_cmd=[], subdomain="guestbook",
static_root="/data/repos/guestbook/public", public=True), static_root="/data/repos/guestbook/public", public=True),
}) })
hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"] hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"]

View File

@@ -20,10 +20,12 @@ is self-sufficient. The mesh is optional.
Castle service is just a well-behaved Unix daemon that happens to Castle service is just a well-behaved Unix daemon that happens to
be registered in a manifest. be registered in a manifest.
3. **Stack and behavior.** Each program has a *stack* (development 3. **Stack and kind.** Each program has an optional *stack* (development
toolchain: python-fastapi, python-cli, react-vite) and a *behavior* toolchain: python-fastapi, python-cli, react-vite). How it's realized is a
(runtime role: daemon, tool, frontend). Scheduling, systemd management, property of its *deployment*, whose **manager** (`systemd`/`caddy`/`path`/
and proxying are orthogonal operations — not behaviors. `none`) determines the derived **kind** (service, job, tool, static,
reference). Scheduling, systemd management, and proxying are orthogonal
operations — not kinds.
4. **Language-agnostic above the build line.** Below the build line, 4. **Language-agnostic above the build line.** Below the build line,
every language is different (uv, pnpm, cargo, go). Above it, every language is different (uv, pnpm, cargo, go). Above it,
@@ -84,17 +86,20 @@ and a list of output paths. This works for any language without Castle
needing to understand the toolchain. needing to understand the toolchain.
For interpreted languages (Python, Node), Castle also needs to know the For interpreted languages (Python, Node), Castle also needs to know the
runtime wrapper — how to invoke the artifact. This is what the `run` runtime wrapper — how to invoke the artifact. For a `manager: systemd`
spec's runner variants handle: deployment this is the nested `run:` block's **launcher** variants:
- `python` — Python (sync via uv, deploy resolves installed binary) - `python` — Python (sync via uv, deploy resolves installed binary)
- `node` — Node.js (sync via pnpm/npm) - `node` — Node.js (sync via pnpm/npm)
- `command` — Direct execution (compiled binaries, shell scripts) - `command` — Direct execution (compiled binaries, shell scripts)
- `container` — Docker/Podman - `container` — Docker/Podman
- `remote` — External service (no local process) - `compose` — a multi-container stack as one unit
Compiled languages (Rust, Go) use `command` — once built, they're just (A non-systemd deployment has no launcher: `manager: caddy` serves files,
binaries. No Castle-specific runner needed. `manager: path` installs a CLI, `manager: none` is an external reference.)
Compiled languages (Rust, Go) use the `command` launcher — once built, they're
just binaries. No Castle-specific launcher needed.
### Runtime Layer ### Runtime Layer
@@ -150,22 +155,21 @@ answers: "is it working?"
#### Source vs. runtime split #### Source vs. runtime split
These map to two files: These map to the config root (`castle.yaml` globals plus per-resource files
under `programs/` and `deployments/`), version-controlled in the repo:
**`castle.yaml`** (in the repo, version-controlled) — Three sections:
```yaml ```yaml
programs: # programs/central-context.yaml — what software exists
central-context:
description: Content storage API description: Content storage API
source: /data/repos/central-context source: /data/repos/central-context
```
services: ```yaml
central-context: # deployments/central-context.yaml — manager: systemd → kind: service
program: central-context program: central-context
manager: systemd
run: run:
runner: python launcher: python
tool: central-context program: central-context
expose: expose:
http: http:
internal: { port: 9001 } internal: { port: 9001 }
@@ -173,25 +177,28 @@ services:
proxy: true # expose at central-context.<gateway.domain> proxy: true # expose at central-context.<gateway.domain>
manage: manage:
systemd: {} systemd: {}
```
jobs: ```yaml
backup-collect: # deployments/backup-collect.yaml — manager: systemd + schedule → kind: job
program: backup-collect program: backup-collect
manager: systemd
run: run:
runner: command launcher: command
argv: [backup-collect] argv: [backup-collect]
schedule: "0 2 * * *" schedule: "0 2 * * *"
manage: manage:
systemd: {} systemd: {}
``` ```
Programs define *what software exists* (identity, source, install, tools). Programs define *what software exists* (identity, source, build).
Services define *how daemons run* (run config, expose, proxy, systemd). Deployments define *how a program is realized on this node* — a single
Jobs define *how scheduled tasks run* (run config, cron schedule, systemd). `manager`-discriminated entry whose derived kind (service/job/tool/static/
reference) captures whether it's an always-on daemon, a scheduled task, a CLI on
PATH, a served frontend, or an external reference.
Services and jobs can reference a program via `program:` for description A deployment can reference a program via `program:` for description fallthrough
fallthrough and source code linking. They can also exist independently and source code linking. It can also exist independently (e.g., `castle-gateway`
(e.g., `castle-gateway` runs Caddy — not our software). runs Caddy — not our software).
A service's env is exactly its `defaults.env` — castle injects nothing A service's env is exactly its `defaults.env` — castle injects nothing
implicitly. Values may use `${port}`/`${data_dir}`/`${name}`/`${secret:…}` implicitly. Values may use `${port}`/`${data_dir}`/`${name}`/`${secret:…}`
@@ -206,16 +213,17 @@ node:
gateway_port: 9000 gateway_port: 9000
deployed: deployed:
central-context: central-context:
runner: python manager: systemd
launcher: python
run_cmd: [/home/user/.local/bin/central-context] run_cmd: [/home/user/.local/bin/central-context]
env: env:
CENTRAL_CONTEXT_DATA_DIR: /home/user/.castle/data/central-context CENTRAL_CONTEXT_DATA_DIR: /home/user/.castle/data/central-context
CENTRAL_CONTEXT_PORT: "9001" CENTRAL_CONTEXT_PORT: "9001"
behavior: daemon kind: service
stack: python-fastapi stack: python-fastapi
port: 9001 port: 9001
health_path: /health health_path: /health
proxy_path: /central-context subdomain: central-context
managed: true managed: true
``` ```
@@ -339,11 +347,11 @@ Daemons · Long-running processes that expose ports
└──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
Components · Software catalog Components · Software catalog
Name Stack Behavior Schedule Status Name Stack Kind Schedule Status
pdf2md Python / CLI tool — installed pdf2md Python / CLI tool — installed
protonmail Python / CLI tool */5 * * * * installed protonmail Python / CLI tool */5 * * * * installed
castle React / Vite frontend — — castle React / Vite static — —
backup-collect Python / CLI tool 0 2 * * * — backup-collect Python / CLI job 0 2 * * * —
``` ```
**Key programs:** **Key programs:**
@@ -355,8 +363,8 @@ Components · Software catalog
- **NodeBar** — Horizontal list of discovered nodes. Hidden in single-node - **NodeBar** — Horizontal list of discovered nodes. Hidden in single-node
mode. Each node links to `/node/{hostname}`. mode. Each node links to `/node/{hostname}`.
- **ServiceSection** — Daemon cards in a responsive grid. - **ServiceSection** — Daemon cards in a responsive grid.
- **ComponentTable** — Unified sortable table for all non-daemon programs - **ComponentTable** — Unified sortable table for all non-daemon deployments
(tools, frontends) with Stack, Behavior, Schedule, and Status columns. (tools, statics) with Stack, Kind, Schedule, and Status columns.
**Real-time updates:** **Real-time updates:**
- SSE stream at `/stream` pushes `health`, `service-action`, and `mesh` - SSE stream at `/stream` pushes `health`, `service-action`, and `mesh`
@@ -444,7 +452,7 @@ data; default `/data/castle`, kept on a dedicated volume):
``` ```
$CASTLE_HOME/ ← Config & artifacts (default ~/.castle) $CASTLE_HOME/ ← Config & artifacts (default ~/.castle)
├── castle.yaml ← Registry spec (programs, services, jobs) ├── castle.yaml ← Global settings; resources under programs/ + deployments/
├── infra.conf ← Infrastructure install choices ├── infra.conf ← Infrastructure install choices
├── code/ ← Program source (your programs) ├── code/ ← Program source (your programs)
│ └── <name>/ │ └── <name>/
@@ -558,7 +566,7 @@ What exists today:
What doesn't exist yet: What doesn't exist yet:
- **Multi-language support** — Rust and Go programs (the abstractions - **Multi-language support** — Rust and Go programs (the abstractions
support them via `command` runner, but no examples exist yet) support them via the `command` launcher, but no examples exist yet)
- **Build automation** — Castle records build specs but doesn't - **Build automation** — Castle records build specs but doesn't
orchestrate builds (each project builds independently) orchestrate builds (each project builds independently)
- **Multi-machine testing** — Mesh infrastructure is built and running - **Multi-machine testing** — Mesh infrastructure is built and running

View File

@@ -141,7 +141,7 @@ a DNS token.
- **`acme` only — a provider API token.** Store it as a secret - **`acme` only — a provider API token.** Store it as a secret
(`~/.castle/secrets/<TOKEN_NAME>`, scope: the DNS provider's "edit DNS records" (`~/.castle/secrets/<TOKEN_NAME>`, scope: the DNS provider's "edit DNS records"
permission for your zone) and map it into the gateway service env in permission for your zone) and map it into the gateway service env in
`services/castle-gateway.yaml` (`defaults.env`), so Caddy reads it as `deployments/castle-gateway.yaml` (`defaults.env`), so Caddy reads it as
`{env.<TOKEN_NAME>}`. `castle deploy` warns if the domain, env var, or secret is `{env.<TOKEN_NAME>}`. `castle deploy` warns if the domain, env var, or secret is
missing. missing.
- **`acme` — stage first.** Set `CASTLE_ACME_STAGING=1` at deploy to use Let's - **`acme` — stage first.** Set `CASTLE_ACME_STAGING=1` at deploy to use Let's

View File

@@ -1,6 +1,6 @@
# Registry # Registry
How castle tracks, configures, and manages programs, services, and jobs. How castle tracks, configures, and manages programs and their deployments.
This is the central reference for `castle.yaml` structure and the registry This is the central reference for `castle.yaml` structure and the registry
architecture. architecture.
@@ -9,27 +9,31 @@ architecture.
Use these terms consistently across code, CLI, API, and docs. Use these terms consistently across code, CLI, API, and docs.
- **program** — any project castle manages, regardless of what it does. The - **program** — any project castle manages, regardless of what it does. The
software catalog (`programs:`). Every program has a **behavior** and an software catalog (`programs/`). Every program has an optional **stack**.
optional **stack**. *("component" was the old name for program — don't use it.)* *("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`, - **stack** — a creation-time toolchain + scaffold template (`python-cli`,
`python-fastapi`, `react-vite`). Optional; seeds a program's default dev `python-fastapi`, `react-vite`). Optional; seeds a program's default dev
commands but isn't required at runtime. commands but isn't required at runtime.
- **service** — a program deployed as a long-running systemd `.service` - **deployment** — a program materialized into this node's runtime
(`services:`). (`deployments/`). Every deployment is discriminated on its **`manager`**.
- **job** — a program deployed as a scheduled systemd `.timer` (+ oneshot) - **manager** — who supervises or realizes a deployment: `systemd` (a process,
(`jobs:`). or with a `schedule` a `.timer`), `caddy` (a gateway static file_server
- **deployment** — the umbrella for "a service or a job" (a program materialized route), `path` (a CLI installed on PATH via `uv tool install`), or `none`
into the runtime). The registry's deployed entries are deployments. (an external remote reference). The manager is the deployment's stored
discriminant.
- **launcher** — for `manager: systemd` only, the process-launch mechanism in
the nested `run:` block: `python` | `command` | `container` | `compose` |
`node`. Non-systemd managers have no `run:`/launcher.
- **kind** — the human-facing label, **derived** from the manager (+ schedule),
never stored: systemd+`schedule`**job**, systemd → **service**, caddy →
**static**, path → **tool**, none → **reference**. *(kind replaces the old
`behavior`; the old `frontend` kind is now `static`.)*
**Two orthogonal axes.** *behavior* (tool/daemon/frontend) is **what** a program **Two orthogonal axes.** *manager* is **who** realizes a deployment; *kind* is
is; *service/job* is **how/when** it's deployed. They're independent: a program the **derived** label describing what it is. A program may have no deployment (a
may have neither (a tool you just install), a **service** (always-on), or a program you just develop), a **service** (always-on), a **job** (scheduled), a
**job** (scheduled). A `daemon`-behavior program is usually deployed as a **tool** (installed on PATH), or a **static** (a built frontend served by the
service; a `tool`-behavior program may back a job or just be installed for gateway). A single `deployments/<name>.yaml` file carries the whole thing.
manual use.
## Configuration Directory Layout ## Configuration Directory Layout
@@ -40,10 +44,11 @@ Castle splits its configuration across a root directory (`~/.castle/` or your co
├── castle.yaml # Global settings (gateway, repo, etc.) ├── castle.yaml # Global settings (gateway, repo, etc.)
├── programs/ # Program configuration files (one file per program) ├── programs/ # Program configuration files (one file per program)
│ └── my-tool.yaml │ └── my-tool.yaml
── services/ # Service configuration files (one file per service) ── deployments/ # Deployment configuration files (one file per deployment)
── my-service.yaml ── my-service.yaml # manager: systemd → kind: service
└── jobs/ # Job configuration files (one file per job) ├── nightly.yaml # manager: systemd + schedule → kind: job
── my-job.yaml ── my-tool.yaml # manager: path → kind: tool
└── my-app.yaml # manager: caddy → kind: static
``` ```
### castle.yaml (Globals) ### castle.yaml (Globals)
@@ -56,25 +61,23 @@ gateway:
repo: /data/repos/castle repo: /data/repos/castle
``` ```
### Resource Configuration Files (`programs/`, `services/`, `jobs/`) ### Resource Configuration Files (`programs/`, `deployments/`)
Each resource (program, service, or job) is configured in its own YAML file named after the resource's unique ID (e.g., `services/my-service.yaml` defines the service `my-service`). Each resource (a program or a deployment) is configured in its own YAML file named after the resource's unique ID (e.g., `deployments/my-service.yaml` defines the deployment `my-service`).
**programs/my-tool.yaml:** **programs/my-tool.yaml:**
```yaml ```yaml
description: Does something useful description: Does something useful
source: /data/repos/my-tool source: /data/repos/my-tool
stack: python-cli stack: python-cli
behavior: tool
system_dependencies: [pandoc] system_dependencies: [pandoc]
``` ```
**services/my-service.yaml:** **deployments/my-service.yaml** (a service — `manager: systemd`, no schedule):
```yaml ```yaml
program: my-service program: my-service
run: manager: systemd
runner: python run: { launcher: python, program: my-service }
program: my-service
expose: expose:
http: http:
internal: { port: 9001 } internal: { port: 9001 }
@@ -84,43 +87,47 @@ manage:
systemd: {} systemd: {}
``` ```
**jobs/my-job.yaml:** **deployments/nightly.yaml** (a job — `manager: systemd` + `schedule`):
```yaml ```yaml
program: my-tool program: my-tool
run: manager: systemd
runner: command run: { launcher: command, argv: [my-tool, sync] }
argv: [my-tool, sync]
schedule: "0 2 * * *" schedule: "0 2 * * *"
manage: manage:
systemd: {} systemd: {}
``` ```
**deployments/my-tool.yaml** (a tool — `manager: path`, no `run:`):
```yaml
program: my-tool
manager: path
```
**deployments/my-app.yaml** (a static frontend — `manager: caddy`, no `run:`):
```yaml
program: my-app
manager: caddy
root: dist
```
### Resource Categories ### Resource Categories
| Category | Location | Purpose | Role / Types | | Category | Location | Purpose | Kinds (derived) |
|----------|----------|---------|--------------| |----------|----------|---------|-----------------|
| **programs** | `programs/*.yaml` | Software catalog — what software exists | tool, frontend, daemon | | **programs** | `programs/*.yaml` | Software catalog — what software exists | |
| **services** | `services/*.yaml` | Long-running daemons — how they run | service | | **deployments** | `deployments/*.yaml` | How a program is realized on this node | service, job, tool, static, reference |
| **jobs** | `jobs/*.yaml` | Scheduled tasks — when they run | job |
Services and jobs can reference a program via `program:` for description A deployment can reference a program via `program:` for description fallthrough
fallthrough and source code linking. They can also exist independently and source code linking. It can also exist independently (e.g., `castle-gateway`
(e.g., `castle-gateway` runs Caddy — not our software). runs Caddy — not our software). The **kind** is derived from `manager` (+
`schedule`), never stored.
## Program blocks ## Program blocks
Programs define **what software exists** — identity, source, behavior, builds. Programs define **what software exists** — identity, source, builds. How a
program is *used* is not a program property: it's decided by its deployment's
### `behavior` — What role this program plays `manager` and surfaces as the derived **kind** (service/job/tool/static/reference).
A program with no deployment is just source castle knows how to develop.
```yaml
behavior: daemon # or: tool, frontend
```
Explicit declaration of how the program is used:
- **daemon** — long-running service (python-fastapi stack)
- **tool** — CLI utility (python-cli stack)
- **frontend** — web UI (react-vite stack)
### `source` — Where the source lives ### `source` — Where the source lives
@@ -188,7 +195,7 @@ system_dependencies: [pandoc, poppler-utils]
``` ```
System packages that must be installed for the program to work. Displayed System packages that must be installed for the program to work. Displayed
in `castle program list --behavior tool` and the dashboard. in `castle list --kind tool` and the dashboard.
### `version` — Program version ### `version` — Program version
@@ -208,63 +215,68 @@ build:
- dist/ - dist/
``` ```
Programs with build outputs are typically frontends. Programs with build outputs are typically served as **static** deployments.
## Service blocks ## Deployment blocks
Services define **how long-running daemons are deployed**. Deployments define **how a program is realized on this node**. Every deployment
declares a **`manager`** — who makes it available and supervises its lifecycle:
### `run` — How to start it (required) ### `manager` — Who realizes it (the discriminant)
A deployment (service/job) is a *managed materialization* of a program. Its A deployment is a *managed materialization* of a program. Its **`manager`** is
**runner** determines the **manager** — who makes it available and supervises its the stored discriminant — the single axis lifecycle, deploy, and status all
lifecycle: dispatch on:
| Manager | Makes available as | Runners | start/stop | | Manager | Makes available as | Launch mechanism | start/stop | Kind |
|---------|--------------------|---------|------------| |---------|--------------------|------------------|------------|------|
| **systemd** | a running process (or a `.timer` for jobs) | `python`, `command`, `container`, `compose`, `node` | `systemctl` | | **systemd** | a running process (or a `.timer` for jobs) | nested `run: { launcher: … }` | `systemctl` | service / job |
| **caddy** | a gateway route | `static` (file_server) — and any exposed process (reverse_proxy) | add/remove route + reload | | **caddy** | a gateway static file_server route | *(none — files on disk; `root:`)* | add/remove route + reload | static |
| **path** | an installed CLI on `PATH` | `path` | `uv tool install` / `uninstall` | | **path** | an installed CLI on `PATH` | *(none — `uv tool install`)* | `uv tool install` / `uninstall` | tool |
| **none** | an external reference | `remote` | *(nothing — not ours)* | | **none** | an external reference | *(none; `base_url:`/`health_url:`)* | *(nothing — not ours)* | reference |
`manager_for(runner)` (in `manifest.py`) is the single source of truth; lifecycle, The **kind** (service/job/tool/static/reference) is **derived** from `manager` (+
deploy, and status all dispatch on it. `behavior` (`tool`/`daemon`/`frontend`) is a `schedule`) — it never drives logic and is never stored. `DeploymentSpec` is a
**derived display label only** — it never drives logic. A program is a *tool* when discriminated union on `manager` (SystemdDeployment/CaddyDeployment/
it has a `path` service, a *frontend* when it has a `static` service, a *daemon* PathDeployment/RemoteDeployment); see [Manifest models](#manifest-models).
when it has a process service.
Discriminated union on `runner`: ### `run` — How to launch it (systemd only)
| Runner | Manager | Deploy | Key fields | For `manager: systemd` **only**, the nested `run:` block carries a **`launcher`**
|--------|---------|--------|------------| — the process-launch mechanism. Non-systemd managers have no `run:`/launcher;
| `python` | systemd | `uv run --project <source> --no-dev <program>` | `program`, `args` | their fields live directly on the deployment (caddy has `root:`, none has
| `command` | systemd | `which(argv[0])` → resolved path | `argv` | `base_url:`/`health_url:`).
| `container` | systemd | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
| `compose` | systemd | `docker compose -p <project> -f <file> up` (+ `ExecStop=down`) | `file`, `project_name` |
| `node` | systemd | `package_manager run script` | `script`, `package_manager` |
| `static` | caddy | *(no process — `file_server` from `<source>/<root>`)* | `root` |
| `path` | path | *(no process — `uv tool install` at enable time)* | *(the referenced program)* |
| `remote` | none | *(none — no local process)* | `base_url`, `health_url` |
A `python` service runs **in place from its own project venv** via `uv run`, which Nested launch spec, discriminated union on `launcher`:
| Launcher | Deploy | Key fields |
|----------|--------|------------|
| `python` | `uv run --project <source> --no-dev <program>` | `program`, `args` |
| `command` | `which(argv[0])` → resolved path | `argv` |
| `container` | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
| `compose` | `docker compose -p <project> -f <file> up` (+ `ExecStop=down`) | `file`, `project_name` |
| `node` | `package_manager run script` | `script`, `package_manager` |
A `python` launcher runs **in place from its own project venv** via `uv run`, which
syncs the env to the project's lockfile before launching. There is no separate syncs the env to the project's lockfile before launching. There is no separate
tool venv and no `uv tool install` step: **a restart picks up both code and tool venv and no `uv tool install` step: **a restart picks up both code and
dependency changes** (the deploy-time `ExecStart` is deterministic from `source`, dependency changes** (the deploy-time `ExecStart` is deterministic from `source`,
so it never goes stale). `uv tool install` is reserved for `tool`-behavior so it never goes stale). `uv tool install` is reserved for `manager: path`
programs, where being on a human's PATH is the point. If a `python` service deployments (tools), where being on a human's PATH is the point. If a `python`
declares a `program` with no resolvable `source`, deploy falls back to a PATH launcher declares a `program` with no resolvable `source`, deploy falls back to a
lookup of the script. PATH lookup of the script.
```yaml ```yaml
manager: systemd
run: run:
runner: python launcher: python
program: my-service # name in [project.scripts] program: my-service # name in [project.scripts]
``` ```
A `compose` service supervises a **whole multi-container stack as one systemd A `compose` launcher supervises a **whole multi-container stack as one systemd
unit** — `ExecStart` runs `docker compose … up` attached (`Type=simple`) and a unit** — `ExecStart` runs `docker compose … up` attached (`Type=simple`) and a
generated `ExecStop` runs `… down` so networks/anonymous volumes are reclaimed on generated `ExecStop` runs `… down` so networks/anonymous volumes are reclaimed on
stop. Unlike the single-container `container` runner, compose owns the stack's own stop. Unlike the single-container `container` launcher, compose owns the stack's own
networking, startup ordering, and per-service health — Castle delegates rather networking, startup ordering, and per-service health — Castle delegates rather
than reinventing orchestration. Secrets/env reach compose through the unit's than reinventing orchestration. Secrets/env reach compose through the unit's
`Environment=`/`EnvironmentFile=` (from `defaults.env`), which compose interpolates `Environment=`/`EnvironmentFile=` (from `defaults.env`), which compose interpolates
@@ -272,12 +284,30 @@ from the process environment. This is what runs the shared **Supabase substrate*
(see @docs/stacks/supabase.md). (see @docs/stacks/supabase.md).
```yaml ```yaml
manager: systemd
run: run:
runner: compose launcher: compose
file: docker-compose.yml # resolved under the program source file: docker-compose.yml # resolved under the program source
# project_name: castle-my-stack # optional; defaults to castle-<name> # project_name: castle-my-stack # optional; defaults to castle-<name>
``` ```
### `root` — Static frontend (caddy only)
For `manager: caddy`, `root:` names the built-frontend directory (relative to the
program source) that the gateway serves via `file_server`. There is no process
and no `run:` block.
```yaml
manager: caddy
root: dist # served at <name>.<gateway.domain>
```
### `base_url` / `health_url` — Remote reference (none only)
For `manager: none`, the deployment is an external reference — a service on
another node — with no local process. It carries `base_url:` and `health_url:`
directly.
### `expose` — What it exposes ### `expose` — What it exposes
```yaml ```yaml
@@ -324,8 +354,8 @@ routes is gone). Caddy proxies WebSocket upgrades transparently.
| Kind | Target | Declared by | | Kind | Target | Declared by |
|------|--------|-------------| |------|--------|-------------|
| **proxy** | a local service on a port — Caddy `reverse_proxy localhost:PORT` | a service's `proxy.caddy` | | **proxy** | a local service on a port — Caddy `reverse_proxy localhost:PORT` | a service's `proxy: true` |
| **static** | a built frontend's `dist/` — Caddy `file_server` (no process) | a `frontend` program with `build.outputs` and **no** service (auto-exposed at `<name>.<domain>`) | | **static** | a built frontend's `dist/` — Caddy `file_server` (no process) | a `manager: caddy` deployment (kind **static**) with a `root:` (served at `<name>.<domain>`) |
| **remote** | a service on another node | mesh discovery (out of scope of the single-node gateway) | | **remote** | a service on another node | mesh discovery (out of scope of the single-node gateway) |
"Serving a frontend" and "proxying a service" are the same thing — a subdomain "Serving a frontend" and "proxying a service" are the same thing — a subdomain
@@ -431,7 +461,7 @@ Setup (the parts castle can't do for you):
installs to `/usr/local/bin/caddy`, which the gateway picks up on next deploy). installs to `/usr/local/bin/caddy`, which the gateway picks up on next deploy).
- **Provider token.** Store a scoped API token as the `CLOUDFLARE_API_TOKEN` - **Provider token.** Store a scoped API token as the `CLOUDFLARE_API_TOKEN`
secret (Cloudflare scope: **Zone → DNS → Edit**), and map it into the gateway secret (Cloudflare scope: **Zone → DNS → Edit**), and map it into the gateway
service env — add to `services/castle-gateway.yaml`: service env — add to `deployments/castle-gateway.yaml`:
```yaml ```yaml
defaults: defaults:
env: env:
@@ -511,12 +541,14 @@ repeating castle's computed paths/ports. `castle program create` scaffolds the
`${port}`/`${data_dir}` lines for new services. Never store secrets in `${port}`/`${data_dir}` lines for new services. Never store secrets in
castle.yaml — use `${secret:…}`. castle.yaml — use `${secret:…}`.
## Job blocks ## Job fields
Jobs define **how scheduled tasks run**. Same blocks as services plus A **job** is just a `manager: systemd` deployment that also carries a
`schedule` and `timezone`. `schedule` — the derived kind flips from service to job. Same blocks as a
service (nested `run:` launch, `manage`, `defaults`) plus `schedule` and
`timezone`.
### `schedule` — Cron expression (required) ### `schedule` — Cron expression (required for a job)
```yaml ```yaml
schedule: "*/5 * * * *" schedule: "*/5 * * * *"
@@ -525,11 +557,6 @@ timezone: America/Los_Angeles # default
Castle generates a systemd `.timer` file alongside the `.service` unit. Castle generates a systemd `.timer` file alongside the `.service` unit.
### Other blocks
Jobs also support `run` (required), `manage`, and `defaults` — same
semantics as services.
## How programs get into `/data/repos/` ## How programs get into `/data/repos/`
Every program's source lives under `$CASTLE_REPOS_DIR` (default `/data/repos/<name>/`). Every program's source lives under `$CASTLE_REPOS_DIR` (default `/data/repos/<name>/`).
@@ -563,31 +590,33 @@ castle program create my-tool --stack python-cli --description "Does something"
### Manually ### Manually
Clone or create the project under `/data/repos/`, then add entries to the Clone or create the project under `/data/repos/`, then add a `programs/<name>.yaml`
appropriate sections of `castle.yaml`: file (plus a `deployments/<name>.yaml` file if it's deployed):
```yaml ```yaml
# Tool — only needs a program entry # Tool — programs/my-tool.yaml (a program)
programs:
my-tool:
description: Does something useful description: Does something useful
source: /data/repos/my-tool source: /data/repos/my-tool
stack: python-cli stack: python-cli
behavior: tool ```
```yaml
# Tool — deployments/my-tool.yaml (installed on PATH → kind: tool)
program: my-tool
manager: path
```
# Service — needs both program and service entries ```yaml
programs: # Service — programs/my-service.yaml (a program)
my-service:
description: Does something useful description: Does something useful
source: /data/repos/my-service source: /data/repos/my-service
stack: python-fastapi stack: python-fastapi
behavior: daemon ```
```yaml
services: # Service — deployments/my-service.yaml (manager: systemd → kind: service)
my-service:
program: my-service program: my-service
manager: systemd
run: run:
runner: python launcher: python
program: my-service program: my-service
expose: expose:
http: http:
@@ -632,14 +661,15 @@ uv tool install --editable /data/repos/my-tool/ # 4. Install to PATH
### Job lifecycle ### Job lifecycle
Jobs are defined in the `jobs:` section with a `run` spec and `schedule`: Jobs are deployments with `manager: systemd` plus a `schedule` — a
`deployments/my-job.yaml` file with a nested `run:` launch block:
```yaml ```yaml
jobs: # deployments/my-job.yaml (manager: systemd + schedule → kind: job)
my-job: program: my-job
description: Runs nightly manager: systemd
run: run:
runner: command launcher: command
argv: ["my-job"] argv: ["my-job"]
schedule: "0 2 * * *" schedule: "0 2 * * *"
manage: manage:
@@ -683,16 +713,18 @@ convention.
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes: The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `ProgramSpec` — software catalog entry (source, behavior, stack, build, system_dependencies) - `ProgramSpec` — software catalog entry (source, stack, build, system_dependencies)
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults) - `DeploymentSpec` — a deployment, a discriminated union on `manager`:
- `JobSpec` — scheduled task (run, schedule, manage, defaults) `SystemdDeployment` (service/job — run, expose, proxy, schedule, manage,
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunCompose, RunNode, RunRemote) defaults), `CaddyDeployment` (static — root), `PathDeployment` (tool),
`RemoteDeployment` (reference — base_url, health_url)
- `LaunchSpec` — the nested `run:` block (systemd only), a discriminated union on
`launcher` (LaunchPython, LaunchCommand, LaunchContainer, LaunchCompose, LaunchNode)
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `BuildSpec` - `ExposeSpec`, `ProxySpec`, `ManageSpec`, `BuildSpec`
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal` - `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
Config loading: `core/src/castle_core/config.py` — `load_config()` parses Config loading: `core/src/castle_core/config.py` — `load_config()` parses the
castle.yaml into `CastleConfig` with typed `programs`, `services`, and config root into `CastleConfig` with typed `programs` and `deployments` dicts.
`jobs` dicts.
Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer
generation (`systemd.py`) and Caddyfile generation (`caddyfile.py`). generation (`systemd.py`) and Caddyfile generation (`caddyfile.py`).

View File

@@ -318,30 +318,33 @@ uv run ruff check . # Lint
uv run ruff format . # Format uv run ruff format . # Format
``` ```
## Registering in castle.yaml ## Registering in the registry
```yaml ```yaml
programs: # programs/my-tool.yaml
my-tool:
description: Does something useful description: Does something useful
source: /data/repos/my-tool source: /data/repos/my-tool
stack: python-cli stack: python-cli
behavior: tool ```
```yaml
# deployments/my-tool.yaml (manager: path → kind: tool)
program: my-tool
manager: path
``` ```
Tools with system dependencies declare them directly on the program: Tools with system dependencies declare them directly on the program:
```yaml ```yaml
programs: # programs/pdf2md.yaml
pdf2md:
description: Convert PDF files to Markdown description: Convert PDF files to Markdown
source: /data/repos/pdf2md source: /data/repos/pdf2md
stack: python-cli stack: python-cli
behavior: tool
system_dependencies: [pandoc, poppler-utils] system_dependencies: [pandoc, poppler-utils]
``` ```
Tools live in the `programs:` section. If a tool also runs on a schedule, A tool is a `programs/<name>.yaml` entry plus a `deployments/<name>.yaml` with
add a separate entry in the `jobs:` section referencing the program. `manager: path` (derived **kind: tool**). If a tool also runs on a schedule, add
a *second* deployment with `manager: systemd` + `schedule` (derived **kind:
job**) referencing the same program.
See @docs/registry.md for the full registry reference. See @docs/registry.md for the full registry reference.

View File

@@ -103,21 +103,20 @@ class Settings(BaseSettings):
settings = Settings() settings = Settings()
``` ```
Castle passes config via env vars in castle.yaml: Castle passes config via env vars in the deployment's `defaults.env`:
```yaml ```yaml
programs: # programs/my-service.yaml
my-service:
description: Does something useful description: Does something useful
source: /data/repos/my-service source: /data/repos/my-service
stack: python-fastapi stack: python-fastapi
behavior: daemon ```
```yaml
services: # deployments/my-service.yaml (manager: systemd → kind: service)
my-service:
program: my-service program: my-service
manager: systemd
run: run:
runner: python launcher: python
program: my-service program: my-service
expose: expose:
http: http:
@@ -131,7 +130,8 @@ services:
The env a service runs with is exactly what's in `defaults.env` — castle injects The env a service runs with is exactly what's in `defaults.env` — castle injects
nothing implicitly. Map the vars your settings read (above, `env_prefix: nothing implicitly. Map the vars your settings read (above, `env_prefix:
"MY_SERVICE_"``MY_SERVICE_PORT`/`MY_SERVICE_DATA_DIR`) to castle's computed "MY_SERVICE_"``MY_SERVICE_PORT`/`MY_SERVICE_DATA_DIR`) to castle's computed
values with the `${port}`/`${data_dir}` placeholders: values with the `${port}`/`${data_dir}` placeholders — add to
`deployments/my-service.yaml`:
```yaml ```yaml
defaults: defaults:

View File

@@ -123,14 +123,13 @@ The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files.
## Registering as a program ## Registering as a program
A frontend program has a `build` spec (produces static output). Register it A frontend program has a `build` spec (produces static output). Register it in
in the `programs:` section of `castle.yaml`. No `run` block needed if Caddy `programs/<name>.yaml`, plus a `deployments/<name>.yaml` with `manager: caddy`
handles serving directly from the build output. (derived **kind: static**) that names the built directory in `root:` — no `run`
block, since Caddy serves the build output directly.
```yaml ```yaml
# castle.yaml # programs/my-frontend.yaml
programs:
my-frontend:
description: Web dashboard description: Web dashboard
source: /data/repos/my-frontend source: /data/repos/my-frontend
build: build:
@@ -139,19 +138,26 @@ programs:
outputs: outputs:
- dist/ - dist/
``` ```
```yaml
# deployments/my-frontend.yaml (manager: caddy → kind: static)
program: my-frontend
manager: caddy
root: dist # served at my-frontend.<gateway.domain>
```
For production, Caddy serves the build output **in place** from the program's For production, Caddy serves the build output **in place** from the program's
repo (`<source>/<build.outputs[0]>`) — no Node process and no copy into a repo (`<source>/<root>`) — no Node process and no copy into a central directory.
central directory. See [Serving with Caddy](#serving-with-caddy) below. See [Serving with Caddy](#serving-with-caddy) below.
For development with Vite's dev server, add a service entry: For development with Vite's dev server, use a `manager: systemd` deployment with
the `node` launcher instead:
```yaml ```yaml
services: # deployments/my-frontend.yaml (dev — manager: systemd, node launcher → kind: service)
my-frontend:
program: my-frontend program: my-frontend
manager: systemd
run: run:
runner: node launcher: node
script: dev script: dev
package_manager: pnpm package_manager: pnpm
expose: expose:
@@ -170,10 +176,10 @@ The flow:
1. You build the frontend with `castle program build <name>` (deploy does **not** 1. You build the frontend with `castle program build <name>` (deploy does **not**
build — it only points Caddy at `dist/`). build — it only points Caddy at `dist/`).
2. The Caddyfile generator emits a route per `behavior: frontend` program that 2. The Caddyfile generator emits a route per `manager: caddy` deployment (kind
has `build.outputs`, rooted **in place** at `<source>/<build.outputs[0]>` **static**), rooted **in place** at `<source>/<root>` — no copy. Each static
no copy. Each frontend is served at its own subdomain `<name>.<gateway.domain>` frontend is served at its own subdomain `<name>.<gateway.domain>` (the
(the dashboard, `castle`, is also the target of the `:9000` redirect). dashboard, `castle`, is also the target of the `:9000` redirect).
The build is run with `VITE_BASE=/`, so a `vite.config` that reads it (see The build is run with `VITE_BASE=/`, so a `vite.config` that reads it (see
[Vite config](#vite-config)) emits root-relative asset URLs. The generated block [Vite config](#vite-config)) emits root-relative asset URLs. The generated block

View File

@@ -4,7 +4,7 @@
> web apps.** A stack is a template + conventions, not a runtime requirement. > web apps.** A stack is a template + conventions, not a runtime requirement.
> `castle program create --stack supabase` scaffolds from it and seeds the > `castle program create --stack supabase` scaffolds from it and seeds the
> program's default dev-verb commands. See @docs/registry.md for `commands:`, > program's default dev-verb commands. See @docs/registry.md for `commands:`,
> `stack:` (optional), and `behavior:`. > `stack:` (optional), and the deployment `manager` (and derived `kind`).
How to build tiny, database-backed web apps as castle programs that target a How to build tiny, database-backed web apps as castle programs that target a
**shared Supabase substrate**. This is Castle's "a stack whose default is a **shared Supabase substrate**. This is Castle's "a stack whose default is a
@@ -17,7 +17,8 @@ Unlike the other stacks (which scaffold a self-contained process), a supabase ap
is **code + migrations that deploy against a shared backend**: is **code + migrations that deploy against a shared backend**:
- **The substrate** is one castle service (`supabase`, the `supabase-substrate` - **The substrate** is one castle service (`supabase`, the `supabase-substrate`
repo) running self-hosted Supabase via the `compose` runner. It is shared by repo) running self-hosted Supabase via a `manager: systemd` deployment with the
`compose` launcher. It is shared by
every supabase app. Stand it up once (see that repo's README). every supabase app. Stand it up once (see that repo's README).
- **Each app** is a directory of `migrations/` + `functions/` + `public/` that - **Each app** is a directory of `migrations/` + `functions/` + `public/` that
deploys onto the substrate. Its rows/blobs live on the substrate; everything deploys onto the substrate. Its rows/blobs live on the substrate; everything
@@ -55,7 +56,8 @@ my-app/
└── CLAUDE.md └── CLAUDE.md
``` ```
Registered as a `behavior: frontend` program with `build.outputs: [public]`, so the Registered as a program with `build.outputs: [public]` plus a `manager: caddy`
deployment (`root: public`, derived **kind: static**), so the
gateway serves `public/` in place at `/my-app/` — no service, no process. gateway serves `public/` in place at `/my-app/` — no service, no process.
## supabase.app.yaml ## supabase.app.yaml
@@ -117,8 +119,9 @@ the function holds credentials and can meter usage.
## Gateway & secure context ## Gateway & secure context
A supabase app is a `frontend` program (its `public/` is served in place), so the A supabase app is a static deployment (`manager: caddy`; its `public/` is served
gateway serves it at its own subdomain `<name>.<gateway.domain>`. With in place), so the gateway serves it at its own subdomain
`<name>.<gateway.domain>`. With
`gateway.tls: acme` that subdomain is HTTPS — a **secure context**, which apps `gateway.tls: acme` that subdomain is HTTPS — a **secure context**, which apps
using **auth or WebCrypto** require — with no private CA to install. (The substrate using **auth or WebCrypto** require — with no private CA to install. (The substrate
service itself is likewise at `supabase.<gateway.domain>`.) See service itself is likewise at `supabase.<gateway.domain>`.) See
@@ -139,6 +142,6 @@ castle deploy && castle gateway reload # serve the static UI at /my-app/
registers the program as a static frontend. Set the anon key in `public/config.js` registers the program as a static frontend. Set the anon key in `public/config.js`
(`cat ~/.castle/secrets/SUPABASE_ANON_KEY`), edit your migrations, and build. (`cat ~/.castle/secrets/SUPABASE_ANON_KEY`), edit your migrations, and build.
See @docs/registry.md for the `compose` runner, the substrate service definition, See @docs/registry.md for the `compose` launcher, the substrate deployment definition,
and the full registry reference. The substrate itself lives in the and the full registry reference. The substrate itself lives in the
`supabase-substrate` repo (vendored, pinned self-hosted Supabase). `supabase-substrate` repo (vendored, pinned self-hosted Supabase).

View File

@@ -54,12 +54,14 @@ chmod 600 ~/.castle/secrets/cloudflared/$TID.json
# tunnel_id: <the-uuid> # tunnel_id: <the-uuid>
``` ```
Then create the tunnel service at `~/.castle/services/castle-tunnel.yaml`: Then create the tunnel deployment at `~/.castle/deployments/castle-tunnel.yaml`
(`manager: systemd` → kind: service):
```yaml ```yaml
description: Cloudflare tunnel — public exposure for public:true services description: Cloudflare tunnel — public exposure for public:true services
manager: systemd
run: run:
runner: command launcher: command
argv: argv:
- cloudflared - cloudflared
- tunnel - tunnel
@@ -80,7 +82,7 @@ castle service enable castle-tunnel # start the tunnel
## Using the toggle ## Using the toggle
Mark a service public in its `services/<name>.yaml`: Mark a service public in its `deployments/<name>.yaml`:
```yaml ```yaml
proxy: true # required — the service must be routed proxy: true # required — the service must be routed