Derive program behavior from deployments; stop persisting it
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.
This commit is contained in:
@@ -11,7 +11,13 @@ from fastapi import APIRouter, HTTPException, status
|
|||||||
|
|
||||||
from castle_core.config import SPECS_DIR
|
from castle_core.config import SPECS_DIR
|
||||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
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_core.stacks import available_actions
|
||||||
|
|
||||||
from castle_api.config import get_castle_root, get_registry
|
from castle_api.config import get_castle_root, get_registry
|
||||||
@@ -133,7 +139,7 @@ def _summary_from_service(
|
|||||||
id=name,
|
id=name,
|
||||||
category="service",
|
category="service",
|
||||||
description=description,
|
description=description,
|
||||||
behavior="daemon",
|
behavior=behavior_for_runner(runner),
|
||||||
stack=stack,
|
stack=stack,
|
||||||
runner=runner,
|
runner=runner,
|
||||||
port=port,
|
port=port,
|
||||||
|
|||||||
@@ -42,19 +42,16 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
"test-tool": {
|
"test-tool": {
|
||||||
"description": "Test tool",
|
"description": "Test tool",
|
||||||
"source": "test-tool",
|
"source": "test-tool",
|
||||||
"behavior": "tool",
|
|
||||||
"system_dependencies": ["pandoc"],
|
"system_dependencies": ["pandoc"],
|
||||||
},
|
},
|
||||||
"test-tool-2": {
|
"test-tool-2": {
|
||||||
"description": "Another test tool",
|
"description": "Another test tool",
|
||||||
"source": "test-tool-2",
|
"source": "test-tool-2",
|
||||||
"behavior": "tool",
|
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
},
|
},
|
||||||
"wired-in": {
|
"wired-in": {
|
||||||
"description": "Adopted repo, no stack",
|
"description": "Adopted repo, no stack",
|
||||||
"source": "wired-in",
|
"source": "wired-in",
|
||||||
"behavior": "tool",
|
|
||||||
"repo": "https://github.com/someone/wired-in.git",
|
"repo": "https://github.com/someone/wired-in.git",
|
||||||
"commands": {
|
"commands": {
|
||||||
"lint": [["make", "lint"]],
|
"lint": [["make", "lint"]],
|
||||||
@@ -64,6 +61,11 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"services": {
|
"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": {
|
"test-svc": {
|
||||||
"program": "test-svc-comp",
|
"program": "test-svc-comp",
|
||||||
"description": "Test service",
|
"description": "Test service",
|
||||||
|
|||||||
@@ -31,14 +31,25 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
"programs": {
|
"programs": {
|
||||||
"test-tool": {
|
"test-tool": {
|
||||||
"description": "Test tool",
|
"description": "Test tool",
|
||||||
"behavior": "tool",
|
|
||||||
},
|
},
|
||||||
"test-daemon": {
|
"test-daemon": {
|
||||||
"description": "Test daemon program",
|
"description": "Test daemon program",
|
||||||
"behavior": "daemon",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"services": {
|
"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": {
|
"test-svc": {
|
||||||
"program": "test-svc-comp",
|
"program": "test-svc-comp",
|
||||||
"description": "Test service",
|
"description": "Test service",
|
||||||
|
|||||||
@@ -137,14 +137,24 @@ class CastleConfig:
|
|||||||
services: dict[str, ServiceSpec]
|
services: dict[str, ServiceSpec]
|
||||||
jobs: dict[str, JobSpec]
|
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
|
@property
|
||||||
def tools(self) -> dict[str, ProgramSpec]:
|
def tools(self) -> dict[str, ProgramSpec]:
|
||||||
"""Programs deployed as a PATH tool (a `runner: path` service) — derived
|
"""Programs deployed as a PATH tool — derived, not a stored label."""
|
||||||
from deployments, not the `behavior` label."""
|
return {k: v for k, v in self.programs.items() if self.behavior_of(k) == "tool"}
|
||||||
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}
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def frontends(self) -> dict[str, ProgramSpec]:
|
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():
|
for name, job_data in _load_resource_dir(root / "jobs").items():
|
||||||
jobs[name] = _parse_job(name, job_data)
|
jobs[name] = _parse_job(name, job_data)
|
||||||
|
|
||||||
return CastleConfig(
|
config = CastleConfig(
|
||||||
root=root,
|
root=root,
|
||||||
repo=repo_path,
|
repo=repo_path,
|
||||||
gateway=gateway,
|
gateway=gateway,
|
||||||
@@ -312,6 +322,11 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
|||||||
services=services,
|
services=services,
|
||||||
jobs=jobs,
|
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:
|
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:
|
def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict:
|
||||||
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
|
"""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)
|
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
|
||||||
minimal = spec.model_dump(
|
minimal = spec.model_dump(
|
||||||
mode="json", exclude_none=True, exclude=exclude_fields, exclude_defaults=True
|
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
|
return None
|
||||||
|
|
||||||
result = merge(full, minimal)
|
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:
|
def _program_to_yaml_dict(spec: ProgramSpec, config: CastleConfig) -> dict:
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ from castle_core.generators.systemd import (
|
|||||||
unit_env_file,
|
unit_env_file,
|
||||||
unit_name,
|
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 (
|
from castle_core.registry import (
|
||||||
REGISTRY_PATH,
|
REGISTRY_PATH,
|
||||||
Deployment,
|
Deployment,
|
||||||
@@ -401,7 +401,7 @@ def _build_deployed_service(
|
|||||||
env=env,
|
env=env,
|
||||||
secret_env_keys=sorted(secret_env),
|
secret_env_keys=sorted(secret_env),
|
||||||
description=_resolve_description(config, svc),
|
description=_resolve_description(config, svc),
|
||||||
behavior="daemon",
|
behavior=behavior_for_runner(run.runner),
|
||||||
stack=stack,
|
stack=stack,
|
||||||
port=port,
|
port=port,
|
||||||
health_path=health_path,
|
health_path=health_path,
|
||||||
|
|||||||
@@ -139,6 +139,17 @@ def manager_for(runner: str) -> str:
|
|||||||
return _RUNNER_MANAGER.get(runner, "systemd")
|
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
|
# Systemd management
|
||||||
# ---------------------
|
# ---------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user