diff --git a/CLAUDE.md b/CLAUDE.md index 564b93a..1412e50 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.yaml` with an absolute `source:`. `castle program add` registers an existing repo in place under `programs/.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/.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 [--json] castle program create [--stack ...] [--description ...] # scaffold new castle program add [--name ...] # adopt existing repo castle program clone [name] # clone repo: source castle program delete [--source] [-y] castle program run [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 [--json] castle service create [--program P] [--port N] [--health ...] \ - [--path ...] [--host ...] [--port-env ...] [--runner ...] + [--path ...] [--host ...] [--port-env ...] [--launcher ...] castle service delete [-y] castle service deploy # generate unit + route castle service enable|disable # systemd enable/disable castle service start|stop|restart # systemd lifecycle (one) castle service logs [-f] [-n 50] -# Jobs — scheduled tasks (same verbs; create takes --schedule) -castle job create [--program P] --schedule "0 2 * * *" [--runner ...] +# Jobs — scheduled tasks (manager: systemd + schedule; same verbs, create takes --schedule) +castle job create [--program P] --schedule "0 2 * * *" [--launcher ...] castle job ... # 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//` (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 ` 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 diff --git a/README.md b/README.md index 8a4c9da..2319431 100644 --- a/README.md +++ b/README.md @@ -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/.yaml`** — Software catalog (source, stack, behavior, build config) -- **`services/.yaml`** — Long-running daemons (run, expose, proxy, systemd) -- **`jobs/.yaml`** — Scheduled tasks (run, cron schedule, systemd timer) +- **`programs/.yaml`** — Software catalog (source, stack, build config) +- **`deployments/.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. 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 | diff --git a/app/src/components/BehaviorBadge.tsx b/app/src/components/BehaviorBadge.tsx deleted file mode 100644 index a7471f2..0000000 --- a/app/src/components/BehaviorBadge.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { cn } from "@/lib/utils" -import { BEHAVIOR_DESCRIPTIONS, behaviorLabel } from "@/lib/labels" - -const behaviorColors: Record = { - 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 ( - - {behaviorLabel(behavior)} - - ) -} diff --git a/app/src/components/JobCard.tsx b/app/src/components/JobCard.tsx index 7bb8460..b0f23e5 100644 --- a/app/src/components/JobCard.tsx +++ b/app/src/components/JobCard.tsx @@ -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} )} - {job.runner && ( + {job.launcher && ( - {runnerLabel(job.runner)} + {launcherLabel(job.launcher)} )} diff --git a/app/src/components/KindBadge.tsx b/app/src/components/KindBadge.tsx new file mode 100644 index 0000000..b5b3db1 --- /dev/null +++ b/app/src/components/KindBadge.tsx @@ -0,0 +1,27 @@ +import { cn } from "@/lib/utils" +import { KIND_DESCRIPTIONS, kindLabel } from "@/lib/labels" + +// Derived deployment kind → badge color. +const kindColors: Record = { + 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 ( + + {kindLabel(kind)} + + ) +} diff --git a/app/src/components/ProgramActions.tsx b/app/src/components/ProgramActions.tsx index 168f38c..34730b8 100644 --- a/app/src/components/ProgramActions.tsx +++ b/app/src/components/ProgramActions.tsx @@ -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(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) diff --git a/app/src/components/ProgramCard.tsx b/app/src/components/ProgramCard.tsx index b539d27..6cc55e1 100644 --- a/app/src/components/ProgramCard.tsx +++ b/app/src/components/ProgramCard.tsx @@ -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) {
- +
@@ -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 /> diff --git a/app/src/components/ServiceCard.tsx b/app/src/components/ServiceCard.tsx index 4cd2c68..628c451 100644 --- a/app/src/components/ServiceCard.tsx +++ b/app/src/components/ServiceCard.tsx @@ -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) { :{service.port} )} - {service.runner && ( + {service.launcher && ( - {runnerLabel(service.runner)} + {launcherLabel(service.launcher)} )} {service.subdomain && ( diff --git a/app/src/components/detail/ConfigPanel.tsx b/app/src/components/detail/ConfigPanel.tsx index 9b03526..219acfa 100644 --- a/app/src/components/detail/ConfigPanel.tsx +++ b/app/src/components/detail/ConfigPanel.tsx @@ -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) => { 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) { diff --git a/app/src/components/detail/CreateDeploymentForm.tsx b/app/src/components/detail/CreateDeploymentForm.tsx index 0fb5d81..d086f71 100644 --- a/app/src/components/detail/CreateDeploymentForm.tsx +++ b/app/src/components/detail/CreateDeploymentForm.tsx @@ -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 = { + 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(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(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 => { - const run = - runner === "command" - ? { runner: "command", argv: runTarget.split(" ").filter(Boolean) } - : { runner: "python", program: runTarget || name } const base: Record = { ...(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 = { + ...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 (
-

New {kind}{program ? ` for ${program}` : ""}

+

New deployment{program ? ` for ${program}` : ""}

@@ -121,11 +148,31 @@ export function CreateDeploymentForm({
)} + {/* Kind picker */} + +
+ {(Object.keys(KIND_INFO) as DeploymentKind[]).map((k) => ( + + ))} +
+
+ 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({ - - - - + {/* Program ref — informational; tool/static require it, systemd optional. */} + {program && ( + + {program} + + )} - {kind === "service" ? ( + {isSystemd && ( + <> + + + + + + )} + + {kind === "service" && ( <> + {proxy && ( + + + + )} - ) : ( + )} + + {kind === "job" && ( )} + {kind === "static" && ( + + )} +
diff --git a/app/src/components/detail/DeploymentsSection.tsx b/app/src/components/detail/DeploymentsSection.tsx index 3578f08..22876db 100644 --- a/app/src/components/detail/DeploymentsSection.tsx +++ b/app/src/components/detail/DeploymentsSection.tsx @@ -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 }) {

Deployments

-
- - -
+

Services and jobs that run this program. @@ -55,19 +46,18 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) { {creating && ( setCreating(null)} + onCancel={() => setCreating(false)} /> )} {none && !creating ? (

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

) : ( diff --git a/app/src/components/detail/DetailHeader.tsx b/app/src/components/detail/DetailHeader.tsx index c6eda84..984efef 100644 --- a/app/src/components/detail/DetailHeader.tsx +++ b/app/src/components/detail/DetailHeader.tsx @@ -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 ( <> @@ -26,7 +26,7 @@ export function DetailHeader({ backTo, backLabel, name, behavior, stack, source,
- + {source && ( {source} diff --git a/app/src/components/detail/JobFields.tsx b/app/src/components/detail/JobFields.tsx index 152b464..9f6206e 100644 --- a/app/src/components/detail/JobFields.tsx +++ b/app/src/components/detail/JobFields.tsx @@ -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) - 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." /> - {runnerLabel(runner)} · + {launcherLabel(launcher)} · setRunTarget(e.target.value)} diff --git a/app/src/components/detail/ServiceFields.tsx b/app/src/components/detail/ServiceFields.tsx index e298d21..90aa8ad 100644 --- a/app/src/components/detail/ServiceFields.tsx +++ b/app/src/components/detail/ServiceFields.tsx @@ -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) - 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." > - {runnerLabel(runner)} · + {launcherLabel(launcher)} · setRunProgram(e.target.value)} diff --git a/app/src/lib/labels.ts b/app/src/lib/labels.ts index d6bb50b..0b2034a 100644 --- a/app/src/lib/labels.ts +++ b/app/src/lib/labels.ts @@ -1,21 +1,26 @@ -export const RUNNER_LABELS: Record = { +export const LAUNCHER_LABELS: Record = { python: "Python", command: "Command", container: "Container", + compose: "Compose", node: "Node.js", - remote: "Remote", } -export const BEHAVIOR_LABELS: Record = { - daemon: "Daemon", +// Derived deployment kinds (service | job | tool | static | reference). +export const KIND_LABELS: Record = { + service: "Service", + job: "Job", tool: "Tool", - frontend: "Frontend", + static: "Static", + reference: "Reference", } -export const BEHAVIOR_DESCRIPTIONS: Record = { - daemon: "Long-running process that exposes ports", - tool: "CLI utility or scheduled task", - frontend: "Built web application", +export const KIND_DESCRIPTIONS: Record = { + 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 = { @@ -31,12 +36,12 @@ export const STACK_LABELS: Record = { 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 { diff --git a/app/src/pages/NodeDetail.tsx b/app/src/pages/NodeDetail.tsx index a30b13d..d63819d 100644 --- a/app/src/pages/NodeDetail.tsx +++ b/app/src/pages/NodeDetail.tsx @@ -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() { Deployment - Behavior + Kind Stack - Runner + Manager Port @@ -87,13 +87,13 @@ export function NodeDetailPage() { )} - + - {comp.runner ?? "—"} + {comp.manager ?? "—"} {comp.port ?? "—"} diff --git a/app/src/pages/ProgramDetail.tsx b/app/src/pages/ProgramDetail.tsx index 2dc96a5..7574d6b 100644 --- a/app/src/pages/ProgramDetail.tsx +++ b/app/src/pages/ProgramDetail.tsx @@ -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}.`) : 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} /> @@ -96,8 +95,8 @@ export function ProgramDetailPage() { )} {deployment.runner && ( <> - Runner - {runnerLabel(deployment.runner)} + Launcher + {launcherLabel(deployment.runner)} )} {deployment.active !== null && ( diff --git a/app/src/pages/ScheduledDetail.tsx b/app/src/pages/ScheduledDetail.tsx index 5869463..edccbb9 100644 --- a/app/src/pages/ScheduledDetail.tsx +++ b/app/src/pages/ScheduledDetail.tsx @@ -34,6 +34,7 @@ export function ScheduledDetailPage() { backTo="/scheduled" backLabel="Back to Jobs" name={deployment.id} + kind="job" stack={deployment.stack} source={deployment.source} > diff --git a/app/src/pages/ServiceDetail.tsx b/app/src/pages/ServiceDetail.tsx index 8a4d349..7041de9 100644 --- a/app/src/pages/ServiceDetail.tsx +++ b/app/src/pages/ServiceDetail.tsx @@ -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() { )} - {deployment.runner && ( + {deployment.launcher && ( <> Runs - {runnerLabel(deployment.runner)} + {launcherLabel(deployment.launcher)} {deployment.run_target && <> · {deployment.run_target}} diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index 5bf6ff7..01bd449 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -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(`/programs${params}`), }) } diff --git a/app/src/types/index.ts b/app/src/types/index.ts index f6dff93..a005b3a 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -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 ., else null diff --git a/castle-api/src/castle_api/config_editor.py b/castle-api/src/castle_api/config_editor.py index 6f66636..491d84d 100644 --- a/castle-api/src/castle_api/config_editor.py +++ b/castle-api/src/castle_api/config_editor.py @@ -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) diff --git a/castle-api/src/castle_api/logs.py b/castle-api/src/castle_api/logs.py index 5a0ccf5..1f89c15 100644 --- a/castle-api/src/castle_api/logs.py +++ b/castle-api/src/castle_api/logs.py @@ -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, diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index e7c73f3..4ad1c0f 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -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 ., 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 diff --git a/castle-api/src/castle_api/mqtt_client.py b/castle-api/src/castle_api/mqtt_client.py index d2abf50..9245484 100644 --- a/castle-api/src/castle_api/mqtt_client.py +++ b/castle-api/src/castle_api/mqtt_client.py @@ -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"), diff --git a/castle-api/src/castle_api/nodes.py b/castle-api/src/castle_api/nodes.py index 11f45fc..963e00d 100644 --- a/castle-api/src/castle_api/nodes.py +++ b/castle-api/src/castle_api/nodes.py @@ -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, diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index c6c8fdf..4d6bb7b 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -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) diff --git a/castle-api/src/castle_api/services.py b/castle-api/src/castle_api/services.py index 9dd640f..f77d2d7 100644 --- a/castle-api/src/castle_api/services.py +++ b/castle-api/src/castle_api/services.py @@ -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 diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index db5047c..4c34a70 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -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", diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 6b1fba2..2b67ccc 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -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 {}) diff --git a/castle-api/tests/test_mesh.py b/castle-api/tests/test_mesh.py index a96a234..05a9ace 100644 --- a/castle-api/tests/test_mesh.py +++ b/castle-api/tests/test_mesh.py @@ -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") diff --git a/castle-api/tests/test_mqtt.py b/castle-api/tests/test_mqtt.py index d322ba1..bba9d4a 100644 --- a/castle-api/tests/test_mqtt.py +++ b/castle-api/tests/test_mqtt.py @@ -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)) diff --git a/castle-api/tests/test_nodes.py b/castle-api/tests/test_nodes.py index 0ee9b17..24ad5e8 100644 --- a/castle-api/tests/test_nodes.py +++ b/castle-api/tests/test_nodes.py @@ -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", ), }, ) diff --git a/castle-api/tests/test_programs_commands.py b/castle-api/tests/test_programs_commands.py index 6ee3b34..def2512 100644 --- a/castle-api/tests/test_programs_commands.py +++ b/castle-api/tests/test_programs_commands.py @@ -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 diff --git a/cli/src/castle_cli/commands/add.py b/cli/src/castle_cli/commands/add.py index 1c312f2..948ffd4 100644 --- a/cli/src/castle_cli/commands/add.py +++ b/cli/src/castle_cli/commands/add.py @@ -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: diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index e5e69b0..ef80d22 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -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 // (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 . (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}") diff --git a/cli/src/castle_cli/commands/delete.py b/cli/src/castle_cli/commands/delete.py index 3acea0c..ab4bc9c 100644 --- a/cli/src/castle_cli/commands/delete.py +++ b/cli/src/castle_cli/commands/delete.py @@ -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}" diff --git a/cli/src/castle_cli/commands/deploy_create.py b/cli/src/castle_cli/commands/deploy_create.py index 60b19f0..0d17f74 100644 --- a/cli/src/castle_cli/commands/deploy_create.py +++ b/cli/src/castle_cli/commands/deploy_create.py @@ -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 . (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 diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 2981ece..60857d8 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -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, diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index a035b0f..8adc3f5 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -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: diff --git a/cli/src/castle_cli/commands/logs.py b/cli/src/castle_cli/commands/logs.py index 0413d14..4f51be5 100644 --- a/cli/src/castle_cli/commands/logs.py +++ b/cli/src/castle_cli/commands/logs.py @@ -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 diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index fe7248a..eeb5788 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -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 diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 41a1880..9a5e3b8 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -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") diff --git a/cli/src/castle_cli/manifest.py b/cli/src/castle_cli/manifest.py index ae3c3cb..8e6ab99 100644 --- a/cli/src/castle_cli/manifest.py +++ b/cli/src/castle_cli/manifest.py @@ -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, ) diff --git a/cli/tests/test_add.py b/cli/tests/test_add.py index 6db84a1..813e9a8 100644 --- a/cli/tests/test_add.py +++ b/cli/tests/test_add.py @@ -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" diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index 1ecb86a..20870fd 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -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.""" diff --git a/cli/tests/test_info.py b/cli/tests/test_info.py index 6aafa2d..57313f7 100644 --- a/cli/tests/test_info.py +++ b/cli/tests/test_info.py @@ -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 diff --git a/cli/tests/test_list.py b/cli/tests/test_list.py index 20e449b..0aea5f1 100644 --- a/cli/tests/test_list.py +++ b/cli/tests/test_list.py @@ -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" diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index c102851..26f6d6b 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -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()}, ) diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 4116c78..ee7f5cb 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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 / 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 / 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( diff --git a/core/src/castle_core/generators/caddyfile.py b/core/src/castle_core/generators/caddyfile.py index 8985c8d..f2abf9a 100644 --- a/core/src/castle_core/generators/caddyfile.py +++ b/core/src/castle_core/generators/caddyfile.py @@ -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()): diff --git a/core/src/castle_core/generators/systemd.py b/core/src/castle_core/generators/systemd.py index 1af0997..93c0a7a 100644 --- a/core/src/castle_core/generators/systemd.py +++ b/core/src/castle_core/generators/systemd.py @@ -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) diff --git a/core/src/castle_core/lifecycle.py b/core/src/castle_core/lifecycle.py index 30656b8..9718ef4 100644 --- a/core/src/castle_core/lifecycle.py +++ b/core/src/castle_core/lifecycle.py @@ -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) diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 7a10843..1208d53 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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-`` -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 specs — a 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 . 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 - # .. 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 . to this process. False → host:port only. + proxy: bool = False + # Also publish to the public internet via the Cloudflare tunnel, at + # .. 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] diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 87c3989..589e7d8 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -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: diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 6e9e00c..b4fa278 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -38,7 +38,6 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "programs": { "test-tool": { "description": "Test tool", - "behavior": "tool", }, }, "services": { diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 2a9e3e5..ee891b4 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -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 . 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, ) diff --git a/core/tests/test_config.py b/core/tests/test_config.py index 0227959..5eed854 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -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 . 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 diff --git a/core/tests/test_deploy_prune.py b/core/tests/test_deploy_prune.py index 024f986..1c04f06 100644 --- a/core/tests/test_deploy_prune.py +++ b/core/tests/test_deploy_prune.py @@ -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, diff --git a/core/tests/test_deploy_run_cmd.py b/core/tests/test_deploy_run_cmd.py index 316707a..c691d81 100644 --- a/core/tests/test_deploy_run_cmd.py +++ b/core/tests/test_deploy_run_cmd.py @@ -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 -f 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) == [] diff --git a/core/tests/test_deploy_secret_env.py b/core/tests/test_deploy_secret_env.py index 42c69de..029355f 100644 --- a/core/tests/test_deploy_secret_env.py +++ b/core/tests/test_deploy_secret_env.py @@ -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"], diff --git a/core/tests/test_lifecycle.py b/core/tests/test_lifecycle.py index f203f4e..1db0fdb 100644 --- a/core/tests/test_lifecycle.py +++ b/core/tests/test_lifecycle.py @@ -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 diff --git a/core/tests/test_manifest.py b/core/tests/test_manifest.py index a14b275..f2f6d86 100644 --- a/core/tests/test_manifest.py +++ b/core/tests/test_manifest.py @@ -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 diff --git a/core/tests/test_systemd.py b/core/tests/test_systemd.py index c6e9c4f..82e1b32 100644 --- a/core/tests/test_systemd.py +++ b/core/tests/test_systemd.py @@ -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 diff --git a/core/tests/test_tunnel.py b/core/tests/test_tunnel.py index 6ed9b32..d52c216 100644 --- a/core/tests/test_tunnel.py +++ b/core/tests/test_tunnel.py @@ -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"] diff --git a/docs/design.md b/docs/design.md index 72eccd9..82fe24a 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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. - 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. +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) │ └── / @@ -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 diff --git a/docs/dns-and-tls.md b/docs/dns-and-tls.md index c157a3d..e746e8b 100644 --- a/docs/dns-and-tls.md +++ b/docs/dns-and-tls.md @@ -141,7 +141,7 @@ a DNS token. - **`acme` only — a provider API token.** Store it as a secret (`~/.castle/secrets/`, 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.}`. `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 diff --git a/docs/registry.md b/docs/registry.md index 5332f54..381712e 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -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/.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 --no-dev ` | `program`, `args` | -| `command` | systemd | `which(argv[0])` → resolved path | `argv` | -| `container` | systemd | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | -| `compose` | systemd | `docker compose -p -f up` (+ `ExecStop=down`) | `file`, `project_name` | -| `node` | systemd | `package_manager run script` | `script`, `package_manager` | -| `static` | caddy | *(no process — `file_server` from `/`)* | `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 --no-dev ` | `program`, `args` | +| `command` | `which(argv[0])` → resolved path | `argv` | +| `container` | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | +| `compose` | `docker compose -p -f 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- ``` +### `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 . +``` + +### `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 `.`) | +| **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 `.`) | | **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//`). @@ -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/.yaml` +file (plus a `deployments/.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. - 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. +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`). diff --git a/docs/stacks/python-cli.md b/docs/stacks/python-cli.md index e64e05c..1670448 100644 --- a/docs/stacks/python-cli.md +++ b/docs/stacks/python-cli.md @@ -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/.yaml` entry plus a `deployments/.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. diff --git a/docs/stacks/python-fastapi.md b/docs/stacks/python-fastapi.md index 122f665..e2de164 100644 --- a/docs/stacks/python-fastapi.md +++ b/docs/stacks/python-fastapi.md @@ -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. - 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. +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. diff --git a/docs/stacks/react-vite.md b/docs/stacks/react-vite.md index 11d4f2a..102a890 100644 --- a/docs/stacks/react-vite.md +++ b/docs/stacks/react-vite.md @@ -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/.yaml`, plus a `deployments/.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. ``` For production, Caddy serves the build output **in place** from the program's -repo (`/`) — no Node process and no copy into a -central directory. See [Serving with Caddy](#serving-with-caddy) below. +repo (`/`) — 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. +# 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. ``` See @docs/registry.md for the full registry reference. @@ -170,10 +176,10 @@ The flow: 1. You build the frontend with `castle program build ` (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 `/` — - no copy. Each frontend is served at its own subdomain `.` - (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 `/` — no copy. Each static + frontend is served at its own subdomain `.` (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 diff --git a/docs/stacks/supabase.md b/docs/stacks/supabase.md index 89cd3b5..c71f60a 100644 --- a/docs/stacks/supabase.md +++ b/docs/stacks/supabase.md @@ -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 `.`. 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 +`.`. 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.`.) 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). diff --git a/docs/tunnel-setup.md b/docs/tunnel-setup.md index 2f3f5b7..716a640 100644 --- a/docs/tunnel-setup.md +++ b/docs/tunnel-setup.md @@ -54,12 +54,14 @@ chmod 600 ~/.castle/secrets/cloudflared/$TID.json # tunnel_id: ``` -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/.yaml`: +Mark a service public in its `deployments/.yaml`: ```yaml proxy: true # required — the service must be routed