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

@@ -20,10 +20,12 @@ is self-sufficient. The mesh is optional.
Castle service is just a well-behaved Unix daemon that happens to
be registered in a manifest.
3. **Stack and behavior.** Each program has a *stack* (development
toolchain: python-fastapi, python-cli, react-vite) and a *behavior*
(runtime role: daemon, tool, frontend). Scheduling, systemd management,
and proxying are orthogonal operations — not behaviors.
3. **Stack and kind.** Each program has an optional *stack* (development
toolchain: python-fastapi, python-cli, react-vite). How it's realized is a
property of its *deployment*, whose **manager** (`systemd`/`caddy`/`path`/
`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,
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.
For interpreted languages (Python, Node), Castle also needs to know the
runtime wrapper — how to invoke the artifact. This is what the `run`
spec's runner variants handle:
runtime wrapper — how to invoke the artifact. For a `manager: systemd`
deployment this is the nested `run:` block's **launcher** variants:
- `python` — Python (sync via uv, deploy resolves installed binary)
- `node` — Node.js (sync via pnpm/npm)
- `command` — Direct execution (compiled binaries, shell scripts)
- `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
binaries. No Castle-specific runner needed.
(A non-systemd deployment has no launcher: `manager: caddy` serves files,
`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
@@ -150,48 +155,50 @@ answers: "is it working?"
#### Source vs. runtime split
These map to two files:
**`castle.yaml`** (in the repo, version-controlled) — Three sections:
These map to the config root (`castle.yaml` globals plus per-resource files
under `programs/` and `deployments/`), version-controlled in the repo:
```yaml
programs:
central-context:
description: Content storage API
source: /data/repos/central-context
services:
central-context:
program: central-context
run:
runner: python
tool: central-context
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy: true # expose at central-context.<gateway.domain>
manage:
systemd: {}
jobs:
backup-collect:
program: backup-collect
run:
runner: command
argv: [backup-collect]
schedule: "0 2 * * *"
manage:
systemd: {}
# programs/central-context.yaml — what software exists
description: Content storage API
source: /data/repos/central-context
```
```yaml
# deployments/central-context.yaml — manager: systemd → kind: service
program: central-context
manager: systemd
run:
launcher: python
program: central-context
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy: true # expose at central-context.<gateway.domain>
manage:
systemd: {}
```
```yaml
# deployments/backup-collect.yaml — manager: systemd + schedule → kind: job
program: backup-collect
manager: systemd
run:
launcher: command
argv: [backup-collect]
schedule: "0 2 * * *"
manage:
systemd: {}
```
Programs define *what software exists* (identity, source, install, tools).
Services define *how daemons run* (run config, expose, proxy, systemd).
Jobs define *how scheduled tasks run* (run config, cron schedule, systemd).
Programs define *what software exists* (identity, source, build).
Deployments define *how a program is realized on this node* — a single
`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
fallthrough and source code linking. They can also exist independently
(e.g., `castle-gateway` runs Caddy — not our software).
A deployment can reference a program via `program:` for description fallthrough
and source code linking. It can also exist independently (e.g., `castle-gateway`
runs Caddy — not our software).
A service's env is exactly its `defaults.env` — castle injects nothing
implicitly. Values may use `${port}`/`${data_dir}`/`${name}`/`${secret:…}`
@@ -206,16 +213,17 @@ node:
gateway_port: 9000
deployed:
central-context:
runner: python
manager: systemd
launcher: python
run_cmd: [/home/user/.local/bin/central-context]
env:
CENTRAL_CONTEXT_DATA_DIR: /home/user/.castle/data/central-context
CENTRAL_CONTEXT_PORT: "9001"
behavior: daemon
kind: service
stack: python-fastapi
port: 9001
health_path: /health
proxy_path: /central-context
subdomain: central-context
managed: true
```
@@ -339,11 +347,11 @@ Daemons · Long-running processes that expose ports
└──────────────┘ └──────────────┘ └──────────────┘
Components · Software catalog
Name Stack Behavior Schedule Status
Name Stack Kind Schedule Status
pdf2md Python / CLI tool — installed
protonmail Python / CLI tool */5 * * * * installed
castle React / Vite frontend — —
backup-collect Python / CLI tool 0 2 * * * —
castle React / Vite static — —
backup-collect Python / CLI job 0 2 * * * —
```
**Key programs:**
@@ -355,8 +363,8 @@ Components · Software catalog
- **NodeBar** — Horizontal list of discovered nodes. Hidden in single-node
mode. Each node links to `/node/{hostname}`.
- **ServiceSection** — Daemon cards in a responsive grid.
- **ComponentTable** — Unified sortable table for all non-daemon programs
(tools, frontends) with Stack, Behavior, Schedule, and Status columns.
- **ComponentTable** — Unified sortable table for all non-daemon deployments
(tools, statics) with Stack, Kind, Schedule, and Status columns.
**Real-time updates:**
- 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.yaml ← Registry spec (programs, services, jobs)
├── castle.yaml ← Global settings; resources under programs/ + deployments/
├── infra.conf ← Infrastructure install choices
├── code/ ← Program source (your programs)
│ └── <name>/
@@ -558,7 +566,7 @@ What exists today:
What doesn't exist yet:
- **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
orchestrate builds (each project builds independently)
- **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
(`~/.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
`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
missing.
- **`acme` — stage first.** Set `CASTLE_ACME_STAGING=1` at deploy to use Let's

View File

@@ -1,6 +1,6 @@
# 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
architecture.
@@ -9,27 +9,31 @@ architecture.
Use these terms consistently across code, CLI, API, and docs.
- **program** — any project castle manages, regardless of what it does. The
software catalog (`programs:`). Every program has a **behavior** and an
optional **stack**. *("component" was the old name for program — don't use it.)*
- **behavior** — what a program *is*: `tool` (a CLI you invoke), `daemon` (a
long-running server), `frontend` (a web UI). A property of the program,
independent of whether/how it's deployed.
software catalog (`programs/`). Every program has an optional **stack**.
*("component" was the old name for program — don't use it.)*
- **stack** — a creation-time toolchain + scaffold template (`python-cli`,
`python-fastapi`, `react-vite`). Optional; seeds a program's default dev
commands but isn't required at runtime.
- **service** — a program deployed as a long-running systemd `.service`
(`services:`).
- **job** — a program deployed as a scheduled systemd `.timer` (+ oneshot)
(`jobs:`).
- **deployment** — the umbrella for "a service or a job" (a program materialized
into the runtime). The registry's deployed entries are deployments.
- **deployment** — a program materialized into this node's runtime
(`deployments/`). Every deployment is discriminated on its **`manager`**.
- **manager** — who supervises or realizes a deployment: `systemd` (a process,
or with a `schedule` a `.timer`), `caddy` (a gateway static file_server
route), `path` (a CLI installed on PATH via `uv tool install`), or `none`
(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
is; *service/job* is **how/when** it's deployed. They're independent: a program
may have neither (a tool you just install), a **service** (always-on), or a
**job** (scheduled). A `daemon`-behavior program is usually deployed as a
service; a `tool`-behavior program may back a job or just be installed for
manual use.
**Two orthogonal axes.** *manager* is **who** realizes a deployment; *kind* is
the **derived** label describing what it is. A program may have no deployment (a
program you just develop), a **service** (always-on), a **job** (scheduled), a
**tool** (installed on PATH), or a **static** (a built frontend served by the
gateway). A single `deployments/<name>.yaml` file carries the whole thing.
## 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.)
├── programs/ # Program configuration files (one file per program)
│ └── my-tool.yaml
── services/ # Service configuration files (one file per service)
── my-service.yaml
└── jobs/ # Job configuration files (one file per job)
── my-job.yaml
── deployments/ # Deployment configuration files (one file per deployment)
── my-service.yaml # manager: systemd → kind: service
├── nightly.yaml # manager: systemd + schedule → kind: job
── my-tool.yaml # manager: path → kind: tool
└── my-app.yaml # manager: caddy → kind: static
```
### castle.yaml (Globals)
@@ -56,25 +61,23 @@ gateway:
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:**
```yaml
description: Does something useful
source: /data/repos/my-tool
stack: python-cli
behavior: tool
system_dependencies: [pandoc]
```
**services/my-service.yaml:**
**deployments/my-service.yaml** (a service — `manager: systemd`, no schedule):
```yaml
program: my-service
run:
runner: python
program: my-service
manager: systemd
run: { launcher: python, program: my-service }
expose:
http:
internal: { port: 9001 }
@@ -84,43 +87,47 @@ manage:
systemd: {}
```
**jobs/my-job.yaml:**
**deployments/nightly.yaml** (a job — `manager: systemd` + `schedule`):
```yaml
program: my-tool
run:
runner: command
argv: [my-tool, sync]
manager: systemd
run: { launcher: command, argv: [my-tool, sync] }
schedule: "0 2 * * *"
manage:
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
| Category | Location | Purpose | Role / Types |
|----------|----------|---------|--------------|
| **programs** | `programs/*.yaml` | Software catalog — what software exists | tool, frontend, daemon |
| **services** | `services/*.yaml` | Long-running daemons — how they run | service |
| **jobs** | `jobs/*.yaml` | Scheduled tasks — when they run | job |
| Category | Location | Purpose | Kinds (derived) |
|----------|----------|---------|-----------------|
| **programs** | `programs/*.yaml` | Software catalog — what software exists | |
| **deployments** | `deployments/*.yaml` | How a program is realized on this node | service, job, tool, static, reference |
Services and jobs can reference a program via `program:` for description
fallthrough and source code linking. They can also exist independently
(e.g., `castle-gateway` runs Caddy — not our software).
A deployment can reference a program via `program:` for description fallthrough
and source code linking. It can also exist independently (e.g., `castle-gateway`
runs Caddy — not our software). The **kind** is derived from `manager` (+
`schedule`), never stored.
## Program blocks
Programs define **what software exists** — identity, source, behavior, builds.
### `behavior` — What role this program plays
```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)
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
`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.
### `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
in `castle program list --behavior tool` and the dashboard.
in `castle list --kind tool` and the dashboard.
### `version` — Program version
@@ -208,63 +215,68 @@ build:
- 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
**runner** determines the **manager** — who makes it available and supervises its
lifecycle:
A deployment is a *managed materialization* of a program. Its **`manager`** is
the stored discriminant — the single axis lifecycle, deploy, and status all
dispatch on:
| Manager | Makes available as | Runners | start/stop |
|---------|--------------------|---------|------------|
| **systemd** | a running process (or a `.timer` for jobs) | `python`, `command`, `container`, `compose`, `node` | `systemctl` |
| **caddy** | a gateway route | `static` (file_server) — and any exposed process (reverse_proxy) | add/remove route + reload |
| **path** | an installed CLI on `PATH` | `path` | `uv tool install` / `uninstall` |
| **none** | an external reference | `remote` | *(nothing — not ours)* |
| Manager | Makes available as | Launch mechanism | start/stop | Kind |
|---------|--------------------|------------------|------------|------|
| **systemd** | a running process (or a `.timer` for jobs) | nested `run: { launcher: … }` | `systemctl` | service / job |
| **caddy** | a gateway static file_server route | *(none — files on disk; `root:`)* | add/remove route + reload | static |
| **path** | an installed CLI on `PATH` | *(none — `uv tool install`)* | `uv tool install` / `uninstall` | tool |
| **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,
deploy, and status all dispatch on it. `behavior` (`tool`/`daemon`/`frontend`) is a
**derived display label only** — it never drives logic. A program is a *tool* when
it has a `path` service, a *frontend* when it has a `static` service, a *daemon*
when it has a process service.
The **kind** (service/job/tool/static/reference) is **derived** from `manager` (+
`schedule`) — it never drives logic and is never stored. `DeploymentSpec` is a
discriminated union on `manager` (SystemdDeployment/CaddyDeployment/
PathDeployment/RemoteDeployment); see [Manifest models](#manifest-models).
Discriminated union on `runner`:
### `run` — How to launch it (systemd only)
| Runner | Manager | Deploy | Key fields |
|--------|---------|--------|------------|
| `python` | systemd | `uv run --project <source> --no-dev <program>` | `program`, `args` |
| `command` | systemd | `which(argv[0])` → resolved path | `argv` |
| `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` |
For `manager: systemd` **only**, the nested `run:` block carries a **`launcher`**
— the process-launch mechanism. Non-systemd managers have no `run:`/launcher;
their fields live directly on the deployment (caddy has `root:`, none has
`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
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`,
so it never goes stale). `uv tool install` is reserved for `tool`-behavior
programs, where being on a human's PATH is the point. If a `python` service
declares a `program` with no resolvable `source`, deploy falls back to a PATH
lookup of the script.
so it never goes stale). `uv tool install` is reserved for `manager: path`
deployments (tools), where being on a human's PATH is the point. If a `python`
launcher declares a `program` with no resolvable `source`, deploy falls back to a
PATH lookup of the script.
```yaml
manager: systemd
run:
runner: python
launcher: python
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
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
than reinventing orchestration. Secrets/env reach compose through the unit's
`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).
```yaml
manager: systemd
run:
runner: compose
launcher: compose
file: docker-compose.yml # resolved under the program source
# 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
```yaml
@@ -324,8 +354,8 @@ routes is gone). Caddy proxies WebSocket upgrades transparently.
| Kind | Target | Declared by |
|------|--------|-------------|
| **proxy** | a local service on a port — Caddy `reverse_proxy localhost:PORT` | a service's `proxy.caddy` |
| **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>`) |
| **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 `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) |
"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).
- **Provider token.** Store a scoped API token as the `CLOUDFLARE_API_TOKEN`
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
defaults:
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
castle.yaml — use `${secret:…}`.
## Job blocks
## Job fields
Jobs define **how scheduled tasks run**. Same blocks as services plus
`schedule` and `timezone`.
A **job** is just a `manager: systemd` deployment that also carries a
`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
schedule: "*/5 * * * *"
@@ -525,11 +557,6 @@ timezone: America/Los_Angeles # default
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/`
Every program's source lives under `$CASTLE_REPOS_DIR` (default `/data/repos/<name>/`).
@@ -563,39 +590,41 @@ castle program create my-tool --stack python-cli --description "Does something"
### Manually
Clone or create the project under `/data/repos/`, then add entries to the
appropriate sections of `castle.yaml`:
Clone or create the project under `/data/repos/`, then add a `programs/<name>.yaml`
file (plus a `deployments/<name>.yaml` file if it's deployed):
```yaml
# Tool — only needs a program entry
programs:
my-tool:
description: Does something useful
source: /data/repos/my-tool
stack: python-cli
behavior: tool
# Tool — programs/my-tool.yaml (a program)
description: Does something useful
source: /data/repos/my-tool
stack: python-cli
```
```yaml
# Tool — deployments/my-tool.yaml (installed on PATH → kind: tool)
program: my-tool
manager: path
```
# Service — needs both program and service entries
programs:
my-service:
description: Does something useful
source: /data/repos/my-service
stack: python-fastapi
behavior: daemon
services:
my-service:
program: my-service
run:
runner: python
program: my-service
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy: true # expose at my-service.<gateway.domain>
manage:
systemd: {}
```yaml
# Service — programs/my-service.yaml (a program)
description: Does something useful
source: /data/repos/my-service
stack: python-fastapi
```
```yaml
# Service — deployments/my-service.yaml (manager: systemd → kind: service)
program: my-service
manager: systemd
run:
launcher: python
program: my-service
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy: true # expose at my-service.<gateway.domain>
manage:
systemd: {}
```
## Lifecycle
@@ -632,18 +661,19 @@ uv tool install --editable /data/repos/my-tool/ # 4. Install to PATH
### 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
jobs:
my-job:
description: Runs nightly
run:
runner: command
argv: ["my-job"]
schedule: "0 2 * * *"
manage:
systemd: {}
# deployments/my-job.yaml (manager: systemd + schedule → kind: job)
program: my-job
manager: systemd
run:
launcher: command
argv: ["my-job"]
schedule: "0 2 * * *"
manage:
systemd: {}
```
`castle job enable my-job` generates both a `.service` (Type=oneshot)
@@ -683,16 +713,18 @@ convention.
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `ProgramSpec` — software catalog entry (source, behavior, stack, build, system_dependencies)
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
- `JobSpec` — scheduled task (run, schedule, manage, defaults)
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunCompose, RunNode, RunRemote)
- `ProgramSpec` — software catalog entry (source, stack, build, system_dependencies)
- `DeploymentSpec` — a deployment, a discriminated union on `manager`:
`SystemdDeployment` (service/job — run, expose, proxy, schedule, manage,
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`
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
Config loading: `core/src/castle_core/config.py` — `load_config()` parses
castle.yaml into `CastleConfig` with typed `programs`, `services`, and
`jobs` dicts.
Config loading: `core/src/castle_core/config.py` — `load_config()` parses the
config root into `CastleConfig` with typed `programs` and `deployments` dicts.
Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer
generation (`systemd.py`) and Caddyfile generation (`caddyfile.py`).

View File

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

View File

@@ -103,42 +103,42 @@ class Settings(BaseSettings):
settings = Settings()
```
Castle passes config via env vars in castle.yaml:
Castle passes config via env vars in the deployment's `defaults.env`:
```yaml
programs:
my-service:
description: Does something useful
source: /data/repos/my-service
stack: python-fastapi
behavior: daemon
services:
my-service:
program: my-service
run:
runner: python
program: my-service
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy: true # expose at my-service.<gateway.domain>
manage:
systemd: {}
# programs/my-service.yaml
description: Does something useful
source: /data/repos/my-service
stack: python-fastapi
```
```yaml
# deployments/my-service.yaml (manager: systemd → kind: service)
program: my-service
manager: systemd
run:
launcher: python
program: my-service
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy: true # expose at my-service.<gateway.domain>
manage:
systemd: {}
```
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:
"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
defaults:
env:
MY_SERVICE_PORT: ${port} # = expose.http.internal.port
MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/my-service
CENTRAL_CONTEXT_URL: http://localhost:9001
defaults:
env:
MY_SERVICE_PORT: ${port} # = expose.http.internal.port
MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/my-service
CENTRAL_CONTEXT_URL: http://localhost:9001
```
`castle program create` scaffolds the `${port}`/`${data_dir}` lines for you.

View File

@@ -123,41 +123,47 @@ The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files.
## Registering as a program
A frontend program has a `build` spec (produces static output). Register it
in the `programs:` section of `castle.yaml`. No `run` block needed if Caddy
handles serving directly from the build output.
A frontend program has a `build` spec (produces static output). Register it in
`programs/<name>.yaml`, plus a `deployments/<name>.yaml` with `manager: caddy`
(derived **kind: static**) that names the built directory in `root:` — no `run`
block, since Caddy serves the build output directly.
```yaml
# castle.yaml
programs:
my-frontend:
description: Web dashboard
source: /data/repos/my-frontend
build:
commands:
- ["pnpm", "build"]
outputs:
- dist/
# programs/my-frontend.yaml
description: Web dashboard
source: /data/repos/my-frontend
build:
commands:
- ["pnpm", "build"]
outputs:
- 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
repo (`<source>/<build.outputs[0]>`) — no Node process and no copy into a
central directory. See [Serving with Caddy](#serving-with-caddy) below.
repo (`<source>/<root>`) — no Node process and no copy into a central directory.
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
services:
my-frontend:
program: my-frontend
run:
runner: node
script: dev
package_manager: pnpm
expose:
http:
internal: { port: 5173 }
proxy: true # expose the dev server at my-frontend.<gateway.domain>
# deployments/my-frontend.yaml (dev — manager: systemd, node launcher → kind: service)
program: my-frontend
manager: systemd
run:
launcher: node
script: dev
package_manager: pnpm
expose:
http:
internal: { port: 5173 }
proxy: true # expose the dev server at my-frontend.<gateway.domain>
```
See @docs/registry.md for the full registry reference.
@@ -170,10 +176,10 @@ The flow:
1. You build the frontend with `castle program build <name>` (deploy does **not**
build — it only points Caddy at `dist/`).
2. The Caddyfile generator emits a route per `behavior: frontend` program that
has `build.outputs`, rooted **in place** at `<source>/<build.outputs[0]>`
no copy. Each frontend is served at its own subdomain `<name>.<gateway.domain>`
(the dashboard, `castle`, is also the target of the `:9000` redirect).
2. The Caddyfile generator emits a route per `manager: caddy` deployment (kind
**static**), rooted **in place** at `<source>/<root>` — no copy. Each static
frontend is served at its own subdomain `<name>.<gateway.domain>` (the
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
[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.
> `castle program create --stack supabase` scaffolds from it and seeds the
> 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
**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**:
- **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).
- **Each app** is a directory of `migrations/` + `functions/` + `public/` that
deploys onto the substrate. Its rows/blobs live on the substrate; everything
@@ -55,7 +56,8 @@ my-app/
└── 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.
## supabase.app.yaml
@@ -117,8 +119,9 @@ the function holds credentials and can meter usage.
## Gateway & secure context
A supabase app is a `frontend` program (its `public/` is served in place), so the
gateway serves it at its own subdomain `<name>.<gateway.domain>`. With
A supabase app is a static deployment (`manager: caddy`; its `public/` is served
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
using **auth or WebCrypto** require — with no private CA to install. (The substrate
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`
(`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
`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>
```
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
description: Cloudflare tunnel — public exposure for public:true services
manager: systemd
run:
runner: command
launcher: command
argv:
- cloudflared
- tunnel
@@ -80,7 +82,7 @@ castle service enable castle-tunnel # start the tunnel
## Using the toggle
Mark a service public in its `services/<name>.yaml`:
Mark a service public in its `deployments/<name>.yaml`:
```yaml
proxy: true # required — the service must be routed