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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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