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

@@ -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)