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

@@ -251,14 +251,16 @@ def _build_deployed_service(
_ensure_python_tool(config, svc.program, messages)
# Build run_cmd (container runners get --env-file for the secrets).
source_dir = _program_source_dir(config, svc.program)
run_cmd = _build_run_cmd(
name,
run,
env,
messages,
_program_source_dir(config, svc.program),
source_dir,
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
# dedicated host site block, so a root-based app serves unchanged).
@@ -284,6 +286,7 @@ def _build_deployed_service(
return Deployment(
runner=run.runner,
run_cmd=run_cmd,
stop_cmd=stop_cmd,
env=env,
secret_env_keys=sorted(secret_env),
description=_resolve_description(config, svc),
@@ -473,6 +476,13 @@ def _build_run_cmd(
if run.args: # type: ignore[union-attr]
cmd.extend(run.args) # type: ignore[union-attr]
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":
# Like the python runner bakes `--project <source>` into `uv run`, 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]
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:
"""Format deployment summary for a component."""
parts = [name]

View File

@@ -145,6 +145,10 @@ ExecStart={exec_start}
RestartSec={restart_sec}
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:
reload_argv = sd.exec_reload.split()

View File

@@ -60,6 +60,22 @@ class RunNode(RunBase):
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):
runner: Literal["remote"]
base_url: str
@@ -67,7 +83,7 @@ class RunRemote(RunBase):
RunSpec = Annotated[
Union[RunCommand, RunPython, RunContainer, RunNode, RunRemote],
Union[RunCommand, RunPython, RunContainer, RunNode, RunCompose, RunRemote],
Field(discriminator="runner"),
]

View File

@@ -34,6 +34,9 @@ class Deployment:
runner: 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)
# 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
@@ -102,6 +105,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
deployed[name] = Deployment(
runner=comp_data.get("runner", "command"),
run_cmd=comp_data.get("run_cmd", []),
stop_cmd=comp_data.get("stop_cmd", []),
env=comp_data.get("env", {}),
secret_env_keys=comp_data.get("secret_env_keys", []),
description=comp_data.get("description"),
@@ -145,6 +149,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
"runner": comp.runner,
"run_cmd": comp.run_cmd,
}
if comp.stop_cmd:
entry["stop_cmd"] = comp.stop_cmd
if comp.env:
entry["env"] = comp.env
if comp.secret_env_keys:

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import os
import shutil
import tomllib
from dataclasses import dataclass
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] = {
"python-cli": PythonHandler(),
"python-fastapi": PythonHandler(),
"react-vite": ReactViteHandler(),
"supabase": SupabaseHandler(),
}

View File

@@ -246,6 +246,25 @@ class TestCaddyfileTlsInternal:
assert "tls internal" 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:
# auto_https off would suppress the internal-CA certs we now want.
caddyfile = generate_caddyfile_from_registry(self._host_registry("internal"))

View File

@@ -5,8 +5,8 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from castle_core.deploy import _build_run_cmd
from castle_core.manifest import RunContainer, RunNode, RunPython
from castle_core.deploy import _build_run_cmd, _build_stop_cmd
from castle_core.manifest import RunCompose, RunContainer, RunNode, RunPython
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"):
cmd = _build_run_cmd("svc", run, {}, [], secret_env_file=None)
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 pathlib import Path
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:

View File

@@ -124,6 +124,25 @@ class TestUnitFromDeployed:
unit = generate_unit_from_deployed("my-svc", deployed)
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:
"""Secrets are referenced via EnvironmentFile=, never inlined."""