From 00e8d58c6a9d7a3e058ea2dda13fd447fe801e47 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Wed, 1 Jul 2026 08:46:54 -0700 Subject: [PATCH] Derive program behavior from deployments; stop persisting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit behavior (tool/daemon/frontend) is now a derived display label computed from a program's deployments — path->tool, static->frontend, process->daemon (behavior_for_runner). load_config populates it so every reader gets the live value; _spec_to_yaml_dict excludes it so it is never written to disk. _summary_from_service derives it from the runner instead of hardcoding daemon. Fixtures gain path/python deployments so tool/daemon behaviors derive. --- castle-api/src/castle_api/routes.py | 10 +++++++-- castle-api/tests/conftest.py | 8 ++++--- cli/tests/conftest.py | 15 +++++++++++-- core/src/castle_core/config.py | 35 +++++++++++++++++++++-------- core/src/castle_core/deploy.py | 4 ++-- core/src/castle_core/manifest.py | 11 +++++++++ 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index 2d47519..c6c8fdf 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -11,7 +11,13 @@ from fastapi import APIRouter, HTTPException, status 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, manager_for +from castle_core.manifest import ( + ProgramSpec, + JobSpec, + ServiceSpec, + behavior_for_runner, + manager_for, +) from castle_core.stacks import available_actions from castle_api.config import get_castle_root, get_registry @@ -133,7 +139,7 @@ def _summary_from_service( id=name, category="service", description=description, - behavior="daemon", + behavior=behavior_for_runner(runner), stack=stack, runner=runner, port=port, diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 801f3b3..db5047c 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -42,19 +42,16 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "test-tool": { "description": "Test tool", "source": "test-tool", - "behavior": "tool", "system_dependencies": ["pandoc"], }, "test-tool-2": { "description": "Another test tool", "source": "test-tool-2", - "behavior": "tool", "version": "2.0.0", }, "wired-in": { "description": "Adopted repo, no stack", "source": "wired-in", - "behavior": "tool", "repo": "https://github.com/someone/wired-in.git", "commands": { "lint": [["make", "lint"]], @@ -64,6 +61,11 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: }, }, "services": { + # Path deployments — behavior "tool" derives from the `path` runner + # (behavior is derived from deployments, never stored). + "test-tool": {"program": "test-tool", "run": {"runner": "path"}}, + "test-tool-2": {"program": "test-tool-2", "run": {"runner": "path"}}, + "wired-in": {"program": "wired-in", "run": {"runner": "path"}}, "test-svc": { "program": "test-svc-comp", "description": "Test service", diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py index f20aa33..4f6c604 100644 --- a/cli/tests/conftest.py +++ b/cli/tests/conftest.py @@ -31,14 +31,25 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "programs": { "test-tool": { "description": "Test tool", - "behavior": "tool", }, "test-daemon": { "description": "Test daemon program", - "behavior": "daemon", }, }, "services": { + # A path deployment — its `path` runner makes test-tool's behavior + # derive as "tool" (behavior is derived from deployments, not stored). + "test-tool": { + "program": "test-tool", + "run": {"runner": "path"}, + }, + # A process deployment — its systemd-managed runner makes test-daemon's + # behavior derive as "daemon". + "test-daemon": { + "program": "test-daemon", + "run": {"runner": "python", "program": "test-daemon"}, + "manage": {"systemd": {}}, + }, "test-svc": { "program": "test-svc-comp", "description": "Test service", diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 7e9632f..c102851 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -137,14 +137,24 @@ class CastleConfig: services: dict[str, ServiceSpec] jobs: dict[str, JobSpec] + 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 + return None + @property def tools(self) -> dict[str, ProgramSpec]: - """Programs deployed as a PATH tool (a `runner: path` service) — derived - from deployments, not the `behavior` label.""" - tool_programs = { - s.program or n for n, s in self.services.items() if s.run.runner == "path" - } - return {k: v for k, v in self.programs.items() if k in tool_programs} + """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"} @property def frontends(self) -> dict[str, ProgramSpec]: @@ -304,7 +314,7 @@ def load_config(root: Path | None = None) -> CastleConfig: for name, job_data in _load_resource_dir(root / "jobs").items(): jobs[name] = _parse_job(name, job_data) - return CastleConfig( + config = CastleConfig( root=root, repo=repo_path, gateway=gateway, @@ -312,6 +322,11 @@ def load_config(root: Path | None = None) -> CastleConfig: services=services, jobs=jobs, ) + # `behavior` is derived from deployments, never stored — populate it so every + # reader of `program.behavior` gets the live, accurate label. + for pname, prog in config.programs.items(): + prog.behavior = config.behavior_of(pname) + return config def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object: @@ -348,7 +363,8 @@ _STRUCTURAL_KEYS = { def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict: """Serialize a spec to a YAML-friendly dict, preserving structural presence.""" - exclude_fields = {"id"} + # `behavior` is derived at load time from deployments — never persisted. + exclude_fields = {"id", "behavior"} 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 @@ -380,7 +396,8 @@ def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict: return None result = merge(full, minimal) - return _clean_for_yaml(result) + cleaned = _clean_for_yaml(result) + return cleaned if isinstance(cleaned, dict) else {} def _program_to_yaml_dict(spec: ProgramSpec, config: CastleConfig) -> dict: diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 15b594f..4116c78 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -41,7 +41,7 @@ from castle_core.generators.systemd import ( unit_env_file, unit_name, ) -from castle_core.manifest import JobSpec, ServiceSpec, manager_for +from castle_core.manifest import JobSpec, ServiceSpec, behavior_for_runner, manager_for from castle_core.registry import ( REGISTRY_PATH, Deployment, @@ -401,7 +401,7 @@ def _build_deployed_service( env=env, secret_env_keys=sorted(secret_env), description=_resolve_description(config, svc), - behavior="daemon", + behavior=behavior_for_runner(run.runner), stack=stack, port=port, health_path=health_path, diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index b8bdbbb..7a10843 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -139,6 +139,17 @@ def manager_for(runner: str) -> str: 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 # ---------------------