Merge pull request #1 from payneio/refactor/apply-converge

castle apply — one declarative converge verb
This commit is contained in:
2026-07-02 12:06:23 -07:00
committed by GitHub
48 changed files with 863 additions and 507 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

@@ -0,0 +1,104 @@
import { useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { Check, GitCompare, Loader2, Play } from "lucide-react"
import { apiClient } from "@/services/api/client"
import type { ApplyResult } from "@/services/api/hooks"
// A terraform-style "plan then apply" for the whole node: preview the diff the
// converge would enact (activate/restart/deactivate), then apply it.
export function ConvergePanel() {
const qc = useQueryClient()
const [plan, setPlan] = useState<ApplyResult | null>(null)
const [busy, setBusy] = useState<"plan" | "apply" | null>(null)
const [msg, setMsg] = useState<string | null>(null)
const preview = async () => {
setBusy("plan")
setMsg(null)
try {
setPlan(await apiClient.post<ApplyResult>("/apply", { plan: true }))
} finally {
setBusy(null)
}
}
const apply = async () => {
setBusy("apply")
setMsg(null)
try {
await apiClient.post<ApplyResult>("/apply", {})
setPlan(null)
setMsg("Applied — the node is converged.")
qc.invalidateQueries()
} catch (e) {
// A self-apply restarts castle-api, dropping the connection — expected.
if (e instanceof TypeError) {
setPlan(null)
setMsg("Applied — services are restarting.")
} else {
setMsg(`Apply failed: ${e instanceof Error ? e.message : String(e)}`)
}
} finally {
setBusy(null)
}
}
const row = (label: string, names: string[], color: string) =>
names.length > 0 && (
<div className="flex gap-2 text-sm">
<span className={`w-24 shrink-0 ${color}`}>{label}</span>
<span className="font-mono text-[var(--muted)]">{names.join(", ")}</span>
</div>
)
return (
<div className="mt-6 rounded-lg border border-[var(--border)] bg-[var(--card)] p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-[var(--muted)]">
<GitCompare size={16} />
<span>Convergence</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={preview}
disabled={busy !== null}
className="flex items-center gap-1.5 px-2.5 py-1 text-sm rounded border border-[var(--border)] hover:border-[var(--primary)] transition-colors disabled:opacity-40"
>
{busy === "plan" ? <Loader2 size={14} className="animate-spin" /> : <GitCompare size={14} />}
Preview
</button>
{plan?.changed && (
<button
onClick={apply}
disabled={busy !== null}
className="flex items-center gap-1.5 px-2.5 py-1 text-sm rounded bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-40"
>
{busy === "apply" ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
Apply
</button>
)}
</div>
</div>
{msg && (
<div className="mt-3 flex items-center gap-1.5 text-sm text-green-400">
<Check size={14} /> {msg}
</div>
)}
{plan && (
<div className="mt-3 space-y-1">
{plan.changed ? (
<>
{row("activate", plan.activated, "text-green-400")}
{row("restart", plan.restarted, "text-blue-400")}
{row("deactivate", plan.deactivated, "text-red-400")}
</>
) : (
<div className="text-sm text-[var(--muted)]">In sync nothing to converge.</div>
)}
</div>
)}
</div>
)
}

View File

@@ -1,7 +1,7 @@
import { Clock, Play, RefreshCw, Square, Terminal } from "lucide-react" import { Clock, Power, RefreshCw, 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, useSetEnabled } from "@/services/api/hooks"
import { launcherLabel } from "@/lib/labels" import { launcherLabel } from "@/lib/labels"
import { StackBadge } from "./StackBadge" import { StackBadge } from "./StackBadge"
@@ -11,12 +11,9 @@ interface JobCardProps {
} }
export function JobCard({ job, health }: JobCardProps) { export function JobCard({ job, health }: JobCardProps) {
const { mutate, isPending } = useServiceAction() const restart = useServiceAction()
const isDown = health?.status === "down" const setEnabled = useSetEnabled()
const busy = restart.isPending || setEnabled.isPending
const doAction = (action: string) => {
mutate({ name: job.id, action })
}
return ( return (
<div className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 hover:border-[var(--primary)] transition-colors"> <div className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 hover:border-[var(--primary)] transition-colors">
@@ -60,32 +57,26 @@ export function JobCard({ job, health }: JobCardProps) {
{job.managed && ( {job.managed && (
<div className="relative z-10 flex items-center gap-1"> <div className="relative z-10 flex items-center gap-1">
{isDown && (
<button <button
onClick={() => doAction("start")} onClick={() => setEnabled.mutate({ name: job.id, enabled: !job.enabled })}
disabled={isPending} disabled={busy}
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40" className={`p-1 rounded transition-colors disabled:opacity-40 ${
title="Start" job.enabled
? "hover:bg-red-800/30 text-red-400"
: "hover:bg-green-800/30 text-green-400"
}`}
title={job.enabled ? "Disable" : "Enable"}
> >
<Play size={14} /> <Power size={14} />
</button> </button>
)} {job.enabled && (
<button <button
onClick={() => doAction("restart")} onClick={() => restart.mutate({ name: job.id, action: "restart" })}
disabled={isPending} disabled={busy}
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40" className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
title="Restart" title="Restart"
> >
<RefreshCw size={14} /> <RefreshCw size={14} className={restart.isPending ? "animate-spin" : ""} />
</button>
{!isDown && (
<button
onClick={() => doAction("stop")}
disabled={isPending}
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Stop"
>
<Square size={14} />
</button> </button>
)} )}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-react" import { ExternalLink, Power, RefreshCw, Server, Terminal } from "lucide-react"
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, useSetEnabled } from "@/services/api/hooks"
import { launcherLabel, 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"
@@ -14,13 +14,9 @@ interface ServiceCardProps {
export function ServiceCard({ service, health }: ServiceCardProps) { export function ServiceCard({ service, health }: ServiceCardProps) {
const hasHttp = service.port != null const hasHttp = service.port != null
const { mutate, isPending } = useServiceAction() const restart = useServiceAction()
const setEnabled = useSetEnabled()
const doAction = (action: string) => { const busy = restart.isPending || setEnabled.isPending
mutate({ name: service.id, action })
}
const isDown = health?.status === "down"
return ( return (
<div className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 hover:border-[var(--primary)] transition-colors"> <div className="relative bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 hover:border-[var(--primary)] transition-colors">
@@ -82,32 +78,26 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
{service.managed && ( {service.managed && (
<div className="relative z-10 flex items-center gap-1"> <div className="relative z-10 flex items-center gap-1">
{isDown && (
<button <button
onClick={() => doAction("start")} onClick={() => setEnabled.mutate({ name: service.id, enabled: !service.enabled })}
disabled={isPending} disabled={busy}
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40" className={`p-1 rounded transition-colors disabled:opacity-40 ${
title="Start" service.enabled
? "hover:bg-red-800/30 text-red-400"
: "hover:bg-green-800/30 text-green-400"
}`}
title={service.enabled ? "Disable" : "Enable"}
> >
<Play size={14} /> <Power size={14} />
</button> </button>
)} {service.enabled && (
<button <button
onClick={() => doAction("restart")} onClick={() => restart.mutate({ name: service.id, action: "restart" })}
disabled={isPending} disabled={busy}
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40" className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
title="Restart" title="Restart"
> >
<RefreshCw size={14} /> <RefreshCw size={14} className={restart.isPending ? "animate-spin" : ""} />
</button>
{!isDown && (
<button
onClick={() => doAction("stop")}
disabled={isPending}
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Stop"
>
<Square size={14} />
</button> </button>
)} )}
</div> </div>

View File

@@ -42,7 +42,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
setMessage({ setMessage({
type: "ok", type: "ok",
text: isDeployment text: isDeployment
? "Saved to castle.yaml — not live yet; apply to deploy & restart." ? "Saved to castle.yaml — not live yet; apply to converge."
: "Saved to castle.yaml", : "Saved to castle.yaml",
}) })
if (isDeployment) setPendingApply(true) if (isDeployment) setPendingApply(true)
@@ -58,16 +58,21 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
setApplying(true) setApplying(true)
setMessage(null) setMessage(null)
try { try {
await apiClient.post(`/deploy`, { name: deployment.id }) // One converge: renders units/routes and reconciles the runtime (restarts
if (configSection === "services") { // only what changed). No separate restart call needed.
await apiClient.post(`/services/${deployment.id}/restart`, {}) await apiClient.post(`/apply`, { name: deployment.id })
}
setPendingApply(false) setPendingApply(false)
setMessage({ type: "ok", text: "Applied — the change is now live." }) setMessage({ type: "ok", text: "Applied — the change is now live." })
qc.invalidateQueries({ queryKey: ["status"] }) qc.invalidateQueries()
qc.invalidateQueries({ queryKey: [configSection] })
onRefetch() onRefetch()
} catch (e: unknown) { } catch (e: unknown) {
// A self-apply of castle-api restarts it, killing the connection — expected.
if (e instanceof TypeError) {
setPendingApply(false)
setMessage({ type: "ok", text: "Applied — the service is restarting." })
setApplying(false)
return
}
let msg = e instanceof Error ? e.message : String(e) let msg = e instanceof Error ? e.message : String(e)
try { try {
msg = JSON.parse((e as Error).message).detail ?? msg msg = JSON.parse((e as Error).message).detail ?? msg
@@ -122,11 +127,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
className="shrink-0 flex items-center gap-1.5 px-3 py-1 text-xs rounded bg-amber-700 hover:bg-amber-600 text-white transition-colors disabled:opacity-50" className="shrink-0 flex items-center gap-1.5 px-3 py-1 text-xs rounded bg-amber-700 hover:bg-amber-600 text-white transition-colors disabled:opacity-50"
> >
<RefreshCw size={12} className={applying ? "animate-spin" : ""} /> <RefreshCw size={12} className={applying ? "animate-spin" : ""} />
{applying {applying ? "Applying…" : "Apply"}
? "Applying…"
: configSection === "services"
? "Apply (deploy & restart)"
: "Apply (deploy)"}
</button> </button>
)} )}
</div> </div>

View File

@@ -108,12 +108,9 @@ export function CreateDeploymentForm({
try { try {
setBusy("Saving…") setBusy("Saving…")
await apiClient.put(`/config/deployments/${name}`, { config: buildConfig() }) await apiClient.put(`/config/deployments/${name}`, { config: buildConfig() })
setBusy("Deploying…") // Converge: render + activate the new deployment in one step.
await apiClient.post(`/deploy`, { name }) setBusy("Applying…")
if (kind === "service") { await apiClient.post(`/apply`, { name })
setBusy("Starting…")
await apiClient.post(`/services/${name}/start`, {})
}
qc.invalidateQueries({ queryKey: ["services"] }) qc.invalidateQueries({ queryKey: ["services"] })
qc.invalidateQueries({ queryKey: ["jobs"] }) qc.invalidateQueries({ queryKey: ["jobs"] })
qc.invalidateQueries({ queryKey: ["programs"] }) qc.invalidateQueries({ queryKey: ["programs"] })

View File

@@ -1,44 +1,40 @@
import { Play, RefreshCw, Square } from "lucide-react" import { Power, RefreshCw } from "lucide-react"
import { useServiceAction } from "@/services/api/hooks" import { useServiceAction, useSetEnabled } from "@/services/api/hooks"
import type { HealthStatus } from "@/types"
interface ServiceControlsProps { interface ServiceControlsProps {
name: string name: string
health?: HealthStatus enabled: boolean
} }
export function ServiceControls({ name, health }: ServiceControlsProps) { // Lifecycle is convergence: the Power toggle sets desired on/off state and applies
const { mutate, isPending } = useServiceAction() // (activate/deactivate); Restart is the one imperative bounce. No raw start/stop.
const isDown = health?.status === "down" export function ServiceControls({ name, enabled }: ServiceControlsProps) {
const restart = useServiceAction()
const setEnabled = useSetEnabled()
const busy = restart.isPending || setEnabled.isPending
return ( return (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{isDown && (
<button <button
onClick={() => mutate({ name, action: "start" })} onClick={() => setEnabled.mutate({ name, enabled: !enabled })}
disabled={isPending} disabled={busy}
className="p-1.5 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40" className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
title="Start" enabled
? "hover:bg-red-800/30 text-red-400"
: "hover:bg-green-800/30 text-green-400"
}`}
title={enabled ? "Disable (stop & keep off)" : "Enable (start)"}
> >
<Play size={16} /> <Power size={16} />
</button> </button>
)} {enabled && (
<button <button
onClick={() => mutate({ name, action: "restart" })} onClick={() => restart.mutate({ name, action: "restart" })}
disabled={isPending} disabled={busy}
className="p-1.5 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40" className="p-1.5 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
title="Restart" title="Restart"
> >
<RefreshCw size={16} /> <RefreshCw size={16} className={restart.isPending ? "animate-spin" : ""} />
</button>
{!isDown && (
<button
onClick={() => mutate({ name, action: "stop" })}
disabled={isPending}
className="p-1.5 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Stop"
>
<Square size={16} />
</button> </button>
)} )}
</div> </div>

View File

@@ -11,6 +11,7 @@ import {
} from "@/services/api/hooks" } from "@/services/api/hooks"
import { NodeBar } from "@/components/NodeBar" import { NodeBar } from "@/components/NodeBar"
import { PageHeader } from "@/components/PageHeader" import { PageHeader } from "@/components/PageHeader"
import { ConvergePanel } from "@/components/ConvergePanel"
export function Overview() { export function Overview() {
const { data: services } = useServices() const { data: services } = useServices()
@@ -96,6 +97,8 @@ export function Overview() {
</Link> </Link>
))} ))}
</div> </div>
<ConvergePanel />
</div> </div>
) )
} }

View File

@@ -1,6 +1,6 @@
import { useParams } from "react-router-dom" import { useParams } from "react-router-dom"
import { Clock } from "lucide-react" import { Clock } from "lucide-react"
import { useJob, useStatus } from "@/services/api/hooks" import { useJob } from "@/services/api/hooks"
import { LogViewer } from "@/components/LogViewer" import { LogViewer } from "@/components/LogViewer"
import { DetailHeader } from "@/components/detail/DetailHeader" import { DetailHeader } from "@/components/detail/DetailHeader"
import { ServiceControls } from "@/components/detail/ServiceControls" import { ServiceControls } from "@/components/detail/ServiceControls"
@@ -10,8 +10,6 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
export function ScheduledDetailPage() { export function ScheduledDetailPage() {
const { name } = useParams<{ name: string }>() const { name } = useParams<{ name: string }>()
const { data: deployment, isLoading, error, refetch } = useJob(name ?? "") const { data: deployment, isLoading, error, refetch } = useJob(name ?? "")
const { data: statusResp } = useStatus()
const health = statusResp?.statuses.find((s) => s.id === name)
if (isLoading) { if (isLoading) {
return ( return (
@@ -38,7 +36,7 @@ export function ScheduledDetailPage() {
stack={deployment.stack} stack={deployment.stack}
source={deployment.source} source={deployment.source}
> >
<ServiceControls name={deployment.id} health={health} /> <ServiceControls name={deployment.id} enabled={deployment.enabled} />
</DetailHeader> </DetailHeader>
{deployment.schedule && ( {deployment.schedule && (

View File

@@ -51,7 +51,7 @@ export function ServiceDetailPage() {
{!isStatic && ( {!isStatic && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{health && <HealthBadge status={health.status} latency={health.latency_ms} />} {health && <HealthBadge status={health.status} latency={health.latency_ms} />}
<ServiceControls name={deployment.id} health={health} /> <ServiceControls name={deployment.id} enabled={deployment.enabled} />
</div> </div>
)} )}
</DetailHeader> </DetailHeader>

View File

@@ -1,6 +1,6 @@
import { useParams, Link } from "react-router-dom" import { useParams, Link } from "react-router-dom"
import { Loader2, Package } from "lucide-react" import { Loader2, Package } from "lucide-react"
import { useDeployment, useProgramAction } from "@/services/api/hooks" import { useDeployment, useSetEnabled } from "@/services/api/hooks"
import { DetailHeader } from "@/components/detail/DetailHeader" import { DetailHeader } from "@/components/detail/DetailHeader"
import { ConfigPanel } from "@/components/detail/ConfigPanel" import { ConfigPanel } from "@/components/detail/ConfigPanel"
@@ -38,9 +38,14 @@ export function ToolDetailPage() {
<p className="text-sm text-[var(--muted)] -mt-4 mb-6">{deployment.description}</p> <p className="text-sm text-[var(--muted)] -mt-4 mb-6">{deployment.description}</p>
)} )}
{/* A tool's PATH deployment: install/uninstall is its start/stop. Its {/* A tool's PATH deployment: enabling converges it onto PATH, disabling
live state is `installed` (on PATH), which the endpoint sets directly. */} removes it. `installed` is the live state; `enabled` the desired one. */}
<PathLifecycle name={deployment.id} installed={deployment.installed} onDone={refetch} /> <PathLifecycle
name={deployment.id}
enabled={deployment.enabled}
installed={deployment.installed}
onDone={refetch}
/>
<div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6"> <div className="bg-[var(--card)] border border-[var(--border)] rounded-lg p-5 mb-6">
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-3"> <h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider mb-3">
@@ -63,17 +68,19 @@ export function ToolDetailPage() {
) )
} }
/** Install/uninstall a tool on PATH — the path deployment's lifecycle. */ /** Enable/disable a tool — convergence installs it onto PATH or removes it. */
function PathLifecycle({ function PathLifecycle({
name, name,
enabled,
installed: installedState, installed: installedState,
onDone, onDone,
}: { }: {
name: string name: string
enabled: boolean
installed: boolean | null installed: boolean | null
onDone: () => void onDone: () => void
}) { }) {
const { mutate, isPending } = useProgramAction() const { mutate, isPending } = useSetEnabled()
const installed = installedState === true const installed = installedState === true
const dot = const dot =
installedState === true installedState === true
@@ -86,21 +93,20 @@ function PathLifecycle({
<div className="flex items-center gap-2 text-sm"> <div className="flex items-center gap-2 text-sm">
<span className={`h-2 w-2 rounded-full shrink-0 ${dot}`} /> <span className={`h-2 w-2 rounded-full shrink-0 ${dot}`} />
<span>{installed ? "Installed on PATH" : "Not installed"}</span> <span>{installed ? "Installed on PATH" : "Not installed"}</span>
{!enabled && <span className="text-xs text-amber-400">disabled</span>}
<span className="text-xs text-[var(--muted)]">manager: path</span> <span className="text-xs text-[var(--muted)]">manager: path</span>
</div> </div>
<button <button
onClick={() => onClick={() => mutate({ name, enabled: !enabled }, { onSuccess: onDone })}
mutate({ name, action: installed ? "uninstall" : "install" }, { onSuccess: onDone })
}
disabled={isPending} disabled={isPending}
className={`flex items-center gap-1.5 px-2.5 py-1 text-sm rounded border transition-colors disabled:opacity-40 ${ className={`flex items-center gap-1.5 px-2.5 py-1 text-sm rounded border transition-colors disabled:opacity-40 ${
installed enabled
? "border-red-800 text-red-400 hover:bg-red-800/30" ? "border-red-800 text-red-400 hover:bg-red-800/30"
: "border-green-800 text-green-400 hover:bg-green-800/30" : "border-green-800 text-green-400 hover:bg-green-800/30"
}`} }`}
> >
{isPending && <Loader2 size={14} className="animate-spin" />} {isPending && <Loader2 size={14} className="animate-spin" />}
{installed ? "Uninstall" : "Install"} {enabled ? "Disable" : "Enable"}
</button> </button>
</div> </div>
) )

View File

@@ -154,6 +154,65 @@ export function useServiceAction() {
}) })
} }
export interface ApplyResult {
status: string
planned: boolean
changed: boolean
activated: string[]
restarted: string[]
deactivated: string[]
unchanged: string[]
messages: string[]
}
// Converge the running system to config. Pass a name to converge one deployment,
// or plan:true for a dry-run diff. Handles the API restarting itself mid-apply.
export function useApply() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ name, plan }: { name?: string; plan?: boolean } = {}) => {
try {
return await apiClient.post<ApplyResult>("/apply", { name, plan })
} catch (err) {
if (err instanceof TypeError) {
// Self-restart killed the connection — treat as accepted, wait + refresh.
await waitForApi()
return {
status: "ok", planned: false, changed: true,
activated: [], restarted: name ? [name] : [], deactivated: [],
unchanged: [], messages: [],
} as ApplyResult
}
throw err
}
},
onSuccess: (data) => {
if (!data.planned) qc.invalidateQueries()
},
})
}
// Set a deployment's desired on/off state, then converge it. One click = "make it
// so": edit config (declarative), then apply that single deployment.
export function useSetEnabled() {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) => {
await apiClient.put(`/config/deployments/${name}/enabled`, { enabled })
try {
return await apiClient.post<ApplyResult>("/apply", { name })
} catch (err) {
if (err instanceof TypeError) {
await waitForApi()
return null
}
throw err
}
},
onSuccess: () => qc.invalidateQueries(),
})
}
export function useProgramAction() { export function useProgramAction() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({

View File

@@ -19,6 +19,7 @@ export interface ServiceSummary {
systemd: SystemdInfo | null systemd: SystemdInfo | null
program: string | null program: string | null
source: string | null source: string | null
enabled: boolean // declared desired state; `apply` converges to it
node: string | null node: string | null
} }
@@ -37,6 +38,7 @@ export interface JobSummary {
systemd: SystemdInfo | null systemd: SystemdInfo | null
program: string | null program: string | null
source: string | null source: string | null
enabled: boolean // declared desired state; `apply` converges to it
node: string | null node: string | null
} }
@@ -98,6 +100,7 @@ export interface DeploymentSummary {
schedule: string | null schedule: string | null
installed: boolean | null installed: boolean | null
active: boolean | null active: boolean | null
enabled: boolean // declared desired state; `apply` converges to it
node: string | null node: string | null
} }

View File

@@ -21,7 +21,7 @@ from castle_core.config import (
) )
from castle_core.manifest import ProgramSpec, kind_for 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
from castle_api.stream import broadcast from castle_api.stream import broadcast
router = APIRouter(prefix="/config", tags=["config"]) router = APIRouter(prefix="/config", tags=["config"])
@@ -323,6 +323,29 @@ def delete_deployment(name: str) -> dict:
return _delete_deployment(name) return _delete_deployment(name)
class EnabledRequest(BaseModel):
enabled: bool
@router.put("/deployments/{name}/enabled")
def set_deployment_enabled(name: str, request: EnabledRequest) -> dict:
"""Set a deployment's declared `enabled` state (desired on/off).
Edits config only — the caller runs `POST /apply` to converge. Keeps the
declarative flow: change what you want, then apply.
"""
config = get_config()
dep = config.deployments.get(name)
if dep is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Deployment '{name}' not found",
)
dep.enabled = request.enabled
save_config(config)
return {"ok": True, "deployment": name, "enabled": request.enabled}
@router.put("/services/{name}") @router.put("/services/{name}")
def save_service(name: str, request: ServiceConfigRequest) -> dict: def save_service(name: str, request: ServiceConfigRequest) -> dict:
"""Alias of PUT /deployments/{name} (kept for the existing dashboard).""" """Alias of PUT /deployments/{name} (kept for the existing dashboard)."""
@@ -347,42 +370,33 @@ def delete_job(name: str) -> dict:
@router.post("/apply", response_model=ApplyResponse) @router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse: async def apply_config() -> ApplyResponse:
"""Apply config: rebuild runtime from castle.yaml, then restart services. """Converge the running system to match castle.yaml (a thin wrapper on core
``apply``). Renders units/Caddyfile/tunnel, then reconciles the runtime —
Runs a full ``deploy`` so the registry, systemd units, and Caddyfile are all activating what's enabled and down, restarting only what changed, deactivating
regenerated from the current castle.yaml (and the gateway reloaded) — this is the disabled. Kept as ``/config/apply`` for compatibility; ``/apply`` exposes
what keeps the running config from drifting behind an edit. Then restarts the the same converge with per-deployment targeting and ``--plan``.
managed services so the freshly written units take effect (``deploy`` only
daemon-reloads; a running unit keeps its old ExecStart until restarted).
Scheduled jobs are left alone — applying config shouldn't fire every job.
""" """
from castle_core.deploy import deploy from castle_core.deploy import apply
# apply is blocking (systemctl + gateway reload) — run off the event loop.
try:
result = await asyncio.to_thread(apply)
except Exception as e:
return ApplyResponse(ok=False, actions=[], errors=[f"Apply failed: {e}"])
actions: list[str] = [] actions: list[str] = []
errors: list[str] = [] for verb, names in (
("Activated", result.activated),
# Rebuild registry + units + Caddyfile from castle.yaml off the event loop ("Restarted", result.restarted),
# (deploy is blocking: it shells out to systemctl and the gateway). ("Deactivated", result.deactivated),
try: ):
result = await asyncio.to_thread(deploy) if names:
except Exception as e: actions.append(f"{verb} {', '.join(sorted(names))}")
return ApplyResponse(ok=False, actions=actions, errors=[f"Deploy failed: {e}"]) if not result.changed:
actions.extend(result.messages) actions.append("Already converged — nothing to do")
# Restart managed services so the new units take effect (skip scheduled jobs).
registry = result.registry or get_registry()
for name, deployed in registry.deployed.items():
if not deployed.managed or deployed.schedule:
continue
unit = f"castle-{name}.service"
ok, output = await _systemctl("restart", unit)
if ok:
actions.append(f"Restarted {name}")
else:
errors.append(f"Failed to restart {name}: {output}")
await broadcast("config-changed", {"actions": actions}) await broadcast("config-changed", {"actions": actions})
return ApplyResponse(ok=len(errors) == 0, actions=actions, errors=errors) return ApplyResponse(ok=True, actions=actions, errors=[])
async def _systemctl(action: str, unit: str) -> tuple[bool, str]: async def _systemctl(action: str, unit: str) -> tuple[bool, str]:

View File

@@ -1,49 +1,67 @@
"""Deploy API endpoint.""" """Apply (converge) API endpoint.
`POST /apply` reconciles the running system to match config — render units/
Caddyfile/tunnel, then activate/restart/deactivate to match. It replaces the old
`/deploy` (+ separate start/enable calls). `?plan=true` returns the diff without
touching anything.
"""
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from castle_core.deploy import deploy from castle_core.deploy import apply
router = APIRouter(tags=["deploy"]) router = APIRouter(tags=["apply"])
class DeployRequest(BaseModel): class ApplyRequest(BaseModel):
"""Optional request body for deploy.""" """Optional request body for apply."""
name: str | None = None name: str | None = None
plan: bool = False
class DeployResponse(BaseModel): class ApplyResponse(BaseModel):
"""Response from a deploy operation.""" """The diff a converge enacted (or would enact, for a plan)."""
status: str status: str
deployed_count: int planned: bool
changed: bool
activated: list[str]
restarted: list[str]
deactivated: list[str]
unchanged: list[str]
messages: list[str] messages: list[str]
@router.post("/deploy", response_model=DeployResponse) @router.post("/apply", response_model=ApplyResponse)
def run_deploy(request: DeployRequest | None = None) -> DeployResponse: def run_apply(request: ApplyRequest | None = None) -> ApplyResponse:
"""Deploy services and jobs from castle.yaml to runtime. """Converge the running system to match castle.yaml.
Resolves env vars and secrets, generates systemd units and Caddyfile, Renders systemd units + the Caddyfile + tunnel config, then reconciles the
copies frontend build outputs, and reloads systemd. runtime: activate enabled deployments that are down, restart any whose unit
changed, deactivate disabled ones. Pass a name to converge one deployment,
Optionally pass a name to deploy a single service or job. or `plan: true` to compute the diff without changing anything.
""" """
target_name = request.name if request else None name = request.name if request else None
plan = request.plan if request else False
try: try:
result = deploy(target_name=target_name) result = apply(target_name=name, plan=plan)
except FileNotFoundError as e: except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e raise HTTPException(status_code=404, detail=str(e)) from e
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) from e raise HTTPException(status_code=500, detail=str(e)) from e
return DeployResponse( return ApplyResponse(
status="ok", status="ok",
deployed_count=result.deployed_count, planned=result.planned,
changed=result.changed,
activated=result.activated,
restarted=result.restarted,
deactivated=result.deactivated,
unchanged=result.unchanged,
messages=result.messages, messages=result.messages,
) )

View File

@@ -35,6 +35,7 @@ class DeploymentSummary(BaseModel):
schedule: str | None = None schedule: str | None = None
installed: bool | None = None installed: bool | None = None
active: bool | None = None # uniform lifecycle state (on PATH / running / served) active: bool | None = None # uniform lifecycle state (on PATH / running / served)
enabled: bool = True # declared desired state; `apply` converges to it
node: str | None = None node: str | None = None
@@ -65,6 +66,7 @@ class ServiceSummary(BaseModel):
systemd: SystemdInfo | None = None systemd: SystemdInfo | None = None
program: str | None = None # the program this deployment references, if any program: str | None = None # the program this deployment references, if any
source: str | None = None source: str | None = None
enabled: bool = True # declared desired state; `apply` converges to it
node: str | None = None node: str | None = None
@@ -87,6 +89,7 @@ class JobSummary(BaseModel):
systemd: SystemdInfo | None = None systemd: SystemdInfo | None = None
program: str | None = None # the program this deployment references, if any program: str | None = None # the program this deployment references, if any
source: str | None = None source: str | None = None
enabled: bool = True # declared desired state; `apply` converges to it
node: str | None = None node: str | None = None

View File

@@ -21,14 +21,14 @@ def list_stacks() -> list[str]:
# Unified program action endpoint # Unified program action endpoint
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Dev verbs only. Activation (install/uninstall of tools/statics) is convergence:
# it happens through `POST /apply`, not as a program action.
_VALID_ACTIONS = { _VALID_ACTIONS = {
"build", "build",
"test", "test",
"lint", "lint",
"type-check", "type-check",
"check", "check",
"install",
"uninstall",
} }

View File

@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
@@ -104,6 +103,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
schedule=deployed.schedule, schedule=deployed.schedule,
installed=installed, installed=installed,
active=active, active=active,
enabled=deployed.enabled,
) )
@@ -152,6 +152,7 @@ def _summary_from_service(
managed=managed, managed=managed,
systemd=systemd_info, systemd=systemd_info,
source=source, source=source,
enabled=svc.enabled,
) )
@@ -187,6 +188,7 @@ def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> Depl
systemd=systemd_info, systemd=systemd_info,
schedule=job.schedule, schedule=job.schedule,
source=source, source=source,
enabled=job.enabled,
) )
@@ -276,6 +278,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
subdomain=deployed.subdomain, subdomain=deployed.subdomain,
managed=deployed.managed, managed=deployed.managed,
systemd=systemd_info, systemd=systemd_info,
enabled=deployed.enabled,
) )
@@ -317,6 +320,7 @@ def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> Ser
systemd=systemd_info, systemd=systemd_info,
program=svc.program, program=svc.program,
source=source, source=source,
enabled=svc.enabled,
) )
@@ -333,6 +337,7 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary:
schedule=deployed.schedule, schedule=deployed.schedule,
managed=deployed.managed, managed=deployed.managed,
systemd=systemd_info, systemd=systemd_info,
enabled=deployed.enabled,
) )
@@ -362,6 +367,7 @@ def _job_from_spec(name: str, job: SystemdDeployment, config: object) -> JobSumm
systemd=systemd_info, systemd=systemd_info,
program=job.program, program=job.program,
source=source, source=source,
enabled=job.enabled,
) )

View File

@@ -153,19 +153,12 @@ def get_unit(name: str) -> dict[str, str | None]:
return {"service": unit, "timer": timer} return {"service": unit, "timer": timer}
@router.post("/{name}/start")
async def start_service(name: str) -> JSONResponse:
"""Start a systemd-managed service."""
return await _do_action(name, "start")
@router.post("/{name}/stop")
async def stop_service(name: str) -> JSONResponse:
"""Stop a systemd-managed service."""
return await _do_action(name, "stop")
@router.post("/{name}/restart") @router.post("/{name}/restart")
async def restart_service(name: str) -> JSONResponse: async def restart_service(name: str) -> JSONResponse:
"""Restart a systemd-managed service.""" """Restart a systemd-managed service — the imperative bounce.
Lifecycle (start/stop/enable/disable) is convergence now: set `enabled` in the
deployment config and POST /apply. Restart stays as a way to re-actualize the
current desired state without changing it.
"""
return await _do_action(name, "restart") return await _do_action(name, "restart")

View File

@@ -164,7 +164,6 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
"services.get_castle_root": services_mod.get_castle_root, "services.get_castle_root": services_mod.get_castle_root,
"nodes.get_registry": nodes_mod.get_registry, "nodes.get_registry": nodes_mod.get_registry,
"stream.get_registry": stream_mod.get_registry, "stream.get_registry": stream_mod.get_registry,
"config_editor.get_registry": config_editor_mod.get_registry,
"config_editor.get_castle_root": config_editor_mod.get_castle_root, "config_editor.get_castle_root": config_editor_mod.get_castle_root,
} }
@@ -192,7 +191,6 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
services_mod.get_castle_root = originals["services.get_castle_root"] services_mod.get_castle_root = originals["services.get_castle_root"]
nodes_mod.get_registry = originals["nodes.get_registry"] nodes_mod.get_registry = originals["nodes.get_registry"]
stream_mod.get_registry = originals["stream.get_registry"] stream_mod.get_registry = originals["stream.get_registry"]
config_editor_mod.get_registry = originals["config_editor.get_registry"]
config_editor_mod.get_castle_root = originals["config_editor.get_castle_root"] config_editor_mod.get_castle_root = originals["config_editor.get_castle_root"]

View File

@@ -99,6 +99,13 @@ class TestServicesList:
svc = next(s for s in data if s["id"] == "test-svc") svc = next(s for s in data if s["id"] == "test-svc")
assert "schedule" not in svc assert "schedule" not in svc
def test_enabled_flows_through(self, client: TestClient) -> None:
"""ServiceSummary surfaces the declared `enabled` state (default True)."""
response = client.get("/services")
data = response.json()
svc = next(s for s in data if s["id"] == "test-svc")
assert svc["enabled"] is True
def test_no_installed_field(self, client: TestClient) -> None: def test_no_installed_field(self, client: TestClient) -> None:
"""ServiceSummary does not have installed field.""" """ServiceSummary does not have installed field."""
response = client.get("/services") response = client.get("/services")

View File

@@ -0,0 +1,60 @@
"""castle apply — converge the running system to match config.
The one workhorse verb: renders systemd units + the Caddyfile + tunnel config,
then reconciles the runtime (activate what's enabled and down, restart what
changed, deactivate what's disabled). Replaces the old `deploy && start` plus the
per-kind enable/disable/install/uninstall verbs.
`--plan` computes and prints the diff without writing or touching the runtime.
"""
from __future__ import annotations
import argparse
_C = {
"activate": "\033[32m", # green
"restart": "\033[33m", # yellow
"deactivate": "\033[31m", # red
"reset": "\033[0m",
"dim": "\033[90m",
}
def _line(verb_color: str, verb: str, names: list[str]) -> None:
if not names:
return
print(f" {verb_color}{verb}{_C['reset']} {', '.join(sorted(names))}")
def run_apply(args: argparse.Namespace) -> int:
from castle_core.deploy import apply
target = getattr(args, "name", None)
plan = getattr(args, "plan", False)
result = apply(target_name=target, plan=plan)
# Surface any warnings the render produced (acme prerequisites, tunnel notes).
for msg in result.messages:
if msg.startswith("Warning"):
print(f" {_C['dim']}{msg}{_C['reset']}")
if plan:
print("\n\033[1mPlan\033[0m " + _C["dim"] + "(no changes made)" + _C["reset"])
if not result.changed:
print(f" {_C['dim']}nothing to do — already converged{_C['reset']}")
return 0
_line(_C["activate"], "would activate ", result.activated)
_line(_C["restart"], "would restart ", result.restarted)
_line(_C["deactivate"], "would deactivate", result.deactivated)
return 0
print("\n\033[1mApplied\033[0m")
if not result.changed:
print(f" {_C['dim']}nothing to do — already converged{_C['reset']}")
return 0
_line(_C["activate"], "activated ", result.activated)
_line(_C["restart"], "restarted ", result.restarted)
_line(_C["deactivate"], "deactivated", result.deactivated)
return 0

View File

@@ -158,12 +158,12 @@ def run_create(args: argparse.Namespace) -> int:
if stack == "supabase": if stack == "supabase":
print(" # edit migrations/, functions/, public/ — targets the shared substrate") print(" # edit migrations/, functions/, public/ — targets the shared substrate")
print(f" castle program build {name} # apply migrations to the substrate") print(f" castle program build {name} # apply migrations to the substrate")
print(f" castle deploy && castle gateway reload # serve at /{name}/") print(f" castle apply # serve at {name}.<gateway.domain>")
elif stack: elif stack:
print(" uv sync") print(" uv sync")
if kind == "service": 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 apply {name}")
print(f" castle test {name}") print(f" castle test {name}")
else: else:
print(" # add code, then declare commands: in castle.yaml") print(" # add code, then declare commands: in castle.yaml")

View File

@@ -116,9 +116,9 @@ def run_delete(args: argparse.Namespace) -> int:
try: try:
deploy() deploy()
print("Reconciled runtime (castle deploy).") print("Reconciled runtime (castle apply).")
except Exception as e: except Exception as e:
print(f"warning: reconcile (castle deploy) failed — run 'castle deploy': {e}") print(f"warning: reconcile failed — run 'castle apply': {e}")
# Optional: delete the source directory. # Optional: delete the source directory.
if args.source and source_dir: if args.source and source_dir:

View File

@@ -1,21 +0,0 @@
"""castle deploy — thin CLI wrapper around castle_core.deploy."""
from __future__ import annotations
import argparse
from castle_core.deploy import deploy
def run_deploy(args: argparse.Namespace) -> int:
"""Deploy from castle.yaml to ~/.castle/."""
target_name = getattr(args, "name", None)
result = deploy(target_name=target_name)
for msg in result.messages:
print(f" {msg}")
print(f"\nDeployed {result.deployed_count} item(s).")
if result.deployed_count > 0:
print("Run 'castle start' to start all services.")
return 0

View File

@@ -88,7 +88,7 @@ def run_service_create(args: argparse.Namespace) -> int:
print(f" port: {args.port}") print(f" port: {args.port}")
if proxy: if proxy:
print(f" subdomain: {name}.<gateway.domain>") print(f" subdomain: {name}.<gateway.domain>")
print(f"\nNext: castle service deploy {name} && castle service start {name}") print(f"\nNext: castle apply {name}")
return 0 return 0
@@ -118,5 +118,5 @@ def run_job_create(args: argparse.Namespace) -> int:
print(f"Created job '{name}'.") print(f"Created job '{name}'.")
print(f" runs: {args.launcher} ({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 apply {name}")
return 0 return 0

View File

@@ -6,7 +6,7 @@ of checks grouped into Environment, Configuration, Runtime, and TLS & exposure;
each check reports ok / warn / fail with a one-line hint when action is needed. each check reports ok / warn / fail with a one-line hint when action is needed.
Exit code: 0 when nothing FAILed (warnings are allowed), 1 otherwise — so it Exit code: 0 when nothing FAILed (warnings are allowed), 1 otherwise — so it
doubles as a scriptable smoke test after `./install.sh` or `castle deploy`. doubles as a scriptable smoke test after `./install.sh` or `castle apply`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -206,7 +206,7 @@ def _check_runtime(config) -> list[Check]:
Check( Check(
FAIL, FAIL,
"gateway not running", "gateway not running",
hint="castle deploy && castle start", hint="castle apply",
) )
) )
@@ -227,7 +227,7 @@ def _check_runtime(config) -> list[Check]:
checks.append(Check(OK, "castle-api running", detail=detail)) checks.append(Check(OK, "castle-api running", detail=detail))
else: else:
checks.append( checks.append(
Check(FAIL, "castle-api not running", hint="castle deploy && castle start") Check(FAIL, "castle-api not running", hint="castle apply")
) )
# Generated artifacts. # Generated artifacts.
@@ -244,7 +244,7 @@ def _check_runtime(config) -> list[Check]:
FAIL, FAIL,
"generated specs missing", "generated specs missing",
detail=", ".join(missing), detail=", ".join(missing),
hint="castle deploy", hint="castle apply",
) )
) )
@@ -347,7 +347,7 @@ def _check_tls_exposure(config) -> list[Check]:
WARN, WARN,
"castle-tunnel not running", "castle-tunnel not running",
detail="public routes are down", detail="public routes are down",
hint="castle service start castle-tunnel", hint="enable castle-tunnel in its deployment, then: castle apply",
) )
) )
checks.append(_check_public_dns(config)) checks.append(_check_public_dns(config))
@@ -364,7 +364,7 @@ def _check_public_dns(config) -> Check:
records. The required permission is a single DNS:Edit (Cloudflare's 'Edit zone records. The required permission is a single DNS:Edit (Cloudflare's 'Edit zone
DNS' template), which also grants the zone lookup — so a correctly-scoped token DNS' template), which also grants the zone lookup — so a correctly-scoped token
passes this probe. Write itself isn't exercised (that would mutate); a passes this probe. Write itself isn't exercised (that would mutate); a
DNS:Read-only token would false-pass here but `castle deploy` then surfaces a DNS:Read-only token would false-pass here but `castle apply` then surfaces a
403 with the fix. Absent token → WARN (CNAMEs stay manual), not a failure. 403 with the fix. Absent token → WARN (CNAMEs stay manual), not a failure.
""" """
import urllib.error import urllib.error

View File

@@ -20,7 +20,7 @@ def _write_generated_files() -> None:
ensure_dirs() ensure_dirs()
if not REGISTRY_PATH.exists(): if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.") print("Error: no registry found. Run 'castle apply' first.")
return return
registry = load_registry() registry = load_registry()
@@ -56,7 +56,7 @@ def run_gateway(args: argparse.Namespace) -> int:
def _gateway_dry_run() -> int: def _gateway_dry_run() -> int:
"""Print generated Caddyfile without applying.""" """Print generated Caddyfile without applying."""
if not REGISTRY_PATH.exists(): if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.") print("Error: no registry found. Run 'castle apply' first.")
return 1 return 1
registry = load_registry() registry = load_registry()
@@ -126,7 +126,7 @@ def _gateway_status() -> int:
print(f"Gateway: {'running' if status == 'active' else status}") print(f"Gateway: {'running' if status == 'active' else status}")
if not REGISTRY_PATH.exists(): if not REGISTRY_PATH.exists():
print(" (no registry — run 'castle deploy')") print(" (no registry — run 'castle apply')")
return 0 return 0
from castle_core.generators.caddyfile import compute_routes from castle_core.generators.caddyfile import compute_routes

View File

@@ -147,7 +147,7 @@ def run_info(args: argparse.Namespace) -> int:
if deployed.secret_env_keys: if deployed.secret_env_keys:
print(f" {BOLD}secrets{RESET}: {', '.join(deployed.secret_env_keys)}") print(f" {BOLD}secrets{RESET}: {', '.join(deployed.secret_env_keys)}")
else: else:
print(f"\n {DIM}not deployed (run 'castle deploy'){RESET}") print(f"\n {DIM}not applied (run 'castle apply'){RESET}")
# Show CLAUDE.md if it exists # Show CLAUDE.md if it exists
source_dir = None source_dir = None

View File

@@ -82,12 +82,12 @@ def run_run(args: argparse.Namespace) -> int:
# Service: deployed command from the registry. # Service: deployed command from the registry.
if not REGISTRY_PATH.exists(): if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.") print("Error: no registry found. Run 'castle apply' first.")
return 1 return 1
registry = load_registry() registry = load_registry()
if name not in registry.deployed: if name not in registry.deployed:
print(f"Error: '{name}' is not a deployed service. Run 'castle deploy' first.") print(f"Error: '{name}' is not a deployed service. Run 'castle apply' first.")
return 1 return 1
deployed = registry.deployed[name] deployed = registry.deployed[name]

View File

@@ -7,13 +7,10 @@ import subprocess
from castle_core.generators.systemd import ( from castle_core.generators.systemd import (
SYSTEMD_USER_DIR, SYSTEMD_USER_DIR,
generate_timer,
generate_unit_from_deployed,
timer_name, timer_name,
unit_name, unit_name,
) )
from castle_core.manifest import kind_for from castle_core.manifest import kind_for
from castle_core.registry import REGISTRY_PATH, load_registry
from castle_cli.config import ( from castle_cli.config import (
CastleConfig, CastleConfig,
@@ -45,44 +42,35 @@ _PAST = {"start": "started", "stop": "stopped", "restart": "restarted"}
def run_service_cmd(args: argparse.Namespace) -> int: def run_service_cmd(args: argparse.Namespace) -> int:
"""`castle service <enable|disable|start|stop|restart> <name>`.""" """`castle service restart <name>` — the imperative bounce (only verb left).
sub = args.service_command
Lifecycle (deploy/enable/disable/start/stop) is now convergence: `castle apply`.
"""
config = load_config() config = load_config()
if sub == "enable": return _unit_action(config, args.name, "restart", is_job=False)
if getattr(args, "dry_run", False):
return _service_dry_run(config, args.name)
return _service_enable(config, args.name)
if sub == "disable":
return _service_disable(config, args.name)
if sub in ("start", "stop", "restart"):
return _unit_action(config, args.name, sub, is_job=False)
return 1
def run_job_cmd(args: argparse.Namespace) -> int: def run_job_cmd(args: argparse.Namespace) -> int:
"""`castle job <enable|disable|start|stop|restart> <name>` (acts on the timer).""" """`castle job restart <name>` — bounce the job's timer."""
sub = args.job_command
config = load_config() config = load_config()
if sub == "enable": return _unit_action(config, args.name, "restart", is_job=True)
return _service_enable(config, args.name) # enable_service handles timers
if sub == "disable":
return _service_disable(config, args.name)
if sub in ("start", "stop", "restart"):
return _unit_action(config, args.name, sub, is_job=True)
return 1
def run_platform(args: argparse.Namespace) -> int: def run_restart(args: argparse.Namespace) -> int:
"""Top-level `castle start|stop|restart` — the whole platform.""" """Top-level `castle restart [name]` — bounce one deployment, or all of them.
An imperative op: it re-actualizes current desired state, it does not change it
(that's `castle apply`).
"""
config = load_config() config = load_config()
action = args.command name = getattr(args, "name", None)
if action == "start": if not name:
return _services_start(config)
if action == "stop":
return _services_stop(config)
if action == "restart":
return _services_restart(config) return _services_restart(config)
dep = config.deployments.get(name)
if dep is None:
print(f"Error: no deployment '{name}'.")
return 1 return 1
return _unit_action(config, name, "restart", is_job=(kind_for(dep) == "job"))
_GATEWAY_NAME = "castle-gateway" _GATEWAY_NAME = "castle-gateway"
@@ -256,78 +244,3 @@ def _service_status(config: CastleConfig) -> int:
print() print()
return 0 return 0
def _service_dry_run(config: CastleConfig, name: str) -> int:
"""Print the generated systemd unit(s) without installing."""
if REGISTRY_PATH.exists():
registry = load_registry()
if name in registry.deployed:
deployed = registry.deployed[name]
systemd_spec = None
dep = config.deployments.get(name)
manage = getattr(dep, "manage", None)
if manage and manage.systemd:
systemd_spec = manage.systemd
svc_unit = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
print(f"# {svc_unit}")
print(svc_content)
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
print(f"# {timer_name(name)}")
print(timer_content)
return 0
print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.")
return 1
def _services_start(config: CastleConfig) -> int:
"""Start all managed services and gateway."""
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
ensure_dirs()
from castle_core.config import SPECS_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
registry = load_registry()
caddyfile_path = SPECS_DIR / "Caddyfile"
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f"Generated {caddyfile_path}")
# Activate every deployment in its mode: systemd unit / timer, gateway route
# (static), or PATH install (tool). activate() dispatches by manager.
for name in config.deployments:
if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue
_service_enable(config, name)
print(f"\nDashboard: http://localhost:{config.gateway.port}")
return 0
def _services_stop(config: CastleConfig) -> int:
"""Stop all managed services and jobs."""
for name in config.jobs:
tmr_unit = timer_name(name)
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")
for name in config.services:
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")
return 0

View File

@@ -137,7 +137,7 @@ def run_tool_info(args: argparse.Namespace) -> int:
if comp.system_dependencies: if comp.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(comp.system_dependencies)}") print(f" {BOLD}requires{RESET}: {', '.join(comp.system_dependencies)}")
if not installed: if not installed:
print(f"\n {DIM}install with: castle tool install {name}{RESET}") print(f"\n {DIM}enable it in its deployment, then: castle apply{RESET}")
else: else:
print(f"\n {DIM}run `{exes[0]} --help` for arguments{RESET}") print(f"\n {DIM}run `{exes[0]} --help` for arguments{RESET}")
print() print()

View File

@@ -70,13 +70,6 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
_add_name(p, "Program name") _add_name(p, "Program name")
p.add_argument("extra", nargs=argparse.REMAINDER, help="Extra args passed to the program") p.add_argument("extra", nargs=argparse.REMAINDER, help="Extra args passed to the program")
sub.add_parser("install", help="Activate a program (tool→PATH, static→served)").add_argument(
"name", nargs="?", help="Program (default: all)"
)
sub.add_parser("uninstall", help="Deactivate a program").add_argument(
"name", nargs="?", help="Program"
)
for verb in DEV_VERBS: for verb in DEV_VERBS:
p = sub.add_parser(verb, help=f"Run {verb}") p = sub.add_parser(verb, help=f"Run {verb}")
_add_name(p, "Program (default: all)", optional=True) _add_name(p, "Program (default: all)", optional=True)
@@ -95,11 +88,6 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
_add_name(p, "Tool name") _add_name(p, "Tool name")
p.add_argument("--json", action="store_true", help="Machine-readable output") p.add_argument("--json", action="store_true", help="Machine-readable output")
sub.add_parser("install", help="Install a tool on PATH").add_argument("name", help="Tool name")
sub.add_parser("uninstall", help="Remove a tool from PATH").add_argument(
"name", help="Tool name"
)
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None: def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml") p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml")
@@ -148,16 +136,9 @@ def _build_deployment_group(subparsers: argparse._SubParsersAction, kind: str) -
p.add_argument("--purge-data", action="store_true", help=argparse.SUPPRESS) p.add_argument("--purge-data", action="store_true", help=argparse.SUPPRESS)
cap = f"{kind.capitalize()} name" cap = f"{kind.capitalize()} name"
_add_name(sub.add_parser("deploy", help=f"Deploy this {kind} (unit + gateway)"), cap) # Lifecycle is convergence: `castle apply [name]`. `restart` stays as the one
# imperative bounce that doesn't change desired state.
p = sub.add_parser("enable", help=f"Enable and start the {kind}") _add_name(sub.add_parser("restart", help=f"Restart the {kind} (imperative bounce)"), cap)
_add_name(p, cap)
if kind == "service":
p.add_argument("--dry-run", action="store_true", help="Print the unit without installing")
_add_name(sub.add_parser("disable", help=f"Stop and disable the {kind}"), cap)
_add_name(sub.add_parser("start", help=f"Start the {kind}"), cap)
_add_name(sub.add_parser("stop", help=f"Stop the {kind}"), cap)
_add_name(sub.add_parser("restart", help=f"Restart the {kind}"), cap)
p = sub.add_parser("logs", help=f"View {kind} logs") p = sub.add_parser("logs", help=f"View {kind} logs")
_add_name(p, f"{kind.capitalize()} name") _add_name(p, f"{kind.capitalize()} name")
@@ -188,16 +169,23 @@ def build_parser() -> argparse.ArgumentParser:
p.add_argument("--dry-run", action="store_true") p.add_argument("--dry-run", action="store_true")
gw_sub.add_parser("status", help="Show gateway status") gw_sub.add_parser("status", help="Show gateway status")
# Platform-wide lifecycle (top-level) # Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
subparsers.add_parser("start", help="Start all services and the gateway") # reconciles the runtime to match config (activate/restart/deactivate).
subparsers.add_parser("stop", help="Stop all services and the gateway") p = subparsers.add_parser(
subparsers.add_parser("restart", help="Restart all services and jobs") "apply", help="Converge the running system to match config (render + reconcile)"
)
p.add_argument("name", nargs="?", help="Single deployment to converge (default: all)")
p.add_argument(
"--plan", action="store_true", help="Show the diff without changing anything"
)
# Imperative ops (don't change desired state)
p = subparsers.add_parser("restart", help="Restart deployment(s) — an imperative bounce")
p.add_argument("name", nargs="?", help="Deployment to restart (default: all)")
subparsers.add_parser("status", help="Show status across the platform") subparsers.add_parser("status", help="Show status across the platform")
subparsers.add_parser( subparsers.add_parser(
"doctor", help="Diagnose setup + runtime health, with next-step hints" "doctor", help="Diagnose setup + runtime health, with next-step hints"
) )
p = subparsers.add_parser("deploy", help="Apply config to runtime (units + Caddyfile)")
p.add_argument("name", nargs="?", help="Service/job to deploy (default: all)")
# Cross-resource overview # Cross-resource overview
p = subparsers.add_parser("list", help="List programs, services, jobs, and tools") p = subparsers.add_parser("list", help="List programs, services, jobs, and tools")
@@ -215,7 +203,7 @@ def build_parser() -> argparse.ArgumentParser:
def _dispatch_program(args: argparse.Namespace) -> int: def _dispatch_program(args: argparse.Namespace) -> int:
sub = args.program_command sub = args.program_command
if not sub: if not sub:
verbs = "list|info|create|add|clone|delete|run|install|uninstall|" + "|".join(DEV_VERBS) verbs = "list|info|create|add|clone|delete|run|" + "|".join(DEV_VERBS)
print(f"Usage: castle program {{{verbs}}}") print(f"Usage: castle program {{{verbs}}}")
return 1 return 1
if sub == "list": if sub == "list":
@@ -246,14 +234,6 @@ def _dispatch_program(args: argparse.Namespace) -> int:
from castle_cli.commands.run_cmd import run_run from castle_cli.commands.run_cmd import run_run
return run_run(args) return run_run(args)
if sub == "install":
from castle_cli.commands.dev import run_install
return run_install(args)
if sub == "uninstall":
from castle_cli.commands.dev import run_uninstall
return run_uninstall(args)
if sub in DEV_VERBS: if sub in DEV_VERBS:
from castle_cli.commands.dev import run_verb from castle_cli.commands.dev import run_verb
@@ -264,7 +244,7 @@ def _dispatch_program(args: argparse.Namespace) -> int:
def _dispatch_tool(args: argparse.Namespace) -> int: def _dispatch_tool(args: argparse.Namespace) -> int:
sub = args.tool_command sub = args.tool_command
if not sub: if not sub:
print("Usage: castle tool {list|info|install|uninstall}") print("Usage: castle tool {list|info} (install/uninstall → edit config + castle apply)")
return 1 return 1
if sub == "list": if sub == "list":
from castle_cli.commands.tool import run_tool_list from castle_cli.commands.tool import run_tool_list
@@ -274,21 +254,13 @@ def _dispatch_tool(args: argparse.Namespace) -> int:
from castle_cli.commands.tool import run_tool_info from castle_cli.commands.tool import run_tool_info
return run_tool_info(args) return run_tool_info(args)
if sub == "install":
from castle_cli.commands.dev import run_install
return run_install(args)
if sub == "uninstall":
from castle_cli.commands.dev import run_uninstall
return run_uninstall(args)
return 1 return 1
def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int: def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
sub = getattr(args, f"{kind}_command") sub = getattr(args, f"{kind}_command")
if not sub: if not sub:
verbs = "list|info|create|delete|deploy|enable|disable|start|stop|restart|logs" verbs = "list|info|create|delete|restart|logs (deploy/enable/... → castle apply)"
print(f"Usage: castle {kind} {{{verbs}}}") print(f"Usage: castle {kind} {{{verbs}}}")
return 1 return 1
if sub == "list": if sub == "list":
@@ -307,11 +279,7 @@ def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
from castle_cli.commands.delete import run_delete from castle_cli.commands.delete import run_delete
return run_delete(args) return run_delete(args)
if sub == "deploy": if sub == "restart":
from castle_cli.commands.deploy import run_deploy
return run_deploy(args)
if sub in ("enable", "disable", "start", "stop", "restart"):
from castle_cli.commands.service import run_job_cmd, run_service_cmd from castle_cli.commands.service import run_job_cmd, run_service_cmd
return run_service_cmd(args) if kind == "service" else run_job_cmd(args) return run_service_cmd(args) if kind == "service" else run_job_cmd(args)
@@ -341,10 +309,14 @@ def main() -> int:
from castle_cli.commands.gateway import run_gateway from castle_cli.commands.gateway import run_gateway
return run_gateway(args) return run_gateway(args)
if cmd in ("start", "stop", "restart"): if cmd == "apply":
from castle_cli.commands.service import run_platform from castle_cli.commands.apply import run_apply
return run_platform(args) return run_apply(args)
if cmd == "restart":
from castle_cli.commands.service import run_restart
return run_restart(args)
if cmd == "status": if cmd == "status":
from castle_cli.commands.service import run_status from castle_cli.commands.service import run_status
@@ -353,10 +325,6 @@ def main() -> int:
from castle_cli.commands.doctor import run_doctor from castle_cli.commands.doctor import run_doctor
return run_doctor(args) return run_doctor(args)
if cmd == "deploy":
from castle_cli.commands.deploy import run_deploy
return run_deploy(args)
if cmd == "list": if cmd == "list":
from castle_cli.commands.list_cmd import run_list from castle_cli.commands.list_cmd import run_list

View File

@@ -26,7 +26,7 @@ class TestDoctor:
assert "repo: not set" in out assert "repo: not set" in out
assert "control plane missing" in out assert "control plane missing" in out
# Every failing check offers a concrete next command. # Every failing check offers a concrete next command.
assert "castle deploy" in out assert "castle apply" in out
def test_load_failure_is_first_fail(self, capsys: object) -> None: def test_load_failure_is_first_fail(self, capsys: object) -> None:
"""A castle.yaml that won't load is surfaced as a FAIL, not a traceback.""" """A castle.yaml that won't load is surfaced as a FAIL, not a traceback."""

View File

@@ -70,6 +70,30 @@ class DeployResult:
registry: NodeRegistry | None = None registry: NodeRegistry | None = None
@dataclass
class ApplyResult:
"""Result of a converge (`castle apply`): what actually changed.
`deploy` renders config → artifacts; `apply` renders *and then* reconciles the
running system to match, so the interesting output is the diff it enacted.
"""
activated: list[str] = field(default_factory=list)
restarted: list[str] = field(default_factory=list)
deactivated: list[str] = field(default_factory=list)
unchanged: list[str] = field(default_factory=list)
pruned: list[str] = field(default_factory=list)
messages: list[str] = field(default_factory=list)
registry: NodeRegistry | None = None
# True for a `--plan` run: the diff was computed but nothing was written or
# activated. Lets callers render "would activate…" vs "activated…".
planned: bool = False
@property
def changed(self) -> bool:
return bool(self.activated or self.restarted or self.deactivated or self.pruned)
def deploy(target_name: str | None = None, root: Path | None = None) -> DeployResult: def deploy(target_name: str | None = None, root: Path | None = None) -> DeployResult:
"""Deploy from castle.yaml to ~/.castle/. """Deploy from castle.yaml to ~/.castle/.
@@ -86,16 +110,7 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
ensure_dirs() ensure_dirs()
# Build node config # Build node config
node = NodeConfig( node = _node_config(config)
castle_root=str(config.root),
gateway_port=config.gateway.port,
gateway_tls=config.gateway.tls,
gateway_domain=config.gateway.domain,
acme_email=config.gateway.acme_email,
acme_dns_provider=config.gateway.acme_dns_provider,
public_domain=config.gateway.public_domain,
tunnel_id=config.gateway.tunnel_id,
)
# Load existing registry to preserve entries not being redeployed, # Load existing registry to preserve entries not being redeployed,
# or start fresh if deploying all # or start fresh if deploying all
@@ -158,6 +173,131 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
return result return result
def _node_config(config: CastleConfig) -> NodeConfig:
"""The registry NodeConfig derived from a config's gateway settings."""
return NodeConfig(
castle_root=str(config.root),
gateway_port=config.gateway.port,
gateway_tls=config.gateway.tls,
gateway_domain=config.gateway.domain,
acme_email=config.gateway.acme_email,
acme_dns_provider=config.gateway.acme_dns_provider,
public_domain=config.gateway.public_domain,
tunnel_id=config.gateway.tunnel_id,
)
def _unit_file_for(name: str, is_job: bool) -> Path:
"""On-disk systemd unit path for a deployment (timer if it's a job)."""
return SYSTEMD_USER_DIR / (timer_name(name) if is_job else unit_name(name))
def _unit_bytes(name: str, is_job: bool) -> str | None:
"""Current unit-file contents, or None if it isn't written yet."""
path = _unit_file_for(name, is_job)
return path.read_text() if path.exists() else None
def apply(
target_name: str | None = None,
root: Path | None = None,
plan: bool = False,
) -> ApplyResult:
"""Converge the running system to match config — the one honest bring-up.
`apply` = `deploy` (render units/Caddyfile/tunnel) **plus** reconcile: activate
every enabled deployment that isn't live, restart any whose unit changed,
deactivate the disabled ones. It replaces the old two-step ``deploy && start``
and the per-kind enable/disable/install verbs — the mechanism varies by manager
(systemd unit / PATH install / gateway route), the verb never does.
`plan=True` computes and returns the diff **without writing or touching the
runtime** (the ``--plan`` dry run).
"""
import asyncio
from castle_core.lifecycle import activate, deactivate, is_active
config = load_config(root)
names = [n for n in config.deployments if not target_name or n == target_name]
is_job = {n: (n in config.jobs) for n in names}
# Snapshot BEFORE rendering: liveness + current unit bytes (for restart-on-change).
before_active = {n: is_active(n, config) for n in names}
before_unit = {n: _unit_bytes(n, is_job[n]) for n in names}
# Desired state, rendered in memory to classify each deployment. For a real run
# this is recomputed by deploy() below (which also writes it); cheap and keeps
# the plan/apply classification identical.
desired = {n: _build_deployed(config, n, config.deployments[n], []) for n in names}
def _classify(name: str, after_unit: str | None) -> str:
dep = desired[name]
if not dep.enabled:
return "deactivate" if before_active[name] else "unchanged"
if not before_active[name]:
return "activate"
if dep.manager == "systemd" and before_unit[name] != after_unit:
return "restart"
return "unchanged"
result = ApplyResult(registry=NodeRegistry(node=_node_config(config), deployed=desired))
if plan:
# No writes: for systemd, predict the new unit bytes by rendering to a string
# so "would restart" is accurate; other managers never restart.
result.planned = True
for name in names:
after = _render_unit_preview(config, name, desired[name], is_job[name])
_record(result, name, _classify(name, after))
return result
# Real run: render everything (writes units/Caddyfile/tunnel, daemon-reload,
# gateway reload, orphan prune), then reconcile the runtime.
deploy_result = deploy(target_name, root)
result.messages = list(deploy_result.messages)
result.registry = deploy_result.registry
for name in names:
after_unit = _unit_bytes(name, is_job[name])
action = _classify(name, after_unit)
if action == "activate":
asyncio.run(activate(name, config, config.root))
result.activated.append(name)
elif action == "deactivate":
asyncio.run(deactivate(name, config, config.root))
result.deactivated.append(name)
elif action == "restart":
unit = timer_name(name) if is_job[name] else unit_name(name)
subprocess.run(["systemctl", "--user", "restart", unit], check=False)
result.restarted.append(name)
else:
result.unchanged.append(name)
return result
def _record(result: ApplyResult, name: str, action: str) -> None:
{
"activate": result.activated,
"deactivate": result.deactivated,
"restart": result.restarted,
"unchanged": result.unchanged,
}[action].append(name)
def _render_unit_preview(
config: CastleConfig, name: str, dep: Deployment, is_job: bool
) -> str | None:
"""The unit bytes `deploy` would write for the deployment we'd restart (the
.timer for a job, the .service for a service), for --plan restart detection.
None when there's no unit to compare (non-systemd, or unmanaged)."""
files = _render_unit_files(config, name, dep)
if not files:
return None
return files.get(timer_name(name) if is_job else unit_name(name))
# Gateway service name in the registry → its systemd unit (castle-castle-gateway). # Gateway service name in the registry → its systemd unit (castle-castle-gateway).
_GATEWAY_NAME = "castle-gateway" _GATEWAY_NAME = "castle-gateway"
@@ -387,6 +527,7 @@ def _build_deployed(
public=bool(dep.public), public=bool(dep.public),
static_root=static_root, static_root=static_root,
managed=False, managed=False,
enabled=dep.enabled,
) )
if isinstance(dep, PathDeployment): if isinstance(dep, PathDeployment):
return Deployment( return Deployment(
@@ -396,6 +537,7 @@ def _build_deployed(
kind=kind, kind=kind,
stack=stack, stack=stack,
managed=False, managed=False,
enabled=dep.enabled,
) )
if isinstance(dep, RemoteDeployment): if isinstance(dep, RemoteDeployment):
return Deployment( return Deployment(
@@ -406,6 +548,7 @@ def _build_deployed(
stack=stack, stack=stack,
base_url=dep.base_url, base_url=dep.base_url,
managed=False, managed=False,
enabled=dep.enabled,
) )
# systemd: a supervised process (a service, or a job when scheduled). # systemd: a supervised process (a service, or a job when scheduled).
@@ -465,6 +608,7 @@ def _build_deployed(
public=bool(dep.public and expose), public=bool(dep.public and expose),
schedule=getattr(dep, "schedule", None), schedule=getattr(dep, "schedule", None),
managed=managed, managed=managed,
enabled=dep.enabled,
) )
@@ -711,31 +855,40 @@ def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None:
_teardown_unit(path.name, messages) _teardown_unit(path.name, messages)
def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> None: def _render_unit_files(
"""Generate systemd units from the registry.""" config: CastleConfig, name: str, deployed: Deployment
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True) ) -> dict[str, str]:
"""The exact unit files `deploy` would write for a deployment: {filename: content}.
for name, deployed in registry.deployed.items(): Empty for a non-systemd-managed deployment (caddy/path/none have no unit). The
single source of truth for unit bytes — used both to write units and to predict
restart-on-change in `apply`/`--plan`, so the prediction can never drift from
what actually gets written.
"""
if not deployed.managed: if not deployed.managed:
continue return {}
systemd_spec = None systemd_spec = None
dep = config.deployments.get(name) dep = config.deployments.get(name)
manage = getattr(dep, "manage", None) manage = getattr(dep, "manage", None)
if manage and manage.systemd: if manage and manage.systemd:
systemd_spec = manage.systemd systemd_spec = manage.systemd
svc_name = unit_name(name) files = {
svc_content = generate_unit_from_deployed( unit_name(name): generate_unit_from_deployed(
name, deployed, systemd_spec, env_file=unit_env_file(deployed, name) name, deployed, systemd_spec, env_file=unit_env_file(deployed, name)
) )
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content) }
if deployed.schedule: if deployed.schedule:
timer_content = generate_timer( files[timer_name(name)] = generate_timer(
name, name, schedule=deployed.schedule, description=deployed.description
schedule=deployed.schedule,
description=deployed.description,
) )
tmr_name = timer_name(name) return files
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> None:
"""Generate systemd units from the registry."""
SYSTEMD_USER_DIR.mkdir(parents=True, exist_ok=True)
for name, deployed in registry.deployed.items():
for fname, content in _render_unit_files(config, name, deployed).items():
(SYSTEMD_USER_DIR / fname).write_text(content)

View File

@@ -77,6 +77,10 @@ def _local_routes(
deployments = getattr(config, "deployments", None) deployments = getattr(config, "deployments", None)
if deployments is not None: if deployments is not None:
for name, dep in sorted(deployments.items()): for name, dep in sorted(deployments.items()):
# A disabled deployment is defined but not running — no route (else it
# would 502). `castle apply` converges it off.
if not dep.enabled:
continue
if isinstance(dep, CaddyDeployment): if isinstance(dep, CaddyDeployment):
src = _program_source(config, dep.program) src = _program_source(config, dep.program)
if src is not None: if src is not None:
@@ -88,6 +92,8 @@ def _local_routes(
return out return out
# No config → route from the deployed registry snapshot. # No config → route from the deployed registry snapshot.
for name, d in sorted(registry.deployed.items()): for name, d in sorted(registry.deployed.items()):
if not d.enabled:
continue
if d.static_root: if d.static_root:
out.append((name, "static", d.static_root)) out.append((name, "static", d.static_root))
elif d.subdomain and (d.port or d.base_url): elif d.subdomain and (d.port or d.base_url):

View File

@@ -311,6 +311,11 @@ class DeploymentBase(BaseModel):
) )
description: str | None = None description: str | None = None
defaults: DefaultsSpec | None = None defaults: DefaultsSpec | None = None
# Declared on/off state. `castle apply` converges reality to this: enabled
# deployments are activated (service started, tool installed, route served),
# disabled ones are deactivated but kept in the catalog. This is *desired
# state*, not a runtime toggle — the only way to durably stop something.
enabled: bool = True
class SystemdDeployment(DeploymentBase): class SystemdDeployment(DeploymentBase):

View File

@@ -72,6 +72,9 @@ class Deployment:
base_url: str | None = None base_url: str | None = None
schedule: str | None = None schedule: str | None = None
managed: bool = False managed: bool = False
# Declared desired state (from the deployment's `enabled:`). `castle apply`
# activates enabled deployments and deactivates disabled ones. Default True.
enabled: bool = True
@dataclass @dataclass
@@ -155,6 +158,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
base_url=comp_data.get("base_url"), base_url=comp_data.get("base_url"),
schedule=comp_data.get("schedule"), schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False), managed=comp_data.get("managed", False),
enabled=comp_data.get("enabled", True),
) )
return NodeRegistry(node=node, deployed=deployed) return NodeRegistry(node=node, deployed=deployed)
@@ -225,6 +229,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["schedule"] = comp.schedule entry["schedule"] = comp.schedule
if comp.managed: if comp.managed:
entry["managed"] = comp.managed entry["managed"] = comp.managed
# Only emit when disabled — default-True omission keeps existing
# registries byte-identical and matches the load-side default.
if not comp.enabled:
entry["enabled"] = comp.enabled
data["deployed"][name] = entry data["deployed"][name] = entry
with open(path, "w") as f: with open(path, "w") as f:

74
core/tests/test_apply.py Normal file
View File

@@ -0,0 +1,74 @@
"""Tests for `castle apply` convergence — the diff classification (plan mode).
Plan mode computes the activate/restart/deactivate/unchanged buckets without
writing or touching the runtime, so it's the deterministic way to test the diff.
`is_active` is patched to control the "before" state; unit bytes come from the
(empty) temp home, so a live systemd service with no prior unit reads as changed.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from castle_core.deploy import _render_unit_preview, apply
from castle_core.registry import Deployment
def _plan(castle_root: Path, active: dict[str, bool]):
"""Run apply(plan=True) with is_active stubbed to `active` (default False)."""
with patch("castle_core.lifecycle.is_active", side_effect=lambda n, c: active.get(n, False)):
return apply(root=castle_root, plan=True)
class TestApplyPlan:
def test_fresh_converge_activates_enabled(self, castle_root: Path) -> None:
"""Nothing running → every enabled deployment is 'activate'; no writes."""
result = _plan(castle_root, active={})
assert result.planned is True
assert set(result.activated) == {"test-svc", "test-tool", "test-job"}
assert result.deactivated == []
assert result.restarted == []
def test_disabled_active_deployment_deactivates(self, castle_root: Path) -> None:
"""A deployment with enabled:false that's currently up → 'deactivate'."""
# Turn the tool off in config.
tool = castle_root / "services" / "test-tool.yaml"
tool.write_text(tool.read_text() + "enabled: false\n")
result = _plan(castle_root, active={"test-tool": True})
assert "test-tool" in result.deactivated
assert "test-tool" not in result.activated
def test_disabled_inactive_is_unchanged(self, castle_root: Path) -> None:
"""enabled:false and already down → nothing to do."""
tool = castle_root / "services" / "test-tool.yaml"
tool.write_text(tool.read_text() + "enabled: false\n")
result = _plan(castle_root, active={})
assert "test-tool" in result.unchanged
assert "test-tool" not in result.deactivated
def test_active_service_with_changed_unit_restarts(self, castle_root: Path) -> None:
"""An up systemd service whose rendered unit differs from disk → 'restart'.
The temp home has no prior unit file (before-bytes = None), so any live
systemd deployment classifies as changed → restart, not a silent no-op.
"""
result = _plan(castle_root, active={"test-svc": True})
assert "test-svc" in result.restarted
assert "test-svc" not in result.activated
assert result.changed is True
def test_render_unit_preview_none_for_non_systemd() -> None:
"""Non-systemd managers have no unit file — preview is None (never 'restart').
A path deployment is unmanaged, so the renderer returns before touching config.
"""
tool = Deployment(manager="path", run_cmd=[], kind="tool")
assert _render_unit_preview(None, "x", tool, is_job=False) is None # type: ignore[arg-type]

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"
} }