docs+install: sweep deploy/start → castle apply

Update the README, AGENTS.md, install.sh, and docs/ for the converge
model: one `castle apply [name] [--plan]` replaces `deploy && start` and
the per-kind enable/disable/install/start/stop verbs. CLI references now
list apply + restart (the imperative bounce) + logs; recipes use
`castle apply`; "turn it off" is documented as `enabled: false` + apply.
install.sh's next-steps collapse to a single `castle apply`.

Verified: no retired-verb command instructions remain in any markdown;
install.sh syntax OK; live `castle apply --plan` reports converged;
doctor all-green; core 129 / cli 31 / api 59 pass.
This commit is contained in:
2026-07-02 11:54:31 -07:00
parent f422c1879d
commit 9abef4ac34
10 changed files with 79 additions and 76 deletions

View File

@@ -59,38 +59,37 @@ castle program add <path|git-url> [--name ...] # adopt EXISTIN
castle program clone [name] # provision repo: source castle program clone [name] # provision 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/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 — daemons (manager: systemd, no schedule) # Services — daemons (manager: systemd, no schedule)
castle service list|info <name> [--json] castle service list|info <name> [--json]
castle service create <name> [--program P] [--port N] [--health ...] [--launcher ...] castle service create <name> [--program P] [--port N] [--health ...] [--launcher ...]
castle service deploy <name> # generate unit + gateway route castle service restart <name> # imperative bounce
castle service enable|disable <name> # systemd enable/disable (+ boot)
castle service start|stop|restart <name>
castle service logs <name> [-f] [-n 50] castle service logs <name> [-f] [-n 50]
# Jobs — scheduled tasks (manager: systemd + schedule). Same verbs; create takes --schedule # Jobs — scheduled tasks (manager: systemd + schedule). create takes --schedule
castle job create <name> [--program P] --schedule "0 2 * * *" [--launcher ...] 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|restart|logs> ...
# Tools — CLIs on PATH (manager: path) # Tools — CLIs on PATH (manager: path)
castle tool list [--json] # each tool's executable + description + install state castle tool list [--json] # each tool's executable + description + install state
castle tool info <name> [--json] castle tool info <name> [--json]
castle tool install|uninstall <name>
# Platform-wide # Platform-wide
castle apply [name] [--plan] # converge runtime to config — the workhorse
castle list [--kind ...] [--stack ...] [--json] # all deployments castle list [--kind ...] [--stack ...] [--json] # all deployments
castle status # unified health/status castle status # unified health/status
castle doctor # diagnose setup + runtime, with fix hints castle doctor # diagnose setup + runtime, with fix hints
castle deploy [name] # apply config → units + Caddyfile castle restart [name] # imperative bounce (one or all)
castle start | stop | restart # all services (+ gateway) castle gateway reload|status # the Caddy gateway
castle gateway start|stop|reload|status # the Caddy gateway
``` ```
`castle service`/`job`/`tool` are **views** over the one deployment set, filtered `castle service`/`job`/`tool` are **views** over the one deployment set, filtered
by derived kind. Bringing everything online is the two honest steps by derived kind. Lifecycle is **convergence**: edit config, then **`castle apply`**
**`castle deploy && castle start`** (apply config, then start). renders units + the Caddyfile and reconciles the runtime (activate what's enabled,
restart what changed, deactivate what's disabled). To durably turn a deployment
off, set `enabled: false` and apply — there is no separate start/stop/enable.
`castle restart` is the one imperative bounce.
**Dev verbs resolve per-program:** a declared `commands:` entry (or `build:`) **Dev verbs resolve per-program:** a declared `commands:` entry (or `build:`)
overrides the program's stack default, else the stack handler, else the verb is overrides the program's stack default, else the stack handler, else the verb is
@@ -108,8 +107,7 @@ castle program create my-service --stack python-fastapi --description "Does X"
cd /data/repos/my-service && uv sync # implement it cd /data/repos/my-service && uv sync # implement it
castle program test my-service castle program test my-service
castle service create my-service --program my-service --port 9001 castle service create my-service --program my-service --port 9001
castle service deploy my-service && castle service enable my-service castle apply my-service # renders the unit + gateway route and starts it
castle gateway reload
``` ```
The service reads its port/data dir from env vars that `deployments/my-service.yaml` The service reads its port/data dir from env vars that `deployments/my-service.yaml`
@@ -120,7 +118,7 @@ maps via placeholders (see §6). Stack guide: **`docs/stacks/python-fastapi.md`*
```bash ```bash
castle program create my-tool --stack python-cli --description "Does Y" castle program create my-tool --stack python-cli --description "Does Y"
cd /data/repos/my-tool && uv sync cd /data/repos/my-tool && uv sync
castle tool install my-tool # uv tool install → on PATH castle apply my-tool # installs the path deployment on PATH
``` ```
`castle tool list --json` is the machine-readable tool catalog (each tool's real `castle tool list --json` is the machine-readable tool catalog (each tool's real
@@ -134,7 +132,7 @@ A job is a `manager: systemd` deployment with a `schedule` (cron). Generates a
```bash ```bash
castle job create nightly --program my-tool --schedule "0 2 * * *" --launcher command castle job create nightly --program my-tool --schedule "0 2 * * *" --launcher command
castle job deploy nightly && castle job enable nightly castle apply nightly
``` ```
### Create a static frontend ### Create a static frontend
@@ -143,7 +141,7 @@ castle job deploy nightly && castle job enable nightly
# scaffold a Vite/React app under /data/repos/my-frontend (see docs/stacks/react-vite.md) # scaffold a Vite/React app under /data/repos/my-frontend (see docs/stacks/react-vite.md)
castle program build my-frontend # produces dist/ castle program build my-frontend # produces dist/
# deployments/my-frontend.yaml → manager: caddy, root: dist # deployments/my-frontend.yaml → manager: caddy, root: dist
castle deploy && castle gateway reload # served at my-frontend.<domain> castle apply # served at my-frontend.<domain>
``` ```
The gateway serves the build **in place** from `<source>/<root>` — no copy, no The gateway serves the build **in place** from `<source>/<root>` — no copy, no
@@ -213,7 +211,7 @@ context** (`crypto.subtle`, service workers), which plain-HTTP LAN hosts lack.
`acme` operational prerequisites (castle can't do these for you): `acme` operational prerequisites (castle can't do these for you):
- **DNS-plugin Caddy** at `/usr/local/bin/caddy``./install.sh --with-dns-plugin=cloudflare`. - **DNS-plugin Caddy** at `/usr/local/bin/caddy``./install.sh --with-dns-plugin=cloudflare`.
- **Provider token** stored as a secret and mapped into the gateway service env - **Provider token** stored as a secret and mapped into the gateway service env
(`CLOUDFLARE_API_TOKEN`); `castle deploy` warns if missing. (`CLOUDFLARE_API_TOKEN`); `castle apply` warns if missing.
- **Bind :443/:80** — lower the floor once: `net.ipv4.ip_unprivileged_port_start=80` - **Bind :443/:80** — lower the floor once: `net.ipv4.ip_unprivileged_port_start=80`
in `/etc/sysctl.d/` (beats `setcap`, which `NoNewPrivileges` would void). in `/etc/sysctl.d/` (beats `setcap`, which `NoNewPrivileges` would void).
- **Stage first**: `CASTLE_ACME_STAGING=1` at deploy, verify issuance, then unset - **Stage first**: `CASTLE_ACME_STAGING=1` at deploy, verify issuance, then unset
@@ -258,7 +256,7 @@ Roots: **`CASTLE_HOME`** (config/code/artifacts/secrets, default `~/.castle`) an
`public: true` (requires `proxy: true`) projects a service to the internet at `public: true` (requires `proxy: true`) projects a service to the internet at
`<name>.<gateway.public_domain>` (a **separate** zone, so internal subdomain names `<name>.<gateway.public_domain>` (a **separate** zone, so internal subdomain names
stay out of public DNS). `castle deploy` generates the cloudflared ingress from the stay out of public DNS). `castle apply` generates the cloudflared ingress from the
set of public services. Needs `gateway.public_domain` + `gateway.tunnel_id` set and set of public services. Needs `gateway.public_domain` + `gateway.tunnel_id` set and
the `castle-tunnel` service running. → One-time setup: **`docs/tunnel-setup.md`**. the `castle-tunnel` service running. → One-time setup: **`docs/tunnel-setup.md`**.

View File

@@ -55,9 +55,10 @@ A program can have several deployments — a CLI that is both a `tool` on PATH a
scheduled `job` — so a program has no single kind of its own; it *has deployments*, scheduled `job` — so a program has no single kind of its own; it *has deployments*,
each with its own. each with its own.
Standing everything up is the two honest steps `castle deploy` (regenerate systemd Standing everything up is one honest step: `castle apply` renders systemd units and
units and gateway config from your config) then `castle start` (enable/start it). gateway config from your config, then reconciles the runtime to match — activating
There is no bundled "up". what's enabled, restarting what changed, deactivating what's disabled. Edit config,
`castle apply`; `castle apply --plan` shows the diff first.
## Stacks ## Stacks
@@ -112,7 +113,7 @@ git clone <this-repo> ~/castle && cd ~/castle
# Postgres), creates ~/.castle, registers Castle's own control plane, builds the UI. # Postgres), creates ~/.castle, registers Castle's own control plane, builds the UI.
./install.sh ./install.sh
castle deploy && castle start # apply config to the runtime, then bring it up castle apply # converge the runtime to config (units, routes, run)
castle doctor # verify — every check should be green castle doctor # verify — every check should be green
open http://localhost:9000 # the dashboard open http://localhost:9000 # the dashboard
``` ```
@@ -124,7 +125,7 @@ looks off — after an install, a deploy, or a config change.
### Exposure: from localhost to your own HTTPS domain ### Exposure: from localhost to your own HTTPS domain
Localhost is the first rung; you climb only as far as you need. Each rung is a small Localhost is the first rung; you climb only as far as you need. Each rung is a small
config change plus `castle deploy`, and `castle doctor` tells you what a rung still config change plus `castle apply`, and `castle doctor` tells you what a rung still
needs. needs.
| Rung | You get | What it takes | | Rung | You get | What it takes |
@@ -145,15 +146,15 @@ are still missing, each with its fix.
# A service — FastAPI app + a systemd service deployment (health, unit, route) # A service — FastAPI app + a systemd service deployment (health, unit, route)
castle program create my-api --stack python-fastapi --description "Does something" castle program create my-api --stack python-fastapi --description "Does something"
castle program test my-api castle program test my-api
castle deploy my-api && castle service enable my-api castle apply my-api # render unit + route, then start it
# A tool — a CLI installed on your PATH # A tool — a CLI installed on your PATH
castle program create my-tool --stack python-cli --description "Does something" castle program create my-tool --stack python-cli --description "Does something"
castle tool install my-tool castle apply my-tool # installs its path deployment on PATH
# A static frontend — built once, served by the gateway # A static frontend — built once, served by the gateway
castle program create my-app --stack react-vite --description "Web interface" castle program create my-app --stack react-vite --description "Web interface"
castle program build my-app && castle deploy castle program build my-app && castle apply
# Adopt an existing repo (no stack needed — dev verbs detected or declared) # Adopt an existing repo (no stack needed — dev verbs detected or declared)
castle program add ~/projects/some-rust-tool castle program add ~/projects/some-rust-tool
@@ -162,28 +163,32 @@ castle program add ~/projects/some-rust-tool
## CLI ## CLI
Operations live under the resource they act on. `program` is the catalog; Operations live under the resource they act on. `program` is the catalog;
`service`, `job`, and `tool` are **views** over the one deployment set; platform `service`, `job`, and `tool` are **views** over the one deployment set. Lifecycle
lifecycle is top-level. is convergence — `castle apply` — not a pile of per-kind verbs.
``` ```
# Programs — the software catalog # Programs — the software catalog
castle program list|info|create|add|clone|delete|run|install|uninstall castle program list|info|create|add|clone|delete|run
castle program build|test|lint|type-check|check [name] # dev verbs castle program build|test|lint|type-check|check [name] # dev verbs
# Deployment lenses (service = systemd, job = systemd + schedule, tool = path) # Deployment lenses (service = systemd, job = systemd + schedule, tool = path)
castle service list|info|create|delete|deploy|enable|disable|start|stop|restart|logs castle service list|info|create|delete|restart|logs
castle job …same verbs; create takes --schedule castle job …same verbs; create takes --schedule
castle tool list|info|install|uninstall # CLIs on your PATH castle tool list|info # CLIs on your PATH
# Platform-wide # Platform-wide
castle apply [name] [--plan] # converge runtime to config — the workhorse
castle list [--kind K] [--stack S] [--json] # catalog + every deployment view castle list [--kind K] [--stack S] [--json] # catalog + every deployment view
castle status # unified runtime status castle status # unified runtime status
castle doctor # diagnose setup + health, with fix hints castle doctor # diagnose setup + health, with fix hints
castle deploy [name] # apply config → units + Caddyfile castle restart [name] # imperative bounce (one or all)
castle start | stop | restart # all deployments (+ gateway) castle gateway reload|status
castle gateway start|stop|reload|status
``` ```
To turn a deployment off, set `enabled: false` in its config and `castle apply`
there's no start/stop/enable/install verb; the manager decides the mechanism
(systemd unit, PATH install, gateway route), the verb is always `apply`.
`castle tool list --json` is the machine-readable tool catalog assistants use to `castle tool list --json` is the machine-readable tool catalog assistants use to
build context — it surfaces each tool's actual **executable** (which can differ from build context — it surfaces each tool's actual **executable** (which can differ from
the program name, e.g. `litellm-intent-router` installs `intent-router`), its the program name, e.g. `litellm-intent-router` installs `intent-router`), its

View File

@@ -204,7 +204,7 @@ 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:…}`
placeholders, which deploy resolves into the registry's flat `env`. placeholders, which deploy resolves into the registry's flat `env`.
**`$CASTLE_HOME/artifacts/specs/registry.yaml`** (per-node, not in the repo, generated by `castle deploy`) — Node config: **`$CASTLE_HOME/artifacts/specs/registry.yaml`** (per-node, not in the repo, generated by `castle apply`) — Node config:
```yaml ```yaml
node: node:
@@ -228,7 +228,7 @@ deployed:
``` ```
The node config says what's deployed *here* and with what concrete The node config says what's deployed *here* and with what concrete
values. `castle deploy` reads the spec from the repo, resolves the values. `castle apply` reads the spec from the repo, resolves the
`defaults.env` placeholders and secrets, resolves binary paths, `defaults.env` placeholders and secrets, resolves binary paths,
and writes the registry. Systemd units and Caddyfile are then generated and writes the registry. Systemd units and Caddyfile are then generated
from the registry — never from the spec directly. from the registry — never from the spec directly.
@@ -280,7 +280,7 @@ programs on a single node and across multiple Castle nodes.
**MQTT topics:** **MQTT topics:**
- `castle/{hostname}/registry` — retained JSON, full NodeRegistry. - `castle/{hostname}/registry` — retained JSON, full NodeRegistry.
Published on connect and after `castle deploy`. Published on connect and after `castle apply`.
- `castle/{hostname}/status``"online"` (retained) / `"offline"` (LWT). - `castle/{hostname}/status``"online"` (retained) / `"offline"` (LWT).
LWT ensures nodes are marked offline if they disconnect unexpectedly. LWT ensures nodes are marked offline if they disconnect unexpectedly.
@@ -457,7 +457,7 @@ $CASTLE_HOME/ ← Config & artifacts (default ~/.castle)
├── code/ ← Program source (your programs) ├── code/ ← Program source (your programs)
│ └── <name>/ │ └── <name>/
├── artifacts/ ├── artifacts/
│ ├── specs/ ← Generated by `castle deploy` │ ├── specs/ ← Generated by `castle apply`
│ │ ├── Caddyfile │ │ ├── Caddyfile
│ │ └── registry.yaml ← Node config (what's deployed here) │ │ └── registry.yaml ← Node config (what's deployed here)
│ └── content/ ← Built frontend assets │ └── content/ ← Built frontend assets
@@ -491,7 +491,7 @@ Castle's architecture parallels Erlang/OTP, mapped onto Unix:
| Application | Component (independent, self-contained) | | Application | Component (independent, self-contained) |
| Application resource file | Component spec in `castle.yaml` | | Application resource file | Component spec in `castle.yaml` |
| Release config (sys.config) | Node config in `$CASTLE_HOME/artifacts/specs/registry.yaml` | | Release config (sys.config) | Node config in `$CASTLE_HOME/artifacts/specs/registry.yaml` |
| Release assembly | `castle deploy` (spec + node config → runtime) | | Release assembly | `castle apply` (spec + node config → runtime) |
| Supervisor | systemd (restart policies, ordering) | | Supervisor | systemd (restart policies, ordering) |
| Process | Running service/worker/job | | Process | Running service/worker/job |
| Application env | Env vars | | Application env | Env vars |
@@ -527,12 +527,12 @@ What exists today:
- **CLI** — `castle` command, installed via `uv tool install --editable cli/` - **CLI** — `castle` command, installed via `uv tool install --editable cli/`
- **Three packages** — `castle-core` (models, config, generators), - **Three packages** — `castle-core` (models, config, generators),
`castle-cli` (commands), `castle-api` (HTTP API) `castle-cli` (commands), `castle-api` (HTTP API)
- **Source/runtime split** — `castle.yaml` (spec) → `castle deploy` - **Source/runtime split** — `castle.yaml` (spec) → `castle apply`
`$CASTLE_HOME/artifacts/specs/registry.yaml` (node config). Systemd units and `$CASTLE_HOME/artifacts/specs/registry.yaml` (node config). Systemd units and
Caddyfile generated from registry with fully resolved paths. No repo references Caddyfile generated from registry with fully resolved paths. No repo references
in runtime artifacts. in runtime artifacts.
- **Explicit env with placeholders** — a deployment's env is exactly its - **Explicit env with placeholders** — a deployment's env is exactly its
`defaults.env`; `castle deploy` resolves `${port}`/`${data_dir}`/`${name}`/ `defaults.env`; `castle apply` resolves `${port}`/`${data_dir}`/`${name}`/
`${secret:…}` into concrete values. No hidden convention injection. `${secret:…}` into concrete values. No hidden convention injection.
- **Gateway** — Caddy on port 9000, Caddyfile generated from registry - **Gateway** — Caddy on port 9000, Caddyfile generated from registry
- **API** — `castle-api` on port 9020, reads from registry (optional - **API** — `castle-api` on port 9020, reads from registry (optional

View File

@@ -63,7 +63,7 @@ for tools/libraries.
at `/usr/local/bin/caddy` when `tls: acme`. at `/usr/local/bin/caddy` when `tls: acme`.
- Systemd user units at `~/.config/systemd/user/castle-*.service` (+ `.timer`); - Systemd user units at `~/.config/systemd/user/castle-*.service` (+ `.timer`);
the unit for program `X` is `castle-X.service`. Use drop-in `*.service.d/*.conf` the unit for program `X` is `castle-X.service`. Use drop-in `*.service.d/*.conf`
for extra env `castle deploy` shouldn't overwrite. for extra env `castle apply` shouldn't overwrite.
- The `container` launcher resolves docker via `shutil.which("docker")` (preferred - The `container` launcher resolves docker via `shutil.which("docker")` (preferred
over rootless podman on this box). over rootless podman on this box).
- Service data at `$CASTLE_DATA_DIR/<name>/`; secrets at `~/.castle/secrets/` - Service data at `$CASTLE_DATA_DIR/<name>/`; secrets at `~/.castle/secrets/`

View File

@@ -122,7 +122,7 @@ origin — moving a service onto HTTPS changes its origin.
(§DNS). Verify: `dig +short <name>.<domain>` → the node's IP. (§DNS). Verify: `dig +short <name>.<domain>` → the node's IP.
3. **Set `gateway.tls: acme`** (with `domain`/`acme_email`), plus the operational 3. **Set `gateway.tls: acme`** (with `domain`/`acme_email`), plus the operational
prerequisites (below). prerequisites (below).
4. **Deploy & reload:** `castle deploy` regenerates the Caddyfile and reloads Caddy. 4. **Deploy & reload:** `castle apply` regenerates the Caddyfile and reloads Caddy.
5. **Update the app's origin allowlist** if it has one (§secure context). 5. **Update the app's origin allowlist** if it has one (§secure context).
## Operational prerequisites ## Operational prerequisites
@@ -142,7 +142,7 @@ a DNS token.
(`~/.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
`deployments/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 apply` 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
Encrypt's staging CA (generous rate limits) while verifying issuance, then unset Encrypt's staging CA (generous rate limits) while verifying issuance, then unset

View File

@@ -333,7 +333,7 @@ proxy: true # expose at <service-name>.<gateway.domain>
`public: true` additionally projects a proxied service to the public internet via a `public: true` additionally projects a proxied service to the public internet via a
Cloudflare tunnel, at **`<service-name>.<gateway.public_domain>`** (a separate zone, Cloudflare tunnel, at **`<service-name>.<gateway.public_domain>`** (a separate zone,
so internal subdomain names stay out of public DNS). Defaults to `false` — public is so internal subdomain names stay out of public DNS). Defaults to `false` — public is
explicit — and **requires `proxy: true`**. `castle deploy` generates the cloudflared explicit — and **requires `proxy: true`**. `castle apply` generates the cloudflared
ingress from the set of public services. Needs `gateway.public_domain` + ingress from the set of public services. Needs `gateway.public_domain` +
`gateway.tunnel_id` set and the `castle-tunnel` service running; see `gateway.tunnel_id` set and the `castle-tunnel` service running; see
@docs/tunnel-setup.md for the one-time setup. @docs/tunnel-setup.md for the one-time setup.
@@ -467,7 +467,7 @@ Setup (the parts castle can't do for you):
env: env:
CLOUDFLARE_API_TOKEN: ${secret:CLOUDFLARE_API_TOKEN} CLOUDFLARE_API_TOKEN: ${secret:CLOUDFLARE_API_TOKEN}
``` ```
`castle deploy` warns if the domain, this env var, or the secret is missing. `castle apply` warns if the domain, this env var, or the secret is missing.
- **LAN DNS.** Add a wildcard on your LAN's DNS server (usually the router) - **LAN DNS.** Add a wildcard on your LAN's DNS server (usually the router)
pointing `*.<domain>` at the gateway's private IP — `address=/<domain>/<gateway-ip>` pointing `*.<domain>` at the gateway's private IP — `address=/<domain>/<gateway-ip>`
(dnsmasq) or the equivalent A record. The public zone gets no A records, so (dnsmasq) or the equivalent A record. The public zone gets no A records, so
@@ -493,8 +493,9 @@ manage:
systemd: {} systemd: {}
``` ```
Enables `castle service enable/disable` and `castle service logs`. An empty `{}` Marks the deployment systemd-managed, so `castle apply` generates and reconciles
uses defaults (enable=true, restart=on-failure, restart_sec=2). its unit (and `castle service logs` tails it). An empty `{}` uses defaults
(enable=true, restart=on-failure, restart_sec=2).
Full options: Full options:
```yaml ```yaml
@@ -636,17 +637,17 @@ castle program create my-service --stack python-fastapi # 1. Scaffold + regist
cd /data/repos/my-service && uv sync # 2. Install deps cd /data/repos/my-service && uv sync # 2. Install deps
# ... implement ... # ... implement ...
castle program test my-service # 3. Run tests castle program test my-service # 3. Run tests
castle service enable my-service # 4. Generate systemd unit, start castle apply my-service # 4. Render unit + routes and reconcile (start it)
castle gateway reload # 5. Update Caddy routes
``` ```
After `service enable`, the service starts automatically on boot and restarts `castle apply` renders the systemd unit + Caddy route and starts the service
on failure. Manage with: (which then runs on boot and restarts on failure). Manage with:
```bash ```bash
castle logs my-service -f # Tail logs castle logs my-service -f # Tail logs
castle service run my-service # Run in foreground (for debugging) castle service run my-service # Run in foreground (for debugging)
castle service disable my-service # Stop and remove systemd unit castle service restart my-service # Imperative bounce
# To stop it durably: set `enabled: false` in its deployment, then `castle apply`
``` ```
### Tool lifecycle ### Tool lifecycle
@@ -656,7 +657,7 @@ castle program create my-tool --stack python-cli # 1. Scaffold + register
cd /data/repos/my-tool && uv sync # 2. Install deps cd /data/repos/my-tool && uv sync # 2. Install deps
# ... implement ... # ... implement ...
castle program test my-tool # 3. Run tests castle program test my-tool # 3. Run tests
uv tool install --editable /data/repos/my-tool/ # 4. Install to PATH castle apply my-tool # 4. Install the path deployment on PATH
``` ```
### Job lifecycle ### Job lifecycle
@@ -676,7 +677,7 @@ manage:
systemd: {} systemd: {}
``` ```
`castle job enable my-job` generates both a `.service` (Type=oneshot) `castle apply my-job` generates both a `.service` (Type=oneshot)
and a `.timer` file. and a `.timer` file.
## Infrastructure paths ## Infrastructure paths

View File

@@ -171,7 +171,7 @@ See @docs/registry.md for the full registry reference.
## Serving with Caddy ## Serving with Caddy
For production, the static build output is served by Caddy rather than a Node For production, the static build output is served by Caddy rather than a Node
process. You do **not** write this block by hand — `castle deploy` generates it. process. You do **not** write this block by hand — `castle apply` generates it.
The flow: 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**

View File

@@ -119,7 +119,7 @@ leaves the schema intact (and says so). To destroy the data too:
castle delete my-app --purge-data # drop schema my_app cascade castle delete my-app --purge-data # drop schema my_app cascade
``` ```
`castle deploy` then prunes the schema from `PGRST_DB_SCHEMAS`; **restart the `castle apply` then prunes the schema from `PGRST_DB_SCHEMAS`; **restart the
`supabase` service** for PostgREST to pick up the added/removed schema list. `supabase` service** for PostgREST to pick up the added/removed schema list.
## Auth, RLS & the three privacy layers ## Auth, RLS & the three privacy layers
@@ -161,7 +161,7 @@ service itself is likewise at `supabase.<gateway.domain>`.) See
castle program create my-app --stack supabase --description "..." # scaffold + register castle program create my-app --stack supabase --description "..." # scaffold + register
castle program build my-app # apply unapplied migrations to the substrate castle program build my-app # apply unapplied migrations to the substrate
castle program test my-app # deno test over functions/ (if deno present) castle program test my-app # deno test over functions/ (if deno present)
castle deploy && castle gateway reload # serve the static UI at /my-app/ castle apply # serve the static UI at /my-app/
``` ```
## Scaffolding ## Scaffolding

View File

@@ -17,7 +17,7 @@ internet at `<name>.<gateway.public_domain>`, via a Cloudflare Tunnel.
Cloudflare (no inbound holes, no public IP needed) and forwards each public Cloudflare (no inbound holes, no public IP needed) and forwards each public
hostname to the gateway on `:443`, rewriting the Host header and TLS SNI to the hostname to the gateway on `:443`, rewriting the Host header and TLS SNI to the
*internal* name so Caddy routes it and its wildcard cert validates. The public *internal* name so Caddy routes it and its wildcard cert validates. The public
surface is exactly the set of `public: true` services — `castle deploy` generates surface is exactly the set of `public: true` services — `castle apply` generates
the ingress from the registry. the ingress from the registry.
- **One kill switch.** Stop `castle-tunnel` → instantly nothing is public. - **One kill switch.** Stop `castle-tunnel` → instantly nothing is public.
@@ -76,8 +76,8 @@ manage:
Bring it online: Bring it online:
```bash ```bash
castle deploy # writes cloudflared.yml from public services castle apply # writes cloudflared.yml from public services
castle service enable castle-tunnel # start the tunnel castle apply castle-tunnel # start the tunnel
``` ```
## Using the toggle ## Using the toggle
@@ -92,10 +92,10 @@ public: true # also expose at <name>.pub.payne.io via the tunnel
Then just deploy: Then just deploy:
```bash ```bash
castle deploy castle apply
``` ```
`castle deploy` regenerates `~/.castle/artifacts/specs/cloudflared.yml` from the `castle apply` regenerates `~/.castle/artifacts/specs/cloudflared.yml` from the
current set of public services and restarts `castle-tunnel`. Flip `public` back to current set of public services and restarts `castle-tunnel`. Flip `public` back to
`false` (or remove it) and redeploy to un-expose — the hostname drops out of the `false` (or remove it) and redeploy to un-expose — the hostname drops out of the
ingress immediately. ingress immediately.
@@ -116,14 +116,14 @@ printf %s "<token>" > ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
chmod 600 ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN chmod 600 ~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN
``` ```
With it present, every `castle deploy` **reconciles** the public CNAMEs against the With it present, every `castle apply` **reconciles** the public CNAMEs against the
current public set — creating missing ones and deleting removed ones — and prints a current public set — creating missing ones and deleting removed ones — and prints a
one-line summary. It only ever touches records pointing at *this* tunnel one-line summary. It only ever touches records pointing at *this* tunnel
(`<tunnel_id>.cfargotunnel.com`), so hand-managed records in the same zone are safe. (`<tunnel_id>.cfargotunnel.com`), so hand-managed records in the same zone are safe.
This token is separate from `CLOUDFLARE_API_TOKEN` (which is the ACME token for the This token is separate from `CLOUDFLARE_API_TOKEN` (which is the ACME token for the
internal zone); the public zone is usually a different zone/account. internal zone); the public zone is usually a different zone/account.
Without the token, `castle deploy` instead prints the exact command to run per host: Without the token, `castle apply` instead prints the exact command to run per host:
```bash ```bash
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io

View File

@@ -22,7 +22,7 @@ CASTLE_HOME="${HOME}/.castle"
CASTLE_CONF="${CASTLE_HOME}/infra.conf" CASTLE_CONF="${CASTLE_HOME}/infra.conf"
# Program data lives on a dedicated volume, decoupled from CASTLE_HOME — must match # Program data lives on a dedicated volume, decoupled from CASTLE_HOME — must match
# castle_core.config._resolve_data_dir (default /data/castle, override CASTLE_DATA_DIR) # castle_core.config._resolve_data_dir (default /data/castle, override CASTLE_DATA_DIR)
# so the data dirs + container mounts created here line up with what castle deploy # so the data dirs + container mounts created here line up with what castle apply
# generates for the mqtt/postgres/neo4j deployments. # generates for the mqtt/postgres/neo4j deployments.
DATA_DIR="${CASTLE_DATA_DIR:-/data/castle}" DATA_DIR="${CASTLE_DATA_DIR:-/data/castle}"
# Program source repos (default /data/repos, override CASTLE_REPOS_DIR). # Program source repos (default /data/repos, override CASTLE_REPOS_DIR).
@@ -177,7 +177,7 @@ CADDY_DNS_VERSION="${CADDY_DNS_VERSION:-v2.11.4}"
# (Let's Encrypt wildcard via DNS-01). Stock apt Caddy has no DNS modules. The # (Let's Encrypt wildcard via DNS-01). Stock apt Caddy has no DNS modules. The
# result goes to /usr/local/bin/caddy, which precedes /usr/bin on PATH, so the # result goes to /usr/local/bin/caddy, which precedes /usr/bin on PATH, so the
# gateway (a `command` runner resolving `caddy` via PATH) picks it up on the next # gateway (a `command` runner resolving `caddy` via PATH) picks it up on the next
# `castle deploy` with no spec change. Idempotent and opt-in (--with-dns-plugin). # `castle apply` with no spec change. Idempotent and opt-in (--with-dns-plugin).
ensure_caddy_dns_plugin() { ensure_caddy_dns_plugin() {
local provider="${1:-cloudflare}" local provider="${1:-cloudflare}"
local module local module
@@ -211,7 +211,7 @@ ensure_caddy_dns_plugin() {
/usr/local/bin/caddy list-modules 2>/dev/null | grep -q "dns.providers.$provider" \ /usr/local/bin/caddy list-modules 2>/dev/null | grep -q "dns.providers.$provider" \
|| log_fail "built caddy is missing dns.providers.$provider" || log_fail "built caddy is missing dns.providers.$provider"
log_info "Built /usr/local/bin/caddy — run 'castle deploy && castle gateway restart' to use it." log_info "Built /usr/local/bin/caddy — run 'castle apply' to use it."
log_ok log_ok
} }
@@ -259,7 +259,7 @@ create_directories() {
# Program data volume (default /data/castle) lives outside $HOME, so on a fresh # Program data volume (default /data/castle) lives outside $HOME, so on a fresh
# machine its parent may not be user-writable — fall back to sudo + chown so the # machine its parent may not be user-writable — fall back to sudo + chown so the
# later container mounts (and castle deploy) can write there. # later container mounts (and castle apply) can write there.
if ! mkdir -p "${DATA_DIR}" 2>/dev/null; then if ! mkdir -p "${DATA_DIR}" 2>/dev/null; then
log_info "creating ${DATA_DIR} (needs sudo — outside \$HOME)" log_info "creating ${DATA_DIR} (needs sudo — outside \$HOME)"
sudo mkdir -p "${DATA_DIR}" sudo mkdir -p "${DATA_DIR}"
@@ -280,7 +280,7 @@ create_directories() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Register Castle's own control-plane programs/deployments from bootstrap/ so a # Register Castle's own control-plane programs/deployments from bootstrap/ so a
# fresh registry is not empty. Without this, `castle deploy && castle start` # fresh registry is not empty. Without this, `castle apply`
# would bring up nothing. Never clobbers existing entries (idempotent). The # would bring up nothing. Never clobbers existing entries (idempotent). The
# gateway deployment carries a `__SPECS_DIR__` placeholder (the source repo has # gateway deployment carries a `__SPECS_DIR__` placeholder (the source repo has
# no machine-specific paths) that we substitute with this machine's specs dir. # no machine-specific paths) that we substitute with this machine's specs dir.
@@ -337,7 +337,7 @@ seed_caddyfile() {
fi fi
cat > "$caddyfile" << 'EOF' cat > "$caddyfile" << 'EOF'
:9000 { :9000 {
respond "Castle is starting. Run 'castle deploy' to configure." 200 respond "Castle is starting. Run 'castle apply' to configure." 200
} }
EOF EOF
log_ok log_ok
@@ -553,8 +553,7 @@ print_summary() {
printf "\n" printf "\n"
printf "Next steps:\n" printf "Next steps:\n"
printf " castle deploy # Generate registry, systemd units, Caddyfile\n" printf " castle apply # Converge: render units/routes + start everything\n"
printf " castle start # Start the gateway, API, and all deployments\n"
printf " castle doctor # Verify setup + health (green = good to go)\n" printf " castle doctor # Verify setup + health (green = good to go)\n"
printf " open http://localhost:9000 # the dashboard\n" printf " open http://localhost:9000 # the dashboard\n"
} }