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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -103,42 +103,42 @@ class Settings(BaseSettings):
settings = Settings()
```
Castle passes config via env vars in castle.yaml:
Castle passes config via env vars in the deployment's `defaults.env`:
```yaml
programs:
my-service:
description: Does something useful
source: /data/repos/my-service
stack: python-fastapi
behavior: daemon
services:
my-service:
program: my-service
run:
runner: python
program: my-service
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy: true # expose at my-service.<gateway.domain>
manage:
systemd: {}
# programs/my-service.yaml
description: Does something useful
source: /data/repos/my-service
stack: python-fastapi
```
```yaml
# deployments/my-service.yaml (manager: systemd → kind: service)
program: my-service
manager: systemd
run:
launcher: python
program: my-service
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy: true # expose at my-service.<gateway.domain>
manage:
systemd: {}
```
The env a service runs with is exactly what's in `defaults.env` — castle injects
nothing implicitly. Map the vars your settings read (above, `env_prefix:
"MY_SERVICE_"``MY_SERVICE_PORT`/`MY_SERVICE_DATA_DIR`) to castle's computed
values with the `${port}`/`${data_dir}` placeholders:
values with the `${port}`/`${data_dir}` placeholders — add to
`deployments/my-service.yaml`:
```yaml
defaults:
env:
MY_SERVICE_PORT: ${port} # = expose.http.internal.port
MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/my-service
CENTRAL_CONTEXT_URL: http://localhost:9001
defaults:
env:
MY_SERVICE_PORT: ${port} # = expose.http.internal.port
MY_SERVICE_DATA_DIR: ${data_dir} # = $CASTLE_DATA_DIR/my-service
CENTRAL_CONTEXT_URL: http://localhost:9001
```
`castle program create` scaffolds the `${port}`/`${data_dir}` lines for you.

View File

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

View File

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

View File

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