Manager-first deployment model: split runner, merge service/job, frontend→static
Replace the conflated `runner` axis with two orthogonal ones: `manager`
(systemd|caddy|path|none) — who supervises/realizes a deployment — and, for
systemd only, a nested `launcher` (python|command|container|compose|node) — how
the process starts. ServiceSpec and JobSpec collapse into one manager-
discriminated DeploymentSpec union (Systemd/Caddy/Path/Remote); the services/
and jobs/ config dirs collapse into one deployments/ dir. The human "kind"
(service|job|tool|static|reference) is fully derived (kind_for), never stored —
the frontend kind is renamed static. behavior is gone.
- core: DeploymentSpec union + LaunchSpec + kind_for; legacy-aware loader
normalizes old runner shapes; CastleConfig.deployments with derived
services/jobs/tools views; registry.Deployment carries manager/launcher/kind.
- cli: service/job/tool as filtered views + a deployment group; --behavior→--kind,
create --runner→--launcher; lifecycle dispatches over config.deployments.
- castle-api: /deployments primary with /services,/jobs as views; summaries
derive kind; PUT/DELETE /config/deployments/{name} (services/jobs aliased).
- app: KindBadge, frontend→static everywhere, pick-a-kind creation wizard,
per-kind config editors.
- docs: single deployments/ layout, manager/launcher, static kind throughout.
Live migration verified byte-identical: regenerated Caddyfile and every unit
ExecStart line unchanged, so nothing restarted. Suites: core 124, cli 25,
castle-api 55; dashboard build + type-check clean.
This commit is contained in:
55
CLAUDE.md
55
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/<name>.yaml` with an absolute
|
||||
`source:`. `castle program add` registers an existing repo in place under `programs/<name>.yaml` (or records
|
||||
its `repo:` URL for `castle program clone`).
|
||||
its `repo:` URL for `castle program clone`). A program's deployment (if any) is
|
||||
recorded separately under `deployments/<name>.yaml`.
|
||||
|
||||
## Castle CLI
|
||||
|
||||
@@ -81,33 +83,33 @@ Platform-wide lifecycle and the cross-resource overview are top-level.
|
||||
|
||||
```bash
|
||||
# Programs — the software catalog
|
||||
castle program list [--behavior daemon] [--stack python-cli] [--json]
|
||||
castle program list [--kind service] [--stack python-cli] [--json]
|
||||
castle program info <name> [--json]
|
||||
castle program create <name> [--stack ...] [--description ...] # scaffold new
|
||||
castle program add <path|git-url> [--name ...] # adopt existing repo
|
||||
castle program clone [name] # clone repo: source
|
||||
castle program delete <name> [--source] [-y]
|
||||
castle program run <name> [args...] # declared run command
|
||||
castle program install|uninstall [name] # activate tools/frontends
|
||||
castle program install|uninstall [name] # activate tools/statics
|
||||
castle program build|test|lint|format|type-check|check [name] # dev verbs
|
||||
|
||||
# Services — long-running daemons
|
||||
# Services — long-running daemons (deployments with manager: systemd, no schedule)
|
||||
castle service list [--json]
|
||||
castle service info <name> [--json]
|
||||
castle service create <name> [--program P] [--port N] [--health ...] \
|
||||
[--path ...] [--host ...] [--port-env ...] [--runner ...]
|
||||
[--path ...] [--host ...] [--port-env ...] [--launcher ...]
|
||||
castle service delete <name> [-y]
|
||||
castle service deploy <name> # generate unit + route
|
||||
castle service enable|disable <name> # systemd enable/disable
|
||||
castle service start|stop|restart <name> # systemd lifecycle (one)
|
||||
castle service logs <name> [-f] [-n 50]
|
||||
|
||||
# Jobs — scheduled tasks (same verbs; create takes --schedule)
|
||||
castle job create <name> [--program P] --schedule "0 2 * * *" [--runner ...]
|
||||
# Jobs — scheduled tasks (manager: systemd + schedule; same verbs, create takes --schedule)
|
||||
castle job create <name> [--program P] --schedule "0 2 * * *" [--launcher ...]
|
||||
castle job <list|info|delete|deploy|enable|disable|start|stop|restart|logs> ...
|
||||
|
||||
# Platform-wide (top-level)
|
||||
castle list [--behavior ...] [--stack ...] [--json] # programs + services + jobs
|
||||
castle list [--kind ...] [--stack ...] [--json] # all deployments (services, jobs, tools, statics)
|
||||
castle status # unified status
|
||||
castle deploy [name] # apply config → units + Caddyfile
|
||||
castle start | stop | restart # all services (+ gateway)
|
||||
@@ -117,10 +119,15 @@ castle gateway start|stop|reload|status # the Caddy gateway
|
||||
Bringing everything online is the two honest steps `castle deploy && castle
|
||||
start` (apply config, then start) — there is no bundled `up`.
|
||||
|
||||
`castle service` and `castle job` are **views** over the single deployment set,
|
||||
filtered by derived kind (systemd-no-schedule → service, systemd+schedule → job).
|
||||
Tools (`manager: path`) and statics (`manager: caddy`) are deployments too —
|
||||
reach them via `castle list --kind tool` / `--kind static`.
|
||||
|
||||
**Dev verbs** resolve per-program: a declared `commands:` entry (or `build:`)
|
||||
overrides the stack default, falling back to the program's stack handler, else
|
||||
the verb is unavailable. So a wired-in repo with **no `stack`** works as long as
|
||||
it declares its commands. Tools are reached via `castle program list --behavior tool`.
|
||||
it declares its commands. Tools are reached via `castle list --kind tool`.
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -138,9 +145,9 @@ data I/O on a dedicated volume; default `/data/castle`). Paths below use
|
||||
- **Systemd**: User units generated under `~/.config/systemd/user/castle-*.service`.
|
||||
Use drop-in overrides (`*.service.d/*.conf`) for extra env vars that `castle deploy`
|
||||
shouldn't overwrite (e.g., `CASTLE_API_MQTT_ENABLED`).
|
||||
- **Containers**: `runner: container` services use Docker (preferred on this system
|
||||
due to rootless podman UID mapping issues). Deploy resolves the runtime via
|
||||
`shutil.which("docker")`.
|
||||
- **Containers**: `manager: systemd` deployments with `run: { launcher: container }`
|
||||
use Docker (preferred on this system due to rootless podman UID mapping issues).
|
||||
Deploy resolves the runtime via `shutil.which("docker")`.
|
||||
- **MQTT**: Mosquitto broker runs as `castle-mqtt` (Docker container on port 1883).
|
||||
Data in `$CASTLE_DATA_DIR/castle-mqtt/`, config in `$CASTLE_DATA_DIR/castle-mqtt/config/`.
|
||||
- **Data**: Service data lives in `$CASTLE_DATA_DIR/<service-name>/` (default
|
||||
@@ -159,7 +166,7 @@ Deployments (the unified view of services + jobs + programs):
|
||||
- `GET /status` — Live health for all services
|
||||
|
||||
Programs / Services / Jobs (typed views + editing):
|
||||
- `GET /programs`, `GET /programs/{name}` — Program catalog (`?behavior=tool` to filter)
|
||||
- `GET /programs`, `GET /programs/{name}` — Program catalog (`?kind=tool` to filter)
|
||||
- `POST /programs/{name}/{action}` — Run a program verb (install/uninstall/build/…)
|
||||
- `PUT|DELETE /programs/{name}` — Edit or remove a program entry
|
||||
- `GET /services`, `GET /services/{name}`, `PUT|DELETE /services/{name}`
|
||||
@@ -219,8 +226,8 @@ Services also support: `uv run <service-name>` to start.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `castle.yaml` — Registry (three sections: programs, services, jobs)
|
||||
- `core/src/castle_core/manifest.py` — Pydantic models (ProgramSpec, ServiceSpec, JobSpec, RunSpec)
|
||||
- `castle.yaml` — Global settings (gateway, repo); resources live under `programs/` and `deployments/`
|
||||
- `core/src/castle_core/manifest.py` — Pydantic models (ProgramSpec, DeploymentSpec, LaunchSpec)
|
||||
- `core/src/castle_core/config.py` — Config loader (castle.yaml → CastleConfig)
|
||||
- `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation
|
||||
- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates
|
||||
|
||||
40
README.md
40
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/<name>.yaml`** — Software catalog (source, stack, behavior, build config)
|
||||
- **`services/<name>.yaml`** — Long-running daemons (run, expose, proxy, systemd)
|
||||
- **`jobs/<name>.yaml`** — Scheduled tasks (run, cron schedule, systemd timer)
|
||||
- **`programs/<name>.yaml`** — Software catalog (source, stack, build config)
|
||||
- **`deployments/<name>.yaml`** — How a program is realized on this node
|
||||
(`manager` + run/expose/proxy/schedule/systemd). The **kind**
|
||||
(service/job/tool/static/reference) is derived from `manager` (+ `schedule`).
|
||||
|
||||
Services and jobs can reference a program via `program:` for description fallthrough.
|
||||
A deployment can reference a program via `program:` for description fallthrough.
|
||||
|
||||
```yaml
|
||||
# ~/.castle/castle.yaml
|
||||
@@ -100,32 +101,32 @@ repo: /path/to/castle
|
||||
```yaml
|
||||
# ~/.castle/programs/central-context.yaml
|
||||
description: Content storage API
|
||||
behavior: daemon
|
||||
source: code/central-context
|
||||
stack: python-fastapi
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ~/.castle/services/central-context.yaml
|
||||
# ~/.castle/deployments/central-context.yaml (manager: systemd → kind: service)
|
||||
program: central-context
|
||||
manager: systemd
|
||||
run:
|
||||
runner: python
|
||||
launcher: python
|
||||
program: central-context
|
||||
expose:
|
||||
http:
|
||||
internal: { port: 9001 }
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy: { path_prefix: /central-context }
|
||||
proxy: true # expose at central-context.<gateway.domain>
|
||||
manage:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ~/.castle/jobs/backup-collect.yaml
|
||||
# ~/.castle/deployments/backup-collect.yaml (manager: systemd + schedule → kind: job)
|
||||
program: backup-collect
|
||||
manager: systemd
|
||||
run:
|
||||
runner: command
|
||||
launcher: command
|
||||
argv: [backup-collect]
|
||||
schedule: "0 2 * * *"
|
||||
manage:
|
||||
@@ -189,7 +190,7 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist
|
||||
| `GET /health` | Health check |
|
||||
| `GET /stream` | SSE stream (health, service-action, mesh events) |
|
||||
| **Programs** | |
|
||||
| `GET /programs` | List all programs (`?behavior=tool\|daemon\|frontend` to filter) |
|
||||
| `GET /programs` | List all programs (`?kind=tool\|service\|job\|static` to filter) |
|
||||
| `GET /programs/{name}` | Program detail |
|
||||
| `POST /programs/{name}/{action}` | Run a lifecycle action (build, test, lint, install, etc.) |
|
||||
| `GET /components` | Unified view across nodes (`?include_remote=true` for cross-node) |
|
||||
@@ -215,8 +216,7 @@ When enabled, the API publishes the node's registry to `castle/{hostname}/regist
|
||||
| `GET /config` | Read castle.yaml |
|
||||
| `PUT /config` | Write castle.yaml |
|
||||
| `PUT /config/programs/{name}` | Update a program entry |
|
||||
| `PUT /config/services/{name}` | Update a service entry |
|
||||
| `PUT /config/jobs/{name}` | Update a job entry |
|
||||
| `PUT /config/deployments/{name}` | Update a deployment entry (service/job/tool/static) |
|
||||
| `POST /config/apply` | Apply config changes (deploy + reload) |
|
||||
| **Secrets** | |
|
||||
| `GET /secrets` | List secrets |
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BEHAVIOR_DESCRIPTIONS, behaviorLabel } from "@/lib/labels"
|
||||
|
||||
const behaviorColors: Record<string, string> = {
|
||||
daemon: "bg-green-700 text-white",
|
||||
tool: "bg-blue-700 text-white",
|
||||
frontend: "bg-yellow-600 text-black",
|
||||
}
|
||||
|
||||
export function BehaviorBadge({ behavior }: { behavior: string | null }) {
|
||||
if (!behavior) return null
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
||||
behaviorColors[behavior] ?? "bg-gray-600 text-gray-200",
|
||||
)}
|
||||
title={BEHAVIOR_DESCRIPTIONS[behavior]}
|
||||
>
|
||||
{behaviorLabel(behavior)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Clock, Play, RefreshCw, Square, Terminal } from "lucide-react"
|
||||
import { Link } from "react-router-dom"
|
||||
import type { JobSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import { runnerLabel } from "@/lib/labels"
|
||||
import { launcherLabel } from "@/lib/labels"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
interface JobCardProps {
|
||||
@@ -50,10 +50,10 @@ export function JobCard({ job, health }: JobCardProps) {
|
||||
{job.schedule}
|
||||
</span>
|
||||
)}
|
||||
{job.runner && (
|
||||
{job.launcher && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Terminal size={12} />
|
||||
{runnerLabel(job.runner)}
|
||||
{launcherLabel(job.launcher)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
27
app/src/components/KindBadge.tsx
Normal file
27
app/src/components/KindBadge.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { KIND_DESCRIPTIONS, kindLabel } from "@/lib/labels"
|
||||
|
||||
// Derived deployment kind → badge color.
|
||||
const kindColors: Record<string, string> = {
|
||||
service: "bg-green-700 text-white",
|
||||
job: "bg-purple-700 text-white",
|
||||
tool: "bg-blue-700 text-white",
|
||||
static: "bg-cyan-700 text-white",
|
||||
reference: "bg-gray-600 text-gray-200",
|
||||
}
|
||||
|
||||
export function KindBadge({ kind }: { kind: string | null }) {
|
||||
if (!kind) return null
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
||||
kindColors[kind] ?? "bg-gray-600 text-gray-200",
|
||||
)}
|
||||
title={KIND_DESCRIPTIONS[kind]}
|
||||
>
|
||||
{kindLabel(kind)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -42,20 +42,16 @@ interface ProgramActionsProps {
|
||||
name: string
|
||||
actions: string[]
|
||||
active?: boolean | null
|
||||
behavior?: string | null
|
||||
/** Names of services/jobs deploying this program — daemons activate via these. */
|
||||
deployedAs?: string[]
|
||||
kind?: string | null
|
||||
compact?: boolean
|
||||
onOutput?: (output: ActionOutput) => void
|
||||
}
|
||||
|
||||
/** install/uninstall (activate) is meaningful only for tools (PATH) and static
|
||||
* frontends (served). A daemon activates through a service/job, so it never
|
||||
* shows install/uninstall here — its run controls live on the deployment page. */
|
||||
function showsActivation(behavior: string | null | undefined, deployedAs: string[]): boolean {
|
||||
if (behavior === "daemon") return false
|
||||
if (behavior === "frontend") return deployedAs.length === 0 // self-serving frontend → its service
|
||||
return true // tools (and unspecified)
|
||||
/** install/uninstall (activate) is meaningful only for a tool (a PATH deployment).
|
||||
* Services, jobs, and static (caddy) deployments are managed through their
|
||||
* deployment — never install/uninstall here. */
|
||||
function showsActivation(kind: string | null | undefined): boolean {
|
||||
return kind === "tool"
|
||||
}
|
||||
|
||||
function visibleActions(
|
||||
@@ -94,16 +90,15 @@ export function ProgramActions({
|
||||
name,
|
||||
actions,
|
||||
active,
|
||||
behavior,
|
||||
deployedAs = [],
|
||||
kind,
|
||||
compact,
|
||||
onOutput,
|
||||
}: ProgramActionsProps) {
|
||||
const { mutate, isPending } = useProgramAction()
|
||||
const [runningAction, setRunningAction] = useState<string | null>(null)
|
||||
|
||||
// Drop install/uninstall for behaviors that activate via a deployment.
|
||||
const allowed = showsActivation(behavior, deployedAs)
|
||||
// Drop install/uninstall for kinds that activate via a deployment.
|
||||
const allowed = showsActivation(kind)
|
||||
? actions
|
||||
: actions.filter((a) => a !== "install" && a !== "uninstall")
|
||||
const visible = visibleActions(allowed, active, !!compact)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import type { ProgramSummary } from "@/types"
|
||||
import { BehaviorBadge } from "./BehaviorBadge"
|
||||
import { KindBadge } from "./KindBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
import { ProgramActions } from "./ProgramActions"
|
||||
|
||||
@@ -21,7 +21,7 @@ export function ProgramCard({ program }: ProgramCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
||||
<BehaviorBadge behavior={program.behavior} />
|
||||
<KindBadge kind={program.kind} />
|
||||
<StackBadge stack={program.stack} />
|
||||
</div>
|
||||
|
||||
@@ -34,8 +34,7 @@ export function ProgramCard({ program }: ProgramCardProps) {
|
||||
name={program.id}
|
||||
actions={program.actions}
|
||||
active={program.active}
|
||||
behavior={program.behavior}
|
||||
deployedAs={[...program.services, ...program.jobs]}
|
||||
kind={program.kind}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-
|
||||
import { Link } from "react-router-dom"
|
||||
import type { ServiceSummary, HealthStatus } from "@/types"
|
||||
import { useServiceAction } from "@/services/api/hooks"
|
||||
import { runnerLabel, subdomainUrl } from "@/lib/labels"
|
||||
import { launcherLabel, subdomainUrl } from "@/lib/labels"
|
||||
import { HealthBadge } from "./HealthBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
|
||||
@@ -52,10 +52,10 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
|
||||
<Server size={12} />:{service.port}
|
||||
</span>
|
||||
)}
|
||||
{service.runner && (
|
||||
{service.launcher && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Terminal size={12} />
|
||||
{runnerLabel(service.runner)}
|
||||
{launcherLabel(service.launcher)}
|
||||
</span>
|
||||
)}
|
||||
{service.subdomain && (
|
||||
|
||||
@@ -23,11 +23,14 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
|
||||
const [pendingApply, setPendingApply] = useState(false)
|
||||
const [applying, setApplying] = useState(false)
|
||||
const isDeployment = configSection !== "programs"
|
||||
// Programs are their own catalog; every deployment kind (service/job/tool/
|
||||
// static) lives in the single deployments/ collection.
|
||||
const writeSection = isDeployment ? "deployments" : "programs"
|
||||
|
||||
const handleSave = async (compName: string, config: Record<string, unknown>) => {
|
||||
setMessage(null)
|
||||
try {
|
||||
await apiClient.put(`/config/${configSection}/${compName}`, { config })
|
||||
await apiClient.put(`/config/${writeSection}/${compName}`, { config })
|
||||
setMessage({
|
||||
type: "ok",
|
||||
text: isDeployment
|
||||
@@ -71,7 +74,7 @@ export function ConfigPanel({ deployment, configSection, onRefetch }: ConfigPane
|
||||
|
||||
const handleDelete = async (compName: string) => {
|
||||
try {
|
||||
await apiClient.delete(`/config/${configSection}/${compName}`)
|
||||
await apiClient.delete(`/config/${writeSection}/${compName}`)
|
||||
qc.invalidateQueries({ queryKey: [configSection] })
|
||||
navigate("/")
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -8,23 +8,32 @@ import { Field, TextField } from "./fields"
|
||||
const SELECT =
|
||||
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
||||
|
||||
export type DeploymentKind = "service" | "job" | "tool" | "static"
|
||||
|
||||
export interface CreatePrefill {
|
||||
name?: string
|
||||
program?: string
|
||||
runTarget?: string
|
||||
runner?: string
|
||||
launcher?: string
|
||||
}
|
||||
|
||||
/** Create a service or job in castle.yaml, then deploy (and start, for a
|
||||
* service). The UI twin of `castle expose`. Reachable standalone or prefilled
|
||||
* from a program page. */
|
||||
const KIND_INFO: Record<DeploymentKind, { label: string; hint: string }> = {
|
||||
service: { label: "Service", hint: "Long-running process (systemd)" },
|
||||
job: { label: "Job", hint: "Scheduled task (systemd timer)" },
|
||||
tool: { label: "Tool", hint: "CLI installed on PATH" },
|
||||
static: { label: "Static", hint: "Static site served by the gateway" },
|
||||
}
|
||||
|
||||
/** Create a deployment in castle.yaml, then deploy (and start, for a service).
|
||||
* A pick-a-kind wizard: the chosen kind sets the manager and shows only its
|
||||
* relevant fields. Reachable standalone or prefilled from a program page. */
|
||||
export function CreateDeploymentForm({
|
||||
kind,
|
||||
kind: initialKind,
|
||||
prefill,
|
||||
existingNames,
|
||||
onCancel,
|
||||
}: {
|
||||
kind: "service" | "job"
|
||||
kind?: DeploymentKind
|
||||
prefill?: CreatePrefill
|
||||
existingNames: string[]
|
||||
onCancel: () => void
|
||||
@@ -32,18 +41,23 @@ export function CreateDeploymentForm({
|
||||
const qc = useQueryClient()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [kind, setKind] = useState<DeploymentKind>(initialKind ?? "service")
|
||||
const [name, setName] = useState(prefill?.name ?? "")
|
||||
const [program] = useState(prefill?.program ?? "")
|
||||
const [description, setDescription] = useState("")
|
||||
const [runner, setRunner] = useState(prefill?.runner ?? "python")
|
||||
const [launcher, setLauncher] = useState(prefill?.launcher ?? "python")
|
||||
const [runTarget, setRunTarget] = useState(prefill?.runTarget ?? prefill?.name ?? "")
|
||||
const [root, setRoot] = useState("dist")
|
||||
const [port, setPort] = useState("")
|
||||
const [health, setHealth] = useState("/health")
|
||||
const [expose, setExpose] = useState(true)
|
||||
const [proxy, setProxy] = useState(true)
|
||||
const [isPublic, setIsPublic] = useState(false)
|
||||
const [schedule, setSchedule] = useState("0 2 * * *")
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const isSystemd = kind === "service" || kind === "job"
|
||||
|
||||
const nameError =
|
||||
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
|
||||
? "lowercase letters, numbers, hyphens"
|
||||
@@ -51,31 +65,41 @@ export function CreateDeploymentForm({
|
||||
? "already exists"
|
||||
: ""
|
||||
|
||||
const buildRun = () =>
|
||||
launcher === "command"
|
||||
? { launcher: "command", argv: runTarget.split(" ").filter(Boolean) }
|
||||
: { launcher: "python", program: runTarget || name }
|
||||
|
||||
const buildConfig = (): Record<string, unknown> => {
|
||||
const run =
|
||||
runner === "command"
|
||||
? { runner: "command", argv: runTarget.split(" ").filter(Boolean) }
|
||||
: { runner: "python", program: runTarget || name }
|
||||
const base: Record<string, unknown> = {
|
||||
...(program ? { program } : {}),
|
||||
...(description ? { description } : {}),
|
||||
run,
|
||||
}
|
||||
if (kind === "tool") return { ...base, manager: "path" }
|
||||
if (kind === "static") return { ...base, manager: "caddy", root }
|
||||
|
||||
// systemd (service or job)
|
||||
const cfg: Record<string, unknown> = {
|
||||
...base,
|
||||
manager: "systemd",
|
||||
run: buildRun(),
|
||||
manage: { systemd: {} },
|
||||
}
|
||||
if (kind === "job") {
|
||||
base.schedule = schedule
|
||||
return base
|
||||
cfg.schedule = schedule
|
||||
return cfg
|
||||
}
|
||||
if (port) {
|
||||
base.expose = {
|
||||
cfg.expose = {
|
||||
http: {
|
||||
internal: { port: parseInt(port, 10) },
|
||||
...(health ? { health_path: health } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
if (port && expose) base.proxy = true
|
||||
return base
|
||||
if (proxy) cfg.proxy = true
|
||||
if (proxy && isPublic) cfg.public = true
|
||||
return cfg
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
@@ -83,17 +107,20 @@ export function CreateDeploymentForm({
|
||||
setError("")
|
||||
try {
|
||||
setBusy("Saving…")
|
||||
await apiClient.put(`/config/${kind}s/${name}`, { config: buildConfig() })
|
||||
await apiClient.put(`/config/deployments/${name}`, { config: buildConfig() })
|
||||
setBusy("Deploying…")
|
||||
await apiClient.post(`/deploy`, { name })
|
||||
if (kind === "service") {
|
||||
setBusy("Starting…")
|
||||
await apiClient.post(`/services/${name}/start`, {})
|
||||
}
|
||||
qc.invalidateQueries({ queryKey: [`${kind}s`] })
|
||||
qc.invalidateQueries({ queryKey: ["services"] })
|
||||
qc.invalidateQueries({ queryKey: ["jobs"] })
|
||||
qc.invalidateQueries({ queryKey: ["programs"] })
|
||||
qc.invalidateQueries({ queryKey: ["status"] })
|
||||
navigate(kind === "service" ? `/services/${name}` : `/jobs/${name}`)
|
||||
if (kind === "service") navigate(`/services/${name}`)
|
||||
else if (kind === "job") navigate(`/jobs/${name}`)
|
||||
else navigate(`/programs/${program || name}`)
|
||||
} catch (e: unknown) {
|
||||
let msg = e instanceof Error ? e.message : String(e)
|
||||
try {
|
||||
@@ -109,7 +136,7 @@ export function CreateDeploymentForm({
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4 mt-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">New {kind}{program ? ` for ${program}` : ""}</h3>
|
||||
<h3 className="font-semibold">New deployment{program ? ` for ${program}` : ""}</h3>
|
||||
<button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||
<X size={16} />
|
||||
</button>
|
||||
@@ -121,11 +148,31 @@ export function CreateDeploymentForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Kind picker */}
|
||||
<Field label="Kind" hint={KIND_INFO[kind].hint}>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(Object.keys(KIND_INFO) as DeploymentKind[]).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
onClick={() => setKind(k)}
|
||||
className={`px-3 py-1 text-sm rounded border transition-colors ${
|
||||
kind === k
|
||||
? "bg-[var(--primary)] text-white border-[var(--primary)]"
|
||||
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{KIND_INFO[k].label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<Field label="Name">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value.toLowerCase())}
|
||||
placeholder={kind === "service" ? "my-service" : "my-job"}
|
||||
placeholder="my-deployment"
|
||||
autoFocus
|
||||
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
@@ -134,8 +181,17 @@ export function CreateDeploymentForm({
|
||||
|
||||
<TextField label="Description" value={description} onChange={setDescription} />
|
||||
|
||||
<Field label="Runner">
|
||||
<select value={runner} onChange={(e) => setRunner(e.target.value)} className={`w-40 ${SELECT}`}>
|
||||
{/* Program ref — informational; tool/static require it, systemd optional. */}
|
||||
{program && (
|
||||
<Field label="Program">
|
||||
<span className="text-sm font-mono text-[var(--muted)]">{program}</span>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{isSystemd && (
|
||||
<>
|
||||
<Field label="Launcher">
|
||||
<select value={launcher} onChange={(e) => setLauncher(e.target.value)} className={`w-40 ${SELECT}`}>
|
||||
<option value="python">python</option>
|
||||
<option value="command">command</option>
|
||||
</select>
|
||||
@@ -145,26 +201,49 @@ export function CreateDeploymentForm({
|
||||
value={runTarget}
|
||||
onChange={setRunTarget}
|
||||
mono
|
||||
placeholder={runner === "command" ? "my-cmd --flag" : "console-script"}
|
||||
placeholder={launcher === "command" ? "my-cmd --flag" : "console-script"}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{kind === "service" ? (
|
||||
{kind === "service" && (
|
||||
<>
|
||||
<TextField label="Port" value={port} onChange={setPort} width="w-32" mono placeholder="9001" />
|
||||
<TextField label="Health path" value={health} onChange={setHealth} width="w-48" mono />
|
||||
<Field label="Expose" hint="Route through the gateway at <name>.<gateway.domain>. Off: reachable only at host:port.">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={expose} onChange={(e) => setExpose(e.target.checked)} />
|
||||
<input type="checkbox" checked={proxy} onChange={(e) => setProxy(e.target.checked)} />
|
||||
<span className="font-mono text-[var(--muted)]">
|
||||
{expose ? `${name || "name"}.<gateway.domain>` : "off (host:port only)"}
|
||||
{proxy ? `${name || "name"}.<gateway.domain>` : "off (host:port only)"}
|
||||
</span>
|
||||
</label>
|
||||
</Field>
|
||||
{proxy && (
|
||||
<Field label="Public" hint="Also publish to the internet via the Cloudflare tunnel at <name>.<gateway.public_domain>.">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
|
||||
<span className="font-mono text-[var(--muted)]">{isPublic ? "public (via tunnel)" : "internal only"}</span>
|
||||
</label>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
|
||||
{kind === "job" && (
|
||||
<TextField label="Schedule" value={schedule} onChange={setSchedule} width="w-48" mono placeholder="0 2 * * *" />
|
||||
)}
|
||||
|
||||
{kind === "static" && (
|
||||
<TextField
|
||||
label="Root"
|
||||
value={root}
|
||||
onChange={setRoot}
|
||||
width="w-48"
|
||||
mono
|
||||
placeholder="dist"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button onClick={onCancel} className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||
Cancel
|
||||
@@ -174,7 +253,7 @@ export function CreateDeploymentForm({
|
||||
disabled={!name || !!nameError || !!busy}
|
||||
className="px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
|
||||
>
|
||||
{busy ?? `Create ${kind}`}
|
||||
{busy ?? `Create ${KIND_INFO[kind].label.toLowerCase()}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,27 +5,26 @@ import type { ProgramDetail } from "@/types"
|
||||
import { useServices, useJobs } from "@/services/api/hooks"
|
||||
import { CreateDeploymentForm, type CreatePrefill } from "./CreateDeploymentForm"
|
||||
|
||||
/** The services and jobs that deploy a program. A program → 0-N services and
|
||||
* 0-N jobs; these are convenience links, not ownership (a deployment can run
|
||||
* anything, program-backed or not). The Create buttons just prefill the
|
||||
* standalone create form with sensible values. */
|
||||
/** The services and jobs that deploy a program. A program → 0-N deployments;
|
||||
* these are convenience links, not ownership (a deployment can run anything,
|
||||
* program-backed or not). The Create button prefills the kind-aware wizard. */
|
||||
export function DeploymentsSection({ program }: { program: ProgramDetail }) {
|
||||
const { services, jobs, behavior } = program
|
||||
const { services, jobs, kind } = program
|
||||
const none = services.length === 0 && jobs.length === 0
|
||||
const [creating, setCreating] = useState<"service" | "job" | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const { data: allServices } = useServices()
|
||||
const { data: allJobs } = useJobs()
|
||||
const existing =
|
||||
creating === "service"
|
||||
? (allServices ?? []).map((s) => s.id)
|
||||
: (allJobs ?? []).map((j) => j.id)
|
||||
const existing = [
|
||||
...(allServices ?? []).map((s) => s.id),
|
||||
...(allJobs ?? []).map((j) => j.id),
|
||||
]
|
||||
|
||||
const prefill: CreatePrefill = {
|
||||
name: program.id,
|
||||
program: program.id,
|
||||
runTarget: program.id,
|
||||
runner: program.stack?.startsWith("python") || !program.stack ? "python" : "command",
|
||||
launcher: program.stack?.startsWith("python") || !program.stack ? "python" : "command",
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -34,20 +33,12 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
|
||||
<h2 className="text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
|
||||
Deployments
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setCreating(creating === "service" ? null : "service")}
|
||||
onClick={() => setCreating((c) => !c)}
|
||||
className="flex items-center gap-1 text-xs text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<Plus size={12} /> Create service
|
||||
<Plus size={12} /> Add deployment
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCreating(creating === "job" ? null : "job")}
|
||||
className="flex items-center gap-1 text-xs text-[var(--primary)] hover:underline"
|
||||
>
|
||||
<Plus size={12} /> Create job
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--muted)] mb-4">
|
||||
Services and jobs that run this program.
|
||||
@@ -55,19 +46,18 @@ export function DeploymentsSection({ program }: { program: ProgramDetail }) {
|
||||
|
||||
{creating && (
|
||||
<CreateDeploymentForm
|
||||
kind={creating}
|
||||
prefill={prefill}
|
||||
existingNames={existing}
|
||||
onCancel={() => setCreating(null)}
|
||||
onCancel={() => setCreating(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{none && !creating ? (
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{behavior === "daemon"
|
||||
? "No service yet — this daemon isn't deployed."
|
||||
: behavior === "tool"
|
||||
? "Not scheduled — add a job to run it on a timer."
|
||||
{kind === "service"
|
||||
? "No service yet — this program isn't deployed."
|
||||
: kind === "tool"
|
||||
? "Installed on PATH. Add a job to also run it on a timer."
|
||||
: "None."}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { Link } from "react-router-dom"
|
||||
import { ArrowLeft } from "lucide-react"
|
||||
import { BehaviorBadge } from "@/components/BehaviorBadge"
|
||||
import { KindBadge } from "@/components/KindBadge"
|
||||
import { StackBadge } from "@/components/StackBadge"
|
||||
|
||||
interface DetailHeaderProps {
|
||||
backTo: string
|
||||
backLabel: string
|
||||
name: string
|
||||
behavior?: string | null
|
||||
kind?: string | null
|
||||
stack?: string | null
|
||||
source?: string | null
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function DetailHeader({ backTo, backLabel, name, behavior, stack, source, children }: DetailHeaderProps) {
|
||||
export function DetailHeader({ backTo, backLabel, name, kind, stack, source, children }: DetailHeaderProps) {
|
||||
return (
|
||||
<>
|
||||
<Link to={backTo} className="text-[var(--primary)] hover:underline flex items-center gap-1 mb-6">
|
||||
@@ -26,7 +26,7 @@ export function DetailHeader({ backTo, backLabel, name, behavior, stack, source,
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap mb-6">
|
||||
<BehaviorBadge behavior={behavior ?? null} />
|
||||
<KindBadge kind={kind ?? null} />
|
||||
<StackBadge stack={stack ?? null} />
|
||||
{source && (
|
||||
<span className="text-sm text-[var(--muted)] font-mono break-all min-w-0">{source}</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import type { JobDetail } from "@/types"
|
||||
import { runnerLabel } from "@/lib/labels"
|
||||
import { launcherLabel } from "@/lib/labels"
|
||||
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
|
||||
|
||||
interface Props {
|
||||
@@ -27,7 +27,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
|
||||
)
|
||||
|
||||
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
||||
const runner = (run.runner as string) ?? "?"
|
||||
const launcher = (run.launcher as string) ?? "?"
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
@@ -39,8 +39,8 @@ export function JobFields({ job, onSave, onDelete }: Props) {
|
||||
config.schedule = schedule || undefined
|
||||
|
||||
const runOut = obj(config.run)
|
||||
if (runner === "command") runOut.argv = runTarget.split(" ").filter(Boolean)
|
||||
else if (runner === "python") runOut.program = runTarget
|
||||
if (launcher === "command") runOut.argv = runTarget.split(" ").filter(Boolean)
|
||||
else if (launcher === "python") runOut.program = runTarget
|
||||
config.run = runOut
|
||||
|
||||
const env = merged()
|
||||
@@ -68,7 +68,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
|
||||
hint="Cron expression — castle generates a systemd timer that runs the job on this schedule."
|
||||
/>
|
||||
<Field label="Runs" hint="The console script or command the job runs on each tick, then exits.">
|
||||
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} · </span>
|
||||
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} · </span>
|
||||
<input
|
||||
value={runTarget}
|
||||
onChange={(e) => setRunTarget(e.target.value)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react"
|
||||
import type { ServiceDetail } from "@/types"
|
||||
import { runnerLabel } from "@/lib/labels"
|
||||
import { launcherLabel } from "@/lib/labels"
|
||||
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
|
||||
|
||||
interface Props {
|
||||
@@ -33,7 +33,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
|
||||
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
||||
|
||||
const runner = (run.runner as string) ?? "?"
|
||||
const launcher = (run.launcher as string) ?? "?"
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
@@ -43,11 +43,11 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
delete config.id
|
||||
config.description = description || undefined
|
||||
|
||||
// Only python/command run specs are edited here; other runners
|
||||
// (container/node/remote) keep their original run block untouched.
|
||||
// Only python/command launchers are edited here; other launchers
|
||||
// (container/node/compose) keep their original run block untouched.
|
||||
const runOut = obj(config.run)
|
||||
if (runner === "command") runOut.argv = runProgram.split(" ").filter(Boolean)
|
||||
else if (runner === "python") runOut.program = runProgram
|
||||
if (launcher === "command") runOut.argv = runProgram.split(" ").filter(Boolean)
|
||||
else if (launcher === "python") runOut.program = runProgram
|
||||
config.run = runOut
|
||||
|
||||
if (port) {
|
||||
@@ -83,7 +83,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
label="Runs"
|
||||
hint="The console script (python runner) or command this service executes."
|
||||
>
|
||||
<span className="text-sm font-mono text-[var(--muted)]">{runnerLabel(runner)} · </span>
|
||||
<span className="text-sm font-mono text-[var(--muted)]">{launcherLabel(launcher)} · </span>
|
||||
<input
|
||||
value={runProgram}
|
||||
onChange={(e) => setRunProgram(e.target.value)}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
export const RUNNER_LABELS: Record<string, string> = {
|
||||
export const LAUNCHER_LABELS: Record<string, string> = {
|
||||
python: "Python",
|
||||
command: "Command",
|
||||
container: "Container",
|
||||
compose: "Compose",
|
||||
node: "Node.js",
|
||||
remote: "Remote",
|
||||
}
|
||||
|
||||
export const BEHAVIOR_LABELS: Record<string, string> = {
|
||||
daemon: "Daemon",
|
||||
// Derived deployment kinds (service | job | tool | static | reference).
|
||||
export const KIND_LABELS: Record<string, string> = {
|
||||
service: "Service",
|
||||
job: "Job",
|
||||
tool: "Tool",
|
||||
frontend: "Frontend",
|
||||
static: "Static",
|
||||
reference: "Reference",
|
||||
}
|
||||
|
||||
export const BEHAVIOR_DESCRIPTIONS: Record<string, string> = {
|
||||
daemon: "Long-running process that exposes ports",
|
||||
tool: "CLI utility or scheduled task",
|
||||
frontend: "Built web application",
|
||||
export const KIND_DESCRIPTIONS: Record<string, string> = {
|
||||
service: "Long-running process (systemd)",
|
||||
job: "Scheduled task (timer)",
|
||||
tool: "CLI installed on PATH",
|
||||
static: "Static site served by the gateway",
|
||||
reference: "External service on another node",
|
||||
}
|
||||
|
||||
export const STACK_LABELS: Record<string, string> = {
|
||||
@@ -31,12 +36,12 @@ export const STACK_LABELS: Record<string, string> = {
|
||||
remote: "Remote",
|
||||
}
|
||||
|
||||
export function runnerLabel(runner: string): string {
|
||||
return RUNNER_LABELS[runner] ?? runner
|
||||
export function launcherLabel(launcher: string): string {
|
||||
return LAUNCHER_LABELS[launcher] ?? launcher
|
||||
}
|
||||
|
||||
export function behaviorLabel(behavior: string): string {
|
||||
return BEHAVIOR_LABELS[behavior] ?? behavior
|
||||
export function kindLabel(kind: string): string {
|
||||
return KIND_LABELS[kind] ?? kind
|
||||
}
|
||||
|
||||
export function stackLabel(stack: string): string {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams, Link } from "react-router-dom"
|
||||
import { ArrowLeft, Server } from "lucide-react"
|
||||
import { useNode } from "@/services/api/hooks"
|
||||
import { BehaviorBadge } from "@/components/BehaviorBadge"
|
||||
import { KindBadge } from "@/components/KindBadge"
|
||||
import { StackBadge } from "@/components/StackBadge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -63,9 +63,9 @@ export function NodeDetailPage() {
|
||||
<thead>
|
||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Deployment</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Behavior</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Kind</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Stack</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Runner</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Manager</th>
|
||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -87,13 +87,13 @@ export function NodeDetailPage() {
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<BehaviorBadge behavior={comp.behavior} />
|
||||
<KindBadge kind={comp.kind} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<StackBadge stack={comp.stack} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{comp.runner ?? "—"}
|
||||
{comp.manager ?? "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
|
||||
{comp.port ?? "—"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react"
|
||||
import { useParams } from "react-router-dom"
|
||||
import { useProgram } from "@/services/api/hooks"
|
||||
import { runnerLabel, subdomainUrl } from "@/lib/labels"
|
||||
import { launcherLabel, subdomainUrl } from "@/lib/labels"
|
||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||
import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
||||
import { DeploymentsSection } from "@/components/detail/DeploymentsSection"
|
||||
@@ -27,11 +27,11 @@ export function ProgramDetailPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// A static frontend (frontend behavior, build outputs, no service) is served by
|
||||
// the gateway in place at its own subdomain — show where.
|
||||
// A static (caddy) deployment with build outputs is served by the gateway in
|
||||
// place at its own subdomain — show where.
|
||||
const buildOutputs = (deployment.manifest.build as { outputs?: string[] } | undefined)?.outputs
|
||||
const servedAt =
|
||||
deployment.behavior === "frontend" && deployment.services.length === 0 && buildOutputs?.length
|
||||
deployment.kind === "static" && buildOutputs?.length
|
||||
? (subdomainUrl(deployment.id) ?? `${deployment.id}.<gateway.domain>`)
|
||||
: null
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ProgramDetailPage() {
|
||||
backTo="/programs"
|
||||
backLabel="Back to Programs"
|
||||
name={deployment.id}
|
||||
behavior={deployment.behavior}
|
||||
kind={deployment.kind}
|
||||
stack={deployment.stack}
|
||||
source={deployment.source}
|
||||
>
|
||||
@@ -49,8 +49,7 @@ export function ProgramDetailPage() {
|
||||
name={deployment.id}
|
||||
actions={deployment.actions}
|
||||
active={deployment.active}
|
||||
behavior={deployment.behavior}
|
||||
deployedAs={[...deployment.services, ...deployment.jobs]}
|
||||
kind={deployment.kind}
|
||||
onOutput={setActionOutput}
|
||||
/>
|
||||
</DetailHeader>
|
||||
@@ -96,8 +95,8 @@ export function ProgramDetailPage() {
|
||||
)}
|
||||
{deployment.runner && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Runner</span>
|
||||
<span>{runnerLabel(deployment.runner)}</span>
|
||||
<span className="text-[var(--muted)]">Launcher</span>
|
||||
<span>{launcherLabel(deployment.runner)}</span>
|
||||
</>
|
||||
)}
|
||||
{deployment.active !== null && (
|
||||
|
||||
@@ -34,6 +34,7 @@ export function ScheduledDetailPage() {
|
||||
backTo="/scheduled"
|
||||
backLabel="Back to Jobs"
|
||||
name={deployment.id}
|
||||
kind="job"
|
||||
stack={deployment.stack}
|
||||
source={deployment.source}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useParams, Link } from "react-router-dom"
|
||||
import { Server, ExternalLink, Terminal } from "lucide-react"
|
||||
import { useService, useStatus, useCaddyfile } from "@/services/api/hooks"
|
||||
import { runnerLabel, subdomainUrl } from "@/lib/labels"
|
||||
import { launcherLabel, subdomainUrl } from "@/lib/labels"
|
||||
import { HealthBadge } from "@/components/HealthBadge"
|
||||
import { LogViewer } from "@/components/LogViewer"
|
||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||
@@ -38,7 +38,7 @@ export function ServiceDetailPage() {
|
||||
backTo="/services"
|
||||
backLabel="Back to Services"
|
||||
name={deployment.id}
|
||||
behavior="daemon"
|
||||
kind="service"
|
||||
stack={deployment.stack}
|
||||
source={deployment.source}
|
||||
>
|
||||
@@ -78,12 +78,12 @@ export function ServiceDetailPage() {
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
{deployment.runner && (
|
||||
{deployment.launcher && (
|
||||
<>
|
||||
<span className="text-[var(--muted)]">Runs</span>
|
||||
<span className="flex items-center gap-1 min-w-0">
|
||||
<Terminal size={12} className="shrink-0" />
|
||||
{runnerLabel(deployment.runner)}
|
||||
{launcherLabel(deployment.launcher)}
|
||||
{deployment.run_target && <> · <span className="font-mono break-all">{deployment.run_target}</span></>}
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -68,10 +68,10 @@ export function useJob(name: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function usePrograms(behavior?: string) {
|
||||
const params = behavior ? `?behavior=${behavior}` : ""
|
||||
export function usePrograms(kind?: string) {
|
||||
const params = kind ? `?kind=${kind}` : ""
|
||||
return useQuery({
|
||||
queryKey: ["programs", behavior ?? "all"],
|
||||
queryKey: ["programs", kind ?? "all"],
|
||||
queryFn: () => apiClient.get<ProgramSummary[]>(`/programs${params}`),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ export interface ServiceSummary {
|
||||
id: string
|
||||
description: string | null
|
||||
stack: string | null
|
||||
runner: string | null
|
||||
manager?: string | null // systemd for a service
|
||||
launcher: string | null // python | command | container | compose | node
|
||||
run_target: string | null
|
||||
port: number | null
|
||||
health_path: string | null
|
||||
@@ -28,7 +29,7 @@ export interface JobSummary {
|
||||
id: string
|
||||
description: string | null
|
||||
stack: string | null
|
||||
runner: string | null
|
||||
launcher: string | null // python | command | container | compose | node
|
||||
run_target: string | null
|
||||
schedule: string | null
|
||||
managed: boolean
|
||||
@@ -45,9 +46,9 @@ export interface JobDetail extends JobSummary {
|
||||
export interface ProgramSummary {
|
||||
id: string
|
||||
description: string | null
|
||||
behavior: string | null
|
||||
kind: string | null // derived: service | job | tool | static | reference
|
||||
stack: string | null
|
||||
runner: string | null
|
||||
runner: string | null // inferred launch hint (python | command)
|
||||
version: string | null
|
||||
source: string | null
|
||||
repo: string | null
|
||||
@@ -74,9 +75,10 @@ export interface DeploymentSummary {
|
||||
id: string
|
||||
category: "program" | "service" | "job" | null
|
||||
description: string | null
|
||||
behavior: string | null
|
||||
kind: string | null // derived: service | job | tool | static | reference
|
||||
stack: string | null
|
||||
runner: string | null
|
||||
manager: string | null // systemd | caddy | path | none
|
||||
launcher: string | null // python | command | container | compose | node (systemd only)
|
||||
port: number | null
|
||||
health_path: string | null
|
||||
subdomain: string | null // exposed at <subdomain>.<gateway.domain>, else null
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,9 +17,10 @@ class DeploymentSummary(BaseModel):
|
||||
id: str
|
||||
category: str | None = None # "program", "service", or "job"
|
||||
description: str | None = None
|
||||
behavior: str | None = None
|
||||
kind: str | None = None # derived: service|job|tool|static|reference
|
||||
stack: str | None = None
|
||||
runner: str | None = None
|
||||
manager: str | None = None # systemd|caddy|path|none
|
||||
launcher: str | None = None # python|command|container|compose|node (systemd only)
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
|
||||
@@ -49,7 +50,7 @@ class ServiceSummary(BaseModel):
|
||||
id: str
|
||||
description: str | None = None
|
||||
stack: str | None = None
|
||||
runner: str | None = None
|
||||
launcher: str | None = None # python|command|container|compose|node
|
||||
run_target: str | None = None # what it runs: program name, argv, image, …
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
@@ -73,7 +74,7 @@ class JobSummary(BaseModel):
|
||||
id: str
|
||||
description: str | None = None
|
||||
stack: str | None = None
|
||||
runner: str | None = None
|
||||
launcher: str | None = None # python|command|container|compose|node
|
||||
run_target: str | None = None # what it runs: program name, argv, …
|
||||
schedule: str | None = None
|
||||
managed: bool = False
|
||||
@@ -94,9 +95,8 @@ class ProgramSummary(BaseModel):
|
||||
|
||||
id: str
|
||||
description: str | None = None
|
||||
behavior: str | None = None
|
||||
kind: str | None = None # derived: service|job|tool|static|reference
|
||||
stack: str | None = None
|
||||
runner: str | None = None
|
||||
version: str | None = None
|
||||
source: str | None = None
|
||||
repo: str | None = None
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,31 +8,31 @@ import subprocess
|
||||
from castle_cli.config import REPOS_DIR, load_config, save_config
|
||||
from castle_cli.manifest import (
|
||||
BuildSpec,
|
||||
CaddyDeployment,
|
||||
DefaultsSpec,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
ManageSpec,
|
||||
PathDeployment,
|
||||
ProgramSpec,
|
||||
RunPath,
|
||||
RunPython,
|
||||
RunStatic,
|
||||
ServiceSpec,
|
||||
SystemdDeployment,
|
||||
SystemdSpec,
|
||||
)
|
||||
from castle_cli.templates.scaffold import scaffold_project
|
||||
|
||||
# Stack determines default behavior and scaffold template
|
||||
# Stack determines the default deployment kind + scaffold template.
|
||||
STACK_DEFAULTS: dict[str, str] = {
|
||||
"python-fastapi": "daemon",
|
||||
"python-fastapi": "service",
|
||||
"python-cli": "tool",
|
||||
"react-vite": "frontend",
|
||||
"supabase": "frontend",
|
||||
"react-vite": "static",
|
||||
"supabase": "static",
|
||||
}
|
||||
|
||||
# Static build output per stack, for `behavior: frontend` programs. The gateway
|
||||
# serves this dir in place at /<name>/ (no service, no process). A supabase app
|
||||
# ships a raw `public/`; react-vite builds to `dist/`.
|
||||
# Static build output per stack, for `static` (caddy) deployments. The gateway
|
||||
# serves this dir in place at <name>.<gateway.domain> (no service, no process).
|
||||
# A supabase app ships a raw `public/`; react-vite builds to `dist/`.
|
||||
STACK_BUILD_OUTPUTS: dict[str, str] = {
|
||||
"supabase": "public",
|
||||
"react-vite": "dist",
|
||||
@@ -42,9 +42,10 @@ STACK_BUILD_OUTPUTS: dict[str, str] = {
|
||||
def next_available_port(config: object) -> int:
|
||||
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
|
||||
used_ports = set()
|
||||
for svc in config.services.values():
|
||||
if svc.expose and svc.expose.http:
|
||||
used_ports.add(svc.expose.http.internal.port)
|
||||
for dep in config.deployments.values():
|
||||
expose = getattr(dep, "expose", None)
|
||||
if expose and expose.http:
|
||||
used_ports.add(expose.http.internal.port)
|
||||
# Also reserve gateway port
|
||||
used_ports.add(config.gateway.port)
|
||||
|
||||
@@ -59,9 +60,9 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
config = load_config()
|
||||
name = args.name
|
||||
stack = args.stack
|
||||
behavior = STACK_DEFAULTS.get(stack) if stack else None
|
||||
kind = STACK_DEFAULTS.get(stack) if stack else None
|
||||
|
||||
if name in config.programs or name in config.services or name in config.jobs:
|
||||
if name in config.programs or name in config.deployments:
|
||||
print(f"Error: '{name}' already exists in castle.yaml")
|
||||
return 1
|
||||
|
||||
@@ -71,9 +72,9 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
print(f"Error: directory already exists: {project_dir}")
|
||||
return 1
|
||||
|
||||
# Determine port for daemons
|
||||
# Determine port for service (daemon) deployments
|
||||
port = args.port
|
||||
if behavior == "daemon" and port is None:
|
||||
if kind == "service" and port is None:
|
||||
port = next_available_port(config)
|
||||
|
||||
package_name = name.replace("-", "_")
|
||||
@@ -102,32 +103,32 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
if static_root:
|
||||
build = BuildSpec(outputs=[static_root])
|
||||
|
||||
# `kind` (and thus behavior) is derived from the deployment below — never
|
||||
# stored on the program.
|
||||
config.programs[name] = ProgramSpec(
|
||||
id=name,
|
||||
description=description,
|
||||
source=str(project_dir),
|
||||
stack=stack,
|
||||
behavior=behavior,
|
||||
build=build,
|
||||
)
|
||||
if behavior == "tool":
|
||||
if kind == "tool":
|
||||
# A PATH-managed deployment: installed via `uv tool install`, no unit/route.
|
||||
config.services[name] = ServiceSpec(
|
||||
id=name, program=name, run=RunPath(runner="path")
|
||||
config.deployments[name] = PathDeployment(
|
||||
id=name, manager="path", program=name
|
||||
)
|
||||
elif behavior == "frontend":
|
||||
# A caddy-managed static service: no systemd unit, served from the build dir.
|
||||
config.services[name] = ServiceSpec(
|
||||
id=name,
|
||||
program=name,
|
||||
run=RunStatic(runner="static", root=static_root or "dist"),
|
||||
elif kind == "static":
|
||||
# A caddy-managed static deployment: no systemd unit, served from the build dir.
|
||||
config.deployments[name] = CaddyDeployment(
|
||||
id=name, manager="caddy", program=name, root=static_root or "dist"
|
||||
)
|
||||
elif behavior == "daemon":
|
||||
elif kind == "service":
|
||||
prefix = name.replace("-", "_").upper()
|
||||
config.services[name] = ServiceSpec(
|
||||
config.deployments[name] = SystemdDeployment(
|
||||
id=name,
|
||||
manager="systemd",
|
||||
program=name,
|
||||
run=RunPython(runner="python", program=name),
|
||||
run=RunPython(launcher="python", program=name),
|
||||
expose=ExposeSpec(
|
||||
http=HttpExposeSpec(
|
||||
internal=HttpInternal(port=port),
|
||||
@@ -143,6 +144,10 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
),
|
||||
)
|
||||
|
||||
# Populate the derived kind on the in-memory program so readers see the live
|
||||
# value immediately (it's excluded from disk — kind_of recomputes on load).
|
||||
config.programs[name].kind = config.kind_of(name)
|
||||
|
||||
save_config(config)
|
||||
|
||||
label = f"{stack} program" if stack else "bare program"
|
||||
@@ -158,7 +163,7 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
print(f" castle deploy && castle gateway reload # serve at /{name}/")
|
||||
elif stack:
|
||||
print(" uv sync")
|
||||
if behavior == "daemon":
|
||||
if kind == "service":
|
||||
print(f" uv run {name} # starts on port {port}")
|
||||
print(f" castle deploy {name}")
|
||||
print(f" castle test {name}")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -15,11 +15,10 @@ from castle_cli.manifest import (
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
JobSpec,
|
||||
ManageSpec,
|
||||
RunCommand,
|
||||
RunPython,
|
||||
ServiceSpec,
|
||||
SystemdDeployment,
|
||||
SystemdSpec,
|
||||
)
|
||||
|
||||
@@ -35,17 +34,16 @@ def _defaults(env_args: list[str] | None) -> DefaultsSpec | None:
|
||||
return DefaultsSpec(env=env)
|
||||
|
||||
|
||||
def _run_spec(runner: str, target: str, name: str) -> RunPython | RunCommand:
|
||||
if runner == "command":
|
||||
return RunCommand(runner="command", argv=target.split() or [name])
|
||||
return RunPython(runner="python", program=target or name)
|
||||
def _run_spec(launcher: str, target: str, name: str) -> RunPython | RunCommand:
|
||||
if launcher == "command":
|
||||
return RunCommand(launcher="command", argv=target.split() or [name])
|
||||
return RunPython(launcher="python", program=target or name)
|
||||
|
||||
|
||||
def _check_new(config: object, name: str, section: str) -> str | None:
|
||||
"""Return an error message if the name can't be created, else None."""
|
||||
existing = getattr(config, section)
|
||||
if name in existing:
|
||||
return f"Error: {section[:-1]} '{name}' already exists."
|
||||
def _check_new(config: object, name: str, label: str) -> str | None:
|
||||
"""Return an error message if the deployment name is taken, else None."""
|
||||
if name in config.deployments: # type: ignore[attr-defined]
|
||||
return f"Error: {label} '{name}' already exists."
|
||||
return None
|
||||
|
||||
|
||||
@@ -53,11 +51,11 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
"""Create a service entry in castle.yaml."""
|
||||
config = load_config()
|
||||
name = args.name
|
||||
if err := _check_new(config, name, "services"):
|
||||
if err := _check_new(config, name, "service"):
|
||||
print(err)
|
||||
return 1
|
||||
|
||||
run = _run_spec(args.runner, args.run or args.program or name, name)
|
||||
run = _run_spec(args.launcher, args.run or args.program or name, name)
|
||||
|
||||
expose = None
|
||||
proxy = False
|
||||
@@ -71,8 +69,9 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
# Expose at <name>.<gateway.domain> (the subdomain is the service name).
|
||||
proxy = not args.no_proxy
|
||||
|
||||
config.services[name] = ServiceSpec(
|
||||
config.deployments[name] = SystemdDeployment(
|
||||
id=name,
|
||||
manager="systemd",
|
||||
program=args.program,
|
||||
description=args.description or None,
|
||||
run=run,
|
||||
@@ -84,7 +83,7 @@ def run_service_create(args: argparse.Namespace) -> int:
|
||||
save_config(config)
|
||||
|
||||
print(f"Created service '{name}'.")
|
||||
print(f" runs: {args.runner} ({args.run or args.program or name})")
|
||||
print(f" runs: {args.launcher} ({args.run or args.program or name})")
|
||||
if expose:
|
||||
print(f" port: {args.port}")
|
||||
if proxy:
|
||||
@@ -97,14 +96,16 @@ def run_job_create(args: argparse.Namespace) -> int:
|
||||
"""Create a job entry in castle.yaml."""
|
||||
config = load_config()
|
||||
name = args.name
|
||||
if err := _check_new(config, name, "jobs"):
|
||||
if err := _check_new(config, name, "job"):
|
||||
print(err)
|
||||
return 1
|
||||
|
||||
run = _run_spec(args.runner, args.run or args.program or name, name)
|
||||
run = _run_spec(args.launcher, args.run or args.program or name, name)
|
||||
|
||||
config.jobs[name] = JobSpec(
|
||||
# A job is a systemd deployment with a schedule (→ a .timer).
|
||||
config.deployments[name] = SystemdDeployment(
|
||||
id=name,
|
||||
manager="systemd",
|
||||
program=args.program,
|
||||
description=args.description or None,
|
||||
run=run,
|
||||
@@ -115,7 +116,7 @@ def run_job_create(args: argparse.Namespace) -> int:
|
||||
save_config(config)
|
||||
|
||||
print(f"Created job '{name}'.")
|
||||
print(f" runs: {args.runner} ({args.run or args.program or name})")
|
||||
print(f" runs: {args.launcher} ({args.run or args.program or name})")
|
||||
print(f" schedule: {args.schedule}")
|
||||
print(f"\nNext: castle job deploy {name} && castle job enable {name}")
|
||||
return 0
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,17 +144,18 @@ 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:
|
||||
for name, dep in config.deployments.items():
|
||||
if dep.manager != "systemd":
|
||||
continue
|
||||
if kind_for(dep) == "job":
|
||||
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":
|
||||
continue
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ from castle_core.config import (
|
||||
from castle_core.generators.caddyfile import (
|
||||
_DNS_TOKEN_ENV,
|
||||
generate_caddyfile_from_registry,
|
||||
service_proxy_targets,
|
||||
)
|
||||
from castle_core.generators.tunnel import (
|
||||
generate_tunnel_config,
|
||||
@@ -41,7 +40,14 @@ from castle_core.generators.systemd import (
|
||||
unit_env_file,
|
||||
unit_name,
|
||||
)
|
||||
from castle_core.manifest import JobSpec, ServiceSpec, behavior_for_runner, manager_for
|
||||
from castle_core.manifest import (
|
||||
CaddyDeployment,
|
||||
DeploymentBase,
|
||||
DeploymentSpec,
|
||||
PathDeployment,
|
||||
RemoteDeployment,
|
||||
kind_for,
|
||||
)
|
||||
from castle_core.registry import (
|
||||
REGISTRY_PATH,
|
||||
Deployment,
|
||||
@@ -101,20 +107,11 @@ def deploy(target_name: str | None = None, root: Path | None = None) -> DeployRe
|
||||
else:
|
||||
registry = NodeRegistry(node=node)
|
||||
|
||||
# Deploy services
|
||||
for name, svc in config.services.items():
|
||||
# Deploy every deployment, dispatched by its manager (systemd/caddy/path/none).
|
||||
for name, dep in config.deployments.items():
|
||||
if target_name and name != target_name:
|
||||
continue
|
||||
deployed = _build_deployed_service(config, name, svc, result.messages)
|
||||
registry.deployed[name] = deployed
|
||||
result.deployed_count += 1
|
||||
result.messages.append(_format_deployed(name, deployed))
|
||||
|
||||
# Deploy jobs
|
||||
for name, job in config.jobs.items():
|
||||
if target_name and name != target_name:
|
||||
continue
|
||||
deployed = _build_deployed_job(config, name, job, result.messages)
|
||||
deployed = _build_deployed(config, name, dep, result.messages)
|
||||
registry.deployed[name] = deployed
|
||||
result.deployed_count += 1
|
||||
result.messages.append(_format_deployed(name, deployed))
|
||||
@@ -316,7 +313,7 @@ def _write_secret_env_file(name: str, secret_env: dict[str, str]) -> Path | None
|
||||
|
||||
|
||||
def _resolve_description(
|
||||
config: CastleConfig, spec: ServiceSpec | JobSpec
|
||||
config: CastleConfig, spec: DeploymentBase
|
||||
) -> str | None:
|
||||
"""Get description, falling through to program if referenced."""
|
||||
if spec.description:
|
||||
@@ -326,132 +323,112 @@ def _resolve_description(
|
||||
return None
|
||||
|
||||
|
||||
def _build_deployed_service(
|
||||
config: CastleConfig, name: str, svc: ServiceSpec, messages: list[str]
|
||||
def _build_deployed(
|
||||
config: CastleConfig, name: str, dep: DeploymentSpec, messages: list[str]
|
||||
) -> Deployment:
|
||||
"""Build a Deployment from a ServiceSpec."""
|
||||
run = svc.run
|
||||
# The data-dir placeholder is keyed by the program the service runs, not the
|
||||
# service name (e.g. job `protonmail-sync` runs program `protonmail` →
|
||||
# /data/castle/protonmail). Falls back to the service name.
|
||||
config_key = svc.program or name
|
||||
"""Build a runtime Deployment from a DeploymentSpec, dispatched by its manager."""
|
||||
description = _resolve_description(config, dep)
|
||||
kind = kind_for(dep)
|
||||
stack = None
|
||||
if dep.program and dep.program in config.programs:
|
||||
stack = config.programs[dep.program].stack
|
||||
source_dir = _program_source_dir(config, dep.program)
|
||||
|
||||
# Only systemd-managed runners get a unit. caddy (static), path (tools), and
|
||||
# none (remote) have no local process — the manager mapping is the single
|
||||
# source of truth, so there's no runner special-casing here.
|
||||
managed = manager_for(run.runner) == "systemd"
|
||||
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
|
||||
# Non-process managers (caddy/path/none) have no unit and no run_cmd — the
|
||||
# gateway, PATH, or another node is their runtime.
|
||||
if isinstance(dep, CaddyDeployment):
|
||||
# Serves <program-source>/<root> via the gateway; inherently exposed.
|
||||
static_root = str(source_dir / dep.root) if source_dir is not None else None
|
||||
return Deployment(
|
||||
manager="caddy",
|
||||
run_cmd=[],
|
||||
description=description,
|
||||
kind=kind,
|
||||
stack=stack,
|
||||
subdomain=name,
|
||||
public=bool(dep.public),
|
||||
static_root=static_root,
|
||||
managed=False,
|
||||
)
|
||||
if isinstance(dep, PathDeployment):
|
||||
return Deployment(
|
||||
manager="path",
|
||||
run_cmd=[],
|
||||
description=description,
|
||||
kind=kind,
|
||||
stack=stack,
|
||||
managed=False,
|
||||
)
|
||||
if isinstance(dep, RemoteDeployment):
|
||||
return Deployment(
|
||||
manager="none",
|
||||
run_cmd=[],
|
||||
description=description,
|
||||
kind=kind,
|
||||
stack=stack,
|
||||
base_url=dep.base_url,
|
||||
managed=False,
|
||||
)
|
||||
|
||||
# systemd: a supervised process (a service, or a job when scheduled).
|
||||
run = dep.run
|
||||
# ${data_dir} is keyed by the program the deployment runs, not the deployment
|
||||
# name (e.g. job `protonmail-sync` runs program `protonmail` →
|
||||
# /data/castle/protonmail). Falls back to the deployment name.
|
||||
config_key = dep.program or name
|
||||
|
||||
managed = True
|
||||
if dep.manage and dep.manage.systemd and not dep.manage.systemd.enable:
|
||||
managed = False
|
||||
|
||||
# Routing comes from the shared deriver, so the registry written here and the
|
||||
# Caddyfile computed from castle.yaml stay in lockstep. `expose` is the
|
||||
# checkbox; the subdomain is the service name. A `static` service is inherently
|
||||
# served (that's its whole purpose), so it's always exposed.
|
||||
expose, port, base_url = service_proxy_targets(name, svc)
|
||||
if run.runner == "static":
|
||||
expose = True
|
||||
# `proxy` is the exposure checkbox; the subdomain is the deployment name.
|
||||
expose = bool(dep.proxy)
|
||||
port = None
|
||||
health_path = None
|
||||
if svc.expose and svc.expose.http:
|
||||
health_path = svc.expose.http.health_path
|
||||
if dep.expose and dep.expose.http:
|
||||
port = dep.expose.http.internal.port
|
||||
health_path = dep.expose.http.health_path
|
||||
|
||||
# Env is exactly what's declared in defaults.env — no hidden convention
|
||||
# injection. ${port}/${data_dir}/${name}/${public_url} let the program's own
|
||||
# env var names map to castle's computed values without hardcoding them.
|
||||
# Secret-bearing vars
|
||||
# are split out so they never land in the unit file or process argv — they're
|
||||
# written to a mode-0600 env file referenced via EnvironmentFile=/--env-file.
|
||||
raw_env = dict(svc.defaults.env) if (svc.defaults and svc.defaults.env) else {}
|
||||
# Env is exactly what's in defaults.env — no hidden convention injection.
|
||||
# ${port}/${data_dir}/${name}/${public_url} map the program's own env var
|
||||
# names to castle's computed values. Secret-bearing vars split out to a
|
||||
# mode-0600 file (never in the unit or argv).
|
||||
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
|
||||
public_url = _public_url(config, name, expose, port)
|
||||
env, secret_env = resolve_env_split(
|
||||
raw_env, _env_context(name, config_key, port, public_url)
|
||||
)
|
||||
secret_env_file = _write_secret_env_file(name, secret_env)
|
||||
|
||||
# `command`-runner services resolve a tool on PATH → ensure it's installed.
|
||||
# `python`-runner services run in place via `uv run` (below) — no tool venv.
|
||||
if run.runner == "command":
|
||||
_ensure_python_tool(config, svc.program, messages)
|
||||
# `command` launchers resolve a tool on PATH → ensure it's installed.
|
||||
# `python` launchers run in place via `uv run` (below) — no tool venv.
|
||||
if run.launcher == "command":
|
||||
_ensure_python_tool(config, dep.program, messages)
|
||||
|
||||
# Build run_cmd (container runners get --env-file for the secrets).
|
||||
source_dir = _program_source_dir(config, svc.program)
|
||||
run_cmd = _build_run_cmd(
|
||||
name,
|
||||
run,
|
||||
env,
|
||||
messages,
|
||||
source_dir,
|
||||
secret_env_file=secret_env_file,
|
||||
name, run, env, messages, source_dir, secret_env_file=secret_env_file
|
||||
)
|
||||
stop_cmd = _build_stop_cmd(name, run, source_dir)
|
||||
|
||||
# A static service serves <program-source>/<run.root> via the gateway.
|
||||
static_root = None
|
||||
if run.runner == "static" and source_dir is not None:
|
||||
static_root = str(source_dir / run.root) # type: ignore[union-attr]
|
||||
|
||||
# Resolve stack from referenced program
|
||||
stack = None
|
||||
if svc.program and svc.program in config.programs:
|
||||
stack = config.programs[svc.program].stack
|
||||
|
||||
return Deployment(
|
||||
runner=run.runner,
|
||||
manager="systemd",
|
||||
launcher=run.launcher,
|
||||
run_cmd=run_cmd,
|
||||
stop_cmd=stop_cmd,
|
||||
env=env,
|
||||
secret_env_keys=sorted(secret_env),
|
||||
description=_resolve_description(config, svc),
|
||||
behavior=behavior_for_runner(run.runner),
|
||||
description=description,
|
||||
kind=kind,
|
||||
stack=stack,
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
subdomain=(name if expose else None),
|
||||
public=bool(getattr(svc, "public", False) and expose),
|
||||
static_root=static_root,
|
||||
base_url=base_url,
|
||||
public=bool(dep.public and expose),
|
||||
schedule=getattr(dep, "schedule", None),
|
||||
managed=managed,
|
||||
)
|
||||
|
||||
|
||||
def _build_deployed_job(
|
||||
config: CastleConfig, name: str, job: JobSpec, messages: list[str]
|
||||
) -> Deployment:
|
||||
"""Build a Deployment from a JobSpec."""
|
||||
run = job.run
|
||||
# ${data_dir} is keyed by the program the job runs, not the job name — see
|
||||
# _build_deployed_service. Falls back to the job name.
|
||||
config_key = job.program or name
|
||||
raw_env = dict(job.defaults.env) if (job.defaults and job.defaults.env) else {}
|
||||
env, secret_env = resolve_env_split(raw_env, _env_context(name, config_key, None))
|
||||
secret_env_file = _write_secret_env_file(name, secret_env)
|
||||
if run.runner == "command":
|
||||
_ensure_python_tool(config, job.program, messages)
|
||||
run_cmd = _build_run_cmd(
|
||||
name,
|
||||
run,
|
||||
env,
|
||||
messages,
|
||||
_program_source_dir(config, job.program),
|
||||
secret_env_file=secret_env_file,
|
||||
)
|
||||
|
||||
stack = None
|
||||
if job.program and job.program in config.programs:
|
||||
stack = config.programs[job.program].stack
|
||||
|
||||
return Deployment(
|
||||
runner=run.runner,
|
||||
run_cmd=run_cmd,
|
||||
env=env,
|
||||
secret_env_keys=sorted(secret_env),
|
||||
description=_resolve_description(config, job),
|
||||
behavior="tool",
|
||||
stack=stack,
|
||||
schedule=job.schedule,
|
||||
managed=True,
|
||||
)
|
||||
|
||||
|
||||
def _python_tool_needs_install(program: str) -> bool:
|
||||
"""Check if a Python tool's editable install is broken."""
|
||||
if not shutil.which(program):
|
||||
@@ -528,7 +505,7 @@ def _build_run_cmd(
|
||||
source_dir: Path | None = None,
|
||||
secret_env_file: Path | None = None,
|
||||
) -> list[str]:
|
||||
"""Build a run command list from a RunSpec.
|
||||
"""Build a run command list from a LaunchSpec (a systemd deployment's `run`).
|
||||
|
||||
``env`` holds plain (non-secret) vars only; ``secret_env_file`` is the
|
||||
mode-0600 file holding the deployment's secrets. For container runners the
|
||||
@@ -536,7 +513,7 @@ def _build_run_cmd(
|
||||
systemd-launched runners get them via ``EnvironmentFile=`` on the unit, so
|
||||
``secret_env_file`` is unused here for those.
|
||||
"""
|
||||
match run.runner: # type: ignore[union-attr]
|
||||
match run.launcher: # type: ignore[union-attr]
|
||||
case "python":
|
||||
# Run the program in place from its own project venv via `uv run`,
|
||||
# which syncs the env to the lockfile before launching. One venv per
|
||||
@@ -609,16 +586,8 @@ def _build_run_cmd(
|
||||
if run.args: # type: ignore[union-attr]
|
||||
cmd.extend(run.args) # type: ignore[union-attr]
|
||||
return cmd
|
||||
case "remote":
|
||||
return []
|
||||
case "static":
|
||||
# No process — the gateway file_servers this service's root.
|
||||
return []
|
||||
case "path":
|
||||
# No process — installed on PATH via `uv tool install` at enable time.
|
||||
return []
|
||||
case _:
|
||||
raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr]
|
||||
raise ValueError(f"Unsupported launcher: {run.launcher}") # type: ignore[union-attr]
|
||||
|
||||
|
||||
def _compose_base(name: str, run: object, source_dir: Path | None) -> list[str]:
|
||||
@@ -643,7 +612,7 @@ def _build_stop_cmd(name: str, run: object, source_dir: Path | None) -> list[str
|
||||
Compose stacks need an explicit ``down`` so networks/anonymous volumes are
|
||||
reclaimed rather than left dangling when the unit stops.
|
||||
"""
|
||||
if run.runner == "compose": # type: ignore[union-attr]
|
||||
if run.launcher == "compose": # type: ignore[union-attr]
|
||||
return [*_compose_base(name, run, source_dir), "down"]
|
||||
return []
|
||||
|
||||
@@ -712,14 +681,10 @@ def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> Non
|
||||
continue
|
||||
|
||||
systemd_spec = None
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
if svc.manage and svc.manage.systemd:
|
||||
systemd_spec = svc.manage.systemd
|
||||
elif name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
if job.manage and job.manage.systemd:
|
||||
systemd_spec = job.manage.systemd
|
||||
dep = config.deployments.get(name)
|
||||
manage = getattr(dep, "manage", None)
|
||||
if manage and manage.systemd:
|
||||
systemd_spec = manage.systemd
|
||||
|
||||
svc_name = unit_name(name)
|
||||
svc_content = generate_unit_from_deployed(
|
||||
|
||||
@@ -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,42 +50,39 @@ 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)
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -17,27 +17,33 @@ class RestartPolicy(str, Enum):
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Run specs (discriminated union)
|
||||
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
|
||||
# ---------------------
|
||||
#
|
||||
# A *launcher* is the process-launch mechanism for a systemd-managed deployment.
|
||||
# It is orthogonal to the deployment's `manager`: only systemd deployments have a
|
||||
# launcher, and it says only how the process starts — not how it's supervised.
|
||||
# The non-process managers (caddy/path/none) have no launcher; their fields live
|
||||
# on the deployment variant itself.
|
||||
|
||||
|
||||
class RunBase(BaseModel):
|
||||
runner: str
|
||||
class LaunchBase(BaseModel):
|
||||
launcher: str
|
||||
|
||||
|
||||
class RunCommand(RunBase):
|
||||
runner: Literal["command"]
|
||||
class RunCommand(LaunchBase):
|
||||
launcher: Literal["command"]
|
||||
argv: list[str] = Field(min_length=1)
|
||||
|
||||
|
||||
class RunPython(RunBase):
|
||||
runner: Literal["python"]
|
||||
class RunPython(LaunchBase):
|
||||
launcher: Literal["python"]
|
||||
program: str
|
||||
args: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RunContainer(RunBase):
|
||||
runner: Literal["container"]
|
||||
class RunContainer(LaunchBase):
|
||||
launcher: Literal["container"]
|
||||
image: str
|
||||
command: list[str] | None = None
|
||||
args: list[str] = Field(default_factory=list)
|
||||
@@ -47,14 +53,14 @@ class RunContainer(RunBase):
|
||||
workdir: str | None = None
|
||||
|
||||
|
||||
class RunNode(RunBase):
|
||||
runner: Literal["node"]
|
||||
class RunNode(LaunchBase):
|
||||
launcher: Literal["node"]
|
||||
script: str
|
||||
package_manager: Literal["npm", "pnpm", "yarn"] = "pnpm"
|
||||
args: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class RunCompose(RunBase):
|
||||
class RunCompose(LaunchBase):
|
||||
"""A multi-container stack supervised as one unit via ``docker compose``.
|
||||
|
||||
Unlike ``container`` (a single ``docker run``), compose owns the stack's own
|
||||
@@ -65,91 +71,23 @@ class RunCompose(RunBase):
|
||||
``defaults.env``); compose interpolates them from the process environment.
|
||||
"""
|
||||
|
||||
runner: Literal["compose"]
|
||||
launcher: Literal["compose"]
|
||||
file: str = "docker-compose.yml" # resolved relative to the program source
|
||||
project_name: str | None = None # ``-p``; defaults to ``castle-<name>``
|
||||
|
||||
|
||||
class RunRemote(RunBase):
|
||||
runner: Literal["remote"]
|
||||
base_url: str
|
||||
health_url: str | None = None
|
||||
|
||||
|
||||
class RunStatic(RunBase):
|
||||
"""A static site served by the gateway (Caddy ``file_server``), no process.
|
||||
|
||||
Like ``remote``, this is a service with no local process and no systemd unit —
|
||||
the gateway *is* its runtime. ``root`` is the built directory to serve, resolved
|
||||
relative to the referenced program's source (e.g. ``dist`` or ``public``).
|
||||
Building that directory is the program's concern; this only serves it.
|
||||
"""
|
||||
|
||||
runner: Literal["static"]
|
||||
root: str = "dist" # served dir, relative to the program source
|
||||
|
||||
|
||||
class RunPath(RunBase):
|
||||
"""A CLI installed on the user's PATH via ``uv tool install`` — no process.
|
||||
|
||||
Like ``remote``/``static`` it has no systemd unit; its manager is **PATH**.
|
||||
The referenced program is what gets installed; its lifecycle is
|
||||
install/uninstall (which is what start/stop/enable/disable map to).
|
||||
"""
|
||||
|
||||
runner: Literal["path"]
|
||||
|
||||
|
||||
RunSpec = Annotated[
|
||||
LaunchSpec = Annotated[
|
||||
Union[
|
||||
RunCommand,
|
||||
RunPython,
|
||||
RunContainer,
|
||||
RunNode,
|
||||
RunCompose,
|
||||
RunRemote,
|
||||
RunStatic,
|
||||
RunPath,
|
||||
],
|
||||
Field(discriminator="runner"),
|
||||
Field(discriminator="launcher"),
|
||||
]
|
||||
|
||||
|
||||
# A deployment's *manager* — who makes the program available and supervises it —
|
||||
# is determined by its runner. This is the single source of truth; lifecycle,
|
||||
# deploy, and status all dispatch on it rather than special-casing runners.
|
||||
# systemd — a long-running process (or a timer, for jobs)
|
||||
# caddy — a gateway route (file_server for static; reverse_proxy for a port)
|
||||
# path — an installed CLI on PATH (via `uv tool install`)
|
||||
# none — external; we only reference/route it (remote)
|
||||
_RUNNER_MANAGER: dict[str, str] = {
|
||||
"python": "systemd",
|
||||
"command": "systemd",
|
||||
"container": "systemd",
|
||||
"compose": "systemd",
|
||||
"node": "systemd",
|
||||
"static": "caddy",
|
||||
"path": "path",
|
||||
"remote": "none",
|
||||
}
|
||||
|
||||
|
||||
def manager_for(runner: str) -> str:
|
||||
"""The manager (`systemd`|`caddy`|`path`|`none`) that supervises a runner."""
|
||||
return _RUNNER_MANAGER.get(runner, "systemd")
|
||||
|
||||
|
||||
# `behavior` (tool/daemon/frontend) is a *derived* descriptive label, computed
|
||||
# from how a program is deployed — never stored/edited. A static service → a
|
||||
# frontend; a path install → a tool; anything else running a process → a daemon.
|
||||
_RUNNER_BEHAVIOR: dict[str, str] = {"static": "frontend", "path": "tool"}
|
||||
|
||||
|
||||
def behavior_for_runner(runner: str) -> str:
|
||||
"""The display behavior implied by a service's runner."""
|
||||
return _RUNNER_BEHAVIOR.get(runner, "daemon")
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Systemd management
|
||||
# ---------------------
|
||||
@@ -269,7 +207,9 @@ class ProgramSpec(BaseModel):
|
||||
|
||||
id: str = ""
|
||||
description: str | None = None
|
||||
behavior: str | None = None
|
||||
# Derived at load time from how the program is deployed (its deployment's
|
||||
# kind: service|job|tool|static|reference) — never stored. See kind_for.
|
||||
kind: str | None = None
|
||||
|
||||
source: str | None = None
|
||||
stack: str | None = None
|
||||
@@ -301,68 +241,93 @@ class ProgramSpec(BaseModel):
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Service spec — long-running daemon
|
||||
# Deployment 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 <service-name>.<gateway.domain> through the gateway
|
||||
# (the subdomain is the service name). False → reachable only at its host:port.
|
||||
proxy: bool = False
|
||||
# Also expose this service to the public internet via the Cloudflare tunnel, at
|
||||
# <service-name>.<gateway.public_domain>. Default False — public is opt-in and
|
||||
# explicit. Requires proxy (the tunnel projects an already-routed subdomain).
|
||||
public: bool = False
|
||||
manage: ManageSpec | None = None
|
||||
defaults: DefaultsSpec | None = None
|
||||
|
||||
|
||||
class SystemdDeployment(DeploymentBase):
|
||||
"""A process supervised by systemd — a *service*, or a *job* when scheduled."""
|
||||
|
||||
manager: Literal["systemd"]
|
||||
run: LaunchSpec
|
||||
# Present → a `.timer` (job); absent → a continuous `.service` (service).
|
||||
schedule: str | None = None
|
||||
timezone: str = "America/Los_Angeles"
|
||||
expose: ExposeSpec | None = None
|
||||
# Route <name>.<gateway.domain> to this process. False → host:port only.
|
||||
proxy: bool = False
|
||||
# Also publish to the public internet via the Cloudflare tunnel, at
|
||||
# <name>.<gateway.public_domain>. Opt-in; requires `proxy`.
|
||||
public: bool = False
|
||||
manage: ManageSpec | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_consistency(self) -> ServiceSpec:
|
||||
if self.manage and self.manage.systemd and self.manage.systemd.enable:
|
||||
if self.run.runner == "remote":
|
||||
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
|
||||
# A static service is inherently exposed (that's its purpose); other
|
||||
# runners need the proxy checkbox to be routed. Public requires exposure.
|
||||
exposed = self.proxy or self.run.runner == "static"
|
||||
if self.public and not exposed:
|
||||
raise ValueError("public requires the service to be exposed (proxy or static).")
|
||||
def _validate(self) -> SystemdDeployment:
|
||||
if self.public and not self.proxy:
|
||||
raise ValueError("public requires proxy (an exposed process).")
|
||||
return self
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Job spec — scheduled task
|
||||
# ---------------------
|
||||
class CaddyDeployment(DeploymentBase):
|
||||
"""A static site served by the gateway (Caddy ``file_server``) — no process.
|
||||
|
||||
The gateway *is* its runtime; it's inherently exposed at its subdomain. ``root``
|
||||
is the built dir to serve, relative to the program source (e.g. ``dist``/``public``).
|
||||
"""
|
||||
|
||||
manager: Literal["caddy"]
|
||||
root: str = "dist"
|
||||
# Inherently exposed at its subdomain; `public` = also project via the tunnel.
|
||||
public: bool = False
|
||||
|
||||
|
||||
class JobSpec(BaseModel):
|
||||
"""Scheduled task that runs periodically and exits."""
|
||||
class PathDeployment(DeploymentBase):
|
||||
"""A CLI installed on PATH via ``uv tool install`` — no process, no route.
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
Lifecycle is install/uninstall (what start/stop/enable/disable map to).
|
||||
"""
|
||||
|
||||
id: str = ""
|
||||
# The program this job runs. (`component` accepted as a legacy alias.)
|
||||
program: str | None = Field(
|
||||
default=None, validation_alias=AliasChoices("program", "component")
|
||||
)
|
||||
description: str | None = None
|
||||
manager: Literal["path"]
|
||||
|
||||
run: RunSpec
|
||||
schedule: str
|
||||
timezone: str = "America/Los_Angeles"
|
||||
|
||||
manage: ManageSpec | None = None
|
||||
defaults: DefaultsSpec | None = None
|
||||
class RemoteDeployment(DeploymentBase):
|
||||
"""An external service (another node) — we only reference/route it, never run it."""
|
||||
|
||||
manager: Literal["none"]
|
||||
base_url: str
|
||||
health_url: str | None = None
|
||||
|
||||
|
||||
DeploymentSpec = Annotated[
|
||||
Union[SystemdDeployment, CaddyDeployment, PathDeployment, RemoteDeployment],
|
||||
Field(discriminator="manager"),
|
||||
]
|
||||
|
||||
|
||||
def kind_for(spec: DeploymentSpec) -> str:
|
||||
"""The derived kind of a deployment: service|job|tool|static|reference."""
|
||||
if spec.manager == "systemd":
|
||||
return "job" if getattr(spec, "schedule", None) else "service"
|
||||
return {"caddy": "static", "path": "tool", "none": "reference"}[spec.manager]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -38,7 +38,6 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"programs": {
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"behavior": "tool",
|
||||
},
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -10,13 +10,14 @@ from castle_core.generators.caddyfile import (
|
||||
generate_caddyfile_from_registry,
|
||||
)
|
||||
from castle_core.manifest import (
|
||||
CaddyDeployment,
|
||||
DeploymentSpec,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
ProgramSpec,
|
||||
RunPython,
|
||||
RunStatic,
|
||||
ServiceSpec,
|
||||
SystemdDeployment,
|
||||
)
|
||||
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||
|
||||
@@ -52,10 +53,14 @@ def _make_registry(
|
||||
)
|
||||
|
||||
|
||||
def _dep(port: int, *, expose: bool, name: str | None = None, runner: str = "python") -> Deployment:
|
||||
def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "python") -> Deployment:
|
||||
"""A deployed service; exposed at <name>.<domain> when expose=True."""
|
||||
return Deployment(
|
||||
runner=runner, run_cmd=["x"], port=port, subdomain=(name if expose else None)
|
||||
manager="systemd",
|
||||
launcher=launcher,
|
||||
run_cmd=["x"],
|
||||
port=port,
|
||||
subdomain=(name if expose else None),
|
||||
)
|
||||
|
||||
|
||||
@@ -109,9 +114,9 @@ class TestAcmeMode:
|
||||
import castle_core.config as config_mod
|
||||
|
||||
cfg = _config(
|
||||
services={
|
||||
"castle": ServiceSpec(
|
||||
program="castle", run=RunStatic(runner="static", root="dist")
|
||||
deployments={
|
||||
"castle": CaddyDeployment(
|
||||
manager="caddy", program="castle", root="dist"
|
||||
)
|
||||
},
|
||||
programs={"castle": ProgramSpec(source="/data/repos/castle/app")},
|
||||
@@ -144,24 +149,24 @@ class TestOffMode:
|
||||
assert "litellm" not in cf # no subdomains without a domain
|
||||
|
||||
|
||||
def _service(port: int, *, expose: bool) -> ServiceSpec:
|
||||
return ServiceSpec(
|
||||
run=RunPython(runner="python", program="svc"),
|
||||
def _service(port: int, *, expose: bool) -> SystemdDeployment:
|
||||
return SystemdDeployment(
|
||||
manager="systemd",
|
||||
run=RunPython(launcher="python", program="svc"),
|
||||
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))),
|
||||
proxy=expose,
|
||||
)
|
||||
|
||||
|
||||
def _config(
|
||||
services: dict[str, ServiceSpec], programs: dict[str, ProgramSpec] | None = None
|
||||
deployments: dict[str, DeploymentSpec], programs: dict[str, ProgramSpec] | None = None
|
||||
) -> CastleConfig:
|
||||
return CastleConfig(
|
||||
root=None, # type: ignore[arg-type]
|
||||
gateway=GatewayConfig(port=9000),
|
||||
repo=None,
|
||||
programs=programs or {},
|
||||
services=services,
|
||||
jobs={},
|
||||
deployments=deployments,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from castle_core.config import (
|
||||
resolve_env_vars,
|
||||
save_config,
|
||||
)
|
||||
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||
from castle_core.manifest import ProgramSpec, SystemdDeployment
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
@@ -32,8 +32,10 @@ class TestLoadConfig:
|
||||
"""Each section produces the correct spec type."""
|
||||
config = load_config(castle_root)
|
||||
assert isinstance(config.programs["test-tool"], ProgramSpec)
|
||||
assert isinstance(config.services["test-svc"], ServiceSpec)
|
||||
assert isinstance(config.jobs["test-job"], JobSpec)
|
||||
# Both a service and a job are systemd deployments; the kind (service/job)
|
||||
# is derived from whether a schedule is present.
|
||||
assert isinstance(config.services["test-svc"], SystemdDeployment)
|
||||
assert isinstance(config.jobs["test-job"], SystemdDeployment)
|
||||
|
||||
def test_service_expose(self, castle_root: Path) -> None:
|
||||
"""Service has correct expose spec."""
|
||||
@@ -49,10 +51,10 @@ class TestLoadConfig:
|
||||
assert svc.proxy is True # exposed at <name>.<gateway.domain>
|
||||
|
||||
def test_service_run_spec(self, castle_root: Path) -> None:
|
||||
"""Service has correct RunSpec."""
|
||||
"""Service has correct launch spec (legacy runner normalized to launcher)."""
|
||||
config = load_config(castle_root)
|
||||
svc = config.services["test-svc"]
|
||||
assert svc.run.runner == "python"
|
||||
assert svc.run.launcher == "python"
|
||||
assert svc.run.program == "test-svc"
|
||||
|
||||
def test_service_component_ref(self, castle_root: Path) -> None:
|
||||
@@ -114,25 +116,26 @@ class TestSaveConfig:
|
||||
assert svc.manage.systemd is not None
|
||||
|
||||
def test_writes_directory_layout(self, castle_root: Path) -> None:
|
||||
"""Save writes one file per resource under programs/services/jobs dirs."""
|
||||
"""Save writes one file per resource under programs/ and one deployments/ dir."""
|
||||
config = load_config(castle_root)
|
||||
save_config(config)
|
||||
assert (castle_root / "programs" / "test-tool.yaml").exists()
|
||||
assert (castle_root / "services" / "test-svc.yaml").exists()
|
||||
assert (castle_root / "jobs" / "test-job.yaml").exists()
|
||||
# service, job, and tool all live under the single deployments/ dir now.
|
||||
assert (castle_root / "deployments" / "test-svc.yaml").exists()
|
||||
assert (castle_root / "deployments" / "test-job.yaml").exists()
|
||||
assert (castle_root / "deployments" / "test-tool.yaml").exists()
|
||||
# Global file holds gateway only, no resource sections
|
||||
global_data = yaml.safe_load((castle_root / "castle.yaml").read_text())
|
||||
assert global_data["gateway"]["port"] == 18000
|
||||
assert "programs" not in global_data
|
||||
assert "services" not in global_data
|
||||
assert "jobs" not in global_data
|
||||
assert "deployments" not in global_data
|
||||
|
||||
def test_delete_prunes_file(self, castle_root: Path) -> None:
|
||||
"""Removing a resource and saving deletes its on-disk file."""
|
||||
"""Removing a deployment and saving deletes its on-disk file."""
|
||||
config = load_config(castle_root)
|
||||
del config.services["test-svc"]
|
||||
del config.deployments["test-svc"]
|
||||
save_config(config)
|
||||
assert not (castle_root / "services" / "test-svc.yaml").exists()
|
||||
assert not (castle_root / "deployments" / "test-svc.yaml").exists()
|
||||
config2 = load_config(castle_root)
|
||||
assert "test-svc" not in config2.services
|
||||
assert "test-tool" in config2.programs
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,7 +11,7 @@ from castle_core.manifest import RunCompose, RunContainer, RunNode, RunPython
|
||||
|
||||
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
|
||||
"""A python service with a real source dir launches via `uv run --project`."""
|
||||
run = RunPython(runner="python", program="my-svc")
|
||||
run = RunPython(launcher="python", program="my-svc")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
|
||||
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd == [
|
||||
@@ -25,7 +25,7 @@ def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_python_runner_appends_args(tmp_path: Path) -> None:
|
||||
run = RunPython(runner="python", program="my-svc", args=["--flag", "x"])
|
||||
run = RunPython(launcher="python", program="my-svc", args=["--flag", "x"])
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
|
||||
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd[-2:] == ["--flag", "x"]
|
||||
@@ -34,7 +34,7 @@ def test_python_runner_appends_args(tmp_path: Path) -> None:
|
||||
|
||||
def test_python_runner_falls_back_to_path_without_source() -> None:
|
||||
"""No resolvable source → PATH lookup of the script (no uv run)."""
|
||||
run = RunPython(runner="python", program="my-svc")
|
||||
run = RunPython(launcher="python", program="my-svc")
|
||||
with patch(
|
||||
"castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"
|
||||
):
|
||||
@@ -44,7 +44,7 @@ def test_python_runner_falls_back_to_path_without_source() -> None:
|
||||
|
||||
def test_python_runner_warns_when_unresolvable() -> None:
|
||||
"""No source and not on PATH → a warning, bare program name as last resort."""
|
||||
run = RunPython(runner="python", program="my-svc")
|
||||
run = RunPython(launcher="python", program="my-svc")
|
||||
messages: list[str] = []
|
||||
with patch("castle_core.deploy.shutil.which", return_value=None):
|
||||
cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None)
|
||||
@@ -54,7 +54,7 @@ def test_python_runner_warns_when_unresolvable() -> None:
|
||||
|
||||
def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
|
||||
"""A node service runs the script in its source dir via `--dir` (no unit cwd)."""
|
||||
run = RunNode(runner="node", script="gateway:watch:raw", package_manager="pnpm")
|
||||
run = RunNode(launcher="node", script="gateway:watch:raw", package_manager="pnpm")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
|
||||
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd == ["/usr/bin/pnpm", "--dir", str(tmp_path), "run", "gateway:watch:raw"]
|
||||
@@ -62,7 +62,7 @@ def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
|
||||
|
||||
def test_node_runner_appends_args(tmp_path: Path) -> None:
|
||||
run = RunNode(
|
||||
runner="node", script="start", package_manager="pnpm", args=["--port", "18789"]
|
||||
launcher="node", script="start", package_manager="pnpm", args=["--port", "18789"]
|
||||
)
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
|
||||
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
|
||||
@@ -79,7 +79,7 @@ def test_node_runner_appends_args(tmp_path: Path) -> None:
|
||||
|
||||
def test_node_runner_without_source_omits_dir() -> None:
|
||||
"""No resolvable source → bare `pnpm run` (package manager still PATH-resolved)."""
|
||||
run = RunNode(runner="node", script="start", package_manager="pnpm")
|
||||
run = RunNode(launcher="node", script="start", package_manager="pnpm")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
|
||||
cmd = _build_run_cmd("oc", run, {}, [], source_dir=None)
|
||||
assert cmd == ["/usr/bin/pnpm", "run", "start"]
|
||||
@@ -87,7 +87,7 @@ def test_node_runner_without_source_omits_dir() -> None:
|
||||
|
||||
def test_container_secrets_use_env_file_not_argv() -> None:
|
||||
"""Secrets go through --env-file; plain vars stay as -e; no secret in argv."""
|
||||
run = RunContainer(runner="container", image="img:latest", env={"PLAIN": "1"})
|
||||
run = RunContainer(launcher="container", image="img:latest", env={"PLAIN": "1"})
|
||||
env_file = Path("/home/u/.castle/secrets/env/castle-svc.service.env")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("svc", run, {"PORT": "9001"}, [], secret_env_file=env_file)
|
||||
@@ -101,7 +101,7 @@ def test_container_secrets_use_env_file_not_argv() -> None:
|
||||
|
||||
|
||||
def test_container_without_secrets_has_no_env_file() -> None:
|
||||
run = RunContainer(runner="container", image="img:latest")
|
||||
run = RunContainer(launcher="container", image="img:latest")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None)
|
||||
assert "--env-file" not in cmd
|
||||
@@ -109,7 +109,7 @@ def test_container_without_secrets_has_no_env_file() -> None:
|
||||
|
||||
def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
|
||||
"""A compose service runs `docker compose -p <project> -f <abs-file> up`."""
|
||||
run = RunCompose(runner="compose", file="docker-compose.yml")
|
||||
run = RunCompose(launcher="compose", file="docker-compose.yml")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("supabase", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd == [
|
||||
@@ -125,7 +125,7 @@ def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
|
||||
|
||||
def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
|
||||
"""Compose reads env from the process (systemd), not --env-file/-e — argv is clean."""
|
||||
run = RunCompose(runner="compose", file="docker-compose.yml")
|
||||
run = RunCompose(launcher="compose", file="docker-compose.yml")
|
||||
env_file = Path("/home/u/.castle/secrets/env/castle-supabase.service.env")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd(
|
||||
@@ -139,7 +139,7 @@ def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_compose_project_name_override(tmp_path: Path) -> None:
|
||||
run = RunCompose(runner="compose", file="stack.yml", project_name="myproj")
|
||||
run = RunCompose(launcher="compose", file="stack.yml", project_name="myproj")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("s", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd[:6] == [
|
||||
@@ -149,7 +149,7 @@ def test_compose_project_name_override(tmp_path: Path) -> None:
|
||||
|
||||
def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
|
||||
"""The teardown command mirrors up but ends in `down` (same project + file)."""
|
||||
run = RunCompose(runner="compose", file="docker-compose.yml")
|
||||
run = RunCompose(launcher="compose", file="docker-compose.yml")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
stop = _build_stop_cmd("supabase", run, tmp_path)
|
||||
assert stop == [
|
||||
@@ -164,5 +164,5 @@ def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_non_compose_runner_has_no_stop_cmd(tmp_path: Path) -> None:
|
||||
run = RunPython(runner="python", program="svc")
|
||||
run = RunPython(launcher="python", program="svc")
|
||||
assert _build_stop_cmd("svc", run, tmp_path) == []
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,22 +155,21 @@ 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:
|
||||
# programs/central-context.yaml — what software exists
|
||||
description: Content storage API
|
||||
source: /data/repos/central-context
|
||||
|
||||
services:
|
||||
central-context:
|
||||
```
|
||||
```yaml
|
||||
# deployments/central-context.yaml — manager: systemd → kind: service
|
||||
program: central-context
|
||||
manager: systemd
|
||||
run:
|
||||
runner: python
|
||||
tool: central-context
|
||||
launcher: python
|
||||
program: central-context
|
||||
expose:
|
||||
http:
|
||||
internal: { port: 9001 }
|
||||
@@ -173,25 +177,28 @@ services:
|
||||
proxy: true # expose at central-context.<gateway.domain>
|
||||
manage:
|
||||
systemd: {}
|
||||
|
||||
jobs:
|
||||
backup-collect:
|
||||
```
|
||||
```yaml
|
||||
# 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:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
Programs define *what software exists* (identity, source, install, tools).
|
||||
Services define *how daemons run* (run config, expose, proxy, systemd).
|
||||
Jobs define *how scheduled tasks run* (run config, cron schedule, systemd).
|
||||
Programs define *what software exists* (identity, source, build).
|
||||
Deployments define *how a program is realized on this node* — a single
|
||||
`manager`-discriminated entry whose derived kind (service/job/tool/static/
|
||||
reference) captures whether it's an always-on daemon, a scheduled task, a CLI on
|
||||
PATH, a served frontend, or an external reference.
|
||||
|
||||
Services and jobs can reference a program via `program:` for description
|
||||
fallthrough and source code linking. They can also exist independently
|
||||
(e.g., `castle-gateway` runs Caddy — not our software).
|
||||
A deployment can reference a program via `program:` for description fallthrough
|
||||
and source code linking. It can also exist independently (e.g., `castle-gateway`
|
||||
runs Caddy — not our software).
|
||||
|
||||
A service's env is exactly its `defaults.env` — castle injects nothing
|
||||
implicitly. Values may use `${port}`/`${data_dir}`/`${name}`/`${secret:…}`
|
||||
@@ -206,16 +213,17 @@ node:
|
||||
gateway_port: 9000
|
||||
deployed:
|
||||
central-context:
|
||||
runner: python
|
||||
manager: systemd
|
||||
launcher: python
|
||||
run_cmd: [/home/user/.local/bin/central-context]
|
||||
env:
|
||||
CENTRAL_CONTEXT_DATA_DIR: /home/user/.castle/data/central-context
|
||||
CENTRAL_CONTEXT_PORT: "9001"
|
||||
behavior: daemon
|
||||
kind: service
|
||||
stack: python-fastapi
|
||||
port: 9001
|
||||
health_path: /health
|
||||
proxy_path: /central-context
|
||||
subdomain: central-context
|
||||
managed: true
|
||||
```
|
||||
|
||||
@@ -339,11 +347,11 @@ Daemons · Long-running processes that expose ports
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
|
||||
Components · Software catalog
|
||||
Name Stack Behavior Schedule Status
|
||||
Name Stack Kind Schedule Status
|
||||
pdf2md Python / CLI tool — installed
|
||||
protonmail Python / CLI tool */5 * * * * installed
|
||||
castle React / Vite frontend — —
|
||||
backup-collect Python / CLI tool 0 2 * * * —
|
||||
castle React / Vite static — —
|
||||
backup-collect Python / CLI job 0 2 * * * —
|
||||
```
|
||||
|
||||
**Key programs:**
|
||||
@@ -355,8 +363,8 @@ Components · Software catalog
|
||||
- **NodeBar** — Horizontal list of discovered nodes. Hidden in single-node
|
||||
mode. Each node links to `/node/{hostname}`.
|
||||
- **ServiceSection** — Daemon cards in a responsive grid.
|
||||
- **ComponentTable** — Unified sortable table for all non-daemon programs
|
||||
(tools, frontends) with Stack, Behavior, Schedule, and Status columns.
|
||||
- **ComponentTable** — Unified sortable table for all non-daemon deployments
|
||||
(tools, statics) with Stack, Kind, Schedule, and Status columns.
|
||||
|
||||
**Real-time updates:**
|
||||
- SSE stream at `/stream` pushes `health`, `service-action`, and `mesh`
|
||||
@@ -444,7 +452,7 @@ data; default `/data/castle`, kept on a dedicated volume):
|
||||
|
||||
```
|
||||
$CASTLE_HOME/ ← Config & artifacts (default ~/.castle)
|
||||
├── castle.yaml ← Registry spec (programs, services, jobs)
|
||||
├── castle.yaml ← Global settings; resources under programs/ + deployments/
|
||||
├── infra.conf ← Infrastructure install choices
|
||||
├── code/ ← Program source (your programs)
|
||||
│ └── <name>/
|
||||
@@ -558,7 +566,7 @@ What exists today:
|
||||
What doesn't exist yet:
|
||||
|
||||
- **Multi-language support** — Rust and Go programs (the abstractions
|
||||
support them via `command` runner, but no examples exist yet)
|
||||
support them via the `command` launcher, but no examples exist yet)
|
||||
- **Build automation** — Castle records build specs but doesn't
|
||||
orchestrate builds (each project builds independently)
|
||||
- **Multi-machine testing** — Mesh infrastructure is built and running
|
||||
|
||||
@@ -141,7 +141,7 @@ a DNS token.
|
||||
- **`acme` only — a provider API token.** Store it as a secret
|
||||
(`~/.castle/secrets/<TOKEN_NAME>`, scope: the DNS provider's "edit DNS records"
|
||||
permission for your zone) and map it into the gateway service env in
|
||||
`services/castle-gateway.yaml` (`defaults.env`), so Caddy reads it as
|
||||
`deployments/castle-gateway.yaml` (`defaults.env`), so Caddy reads it as
|
||||
`{env.<TOKEN_NAME>}`. `castle deploy` warns if the domain, env var, or secret is
|
||||
missing.
|
||||
- **`acme` — stage first.** Set `CASTLE_ACME_STAGING=1` at deploy to use Let's
|
||||
|
||||
292
docs/registry.md
292
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/<name>.yaml` file carries the whole thing.
|
||||
|
||||
## Configuration Directory Layout
|
||||
|
||||
@@ -40,10 +44,11 @@ Castle splits its configuration across a root directory (`~/.castle/` or your co
|
||||
├── castle.yaml # Global settings (gateway, repo, etc.)
|
||||
├── programs/ # Program configuration files (one file per program)
|
||||
│ └── my-tool.yaml
|
||||
├── services/ # Service configuration files (one file per service)
|
||||
│ └── my-service.yaml
|
||||
└── jobs/ # Job configuration files (one file per job)
|
||||
└── my-job.yaml
|
||||
└── deployments/ # Deployment configuration files (one file per deployment)
|
||||
├── my-service.yaml # manager: systemd → kind: service
|
||||
├── nightly.yaml # manager: systemd + schedule → kind: job
|
||||
├── my-tool.yaml # manager: path → kind: tool
|
||||
└── my-app.yaml # manager: caddy → kind: static
|
||||
```
|
||||
|
||||
### castle.yaml (Globals)
|
||||
@@ -56,25 +61,23 @@ gateway:
|
||||
repo: /data/repos/castle
|
||||
```
|
||||
|
||||
### Resource Configuration Files (`programs/`, `services/`, `jobs/`)
|
||||
### Resource Configuration Files (`programs/`, `deployments/`)
|
||||
|
||||
Each resource (program, service, or job) is configured in its own YAML file named after the resource's unique ID (e.g., `services/my-service.yaml` defines the service `my-service`).
|
||||
Each resource (a program or a deployment) is configured in its own YAML file named after the resource's unique ID (e.g., `deployments/my-service.yaml` defines the deployment `my-service`).
|
||||
|
||||
**programs/my-tool.yaml:**
|
||||
```yaml
|
||||
description: Does something useful
|
||||
source: /data/repos/my-tool
|
||||
stack: python-cli
|
||||
behavior: tool
|
||||
system_dependencies: [pandoc]
|
||||
```
|
||||
|
||||
**services/my-service.yaml:**
|
||||
**deployments/my-service.yaml** (a service — `manager: systemd`, no schedule):
|
||||
```yaml
|
||||
program: my-service
|
||||
run:
|
||||
runner: python
|
||||
program: my-service
|
||||
manager: systemd
|
||||
run: { launcher: python, program: my-service }
|
||||
expose:
|
||||
http:
|
||||
internal: { port: 9001 }
|
||||
@@ -84,43 +87,47 @@ manage:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
**jobs/my-job.yaml:**
|
||||
**deployments/nightly.yaml** (a job — `manager: systemd` + `schedule`):
|
||||
```yaml
|
||||
program: my-tool
|
||||
run:
|
||||
runner: command
|
||||
argv: [my-tool, sync]
|
||||
manager: systemd
|
||||
run: { launcher: command, argv: [my-tool, sync] }
|
||||
schedule: "0 2 * * *"
|
||||
manage:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
**deployments/my-tool.yaml** (a tool — `manager: path`, no `run:`):
|
||||
```yaml
|
||||
program: my-tool
|
||||
manager: path
|
||||
```
|
||||
|
||||
**deployments/my-app.yaml** (a static frontend — `manager: caddy`, no `run:`):
|
||||
```yaml
|
||||
program: my-app
|
||||
manager: caddy
|
||||
root: dist
|
||||
```
|
||||
|
||||
### Resource Categories
|
||||
|
||||
| Category | Location | Purpose | Role / Types |
|
||||
|----------|----------|---------|--------------|
|
||||
| **programs** | `programs/*.yaml` | Software catalog — what software exists | tool, frontend, daemon |
|
||||
| **services** | `services/*.yaml` | Long-running daemons — how they run | service |
|
||||
| **jobs** | `jobs/*.yaml` | Scheduled tasks — when they run | job |
|
||||
| Category | Location | Purpose | Kinds (derived) |
|
||||
|----------|----------|---------|-----------------|
|
||||
| **programs** | `programs/*.yaml` | Software catalog — what software exists | — |
|
||||
| **deployments** | `deployments/*.yaml` | How a program is realized on this node | service, job, tool, static, reference |
|
||||
|
||||
Services and jobs can reference a program via `program:` for description
|
||||
fallthrough and source code linking. They can also exist independently
|
||||
(e.g., `castle-gateway` runs Caddy — not our software).
|
||||
A deployment can reference a program via `program:` for description fallthrough
|
||||
and source code linking. It can also exist independently (e.g., `castle-gateway`
|
||||
runs Caddy — not our software). The **kind** is derived from `manager` (+
|
||||
`schedule`), never stored.
|
||||
|
||||
## Program blocks
|
||||
|
||||
Programs define **what software exists** — identity, source, behavior, builds.
|
||||
|
||||
### `behavior` — What role this program plays
|
||||
|
||||
```yaml
|
||||
behavior: daemon # or: tool, frontend
|
||||
```
|
||||
|
||||
Explicit declaration of how the program is used:
|
||||
- **daemon** — long-running service (python-fastapi stack)
|
||||
- **tool** — CLI utility (python-cli stack)
|
||||
- **frontend** — web UI (react-vite stack)
|
||||
Programs define **what software exists** — identity, source, builds. How a
|
||||
program is *used* is not a program property: it's decided by its deployment's
|
||||
`manager` and surfaces as the derived **kind** (service/job/tool/static/reference).
|
||||
A program with no deployment is just source castle knows how to develop.
|
||||
|
||||
### `source` — Where the source lives
|
||||
|
||||
@@ -188,7 +195,7 @@ system_dependencies: [pandoc, poppler-utils]
|
||||
```
|
||||
|
||||
System packages that must be installed for the program to work. Displayed
|
||||
in `castle program list --behavior tool` and the dashboard.
|
||||
in `castle list --kind tool` and the dashboard.
|
||||
|
||||
### `version` — Program version
|
||||
|
||||
@@ -208,63 +215,68 @@ build:
|
||||
- dist/
|
||||
```
|
||||
|
||||
Programs with build outputs are typically frontends.
|
||||
Programs with build outputs are typically served as **static** deployments.
|
||||
|
||||
## Service blocks
|
||||
## Deployment blocks
|
||||
|
||||
Services define **how long-running daemons are deployed**.
|
||||
Deployments define **how a program is realized on this node**. Every deployment
|
||||
declares a **`manager`** — who makes it available and supervises its lifecycle:
|
||||
|
||||
### `run` — How to start it (required)
|
||||
### `manager` — Who realizes it (the discriminant)
|
||||
|
||||
A deployment (service/job) is a *managed materialization* of a program. Its
|
||||
**runner** determines the **manager** — who makes it available and supervises its
|
||||
lifecycle:
|
||||
A deployment is a *managed materialization* of a program. Its **`manager`** is
|
||||
the stored discriminant — the single axis lifecycle, deploy, and status all
|
||||
dispatch on:
|
||||
|
||||
| Manager | Makes available as | Runners | start/stop |
|
||||
|---------|--------------------|---------|------------|
|
||||
| **systemd** | a running process (or a `.timer` for jobs) | `python`, `command`, `container`, `compose`, `node` | `systemctl` |
|
||||
| **caddy** | a gateway route | `static` (file_server) — and any exposed process (reverse_proxy) | add/remove route + reload |
|
||||
| **path** | an installed CLI on `PATH` | `path` | `uv tool install` / `uninstall` |
|
||||
| **none** | an external reference | `remote` | *(nothing — not ours)* |
|
||||
| Manager | Makes available as | Launch mechanism | start/stop | Kind |
|
||||
|---------|--------------------|------------------|------------|------|
|
||||
| **systemd** | a running process (or a `.timer` for jobs) | nested `run: { launcher: … }` | `systemctl` | service / job |
|
||||
| **caddy** | a gateway static file_server route | *(none — files on disk; `root:`)* | add/remove route + reload | static |
|
||||
| **path** | an installed CLI on `PATH` | *(none — `uv tool install`)* | `uv tool install` / `uninstall` | tool |
|
||||
| **none** | an external reference | *(none; `base_url:`/`health_url:`)* | *(nothing — not ours)* | reference |
|
||||
|
||||
`manager_for(runner)` (in `manifest.py`) is the single source of truth; lifecycle,
|
||||
deploy, and status all dispatch on it. `behavior` (`tool`/`daemon`/`frontend`) is a
|
||||
**derived display label only** — it never drives logic. A program is a *tool* when
|
||||
it has a `path` service, a *frontend* when it has a `static` service, a *daemon*
|
||||
when it has a process service.
|
||||
The **kind** (service/job/tool/static/reference) is **derived** from `manager` (+
|
||||
`schedule`) — it never drives logic and is never stored. `DeploymentSpec` is a
|
||||
discriminated union on `manager` (SystemdDeployment/CaddyDeployment/
|
||||
PathDeployment/RemoteDeployment); see [Manifest models](#manifest-models).
|
||||
|
||||
Discriminated union on `runner`:
|
||||
### `run` — How to launch it (systemd only)
|
||||
|
||||
| Runner | Manager | Deploy | Key fields |
|
||||
|--------|---------|--------|------------|
|
||||
| `python` | systemd | `uv run --project <source> --no-dev <program>` | `program`, `args` |
|
||||
| `command` | systemd | `which(argv[0])` → resolved path | `argv` |
|
||||
| `container` | systemd | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
|
||||
| `compose` | systemd | `docker compose -p <project> -f <file> up` (+ `ExecStop=down`) | `file`, `project_name` |
|
||||
| `node` | systemd | `package_manager run script` | `script`, `package_manager` |
|
||||
| `static` | caddy | *(no process — `file_server` from `<source>/<root>`)* | `root` |
|
||||
| `path` | path | *(no process — `uv tool install` at enable time)* | *(the referenced program)* |
|
||||
| `remote` | none | *(none — no local process)* | `base_url`, `health_url` |
|
||||
For `manager: systemd` **only**, the nested `run:` block carries a **`launcher`**
|
||||
— the process-launch mechanism. Non-systemd managers have no `run:`/launcher;
|
||||
their fields live directly on the deployment (caddy has `root:`, none has
|
||||
`base_url:`/`health_url:`).
|
||||
|
||||
A `python` service runs **in place from its own project venv** via `uv run`, which
|
||||
Nested launch spec, discriminated union on `launcher`:
|
||||
|
||||
| Launcher | Deploy | Key fields |
|
||||
|----------|--------|------------|
|
||||
| `python` | `uv run --project <source> --no-dev <program>` | `program`, `args` |
|
||||
| `command` | `which(argv[0])` → resolved path | `argv` |
|
||||
| `container` | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
|
||||
| `compose` | `docker compose -p <project> -f <file> up` (+ `ExecStop=down`) | `file`, `project_name` |
|
||||
| `node` | `package_manager run script` | `script`, `package_manager` |
|
||||
|
||||
A `python` launcher runs **in place from its own project venv** via `uv run`, which
|
||||
syncs the env to the project's lockfile before launching. There is no separate
|
||||
tool venv and no `uv tool install` step: **a restart picks up both code and
|
||||
dependency changes** (the deploy-time `ExecStart` is deterministic from `source`,
|
||||
so it never goes stale). `uv tool install` is reserved for `tool`-behavior
|
||||
programs, where being on a human's PATH is the point. If a `python` service
|
||||
declares a `program` with no resolvable `source`, deploy falls back to a PATH
|
||||
lookup of the script.
|
||||
so it never goes stale). `uv tool install` is reserved for `manager: path`
|
||||
deployments (tools), where being on a human's PATH is the point. If a `python`
|
||||
launcher declares a `program` with no resolvable `source`, deploy falls back to a
|
||||
PATH lookup of the script.
|
||||
|
||||
```yaml
|
||||
manager: systemd
|
||||
run:
|
||||
runner: python
|
||||
launcher: python
|
||||
program: my-service # name in [project.scripts]
|
||||
```
|
||||
|
||||
A `compose` service supervises a **whole multi-container stack as one systemd
|
||||
A `compose` launcher supervises a **whole multi-container stack as one systemd
|
||||
unit** — `ExecStart` runs `docker compose … up` attached (`Type=simple`) and a
|
||||
generated `ExecStop` runs `… down` so networks/anonymous volumes are reclaimed on
|
||||
stop. Unlike the single-container `container` runner, compose owns the stack's own
|
||||
stop. Unlike the single-container `container` launcher, compose owns the stack's own
|
||||
networking, startup ordering, and per-service health — Castle delegates rather
|
||||
than reinventing orchestration. Secrets/env reach compose through the unit's
|
||||
`Environment=`/`EnvironmentFile=` (from `defaults.env`), which compose interpolates
|
||||
@@ -272,12 +284,30 @@ from the process environment. This is what runs the shared **Supabase substrate*
|
||||
(see @docs/stacks/supabase.md).
|
||||
|
||||
```yaml
|
||||
manager: systemd
|
||||
run:
|
||||
runner: compose
|
||||
launcher: compose
|
||||
file: docker-compose.yml # resolved under the program source
|
||||
# project_name: castle-my-stack # optional; defaults to castle-<name>
|
||||
```
|
||||
|
||||
### `root` — Static frontend (caddy only)
|
||||
|
||||
For `manager: caddy`, `root:` names the built-frontend directory (relative to the
|
||||
program source) that the gateway serves via `file_server`. There is no process
|
||||
and no `run:` block.
|
||||
|
||||
```yaml
|
||||
manager: caddy
|
||||
root: dist # served at <name>.<gateway.domain>
|
||||
```
|
||||
|
||||
### `base_url` / `health_url` — Remote reference (none only)
|
||||
|
||||
For `manager: none`, the deployment is an external reference — a service on
|
||||
another node — with no local process. It carries `base_url:` and `health_url:`
|
||||
directly.
|
||||
|
||||
### `expose` — What it exposes
|
||||
|
||||
```yaml
|
||||
@@ -324,8 +354,8 @@ routes is gone). Caddy proxies WebSocket upgrades transparently.
|
||||
|
||||
| Kind | Target | Declared by |
|
||||
|------|--------|-------------|
|
||||
| **proxy** | a local service on a port — Caddy `reverse_proxy localhost:PORT` | a service's `proxy.caddy` |
|
||||
| **static** | a built frontend's `dist/` — Caddy `file_server` (no process) | a `frontend` program with `build.outputs` and **no** service (auto-exposed at `<name>.<domain>`) |
|
||||
| **proxy** | a local service on a port — Caddy `reverse_proxy localhost:PORT` | a service's `proxy: true` |
|
||||
| **static** | a built frontend's `dist/` — Caddy `file_server` (no process) | a `manager: caddy` deployment (kind **static**) with a `root:` (served at `<name>.<domain>`) |
|
||||
| **remote** | a service on another node | mesh discovery (out of scope of the single-node gateway) |
|
||||
|
||||
"Serving a frontend" and "proxying a service" are the same thing — a subdomain
|
||||
@@ -431,7 +461,7 @@ Setup (the parts castle can't do for you):
|
||||
installs to `/usr/local/bin/caddy`, which the gateway picks up on next deploy).
|
||||
- **Provider token.** Store a scoped API token as the `CLOUDFLARE_API_TOKEN`
|
||||
secret (Cloudflare scope: **Zone → DNS → Edit**), and map it into the gateway
|
||||
service env — add to `services/castle-gateway.yaml`:
|
||||
service env — add to `deployments/castle-gateway.yaml`:
|
||||
```yaml
|
||||
defaults:
|
||||
env:
|
||||
@@ -511,12 +541,14 @@ repeating castle's computed paths/ports. `castle program create` scaffolds the
|
||||
`${port}`/`${data_dir}` lines for new services. Never store secrets in
|
||||
castle.yaml — use `${secret:…}`.
|
||||
|
||||
## Job blocks
|
||||
## Job fields
|
||||
|
||||
Jobs define **how scheduled tasks run**. Same blocks as services plus
|
||||
`schedule` and `timezone`.
|
||||
A **job** is just a `manager: systemd` deployment that also carries a
|
||||
`schedule` — the derived kind flips from service to job. Same blocks as a
|
||||
service (nested `run:` launch, `manage`, `defaults`) plus `schedule` and
|
||||
`timezone`.
|
||||
|
||||
### `schedule` — Cron expression (required)
|
||||
### `schedule` — Cron expression (required for a job)
|
||||
|
||||
```yaml
|
||||
schedule: "*/5 * * * *"
|
||||
@@ -525,11 +557,6 @@ timezone: America/Los_Angeles # default
|
||||
|
||||
Castle generates a systemd `.timer` file alongside the `.service` unit.
|
||||
|
||||
### Other blocks
|
||||
|
||||
Jobs also support `run` (required), `manage`, and `defaults` — same
|
||||
semantics as services.
|
||||
|
||||
## How programs get into `/data/repos/`
|
||||
|
||||
Every program's source lives under `$CASTLE_REPOS_DIR` (default `/data/repos/<name>/`).
|
||||
@@ -563,31 +590,33 @@ castle program create my-tool --stack python-cli --description "Does something"
|
||||
|
||||
### Manually
|
||||
|
||||
Clone or create the project under `/data/repos/`, then add entries to the
|
||||
appropriate sections of `castle.yaml`:
|
||||
Clone or create the project under `/data/repos/`, then add a `programs/<name>.yaml`
|
||||
file (plus a `deployments/<name>.yaml` file if it's deployed):
|
||||
|
||||
```yaml
|
||||
# Tool — only needs a program entry
|
||||
programs:
|
||||
my-tool:
|
||||
# Tool — programs/my-tool.yaml (a program)
|
||||
description: Does something useful
|
||||
source: /data/repos/my-tool
|
||||
stack: python-cli
|
||||
behavior: tool
|
||||
```
|
||||
```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:
|
||||
```yaml
|
||||
# Service — programs/my-service.yaml (a program)
|
||||
description: Does something useful
|
||||
source: /data/repos/my-service
|
||||
stack: python-fastapi
|
||||
behavior: daemon
|
||||
|
||||
services:
|
||||
my-service:
|
||||
```
|
||||
```yaml
|
||||
# Service — deployments/my-service.yaml (manager: systemd → kind: service)
|
||||
program: my-service
|
||||
manager: systemd
|
||||
run:
|
||||
runner: python
|
||||
launcher: python
|
||||
program: my-service
|
||||
expose:
|
||||
http:
|
||||
@@ -632,14 +661,15 @@ 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
|
||||
# deployments/my-job.yaml (manager: systemd + schedule → kind: job)
|
||||
program: my-job
|
||||
manager: systemd
|
||||
run:
|
||||
runner: command
|
||||
launcher: command
|
||||
argv: ["my-job"]
|
||||
schedule: "0 2 * * *"
|
||||
manage:
|
||||
@@ -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`).
|
||||
|
||||
@@ -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:
|
||||
# programs/my-tool.yaml
|
||||
description: Does something useful
|
||||
source: /data/repos/my-tool
|
||||
stack: python-cli
|
||||
behavior: tool
|
||||
```
|
||||
```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:
|
||||
# programs/pdf2md.yaml
|
||||
description: Convert PDF files to Markdown
|
||||
source: /data/repos/pdf2md
|
||||
stack: python-cli
|
||||
behavior: tool
|
||||
system_dependencies: [pandoc, poppler-utils]
|
||||
```
|
||||
|
||||
Tools live in the `programs:` section. If a tool also runs on a schedule,
|
||||
add a separate entry in the `jobs:` section referencing the program.
|
||||
A tool is a `programs/<name>.yaml` entry plus a `deployments/<name>.yaml` with
|
||||
`manager: path` (derived **kind: tool**). If a tool also runs on a schedule, add
|
||||
a *second* deployment with `manager: systemd` + `schedule` (derived **kind:
|
||||
job**) referencing the same program.
|
||||
|
||||
See @docs/registry.md for the full registry reference.
|
||||
|
||||
@@ -103,21 +103,20 @@ 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:
|
||||
# programs/my-service.yaml
|
||||
description: Does something useful
|
||||
source: /data/repos/my-service
|
||||
stack: python-fastapi
|
||||
behavior: daemon
|
||||
|
||||
services:
|
||||
my-service:
|
||||
```
|
||||
```yaml
|
||||
# deployments/my-service.yaml (manager: systemd → kind: service)
|
||||
program: my-service
|
||||
manager: systemd
|
||||
run:
|
||||
runner: python
|
||||
launcher: python
|
||||
program: my-service
|
||||
expose:
|
||||
http:
|
||||
@@ -131,7 +130,8 @@ services:
|
||||
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:
|
||||
|
||||
@@ -123,14 +123,13 @@ The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files.
|
||||
|
||||
## Registering as a program
|
||||
|
||||
A frontend program has a `build` spec (produces static output). Register it
|
||||
in the `programs:` section of `castle.yaml`. No `run` block needed if Caddy
|
||||
handles serving directly from the build output.
|
||||
A frontend program has a `build` spec (produces static output). Register it in
|
||||
`programs/<name>.yaml`, plus a `deployments/<name>.yaml` with `manager: caddy`
|
||||
(derived **kind: static**) that names the built directory in `root:` — no `run`
|
||||
block, since Caddy serves the build output directly.
|
||||
|
||||
```yaml
|
||||
# castle.yaml
|
||||
programs:
|
||||
my-frontend:
|
||||
# programs/my-frontend.yaml
|
||||
description: Web dashboard
|
||||
source: /data/repos/my-frontend
|
||||
build:
|
||||
@@ -139,19 +138,26 @@ programs:
|
||||
outputs:
|
||||
- dist/
|
||||
```
|
||||
```yaml
|
||||
# deployments/my-frontend.yaml (manager: caddy → kind: static)
|
||||
program: my-frontend
|
||||
manager: caddy
|
||||
root: dist # served at my-frontend.<gateway.domain>
|
||||
```
|
||||
|
||||
For production, Caddy serves the build output **in place** from the program's
|
||||
repo (`<source>/<build.outputs[0]>`) — no Node process and no copy into a
|
||||
central directory. See [Serving with Caddy](#serving-with-caddy) below.
|
||||
repo (`<source>/<root>`) — no Node process and no copy into a central directory.
|
||||
See [Serving with Caddy](#serving-with-caddy) below.
|
||||
|
||||
For development with Vite's dev server, add a service entry:
|
||||
For development with Vite's dev server, use a `manager: systemd` deployment with
|
||||
the `node` launcher instead:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
my-frontend:
|
||||
# deployments/my-frontend.yaml (dev — manager: systemd, node launcher → kind: service)
|
||||
program: my-frontend
|
||||
manager: systemd
|
||||
run:
|
||||
runner: node
|
||||
launcher: node
|
||||
script: dev
|
||||
package_manager: pnpm
|
||||
expose:
|
||||
@@ -170,10 +176,10 @@ The flow:
|
||||
|
||||
1. You build the frontend with `castle program build <name>` (deploy does **not**
|
||||
build — it only points Caddy at `dist/`).
|
||||
2. The Caddyfile generator emits a route per `behavior: frontend` program that
|
||||
has `build.outputs`, rooted **in place** at `<source>/<build.outputs[0]>` —
|
||||
no copy. Each frontend is served at its own subdomain `<name>.<gateway.domain>`
|
||||
(the dashboard, `castle`, is also the target of the `:9000` redirect).
|
||||
2. The Caddyfile generator emits a route per `manager: caddy` deployment (kind
|
||||
**static**), rooted **in place** at `<source>/<root>` — no copy. Each static
|
||||
frontend is served at its own subdomain `<name>.<gateway.domain>` (the
|
||||
dashboard, `castle`, is also the target of the `:9000` redirect).
|
||||
|
||||
The build is run with `VITE_BASE=/`, so a `vite.config` that reads it (see
|
||||
[Vite config](#vite-config)) emits root-relative asset URLs. The generated block
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> web apps.** A stack is a template + conventions, not a runtime requirement.
|
||||
> `castle program create --stack supabase` scaffolds from it and seeds the
|
||||
> program's default dev-verb commands. See @docs/registry.md for `commands:`,
|
||||
> `stack:` (optional), and `behavior:`.
|
||||
> `stack:` (optional), and the deployment `manager` (and derived `kind`).
|
||||
|
||||
How to build tiny, database-backed web apps as castle programs that target a
|
||||
**shared Supabase substrate**. This is Castle's "a stack whose default is a
|
||||
@@ -17,7 +17,8 @@ Unlike the other stacks (which scaffold a self-contained process), a supabase ap
|
||||
is **code + migrations that deploy against a shared backend**:
|
||||
|
||||
- **The substrate** is one castle service (`supabase`, the `supabase-substrate`
|
||||
repo) running self-hosted Supabase via the `compose` runner. It is shared by
|
||||
repo) running self-hosted Supabase via a `manager: systemd` deployment with the
|
||||
`compose` launcher. It is shared by
|
||||
every supabase app. Stand it up once (see that repo's README).
|
||||
- **Each app** is a directory of `migrations/` + `functions/` + `public/` that
|
||||
deploys onto the substrate. Its rows/blobs live on the substrate; everything
|
||||
@@ -55,7 +56,8 @@ my-app/
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
Registered as a `behavior: frontend` program with `build.outputs: [public]`, so the
|
||||
Registered as a program with `build.outputs: [public]` plus a `manager: caddy`
|
||||
deployment (`root: public`, derived **kind: static**), so the
|
||||
gateway serves `public/` in place at `/my-app/` — no service, no process.
|
||||
|
||||
## supabase.app.yaml
|
||||
@@ -117,8 +119,9 @@ the function holds credentials and can meter usage.
|
||||
|
||||
## Gateway & secure context
|
||||
|
||||
A supabase app is a `frontend` program (its `public/` is served in place), so the
|
||||
gateway serves it at its own subdomain `<name>.<gateway.domain>`. With
|
||||
A supabase app is a static deployment (`manager: caddy`; its `public/` is served
|
||||
in place), so the gateway serves it at its own subdomain
|
||||
`<name>.<gateway.domain>`. With
|
||||
`gateway.tls: acme` that subdomain is HTTPS — a **secure context**, which apps
|
||||
using **auth or WebCrypto** require — with no private CA to install. (The substrate
|
||||
service itself is likewise at `supabase.<gateway.domain>`.) See
|
||||
@@ -139,6 +142,6 @@ castle deploy && castle gateway reload # serve the static UI at /my-app/
|
||||
registers the program as a static frontend. Set the anon key in `public/config.js`
|
||||
(`cat ~/.castle/secrets/SUPABASE_ANON_KEY`), edit your migrations, and build.
|
||||
|
||||
See @docs/registry.md for the `compose` runner, the substrate service definition,
|
||||
See @docs/registry.md for the `compose` launcher, the substrate deployment definition,
|
||||
and the full registry reference. The substrate itself lives in the
|
||||
`supabase-substrate` repo (vendored, pinned self-hosted Supabase).
|
||||
|
||||
@@ -54,12 +54,14 @@ chmod 600 ~/.castle/secrets/cloudflared/$TID.json
|
||||
# tunnel_id: <the-uuid>
|
||||
```
|
||||
|
||||
Then create the tunnel service at `~/.castle/services/castle-tunnel.yaml`:
|
||||
Then create the tunnel deployment at `~/.castle/deployments/castle-tunnel.yaml`
|
||||
(`manager: systemd` → kind: service):
|
||||
|
||||
```yaml
|
||||
description: Cloudflare tunnel — public exposure for public:true services
|
||||
manager: systemd
|
||||
run:
|
||||
runner: command
|
||||
launcher: command
|
||||
argv:
|
||||
- cloudflared
|
||||
- tunnel
|
||||
@@ -80,7 +82,7 @@ castle service enable castle-tunnel # start the tunnel
|
||||
|
||||
## Using the toggle
|
||||
|
||||
Mark a service public in its `services/<name>.yaml`:
|
||||
Mark a service public in its `deployments/<name>.yaml`:
|
||||
|
||||
```yaml
|
||||
proxy: true # required — the service must be routed
|
||||
|
||||
Reference in New Issue
Block a user