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

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

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

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

View File

@@ -12,12 +12,14 @@ from pydantic import BaseModel
from castle_core.config import (
CastleConfig,
GatewayConfig,
_DEPLOYMENT_ADAPTER,
_normalize_deployment_dict,
_program_to_yaml_dict,
_spec_to_yaml_dict,
load_config,
save_config,
)
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
from castle_core.manifest import ProgramSpec, kind_for
from castle_api.config import get_castle_root, get_config, get_registry
from castle_api.stream import broadcast
@@ -79,12 +81,10 @@ def _aggregate_yaml(config: CastleConfig) -> str:
data["programs"] = {
n: _program_to_yaml_dict(s, config) for n, s in config.programs.items()
}
if config.services:
data["services"] = {
n: _spec_to_yaml_dict(s) for n, s in config.services.items()
if config.deployments:
data["deployments"] = {
n: _spec_to_yaml_dict(s) for n, s in config.deployments.items()
}
if config.jobs:
data["jobs"] = {n: _spec_to_yaml_dict(s) for n, s in config.jobs.items()}
return yaml.dump(data, default_flow_style=False, sort_keys=False)
@@ -148,25 +148,20 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
except Exception as e:
errors.append(f"programs.{name}: {e}")
# Validate services
services: dict[str, ServiceSpec] = {}
for name, svc_data in data.get("services", {}).items():
# Validate deployments (accepting a legacy services:/jobs: split too, which
# the normalizer folds into the single manager-discriminated collection).
deployments = {}
raw_deps: dict = dict(data.get("deployments") or {})
for legacy in ("services", "jobs"):
raw_deps.update(data.get(legacy) or {})
for name, dep_data in raw_deps.items():
try:
svc_data_copy = dict(svc_data) if svc_data else {}
svc_data_copy["id"] = name
services[name] = ServiceSpec.model_validate(svc_data_copy)
dep_copy = _normalize_deployment_dict(dict(dep_data) if dep_data else {})
dep_copy = dict(dep_copy)
dep_copy["id"] = name
deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(dep_copy)
except Exception as e:
errors.append(f"services.{name}: {e}")
# Validate jobs
jobs: dict[str, JobSpec] = {}
for name, job_data in data.get("jobs", {}).items():
try:
job_data_copy = dict(job_data) if job_data else {}
job_data_copy["id"] = name
jobs[name] = JobSpec.model_validate(job_data_copy)
except Exception as e:
errors.append(f"jobs.{name}: {e}")
errors.append(f"deployments.{name}: {e}")
if errors:
raise HTTPException(
@@ -175,8 +170,8 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
)
prog_count = len(programs)
svc_count = len(services)
job_count = len(jobs)
svc_count = sum(1 for d in deployments.values() if kind_for(d) == "service")
job_count = sum(1 for d in deployments.values() if kind_for(d) == "job")
gateway_data = data.get("gateway", {})
config = CastleConfig(
@@ -184,8 +179,7 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
repo=repo_path,
gateway=GatewayConfig(port=gateway_data.get("port", 9000)),
programs=programs,
services=services,
jobs=jobs,
deployments=deployments,
)
save_config(config)
@@ -232,8 +226,7 @@ def delete_program(name: str) -> dict:
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Program '{name}' not found",
)
refs = [s for s, spec in config.services.items() if spec.program == name]
refs += [j for j, spec in config.jobs.items() if spec.program == name]
refs = [d for d, spec in config.deployments.items() if spec.program == name]
if refs:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
@@ -247,74 +240,70 @@ def delete_program(name: str) -> dict:
return {"ok": True, "program": name, "action": "deleted"}
@router.put("/services/{name}")
def save_service(name: str, request: ServiceConfigRequest) -> dict:
"""Update a single service's config in castle.yaml."""
def _save_deployment(name: str, config_dict: dict) -> dict:
"""Validate a deployment (any manager) and persist it to config.deployments."""
_require_repo()
try:
svc_data = dict(request.config)
svc_data["id"] = name
ServiceSpec.model_validate(svc_data)
dep_data = _normalize_deployment_dict({**config_dict, "id": name})
_DEPLOYMENT_ADAPTER.validate_python(dep_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid service config: {e}",
detail=f"Invalid deployment config: {e}",
)
config = get_config()
config.services[name] = ServiceSpec.model_validate({**request.config, "id": name})
config.deployments[name] = _DEPLOYMENT_ADAPTER.validate_python(
_normalize_deployment_dict({**config_dict, "id": name})
)
save_config(config)
return {"ok": True, "service": name}
return {"ok": True, "deployment": name}
def _delete_deployment(name: str) -> dict:
config = get_config()
if name not in config.deployments:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Deployment '{name}' not found",
)
del config.deployments[name]
save_config(config)
return {"ok": True, "deployment": name, "action": "deleted"}
# The deployment endpoints — `deployments` is canonical; `services`/`jobs` remain
# as aliases (the kind is derived, so all three target the one collection).
@router.put("/deployments/{name}")
def save_deployment(name: str, request: ServiceConfigRequest) -> dict:
"""Create/update a deployment of any kind (service/job/tool/static)."""
return _save_deployment(name, request.config)
@router.delete("/deployments/{name}")
def delete_deployment(name: str) -> dict:
return _delete_deployment(name)
@router.put("/services/{name}")
def save_service(name: str, request: ServiceConfigRequest) -> dict:
"""Alias of PUT /deployments/{name} (kept for the existing dashboard)."""
return _save_deployment(name, request.config)
@router.delete("/services/{name}")
def delete_service(name: str) -> dict:
"""Remove a service from castle.yaml."""
config = get_config()
if name not in config.services:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service '{name}' not found",
)
del config.services[name]
save_config(config)
return {"ok": True, "service": name, "action": "deleted"}
return _delete_deployment(name)
@router.put("/jobs/{name}")
def save_job(name: str, request: JobConfigRequest) -> dict:
"""Update a single job's config in castle.yaml."""
_require_repo()
try:
job_data = dict(request.config)
job_data["id"] = name
JobSpec.model_validate(job_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid job config: {e}",
)
config = get_config()
config.jobs[name] = JobSpec.model_validate({**request.config, "id": name})
save_config(config)
return {"ok": True, "job": name}
"""Alias of PUT /deployments/{name} (kept for the existing dashboard)."""
return _save_deployment(name, request.config)
@router.delete("/jobs/{name}")
def delete_job(name: str) -> dict:
"""Remove a job from castle.yaml."""
config = get_config()
if name not in config.jobs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job '{name}' not found",
)
del config.jobs[name]
save_config(config)
return {"ok": True, "job": name, "action": "deleted"}
return _delete_deployment(name)
@router.post("/apply", response_model=ApplyResponse)

View File

@@ -27,10 +27,8 @@ async def get_logs(
from castle_core.config import load_config
config = load_config(root)
is_managed = (
(name in config.services and config.services[name].manage is not None)
or (name in config.jobs and config.jobs[name].manage is not None)
)
dep = config.deployments.get(name)
is_managed = dep is not None and getattr(dep, "manage", None) is not None
if not is_managed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,

View File

@@ -17,9 +17,10 @@ class DeploymentSummary(BaseModel):
id: str
category: str | None = None # "program", "service", or "job"
description: str | None = None
behavior: str | None = None
kind: str | None = None # derived: service|job|tool|static|reference
stack: str | None = None
runner: str | None = None
manager: str | None = None # systemd|caddy|path|none
launcher: str | None = None # python|command|container|compose|node (systemd only)
port: int | None = None
health_path: str | None = None
subdomain: str | None = None # exposed at <subdomain>.<gateway.domain>, else None
@@ -49,7 +50,7 @@ class ServiceSummary(BaseModel):
id: str
description: str | None = None
stack: str | None = None
runner: str | None = None
launcher: str | None = None # python|command|container|compose|node
run_target: str | None = None # what it runs: program name, argv, image, …
port: int | None = None
health_path: str | None = None
@@ -73,7 +74,7 @@ class JobSummary(BaseModel):
id: str
description: str | None = None
stack: str | None = None
runner: str | None = None
launcher: str | None = None # python|command|container|compose|node
run_target: str | None = None # what it runs: program name, argv, …
schedule: str | None = None
managed: bool = False
@@ -94,9 +95,8 @@ class ProgramSummary(BaseModel):
id: str
description: str | None = None
behavior: str | None = None
kind: str | None = None # derived: service|job|tool|static|reference
stack: str | None = None
runner: str | None = None
version: str | None = None
source: str | None = None
repo: str | None = None

View File

@@ -43,8 +43,9 @@ def _registry_to_json(registry: NodeRegistry) -> str:
for name, comp in registry.deployed.items():
entry: dict = {
"runner": comp.runner,
"behavior": comp.behavior,
"manager": comp.manager,
"launcher": comp.launcher,
"kind": comp.kind,
}
if comp.stack:
entry["stack"] = comp.stack
@@ -77,11 +78,12 @@ def _json_to_registry(payload: str) -> NodeRegistry:
deployed: dict[str, Deployment] = {}
for name, comp_data in data.get("deployed", {}).items():
deployed[name] = Deployment(
runner=comp_data.get("runner", "command"),
manager=comp_data.get("manager", "systemd"),
launcher=comp_data.get("launcher"),
run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}),
description=comp_data.get("description"),
behavior=comp_data.get("behavior", "daemon"),
kind=comp_data.get("kind", "service"),
stack=comp_data.get("stack"),
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),

View File

@@ -48,9 +48,10 @@ def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSu
id=name,
category="job" if d.schedule else "service",
description=d.description,
behavior=d.behavior,
kind=d.kind,
stack=d.stack,
runner=d.runner,
manager=d.manager,
launcher=d.launcher,
port=d.port,
health_path=d.health_path,
subdomain=d.subdomain,

View File

@@ -13,10 +13,8 @@ from castle_core.config import SPECS_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import (
ProgramSpec,
JobSpec,
ServiceSpec,
behavior_for_runner,
manager_for,
SystemdDeployment,
kind_for,
)
from castle_core.stacks import available_actions
@@ -80,7 +78,7 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
# A PATH-managed deployment (a tool) is "installed" when it's on PATH.
installed: bool | None = None
if manager_for(deployed.runner) == "path":
if deployed.manager == "path":
installed = shutil.which(name) is not None
category = "job" if deployed.schedule else "service"
@@ -89,9 +87,10 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
id=name,
category=category,
description=deployed.description,
behavior=deployed.behavior,
kind=deployed.kind,
stack=deployed.stack,
runner=deployed.runner,
manager=deployed.manager,
launcher=deployed.launcher,
port=deployed.port,
health_path=deployed.health_path,
subdomain=deployed.subdomain,
@@ -103,9 +102,9 @@ def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
def _summary_from_service(
name: str, svc: ServiceSpec, config: object
name: str, svc: SystemdDeployment, config: object
) -> DeploymentSummary:
"""Build a DeploymentSummary from a ServiceSpec (non-deployed)."""
"""Build a DeploymentSummary from a systemd deployment (service, non-deployed)."""
port = None
health_path = None
if svc.expose and svc.expose.http:
@@ -133,15 +132,14 @@ def _summary_from_service(
source = comp.source
stack = comp.stack
runner = svc.run.runner
return DeploymentSummary(
id=name,
category="service",
description=description,
behavior=behavior_for_runner(runner),
kind=kind_for(svc),
stack=stack,
runner=runner,
manager="systemd",
launcher=svc.run.launcher,
port=port,
health_path=health_path,
subdomain=subdomain,
@@ -151,8 +149,8 @@ def _summary_from_service(
)
def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSummary:
"""Build a DeploymentSummary from a JobSpec (non-deployed)."""
def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> DeploymentSummary:
"""Build a DeploymentSummary from a systemd deployment (job, non-deployed)."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info: SystemdInfo | None = None
@@ -175,9 +173,10 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSumm
id=name,
category="job",
description=description,
behavior="tool",
kind="job",
stack=stack,
runner=job.run.runner,
manager="systemd",
launcher=job.run.launcher,
managed=managed,
systemd=systemd_info,
schedule=job.schedule,
@@ -188,18 +187,9 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSumm
def _summary_from_program(
name: str, comp: ProgramSpec, root: Path
) -> DeploymentSummary:
"""Build a DeploymentSummary from a ProgramSpec (tools/frontends)."""
"""Build a DeploymentSummary from a ProgramSpec (its derived kind)."""
source = comp.source
# Infer runner from source directory
runner = None
if source:
source_dir = Path(source)
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if comp.source and (comp.stack or comp.commands):
installed = shutil.which(name) is not None
@@ -208,9 +198,8 @@ def _summary_from_program(
id=name,
category="program",
description=comp.description,
behavior=comp.behavior,
kind=comp.kind,
stack=comp.stack,
runner=runner,
version=comp.version,
source=source,
repo=comp.repo,
@@ -268,7 +257,7 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
id=name,
description=deployed.description,
stack=deployed.stack,
runner=deployed.runner,
launcher=deployed.launcher,
run_target=run_target,
port=deployed.port,
health_path=deployed.health_path,
@@ -278,8 +267,8 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
)
def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSummary:
"""Build a ServiceSummary from a ServiceSpec."""
def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> ServiceSummary:
"""Build a ServiceSummary from a systemd deployment."""
port = None
health_path = None
if svc.expose and svc.expose.http:
@@ -305,7 +294,7 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
id=name,
description=description,
stack=stack,
runner=svc.run.runner,
launcher=svc.run.launcher,
run_target=_run_target(svc.run),
port=port,
health_path=health_path,
@@ -325,7 +314,7 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary:
id=name,
description=deployed.description,
stack=deployed.stack,
runner=deployed.runner,
launcher=deployed.launcher,
run_target=run_target,
schedule=deployed.schedule,
managed=deployed.managed,
@@ -333,8 +322,8 @@ def _job_from_deployed(name: str, deployed: object) -> JobSummary:
)
def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
"""Build a JobSummary from a JobSpec."""
def _job_from_spec(name: str, job: SystemdDeployment, config: object) -> JobSummary:
"""Build a JobSummary from a systemd deployment (job)."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info = _make_systemd_info(name, timer=True) if managed else None
@@ -352,7 +341,7 @@ def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
id=name,
description=description,
stack=stack,
runner=job.run.runner,
launcher=job.run.launcher,
run_target=_run_target(job.run),
schedule=job.schedule,
managed=managed,
@@ -367,13 +356,6 @@ def _program_from_spec(
) -> ProgramSummary:
"""Build a ProgramSummary from a ProgramSpec."""
source = comp.source
runner = None
if source:
source_dir = Path(source)
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if comp.source and (comp.stack or comp.commands):
@@ -394,9 +376,8 @@ def _program_from_spec(
return ProgramSummary(
id=name,
description=comp.description,
behavior=comp.behavior,
kind=comp.kind,
stack=comp.stack,
runner=runner,
version=comp.version,
source=source,
repo=comp.repo,
@@ -508,7 +489,8 @@ def get_service(name: str) -> ServiceDetail:
if config is not None and summary.source is None:
summary.source = _backfill_source(name, config)
manifest = {
"runner": deployed.runner,
"manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,
@@ -516,7 +498,7 @@ def get_service(name: str) -> ServiceDetail:
"health_path": deployed.health_path,
"subdomain": deployed.subdomain,
"managed": deployed.managed,
"behavior": deployed.behavior,
"kind": deployed.kind,
"stack": deployed.stack,
}
return ServiceDetail(**summary.model_dump(), manifest=manifest)
@@ -609,13 +591,14 @@ def get_job(name: str) -> JobDetail:
if config is not None and summary.source is None:
summary.source = _backfill_source(name, config)
manifest = {
"runner": deployed.runner,
"manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,
"managed": deployed.managed,
"schedule": deployed.schedule,
"behavior": deployed.behavior,
"kind": deployed.kind,
"stack": deployed.stack,
}
return JobDetail(**summary.model_dump(), manifest=manifest)
@@ -627,10 +610,10 @@ def get_job(name: str) -> JobDetail:
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
def list_programs(behavior: str | None = None) -> list[ProgramSummary]:
def list_programs(kind: str | None = None) -> list[ProgramSummary]:
"""List all programs from the software catalog (castle.yaml programs section).
Optionally filter by behavior: daemon, tool, or frontend.
Optionally filter by derived kind: service, job, tool, static, or reference.
"""
root = get_castle_root()
if not root:
@@ -648,9 +631,9 @@ def list_programs(behavior: str | None = None) -> list[ProgramSummary]:
for name, comp in config.programs.items():
summary = _program_from_spec(name, comp, root, config)
if summary.behavior is None:
if summary.kind is None:
continue
if behavior and summary.behavior != behavior:
if kind and summary.kind != kind:
continue
summary.node = hostname
summaries.append(summary)
@@ -742,7 +725,7 @@ def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
# Programs from the software catalog
for name, comp in config.programs.items():
summary = _summary_from_program(name, comp, root)
if summary.behavior is None:
if summary.kind is None:
continue
summary.node = local_hostname
summaries.append(summary)
@@ -759,9 +742,10 @@ def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
id=name,
category="job" if d.schedule else "service",
description=d.description,
behavior=d.behavior,
kind=d.kind,
stack=d.stack,
runner=d.runner,
manager=d.manager,
launcher=d.launcher,
port=d.port,
health_path=d.health_path,
subdomain=d.subdomain,
@@ -805,7 +789,8 @@ def get_component(name: str) -> DeploymentDetail:
pass
raw = {
"runner": deployed.runner,
"manager": deployed.manager,
"launcher": deployed.launcher,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"secret_env_keys": deployed.secret_env_keys,
@@ -813,7 +798,7 @@ def get_component(name: str) -> DeploymentDetail:
"health_path": deployed.health_path,
"subdomain": deployed.subdomain,
"managed": deployed.managed,
"behavior": deployed.behavior,
"kind": deployed.kind,
"stack": deployed.stack,
}
return DeploymentDetail(**summary.model_dump(), manifest=raw)

View File

@@ -140,17 +140,13 @@ def get_unit(name: str) -> dict[str, str | None]:
from castle_core.config import load_config
config = load_config(root)
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
description = svc.description
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
schedule = job.schedule
description = job.description
dep = config.deployments.get(name)
if dep is not None:
manage = getattr(dep, "manage", None)
if manage and manage.systemd:
systemd_spec = manage.systemd
description = dep.description
schedule = getattr(dep, "schedule", None)
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
timer = generate_timer(name, schedule, description) if schedule else None