Refactor component terminology to programs in config and manifest
- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications. - Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks. - Adjusted tests to reflect the new program structure and ensure proper functionality. - Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
This commit is contained in:
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
||||
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||
|
||||
|
||||
def find_castle_root() -> Path:
|
||||
@@ -47,25 +47,25 @@ class CastleConfig:
|
||||
|
||||
root: Path
|
||||
gateway: GatewayConfig
|
||||
components: dict[str, ComponentSpec]
|
||||
programs: dict[str, ProgramSpec]
|
||||
services: dict[str, ServiceSpec]
|
||||
jobs: dict[str, JobSpec]
|
||||
|
||||
@property
|
||||
def tools(self) -> dict[str, ComponentSpec]:
|
||||
"""Return components that are tools (have install.path or tool spec)."""
|
||||
def tools(self) -> dict[str, ProgramSpec]:
|
||||
"""Return programs that are tools (have install.path or tool spec)."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in self.components.items()
|
||||
for k, v in self.programs.items()
|
||||
if (v.install and v.install.path) or v.tool
|
||||
}
|
||||
|
||||
@property
|
||||
def frontends(self) -> dict[str, ComponentSpec]:
|
||||
"""Return components that are frontends (have build outputs)."""
|
||||
def frontends(self) -> dict[str, ProgramSpec]:
|
||||
"""Return programs that are frontends (have build outputs)."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in self.components.items()
|
||||
for k, v in self.programs.items()
|
||||
if v.build and (v.build.outputs or v.build.commands)
|
||||
}
|
||||
|
||||
@@ -94,11 +94,11 @@ def _read_secret(name: str) -> str:
|
||||
return f"<MISSING_SECRET:{name}>"
|
||||
|
||||
|
||||
def _parse_component(name: str, data: dict) -> ComponentSpec:
|
||||
"""Parse a components: entry into a ComponentSpec."""
|
||||
def _parse_program(name: str, data: dict) -> ProgramSpec:
|
||||
"""Parse a programs: entry into a ProgramSpec."""
|
||||
data_copy = dict(data)
|
||||
data_copy["id"] = name
|
||||
return ComponentSpec.model_validate(data_copy)
|
||||
return ProgramSpec.model_validate(data_copy)
|
||||
|
||||
|
||||
def _parse_service(name: str, data: dict) -> ServiceSpec:
|
||||
@@ -130,9 +130,11 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
||||
gateway_data = data.get("gateway", {})
|
||||
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
|
||||
|
||||
components: dict[str, ComponentSpec] = {}
|
||||
for name, comp_data in data.get("components", {}).items():
|
||||
components[name] = _parse_component(name, comp_data)
|
||||
programs: dict[str, ProgramSpec] = {}
|
||||
# Support both "programs:" and legacy "components:" key
|
||||
programs_data = data.get("programs") or data.get("components") or {}
|
||||
for name, comp_data in programs_data.items():
|
||||
programs[name] = _parse_program(name, comp_data)
|
||||
|
||||
services: dict[str, ServiceSpec] = {}
|
||||
for name, svc_data in data.get("services", {}).items():
|
||||
@@ -145,7 +147,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
||||
return CastleConfig(
|
||||
root=root,
|
||||
gateway=gateway,
|
||||
components=components,
|
||||
programs=programs,
|
||||
services=services,
|
||||
jobs=jobs,
|
||||
)
|
||||
@@ -187,7 +189,7 @@ _STRUCTURAL_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _spec_to_yaml_dict(spec: ComponentSpec | ServiceSpec | JobSpec) -> dict:
|
||||
def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict:
|
||||
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
|
||||
exclude_fields = {"id"}
|
||||
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
|
||||
@@ -228,10 +230,10 @@ def save_config(config: CastleConfig) -> None:
|
||||
"""Save castle configuration to castle.yaml."""
|
||||
data: dict = {"gateway": {"port": config.gateway.port}}
|
||||
|
||||
if config.components:
|
||||
data["components"] = {}
|
||||
for name, spec in config.components.items():
|
||||
data["components"][name] = _spec_to_yaml_dict(spec)
|
||||
if config.programs:
|
||||
data["programs"] = {}
|
||||
for name, spec in config.programs.items():
|
||||
data["programs"][name] = _spec_to_yaml_dict(spec)
|
||||
|
||||
if config.services:
|
||||
data["services"] = {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Castle manifest models — component specs, service specs, job specs."""
|
||||
"""Castle manifest models — program specs, service specs, job specs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -197,11 +197,11 @@ class DefaultsSpec(BaseModel):
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Component spec — software identity
|
||||
# Program spec — software identity
|
||||
# ---------------------
|
||||
|
||||
|
||||
class ComponentSpec(BaseModel):
|
||||
class ProgramSpec(BaseModel):
|
||||
"""Software catalog entry — what exists."""
|
||||
|
||||
id: str = ""
|
||||
|
||||
260
core/src/castle_core/stacks.py
Normal file
260
core/src/castle_core/stacks.py
Normal file
@@ -0,0 +1,260 @@
|
||||
"""Stack protocol — lifecycle actions for each development stack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.config import STATIC_DIR
|
||||
from castle_core.manifest import ProgramSpec
|
||||
|
||||
DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
|
||||
INSTALL_ACTIONS = ["install", "uninstall"]
|
||||
ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS
|
||||
|
||||
# User-local tool directories that may not be on the systemd service PATH.
|
||||
_EXTRA_PATH_DIRS = [
|
||||
Path.home() / ".local" / "share" / "pnpm",
|
||||
Path.home() / ".local" / "bin",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionResult:
|
||||
"""Result of a stack lifecycle action."""
|
||||
|
||||
component: str
|
||||
action: str
|
||||
status: str # "ok" | "error"
|
||||
output: str = ""
|
||||
|
||||
|
||||
def _build_env() -> dict[str, str]:
|
||||
"""Build a subprocess env with user tool dirs on PATH."""
|
||||
env = os.environ.copy()
|
||||
extra = ":".join(str(d) for d in _EXTRA_PATH_DIRS if d.exists())
|
||||
if extra:
|
||||
env["PATH"] = extra + ":" + env.get("PATH", "")
|
||||
return env
|
||||
|
||||
|
||||
async def _run(cmd: list[str], cwd: Path) -> tuple[int, str]:
|
||||
"""Run a subprocess and return (returncode, combined output)."""
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=cwd,
|
||||
env=_build_env(),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
return proc.returncode or 0, (stdout or b"").decode()
|
||||
|
||||
|
||||
def _source_dir(comp: ProgramSpec, root: Path) -> Path:
|
||||
"""Resolve source directory, raising ValueError if absent."""
|
||||
if not comp.source:
|
||||
raise ValueError("No source directory")
|
||||
return root / comp.source
|
||||
|
||||
|
||||
class StackHandler:
|
||||
"""Base class — subclasses implement each lifecycle action."""
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Composite: lint + type-check + test. Runs all, reports first failure."""
|
||||
for action_fn, action_name in [
|
||||
(self.lint, "lint"),
|
||||
(self.type_check, "type-check"),
|
||||
(self.test, "test"),
|
||||
]:
|
||||
result = await action_fn(name, comp, root)
|
||||
if result.status != "ok":
|
||||
return ActionResult(
|
||||
component=name,
|
||||
action="check",
|
||||
status="error",
|
||||
output=f"{action_name} failed:\n{result.output}",
|
||||
)
|
||||
return ActionResult(component=name, action="check", status="ok")
|
||||
|
||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PythonHandler(StackHandler):
|
||||
"""Handler for python-cli and python-fastapi stacks."""
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["uv", "sync"], src)
|
||||
return ActionResult(
|
||||
component=name, action="build", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
if not (src / "tests").exists():
|
||||
return ActionResult(
|
||||
component=name, action="test", status="ok",
|
||||
output="No tests directory found, skipping.",
|
||||
)
|
||||
rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src)
|
||||
return ActionResult(
|
||||
component=name, action="test", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["uv", "run", "ruff", "check", "."], src)
|
||||
return ActionResult(
|
||||
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["uv", "run", "pyright"], src)
|
||||
return ActionResult(
|
||||
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(
|
||||
["uv", "tool", "install", "--editable", str(src), "--force"], src
|
||||
)
|
||||
return ActionResult(
|
||||
component=name, action="install", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
pkg_name = src.name
|
||||
pyproject = src / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
with open(pyproject, "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
pkg_name = data.get("project", {}).get("name", pkg_name)
|
||||
rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src)
|
||||
return ActionResult(
|
||||
component=name, action="uninstall", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
|
||||
class ReactViteHandler(StackHandler):
|
||||
"""Handler for react-vite stack."""
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["pnpm", "build"], src)
|
||||
return ActionResult(
|
||||
component=name, action="build", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["pnpm", "test"], src)
|
||||
return ActionResult(
|
||||
component=name, action="test", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["pnpm", "lint"], src)
|
||||
return ActionResult(
|
||||
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["pnpm", "type-check"], src)
|
||||
return ActionResult(
|
||||
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
|
||||
)
|
||||
|
||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Build and copy static assets to ~/.castle/static/{name}/."""
|
||||
result = await self.build(name, comp, root)
|
||||
if result.status != "ok":
|
||||
return ActionResult(
|
||||
component=name, action="install", status="error",
|
||||
output=f"Build failed:\n{result.output}",
|
||||
)
|
||||
|
||||
src = _source_dir(comp, root)
|
||||
outputs = comp.build.outputs if comp.build else []
|
||||
if not outputs:
|
||||
return ActionResult(
|
||||
component=name, action="install", status="error",
|
||||
output="No build outputs configured.",
|
||||
)
|
||||
|
||||
for output_dir in outputs:
|
||||
src_path = src / output_dir
|
||||
if src_path.exists():
|
||||
dest = STATIC_DIR / name
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
shutil.copytree(src_path, dest)
|
||||
|
||||
return ActionResult(
|
||||
component=name, action="install", status="ok",
|
||||
output=f"Built and deployed to {STATIC_DIR / name}",
|
||||
)
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Remove static assets from ~/.castle/static/{name}/."""
|
||||
dest = STATIC_DIR / name
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
return ActionResult(
|
||||
component=name, action="uninstall", status="ok",
|
||||
output=f"Removed {dest}",
|
||||
)
|
||||
return ActionResult(
|
||||
component=name, action="uninstall", status="ok",
|
||||
output=f"Nothing to remove ({dest} does not exist)",
|
||||
)
|
||||
|
||||
|
||||
HANDLERS: dict[str, StackHandler] = {
|
||||
"python-cli": PythonHandler(),
|
||||
"python-fastapi": PythonHandler(),
|
||||
"react-vite": ReactViteHandler(),
|
||||
}
|
||||
|
||||
|
||||
def get_handler(stack: str | None) -> StackHandler | None:
|
||||
"""Get the handler for a given stack, or None if unsupported."""
|
||||
if stack is None:
|
||||
return None
|
||||
return HANDLERS.get(stack)
|
||||
|
||||
|
||||
def available_actions(comp: ProgramSpec) -> list[str]:
|
||||
"""Return the list of actions available for a program."""
|
||||
if not comp.source:
|
||||
return []
|
||||
handler = get_handler(comp.stack)
|
||||
if handler is None:
|
||||
return []
|
||||
return list(ALL_ACTIONS)
|
||||
@@ -11,7 +11,7 @@ from castle_core.config import (
|
||||
resolve_env_vars,
|
||||
save_config,
|
||||
)
|
||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
||||
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
@@ -22,14 +22,14 @@ class TestLoadConfig:
|
||||
config = load_config(castle_root)
|
||||
assert isinstance(config, CastleConfig)
|
||||
assert config.gateway.port == 18000
|
||||
assert "test-tool" in config.components
|
||||
assert "test-tool" in config.programs
|
||||
assert "test-svc" in config.services
|
||||
assert "test-job" in config.jobs
|
||||
|
||||
def test_load_produces_typed_specs(self, castle_root: Path) -> None:
|
||||
"""Each section produces the correct spec type."""
|
||||
config = load_config(castle_root)
|
||||
assert isinstance(config.components["test-tool"], ComponentSpec)
|
||||
assert isinstance(config.programs["test-tool"], ProgramSpec)
|
||||
assert isinstance(config.services["test-svc"], ServiceSpec)
|
||||
assert isinstance(config.jobs["test-job"], JobSpec)
|
||||
|
||||
@@ -86,21 +86,21 @@ class TestSaveConfig:
|
||||
config2 = load_config(castle_root)
|
||||
|
||||
assert config2.gateway.port == config.gateway.port
|
||||
assert set(config2.components.keys()) == set(config.components.keys())
|
||||
assert set(config2.programs.keys()) == set(config.programs.keys())
|
||||
assert set(config2.services.keys()) == set(config.services.keys())
|
||||
assert set(config2.jobs.keys()) == set(config.jobs.keys())
|
||||
|
||||
def test_save_adds_component(self, castle_root: Path) -> None:
|
||||
"""Adding a component and saving persists it."""
|
||||
config = load_config(castle_root)
|
||||
config.components["new-lib"] = ComponentSpec(
|
||||
config.programs["new-lib"] = ProgramSpec(
|
||||
id="new-lib", description="A new library"
|
||||
)
|
||||
save_config(config)
|
||||
|
||||
config2 = load_config(castle_root)
|
||||
assert "new-lib" in config2.components
|
||||
assert config2.components["new-lib"].description == "A new library"
|
||||
assert "new-lib" in config2.programs
|
||||
assert config2.programs["new-lib"].description == "A new library"
|
||||
|
||||
def test_preserves_manage_systemd(self, castle_root: Path) -> None:
|
||||
"""Roundtrip preserves manage.systemd even with all defaults."""
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from castle_core.manifest import (
|
||||
BuildSpec,
|
||||
CaddySpec,
|
||||
ComponentSpec,
|
||||
ProgramSpec,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
@@ -24,12 +24,12 @@ from castle_core.manifest import (
|
||||
)
|
||||
|
||||
|
||||
class TestComponentSpec:
|
||||
class TestProgramSpec:
|
||||
"""Tests for component (software catalog) model."""
|
||||
|
||||
def test_minimal(self) -> None:
|
||||
"""Minimal component just needs an id."""
|
||||
c = ComponentSpec(id="bare")
|
||||
c = ProgramSpec(id="bare")
|
||||
assert c.description is None
|
||||
assert c.source is None
|
||||
assert c.install is None
|
||||
@@ -38,7 +38,7 @@ class TestComponentSpec:
|
||||
|
||||
def test_tool_component(self) -> None:
|
||||
"""Component with tool and install specs."""
|
||||
c = ComponentSpec(
|
||||
c = ProgramSpec(
|
||||
id="my-tool",
|
||||
description="A tool",
|
||||
source="my-tool/",
|
||||
@@ -50,7 +50,7 @@ class TestComponentSpec:
|
||||
|
||||
def test_frontend_component(self) -> None:
|
||||
"""Component with build spec."""
|
||||
c = ComponentSpec(
|
||||
c = ProgramSpec(
|
||||
id="my-app",
|
||||
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
|
||||
)
|
||||
@@ -58,12 +58,12 @@ class TestComponentSpec:
|
||||
|
||||
def test_source_dir_from_source(self) -> None:
|
||||
"""source_dir uses source field."""
|
||||
c = ComponentSpec(id="x", source="components/x/")
|
||||
c = ProgramSpec(id="x", source="components/x/")
|
||||
assert c.source_dir == "components/x"
|
||||
|
||||
def test_source_dir_none(self) -> None:
|
||||
"""source_dir returns None when no source available."""
|
||||
c = ComponentSpec(id="x")
|
||||
c = ProgramSpec(id="x")
|
||||
assert c.source_dir is None
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ class TestModelSerialization:
|
||||
|
||||
def test_dump_component_excludes_none(self) -> None:
|
||||
"""model_dump with exclude_none drops None fields."""
|
||||
c = ComponentSpec(id="test", description="Test")
|
||||
c = ProgramSpec(id="test", description="Test")
|
||||
data = c.model_dump(exclude_none=True, exclude={"id"})
|
||||
assert "description" in data
|
||||
assert "install" not in data
|
||||
|
||||
Reference in New Issue
Block a user