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