Add supabase stack + general compose runner

Adds "a stack whose default is a substrate": a shared self-hosted Supabase
backend plus a `--stack supabase` that scaffolds per-app projects (migrations +
edge functions + static UI) which deploy against it. Apps own their code and
stay repo-durable; only their rows/blobs live on the shared substrate.

- compose runner: new RunCompose supervises a multi-container stack as one
  systemd unit (ExecStart=`compose up`, generated ExecStop=`compose down`);
  secrets via EnvironmentFile. Reusable beyond Supabase. Deployment.stop_cmd
  carries the teardown command through the registry.
- supabase stack: CLI --stack choice, STACK_DEFAULTS→frontend with
  build.outputs=[public], _scaffold_supabase() (migrations/RLS/functions/
  static UI/app manifest), and SupabaseHandler with a forward-only idempotent
  migration runner (plan_migrations + psql build) and deno dev-verbs.
- docs: docs/stacks/supabase.md, registered in CLAUDE.md; compose runner
  documented in registry.md.
- tests: compose run/stop cmd, systemd ExecStop, compose host-route TLS,
  migration planner, supabase verb resolution, create --stack supabase.
This commit is contained in:
2026-06-30 18:18:47 -07:00
parent fc5802d823
commit b726e79c23
19 changed files with 822 additions and 10 deletions

View File

@@ -44,6 +44,7 @@ Stack guides (for writing *new* code, AI-facing):
- @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing) - @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing)
- @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing) - @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
- @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns - @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns
- @docs/stacks/supabase.md — Database-backed apps on the shared Supabase substrate (migrations, RLS, edge functions)
### Quick start ### Quick start
@@ -64,8 +65,8 @@ castle program add https://github.com/me/widget.git --name widget
``` ```
`castle program create` scaffolds under `/data/repos/` (override with `castle program create` scaffolds under `/data/repos/` (override with
`CASTLE_REPOS_DIR`) and registers the program in `castle.yaml` with an absolute `CASTLE_REPOS_DIR`) and registers the program under `programs/<name>.yaml` with an absolute
`source:`. `castle program add` registers an existing repo in place (or records `source:`. `castle program add` registers an existing repo in place under `programs/<name>.yaml` (or records
its `repo:` URL for `castle program clone`). its `repo:` URL for `castle program clone`).
## Castle CLI ## Castle CLI

View File

@@ -7,6 +7,7 @@ import subprocess
from castle_cli.config import REPOS_DIR, load_config, save_config from castle_cli.config import REPOS_DIR, load_config, save_config
from castle_cli.manifest import ( from castle_cli.manifest import (
BuildSpec,
CaddySpec, CaddySpec,
DefaultsSpec, DefaultsSpec,
ExposeSpec, ExposeSpec,
@@ -26,6 +27,15 @@ STACK_DEFAULTS: dict[str, str] = {
"python-fastapi": "daemon", "python-fastapi": "daemon",
"python-cli": "tool", "python-cli": "tool",
"react-vite": "frontend", "react-vite": "frontend",
"supabase": "frontend",
}
# Static build output per stack, for `behavior: frontend` programs. The gateway
# serves this dir in place at /<name>/ (no service, no process). A supabase app
# ships a raw `public/`; react-vite builds to `dist/`.
STACK_BUILD_OUTPUTS: dict[str, str] = {
"supabase": "public",
"react-vite": "dist",
} }
@@ -85,12 +95,19 @@ def run_create(args: argparse.Namespace) -> int:
# Initialize a git repo for the new source. # Initialize a git repo for the new source.
subprocess.run(["git", "init", "-q", str(project_dir)], check=False) subprocess.run(["git", "init", "-q", str(project_dir)], check=False)
# Frontend stacks with a static output get a build spec so the gateway emits
# an in-place static route at /<name>/ (no service, no process).
build = None
if stack in STACK_BUILD_OUTPUTS:
build = BuildSpec(outputs=[STACK_BUILD_OUTPUTS[stack]])
config.programs[name] = ProgramSpec( config.programs[name] = ProgramSpec(
id=name, id=name,
description=description, description=description,
source=str(project_dir), source=str(project_dir),
stack=stack, stack=stack,
behavior=behavior, behavior=behavior,
build=build,
) )
if behavior == "daemon": if behavior == "daemon":
prefix = name.replace("-", "_").upper() prefix = name.replace("-", "_").upper()
@@ -122,7 +139,11 @@ def run_create(args: argparse.Namespace) -> int:
print(" Registered in castle.yaml") print(" Registered in castle.yaml")
print("\nNext steps:") print("\nNext steps:")
print(f" cd {project_dir}") print(f" cd {project_dir}")
if stack: if stack == "supabase":
print(" # edit migrations/, functions/, public/ — targets the shared substrate")
print(f" castle program build {name} # apply migrations to the substrate")
print(f" castle deploy && castle gateway reload # serve at /{name}/")
elif stack:
print(" uv sync") print(" uv sync")
if behavior == "daemon": if behavior == "daemon":
print(f" uv run {name} # starts on port {port}") print(f" uv run {name} # starts on port {port}")

View File

@@ -30,6 +30,7 @@ STACK_DISPLAY: dict[str, str] = {
"python-fastapi": "python-fastapi", "python-fastapi": "python-fastapi",
"python-cli": "python-cli", "python-cli": "python-cli",
"react-vite": "react-vite", "react-vite": "react-vite",
"supabase": "supabase",
"rust": "rust", "rust": "rust",
"go": "go", "go": "go",
"bash": "bash", "bash": "bash",

View File

@@ -19,6 +19,8 @@ def run_logs(args: argparse.Namespace) -> int:
svc = config.services[name] svc = config.services[name]
if svc.run.runner == "container": if svc.run.runner == "container":
return _container_logs(name, args) return _container_logs(name, args)
if svc.run.runner == "compose":
return _compose_logs(name, svc, args)
return _systemd_logs(name, args) return _systemd_logs(name, args)
# Check jobs # Check jobs
@@ -45,6 +47,25 @@ def _systemd_logs(name: str, args: argparse.Namespace) -> int:
return result.returncode return result.returncode
def _compose_logs(name: str, svc: object, args: argparse.Namespace) -> int:
"""Show aggregated logs for a compose-runner stack (by project label)."""
import shutil
runtime = shutil.which("docker") or shutil.which("podman") or "docker"
project = getattr(svc.run, "project_name", None) or f"castle-{name}" # type: ignore[attr-defined]
cmd = [runtime, "compose", "-p", project, "logs"]
lines = getattr(args, "lines", 50)
if lines:
cmd.extend(["--tail", str(lines)])
if getattr(args, "follow", False):
cmd.append("-f")
result = subprocess.run(cmd)
return result.returncode
def _container_logs(name: str, args: argparse.Namespace) -> int: def _container_logs(name: str, args: argparse.Namespace) -> int:
"""Show container logs.""" """Show container logs."""
import shutil import shutil

View File

@@ -37,7 +37,11 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
p = sub.add_parser("create", help="Scaffold a new program") p = sub.add_parser("create", help="Scaffold a new program")
_add_name(p, "Program name") _add_name(p, "Program name")
p.add_argument("--stack", choices=["python-cli", "python-fastapi", "react-vite"], default=None) p.add_argument(
"--stack",
choices=["python-cli", "python-fastapi", "react-vite", "supabase"],
default=None,
)
p.add_argument("--description", default="", help="Program description") p.add_argument("--description", default="", help="Program description")
p.add_argument("--port", type=int, help="Port (daemons only)") p.add_argument("--port", type=int, help="Port (daemons only)")

View File

@@ -20,6 +20,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
RestartPolicy, RestartPolicy,
RunBase, RunBase,
RunCommand, RunCommand,
RunCompose,
RunContainer, RunContainer,
RunNode, RunNode,
RunPython, RunPython,

View File

@@ -18,6 +18,8 @@ def scaffold_project(
_scaffold_service(project_dir, name, package_name, description, port or 9000) _scaffold_service(project_dir, name, package_name, description, port or 9000)
elif stack == "python-cli": elif stack == "python-cli":
_scaffold_tool(project_dir, name, package_name, description) _scaffold_tool(project_dir, name, package_name, description)
elif stack == "supabase":
_scaffold_supabase(project_dir, name, description)
else: else:
raise ValueError(f"No scaffold template for stack: {stack}") raise ValueError(f"No scaffold template for stack: {stack}")
@@ -566,6 +568,199 @@ uv run ruff format . # Format
) )
def _scaffold_supabase(project_dir: Path, name: str, description: str) -> None:
"""Scaffold a Patch-shaped app that targets the shared Supabase substrate.
Produces migrations/ (applied to the substrate by `castle program build`),
functions/ (deno edge functions), public/ (static UI served in place at
/<name>/ by the gateway), and supabase.app.yaml (auth policy + wiring).
The app owns its code and stays repo-durable; only its rows/blobs live on the
shared substrate. Tables are namespaced by the app id (they share the `public`
schema so no substrate reconfiguration is needed) and protected by RLS.
"""
ident = name.replace("-", "_") # a safe SQL/JS identifier
table = f"{ident}_entries"
def sub(text: str) -> str:
return (
text.replace("__NAME__", name)
.replace("__IDENT__", ident)
.replace("__TABLE__", table)
.replace("__DESC__", description)
)
# --- supabase.app.yaml — app manifest (substrate wiring + auth policy) ---
_write(
project_dir / "supabase.app.yaml",
sub(
"""# Patch-shaped app targeting the shared Supabase substrate.
name: __NAME__
description: __DESC__
substrate: supabase # the shared castle service this app deploys against
# auth policy: public | private | shared
# public — anyone with the URL (anon read/write, still RLS-gated)
# private — only the owner; RLS locks rows to auth.uid()
# shared — owner + named people (list handles below)
auth: public
# shared: [alice, bob]
# Tables are namespaced by this prefix in the shared `public` schema.
table_prefix: __IDENT___
"""
),
)
# --- migrations/0001_init.sql — versioned, idempotent, forward-only ---
_write(
project_dir / "migrations" / "0001_init.sql",
sub(
"""-- 0001_init: example table + RLS. Applied by `castle program build`
-- via the versioned migration runner (tracked in schema_migrations; only
-- unapplied migrations run). Forward-only — never edit an applied migration;
-- add a new numbered file instead.
create table if not exists public.__TABLE__ (
id bigint generated always as identity primary key,
message text not null check (char_length(message) <= 500),
author text,
created_at timestamptz not null default now()
);
-- RLS is necessary but NOT sufficient on its own: it protects rows, not the
-- static app shell or Storage objects. For a `public` app, anon read/write is
-- intended (still row-gated). For `private`/`shared`, replace the policies below
-- with owner checks (auth.uid()) AND gate the shell + use signed Storage URLs.
alter table public.__TABLE__ enable row level security;
drop policy if exists "__IDENT___public_read" on public.__TABLE__;
create policy "__IDENT___public_read" on public.__TABLE__ for select using (true);
drop policy if exists "__IDENT___public_write" on public.__TABLE__;
create policy "__IDENT___public_write" on public.__TABLE__ for insert with check (true);
"""
),
)
# --- functions/hello/index.ts — deno edge-function stub ---
_write(
project_dir / "functions" / "hello" / "index.ts",
sub(
"""// Edge function for __NAME__. Runs on the substrate's edge-runtime.
// Deployed alongside migrations; call it from the app (never expose the
// service_role key to the browser — privileged work happens here, server-side).
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
serve((_req: Request) => {
return new Response(
JSON.stringify({ ok: true, app: "__NAME__", ts: new Date().toISOString() }),
{ headers: { "content-type": "application/json" } },
);
});
"""
),
)
# --- public/config.js — substrate wiring (anon key is public-safe by design) ---
_write(
project_dir / "public" / "config.js",
sub(
"""// Substrate wiring for __NAME__. The anon key is designed to be public
// (RLS enforces access) — paste yours here, or inject at deploy time:
// cat ~/.castle/secrets/SUPABASE_ANON_KEY
window.APP = {
SUPABASE_URL: "https://supabase.lan",
SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE",
TABLE: "__TABLE__",
};
"""
),
)
# --- public/index.html — reads/writes the substrate via supabase-js ---
_write(
project_dir / "public" / "index.html",
sub(
"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>__NAME__</title>
<script src="./config.js"></script>
<script type="module">
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const { SUPABASE_URL, SUPABASE_ANON_KEY, TABLE } = window.APP;
const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
const list = document.getElementById("list");
async function refresh() {
const { data, error } = await db.from(TABLE)
.select("message, author, created_at")
.order("created_at", { ascending: false }).limit(50);
list.textContent = error ? "Error: " + error.message
: (data.map(r => `${r.author ?? "anon"}: ${r.message}`).join("\\n") || "(empty)");
}
document.getElementById("form").addEventListener("submit", async (e) => {
e.preventDefault();
const message = e.target.message.value.trim();
if (!message) return;
const { error } = await db.from(TABLE).insert({ message });
if (error) { alert(error.message); return; }
e.target.reset();
refresh();
});
refresh();
</script>
</head>
<body>
<h1>__NAME__</h1>
<p>__DESC__</p>
<form id="form"><input name="message" placeholder="Say something…" autocomplete="off" />
<button>Post</button></form>
<pre id="list">Loading…</pre>
</body>
</html>
"""
),
)
# --- CLAUDE.md — how this app works ---
_write(
project_dir / "CLAUDE.md",
sub(
"""# __NAME__
__DESC__
A **supabase-stack** app: it owns its code (this repo) and rents its backend from
the shared Supabase substrate (the `supabase` castle service). Only its rows/blobs
live on the substrate; rebuild the rest from git anytime.
## Layout
- `migrations/` — versioned, idempotent Postgres migrations (forward-only). Applied
to the substrate by `castle program build __NAME__`.
- `functions/` — deno edge functions deployed to the substrate's edge-runtime.
- `public/` — static UI served in place at `/__NAME__/` by the gateway. Talks to
the substrate with `@supabase/supabase-js` + the public anon key (RLS-gated).
- `supabase.app.yaml` — substrate wiring + auth policy (public/private/shared).
## Develop
- Edit `migrations/`, then `castle program build __NAME__` to apply new migrations
(re-running is a no-op — only unapplied migrations run).
- Set the anon key in `public/config.js` (`cat ~/.castle/secrets/SUPABASE_ANON_KEY`).
- `castle deploy && castle gateway reload` → served at `/__NAME__/`.
## Privacy note
RLS protects rows, not the static shell or Storage. For a `private`/`shared` app,
also gate the shell and use signed Storage URLs — RLS alone is not leak-proof.
Auth/WebCrypto apps should get their own HTTPS host route (secure context).
"""
),
)
def _write(path: Path, content: str) -> None: def _write(path: Path, content: str) -> None:
"""Write content to a file, creating parent directories.""" """Write content to a file, creating parent directories."""
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)

View File

@@ -76,6 +76,39 @@ class TestCreateCommand:
comp = config.programs["my-tool2"] comp = config.programs["my-tool2"]
assert comp.behavior == "tool" assert comp.behavior == "tool"
def test_create_supabase_app(self, castle_root: Path, tmp_path: Path) -> None:
"""A supabase app scaffolds a Patch-shaped project registered as a static
frontend (build.outputs=[public]) with no service."""
repos = tmp_path / "repos"
with (
patch("castle_cli.commands.create.load_config") as mock_load,
patch("castle_cli.commands.create.save_config"),
patch("castle_cli.commands.create.REPOS_DIR", repos),
):
config = load_config(castle_root)
mock_load.return_value = config
from castle_cli.commands.create import run_create
args = Namespace(
name="guestbook", stack="supabase", description="Guestbook", port=None
)
result = run_create(args)
assert result == 0
project_dir = repos / "guestbook"
assert (project_dir / "migrations" / "0001_init.sql").exists()
assert (project_dir / "functions" / "hello" / "index.ts").exists()
assert (project_dir / "public" / "index.html").exists()
assert (project_dir / "supabase.app.yaml").exists()
# Registered as a static frontend, no service, build output = public/
comp = config.programs["guestbook"]
assert comp.behavior == "frontend"
assert comp.stack == "supabase"
assert comp.build is not None and comp.build.outputs == ["public"]
assert "guestbook" not in config.services
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None: def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
"""Creating a project with existing name fails.""" """Creating a project with existing name fails."""
with patch("castle_cli.commands.create.load_config") as mock_load: with patch("castle_cli.commands.create.load_config") as mock_load:

View File

@@ -251,14 +251,16 @@ def _build_deployed_service(
_ensure_python_tool(config, svc.program, messages) _ensure_python_tool(config, svc.program, messages)
# Build run_cmd (container runners get --env-file for the secrets). # Build run_cmd (container runners get --env-file for the secrets).
source_dir = _program_source_dir(config, svc.program)
run_cmd = _build_run_cmd( run_cmd = _build_run_cmd(
name, name,
run, run,
env, env,
messages, messages,
_program_source_dir(config, svc.program), source_dir,
secret_env_file=secret_env_file, secret_env_file=secret_env_file,
) )
stop_cmd = _build_stop_cmd(name, run, source_dir)
# Proxy: a path prefix (handle_path on the gateway) and/or a hostname (a # Proxy: a path prefix (handle_path on the gateway) and/or a hostname (a
# dedicated host site block, so a root-based app serves unchanged). # dedicated host site block, so a root-based app serves unchanged).
@@ -284,6 +286,7 @@ def _build_deployed_service(
return Deployment( return Deployment(
runner=run.runner, runner=run.runner,
run_cmd=run_cmd, run_cmd=run_cmd,
stop_cmd=stop_cmd,
env=env, env=env,
secret_env_keys=sorted(secret_env), secret_env_keys=sorted(secret_env),
description=_resolve_description(config, svc), description=_resolve_description(config, svc),
@@ -473,6 +476,13 @@ def _build_run_cmd(
if run.args: # type: ignore[union-attr] if run.args: # type: ignore[union-attr]
cmd.extend(run.args) # type: ignore[union-attr] cmd.extend(run.args) # type: ignore[union-attr]
return cmd return cmd
case "compose":
# A whole docker-compose stack supervised as one unit. `up` runs
# attached (no -d) so systemd Type=simple owns the lifecycle; teardown
# is a generated ExecStop (`down`, see _build_stop_cmd). Secrets/env
# reach compose via the unit's Environment=/EnvironmentFile= (compose
# interpolates from the process env), not argv — so nothing here.
return [*_compose_base(name, run, source_dir), "up"]
case "node": case "node":
# Like the python runner bakes `--project <source>` into `uv run`, the # Like the python runner bakes `--project <source>` into `uv run`, the
# node runner bakes `--dir <source>` so the package manager runs the # node runner bakes `--dir <source>` so the package manager runs the
@@ -493,6 +503,33 @@ def _build_run_cmd(
raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr] raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr]
def _compose_base(name: str, run: object, source_dir: Path | None) -> list[str]:
"""The shared ``docker compose -p <project> -f <file>`` prefix for a stack.
The compose file is resolved against the program source (like the node runner
uses ``--dir``) so the unit — which carries no WorkingDirectory — finds it. The
project name defaults to the unit name so a stack's containers/networks are
namespaced and collision-free.
"""
runtime = shutil.which("docker") or shutil.which("podman") or "docker"
project = run.project_name or f"castle-{name}" # type: ignore[union-attr]
compose_file = Path(run.file) # type: ignore[union-attr]
if not compose_file.is_absolute() and source_dir is not None:
compose_file = source_dir / compose_file
return [runtime, "compose", "-p", project, "-f", str(compose_file)]
def _build_stop_cmd(name: str, run: object, source_dir: Path | None) -> list[str]:
"""The ExecStop teardown command for a runner, or [] if a plain SIGTERM suffices.
Compose stacks need an explicit ``down`` so networks/anonymous volumes are
reclaimed rather than left dangling when the unit stops.
"""
if run.runner == "compose": # type: ignore[union-attr]
return [*_compose_base(name, run, source_dir), "down"]
return []
def _format_deployed(name: str, deployed: Deployment) -> str: def _format_deployed(name: str, deployed: Deployment) -> str:
"""Format deployment summary for a component.""" """Format deployment summary for a component."""
parts = [name] parts = [name]

View File

@@ -145,6 +145,10 @@ ExecStart={exec_start}
RestartSec={restart_sec} RestartSec={restart_sec}
SuccessExitStatus=143 SuccessExitStatus=143
""" """
# Explicit teardown (e.g. compose `down`) so the stack's networks/volumes
# are reclaimed on stop rather than left dangling.
if deployed.stop_cmd:
unit += f"ExecStop={' '.join(deployed.stop_cmd)}\n"
if sd and sd.exec_reload: if sd and sd.exec_reload:
reload_argv = sd.exec_reload.split() reload_argv = sd.exec_reload.split()

View File

@@ -60,6 +60,22 @@ class RunNode(RunBase):
args: list[str] = Field(default_factory=list) args: list[str] = Field(default_factory=list)
class RunCompose(RunBase):
"""A multi-container stack supervised as one unit via ``docker compose``.
Unlike ``container`` (a single ``docker run``), compose owns the stack's own
networking, startup ordering, and per-service health — Castle delegates rather
than reinventing orchestration. The unit runs ``compose up`` attached
(``Type=simple``) and tears the stack down via a generated ``ExecStop`` (down).
Env/secrets flow through systemd ``Environment=``/``EnvironmentFile=`` (from
``defaults.env``); compose interpolates them from the process environment.
"""
runner: Literal["compose"]
file: str = "docker-compose.yml" # resolved relative to the program source
project_name: str | None = None # ``-p``; defaults to ``castle-<name>``
class RunRemote(RunBase): class RunRemote(RunBase):
runner: Literal["remote"] runner: Literal["remote"]
base_url: str base_url: str
@@ -67,7 +83,7 @@ class RunRemote(RunBase):
RunSpec = Annotated[ RunSpec = Annotated[
Union[RunCommand, RunPython, RunContainer, RunNode, RunRemote], Union[RunCommand, RunPython, RunContainer, RunNode, RunCompose, RunRemote],
Field(discriminator="runner"), Field(discriminator="runner"),
] ]

View File

@@ -34,6 +34,9 @@ class Deployment:
runner: str runner: str
run_cmd: list[str] run_cmd: list[str]
# Optional teardown command emitted as systemd ``ExecStop=`` (e.g. compose
# ``down``). Empty for runners whose stop is just SIGTERM to the ExecStart pid.
stop_cmd: list[str] = field(default_factory=list)
env: dict[str, str] = field(default_factory=dict) env: dict[str, str] = field(default_factory=dict)
# Names (never values) of secret-bearing env vars. Their resolved values live # Names (never values) of secret-bearing env vars. Their resolved values live
# only in the mode-0600 env file, never in env/run_cmd/registry — this is for # only in the mode-0600 env file, never in env/run_cmd/registry — this is for
@@ -102,6 +105,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
deployed[name] = Deployment( deployed[name] = Deployment(
runner=comp_data.get("runner", "command"), runner=comp_data.get("runner", "command"),
run_cmd=comp_data.get("run_cmd", []), run_cmd=comp_data.get("run_cmd", []),
stop_cmd=comp_data.get("stop_cmd", []),
env=comp_data.get("env", {}), env=comp_data.get("env", {}),
secret_env_keys=comp_data.get("secret_env_keys", []), secret_env_keys=comp_data.get("secret_env_keys", []),
description=comp_data.get("description"), description=comp_data.get("description"),
@@ -145,6 +149,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
"runner": comp.runner, "runner": comp.runner,
"run_cmd": comp.run_cmd, "run_cmd": comp.run_cmd,
} }
if comp.stop_cmd:
entry["stop_cmd"] = comp.stop_cmd
if comp.env: if comp.env:
entry["env"] = comp.env entry["env"] = comp.env
if comp.secret_env_keys: if comp.secret_env_keys:

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import shutil
import tomllib import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -335,10 +336,181 @@ class ReactViteHandler(StackHandler):
) )
def _migration_version(path: Path) -> str:
"""The version key of a migration file — the leading token before '_'.
e.g. ``0001_init.sql`` → ``0001``. Recorded in ``schema_migrations`` so a
redeploy applies only the unapplied files.
"""
return path.name.split("_", 1)[0]
def plan_migrations(files: list[Path], applied: set[str]) -> list[Path]:
"""Order migrations by filename and drop any whose version is already applied.
Forward-only and idempotent (mirrors Patch's runner): re-running applies only
new files, never re-applies an existing one. Pure — no DB — so it's unit-tested
without a substrate.
"""
return [
p
for p in sorted(files, key=lambda x: x.name)
if _migration_version(p) not in applied
]
def _substrate_db_url() -> str | None:
"""Best-effort Postgres URL for the shared substrate.
Prefers an explicit ``SUPABASE_DB_URL``; otherwise builds one from the
generated ``SUPABASE_POSTGRES_PASSWORD`` secret against localhost:5432. Returns
None if neither is available (build then fails loud with guidance).
"""
explicit = os.environ.get("SUPABASE_DB_URL")
if explicit:
return explicit
from castle_core.config import SECRETS_DIR
pw_file = SECRETS_DIR / "SUPABASE_POSTGRES_PASSWORD"
if pw_file.exists():
pw = pw_file.read_text().strip()
return f"postgresql://postgres:{pw}@localhost:5432/postgres"
return None
class SupabaseHandler(StackHandler):
"""Stack handler for supabase apps (migrations + edge functions + static UI).
`build` runs the versioned migration runner against the shared substrate; the
static UI is served in place by the gateway (no process), so install/uninstall
are no-ops like a frontend.
"""
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Apply unapplied migrations to the shared substrate (idempotent)."""
src = _source_dir(comp, root)
mig_dir = src / "migrations"
files = sorted(mig_dir.glob("*.sql")) if mig_dir.is_dir() else []
if not files:
return ActionResult(name, "build", "ok", "No migrations to apply.")
psql = shutil.which("psql")
url = _substrate_db_url()
if not psql:
return ActionResult(
name,
"build",
"error",
"psql not found — install postgresql-client to run migrations.",
)
if not url:
return ActionResult(
name,
"build",
"error",
"No substrate DB URL. Set SUPABASE_DB_URL, or generate secrets "
"(scripts/gen-keys.py) so SUPABASE_POSTGRES_PASSWORD exists.",
)
# Ensure the tracking table, then read applied versions.
rc, out = await _run(
[
psql,
url,
"-v",
"ON_ERROR_STOP=1",
"-c",
"create table if not exists public.schema_migrations "
"(version text primary key, applied_at timestamptz default now())",
],
src,
)
if rc != 0:
return ActionResult(name, "build", "error", f"connect/init failed:\n{out}")
rc, out = await _run(
[psql, url, "-tA", "-c", "select version from public.schema_migrations"],
src,
)
if rc != 0:
return ActionResult(
name, "build", "error", f"read migrations failed:\n{out}"
)
applied = {line.strip() for line in out.splitlines() if line.strip()}
pending = plan_migrations(files, applied)
if not pending:
return ActionResult(name, "build", "ok", "All migrations already applied.")
log = []
for path in pending:
version = _migration_version(path)
# File + version-insert in ONE transaction: a failed migration records
# nothing, so the next build safely retries it.
rc, out = await _run(
[
psql,
url,
"-v",
"ON_ERROR_STOP=1",
"--single-transaction",
"-f",
str(path),
"-c",
f"insert into public.schema_migrations(version) values('{version}')",
],
src,
)
if rc != 0:
log.append(f"{path.name}\n{out}")
return ActionResult(name, "build", "error", "\n".join(log))
log.append(f"{path.name}")
return ActionResult(name, "build", "ok", "\n".join(log))
async def _deno(
self, name: str, action: str, comp: ProgramSpec, root: Path, args: list[str]
) -> ActionResult:
"""Run a `deno` subcommand over functions/, or skip cleanly if deno absent."""
src = _source_dir(comp, root)
fns = src / "functions"
deno = shutil.which("deno")
if not deno or not fns.is_dir():
return ActionResult(
name, action, "ok", f"{action}: skipped (no deno/functions)"
)
rc, out = await _run([deno, *args, "functions/"], src)
return ActionResult(name, action, "ok" if rc == 0 else "error", out)
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
return await self._deno(name, "test", comp, root, ["test", "--allow-all"])
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
return await self._deno(name, "lint", comp, root, ["lint"])
async def format(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
return await self._deno(name, "format", comp, root, ["fmt"])
async def type_check(
self, name: str, comp: ProgramSpec, root: Path
) -> ActionResult:
return await self._deno(name, "type-check", comp, root, ["check"])
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Static UI is served in place by the gateway; nothing to install."""
return ActionResult(
name, "install", "ok", f"{name}: served in place at /{name}/."
)
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
return ActionResult(
name, "uninstall", "ok", f"{name}: served in place; nothing to remove."
)
HANDLERS: dict[str, StackHandler] = { HANDLERS: dict[str, StackHandler] = {
"python-cli": PythonHandler(), "python-cli": PythonHandler(),
"python-fastapi": PythonHandler(), "python-fastapi": PythonHandler(),
"react-vite": ReactViteHandler(), "react-vite": ReactViteHandler(),
"supabase": SupabaseHandler(),
} }

View File

@@ -246,6 +246,25 @@ class TestCaddyfileTlsInternal:
assert "tls internal" in caddyfile assert "tls internal" in caddyfile
assert "reverse_proxy localhost:18789" in caddyfile assert "reverse_proxy localhost:18789" in caddyfile
def test_compose_substrate_host_route_is_tls_site(self) -> None:
"""The Supabase substrate (compose runner + host route) becomes its own
HTTPS site under tls:internal — routing is runner-agnostic."""
registry = _make_registry(
gateway_tls="internal",
deployed={
"supabase": Deployment(
runner="compose",
run_cmd=["docker", "compose", "-p", "castle-supabase", "up"],
port=8000,
proxy_host="supabase.lan",
),
},
)
caddyfile = generate_caddyfile_from_registry(registry)
assert "supabase.lan {" in caddyfile
assert "tls internal" in caddyfile
assert "reverse_proxy localhost:8000" in caddyfile
def test_no_auto_https_off_in_tls_mode(self) -> None: def test_no_auto_https_off_in_tls_mode(self) -> None:
# auto_https off would suppress the internal-CA certs we now want. # auto_https off would suppress the internal-CA certs we now want.
caddyfile = generate_caddyfile_from_registry(self._host_registry("internal")) caddyfile = generate_caddyfile_from_registry(self._host_registry("internal"))

View File

@@ -5,8 +5,8 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from castle_core.deploy import _build_run_cmd from castle_core.deploy import _build_run_cmd, _build_stop_cmd
from castle_core.manifest import RunContainer, RunNode, RunPython from castle_core.manifest import RunCompose, RunContainer, RunNode, RunPython
def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None: def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None:
@@ -105,3 +105,64 @@ def test_container_without_secrets_has_no_env_file() -> None:
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"): with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None) cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None)
assert "--env-file" not in cmd assert "--env-file" not in cmd
def test_compose_runner_up_with_resolved_file(tmp_path: Path) -> None:
"""A compose service runs `docker compose -p <project> -f <abs-file> up`."""
run = RunCompose(runner="compose", file="docker-compose.yml")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("supabase", run, {}, [], source_dir=tmp_path)
assert cmd == [
"/usr/bin/docker",
"compose",
"-p",
"castle-supabase",
"-f",
str(tmp_path / "docker-compose.yml"),
"up",
]
def test_compose_runner_secrets_never_hit_argv(tmp_path: Path) -> None:
"""Compose reads env from the process (systemd), not --env-file/-e — argv is clean."""
run = RunCompose(runner="compose", file="docker-compose.yml")
env_file = Path("/home/u/.castle/secrets/env/castle-supabase.service.env")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd(
"supabase", run, {"PORT": "8000"}, [], source_dir=tmp_path,
secret_env_file=env_file,
)
joined = " ".join(cmd)
assert "--env-file" not in cmd
assert "-e" not in cmd
assert str(env_file) not in joined
def test_compose_project_name_override(tmp_path: Path) -> None:
run = RunCompose(runner="compose", file="stack.yml", project_name="myproj")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
cmd = _build_run_cmd("s", run, {}, [], source_dir=tmp_path)
assert cmd[:6] == [
"/usr/bin/docker", "compose", "-p", "myproj", "-f", str(tmp_path / "stack.yml"),
]
def test_compose_stop_cmd_is_down(tmp_path: Path) -> None:
"""The teardown command mirrors up but ends in `down` (same project + file)."""
run = RunCompose(runner="compose", file="docker-compose.yml")
with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/docker"):
stop = _build_stop_cmd("supabase", run, tmp_path)
assert stop == [
"/usr/bin/docker",
"compose",
"-p",
"castle-supabase",
"-f",
str(tmp_path / "docker-compose.yml"),
"down",
]
def test_non_compose_runner_has_no_stop_cmd(tmp_path: Path) -> None:
run = RunPython(runner="python", program="svc")
assert _build_stop_cmd("svc", run, tmp_path) == []

View File

@@ -2,8 +2,48 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from castle_core.manifest import ProgramSpec from castle_core.manifest import ProgramSpec
from castle_core.stacks import _declared_commands, available_actions, is_available from castle_core.stacks import (
_declared_commands,
_migration_version,
available_actions,
is_available,
plan_migrations,
)
class TestMigrationPlanner:
"""The supabase stack's forward-only, idempotent migration runner (pure part)."""
def test_version_is_leading_token(self) -> None:
assert _migration_version(Path("0007_add_users.sql")) == "0007"
def test_orders_by_filename_and_skips_applied(self) -> None:
files = [Path("0003_c.sql"), Path("0001_a.sql"), Path("0002_b.sql")]
pending = plan_migrations(files, {"0001"})
assert [p.name for p in pending] == ["0002_b.sql", "0003_c.sql"]
def test_none_pending_when_all_applied(self) -> None:
files = [Path("0001_a.sql"), Path("0002_b.sql")]
assert plan_migrations(files, {"0001", "0002"}) == []
def test_all_pending_on_fresh_db(self) -> None:
files = [Path("0002_b.sql"), Path("0001_a.sql")]
assert [p.name for p in plan_migrations(files, set())] == [
"0001_a.sql",
"0002_b.sql",
]
class TestSupabaseStackResolution:
def test_supabase_stack_resolves_verbs(self) -> None:
"""A supabase program resolves build (migrations) + deno verbs."""
p = ProgramSpec.model_validate({"source": "/tmp/x", "stack": "supabase"})
actions = available_actions(p)
assert "build" in actions and "test" in actions
assert "install" in actions and "uninstall" in actions
class TestResolution: class TestResolution:

View File

@@ -124,6 +124,25 @@ class TestUnitFromDeployed:
unit = generate_unit_from_deployed("my-svc", deployed) unit = generate_unit_from_deployed("my-svc", deployed)
assert "/data/repos/" not in unit assert "/data/repos/" not in unit
def test_exec_stop_emitted_for_compose(self) -> None:
"""A compose deployment's stop_cmd becomes ExecStop= (clean teardown)."""
deployed = Deployment(
runner="compose",
run_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "up"],
stop_cmd=["/usr/bin/docker", "compose", "-p", "castle-x", "-f", "c.yml", "down"],
description="Stack",
)
unit = generate_unit_from_deployed("x", deployed)
assert "ExecStop=/usr/bin/docker compose -p castle-x -f c.yml down" in unit
def test_no_exec_stop_without_stop_cmd(self) -> None:
"""Runners without a stop_cmd rely on SIGTERM — no ExecStop line."""
deployed = Deployment(
runner="python", run_cmd=["/uv", "run", "x"], description="X"
)
unit = generate_unit_from_deployed("x", deployed)
assert "ExecStop=" not in unit
class TestSecretEnvFile: class TestSecretEnvFile:
"""Secrets are referenced via EnvironmentFile=, never inlined.""" """Secrets are referenced via EnvironmentFile=, never inlined."""

View File

@@ -224,6 +224,7 @@ Discriminated union on `runner`:
| `python` | *(none — `uv run` self-syncs)* | `uv run --project <source> --no-dev <program>` | `program`, `args` | | `python` | *(none — `uv run` self-syncs)* | `uv run --project <source> --no-dev <program>` | `program`, `args` |
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` | | `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
| `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | | `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` |
| `compose` | *(none)* | `docker compose -p <project> -f <file> up` (+ `ExecStop=down`) | `file`, `project_name` |
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` | | `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` | | `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
@@ -242,6 +243,23 @@ run:
program: my-service # name in [project.scripts] program: my-service # name in [project.scripts]
``` ```
A `compose` service supervises a **whole multi-container stack as one systemd
unit** — `ExecStart` runs `docker compose … up` attached (`Type=simple`) and a
generated `ExecStop` runs `… down` so networks/anonymous volumes are reclaimed on
stop. Unlike the single-container `container` runner, compose owns the stack's own
networking, startup ordering, and per-service health — Castle delegates rather
than reinventing orchestration. Secrets/env reach compose through the unit's
`Environment=`/`EnvironmentFile=` (from `defaults.env`), which compose interpolates
from the process environment. This is what runs the shared **Supabase substrate**
(see @docs/stacks/supabase.md).
```yaml
run:
runner: compose
file: docker-compose.yml # resolved under the program source
# project_name: castle-my-stack # optional; defaults to castle-<name>
```
### `expose` — What it exposes ### `expose` — What it exposes
```yaml ```yaml
@@ -599,7 +617,7 @@ The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `ProgramSpec` — software catalog entry (source, behavior, stack, build, system_dependencies) - `ProgramSpec` — software catalog entry (source, behavior, stack, build, system_dependencies)
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults) - `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
- `JobSpec` — scheduled task (run, schedule, manage, defaults) - `JobSpec` — scheduled task (run, schedule, manage, defaults)
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote) - `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunCompose, RunNode, RunRemote)
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `BuildSpec` - `ExposeSpec`, `ProxySpec`, `ManageSpec`, `BuildSpec`
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal` - `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`

143
docs/stacks/supabase.md Normal file
View File

@@ -0,0 +1,143 @@
# Supabase Apps in Castle
> **This is a stack — creation-time guidance for writing _new_ database-backed
> web apps.** A stack is a template + conventions, not a runtime requirement.
> `castle program create --stack supabase` scaffolds from it and seeds the
> program's default dev-verb commands. See @docs/registry.md for `commands:`,
> `stack:` (optional), and `behavior:`.
How to build tiny, database-backed web apps as castle programs that target a
**shared Supabase substrate**. This is Castle's "a stack whose default is a
substrate": the app owns its code (and stays repo-durable), and rents the boring
backend — Postgres + auth + storage + RLS — that an app can't reliably reinvent.
## The model: one shared substrate, many apps
Unlike the other stacks (which scaffold a self-contained process), a supabase app
is **code + migrations that deploy against a shared backend**:
- **The substrate** is one castle service (`supabase`, the `supabase-substrate`
repo) running self-hosted Supabase via the `compose` runner. It is shared by
every supabase app. Stand it up once (see that repo's README).
- **Each app** is a directory of `migrations/` + `functions/` + `public/` that
deploys onto the substrate. Its rows/blobs live on the substrate; everything
else rebuilds from git.
Apps are isolated on the shared instance by **table prefix + RLS** in the shared
`public` schema (and Storage buckets), under **one identity pool** — correct for a
single-operator datalake. Substrate-per-app is deliberately not supported: ~14
containers per app doesn't scale to "lots of small ideas," and a DB-backed app is
a pet either way.
## Stack
| Layer | Choice |
|-------|--------|
| **Backend** | Self-hosted Supabase (Postgres + PostgREST + GoTrue + Storage + Realtime + Edge Functions) |
| **Client** | `@supabase/supabase-js` (from a CDN; no build step required) |
| **Migrations** | Ordered SQL files, applied by a versioned idempotent runner |
| **Functions** | Deno edge functions |
| **UI** | Static HTML/JS in `public/`, served in place by the gateway |
| **Auth** | GoTrue + Postgres Row-Level Security |
## Project layout
```
my-app/
├── supabase.app.yaml # substrate wiring + auth policy (public/private/shared)
├── migrations/
│ └── 0001_init.sql # versioned, idempotent, forward-only
├── functions/
│ └── hello/index.ts # deno edge function
├── public/
│ ├── index.html # static UI — talks to the substrate via supabase-js
│ └── config.js # SUPABASE_URL + anon key (public-safe)
└── CLAUDE.md
```
Registered as a `behavior: frontend` program with `build.outputs: [public]`, so the
gateway serves `public/` in place at `/my-app/` — no service, no process.
## supabase.app.yaml
```yaml
name: my-app
substrate: supabase # the shared castle service this app deploys against
auth: public # public | private | shared: [handles]
table_prefix: my_app_
```
## Migrations
`migrations/*.sql` are **numbered, forward-only, and idempotent**. `castle program
build my-app` runs the versioned migration runner: it ensures a
`public.schema_migrations` table, reads applied versions, and applies only the
**unapplied** files (in filename order), each in a single transaction with its
version-insert — so a failed migration records nothing and the next build retries
it. Never edit an applied migration; add a new numbered file.
```sql
-- migrations/0001_init.sql
create table if not exists public.my_app_entries (
id bigint generated always as identity primary key,
message text not null,
created_at timestamptz not null default now()
);
alter table public.my_app_entries enable row level security;
create policy "my_app_read" on public.my_app_entries for select using (true);
create policy "my_app_write" on public.my_app_entries for insert with check (true);
```
The runner connects via `SUPABASE_DB_URL`, or builds one from the generated
`SUPABASE_POSTGRES_PASSWORD` secret against `localhost:5432`. `psql` must be on
PATH; a missing URL or client fails loud with guidance.
## Auth, RLS & the three privacy layers
`auth:` in `supabase.app.yaml` declares the policy. **RLS protects rows, but it is
not sufficient on its own** — a leaked URL to a `private` app would still serve the
static shell and any known Storage URL. So a non-public app must enforce privacy at
**three** layers:
1. **Rows** — RLS locks rows to `auth.uid()` (owner) or a shared allowlist.
2. **Static shell** — an auth check gates serving `public/` (unauthenticated
requests get login/denied, never the app).
3. **Storage** — served via short-lived **signed URLs**, never long-lived public
object URLs.
A `public` app intentionally skips shell/Storage gating (anon read/write, still
row-gated).
## Edge functions
`functions/<name>/index.ts` are deno functions deployed to the substrate's
edge-runtime. Privileged work (anything needing the `service_role` key) happens
here, server-side — **never** browser-direct. The app frontend calls the function;
the function holds credentials and can meter usage.
## Gateway & secure context
A `public` app is fine on the gateway's HTTP static route at `/my-app/`. An app
that uses **auth or WebCrypto** needs a **secure context**, so give it its own
HTTPS host route instead — `proxy.caddy.host: my-app.lan` with `gateway.tls:
internal` — exactly like the substrate itself (`supabase.lan`). See the "HTTPS for
host routes" section of @docs/registry.md.
## Commands
```bash
castle program create my-app --stack supabase --description "..." # scaffold + register
castle program build my-app # apply unapplied migrations to the substrate
castle program test my-app # deno test over functions/ (if deno present)
castle deploy && castle gateway reload # serve the static UI at /my-app/
```
## Scaffolding
`castle program create --stack supabase` generates the full layout above and
registers the program as a static frontend. Set the anon key in `public/config.js`
(`cat ~/.castle/secrets/SUPABASE_ANON_KEY`), edit your migrations, and build.
See @docs/registry.md for the `compose` runner, the substrate service definition,
and the full registry reference. The substrate itself lives in the
`supabase-substrate` repo (vendored, pinned self-hosted Supabase).