feat(supabase): isolate each app in its own Postgres schema + stack teardown
Rework the supabase stack so every app owns an isolated Postgres schema
(= program name) instead of sharing `public` with a table_prefix. This
gives a clean, knowable teardown and drops migration version tracking to
per-app, so version tokens can no longer collide across apps.
- stacks: add StackHandler.owns_data + teardown() (default no-op). Supabase
build now creates+grants the app schema, tracks migrations in
<schema>.schema_migrations, and applies each with search_path set to the
schema (write unqualified names). teardown = drop schema <app> cascade.
Fix _substrate_db_url default port 5432 -> 5433 (substrate's direct PG).
- deploy: derive ${supabase_app_schemas} from registered apps; substrate maps
PGRST_DB_SCHEMAS: public,storage,graphql_public${supabase_app_schemas} so
PostgREST exposes each app schema (restart substrate after add/remove).
- delete: generic owns_data -> teardown path behind new --purge-data flag,
replacing the hardcoded supabase remnant note.
- scaffold + docs: new apps born schema-isolated (unqualified tables,
db:{schema}, schema: in supabase.app.yaml).
This commit is contained in:
@@ -174,7 +174,9 @@ def _acme_preflight(config: CastleConfig, messages: list[str]) -> None:
|
||||
"won't get a wildcard cert (serving plain HTTP on the gateway port)."
|
||||
)
|
||||
return
|
||||
token_env = _DNS_TOKEN_ENV.get(gw.acme_dns_provider or "cloudflare", "CLOUDFLARE_API_TOKEN")
|
||||
token_env = _DNS_TOKEN_ENV.get(
|
||||
gw.acme_dns_provider or "cloudflare", "CLOUDFLARE_API_TOKEN"
|
||||
)
|
||||
svc = config.services.get(_GATEWAY_NAME)
|
||||
env = dict(svc.defaults.env) if (svc and svc.defaults and svc.defaults.env) else {}
|
||||
if token_env not in env:
|
||||
@@ -216,11 +218,15 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
|
||||
# exact commands rather than silently assuming they're routed.
|
||||
tid = registry.node.tunnel_id
|
||||
for h in hosts:
|
||||
messages.append(f" public: {h} (route once: cloudflared tunnel route dns {tid} {h})")
|
||||
messages.append(
|
||||
f" public: {h} (route once: cloudflared tunnel route dns {tid} {h})"
|
||||
)
|
||||
|
||||
tunnel_unit = unit_name(_TUNNEL_NAME)
|
||||
active = subprocess.run(
|
||||
["systemctl", "--user", "is-active", tunnel_unit], capture_output=True, text=True
|
||||
["systemctl", "--user", "is-active", tunnel_unit],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if active.stdout.strip() == "active":
|
||||
subprocess.run(["systemctl", "--user", "restart", tunnel_unit], check=False)
|
||||
@@ -278,15 +284,41 @@ def _public_url(
|
||||
return None
|
||||
|
||||
|
||||
def _supabase_app_schemas(config: CastleConfig) -> str:
|
||||
"""The ``${supabase_app_schemas}`` placeholder: each registered supabase app's
|
||||
own schema, comma-prefixed and joined (or '' when there are none).
|
||||
|
||||
The substrate exposes app schemas through PostgREST by listing them in
|
||||
PGRST_DB_SCHEMAS; its deployment maps that env to
|
||||
``public,storage,graphql_public${supabase_app_schemas}``. Comma-prefixing each
|
||||
entry keeps the base list valid when zero apps are registered (no trailing
|
||||
comma). Adding/removing a supabase app thus changes this list — the substrate
|
||||
needs a restart (recreate) after `castle deploy` to pick it up.
|
||||
"""
|
||||
from castle_core.stacks import app_schema
|
||||
|
||||
schemas = sorted(
|
||||
app_schema(pn) for pn, ps in config.programs.items() if ps.stack == "supabase"
|
||||
)
|
||||
return "".join(f",{s}" for s in schemas)
|
||||
|
||||
|
||||
def _env_context(
|
||||
name: str, config_key: str, port: int | None, public_url: str | None = None
|
||||
name: str,
|
||||
config_key: str,
|
||||
port: int | None,
|
||||
public_url: str | None = None,
|
||||
supabase_app_schemas: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Placeholder values for defaults.env: ${name}/${data_dir}/${port}/${public_url}."""
|
||||
"""Placeholder values for defaults.env: ${name}/${data_dir}/${port}/${public_url}/
|
||||
${supabase_app_schemas}."""
|
||||
ctx = {"name": name, "data_dir": str(DATA_DIR / config_key)}
|
||||
if port is not None:
|
||||
ctx["port"] = str(port)
|
||||
if public_url is not None:
|
||||
ctx["public_url"] = public_url
|
||||
if supabase_app_schemas is not None:
|
||||
ctx["supabase_app_schemas"] = supabase_app_schemas
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -313,9 +345,7 @@ def _write_secret_env_file(name: str, secret_env: dict[str, str]) -> Path | None
|
||||
return path
|
||||
|
||||
|
||||
def _resolve_description(
|
||||
config: CastleConfig, spec: DeploymentBase
|
||||
) -> str | None:
|
||||
def _resolve_description(config: CastleConfig, spec: DeploymentBase) -> str | None:
|
||||
"""Get description, falling through to program if referenced."""
|
||||
if spec.description:
|
||||
return spec.description
|
||||
@@ -397,7 +427,8 @@ def _build_deployed(
|
||||
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
|
||||
public_url = _public_url(config, name, expose, port)
|
||||
env, secret_env = resolve_env_split(
|
||||
raw_env, _env_context(name, config_key, port, public_url)
|
||||
raw_env,
|
||||
_env_context(name, config_key, port, public_url, _supabase_app_schemas(config)),
|
||||
)
|
||||
secret_env_file = _write_secret_env_file(name, secret_env)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user