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:
@@ -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-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
|
||||
- @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
|
||||
|
||||
@@ -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_REPOS_DIR`) and registers the program in `castle.yaml` with an absolute
|
||||
`source:`. `castle program add` registers an existing repo in place (or records
|
||||
`CASTLE_REPOS_DIR`) and registers the program under `programs/<name>.yaml` with an absolute
|
||||
`source:`. `castle program add` registers an existing repo in place under `programs/<name>.yaml` (or records
|
||||
its `repo:` URL for `castle program clone`).
|
||||
|
||||
## Castle CLI
|
||||
|
||||
@@ -7,6 +7,7 @@ import subprocess
|
||||
|
||||
from castle_cli.config import REPOS_DIR, load_config, save_config
|
||||
from castle_cli.manifest import (
|
||||
BuildSpec,
|
||||
CaddySpec,
|
||||
DefaultsSpec,
|
||||
ExposeSpec,
|
||||
@@ -26,6 +27,15 @@ STACK_DEFAULTS: dict[str, str] = {
|
||||
"python-fastapi": "daemon",
|
||||
"python-cli": "tool",
|
||||
"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.
|
||||
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(
|
||||
id=name,
|
||||
description=description,
|
||||
source=str(project_dir),
|
||||
stack=stack,
|
||||
behavior=behavior,
|
||||
build=build,
|
||||
)
|
||||
if behavior == "daemon":
|
||||
prefix = name.replace("-", "_").upper()
|
||||
@@ -122,7 +139,11 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
print(" Registered in castle.yaml")
|
||||
print("\nNext steps:")
|
||||
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")
|
||||
if behavior == "daemon":
|
||||
print(f" uv run {name} # starts on port {port}")
|
||||
|
||||
@@ -30,6 +30,7 @@ STACK_DISPLAY: dict[str, str] = {
|
||||
"python-fastapi": "python-fastapi",
|
||||
"python-cli": "python-cli",
|
||||
"react-vite": "react-vite",
|
||||
"supabase": "supabase",
|
||||
"rust": "rust",
|
||||
"go": "go",
|
||||
"bash": "bash",
|
||||
|
||||
@@ -19,6 +19,8 @@ def run_logs(args: argparse.Namespace) -> int:
|
||||
svc = config.services[name]
|
||||
if svc.run.runner == "container":
|
||||
return _container_logs(name, args)
|
||||
if svc.run.runner == "compose":
|
||||
return _compose_logs(name, svc, args)
|
||||
return _systemd_logs(name, args)
|
||||
|
||||
# Check jobs
|
||||
@@ -45,6 +47,25 @@ def _systemd_logs(name: str, args: argparse.Namespace) -> int:
|
||||
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:
|
||||
"""Show container logs."""
|
||||
import shutil
|
||||
|
||||
@@ -37,7 +37,11 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
|
||||
|
||||
p = sub.add_parser("create", help="Scaffold a new program")
|
||||
_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("--port", type=int, help="Port (daemons only)")
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
RestartPolicy,
|
||||
RunBase,
|
||||
RunCommand,
|
||||
RunCompose,
|
||||
RunContainer,
|
||||
RunNode,
|
||||
RunPython,
|
||||
|
||||
@@ -18,6 +18,8 @@ def scaffold_project(
|
||||
_scaffold_service(project_dir, name, package_name, description, port or 9000)
|
||||
elif stack == "python-cli":
|
||||
_scaffold_tool(project_dir, name, package_name, description)
|
||||
elif stack == "supabase":
|
||||
_scaffold_supabase(project_dir, name, description)
|
||||
else:
|
||||
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:
|
||||
"""Write content to a file, creating parent directories."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -76,6 +76,39 @@ class TestCreateCommand:
|
||||
comp = config.programs["my-tool2"]
|
||||
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:
|
||||
"""Creating a project with existing name fails."""
|
||||
with patch("castle_cli.commands.create.load_config") as mock_load:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -224,6 +224,7 @@ Discriminated union on `runner`:
|
||||
| `python` | *(none — `uv run` self-syncs)* | `uv run --project <source> --no-dev <program>` | `program`, `args` |
|
||||
| `command` | *(none)* | `which(argv[0])` → resolved path | `argv` |
|
||||
| `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` |
|
||||
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
|
||||
|
||||
@@ -242,6 +243,23 @@ run:
|
||||
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
|
||||
|
||||
```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)
|
||||
- `ServiceSpec` — long-running daemon (run, expose, proxy, 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`
|
||||
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
|
||||
|
||||
|
||||
143
docs/stacks/supabase.md
Normal file
143
docs/stacks/supabase.md
Normal 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).
|
||||
Reference in New Issue
Block a user