refactor: Update runner specifications from 'python_uv_tool' to 'python' across components and documentation

This commit is contained in:
2026-02-22 23:56:37 -08:00
parent 73e4a2ba00
commit 5e3e01a5b6
23 changed files with 539 additions and 124 deletions

View File

@@ -65,13 +65,10 @@ gateway:
components: components:
central-context: central-context:
description: Content storage API description: Content storage API
source: components/central-context
run: run:
runner: python_uv_tool runner: python
tool: central-context tool: central-context
working_dir: components/central-context
env:
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
CENTRAL_CONTEXT_PORT: "9001"
expose: expose:
http: http:
internal: { port: 9001 } internal: { port: 9001 }
@@ -80,8 +77,30 @@ components:
caddy: { path_prefix: /central-context } caddy: { path_prefix: /central-context }
manage: manage:
systemd: {} systemd: {}
notification-bridge:
description: Desktop notification forwarder
source: components/notification-bridge
run:
runner: python
tool: notification-bridge
defaults:
env:
CENTRAL_CONTEXT_URL: http://localhost:9001
BUCKET_NAME: notifications
expose:
http:
internal: { port: 9002 }
health_path: /health
proxy:
caddy: { path_prefix: /notifications }
manage:
systemd: {}
``` ```
Convention-based env vars (`<PREFIX>_DATA_DIR`, `<PREFIX>_PORT`) are generated
automatically by `castle deploy`. Only non-convention values need `defaults.env`.
## Architecture ## Architecture
``` ```

View File

@@ -1,10 +1,7 @@
# TO DO # TO DO
- Remove devbox-connect. Instead, make it easy to copy an ssh tunnel command to expose all ports of all services to a remote box: `ssh -L 9000:localhost:9000 payne@dev.payne.io, etc. - Remove devbox-connect. Instead, make it easy to copy an ssh tunnel command to expose all ports of all services to a remote box: `ssh -L 9000:localhost:9000 payne@dev.payne.io, etc.
- tool.uv.sources should be in cli, not in castle-api
- Add a scripts dir? - Add a scripts dir?
- Maybe there's no real reason to have special handling for the `tools` dir (one md file per tool, put in categories, etc.). On the one hand, the categories help them share dependencies. On the other, there's not really a need to share dependencies because uv does just fine. Also, they prob don't need markdown files because their description can just be in the --help arg, and in the `castle.yaml` registration. Having them flat would allow us to just think of everything as a tool: a bash script tool, a python tool, a rust tool. They're all things just follow a std-in std-out pattern so they are unix-philosophy good. Daemons otoh, follow daemon patterns (env config, logging, long-running, port mapping, etc.)
- tools: use std-in/std-out unix philosophy - tools: use std-in/std-out unix philosophy
- daemons or web-services: expose ports - daemons or web-services: expose ports

View File

@@ -4,7 +4,7 @@ import { Plus, X } from "lucide-react"
const TEMPLATES: Record<string, Record<string, unknown>> = { const TEMPLATES: Record<string, Record<string, unknown>> = {
service: { service: {
run: { run: {
runner: "python_uv_tool", runner: "python",
tool: "", tool: "",
cwd: "", cwd: "",
env: {}, env: {},

View File

@@ -1,7 +1,6 @@
export const RUNNER_LABELS: Record<string, string> = { export const RUNNER_LABELS: Record<string, string> = {
python_uv_tool: "Python (uv)", python: "Python",
command: "Command", command: "Command",
python_module: "Python module",
container: "Container", container: "Container",
node: "Node.js", node: "Node.js",
remote: "Remote", remote: "Remote",

View File

@@ -95,7 +95,7 @@ def _summary_from_manifest(name: str, manifest: object, root: Path) -> Component
if runner is None and manifest.tool and manifest.tool.source: if runner is None and manifest.tool and manifest.tool.source:
source_dir = root / manifest.tool.source source_dir = root / manifest.tool.source
if (source_dir / "pyproject.toml").exists(): if (source_dir / "pyproject.toml").exists():
runner = "python_uv_tool" runner = "python"
elif source_dir.is_file(): elif source_dir.is_file():
runner = "command" runner = "command"

View File

@@ -30,7 +30,7 @@ def _tool_summary(
if runner is None and t.source and root: if runner is None and t.source and root:
source_dir = root / t.source source_dir = root / t.source
if (source_dir / "pyproject.toml").exists(): if (source_dir / "pyproject.toml").exists():
runner = "python_uv_tool" runner = "python"
elif source_dir.is_file(): elif source_dir.is_file():
runner = "command" runner = "command"

View File

@@ -28,7 +28,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"description": "Test service", "description": "Test service",
"source": "test-svc", "source": "test-svc",
"run": { "run": {
"runner": "python_uv_tool", "runner": "python",
"tool": "test-svc", "tool": "test-svc",
}, },
"expose": { "expose": {
@@ -73,7 +73,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
), ),
deployed={ deployed={
"test-svc": DeployedComponent( "test-svc": DeployedComponent(
runner="python_uv_tool", runner="python",
run_cmd=["uv", "run", "test-svc"], run_cmd=["uv", "run", "test-svc"],
env={ env={
"TEST_SVC_PORT": "19000", "TEST_SVC_PORT": "19000",

View File

@@ -55,7 +55,7 @@ class TestComponentDetail:
data = response.json() data = response.json()
assert data["id"] == "test-svc" assert data["id"] == "test-svc"
assert "manifest" in data assert "manifest" in data
assert data["manifest"]["runner"] == "python_uv_tool" assert data["manifest"]["runner"] == "python"
def test_not_found(self, client: TestClient) -> None: def test_not_found(self, client: TestClient) -> None:
"""Returns 404 for unknown component.""" """Returns 404 for unknown component."""

View File

@@ -26,7 +26,7 @@ components:
on the LAN on the LAN
source: components/central-context source: components/central-context
run: run:
runner: python_uv_tool runner: python
tool: central-context tool: central-context
manage: manage:
systemd: {} systemd: {}
@@ -44,7 +44,7 @@ components:
server. server.
source: components/notification-bridge source: components/notification-bridge
run: run:
runner: python_uv_tool runner: python
tool: notification-bridge tool: notification-bridge
defaults: defaults:
env: env:
@@ -65,7 +65,7 @@ components:
description: Castle API description: Castle API
source: castle-api source: castle-api
run: run:
runner: python_uv_tool runner: python
tool: castle-api tool: castle-api
manage: manage:
systemd: {} systemd: {}
@@ -98,8 +98,6 @@ components:
install: install:
path: path:
alias: protonmail alias: protonmail
tool:
source: components/protonmail/
backup-collect: backup-collect:
description: Collect files from various sources into backup directory description: Collect files from various sources into backup directory
source: components/backup-collect source: components/backup-collect
@@ -117,7 +115,6 @@ components:
manage: manage:
systemd: {} systemd: {}
tool: tool:
source: components/backup-collect/
system_dependencies: system_dependencies:
- rsync - rsync
backup-data: backup-data:
@@ -136,7 +133,6 @@ components:
timezone: America/Los_Angeles timezone: America/Los_Angeles
manage: manage:
systemd: {} systemd: {}
tool: {}
castle-app: castle-app:
description: Castle web app description: Castle web app
source: app source: app
@@ -152,16 +148,12 @@ components:
install: install:
path: path:
alias: devbox-connect alias: devbox-connect
tool:
source: components/devbox-connect/
mbox2eml: mbox2eml:
description: MBOX to EML email converter description: MBOX to EML email converter
source: components/mbox2eml source: components/mbox2eml
install: install:
path: path:
alias: mbox2eml alias: mbox2eml
tool:
source: components/mbox2eml/
android-backup: android-backup:
description: Backup Android device using ADB description: Backup Android device using ADB
source: components/android-backup source: components/android-backup
@@ -169,7 +161,6 @@ components:
path: path:
alias: android-backup alias: android-backup
tool: tool:
source: components/android-backup/
system_dependencies: system_dependencies:
- adb - adb
browser: browser:
@@ -178,8 +169,6 @@ components:
install: install:
path: path:
alias: browser alias: browser
tool:
source: components/browser/
docx-extractor: docx-extractor:
description: Extract content and metadata from Word .docx files description: Extract content and metadata from Word .docx files
source: components/docx-extractor source: components/docx-extractor
@@ -187,7 +176,6 @@ components:
path: path:
alias: docx-extractor alias: docx-extractor
tool: tool:
source: components/docx-extractor/
system_dependencies: system_dependencies:
- pandoc - pandoc
docx2md: docx2md:
@@ -197,7 +185,6 @@ components:
path: path:
alias: docx2md alias: docx2md
tool: tool:
source: components/docx2md/
system_dependencies: system_dependencies:
- pandoc - pandoc
gpt: gpt:
@@ -206,16 +193,12 @@ components:
install: install:
path: path:
alias: gpt alias: gpt
tool:
source: components/gpt/
html2text: html2text:
description: Convert HTML content to plain text description: Convert HTML content to plain text
source: components/html2text source: components/html2text
install: install:
path: path:
alias: html2text alias: html2text
tool:
source: components/html2text/
md2pdf: md2pdf:
description: Convert Markdown files to PDF description: Convert Markdown files to PDF
source: components/md2pdf source: components/md2pdf
@@ -223,7 +206,6 @@ components:
path: path:
alias: md2pdf alias: md2pdf
tool: tool:
source: components/md2pdf/
system_dependencies: system_dependencies:
- pandoc - pandoc
- texlive-latex-base - texlive-latex-base
@@ -233,16 +215,12 @@ components:
install: install:
path: path:
alias: mdscraper alias: mdscraper
tool:
source: components/mdscraper/
pdf-extractor: pdf-extractor:
description: Extract content and metadata from PDF files description: Extract content and metadata from PDF files
source: components/pdf-extractor source: components/pdf-extractor
install: install:
path: path:
alias: pdf-extractor alias: pdf-extractor
tool:
source: components/pdf-extractor/
pdf2md: pdf2md:
description: Convert PDF files to Markdown description: Convert PDF files to Markdown
source: components/pdf2md source: components/pdf2md
@@ -250,7 +228,6 @@ components:
path: path:
alias: pdf2md alias: pdf2md
tool: tool:
source: components/pdf2md/
system_dependencies: system_dependencies:
- pandoc - pandoc
- poppler-utils - poppler-utils
@@ -260,21 +237,15 @@ components:
install: install:
path: path:
alias: schedule alias: schedule
tool:
source: components/schedule/
search: search:
description: Manage self-contained searchable collections of files description: Manage self-contained searchable collections of files
source: components/search source: components/search
install: install:
path: path:
alias: search alias: search
tool:
source: components/search/
text-extractor: text-extractor:
description: Extract content and metadata from text files description: Extract content and metadata from text files
source: components/text-extractor source: components/text-extractor
install: install:
path: path:
alias: text-extractor alias: text-extractor
tool:
source: components/text-extractor/

View File

@@ -15,7 +15,7 @@ from castle_cli.manifest import (
ManageSpec, ManageSpec,
PathInstallSpec, PathInstallSpec,
ProxySpec, ProxySpec,
RunPythonUvTool, RunPython,
SystemdSpec, SystemdSpec,
ToolSpec, ToolSpec,
) )
@@ -78,8 +78,8 @@ def run_create(args: argparse.Namespace) -> int:
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {proj_type}",
source=f"components/{name}", source=f"components/{name}",
run=RunPythonUvTool( run=RunPython(
runner="python_uv_tool", runner="python",
tool=name, tool=name,
), ),
expose=ExposeSpec( expose=ExposeSpec(

View File

@@ -174,7 +174,7 @@ def _build_deployed(
def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]: def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
"""Build a run command list from a RunSpec.""" """Build a run command list from a RunSpec."""
match run.runner: match run.runner:
case "python_uv_tool": case "python":
resolved = shutil.which(run.tool) resolved = shutil.which(run.tool)
if not resolved: if not resolved:
print( print(
@@ -185,12 +185,6 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
if run.args: if run.args:
cmd.extend(run.args) cmd.extend(run.args)
return cmd return cmd
case "python_module":
python = run.python or shutil.which("python3") or "python3"
cmd = [python, "-m", run.module]
if run.args:
cmd.extend(run.args)
return cmd
case "command": case "command":
cmd = list(run.argv) cmd = list(run.argv)
resolved = shutil.which(cmd[0]) resolved = shutil.which(cmd[0])

View File

@@ -22,7 +22,7 @@ def _sync_cmd(manifest: ComponentManifest) -> list[str] | None:
return None return None
match run.runner: match run.runner:
case "python_uv_tool" | "python_module": case "python":
return ["uv", "sync"] return ["uv", "sync"]
case "node": case "node":
return [run.package_manager, "install"] return [run.package_manager, "install"]
@@ -80,13 +80,14 @@ def run_sync(args: argparse.Namespace) -> int:
installed_dirs: set[Path] = set() installed_dirs: set[Path] = set()
for name, manifest in config.components.items(): for name, manifest in config.components.items():
# Determine source directory — from tool.source or manifest.source # Install if: has install.path, or is a python runner service
source = None if not (
if manifest.tool and manifest.tool.source: (manifest.install and manifest.install.path)
source = manifest.tool.source or (manifest.run and manifest.run.runner == "python")
elif manifest.run and manifest.run.runner == "python_uv_tool" and manifest.source_dir: ):
source = manifest.source_dir continue
source = manifest.source_dir
if not source: if not source:
continue continue

View File

@@ -23,8 +23,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
RunCommand, RunCommand,
RunContainer, RunContainer,
RunNode, RunNode,
RunPythonModule, RunPython,
RunPythonUvTool,
RunRemote, RunRemote,
RunSpec, RunSpec,
SystemdSpec, SystemdSpec,

View File

@@ -20,7 +20,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"description": "Test service", "description": "Test service",
"source": "test-svc", "source": "test-svc",
"run": { "run": {
"runner": "python_uv_tool", "runner": "python",
"tool": "test-svc", "tool": "test-svc",
}, },
"defaults": { "defaults": {

View File

@@ -88,20 +88,13 @@ def manifest_to_exec_start(manifest: ComponentManifest, root: Path) -> str:
raise ValueError(f"Component '{manifest.id}' has no run spec") raise ValueError(f"Component '{manifest.id}' has no run spec")
match run.runner: match run.runner:
case "python_uv_tool": case "python":
uv_path = shutil.which("uv") or "uv" uv_path = shutil.which("uv") or "uv"
args_str = " ".join(run.args) if run.args else "" args_str = " ".join(run.args) if run.args else ""
cmd = f"{uv_path} run {run.tool}" cmd = f"{uv_path} run {run.tool}"
if args_str: if args_str:
cmd += f" {args_str}" cmd += f" {args_str}"
return cmd return cmd
case "python_module":
python = run.python or shutil.which("python3") or "python3"
args_str = " ".join(run.args) if run.args else ""
cmd = f"{python} -m {run.module}"
if args_str:
cmd += f" {args_str}"
return cmd
case "command": case "command":
argv = list(run.argv) argv = list(run.argv)
resolved = shutil.which(argv[0]) resolved = shutil.which(argv[0])

View File

@@ -46,15 +46,8 @@ class RunCommand(RunBase):
argv: list[str] = Field(min_length=1) argv: list[str] = Field(min_length=1)
class RunPythonModule(RunBase): class RunPython(RunBase):
runner: Literal["python_module"] runner: Literal["python"]
module: str
args: list[str] = Field(default_factory=list)
python: str | None = None
class RunPythonUvTool(RunBase):
runner: Literal["python_uv_tool"]
tool: str tool: str
args: list[str] = Field(default_factory=list) args: list[str] = Field(default_factory=list)
@@ -84,9 +77,7 @@ class RunRemote(RunBase):
RunSpec = Annotated[ RunSpec = Annotated[
Union[ Union[RunCommand, RunPython, RunContainer, RunNode, RunRemote],
RunCommand, RunPythonModule, RunPythonUvTool, RunContainer, RunNode, RunRemote
],
Field(discriminator="runner"), Field(discriminator="runner"),
] ]

View File

@@ -20,7 +20,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"description": "Test service", "description": "Test service",
"source": "test-svc", "source": "test-svc",
"run": { "run": {
"runner": "python_uv_tool", "runner": "python",
"tool": "test-svc", "tool": "test-svc",
}, },
"defaults": { "defaults": {

View File

@@ -60,7 +60,7 @@ class TestLoadConfig:
"""Service has correct RunSpec.""" """Service has correct RunSpec."""
config = load_config(castle_root) config = load_config(castle_root)
svc = config.components["test-svc"] svc = config.components["test-svc"]
assert svc.run.runner == "python_uv_tool" assert svc.run.runner == "python"
assert svc.run.tool == "test-svc" assert svc.run.tool == "test-svc"
assert svc.source == "test-svc" assert svc.source == "test-svc"

View File

@@ -17,7 +17,7 @@ from castle_core.manifest import (
Role, Role,
RunCommand, RunCommand,
RunContainer, RunContainer,
RunPythonUvTool, RunPython,
RunRemote, RunRemote,
SystemdSpec, SystemdSpec,
ToolSpec, ToolSpec,
@@ -32,7 +32,7 @@ class TestRoleDerivation:
"""Component with expose.http gets SERVICE role.""" """Component with expose.http gets SERVICE role."""
m = ComponentManifest( m = ComponentManifest(
id="svc", id="svc",
run=RunPythonUvTool(runner="python_uv_tool", tool="svc"), run=RunPython(runner="python", tool="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))), expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
) )
assert Role.SERVICE in m.roles assert Role.SERVICE in m.roles
@@ -114,7 +114,7 @@ class TestRoleDerivation:
"""Component can have multiple roles.""" """Component can have multiple roles."""
m = ComponentManifest( m = ComponentManifest(
id="multi", id="multi",
run=RunPythonUvTool(runner="python_uv_tool", tool="multi"), run=RunPython(runner="python", tool="multi"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))), expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
install=InstallSpec(path=PathInstallSpec(alias="multi")), install=InstallSpec(path=PathInstallSpec(alias="multi")),
) )
@@ -125,7 +125,7 @@ class TestRoleDerivation:
"""Systemd + HTTP = SERVICE, not WORKER.""" """Systemd + HTTP = SERVICE, not WORKER."""
m = ComponentManifest( m = ComponentManifest(
id="svc", id="svc",
run=RunPythonUvTool(runner="python_uv_tool", tool="svc"), run=RunPython(runner="python", tool="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))), expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
) )
@@ -170,7 +170,7 @@ class TestModelSerialization:
m = ComponentManifest( m = ComponentManifest(
id="svc", id="svc",
description="A service", description="A service",
run=RunPythonUvTool(runner="python_uv_tool", tool="svc", cwd="svc"), run=RunPython(runner="python", tool="svc", cwd="svc"),
expose=ExposeSpec( expose=ExposeSpec(
http=HttpExposeSpec( http=HttpExposeSpec(
internal=HttpInternal(port=9001), health_path="/health" internal=HttpInternal(port=9001), health_path="/health"
@@ -180,6 +180,6 @@ class TestModelSerialization:
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
) )
data = m.model_dump(exclude_none=True, exclude={"id", "roles"}) data = m.model_dump(exclude_none=True, exclude={"id", "roles"})
assert data["run"]["runner"] == "python_uv_tool" assert data["run"]["runner"] == "python"
assert data["expose"]["http"]["internal"]["port"] == 9001 assert data["expose"]["http"]["internal"]["port"] == 9001
assert data["proxy"]["caddy"]["path_prefix"] == "/svc" assert data["proxy"]["caddy"]["path_prefix"] == "/svc"

View File

@@ -55,7 +55,7 @@ class TestUnitGeneration:
assert "Restart=on-failure" in unit assert "Restart=on-failure" in unit
def test_uses_uv_run(self, castle_root: Path) -> None: def test_uses_uv_run(self, castle_root: Path) -> None:
"""Unit file ExecStart uses uv run for python_uv_tool.""" """Unit file ExecStart uses uv run for python runner."""
config = load_config(castle_root) config = load_config(castle_root)
manifest = config.components["test-svc"] manifest = config.components["test-svc"]
unit = generate_unit(config, "test-svc", manifest) unit = generate_unit(config, "test-svc", manifest)
@@ -68,7 +68,7 @@ class TestUnitFromDeployed:
def test_basic_service(self) -> None: def test_basic_service(self) -> None:
"""Generate a unit from a deployed component.""" """Generate a unit from a deployed component."""
deployed = DeployedComponent( deployed = DeployedComponent(
runner="python_uv_tool", runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/data/castle/my-svc"}, env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/data/castle/my-svc"},
description="My service", description="My service",
@@ -97,7 +97,7 @@ class TestUnitFromDeployed:
def test_no_repo_paths(self) -> None: def test_no_repo_paths(self) -> None:
"""Generated units must not reference repo paths.""" """Generated units must not reference repo paths."""
deployed = DeployedComponent( deployed = DeployedComponent(
runner="python_uv_tool", runner="python",
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"], run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
env={"DATA_DIR": "/data/castle/my-svc"}, env={"DATA_DIR": "/data/castle/my-svc"},
description="Test", description="Test",

View File

@@ -15,12 +15,8 @@ components:
my-service: my-service:
description: Does something useful description: Does something useful
run: run:
runner: python_uv_tool runner: python
tool: my-service tool: my-service
cwd: my-service
env:
MY_SERVICE_DATA_DIR: /data/castle/my-service
MY_SERVICE_PORT: "9001"
expose: expose:
http: http:
internal: { port: 9001 } internal: { port: 9001 }
@@ -37,26 +33,22 @@ Each component declares **what it does** through these optional blocks:
### `run` — How to start it ### `run` — How to start it
Discriminated union on `runner`: Discriminated union on `runner`. The runner encodes both the language/toolchain
(used by `castle sync`) and the deployment resolution (used by `castle deploy`):
| Runner | Use case | Key fields | | Runner | Sync | Deploy | Key fields |
|--------|----------|------------| |--------|------|--------|------------|
| `python_uv_tool` | Python service/tool via uv | `tool`, `cwd`, `env` | | `python` | `uv sync` | `which(tool)` → installed binary | `tool`, `args` |
| `command` | Shell command | `argv`, `cwd`, `env` | | `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
| `python_module` | Python -m invocation | `module`, `args`, `python` | | `container` | *(none)* | `podman run` | `image`, `command`, `ports`, `volumes` |
| `container` | Docker/Podman | `image`, `command`, `ports`, `volumes` | | `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
| `node` | Node.js script | `script`, `package_manager` (npm/pnpm/yarn) | | `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
| `remote` | External service | `base_url`, `health_url` |
**Services** use `python_uv_tool`: **Services** use `python`:
```yaml ```yaml
run: run:
runner: python_uv_tool runner: python
tool: my-service # name in [project.scripts] tool: my-service # name in [project.scripts]
cwd: my-service # working directory relative to repo root
env:
MY_SERVICE_DATA_DIR: /data/castle/my-service
MY_SERVICE_PORT: "9001"
``` ```
**Tools invoked by castle** (jobs, scheduled tasks) use `command`: **Tools invoked by castle** (jobs, scheduled tasks) use `command`:
@@ -298,7 +290,7 @@ and a `.timer` file.
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes: The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `ComponentManifest` — top-level model, has `roles` computed property - `ComponentManifest` — top-level model, has `roles` computed property
- `RunSpec` — discriminated union (RunPythonUvTool, RunCommand, etc.) - `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
- `TriggerSpec` — union (TriggerSchedule, TriggerManual, TriggerEvent, TriggerRequest) - `TriggerSpec` — union (TriggerSchedule, TriggerManual, TriggerEvent, TriggerRequest)
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec` - `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec`
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal` - `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`

451
docs/design.md Normal file
View File

@@ -0,0 +1,451 @@
# Castle Design
Castle is a personal software platform. It manages independent services,
tools, and frontends on a Linux machine using standard Unix primitives —
systemd for process supervision, Caddy for HTTP routing, the filesystem
for storage, and env vars for configuration. The `castle` CLI and API
provide a registry and coordination layer on top.
The long-term goal: multiple Castle nodes (machines) that discover each
other and coordinate, forming a personal infrastructure mesh. Each node
is self-sufficient. The mesh is optional.
## Principles
1. **Unix-native.** Use the OS. systemd, journald, filesystem, signals,
env vars, DNS. Don't reimplement what Linux already provides.
2. **Independence.** Components never depend on Castle. They accept
standard configuration (ports, data dirs, URLs) via env vars. A
Castle service is just a well-behaved Unix daemon that happens to
be registered in a manifest.
3. **Declare capabilities, derive roles.** Components say what they
do (expose HTTP, run on a schedule, install to PATH). Castle infers
what they are (service, job, tool, frontend). No role labels.
4. **Language-agnostic above the build line.** Below the build line,
every language is different (uv, pnpm, cargo, go). Above it,
everything is just processes, ports, files, and signals. Castle
operates above the line.
5. **Separate source from runtime.** The repo is for development. The
runtime lives in standard Unix locations (`~/.castle/`, `/data/castle/`,
systemd units). Nothing running should point into the source tree.
6. **AI-manageable.** The CLI and API exist so that AI assistants can
discover, create, and manage components programmatically. Humans
use the dashboard. Agents use the CLI and API.
7. **Simple until proven otherwise.** Filesystem over databases. HTTP
over custom protocols. Shell commands over plugin systems. Add
complexity only when the simple thing actually fails.
## Architecture Layers
```
┌─────────────────────────────────────────────┐
│ Coordination │
│ Node discovery, global registry, messaging │
├─────────────────────────────────────────────┤
│ Registry │
│ Component spec, node config, CLI, API │
├─────────────────────────────────────────────┤
│ Runtime │
│ systemd, Caddy, filesystem, journald │
├─────────────────────────────────────────────┤
│ Build │
│ uv, pnpm, cargo, go build, etc. │
└─────────────────────────────────────────────┘
```
The critical boundary is between Build and Runtime. Below it, each
language has its own toolchain. Above it, everything is uniform — a
process that reads env vars, listens on a port, logs to stdout, and
responds to SIGTERM.
### Build Layer
Transforms source code into runnable artifacts. Castle does not abstract
over language toolchains — it just records the build commands and their
outputs.
| Language | Toolchain | Artifact |
|----------|-----------|----------|
| Python | uv | Entry point in venv |
| Node/TS | pnpm | Static bundle (frontends) or node script |
| Rust | cargo | Binary |
| Go | go build | Binary |
Castle's `build` spec is intentionally minimal: a list of shell commands
and a list of output paths. This works for any language without Castle
needing to understand the toolchain.
For interpreted languages (Python, Node), Castle also needs to know the
runtime wrapper — how to invoke the artifact. This is what the `run`
spec's runner variants handle:
- `python` — Python (sync via uv, deploy resolves installed binary)
- `node` — Node.js (sync via pnpm/npm)
- `command` — Direct execution (compiled binaries, shell scripts)
- `container` — Docker/Podman
- `remote` — External service (no local process)
Compiled languages (Rust, Go) use `command` — once built, they're just
binaries. No Castle-specific runner needed.
### Runtime Layer
Manages running processes using standard Linux infrastructure.
**systemd** handles process supervision:
- Start/stop/restart services
- Restart-on-failure policies (OTP's "let it crash")
- Dependency ordering via `After=` / `Wants=`
- Scheduled execution via `.timer` units
- Logging via journald (stdout/stderr capture)
**Caddy** handles HTTP routing:
- Reverse proxy on port 9000
- Path-based routing to services (`/api` → port 9020)
- Static file serving for frontends
- TLS termination
**Filesystem** handles storage:
- Service data: `/data/castle/<name>/`
- Secrets: `~/.castle/secrets/`
- Generated config: `~/.castle/generated/`
Castle generates systemd unit files and Caddyfile entries from the
registry. It doesn't run a daemon itself — it configures OS-level
infrastructure and gets out of the way.
Critically, the runtime layer references only standard paths — never
the source tree. Systemd units point to installed binaries (on PATH
or in `~/.castle/bin/`), not to repo subdirectories. Caddy serves
from `~/.castle/static/`, not from build output directories in the repo.
### Registry Layer
The registry is the central concept in Castle. It tracks what components
exist, what they can do, and how they're configured. But it's not a
single thing — it's three distinct concepts:
**1. Component spec** — what a component *is*. Description, capabilities,
build instructions, default configuration. This is source-level
information, version-controlled in the repo. It answers: "what components
could exist?"
**2. Node config** — what's *deployed on this machine*, with what concrete
ports, data paths, and env vars. This is per-machine. Two Castle nodes
might run different subsets of components with different parameters. It
answers: "what's running here, and how?"
**3. Runtime state** — what's *actually happening*. PIDs, health, uptime,
logs. This is ephemeral, owned by systemd and queried on demand. It
answers: "is it working?"
#### Source vs. runtime split
These map to two files:
**`castle.yaml`** (in the repo, version-controlled) — Component specs:
```yaml
components:
central-context:
description: Content storage API
source: components/central-context
run:
runner: python
tool: central-context
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy:
caddy:
path_prefix: /central-context
manage:
systemd: {}
```
The spec says what the component *is* and what it *needs* — a port, a
data directory, HTTP exposure. Convention-based env vars (`<PREFIX>_PORT`,
`<PREFIX>_DATA_DIR`) are generated automatically during deploy. Only
non-convention values need `defaults.env`.
**`~/.castle/registry.yaml`** (per-node, not in the repo) — Node config:
```yaml
node:
hostname: tower
castle_root: /data/repos/castle
gateway_port: 9000
deployed:
central-context:
runner: python
run_cmd: [/home/user/.local/bin/central-context]
env:
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
CENTRAL_CONTEXT_PORT: "9001"
roles: [service]
port: 9001
health_path: /health
proxy_path: /central-context
managed: true
```
The node config says what's deployed *here* and with what concrete
values. `castle deploy` reads the spec from the repo, generates
convention-based env vars, resolves secrets, resolves binary paths,
and writes the registry. Systemd units and Caddyfile are then generated
from the registry — never from the spec directly.
This separation means:
- The repo is just a repo. `git pull` doesn't affect running services.
- Multi-node works: sync the spec + deploy on each node, no repo needed.
- The spec is portable and version-controlled. The node config is local.
- AI agents read the node registry to know what's deployed and running.
#### Interfaces
Three interfaces expose the registry:
- **CLI** (`castle`) — For AI agents and terminal users. Structured
output via `--json`. Commands for listing, inspecting, creating,
and managing components.
- **API** (`castle-api`) — For programmatic access over HTTP. Used by
the dashboard, other nodes, and remote agents.
- **Dashboard** (`castle-app`) — For human discoverability. Visual
overview of what's running, health status, logs.
### Coordination Layer
*Partially built. This section describes the target architecture.*
Coordination handles discovery and communication — both between
components on a single node and across multiple Castle nodes.
**Intra-node coordination** (current):
- Components find each other through the gateway (path-based routing)
or direct port access via env vars.
- The registry (CLI/API) provides discoverability.
- No service mesh or message broker required for basic operation.
**Inter-node coordination** (future):
- Each Castle node runs the API, which exposes its component registry.
- Nodes discover each other via MQTT retained messages or mDNS/DNS-SD
(Avahi) for LAN environments.
- The gateway on each node can proxy to services on other nodes,
preserving path-based routing. Components don't know which node
they're talking to.
- MQTT provides pub/sub messaging for events, status, and coordination
across nodes.
**Why MQTT over custom gossip:**
- Standard protocol, every language has a client library.
- Retained messages give new nodes an immediate view of the network.
- Topic-based routing maps naturally to `castle/{node}/{component}`.
- Works across networks (not just LAN like mDNS).
- Mosquitto is a single binary, simple to run as a Castle component.
**Why mDNS/DNS-SD as a complement:**
- Zero-config LAN discovery via Avahi (already on most Linux systems).
- Each node advertises `_castle._tcp` — standard tooling works
(`avahi-browse`).
- Good for bootstrapping: find the MQTT broker without hardcoding
its address.
## Component Contract
Every Castle component, regardless of language, must satisfy a minimal
contract. This is what makes the system uniform above the build line.
### Services (long-running daemons)
| Requirement | Mechanism |
|-------------|-----------|
| Accept configuration | Env vars (prefixed by service name) |
| Declare its port | Env var, registered in `expose.http.internal.port` |
| Health endpoint | `GET /health` returns 200 |
| Data storage | Read `*_DATA_DIR` env var, write there |
| Logging | stdout for output, stderr for errors |
| Graceful shutdown | Handle SIGTERM, exit cleanly |
| Secrets | Read from env vars (Castle resolves `${secret:NAME}`) |
| No Castle dependency | Must run standalone with just env vars set |
### Tools (CLI utilities)
| Requirement | Mechanism |
|-------------|-----------|
| Input | File argument or stdin |
| Output | stdout (pipeable) |
| Errors/status | stderr |
| Exit codes | 0 success, non-zero failure |
| No interactive prompts | Scriptable by default |
### Jobs (scheduled tasks)
Same contract as tools, plus:
| Requirement | Mechanism |
|-------------|-----------|
| Idempotent | Safe to re-run or run concurrently |
| Short-lived | Exit when done (oneshot systemd unit) |
## Component Lifecycle
The path from source to managed process:
```
source → [build] → artifact → [install] → available → [deploy] → managed
```
Each step is distinct:
1. **Build** — Language-specific. Produces an artifact (binary, venv
entry point, static bundle). Castle records the commands but doesn't
execute them implicitly.
2. **Install** — Makes the artifact available on the system. For tools:
`uv tool install` or compiled binary placed in `~/.castle/bin/`. For
services: same — the binary or entry point is on PATH or in a known
location. For frontends: built assets copied to `~/.castle/static/`.
3. **Deploy** — Materializes the runtime configuration. Reads the
component spec, merges with node config, generates systemd units
and Caddyfile entries that reference *installed* artifacts — never
the source tree. Enables and starts services.
For compiled languages (Rust, Go), build produces a standalone binary
and install is just placing it in `~/.castle/bin/`. For interpreted
languages (Python, Node), the runtime wrapper (uv, node) handles
finding the installed artifact.
## Runtime Filesystem Layout
What already exists and what the target looks like:
```
~/.castle/ ← Castle runtime home
├── registry.yaml ← Node config (what's deployed here)
├── generated/ ← Generated Caddyfile
│ └── Caddyfile
├── secrets/ ← Secret files (NAME → value)
│ └── PROTONMAIL_API_KEY
├── bin/ ← Compiled binaries, shims
│ └── my-go-tool
└── static/ ← Built frontend assets
└── castle-app/
└── dist/
/data/castle/ ← Persistent service data
└── <name>/
~/.config/systemd/user/ ← Systemd units (standard location)
├── castle-central-context.service
├── castle-protonmail.service
├── castle-protonmail.timer
└── ...
```
Source (the repo) is referenced only during build and install. Everything
the runtime touches lives in `~/.castle/`, `/data/castle/`, or standard
systemd paths.
## OTP as Design Guide
Castle's architecture parallels Erlang/OTP, mapped onto Unix:
| OTP Concept | Castle Equivalent |
|-------------|------------------|
| Application | Component (independent, self-contained) |
| Application resource file | Component spec in `castle.yaml` |
| Release config (sys.config) | Node config in `~/.castle/registry.yaml` |
| Release assembly | `castle deploy` (spec + node config → runtime) |
| Supervisor | systemd (restart policies, ordering) |
| Process | Running service/worker/job |
| Application env | Env vars |
| Node | A machine running Castle |
| epmd | mDNS / MQTT discovery |
| Distribution | Inter-node coordination via MQTT + gateway proxying |
| "Let it crash" | `restart: on-failure` in systemd |
| Global registry | Merged node registries via MQTT retained messages |
The mapping is conceptual, not literal. Castle doesn't implement OTP
semantics — it uses OTP's *thinking* to guide which Unix primitives
to compose and how.
Key OTP ideas that apply:
- **Isolation.** Components don't share state. Communication is
through explicit interfaces (HTTP, MQTT, filesystem paths).
- **Let it crash.** Services don't need elaborate error recovery.
systemd restarts them. Design for restartability, not immortality.
- **Supervision hierarchy.** systemd's dependency ordering provides
this. Services declare what they need to start after.
- **Location transparency.** Components talk to paths (`/api`,
`/central-context`), not to specific hosts or ports. The gateway
can remap these across nodes.
- **Spec vs. config.** In OTP, an application defines its structure
(the `.app` file) and a release provides the deployment config
(`sys.config`). Castle mirrors this: the component spec defines
structure, the node config provides deployment values.
## Current State
What exists today:
- **CLI** — `castle` command, installed via `uv tool install --editable cli/`
- **Three packages** — `castle-core` (models, config, generators),
`castle-cli` (commands), `castle-api` (HTTP API)
- **Source/runtime split** — `castle.yaml` (spec) → `castle deploy`
`~/.castle/registry.yaml` (node config). Systemd units and Caddyfile
generated from registry with fully resolved paths. No repo references
in runtime artifacts.
- **Convention-based env generation** — `castle deploy` auto-generates
`<PREFIX>_DATA_DIR=/data/castle/<name>` and `<PREFIX>_PORT` from
the manifest. Only non-convention values need `defaults.env`.
- **Gateway** — Caddy on port 9000, Caddyfile generated from registry
- **API** — `castle-api` on port 9020, reads from registry (optional
castle.yaml fallback for non-deployed components)
- **Dashboard** — `castle-app` React/Vite frontend, static assets
served from `~/.castle/static/castle-app/`
- **Services** — central-context (content storage), notification-bridge
(desktop notification forwarder)
- **Jobs** — protonmail (email sync every 5 min), backup-collect (nightly),
backup-data (nightly restic backup)
- **Tools** — ~15 CLI utilities (pdf2md, docx2md, search, gpt, etc.)
- **Manifest** — `castle.yaml` with typed Pydantic models
What doesn't exist yet:
- **Multi-language support** — Rust and Go components (the abstractions
support them via `command` runner, but no examples exist yet)
- **Inter-node coordination** — MQTT broker, node discovery, cross-node
routing
- **Build automation** — Castle records build specs but doesn't
orchestrate builds (each project builds independently)
## Technology Map
| Concern | Technology | Status |
|---------|-----------|--------|
| Process supervision | systemd (user units) | Active |
| HTTP routing | Caddy (port 9000) | Active |
| Component specs | castle.yaml + Pydantic models | Active |
| Node config | `~/.castle/registry.yaml` | Active |
| CLI | castle (Python, uv) | Active |
| API | castle-api (FastAPI) | Active |
| Dashboard | castle-app (React, Vite, shadcn/ui) | Active |
| Python packaging | uv | Active |
| Node packaging | pnpm | Active |
| Linting | ruff (Python), ESLint (TS) | Active |
| Type checking | pyright (Python), tsc (TS) | Active |
| Testing | pytest (Python), Vitest (TS) | Active |
| Secrets | `~/.castle/secrets/` file-based | Active |
| Data storage | Filesystem (`/data/castle/`) | Active |
| Messaging | MQTT (Mosquitto) | Planned |
| Node discovery | mDNS/Avahi + MQTT | Planned |
| Rust packaging | cargo | Planned |
| Go packaging | go build | Planned |

View File

@@ -101,12 +101,10 @@ Castle passes config via env vars in castle.yaml:
```yaml ```yaml
components: components:
my-service: my-service:
source: my-service
run: run:
runner: python_uv_tool runner: python
tool: my-service tool: my-service
cwd: my-service
env:
MY_SERVICE_DATA_DIR: /data/castle/my-service
expose: expose:
http: http:
internal: { port: 9001 } internal: { port: 9001 }
@@ -117,6 +115,16 @@ components:
systemd: {} systemd: {}
``` ```
Convention-based env vars (`MY_SERVICE_DATA_DIR`, `MY_SERVICE_PORT`) are
generated automatically by `castle deploy`. Only non-convention values
need `defaults.env`:
```yaml
defaults:
env:
CENTRAL_CONTEXT_URL: http://localhost:9001
```
## Application entry point ## Application entry point
```python ```python