diff --git a/CLAUDE.md b/CLAUDE.md index fe6c0dc..4fac6fa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/.yaml` with an absolute +`source:`. `castle program add` registers an existing repo in place under `programs/.yaml` (or records its `repo:` URL for `castle program clone`). ## Castle CLI diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 67efa1e..f7af4f8 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -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 // (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 // (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}") diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index 770e139..a035b0f 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -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", diff --git a/cli/src/castle_cli/commands/logs.py b/cli/src/castle_cli/commands/logs.py index 95076cb..0413d14 100644 --- a/cli/src/castle_cli/commands/logs.py +++ b/cli/src/castle_cli/commands/logs.py @@ -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 diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 83b56d3..cc6b867 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -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)") diff --git a/cli/src/castle_cli/manifest.py b/cli/src/castle_cli/manifest.py index b7b8ac6..b5a89b7 100644 --- a/cli/src/castle_cli/manifest.py +++ b/cli/src/castle_cli/manifest.py @@ -20,6 +20,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ RestartPolicy, RunBase, RunCommand, + RunCompose, RunContainer, RunNode, RunPython, diff --git a/cli/src/castle_cli/templates/scaffold.py b/cli/src/castle_cli/templates/scaffold.py index 1713439..fc115ff 100644 --- a/cli/src/castle_cli/templates/scaffold.py +++ b/cli/src/castle_cli/templates/scaffold.py @@ -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 + // 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( + """ + + + + + __NAME__ + + + + +

__NAME__

+

__DESC__

+
+
+
Loading…
+ + +""" + ), + ) + + # --- 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) diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index e0a993d..1828cff 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -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: diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 4255589..2f6b2c6 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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 ` into `uv run`, the # node runner bakes `--dir ` 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 -f `` 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] diff --git a/core/src/castle_core/generators/systemd.py b/core/src/castle_core/generators/systemd.py index 52013c7..1af0997 100644 --- a/core/src/castle_core/generators/systemd.py +++ b/core/src/castle_core/generators/systemd.py @@ -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() diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 3cd2000..c6ecf95 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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-`` + + 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"), ] diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 74c8325..3e14c2d 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -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: diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 02164ce..d5c0fd4 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -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(), } diff --git a/core/tests/test_caddyfile.py b/core/tests/test_caddyfile.py index 2f9dcb8..4f00a22 100644 --- a/core/tests/test_caddyfile.py +++ b/core/tests/test_caddyfile.py @@ -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")) diff --git a/core/tests/test_deploy_run_cmd.py b/core/tests/test_deploy_run_cmd.py index d0993ba..316707a 100644 --- a/core/tests/test_deploy_run_cmd.py +++ b/core/tests/test_deploy_run_cmd.py @@ -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 -f 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) == [] diff --git a/core/tests/test_resolution.py b/core/tests/test_resolution.py index 1c7f4ae..4bd3bea 100644 --- a/core/tests/test_resolution.py +++ b/core/tests/test_resolution.py @@ -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: diff --git a/core/tests/test_systemd.py b/core/tests/test_systemd.py index 94b62fb..c6e9c4f 100644 --- a/core/tests/test_systemd.py +++ b/core/tests/test_systemd.py @@ -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.""" diff --git a/docs/registry.md b/docs/registry.md index 0d27c60..4d6b8c5 100644 --- a/docs/registry.md +++ b/docs/registry.md @@ -224,6 +224,7 @@ Discriminated union on `runner`: | `python` | *(none — `uv run` self-syncs)* | `uv run --project --no-dev ` | `program`, `args` | | `command` | *(none)* | `which(argv[0])` → resolved path | `argv` | | `container` | *(none)* | `docker`/`podman` `run` | `image`, `command`, `ports`, `volumes` | +| `compose` | *(none)* | `docker compose -p -f 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- +``` + ### `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` diff --git a/docs/stacks/supabase.md b/docs/stacks/supabase.md new file mode 100644 index 0000000..c2e2ca1 --- /dev/null +++ b/docs/stacks/supabase.md @@ -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//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).