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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user