Merge pull request #1 from payneio/refactor/apply-converge
castle apply — one declarative converge verb
This commit is contained in:
40
AGENTS.md
40
AGENTS.md
@@ -59,38 +59,37 @@ castle program add <path|git-url> [--name ...] # adopt EXISTIN
|
||||
castle program clone [name] # provision repo: source
|
||||
castle program delete <name> [--source] [-y]
|
||||
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
|
||||
|
||||
# Services — daemons (manager: systemd, no schedule)
|
||||
castle service list|info <name> [--json]
|
||||
castle service create <name> [--program P] [--port N] [--health ...] [--launcher ...]
|
||||
castle service deploy <name> # generate unit + gateway route
|
||||
castle service enable|disable <name> # systemd enable/disable (+ boot)
|
||||
castle service start|stop|restart <name>
|
||||
castle service restart <name> # imperative bounce
|
||||
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 <list|info|delete|deploy|enable|disable|start|stop|restart|logs> ...
|
||||
castle job <list|info|delete|restart|logs> ...
|
||||
|
||||
# Tools — CLIs on PATH (manager: path)
|
||||
castle tool list [--json] # each tool's executable + description + install state
|
||||
castle tool info <name> [--json]
|
||||
castle tool install|uninstall <name>
|
||||
|
||||
# Platform-wide
|
||||
castle list [--kind ...] [--stack ...] [--json] # all deployments
|
||||
castle apply [name] [--plan] # converge runtime to config — the workhorse
|
||||
castle list [--kind ...] [--stack ...] [--json] # all deployments
|
||||
castle status # unified health/status
|
||||
castle doctor # diagnose setup + runtime, with fix hints
|
||||
castle deploy [name] # apply config → units + Caddyfile
|
||||
castle start | stop | restart # all services (+ gateway)
|
||||
castle gateway start|stop|reload|status # the Caddy gateway
|
||||
castle doctor # diagnose setup + runtime, with fix hints
|
||||
castle restart [name] # imperative bounce (one or all)
|
||||
castle gateway reload|status # the Caddy gateway
|
||||
```
|
||||
|
||||
`castle service`/`job`/`tool` are **views** over the one deployment set, filtered
|
||||
by derived kind. Bringing everything online is the two honest steps
|
||||
**`castle deploy && castle start`** (apply config, then start).
|
||||
by derived kind. Lifecycle is **convergence**: edit config, then **`castle apply`**
|
||||
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:`)
|
||||
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
|
||||
castle program test my-service
|
||||
castle service create my-service --program my-service --port 9001
|
||||
castle service deploy my-service && castle service enable my-service
|
||||
castle gateway reload
|
||||
castle apply my-service # renders the unit + gateway route and starts it
|
||||
```
|
||||
|
||||
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
|
||||
castle program create my-tool --stack python-cli --description "Does Y"
|
||||
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
|
||||
@@ -134,7 +132,7 @@ A job is a `manager: systemd` deployment with a `schedule` (cron). Generates a
|
||||
|
||||
```bash
|
||||
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
|
||||
@@ -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)
|
||||
castle program build my-frontend # produces 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
|
||||
@@ -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):
|
||||
- **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
|
||||
(`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`
|
||||
in `/etc/sysctl.d/` (beats `setcap`, which `NoNewPrivileges` would void).
|
||||
- **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
|
||||
`<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
|
||||
the `castle-tunnel` service running. → One-time setup: **`docs/tunnel-setup.md`**.
|
||||
|
||||
|
||||
37
README.md
37
README.md
@@ -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*,
|
||||
each with its own.
|
||||
|
||||
Standing everything up is the two honest steps `castle deploy` (regenerate systemd
|
||||
units and gateway config from your config) then `castle start` (enable/start it).
|
||||
There is no bundled "up".
|
||||
Standing everything up is one honest step: `castle apply` renders systemd units and
|
||||
gateway config from your config, then reconciles the runtime to match — activating
|
||||
what's enabled, restarting what changed, deactivating what's disabled. Edit config,
|
||||
`castle apply`; `castle apply --plan` shows the diff first.
|
||||
|
||||
## Stacks
|
||||
|
||||
@@ -112,7 +113,7 @@ git clone <this-repo> ~/castle && cd ~/castle
|
||||
# Postgres), creates ~/.castle, registers Castle's own control plane, builds the UI.
|
||||
./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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
| 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)
|
||||
castle program create my-api --stack python-fastapi --description "Does something"
|
||||
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
|
||||
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
|
||||
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)
|
||||
castle program add ~/projects/some-rust-tool
|
||||
@@ -162,28 +163,32 @@ castle program add ~/projects/some-rust-tool
|
||||
## CLI
|
||||
|
||||
Operations live under the resource they act on. `program` is the catalog;
|
||||
`service`, `job`, and `tool` are **views** over the one deployment set; platform
|
||||
lifecycle is top-level.
|
||||
`service`, `job`, and `tool` are **views** over the one deployment set. Lifecycle
|
||||
is convergence — `castle apply` — not a pile of per-kind verbs.
|
||||
|
||||
```
|
||||
# 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
|
||||
|
||||
# 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 tool list|info|install|uninstall # CLIs on your PATH
|
||||
castle tool list|info # CLIs on your PATH
|
||||
|
||||
# Platform-wide
|
||||
castle apply [name] [--plan] # converge runtime to config — the workhorse
|
||||
castle list [--kind K] [--stack S] [--json] # catalog + every deployment view
|
||||
castle status # unified runtime status
|
||||
castle doctor # diagnose setup + health, with fix hints
|
||||
castle deploy [name] # apply config → units + Caddyfile
|
||||
castle start | stop | restart # all deployments (+ gateway)
|
||||
castle gateway start|stop|reload|status
|
||||
castle restart [name] # imperative bounce (one or all)
|
||||
castle gateway 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
|
||||
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
|
||||
|
||||
104
app/src/components/ConvergePanel.tsx
Normal file
104
app/src/components/ConvergePanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 type { JobSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import { useServiceAction, useSetEnabled } from "@/services/api/hooks"
|
||||
import { launcherLabel } from "@/lib/labels"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
@@ -11,12 +11,9 @@ interface JobCardProps {
|
||||
}
|
||||
|
||||
export function JobCard({ job, health }: JobCardProps) {
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
const isDown = health?.status === "down"
|
||||
|
||||
const doAction = (action: string) => {
|
||||
mutate({ name: job.id, action })
|
||||
}
|
||||
const restart = useServiceAction()
|
||||
const setEnabled = useSetEnabled()
|
||||
const busy = restart.isPending || setEnabled.isPending
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="relative z-10 flex items-center gap-1">
|
||||
{isDown && (
|
||||
<button
|
||||
onClick={() => doAction("start")}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||
title="Start"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => doAction("restart")}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
onClick={() => setEnabled.mutate({ name: job.id, enabled: !job.enabled })}
|
||||
disabled={busy}
|
||||
className={`p-1 rounded transition-colors disabled:opacity-40 ${
|
||||
job.enabled
|
||||
? "hover:bg-red-800/30 text-red-400"
|
||||
: "hover:bg-green-800/30 text-green-400"
|
||||
}`}
|
||||
title={job.enabled ? "Disable" : "Enable"}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<Power size={14} />
|
||||
</button>
|
||||
{!isDown && (
|
||||
{job.enabled && (
|
||||
<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"
|
||||
onClick={() => restart.mutate({ name: job.id, action: "restart" })}
|
||||
disabled={busy}
|
||||
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
>
|
||||
<Square size={14} />
|
||||
<RefreshCw size={14} className={restart.isPending ? "animate-spin" : ""} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 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 { HealthBadge } from "./HealthBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
@@ -14,13 +14,9 @@ interface ServiceCardProps {
|
||||
|
||||
export function ServiceCard({ service, health }: ServiceCardProps) {
|
||||
const hasHttp = service.port != null
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
|
||||
const doAction = (action: string) => {
|
||||
mutate({ name: service.id, action })
|
||||
}
|
||||
|
||||
const isDown = health?.status === "down"
|
||||
const restart = useServiceAction()
|
||||
const setEnabled = useSetEnabled()
|
||||
const busy = restart.isPending || setEnabled.isPending
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<div className="relative z-10 flex items-center gap-1">
|
||||
{isDown && (
|
||||
<button
|
||||
onClick={() => doAction("start")}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||
title="Start"
|
||||
>
|
||||
<Play size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => doAction("restart")}
|
||||
disabled={isPending}
|
||||
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
onClick={() => setEnabled.mutate({ name: service.id, enabled: !service.enabled })}
|
||||
disabled={busy}
|
||||
className={`p-1 rounded transition-colors disabled:opacity-40 ${
|
||||
service.enabled
|
||||
? "hover:bg-red-800/30 text-red-400"
|
||||
: "hover:bg-green-800/30 text-green-400"
|
||||
}`}
|
||||
title={service.enabled ? "Disable" : "Enable"}
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
<Power size={14} />
|
||||
</button>
|
||||
{!isDown && (
|
||||
{service.enabled && (
|
||||
<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"
|
||||
onClick={() => restart.mutate({ name: service.id, action: "restart" })}
|
||||
disabled={busy}
|
||||
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
>
|
||||
<Square size={14} />
|
||||
<RefreshCw size={14} className={restart.isPending ? "animate-spin" : ""} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
|
||||
setMessage({
|
||||
type: "ok",
|
||||
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",
|
||||
})
|
||||
if (isDeployment) setPendingApply(true)
|
||||
@@ -58,16 +58,21 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
|
||||
setApplying(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
await apiClient.post(`/deploy`, { name: deployment.id })
|
||||
if (configSection === "services") {
|
||||
await apiClient.post(`/services/${deployment.id}/restart`, {})
|
||||
}
|
||||
// One converge: renders units/routes and reconciles the runtime (restarts
|
||||
// only what changed). No separate restart call needed.
|
||||
await apiClient.post(`/apply`, { name: deployment.id })
|
||||
setPendingApply(false)
|
||||
setMessage({ type: "ok", text: "Applied — the change is now live." })
|
||||
qc.invalidateQueries({ queryKey: ["status"] })
|
||||
qc.invalidateQueries({ queryKey: [configSection] })
|
||||
qc.invalidateQueries()
|
||||
onRefetch()
|
||||
} 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)
|
||||
try {
|
||||
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"
|
||||
>
|
||||
<RefreshCw size={12} className={applying ? "animate-spin" : ""} />
|
||||
{applying
|
||||
? "Applying…"
|
||||
: configSection === "services"
|
||||
? "Apply (deploy & restart)"
|
||||
: "Apply (deploy)"}
|
||||
{applying ? "Applying…" : "Apply"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -108,12 +108,9 @@ export function CreateDeploymentForm({
|
||||
try {
|
||||
setBusy("Saving…")
|
||||
await apiClient.put(`/config/deployments/${name}`, { config: buildConfig() })
|
||||
setBusy("Deploying…")
|
||||
await apiClient.post(`/deploy`, { name })
|
||||
if (kind === "service") {
|
||||
setBusy("Starting…")
|
||||
await apiClient.post(`/services/${name}/start`, {})
|
||||
}
|
||||
// Converge: render + activate the new deployment in one step.
|
||||
setBusy("Applying…")
|
||||
await apiClient.post(`/apply`, { name })
|
||||
qc.invalidateQueries({ queryKey: ["services"] })
|
||||
qc.invalidateQueries({ queryKey: ["jobs"] })
|
||||
qc.invalidateQueries({ queryKey: ["programs"] })
|
||||
|
||||
@@ -1,44 +1,40 @@
|
||||
import { Play, RefreshCw, Square } from "lucide-react"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import type { HealthStatus } from "@/types"
|
||||
import { Power, RefreshCw } from "lucide-react"
|
||||
import { useServiceAction, useSetEnabled } from "@/services/api/hooks"
|
||||
|
||||
interface ServiceControlsProps {
|
||||
name: string
|
||||
health?: HealthStatus
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export function ServiceControls({ name, health }: ServiceControlsProps) {
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
const isDown = health?.status === "down"
|
||||
// Lifecycle is convergence: the Power toggle sets desired on/off state and applies
|
||||
// (activate/deactivate); Restart is the one imperative bounce. No raw start/stop.
|
||||
export function ServiceControls({ name, enabled }: ServiceControlsProps) {
|
||||
const restart = useServiceAction()
|
||||
const setEnabled = useSetEnabled()
|
||||
const busy = restart.isPending || setEnabled.isPending
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{isDown && (
|
||||
<button
|
||||
onClick={() => mutate({ name, action: "start" })}
|
||||
disabled={isPending}
|
||||
className="p-1.5 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
||||
title="Start"
|
||||
>
|
||||
<Play size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => mutate({ name, action: "restart" })}
|
||||
disabled={isPending}
|
||||
className="p-1.5 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
onClick={() => setEnabled.mutate({ name, enabled: !enabled })}
|
||||
disabled={busy}
|
||||
className={`p-1.5 rounded transition-colors disabled:opacity-40 ${
|
||||
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)"}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
<Power size={16} />
|
||||
</button>
|
||||
{!isDown && (
|
||||
{enabled && (
|
||||
<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"
|
||||
onClick={() => restart.mutate({ name, action: "restart" })}
|
||||
disabled={busy}
|
||||
className="p-1.5 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
|
||||
title="Restart"
|
||||
>
|
||||
<Square size={16} />
|
||||
<RefreshCw size={16} className={restart.isPending ? "animate-spin" : ""} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "@/services/api/hooks"
|
||||
import { NodeBar } from "@/components/NodeBar"
|
||||
import { PageHeader } from "@/components/PageHeader"
|
||||
import { ConvergePanel } from "@/components/ConvergePanel"
|
||||
|
||||
export function Overview() {
|
||||
const { data: services } = useServices()
|
||||
@@ -96,6 +97,8 @@ export function Overview() {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ConvergePanel />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useParams } from "react-router-dom"
|
||||
import { Clock } from "lucide-react"
|
||||
import { useJob, useStatus } from "@/services/api/hooks"
|
||||
import { useJob } from "@/services/api/hooks"
|
||||
import { LogViewer } from "@/components/LogViewer"
|
||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||
import { ServiceControls } from "@/components/detail/ServiceControls"
|
||||
@@ -10,8 +10,6 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
||||
export function ScheduledDetailPage() {
|
||||
const { name } = useParams<{ name: string }>()
|
||||
const { data: deployment, isLoading, error, refetch } = useJob(name ?? "")
|
||||
const { data: statusResp } = useStatus()
|
||||
const health = statusResp?.statuses.find((s) => s.id === name)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -38,7 +36,7 @@ export function ScheduledDetailPage() {
|
||||
stack={deployment.stack}
|
||||
source={deployment.source}
|
||||
>
|
||||
<ServiceControls name={deployment.id} health={health} />
|
||||
<ServiceControls name={deployment.id} enabled={deployment.enabled} />
|
||||
</DetailHeader>
|
||||
|
||||
{deployment.schedule && (
|
||||
|
||||
@@ -51,7 +51,7 @@ export function ServiceDetailPage() {
|
||||
{!isStatic && (
|
||||
<div className="flex items-center gap-2">
|
||||
{health && <HealthBadge status={health.status} latency={health.latency_ms} />}
|
||||
<ServiceControls name={deployment.id} health={health} />
|
||||
<ServiceControls name={deployment.id} enabled={deployment.enabled} />
|
||||
</div>
|
||||
)}
|
||||
</DetailHeader>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useParams, Link } from "react-router-dom"
|
||||
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 { 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>
|
||||
)}
|
||||
|
||||
{/* A tool's PATH deployment: install/uninstall is its start/stop. Its
|
||||
live state is `installed` (on PATH), which the endpoint sets directly. */}
|
||||
<PathLifecycle name={deployment.id} installed={deployment.installed} onDone={refetch} />
|
||||
{/* A tool's PATH deployment: enabling converges it onto PATH, disabling
|
||||
removes it. `installed` is the live state; `enabled` the desired one. */}
|
||||
<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">
|
||||
<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({
|
||||
name,
|
||||
enabled,
|
||||
installed: installedState,
|
||||
onDone,
|
||||
}: {
|
||||
name: string
|
||||
enabled: boolean
|
||||
installed: boolean | null
|
||||
onDone: () => void
|
||||
}) {
|
||||
const { mutate, isPending } = useProgramAction()
|
||||
const { mutate, isPending } = useSetEnabled()
|
||||
const installed = installedState === true
|
||||
const dot =
|
||||
installedState === true
|
||||
@@ -86,21 +93,20 @@ function PathLifecycle({
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className={`h-2 w-2 rounded-full shrink-0 ${dot}`} />
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
onClick={() =>
|
||||
mutate({ name, action: installed ? "uninstall" : "install" }, { onSuccess: onDone })
|
||||
}
|
||||
onClick={() => mutate({ name, enabled: !enabled }, { onSuccess: onDone })}
|
||||
disabled={isPending}
|
||||
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-green-800 text-green-400 hover:bg-green-800/30"
|
||||
}`}
|
||||
>
|
||||
{isPending && <Loader2 size={14} className="animate-spin" />}
|
||||
{installed ? "Uninstall" : "Install"}
|
||||
{enabled ? "Disable" : "Enable"}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ServiceSummary {
|
||||
systemd: SystemdInfo | null
|
||||
program: string | null
|
||||
source: string | null
|
||||
enabled: boolean // declared desired state; `apply` converges to it
|
||||
node: string | null
|
||||
}
|
||||
|
||||
@@ -37,6 +38,7 @@ export interface JobSummary {
|
||||
systemd: SystemdInfo | null
|
||||
program: string | null
|
||||
source: string | null
|
||||
enabled: boolean // declared desired state; `apply` converges to it
|
||||
node: string | null
|
||||
}
|
||||
|
||||
@@ -98,6 +100,7 @@ export interface DeploymentSummary {
|
||||
schedule: string | null
|
||||
installed: boolean | null
|
||||
active: boolean | null
|
||||
enabled: boolean // declared desired state; `apply` converges to it
|
||||
node: string | null
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from castle_core.config import (
|
||||
)
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/config", tags=["config"])
|
||||
@@ -323,6 +323,29 @@ def delete_deployment(name: str) -> dict:
|
||||
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}")
|
||||
def save_service(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""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)
|
||||
async def apply_config() -> ApplyResponse:
|
||||
"""Apply config: rebuild runtime from castle.yaml, then restart services.
|
||||
|
||||
Runs a full ``deploy`` so the registry, systemd units, and Caddyfile are all
|
||||
regenerated from the current castle.yaml (and the gateway reloaded) — this is
|
||||
what keeps the running config from drifting behind an edit. Then restarts the
|
||||
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.
|
||||
"""Converge the running system to match castle.yaml (a thin wrapper on core
|
||||
``apply``). Renders units/Caddyfile/tunnel, then reconciles the runtime —
|
||||
activating what's enabled and down, restarting only what changed, deactivating
|
||||
the disabled. Kept as ``/config/apply`` for compatibility; ``/apply`` exposes
|
||||
the same converge with per-deployment targeting and ``--plan``.
|
||||
"""
|
||||
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] = []
|
||||
errors: list[str] = []
|
||||
|
||||
# Rebuild registry + units + Caddyfile from castle.yaml off the event loop
|
||||
# (deploy is blocking: it shells out to systemctl and the gateway).
|
||||
try:
|
||||
result = await asyncio.to_thread(deploy)
|
||||
except Exception as e:
|
||||
return ApplyResponse(ok=False, actions=actions, errors=[f"Deploy failed: {e}"])
|
||||
actions.extend(result.messages)
|
||||
|
||||
# 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}")
|
||||
for verb, names in (
|
||||
("Activated", result.activated),
|
||||
("Restarted", result.restarted),
|
||||
("Deactivated", result.deactivated),
|
||||
):
|
||||
if names:
|
||||
actions.append(f"{verb} {', '.join(sorted(names))}")
|
||||
if not result.changed:
|
||||
actions.append("Already converged — nothing to do")
|
||||
|
||||
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]:
|
||||
|
||||
@@ -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 fastapi import APIRouter, HTTPException
|
||||
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):
|
||||
"""Optional request body for deploy."""
|
||||
class ApplyRequest(BaseModel):
|
||||
"""Optional request body for apply."""
|
||||
|
||||
name: str | None = None
|
||||
plan: bool = False
|
||||
|
||||
|
||||
class DeployResponse(BaseModel):
|
||||
"""Response from a deploy operation."""
|
||||
class ApplyResponse(BaseModel):
|
||||
"""The diff a converge enacted (or would enact, for a plan)."""
|
||||
|
||||
status: str
|
||||
deployed_count: int
|
||||
planned: bool
|
||||
changed: bool
|
||||
activated: list[str]
|
||||
restarted: list[str]
|
||||
deactivated: list[str]
|
||||
unchanged: list[str]
|
||||
messages: list[str]
|
||||
|
||||
|
||||
@router.post("/deploy", response_model=DeployResponse)
|
||||
def run_deploy(request: DeployRequest | None = None) -> DeployResponse:
|
||||
"""Deploy services and jobs from castle.yaml to runtime.
|
||||
@router.post("/apply", response_model=ApplyResponse)
|
||||
def run_apply(request: ApplyRequest | None = None) -> ApplyResponse:
|
||||
"""Converge the running system to match castle.yaml.
|
||||
|
||||
Resolves env vars and secrets, generates systemd units and Caddyfile,
|
||||
copies frontend build outputs, and reloads systemd.
|
||||
|
||||
Optionally pass a name to deploy a single service or job.
|
||||
Renders systemd units + the Caddyfile + tunnel config, then reconciles the
|
||||
runtime: activate enabled deployments that are down, restart any whose unit
|
||||
changed, deactivate disabled ones. Pass a name to converge one deployment,
|
||||
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:
|
||||
result = deploy(target_name=target_name)
|
||||
result = apply(target_name=name, plan=plan)
|
||||
except FileNotFoundError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e)) from e
|
||||
|
||||
return DeployResponse(
|
||||
return ApplyResponse(
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -35,6 +35,7 @@ class DeploymentSummary(BaseModel):
|
||||
schedule: str | None = None
|
||||
installed: bool | None = None
|
||||
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
|
||||
|
||||
|
||||
@@ -65,6 +66,7 @@ class ServiceSummary(BaseModel):
|
||||
systemd: SystemdInfo | None = None
|
||||
program: str | None = None # the program this deployment references, if any
|
||||
source: str | None = None
|
||||
enabled: bool = True # declared desired state; `apply` converges to it
|
||||
node: str | None = None
|
||||
|
||||
|
||||
@@ -87,6 +89,7 @@ class JobSummary(BaseModel):
|
||||
systemd: SystemdInfo | None = None
|
||||
program: str | None = None # the program this deployment references, if any
|
||||
source: str | None = None
|
||||
enabled: bool = True # declared desired state; `apply` converges to it
|
||||
node: str | None = None
|
||||
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@ def list_stacks() -> list[str]:
|
||||
# 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 = {
|
||||
"build",
|
||||
"test",
|
||||
"lint",
|
||||
"type-check",
|
||||
"check",
|
||||
"install",
|
||||
"uninstall",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -104,6 +103,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
|
||||
schedule=deployed.schedule,
|
||||
installed=installed,
|
||||
active=active,
|
||||
enabled=deployed.enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ def _summary_from_service(
|
||||
managed=managed,
|
||||
systemd=systemd_info,
|
||||
source=source,
|
||||
enabled=svc.enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -187,6 +188,7 @@ def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> Depl
|
||||
systemd=systemd_info,
|
||||
schedule=job.schedule,
|
||||
source=source,
|
||||
enabled=job.enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -276,6 +278,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
||||
subdomain=deployed.subdomain,
|
||||
managed=deployed.managed,
|
||||
systemd=systemd_info,
|
||||
enabled=deployed.enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -317,6 +320,7 @@ def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> Ser
|
||||
systemd=systemd_info,
|
||||
program=svc.program,
|
||||
source=source,
|
||||
enabled=svc.enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -333,6 +337,7 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
||||
schedule=deployed.schedule,
|
||||
managed=deployed.managed,
|
||||
systemd=systemd_info,
|
||||
enabled=deployed.enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -362,6 +367,7 @@ def _job_from_spec(name: str, job: SystemdDeployment, config: object) -> JobSumm
|
||||
systemd=systemd_info,
|
||||
program=job.program,
|
||||
source=source,
|
||||
enabled=job.enabled,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -153,19 +153,12 @@ def get_unit(name: str) -> dict[str, str | None]:
|
||||
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")
|
||||
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")
|
||||
|
||||
@@ -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,
|
||||
"nodes.get_registry": nodes_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,
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
nodes_mod.get_registry = originals["nodes.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"]
|
||||
|
||||
|
||||
|
||||
@@ -99,6 +99,13 @@ class TestServicesList:
|
||||
svc = next(s for s in data if s["id"] == "test-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:
|
||||
"""ServiceSummary does not have installed field."""
|
||||
response = client.get("/services")
|
||||
|
||||
60
cli/src/castle_cli/commands/apply.py
Normal file
60
cli/src/castle_cli/commands/apply.py
Normal 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
|
||||
@@ -158,12 +158,12 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
if stack == "supabase":
|
||||
print(" # edit migrations/, functions/, public/ — targets the shared 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:
|
||||
print(" uv sync")
|
||||
if kind == "service":
|
||||
print(f" uv run {name} # starts on port {port}")
|
||||
print(f" castle deploy {name}")
|
||||
print(f" castle apply {name}")
|
||||
print(f" castle test {name}")
|
||||
else:
|
||||
print(" # add code, then declare commands: in castle.yaml")
|
||||
|
||||
@@ -116,9 +116,9 @@ def run_delete(args: argparse.Namespace) -> int:
|
||||
|
||||
try:
|
||||
deploy()
|
||||
print("Reconciled runtime (castle deploy).")
|
||||
print("Reconciled runtime (castle apply).")
|
||||
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.
|
||||
if args.source and source_dir:
|
||||
|
||||
@@ -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
|
||||
@@ -88,7 +88,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
print(f" port: {args.port}")
|
||||
if proxy:
|
||||
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
|
||||
|
||||
|
||||
@@ -118,5 +118,5 @@ def run_job_create(args: argparse.Namespace) -> int:
|
||||
print(f"Created job '{name}'.")
|
||||
print(f" runs: {args.launcher} ({args.run or args.program or name})")
|
||||
print(f" schedule: {args.schedule}")
|
||||
print(f"\nNext: castle job deploy {name} && castle job enable {name}")
|
||||
print(f"\nNext: castle apply {name}")
|
||||
return 0
|
||||
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
@@ -206,7 +206,7 @@ def _check_runtime(config) -> list[Check]:
|
||||
Check(
|
||||
FAIL,
|
||||
"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))
|
||||
else:
|
||||
checks.append(
|
||||
Check(FAIL, "castle-api not running", hint="castle deploy && castle start")
|
||||
Check(FAIL, "castle-api not running", hint="castle apply")
|
||||
)
|
||||
|
||||
# Generated artifacts.
|
||||
@@ -244,7 +244,7 @@ def _check_runtime(config) -> list[Check]:
|
||||
FAIL,
|
||||
"generated specs missing",
|
||||
detail=", ".join(missing),
|
||||
hint="castle deploy",
|
||||
hint="castle apply",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -347,7 +347,7 @@ def _check_tls_exposure(config) -> list[Check]:
|
||||
WARN,
|
||||
"castle-tunnel not running",
|
||||
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))
|
||||
@@ -364,7 +364,7 @@ def _check_public_dns(config) -> Check:
|
||||
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
|
||||
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.
|
||||
"""
|
||||
import urllib.error
|
||||
|
||||
@@ -20,7 +20,7 @@ def _write_generated_files() -> None:
|
||||
ensure_dirs()
|
||||
|
||||
if not REGISTRY_PATH.exists():
|
||||
print("Error: no registry found. Run 'castle deploy' first.")
|
||||
print("Error: no registry found. Run 'castle apply' first.")
|
||||
return
|
||||
|
||||
registry = load_registry()
|
||||
@@ -56,7 +56,7 @@ def run_gateway(args: argparse.Namespace) -> int:
|
||||
def _gateway_dry_run() -> int:
|
||||
"""Print generated Caddyfile without applying."""
|
||||
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
|
||||
|
||||
registry = load_registry()
|
||||
@@ -126,7 +126,7 @@ def _gateway_status() -> int:
|
||||
print(f"Gateway: {'running' if status == 'active' else status}")
|
||||
|
||||
if not REGISTRY_PATH.exists():
|
||||
print(" (no registry — run 'castle deploy')")
|
||||
print(" (no registry — run 'castle apply')")
|
||||
return 0
|
||||
|
||||
from castle_core.generators.caddyfile import compute_routes
|
||||
|
||||
@@ -147,7 +147,7 @@ def run_info(args: argparse.Namespace) -> int:
|
||||
if deployed.secret_env_keys:
|
||||
print(f" {BOLD}secrets{RESET}: {', '.join(deployed.secret_env_keys)}")
|
||||
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
|
||||
source_dir = None
|
||||
|
||||
@@ -82,12 +82,12 @@ def run_run(args: argparse.Namespace) -> int:
|
||||
|
||||
# Service: deployed command from the registry.
|
||||
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
|
||||
|
||||
registry = load_registry()
|
||||
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
|
||||
|
||||
deployed = registry.deployed[name]
|
||||
|
||||
@@ -7,13 +7,10 @@ import subprocess
|
||||
|
||||
from castle_core.generators.systemd import (
|
||||
SYSTEMD_USER_DIR,
|
||||
generate_timer,
|
||||
generate_unit_from_deployed,
|
||||
timer_name,
|
||||
unit_name,
|
||||
)
|
||||
from castle_core.manifest import kind_for
|
||||
from castle_core.registry import REGISTRY_PATH, load_registry
|
||||
|
||||
from castle_cli.config import (
|
||||
CastleConfig,
|
||||
@@ -45,44 +42,35 @@ _PAST = {"start": "started", "stop": "stopped", "restart": "restarted"}
|
||||
|
||||
|
||||
def run_service_cmd(args: argparse.Namespace) -> int:
|
||||
"""`castle service <enable|disable|start|stop|restart> <name>`."""
|
||||
sub = args.service_command
|
||||
"""`castle service restart <name>` — the imperative bounce (only verb left).
|
||||
|
||||
Lifecycle (deploy/enable/disable/start/stop) is now convergence: `castle apply`.
|
||||
"""
|
||||
config = load_config()
|
||||
if sub == "enable":
|
||||
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
|
||||
return _unit_action(config, args.name, "restart", is_job=False)
|
||||
|
||||
|
||||
def run_job_cmd(args: argparse.Namespace) -> int:
|
||||
"""`castle job <enable|disable|start|stop|restart> <name>` (acts on the timer)."""
|
||||
sub = args.job_command
|
||||
"""`castle job restart <name>` — bounce the job's timer."""
|
||||
config = load_config()
|
||||
if sub == "enable":
|
||||
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
|
||||
return _unit_action(config, args.name, "restart", is_job=True)
|
||||
|
||||
|
||||
def run_platform(args: argparse.Namespace) -> int:
|
||||
"""Top-level `castle start|stop|restart` — the whole platform."""
|
||||
def run_restart(args: argparse.Namespace) -> int:
|
||||
"""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()
|
||||
action = args.command
|
||||
if action == "start":
|
||||
return _services_start(config)
|
||||
if action == "stop":
|
||||
return _services_stop(config)
|
||||
if action == "restart":
|
||||
name = getattr(args, "name", None)
|
||||
if not name:
|
||||
return _services_restart(config)
|
||||
return 1
|
||||
dep = config.deployments.get(name)
|
||||
if dep is None:
|
||||
print(f"Error: no deployment '{name}'.")
|
||||
return 1
|
||||
return _unit_action(config, name, "restart", is_job=(kind_for(dep) == "job"))
|
||||
|
||||
|
||||
_GATEWAY_NAME = "castle-gateway"
|
||||
@@ -256,78 +244,3 @@ def _service_status(config: CastleConfig) -> int:
|
||||
print()
|
||||
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
|
||||
|
||||
@@ -137,7 +137,7 @@ def run_tool_info(args: argparse.Namespace) -> int:
|
||||
if comp.system_dependencies:
|
||||
print(f" {BOLD}requires{RESET}: {', '.join(comp.system_dependencies)}")
|
||||
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:
|
||||
print(f"\n {DIM}run `{exes[0]} --help` for arguments{RESET}")
|
||||
print()
|
||||
|
||||
@@ -70,13 +70,6 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
|
||||
_add_name(p, "Program name")
|
||||
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:
|
||||
p = sub.add_parser(verb, help=f"Run {verb}")
|
||||
_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")
|
||||
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:
|
||||
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)
|
||||
|
||||
cap = f"{kind.capitalize()} name"
|
||||
_add_name(sub.add_parser("deploy", help=f"Deploy this {kind} (unit + gateway)"), cap)
|
||||
|
||||
p = sub.add_parser("enable", help=f"Enable and start the {kind}")
|
||||
_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)
|
||||
# Lifecycle is convergence: `castle apply [name]`. `restart` stays as the one
|
||||
# imperative bounce that doesn't change desired state.
|
||||
_add_name(sub.add_parser("restart", help=f"Restart the {kind} (imperative bounce)"), cap)
|
||||
|
||||
p = sub.add_parser("logs", help=f"View {kind} logs")
|
||||
_add_name(p, f"{kind.capitalize()} name")
|
||||
@@ -188,16 +169,23 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
p.add_argument("--dry-run", action="store_true")
|
||||
gw_sub.add_parser("status", help="Show gateway status")
|
||||
|
||||
# Platform-wide lifecycle (top-level)
|
||||
subparsers.add_parser("start", help="Start all services and the gateway")
|
||||
subparsers.add_parser("stop", help="Stop all services and the gateway")
|
||||
subparsers.add_parser("restart", help="Restart all services and jobs")
|
||||
# Convergence — the one lifecycle verb. Renders units/Caddyfile/tunnel, then
|
||||
# reconciles the runtime to match config (activate/restart/deactivate).
|
||||
p = subparsers.add_parser(
|
||||
"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(
|
||||
"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
|
||||
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:
|
||||
sub = args.program_command
|
||||
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}}}")
|
||||
return 1
|
||||
if sub == "list":
|
||||
@@ -246,14 +234,6 @@ def _dispatch_program(args: argparse.Namespace) -> int:
|
||||
from castle_cli.commands.run_cmd import run_run
|
||||
|
||||
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:
|
||||
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:
|
||||
sub = args.tool_command
|
||||
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
|
||||
if sub == "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
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
|
||||
sub = getattr(args, f"{kind}_command")
|
||||
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}}}")
|
||||
return 1
|
||||
if sub == "list":
|
||||
@@ -307,11 +279,7 @@ def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
|
||||
from castle_cli.commands.delete import run_delete
|
||||
|
||||
return run_delete(args)
|
||||
if sub == "deploy":
|
||||
from castle_cli.commands.deploy import run_deploy
|
||||
|
||||
return run_deploy(args)
|
||||
if sub in ("enable", "disable", "start", "stop", "restart"):
|
||||
if sub == "restart":
|
||||
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)
|
||||
@@ -341,10 +309,14 @@ def main() -> int:
|
||||
from castle_cli.commands.gateway import run_gateway
|
||||
|
||||
return run_gateway(args)
|
||||
if cmd in ("start", "stop", "restart"):
|
||||
from castle_cli.commands.service import run_platform
|
||||
if cmd == "apply":
|
||||
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":
|
||||
from castle_cli.commands.service import run_status
|
||||
|
||||
@@ -353,10 +325,6 @@ def main() -> int:
|
||||
from castle_cli.commands.doctor import run_doctor
|
||||
|
||||
return run_doctor(args)
|
||||
if cmd == "deploy":
|
||||
from castle_cli.commands.deploy import run_deploy
|
||||
|
||||
return run_deploy(args)
|
||||
if cmd == "list":
|
||||
from castle_cli.commands.list_cmd import run_list
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ class TestDoctor:
|
||||
assert "repo: not set" in out
|
||||
assert "control plane missing" in out
|
||||
# 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:
|
||||
"""A castle.yaml that won't load is surfaced as a FAIL, not a traceback."""
|
||||
|
||||
@@ -70,6 +70,30 @@ class DeployResult:
|
||||
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:
|
||||
"""Deploy from castle.yaml to ~/.castle/.
|
||||
|
||||
@@ -86,16 +110,7 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
||||
ensure_dirs()
|
||||
|
||||
# Build node config
|
||||
node = 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,
|
||||
)
|
||||
node = _node_config(config)
|
||||
|
||||
# Load existing registry to preserve entries not being redeployed,
|
||||
# or start fresh if deploying all
|
||||
@@ -158,6 +173,131 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
||||
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_NAME = "castle-gateway"
|
||||
|
||||
@@ -387,6 +527,7 @@ def _build_deployed(
|
||||
public=bool(dep.public),
|
||||
static_root=static_root,
|
||||
managed=False,
|
||||
enabled=dep.enabled,
|
||||
)
|
||||
if isinstance(dep, PathDeployment):
|
||||
return Deployment(
|
||||
@@ -396,6 +537,7 @@ def _build_deployed(
|
||||
kind=kind,
|
||||
stack=stack,
|
||||
managed=False,
|
||||
enabled=dep.enabled,
|
||||
)
|
||||
if isinstance(dep, RemoteDeployment):
|
||||
return Deployment(
|
||||
@@ -406,6 +548,7 @@ def _build_deployed(
|
||||
stack=stack,
|
||||
base_url=dep.base_url,
|
||||
managed=False,
|
||||
enabled=dep.enabled,
|
||||
)
|
||||
|
||||
# systemd: a supervised process (a service, or a job when scheduled).
|
||||
@@ -465,6 +608,7 @@ def _build_deployed(
|
||||
public=bool(dep.public and expose),
|
||||
schedule=getattr(dep, "schedule", None),
|
||||
managed=managed,
|
||||
enabled=dep.enabled,
|
||||
)
|
||||
|
||||
|
||||
@@ -711,31 +855,40 @@ def _prune_orphans(registry: NodeRegistry, messages: list[str]) -> None:
|
||||
_teardown_unit(path.name, messages)
|
||||
|
||||
|
||||
def _render_unit_files(
|
||||
config: CastleConfig, name: str, deployed: Deployment
|
||||
) -> dict[str, str]:
|
||||
"""The exact unit files `deploy` would write for a deployment: {filename: content}.
|
||||
|
||||
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:
|
||||
return {}
|
||||
systemd_spec = None
|
||||
dep = config.deployments.get(name)
|
||||
manage = getattr(dep, "manage", None)
|
||||
if manage and manage.systemd:
|
||||
systemd_spec = manage.systemd
|
||||
|
||||
files = {
|
||||
unit_name(name): generate_unit_from_deployed(
|
||||
name, deployed, systemd_spec, env_file=unit_env_file(deployed, name)
|
||||
)
|
||||
}
|
||||
if deployed.schedule:
|
||||
files[timer_name(name)] = generate_timer(
|
||||
name, schedule=deployed.schedule, description=deployed.description
|
||||
)
|
||||
return files
|
||||
|
||||
|
||||
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():
|
||||
if not deployed.managed:
|
||||
continue
|
||||
|
||||
systemd_spec = None
|
||||
dep = config.deployments.get(name)
|
||||
manage = getattr(dep, "manage", None)
|
||||
if manage and manage.systemd:
|
||||
systemd_spec = manage.systemd
|
||||
|
||||
svc_name = unit_name(name)
|
||||
svc_content = generate_unit_from_deployed(
|
||||
name, deployed, systemd_spec, env_file=unit_env_file(deployed, name)
|
||||
)
|
||||
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content)
|
||||
|
||||
if deployed.schedule:
|
||||
timer_content = generate_timer(
|
||||
name,
|
||||
schedule=deployed.schedule,
|
||||
description=deployed.description,
|
||||
)
|
||||
tmr_name = timer_name(name)
|
||||
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
|
||||
for fname, content in _render_unit_files(config, name, deployed).items():
|
||||
(SYSTEMD_USER_DIR / fname).write_text(content)
|
||||
|
||||
@@ -77,6 +77,10 @@ def _local_routes(
|
||||
deployments = getattr(config, "deployments", None)
|
||||
if deployments is not None:
|
||||
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):
|
||||
src = _program_source(config, dep.program)
|
||||
if src is not None:
|
||||
@@ -88,6 +92,8 @@ def _local_routes(
|
||||
return out
|
||||
# No config → route from the deployed registry snapshot.
|
||||
for name, d in sorted(registry.deployed.items()):
|
||||
if not d.enabled:
|
||||
continue
|
||||
if d.static_root:
|
||||
out.append((name, "static", d.static_root))
|
||||
elif d.subdomain and (d.port or d.base_url):
|
||||
|
||||
@@ -311,6 +311,11 @@ class DeploymentBase(BaseModel):
|
||||
)
|
||||
description: str | 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):
|
||||
|
||||
@@ -72,6 +72,9 @@ class Deployment:
|
||||
base_url: str | None = None
|
||||
schedule: str | None = None
|
||||
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
|
||||
@@ -155,6 +158,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
base_url=comp_data.get("base_url"),
|
||||
schedule=comp_data.get("schedule"),
|
||||
managed=comp_data.get("managed", False),
|
||||
enabled=comp_data.get("enabled", True),
|
||||
)
|
||||
|
||||
return NodeRegistry(node=node, deployed=deployed)
|
||||
@@ -225,6 +229,10 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
entry["schedule"] = comp.schedule
|
||||
if 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
|
||||
|
||||
with open(path, "w") as f:
|
||||
|
||||
74
core/tests/test_apply.py
Normal file
74
core/tests/test_apply.py
Normal 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]
|
||||
@@ -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:…}`
|
||||
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
|
||||
node:
|
||||
@@ -228,7 +228,7 @@ deployed:
|
||||
```
|
||||
|
||||
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,
|
||||
and writes the registry. Systemd units and Caddyfile are then generated
|
||||
from the registry — never from the spec directly.
|
||||
@@ -280,7 +280,7 @@ programs on a single node and across multiple Castle nodes.
|
||||
|
||||
**MQTT topics:**
|
||||
- `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).
|
||||
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)
|
||||
│ └── <name>/
|
||||
├── artifacts/
|
||||
│ ├── specs/ ← Generated by `castle deploy`
|
||||
│ ├── specs/ ← Generated by `castle apply`
|
||||
│ │ ├── Caddyfile
|
||||
│ │ └── registry.yaml ← Node config (what's deployed here)
|
||||
│ └── content/ ← Built frontend assets
|
||||
@@ -491,7 +491,7 @@ Castle's architecture parallels Erlang/OTP, mapped onto Unix:
|
||||
| Application | Component (independent, self-contained) |
|
||||
| Application resource file | Component spec in `castle.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) |
|
||||
| Process | Running service/worker/job |
|
||||
| Application env | Env vars |
|
||||
@@ -527,12 +527,12 @@ What exists today:
|
||||
- **CLI** — `castle` command, installed via `uv tool install --editable cli/`
|
||||
- **Three packages** — `castle-core` (models, config, generators),
|
||||
`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
|
||||
Caddyfile generated from registry with fully resolved paths. No repo references
|
||||
in runtime artifacts.
|
||||
- **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.
|
||||
- **Gateway** — Caddy on port 9000, Caddyfile generated from registry
|
||||
- **API** — `castle-api` on port 9020, reads from registry (optional
|
||||
|
||||
@@ -63,7 +63,7 @@ for tools/libraries.
|
||||
at `/usr/local/bin/caddy` when `tls: acme`.
|
||||
- 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`
|
||||
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
|
||||
over rootless podman on this box).
|
||||
- Service data at `$CASTLE_DATA_DIR/<name>/`; secrets at `~/.castle/secrets/`
|
||||
|
||||
@@ -122,7 +122,7 @@ origin — moving a service onto HTTPS changes its origin.
|
||||
(§DNS). Verify: `dig +short <name>.<domain>` → the node's IP.
|
||||
3. **Set `gateway.tls: acme`** (with `domain`/`acme_email`), plus the operational
|
||||
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).
|
||||
|
||||
## Operational prerequisites
|
||||
@@ -142,7 +142,7 @@ a DNS token.
|
||||
(`~/.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
|
||||
`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.
|
||||
- **`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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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` +
|
||||
`gateway.tunnel_id` set and the `castle-tunnel` service running; see
|
||||
@docs/tunnel-setup.md for the one-time setup.
|
||||
@@ -467,7 +467,7 @@ Setup (the parts castle can't do for you):
|
||||
env:
|
||||
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)
|
||||
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
|
||||
@@ -493,8 +493,9 @@ manage:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
Enables `castle service enable/disable` and `castle service logs`. An empty `{}`
|
||||
uses defaults (enable=true, restart=on-failure, restart_sec=2).
|
||||
Marks the deployment systemd-managed, so `castle apply` generates and reconciles
|
||||
its unit (and `castle service logs` tails it). An empty `{}` uses defaults
|
||||
(enable=true, restart=on-failure, restart_sec=2).
|
||||
|
||||
Full options:
|
||||
```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
|
||||
# ... implement ...
|
||||
castle program test my-service # 3. Run tests
|
||||
castle service enable my-service # 4. Generate systemd unit, start
|
||||
castle gateway reload # 5. Update Caddy routes
|
||||
castle apply my-service # 4. Render unit + routes and reconcile (start it)
|
||||
```
|
||||
|
||||
After `service enable`, the service starts automatically on boot and restarts
|
||||
on failure. Manage with:
|
||||
`castle apply` renders the systemd unit + Caddy route and starts the service
|
||||
(which then runs on boot and restarts on failure). Manage with:
|
||||
|
||||
```bash
|
||||
castle logs my-service -f # Tail logs
|
||||
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
|
||||
@@ -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
|
||||
# ... implement ...
|
||||
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
|
||||
@@ -676,7 +677,7 @@ manage:
|
||||
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.
|
||||
|
||||
## Infrastructure paths
|
||||
|
||||
@@ -171,7 +171,7 @@ See @docs/registry.md for the full registry reference.
|
||||
## Serving with Caddy
|
||||
|
||||
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:
|
||||
|
||||
1. You build the frontend with `castle program build <name>` (deploy does **not**
|
||||
|
||||
@@ -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 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.
|
||||
|
||||
## 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 build my-app # apply unapplied migrations to the substrate
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
- **One kill switch.** Stop `castle-tunnel` → instantly nothing is public.
|
||||
|
||||
@@ -76,8 +76,8 @@ manage:
|
||||
Bring it online:
|
||||
|
||||
```bash
|
||||
castle deploy # writes cloudflared.yml from public services
|
||||
castle service enable castle-tunnel # start the tunnel
|
||||
castle apply # writes cloudflared.yml from public services
|
||||
castle apply castle-tunnel # start the tunnel
|
||||
```
|
||||
|
||||
## Using the toggle
|
||||
@@ -92,10 +92,10 @@ public: true # also expose at <name>.pub.payne.io via the tunnel
|
||||
Then just deploy:
|
||||
|
||||
```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
|
||||
`false` (or remove it) and redeploy to un-expose — the hostname drops out of the
|
||||
ingress immediately.
|
||||
@@ -116,14 +116,14 @@ printf %s "<token>" > ~/.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
|
||||
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.
|
||||
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.
|
||||
|
||||
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
|
||||
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io
|
||||
|
||||
15
install.sh
15
install.sh
@@ -22,7 +22,7 @@ CASTLE_HOME="${HOME}/.castle"
|
||||
CASTLE_CONF="${CASTLE_HOME}/infra.conf"
|
||||
# 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)
|
||||
# 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.
|
||||
DATA_DIR="${CASTLE_DATA_DIR:-/data/castle}"
|
||||
# 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
|
||||
# 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
|
||||
# `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() {
|
||||
local provider="${1:-cloudflare}"
|
||||
local module
|
||||
@@ -211,7 +211,7 @@ ensure_caddy_dns_plugin() {
|
||||
|
||||
/usr/local/bin/caddy list-modules 2>/dev/null | grep -q "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
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ create_directories() {
|
||||
|
||||
# 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
|
||||
# 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
|
||||
log_info "creating ${DATA_DIR} (needs sudo — outside \$HOME)"
|
||||
sudo mkdir -p "${DATA_DIR}"
|
||||
@@ -280,7 +280,7 @@ create_directories() {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 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
|
||||
# gateway deployment carries a `__SPECS_DIR__` placeholder (the source repo has
|
||||
# no machine-specific paths) that we substitute with this machine's specs dir.
|
||||
@@ -337,7 +337,7 @@ seed_caddyfile() {
|
||||
fi
|
||||
cat > "$caddyfile" << 'EOF'
|
||||
:9000 {
|
||||
respond "Castle is starting. Run 'castle deploy' to configure." 200
|
||||
respond "Castle is starting. Run 'castle apply' to configure." 200
|
||||
}
|
||||
EOF
|
||||
log_ok
|
||||
@@ -553,8 +553,7 @@ print_summary() {
|
||||
printf "\n"
|
||||
|
||||
printf "Next steps:\n"
|
||||
printf " castle deploy # Generate registry, systemd units, Caddyfile\n"
|
||||
printf " castle start # Start the gateway, API, and all deployments\n"
|
||||
printf " castle apply # Converge: render units/routes + start everything\n"
|
||||
printf " castle doctor # Verify setup + health (green = good to go)\n"
|
||||
printf " open http://localhost:9000 # the dashboard\n"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user