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

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

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

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

View File

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