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:
|
||||
|
||||
@@ -38,7 +38,6 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"programs": {
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"behavior": "tool",
|
||||
},
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -10,13 +10,14 @@ from castle_core.generators.caddyfile import (
|
||||
generate_caddyfile_from_registry,
|
||||
)
|
||||
from castle_core.manifest import (
|
||||
CaddyDeployment,
|
||||
DeploymentSpec,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
ProgramSpec,
|
||||
RunPython,
|
||||
RunStatic,
|
||||
ServiceSpec,
|
||||
SystemdDeployment,
|
||||
)
|
||||
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||
|
||||
@@ -52,10 +53,14 @@ def _make_registry(
|
||||
)
|
||||
|
||||
|
||||
def _dep(port: int, *, expose: bool, name: str | None = None, runner: str = "python") -> Deployment:
|
||||
def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "python") -> Deployment:
|
||||
"""A deployed service; exposed at <name>.<domain> when expose=True."""
|
||||
return Deployment(
|
||||
runner=runner, run_cmd=["x"], port=port, subdomain=(name if expose else None)
|
||||
manager="systemd",
|
||||
launcher=launcher,
|
||||
run_cmd=["x"],
|
||||
port=port,
|
||||
subdomain=(name if expose else None),
|
||||
)
|
||||
|
||||
|
||||
@@ -109,9 +114,9 @@ class TestAcmeMode:
|
||||
import castle_core.config as config_mod
|
||||
|
||||
cfg = _config(
|
||||
services={
|
||||
"castle": ServiceSpec(
|
||||
program="castle", run=RunStatic(runner="static", root="dist")
|
||||
deployments={
|
||||
"castle": CaddyDeployment(
|
||||
manager="caddy", program="castle", root="dist"
|
||||
)
|
||||
},
|
||||
programs={"castle": ProgramSpec(source="/data/repos/castle/app")},
|
||||
@@ -144,24 +149,24 @@ class TestOffMode:
|
||||
assert "litellm" not in cf # no subdomains without a domain
|
||||
|
||||
|
||||
def _service(port: int, *, expose: bool) -> ServiceSpec:
|
||||
return ServiceSpec(
|
||||
run=RunPython(runner="python", program="svc"),
|
||||
def _service(port: int, *, expose: bool) -> SystemdDeployment:
|
||||
return SystemdDeployment(
|
||||
manager="systemd",
|
||||
run=RunPython(launcher="python", program="svc"),
|
||||
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=port))),
|
||||
proxy=expose,
|
||||
)
|
||||
|
||||
|
||||
def _config(
|
||||
services: dict[str, ServiceSpec], programs: dict[str, ProgramSpec] | None = None
|
||||
deployments: dict[str, DeploymentSpec], programs: dict[str, ProgramSpec] | None = None
|
||||
) -> CastleConfig:
|
||||
return CastleConfig(
|
||||
root=None, # type: ignore[arg-type]
|
||||
gateway=GatewayConfig(port=9000),
|
||||
repo=None,
|
||||
programs=programs or {},
|
||||
services=services,
|
||||
jobs={},
|
||||
deployments=deployments,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from castle_core.config import (
|
||||
resolve_env_vars,
|
||||
save_config,
|
||||
)
|
||||
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||
from castle_core.manifest import ProgramSpec, SystemdDeployment
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
@@ -32,8 +32,10 @@ class TestLoadConfig:
|
||||
"""Each section produces the correct spec type."""
|
||||
config = load_config(castle_root)
|
||||
assert isinstance(config.programs["test-tool"], ProgramSpec)
|
||||
assert isinstance(config.services["test-svc"], ServiceSpec)
|
||||
assert isinstance(config.jobs["test-job"], JobSpec)
|
||||
# Both a service and a job are systemd deployments; the kind (service/job)
|
||||
# is derived from whether a schedule is present.
|
||||
assert isinstance(config.services["test-svc"], SystemdDeployment)
|
||||
assert isinstance(config.jobs["test-job"], SystemdDeployment)
|
||||
|
||||
def test_service_expose(self, castle_root: Path) -> None:
|
||||
"""Service has correct expose spec."""
|
||||
@@ -49,10 +51,10 @@ class TestLoadConfig:
|
||||
assert svc.proxy is True # exposed at <name>.<gateway.domain>
|
||||
|
||||
def test_service_run_spec(self, castle_root: Path) -> None:
|
||||
"""Service has correct RunSpec."""
|
||||
"""Service has correct launch spec (legacy runner normalized to launcher)."""
|
||||
config = load_config(castle_root)
|
||||
svc = config.services["test-svc"]
|
||||
assert svc.run.runner == "python"
|
||||
assert svc.run.launcher == "python"
|
||||
assert svc.run.program == "test-svc"
|
||||
|
||||
def test_service_component_ref(self, castle_root: Path) -> None:
|
||||
@@ -114,25 +116,26 @@ class TestSaveConfig:
|
||||
assert svc.manage.systemd is not None
|
||||
|
||||
def test_writes_directory_layout(self, castle_root: Path) -> None:
|
||||
"""Save writes one file per resource under programs/services/jobs dirs."""
|
||||
"""Save writes one file per resource under programs/ and one deployments/ dir."""
|
||||
config = load_config(castle_root)
|
||||
save_config(config)
|
||||
assert (castle_root / "programs" / "test-tool.yaml").exists()
|
||||
assert (castle_root / "services" / "test-svc.yaml").exists()
|
||||
assert (castle_root / "jobs" / "test-job.yaml").exists()
|
||||
# service, job, and tool all live under the single deployments/ dir now.
|
||||
assert (castle_root / "deployments" / "test-svc.yaml").exists()
|
||||
assert (castle_root / "deployments" / "test-job.yaml").exists()
|
||||
assert (castle_root / "deployments" / "test-tool.yaml").exists()
|
||||
# Global file holds gateway only, no resource sections
|
||||
global_data = yaml.safe_load((castle_root / "castle.yaml").read_text())
|
||||
assert global_data["gateway"]["port"] == 18000
|
||||
assert "programs" not in global_data
|
||||
assert "services" not in global_data
|
||||
assert "jobs" not in global_data
|
||||
assert "deployments" not in global_data
|
||||
|
||||
def test_delete_prunes_file(self, castle_root: Path) -> None:
|
||||
"""Removing a resource and saving deletes its on-disk file."""
|
||||
"""Removing a deployment and saving deletes its on-disk file."""
|
||||
config = load_config(castle_root)
|
||||
del config.services["test-svc"]
|
||||
del config.deployments["test-svc"]
|
||||
save_config(config)
|
||||
assert not (castle_root / "services" / "test-svc.yaml").exists()
|
||||
assert not (castle_root / "deployments" / "test-svc.yaml").exists()
|
||||
config2 = load_config(castle_root)
|
||||
assert "test-svc" not in config2.services
|
||||
assert "test-tool" in config2.programs
|
||||
|
||||
@@ -12,7 +12,8 @@ from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||
|
||||
def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
|
||||
return Deployment(
|
||||
runner="python",
|
||||
manager="systemd",
|
||||
launcher="python",
|
||||
run_cmd=["x"],
|
||||
env={},
|
||||
managed=managed,
|
||||
|
||||
@@ -11,7 +11,7 @@ from castle_core.manifest import RunCompose, RunContainer, RunNode, RunPython
|
||||
|
||||
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
|
||||
"""A python service with a real source dir launches via `uv run --project`."""
|
||||
run = RunPython(runner="python", program="my-svc")
|
||||
run = RunPython(launcher="python", program="my-svc")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
|
||||
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd == [
|
||||
@@ -25,7 +25,7 @@ def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_python_runner_appends_args(tmp_path: Path) -> None:
|
||||
run = RunPython(runner="python", program="my-svc", args=["--flag", "x"])
|
||||
run = RunPython(launcher="python", program="my-svc", args=["--flag", "x"])
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/uv"):
|
||||
cmd = _build_run_cmd("my-svc", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd[-2:] == ["--flag", "x"]
|
||||
@@ -34,7 +34,7 @@ def test_python_runner_appends_args(tmp_path: Path) -> None:
|
||||
|
||||
def test_python_runner_falls_back_to_path_without_source() -> None:
|
||||
"""No resolvable source → PATH lookup of the script (no uv run)."""
|
||||
run = RunPython(runner="python", program="my-svc")
|
||||
run = RunPython(launcher="python", program="my-svc")
|
||||
with patch(
|
||||
"castle_core.deploy.shutil.which", return_value="/home/u/.local/bin/my-svc"
|
||||
):
|
||||
@@ -44,7 +44,7 @@ def test_python_runner_falls_back_to_path_without_source() -> None:
|
||||
|
||||
def test_python_runner_warns_when_unresolvable() -> None:
|
||||
"""No source and not on PATH → a warning, bare program name as last resort."""
|
||||
run = RunPython(runner="python", program="my-svc")
|
||||
run = RunPython(launcher="python", program="my-svc")
|
||||
messages: list[str] = []
|
||||
with patch("castle_core.deploy.shutil.which", return_value=None):
|
||||
cmd = _build_run_cmd("my-svc", run, {}, messages, source_dir=None)
|
||||
@@ -54,7 +54,7 @@ def test_python_runner_warns_when_unresolvable() -> None:
|
||||
|
||||
def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
|
||||
"""A node service runs the script in its source dir via `--dir` (no unit cwd)."""
|
||||
run = RunNode(runner="node", script="gateway:watch:raw", package_manager="pnpm")
|
||||
run = RunNode(launcher="node", script="gateway:watch:raw", package_manager="pnpm")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
|
||||
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd == ["/usr/bin/pnpm", "--dir", str(tmp_path), "run", "gateway:watch:raw"]
|
||||
@@ -62,7 +62,7 @@ def test_node_runner_bakes_source_dir(tmp_path: Path) -> None:
|
||||
|
||||
def test_node_runner_appends_args(tmp_path: Path) -> None:
|
||||
run = RunNode(
|
||||
runner="node", script="start", package_manager="pnpm", args=["--port", "18789"]
|
||||
launcher="node", script="start", package_manager="pnpm", args=["--port", "18789"]
|
||||
)
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
|
||||
cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path)
|
||||
@@ -79,7 +79,7 @@ def test_node_runner_appends_args(tmp_path: Path) -> None:
|
||||
|
||||
def test_node_runner_without_source_omits_dir() -> None:
|
||||
"""No resolvable source → bare `pnpm run` (package manager still PATH-resolved)."""
|
||||
run = RunNode(runner="node", script="start", package_manager="pnpm")
|
||||
run = RunNode(launcher="node", script="start", package_manager="pnpm")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"):
|
||||
cmd = _build_run_cmd("oc", run, {}, [], source_dir=None)
|
||||
assert cmd == ["/usr/bin/pnpm", "run", "start"]
|
||||
@@ -87,7 +87,7 @@ def test_node_runner_without_source_omits_dir() -> None:
|
||||
|
||||
def test_container_secrets_use_env_file_not_argv() -> None:
|
||||
"""Secrets go through --env-file; plain vars stay as -e; no secret in argv."""
|
||||
run = RunContainer(runner="container", image="img:latest", env={"PLAIN": "1"})
|
||||
run = RunContainer(launcher="container", image="img:latest", env={"PLAIN": "1"})
|
||||
env_file = Path("/home/u/.castle/secrets/env/castle-svc.service.env")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("svc", run, {"PORT": "9001"}, [], secret_env_file=env_file)
|
||||
@@ -101,7 +101,7 @@ def test_container_secrets_use_env_file_not_argv() -> None:
|
||||
|
||||
|
||||
def test_container_without_secrets_has_no_env_file() -> None:
|
||||
run = RunContainer(runner="container", image="img:latest")
|
||||
run = RunContainer(launcher="container", image="img:latest")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None)
|
||||
assert "--env-file" not in cmd
|
||||
@@ -109,7 +109,7 @@ def test_container_without_secrets_has_no_env_file() -> None:
|
||||
|
||||
def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
|
||||
"""A compose service runs `docker compose -p <project> -f <abs-file> up`."""
|
||||
run = RunCompose(runner="compose", file="docker-compose.yml")
|
||||
run = RunCompose(launcher="compose", file="docker-compose.yml")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("supabase", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd == [
|
||||
@@ -125,7 +125,7 @@ def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
|
||||
|
||||
def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
|
||||
"""Compose reads env from the process (systemd), not --env-file/-e — argv is clean."""
|
||||
run = RunCompose(runner="compose", file="docker-compose.yml")
|
||||
run = RunCompose(launcher="compose", file="docker-compose.yml")
|
||||
env_file = Path("/home/u/.castle/secrets/env/castle-supabase.service.env")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd(
|
||||
@@ -139,7 +139,7 @@ def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_compose_project_name_override(tmp_path: Path) -> None:
|
||||
run = RunCompose(runner="compose", file="stack.yml", project_name="myproj")
|
||||
run = RunCompose(launcher="compose", file="stack.yml", project_name="myproj")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
cmd = _build_run_cmd("s", run, {}, [], source_dir=tmp_path)
|
||||
assert cmd[:6] == [
|
||||
@@ -149,7 +149,7 @@ def test_compose_project_name_override(tmp_path: Path) -> None:
|
||||
|
||||
def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
|
||||
"""The teardown command mirrors up but ends in `down` (same project + file)."""
|
||||
run = RunCompose(runner="compose", file="docker-compose.yml")
|
||||
run = RunCompose(launcher="compose", file="docker-compose.yml")
|
||||
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
|
||||
stop = _build_stop_cmd("supabase", run, tmp_path)
|
||||
assert stop == [
|
||||
@@ -164,5 +164,5 @@ def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_non_compose_runner_has_no_stop_cmd(tmp_path: Path) -> None:
|
||||
run = RunPython(runner="python", program="svc")
|
||||
run = RunPython(launcher="python", program="svc")
|
||||
assert _build_stop_cmd("svc", run, tmp_path) == []
|
||||
|
||||
@@ -65,7 +65,7 @@ class TestRegistrySecretKeys:
|
||||
node=NodeConfig(hostname="h"),
|
||||
deployed={
|
||||
"svc": Deployment(
|
||||
runner="container",
|
||||
manager="systemd", launcher="container",
|
||||
run_cmd=["docker", "run"],
|
||||
env={"PORT": "9001"},
|
||||
secret_env_keys=["ANTHROPIC_API_KEY", "OPENAI_API_KEY"],
|
||||
|
||||
@@ -34,37 +34,38 @@ class TestIsActive:
|
||||
config = load_config(castle_root)
|
||||
assert lifecycle.is_active("does-not-exist", config) is False
|
||||
|
||||
def test_path_service_checks_path(self, castle_root: Path) -> None:
|
||||
# A `runner: path` service (a tool) is active when on PATH.
|
||||
from castle_core.manifest import ProgramSpec, RunPath, ServiceSpec, manager_for
|
||||
def test_path_deployment_checks_path(self, castle_root: Path) -> None:
|
||||
# A `manager: path` deployment (a tool) is active when on PATH.
|
||||
from castle_core.manifest import PathDeployment, ProgramSpec
|
||||
|
||||
assert manager_for("path") == "path"
|
||||
config = load_config(castle_root)
|
||||
config.programs["mytool"] = ProgramSpec(id="mytool", source="/tmp/mytool")
|
||||
config.services["mytool"] = ServiceSpec(program="mytool", run=RunPath(runner="path"))
|
||||
config.deployments["mytool"] = PathDeployment(manager="path", program="mytool")
|
||||
with patch.object(lifecycle, "_on_path", return_value=True) as mock:
|
||||
assert lifecycle.is_active("mytool", config) is True
|
||||
mock.assert_called_once_with("mytool")
|
||||
|
||||
def test_remote_service_is_active(self, castle_root: Path) -> None:
|
||||
# A remote service has no local process; the manager is `none` → available.
|
||||
from castle_core.manifest import RunRemote, ServiceSpec
|
||||
def test_remote_deployment_is_active(self, castle_root: Path) -> None:
|
||||
# A remote deployment has no local process; the manager is `none` → available.
|
||||
from castle_core.manifest import RemoteDeployment
|
||||
|
||||
config = load_config(castle_root)
|
||||
config.services["ext"] = ServiceSpec(
|
||||
program="ext", run=RunRemote(runner="remote", base_url="http://x")
|
||||
config.deployments["ext"] = RemoteDeployment(
|
||||
manager="none", program="ext", base_url="http://x"
|
||||
)
|
||||
assert lifecycle.is_active("ext", config) is True
|
||||
|
||||
def test_static_service_active_when_dist_built(self, castle_root: Path, tmp_path: Path) -> None:
|
||||
# A frontend is a `runner: static` service; active once its served dir exists.
|
||||
from castle_core.manifest import ProgramSpec, RunStatic, ServiceSpec
|
||||
def test_static_deployment_active_when_dist_built(
|
||||
self, castle_root: Path, tmp_path: Path
|
||||
) -> None:
|
||||
# A static (caddy) deployment is active once its served dir exists.
|
||||
from castle_core.manifest import CaddyDeployment, ProgramSpec
|
||||
|
||||
config = load_config(castle_root)
|
||||
repo = tmp_path / "fe"
|
||||
config.programs["fe"] = ProgramSpec(id="fe", source=str(repo))
|
||||
config.services["fe"] = ServiceSpec(
|
||||
program="fe", run=RunStatic(runner="static", root="dist")
|
||||
config.deployments["fe"] = CaddyDeployment(
|
||||
manager="caddy", program="fe", root="dist"
|
||||
)
|
||||
# No dist yet → inactive (caddy manager checks the served dir)
|
||||
assert lifecycle.is_active("fe", config) is False
|
||||
|
||||
@@ -5,49 +5,49 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from castle_core.manifest import (
|
||||
BuildSpec,
|
||||
ProgramSpec,
|
||||
CaddyDeployment,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
JobSpec,
|
||||
ManageSpec,
|
||||
PathDeployment,
|
||||
ProgramSpec,
|
||||
RunCommand,
|
||||
RunPython,
|
||||
RunRemote,
|
||||
ServiceSpec,
|
||||
RemoteDeployment,
|
||||
SystemdDeployment,
|
||||
SystemdSpec,
|
||||
kind_for,
|
||||
)
|
||||
|
||||
|
||||
class TestProgramSpec:
|
||||
"""Tests for component (software catalog) model."""
|
||||
"""Tests for program (software catalog) model."""
|
||||
|
||||
def test_minimal(self) -> None:
|
||||
"""Minimal component just needs an id."""
|
||||
"""Minimal program just needs an id."""
|
||||
c = ProgramSpec(id="bare")
|
||||
assert c.description is None
|
||||
assert c.source is None
|
||||
assert c.behavior is None
|
||||
# `kind` is derived at load time; a bare spec has none.
|
||||
assert c.kind is None
|
||||
assert c.build is None
|
||||
|
||||
def test_tool_component(self) -> None:
|
||||
"""Component with tool behavior and system_dependencies."""
|
||||
def test_tool_program(self) -> None:
|
||||
"""Program with source and system_dependencies."""
|
||||
c = ProgramSpec(
|
||||
id="my-tool",
|
||||
description="A tool",
|
||||
source="my-tool/",
|
||||
behavior="tool",
|
||||
system_dependencies=["pandoc"],
|
||||
)
|
||||
assert c.source == "my-tool/"
|
||||
assert c.behavior == "tool"
|
||||
assert c.system_dependencies == ["pandoc"]
|
||||
|
||||
def test_frontend_component(self) -> None:
|
||||
"""Component with build spec."""
|
||||
def test_frontend_program(self) -> None:
|
||||
"""Program with build spec."""
|
||||
c = ProgramSpec(
|
||||
id="my-app",
|
||||
behavior="frontend",
|
||||
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
|
||||
)
|
||||
assert c.build.outputs == ["dist/"]
|
||||
@@ -63,109 +63,91 @@ class TestProgramSpec:
|
||||
assert c.source_dir is None
|
||||
|
||||
|
||||
class TestServiceSpec:
|
||||
"""Tests for service (long-running daemon) model."""
|
||||
class TestSystemdDeployment:
|
||||
"""Tests for the systemd deployment (service or job)."""
|
||||
|
||||
def test_basic_service(self) -> None:
|
||||
"""Service with run and expose."""
|
||||
s = ServiceSpec(
|
||||
"""A systemd deployment with a launcher and expose is a service."""
|
||||
s = SystemdDeployment(
|
||||
id="svc",
|
||||
run=RunPython(runner="python", program="svc"),
|
||||
manager="systemd",
|
||||
run=RunPython(launcher="python", program="svc"),
|
||||
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
|
||||
)
|
||||
assert s.run.runner == "python"
|
||||
assert s.run.launcher == "python"
|
||||
assert s.expose.http.internal.port == 8000
|
||||
assert kind_for(s) == "service"
|
||||
|
||||
def test_service_with_component_ref(self) -> None:
|
||||
"""Service can reference a component."""
|
||||
s = ServiceSpec(
|
||||
id="svc",
|
||||
program="my-component",
|
||||
run=RunPython(runner="python", program="svc"),
|
||||
)
|
||||
assert s.program == "my-component"
|
||||
|
||||
def test_service_with_proxy(self) -> None:
|
||||
"""Service with proxy spec."""
|
||||
s = ServiceSpec(
|
||||
id="svc",
|
||||
run=RunPython(runner="python", program="svc"),
|
||||
proxy=True,
|
||||
)
|
||||
assert s.proxy is True
|
||||
|
||||
def test_service_with_manage(self) -> None:
|
||||
"""Service with systemd management."""
|
||||
s = ServiceSpec(
|
||||
id="svc",
|
||||
run=RunCommand(runner="command", argv=["bin"]),
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
assert s.manage.systemd.enable is True
|
||||
|
||||
def test_remote_with_systemd_raises(self) -> None:
|
||||
"""Remote runner + systemd management is invalid."""
|
||||
with pytest.raises(
|
||||
ValueError, match="manage.systemd cannot be enabled for runner=remote"
|
||||
):
|
||||
ServiceSpec(
|
||||
id="bad",
|
||||
run=RunRemote(runner="remote", base_url="http://example.com"),
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
|
||||
def test_no_run_is_invalid(self) -> None:
|
||||
"""Service requires a run spec."""
|
||||
with pytest.raises(Exception):
|
||||
ServiceSpec(id="bad")
|
||||
|
||||
|
||||
class TestJobSpec:
|
||||
"""Tests for job (scheduled task) model."""
|
||||
|
||||
def test_basic_job(self) -> None:
|
||||
"""Job with run and schedule."""
|
||||
j = JobSpec(
|
||||
def test_scheduled_is_a_job(self) -> None:
|
||||
"""A systemd deployment with a schedule derives kind `job`."""
|
||||
j = SystemdDeployment(
|
||||
id="my-job",
|
||||
run=RunCommand(runner="command", argv=["backup"]),
|
||||
manager="systemd",
|
||||
run=RunCommand(launcher="command", argv=["backup"]),
|
||||
schedule="0 2 * * *",
|
||||
)
|
||||
assert j.schedule == "0 2 * * *"
|
||||
assert j.timezone == "America/Los_Angeles"
|
||||
assert kind_for(j) == "job"
|
||||
|
||||
def test_job_with_component_ref(self) -> None:
|
||||
"""Job can reference a component."""
|
||||
j = JobSpec(
|
||||
id="sync",
|
||||
program="protonmail",
|
||||
run=RunCommand(runner="command", argv=["protonmail", "sync"]),
|
||||
schedule="*/5 * * * *",
|
||||
def test_program_ref(self) -> None:
|
||||
"""A deployment can reference a program."""
|
||||
s = SystemdDeployment(
|
||||
id="svc",
|
||||
manager="systemd",
|
||||
program="my-program",
|
||||
run=RunPython(launcher="python", program="svc"),
|
||||
)
|
||||
assert j.program == "protonmail"
|
||||
assert s.program == "my-program"
|
||||
|
||||
def test_job_requires_schedule(self) -> None:
|
||||
"""Job without schedule is invalid."""
|
||||
with pytest.raises(Exception):
|
||||
JobSpec(
|
||||
def test_with_manage(self) -> None:
|
||||
"""A deployment with systemd management."""
|
||||
s = SystemdDeployment(
|
||||
id="svc",
|
||||
manager="systemd",
|
||||
run=RunCommand(launcher="command", argv=["bin"]),
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
assert s.manage.systemd.enable is True
|
||||
|
||||
def test_public_requires_proxy(self) -> None:
|
||||
"""public without proxy is invalid (public needs an exposed process)."""
|
||||
with pytest.raises(ValueError, match="public requires proxy"):
|
||||
SystemdDeployment(
|
||||
id="bad",
|
||||
run=RunCommand(runner="command", argv=["x"]),
|
||||
manager="systemd",
|
||||
run=RunPython(launcher="python", program="svc"),
|
||||
public=True,
|
||||
)
|
||||
|
||||
def test_job_custom_timezone(self) -> None:
|
||||
"""Job with custom timezone."""
|
||||
j = JobSpec(
|
||||
id="job",
|
||||
run=RunCommand(runner="command", argv=["x"]),
|
||||
schedule="0 0 * * *",
|
||||
timezone="UTC",
|
||||
)
|
||||
assert j.timezone == "UTC"
|
||||
def test_no_run_is_invalid(self) -> None:
|
||||
"""A systemd deployment requires a run (launch) spec."""
|
||||
with pytest.raises(Exception):
|
||||
SystemdDeployment(id="bad", manager="systemd")
|
||||
|
||||
|
||||
class TestOtherManagers:
|
||||
"""Tests for the non-systemd managers and derived kinds."""
|
||||
|
||||
def test_caddy_is_static(self) -> None:
|
||||
c = CaddyDeployment(id="fe", manager="caddy", program="fe", root="dist")
|
||||
assert c.root == "dist"
|
||||
assert kind_for(c) == "static"
|
||||
|
||||
def test_path_is_tool(self) -> None:
|
||||
p = PathDeployment(id="cli", manager="path", program="cli")
|
||||
assert kind_for(p) == "tool"
|
||||
|
||||
def test_none_is_reference(self) -> None:
|
||||
r = RemoteDeployment(id="ext", manager="none", base_url="http://example.com")
|
||||
assert r.base_url == "http://example.com"
|
||||
assert kind_for(r) == "reference"
|
||||
|
||||
|
||||
class TestModelSerialization:
|
||||
"""Tests for model_dump behavior."""
|
||||
|
||||
def test_dump_component_excludes_none(self) -> None:
|
||||
def test_dump_program_excludes_none(self) -> None:
|
||||
"""model_dump with exclude_none drops None fields."""
|
||||
c = ProgramSpec(id="test", description="Test")
|
||||
data = c.model_dump(exclude_none=True, exclude={"id"})
|
||||
@@ -173,11 +155,12 @@ class TestModelSerialization:
|
||||
assert "build" not in data
|
||||
|
||||
def test_dump_service(self) -> None:
|
||||
"""Full service serializes correctly."""
|
||||
s = ServiceSpec(
|
||||
"""Full systemd deployment serializes correctly."""
|
||||
s = SystemdDeployment(
|
||||
id="svc",
|
||||
manager="systemd",
|
||||
description="A service",
|
||||
run=RunPython(runner="python", program="svc"),
|
||||
run=RunPython(launcher="python", program="svc"),
|
||||
expose=ExposeSpec(
|
||||
http=HttpExposeSpec(
|
||||
internal=HttpInternal(port=9001), health_path="/health"
|
||||
@@ -187,6 +170,7 @@ class TestModelSerialization:
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
data = s.model_dump(exclude_none=True, exclude={"id"})
|
||||
assert data["run"]["runner"] == "python"
|
||||
assert data["manager"] == "systemd"
|
||||
assert data["run"]["launcher"] == "python"
|
||||
assert data["expose"]["http"]["internal"]["port"] == 9001
|
||||
assert data["proxy"] is True
|
||||
|
||||
@@ -29,7 +29,7 @@ class TestUnitFromDeployed:
|
||||
def test_contains_description(self) -> None:
|
||||
"""Unit file has service description."""
|
||||
deployed = Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||
env={"TEST_SVC_PORT": "19000"},
|
||||
description="Test service",
|
||||
@@ -40,7 +40,7 @@ class TestUnitFromDeployed:
|
||||
def test_no_working_directory(self) -> None:
|
||||
"""Unit file has no WorkingDirectory (source/runtime separation)."""
|
||||
deployed = Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||
env={},
|
||||
description="Test service",
|
||||
@@ -51,7 +51,7 @@ class TestUnitFromDeployed:
|
||||
def test_contains_environment(self) -> None:
|
||||
"""Unit file has environment variables from deployed config."""
|
||||
deployed = Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||
env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"},
|
||||
description="Test service",
|
||||
@@ -62,7 +62,7 @@ class TestUnitFromDeployed:
|
||||
def test_contains_restart_policy(self) -> None:
|
||||
"""Unit file has restart configuration."""
|
||||
deployed = Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||
env={},
|
||||
description="Test service",
|
||||
@@ -73,7 +73,7 @@ class TestUnitFromDeployed:
|
||||
def test_exec_start_from_run_cmd(self) -> None:
|
||||
"""Unit file ExecStart uses resolved run_cmd."""
|
||||
deployed = Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||
env={},
|
||||
description="Test service",
|
||||
@@ -84,7 +84,7 @@ class TestUnitFromDeployed:
|
||||
def test_basic_service(self) -> None:
|
||||
"""Generate a unit from a deployed component."""
|
||||
deployed = Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
|
||||
env={
|
||||
"MY_SVC_PORT": "9001",
|
||||
@@ -103,7 +103,7 @@ class TestUnitFromDeployed:
|
||||
def test_scheduled_job(self) -> None:
|
||||
"""Scheduled component generates oneshot unit."""
|
||||
deployed = Deployment(
|
||||
runner="command",
|
||||
manager="systemd", launcher="command",
|
||||
run_cmd=["/home/user/.local/bin/my-job"],
|
||||
env={},
|
||||
description="Nightly job",
|
||||
@@ -116,7 +116,7 @@ class TestUnitFromDeployed:
|
||||
def test_no_repo_paths(self) -> None:
|
||||
"""Generated units must not reference repo paths."""
|
||||
deployed = Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
|
||||
env={"DATA_DIR": "/home/user/.castle/data/my-svc"},
|
||||
description="Test",
|
||||
@@ -127,7 +127,7 @@ class TestUnitFromDeployed:
|
||||
def test_exec_stop_emitted_for_compose(self) -> None:
|
||||
"""A compose deployment's stop_cmd becomes ExecStop= (clean teardown)."""
|
||||
deployed = Deployment(
|
||||
runner="compose",
|
||||
manager="systemd", launcher="compose",
|
||||
run_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "up"],
|
||||
stop_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "down"],
|
||||
description="Stack",
|
||||
@@ -138,7 +138,7 @@ class TestUnitFromDeployed:
|
||||
def test_no_exec_stop_without_stop_cmd(self) -> None:
|
||||
"""Runners without a stop_cmd rely on SIGTERM — no ExecStop line."""
|
||||
deployed = Deployment(
|
||||
runner="python", run_cmd=["/uv", "run", "x"], description="X"
|
||||
manager="systemd", launcher="python", run_cmd=["/uv", "run", "x"], description="X"
|
||||
)
|
||||
unit = generate_unit_from_deployed("x", deployed)
|
||||
assert "ExecStop=" not in unit
|
||||
@@ -149,7 +149,7 @@ class TestSecretEnvFile:
|
||||
|
||||
def test_environment_file_added_for_simple_unit(self) -> None:
|
||||
deployed = Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/uv", "run", "my-svc"],
|
||||
env={"PORT": "9001"},
|
||||
secret_env_keys=["API_KEY"],
|
||||
@@ -162,7 +162,7 @@ class TestSecretEnvFile:
|
||||
|
||||
def test_environment_file_added_for_oneshot_job(self) -> None:
|
||||
deployed = Deployment(
|
||||
runner="command",
|
||||
manager="systemd", launcher="command",
|
||||
run_cmd=["/bin/job"],
|
||||
env={},
|
||||
schedule="0 2 * * *",
|
||||
@@ -174,14 +174,14 @@ class TestSecretEnvFile:
|
||||
assert f"EnvironmentFile={path}" in unit
|
||||
|
||||
def test_no_environment_file_when_none(self) -> None:
|
||||
deployed = Deployment(runner="python", run_cmd=["/uv", "run", "x"], env={})
|
||||
deployed = Deployment(manager="systemd", launcher="python", run_cmd=["/uv", "run", "x"], env={})
|
||||
unit = generate_unit_from_deployed("x", deployed, env_file=None)
|
||||
assert "EnvironmentFile" not in unit
|
||||
|
||||
def test_secret_values_never_in_unit(self) -> None:
|
||||
"""The unit references the file path; resolved secret values never appear."""
|
||||
deployed = Deployment(
|
||||
runner="python",
|
||||
manager="systemd", launcher="python",
|
||||
run_cmd=["/uv", "run", "x"],
|
||||
env={"PORT": "9001"},
|
||||
secret_env_keys=["API_KEY"],
|
||||
@@ -195,16 +195,16 @@ class TestUnitEnvFile:
|
||||
"""unit_env_file decides which runners get an EnvironmentFile= path."""
|
||||
|
||||
def test_none_without_secrets(self) -> None:
|
||||
d = Deployment(runner="python", run_cmd=[], env={}, secret_env_keys=[])
|
||||
d = Deployment(manager="systemd", launcher="python", run_cmd=[], env={}, secret_env_keys=[])
|
||||
assert unit_env_file(d, "x") is None
|
||||
|
||||
def test_path_for_python_with_secrets(self) -> None:
|
||||
d = Deployment(runner="python", run_cmd=[], env={}, secret_env_keys=["K"])
|
||||
d = Deployment(manager="systemd", launcher="python", run_cmd=[], env={}, secret_env_keys=["K"])
|
||||
assert unit_env_file(d, "x") == secret_env_path("x")
|
||||
|
||||
def test_none_for_container(self) -> None:
|
||||
"""Containers load secrets via docker --env-file, not systemd."""
|
||||
d = Deployment(runner="container", run_cmd=[], env={}, secret_env_keys=["K"])
|
||||
d = Deployment(manager="systemd", launcher="container", run_cmd=[], env={}, secret_env_keys=["K"])
|
||||
assert unit_env_file(d, "x") is None
|
||||
|
||||
|
||||
|
||||
@@ -28,9 +28,9 @@ def _registry(
|
||||
),
|
||||
deployed=deployed
|
||||
or {
|
||||
"app": Deployment(runner="python", run_cmd=["x"], port=9001,
|
||||
"app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
|
||||
subdomain="app", public=True),
|
||||
"private": Deployment(runner="python", run_cmd=["y"], port=9002,
|
||||
"private": Deployment(manager="systemd", launcher="python", run_cmd=["y"], port=9002,
|
||||
subdomain="private", public=False),
|
||||
},
|
||||
)
|
||||
@@ -60,7 +60,7 @@ def test_private_service_not_in_public_dns() -> None:
|
||||
|
||||
def test_none_when_no_public_services() -> None:
|
||||
only_private = {
|
||||
"x": Deployment(runner="python", run_cmd=["x"], subdomain="x", public=False)
|
||||
"x": Deployment(manager="systemd", launcher="python", run_cmd=["x"], subdomain="x", public=False)
|
||||
}
|
||||
assert generate_tunnel_config(_registry(deployed=only_private)) is None
|
||||
|
||||
@@ -74,7 +74,7 @@ def test_public_static_frontend_gets_ingress() -> None:
|
||||
# A `static` (frontend) service can be public too — the toggle composes for
|
||||
# any exposed deployment, process-backed or not.
|
||||
reg = _registry(deployed={
|
||||
"guestbook": Deployment(runner="static", run_cmd=[], subdomain="guestbook",
|
||||
"guestbook": Deployment(manager="caddy", run_cmd=[], subdomain="guestbook",
|
||||
static_root="/data/repos/guestbook/public", public=True),
|
||||
})
|
||||
hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"]
|
||||
|
||||
Reference in New Issue
Block a user