From 10a86d0b6fb08b0c3f082db95b13b947c705bc1c Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 12:25:54 -0700 Subject: [PATCH] kind is a deployment property, not a program property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- castle-api/src/castle_api/models.py | 27 ++++++++++++---- castle-api/src/castle_api/routes.py | 42 ++++++++++++++----------- castle-api/tests/test_health.py | 10 +++--- cli/src/castle_cli/commands/create.py | 4 --- cli/src/castle_cli/commands/info.py | 22 +++++++------ cli/src/castle_cli/commands/list_cmd.py | 25 +++++++++------ cli/src/castle_cli/commands/service.py | 4 ++- cli/tests/test_add.py | 6 ++-- cli/tests/test_create.py | 7 ++--- cli/tests/test_list.py | 6 ++-- core/src/castle_core/config.py | 35 ++++++++++++--------- core/src/castle_core/manifest.py | 6 ++-- core/tests/test_manifest.py | 4 +-- 13 files changed, 116 insertions(+), 82 deletions(-) diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 4ad1c0f..24485b7 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -45,12 +45,18 @@ class DeploymentDetail(DeploymentSummary): class ServiceSummary(BaseModel): - """Summary of a service (long-running daemon).""" + """Summary of a service — a systemd daemon OR a caddy-served static site. + + Both are "services" (exposed, URL-reachable things); `kind`/`manager` + distinguish them (service+systemd vs static+caddy). + """ id: str description: str | None = None stack: str | None = None - launcher: str | None = None # python|command|container|compose|node + kind: str | None = None # service | static + manager: str | None = None # systemd | caddy + launcher: str | None = None # python|command|container|compose|node (systemd only) run_target: str | None = None # what it runs: program name, argv, image, … port: int | None = None health_path: str | None = None @@ -90,12 +96,22 @@ class JobDetail(JobSummary): manifest: dict +class DeploymentRef(BaseModel): + """A reference to one of a program's deployments (name + its derived kind).""" + + name: str + kind: str # service | job | tool | static | reference + + class ProgramSummary(BaseModel): - """Summary of a program (software catalog entry).""" + """Summary of a program (software catalog entry). + + A program has NO kind of its own — it *has deployments*, each with a kind + (a program can be a tool AND a job). `deployments` is that list. + """ id: str description: str | None = None - kind: str | None = None # derived: service|job|tool|static|reference stack: str | None = None version: str | None = None source: str | None = None @@ -106,8 +122,7 @@ class ProgramSummary(BaseModel): installed: bool | None = None active: bool | None = None # uniform lifecycle state (on PATH / running / served) actions: list[str] = [] - services: list[str] = [] # services that deploy this program - jobs: list[str] = [] # jobs that deploy this program + deployments: list[DeploymentRef] = [] # this program's deployments (name + kind) node: str | None = None diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index fc9ad31..8b9995c 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -23,6 +23,7 @@ from castle_api.mesh import mesh_state from castle_api.health import check_all_health from castle_api.models import ( DeploymentDetail, + DeploymentRef, DeploymentSummary, GatewayConfigRequest, GatewayInfo, @@ -187,7 +188,11 @@ def _summary_from_job(name: str, job: SystemdDeployment, config: object) -> Depl def _summary_from_program( name: str, comp: ProgramSpec, root: Path ) -> DeploymentSummary: - """Build a DeploymentSummary from a ProgramSpec (its derived kind).""" + """Build a DeploymentSummary from a ProgramSpec (legacy unified view). + + A program has no single kind (kind is a deployment property), so the legacy + entry carries none — the typed /programs view exposes its deployment list. + """ source = comp.source installed: bool | None = None @@ -198,7 +203,7 @@ def _summary_from_program( id=name, category="program", description=comp.description, - kind=comp.kind, + kind=None, stack=comp.stack, version=comp.version, source=source, @@ -257,6 +262,8 @@ def _service_from_deployed(name: str, deployed: object) -> ServiceSummary: id=name, description=deployed.description, stack=deployed.stack, + kind=deployed.kind, + manager=deployed.manager, launcher=deployed.launcher, run_target=run_target, port=deployed.port, @@ -294,6 +301,8 @@ def _service_from_spec(name: str, svc: SystemdDeployment, config: object) -> Ser id=name, description=description, stack=stack, + kind="service", + manager="systemd", launcher=svc.run.launcher, run_target=_run_target(svc.run), port=port, @@ -363,20 +372,20 @@ def _program_from_spec( # Uniform lifecycle state (on PATH / running / served) — needs full config. active: bool | None = None - services: list[str] = [] - jobs: list[str] = [] + deployments: list[DeploymentRef] = [] if config is not None: from castle_core.lifecycle import is_active active = is_active(name, config) - # Deployments that reference this program (a program → 0-N services/jobs). - services = [s for s, spec in config.services.items() if spec.program == name] - jobs = [j for j, spec in config.jobs.items() if spec.program == name] + # A program → 0-N deployments, each with its own kind. + deployments = [ + DeploymentRef(name=dname, kind=kind) + for dname, kind in config.deployments_of(name) + ] return ProgramSummary( id=name, description=comp.description, - kind=comp.kind, stack=comp.stack, version=comp.version, source=source, @@ -387,8 +396,7 @@ def _program_from_spec( installed=installed, active=active, actions=available_actions(comp), - services=services, - jobs=jobs, + deployments=deployments, ) @@ -405,9 +413,10 @@ def list_services(include_remote: bool = False) -> list[ServiceSummary]: summaries: list[ServiceSummary] = [] seen: set[str] = set() - # Deployed services only — not jobs, tools (path), statics (caddy), or remotes. + # Services page shows services (systemd) AND statics (caddy) — both are + # exposed, URL-reachable "services". Not jobs, tools, or remotes. for name, deployed in registry.deployed.items(): - if deployed.kind != "service": + if deployed.kind not in ("service", "static"): continue s = _service_from_deployed(name, deployed) s.node = hostname @@ -631,9 +640,8 @@ def list_programs(kind: str | None = None) -> list[ProgramSummary]: for name, comp in config.programs.items(): summary = _program_from_spec(name, comp, root, config) - if summary.kind is None: - continue - if kind and summary.kind != kind: + # A program's kinds are the kinds of its deployments; filter by membership. + if kind and kind not in {d.kind for d in summary.deployments}: continue summary.node = hostname summaries.append(summary) @@ -722,11 +730,9 @@ def list_components(include_remote: bool = False) -> list[DeploymentSummary]: if ref and ref in config.programs: s.source = config.programs[ref].source - # Programs from the software catalog + # Programs from the software catalog (legacy unified view) for name, comp in config.programs.items(): summary = _summary_from_program(name, comp, root) - if summary.kind is None: - continue summary.node = local_hostname summaries.append(summary) except FileNotFoundError: diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 230bb5f..0cea79c 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -203,12 +203,14 @@ class TestProgramsList: names = [p["id"] for p in data] assert "test-tool" in names - def test_program_has_kind(self, client: TestClient) -> None: - """Program summary includes the derived kind.""" + def test_program_lists_deployments(self, client: TestClient) -> None: + """Program summary lists its deployments (name + kind), not a single kind.""" response = client.get("/programs") data = response.json() tool = next(p for p in data if p["id"] == "test-tool") - assert tool["kind"] == "tool" + assert "kind" not in tool + kinds = {d["kind"] for d in tool["deployments"]} + assert "tool" in kinds def test_no_port_field(self, client: TestClient) -> None: """ProgramSummary does not have port field.""" @@ -235,7 +237,7 @@ class TestProgramDetail: data = response.json() assert data["id"] == "test-tool" assert "manifest" in data - assert data["kind"] == "tool" + assert {d["kind"] for d in data["deployments"]} == {"tool"} def test_not_found(self, client: TestClient) -> None: """Returns 404 for unknown program.""" diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index ef80d22..2e98633 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -144,10 +144,6 @@ def run_create(args: argparse.Namespace) -> int: ), ) - # Populate the derived kind on the in-memory program so readers see the live - # value immediately (it's excluded from disk — kind_of recomputes on load). - config.programs[name].kind = config.kind_of(name) - save_config(config) label = f"{stack} program" if stack else "bare program" diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index 60857d8..f3355ca 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -53,16 +53,18 @@ def run_info(args: argparse.Namespace) -> int: print(f"\n{BOLD}{name}{RESET}") print(f"{'─' * 40}") - # Determine kind (derived) - kind = None - if program and program.kind: - kind = program.kind + # Determine kind(s) — for a program, the kinds of its deployments; for a + # single deployment, its own kind. + kinds: list[str] = [] + if program: + kinds = sorted({k for _, k in config.deployments_of(name)}) elif service: - kind = "service" + kinds = ["service"] elif job: - kind = "job" - if kind: - print(f" {BOLD}kind{RESET}: {kind}") + kinds = ["job"] + if kinds: + label = "kind" if len(kinds) == 1 else "kinds" + print(f" {BOLD}{label}{RESET}: {', '.join(kinds)}") # Show stack stack = None @@ -182,8 +184,8 @@ def _info_json( data["service"] = service.model_dump(exclude_none=True, exclude={"id"}) if job: data["job"] = job.model_dump(exclude_none=True, exclude={"id"}) - if program and program.kind: - data["kind"] = program.kind + if program: + data["kinds"] = sorted({k for _, k in config.deployments_of(name)}) elif service: data["kind"] = "service" elif job: diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index 8adc3f5..36d974e 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -84,12 +84,17 @@ def run_list(args: argparse.Namespace) -> int: any_output = False - # Programs (the catalog) — filtered by real behavior + stack + # A program's kinds are the kinds of its deployments (a program has no kind + # of its own). Sorted, de-duplicated. + def prog_kinds(name: str) -> list[str]: + return sorted({kind for _, kind in config.deployments_of(name)}) + + # Programs (the catalog) — filtered by a deployment kind + stack. progs = ( { name: comp for name, comp in config.programs.items() - if (not filter_kind or comp.kind == filter_kind) + if (not filter_kind or filter_kind in prog_kinds(name)) and (not filter_stack or comp.stack == filter_stack) } if resource in (None, "program") @@ -100,12 +105,13 @@ def run_list(args: argparse.Namespace) -> int: print(f"\n{BOLD}{CYAN}Programs{RESET}") print(f"{CYAN}{'─' * 40}{RESET}") for name, comp in progs.items(): - kind = comp.kind or "program" - bcolor = KIND_COLORS.get(kind, "") - behavior_str = f" {bcolor}{kind}{RESET}" + kinds = prog_kinds(name) + kinds_str = "".join( + f" {KIND_COLORS.get(k, '')}{k}{RESET}" for k in kinds + ) stack_str = f" {DIM}{comp.stack}{RESET}" if comp.stack else "" desc = f" {DIM}{comp.description}{RESET}" if comp.description else "" - print(f" {dot(name)} {BOLD}{name}{RESET}{behavior_str}{stack_str}{desc}") + print(f" {dot(name)} {BOLD}{name}{RESET}{kinds_str}{stack_str}{desc}") # Services + Jobs (deployment views) — independent of behavior, so only shown # when no behavior filter is applied. Each gated by its own resource scope. @@ -168,15 +174,16 @@ def _list_json( output = [] - # Programs (catalog) — filtered by derived kind + stack + # Programs (catalog) — a program's kinds are its deployments' kinds. for name, comp in config.programs.items(): - if filter_kind and comp.kind != filter_kind: + kinds = sorted({kind for _, kind in config.deployments_of(name)}) + if filter_kind and filter_kind not in kinds: continue if filter_stack and comp.stack != filter_stack: continue entry: dict = { "name": name, - "kind": comp.kind, + "kinds": kinds, "active": is_active(name, config), } if comp.stack: diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index eeb5788..119db1d 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -183,7 +183,9 @@ def run_status(args: argparse.Namespace) -> int: on = is_active(name, config) color = "\033[92m" if on else "\033[90m" label = "active" if on else "inactive" - print(f" {color}{label:10s}\033[0m {name} ({comp.kind or 'program'})") + kinds = sorted({k for _, k in config.deployments_of(name)}) + tag = ", ".join(kinds) if kinds else "program" + print(f" {color}{label:10s}\033[0m {name} ({tag})") print() return 0 diff --git a/cli/tests/test_add.py b/cli/tests/test_add.py index 813e9a8..a026939 100644 --- a/cli/tests/test_add.py +++ b/cli/tests/test_add.py @@ -42,10 +42,10 @@ class TestAdd: ) rc, config = _run_add(castle_root, target=str(repo)) assert rc == 0 - # `add` adopts source only (kind is derived from a deployment declared - # later); a fastapi project is detected as the python-fastapi stack. + # `add` adopts source only — no deployment yet (kind is a deployment + # property); a fastapi project is detected as the python-fastapi stack. assert config.programs["svc"].stack == "python-fastapi" - assert config.programs["svc"].kind is None + assert config.deployments_of("svc") == [] def test_adopt_rust_declares_commands(self, castle_root: Path, tmp_path: Path) -> None: repo = tmp_path / "rusty" diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index 20870fd..e4c0174 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -73,10 +73,9 @@ class TestCreateCommand: assert (project_dir / "src" / "my_tool2" / "main.py").exists() assert (project_dir / "CLAUDE.md").exists() assert "my-tool2" in config.programs - comp = config.programs["my-tool2"] - assert comp.kind == "tool" - # A tool is a PATH deployment: manager=path. + # A tool is a PATH deployment: manager=path, derived kind=tool. assert config.deployments["my-tool2"].manager == "path" + assert config.deployments_of("my-tool2") == [("my-tool2", "tool")] def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None: """A supabase app scaffolds a Patch-shaped project registered as a static @@ -106,12 +105,12 @@ class TestCreateCommand: # Registered as a program + a caddy (static) deployment serving public/ comp = config.programs["guestbook"] - assert comp.kind == "static" assert comp.stack == "supabase" assert comp.build is not None and comp.build.outputs == ["public"] dep = config.deployments["guestbook"] assert dep.manager == "caddy" assert dep.root == "public" + assert config.deployments_of("guestbook") == [("guestbook", "static")] def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None: """Creating a project with existing name fails.""" diff --git a/cli/tests/test_list.py b/cli/tests/test_list.py index 0aea5f1..b6695af 100644 --- a/cli/tests/test_list.py +++ b/cli/tests/test_list.py @@ -89,7 +89,7 @@ class TestListCommand: assert "test-svc" in names assert "test-tool" in names svc = next(p for p in data if p["name"] == "test-svc") - assert svc["kind"] == "service" - # test-tool is a program deployed on PATH → its derived kind is `tool`. + assert svc["kind"] == "service" # a service deployment entry (singular) + # test-tool is a program with a PATH deployment → kinds includes `tool`. tool = next(p for p in data if p["name"] == "test-tool") - assert tool["kind"] == "tool" + assert "tool" in tool["kinds"] diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 26f6d6b..3d96892 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -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 diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 1208d53..65240f1 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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 diff --git a/core/tests/test_manifest.py b/core/tests/test_manifest.py index f2f6d86..741c01a 100644 --- a/core/tests/test_manifest.py +++ b/core/tests/test_manifest.py @@ -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: