refactor: Update runner specifications from 'python_uv_tool' to 'python' across components and documentation
This commit is contained in:
29
README.md
29
README.md
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
3
TODO.md
3
TODO.md
@@ -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
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
35
castle.yaml
35
castle.yaml
@@ -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/
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
451
docs/design.md
Normal 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 |
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user