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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user