kind is a deployment property, not a program property

A program has no single kind — it HAS deployments, each with its own kind (a
program can be a tool AND a job, e.g. protonmail). Remove program-level kind:

- core: drop ProgramSpec.kind; CastleConfig.kind_of → deployments_of(name) →
  [(deployment-name, kind)]; tools property derives from a tool deployment.
- api: ProgramSummary drops kind/services/jobs → deployments: [{name, kind}];
  /programs?kind= filters by deployment-kind membership; a program's legacy
  DeploymentSummary carries kind=None.
- cli: list/info show a program's set of deployment kinds; --kind filters by
  membership.

Also: Services page now covers statics — /services returns kind in
{service, static} (both are exposed, URL-reachable 'services', caddy vs systemd),
and ServiceSummary gains kind + manager to distinguish them.

Suites: core 124, cli 25, castle-api 58.
This commit is contained in:
2026-07-01 12:25:54 -07:00
parent 876b424722
commit 10a86d0b6f
13 changed files with 116 additions and 82 deletions

View File

@@ -143,13 +143,19 @@ class CastleConfig:
# reference are *derived views* over this, filtered by kind_for — see below.
deployments: dict[str, DeploymentSpec]
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
def deployments_of(self, name: str) -> list[tuple[str, str]]:
"""A program's deployments as (deployment-name, kind) pairs, name-sorted.
A program has no kind of its own — it *has deployments*, each with a kind.
A deployment belongs to a program when it names it (`program:`) or shares
its name (the 1:1 tool/static case). Empty for a bare, undeployed program.
"""
out = [
(dname, kind_for(dep))
for dname, dep in self.deployments.items()
if dname == name or dep.program == name
]
return sorted(out)
@property
def services(self) -> dict[str, DeploymentSpec]:
@@ -163,8 +169,12 @@ class CastleConfig:
@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.kind_of(k) == "tool"}
"""Programs with a PATH (tool) deployment — derived, not a stored label."""
return {
k: v
for k, v in self.programs.items()
if any(kind == "tool" for _, kind in self.deployments_of(k))
}
@property
def frontends(self) -> dict[str, ProgramSpec]:
@@ -361,10 +371,6 @@ def load_config(root: Path | None = None) -> CastleConfig:
programs=programs,
deployments=deployments,
)
# `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.kind = config.kind_of(pname)
return config
@@ -402,8 +408,7 @@ _STRUCTURAL_KEYS = {
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"}
exclude_fields = {"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

View File

@@ -207,9 +207,9 @@ class ProgramSpec(BaseModel):
id: str = ""
description: 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
# A program has NO kind of its own — kind is a *deployment* property. A program
# is a catalog entry that has 0..N deployments, each with its own kind (see
# kind_for and CastleConfig.deployments_of).
source: str | None = None
stack: str | None = None

View File

@@ -29,8 +29,8 @@ class TestProgramSpec:
c = ProgramSpec(id="bare")
assert c.description is None
assert c.source is None
# `kind` is derived at load time; a bare spec has none.
assert c.kind is None
# A program has no `kind` of its own — kind is a deployment property.
assert not hasattr(c, "kind")
assert c.build is None
def test_tool_program(self) -> None: