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:
2026-07-02 07:39:02 -07:00
parent 4c12882cc7
commit b917db5e00
6 changed files with 378 additions and 92 deletions

View File

@@ -1,7 +1,15 @@
"""castle delete — remove a program/service/job from the registry.
"""castle delete — remove a program/deployment from the registry AND tear it down.
Config-only by default (removes the castle.yaml entry; leaves source and any
installed binary in place). Use --source to also delete the source directory.
Deleting cascades: a program's referencing deployments are taken offline (stop +
disable a service/job, uninstall a tool from PATH, drop a static route) before the
config entries are removed, then the runtime is reconciled (`deploy()` prunes
orphan units, regenerates the Caddyfile, reloads the gateway + tunnel) so nothing
is left running or served. Use --source to also delete the source directory.
Persistent *data* a program's stack created (e.g. a Supabase app's Postgres
schema) is destroyed only with --purge-data — it survives an ordinary delete and
is surfaced as a remnant otherwise. One remnant is still only surfaced, not
auto-removed (pending a DNS-token decision): a public service's Cloudflare CNAME.
"""
from __future__ import annotations
@@ -16,7 +24,7 @@ from castle_cli.config import load_config, save_config
def run_delete(args: argparse.Namespace) -> int:
config = load_config()
name = args.name
resource = getattr(args, "resource", None) # "program" | "service" | "job"
resource = getattr(args, "resource", None) # "program" | "service" | "job" | ...
# Resolve which sections this delete touches (scoped to one resource). Any
# deployment resource name (service/job/tool/static/deployment) targets the
@@ -29,32 +37,45 @@ def run_delete(args: argparse.Namespace) -> int:
print(f"Error: no{where} '{name}' in castle.yaml")
return 1
where = [s for s, present in
(("program", in_programs), ("deployment", in_deployment)) if present]
where = [
s for s, present in (("program", in_programs), ("deployment", in_deployment)) if present
]
# A program can't be removed while a deployment still references it. A ref
# named the same is removed in this call; any other referencing deployment
# would be left dangling, so refuse.
# Cascade: every deployment referencing this program is torn down and removed
# (a program and its 1:1 service/tool/static are one thing to the user). A
# deployment-only delete targets just the co-named deployment.
deployments_to_remove: list[str] = []
if in_programs:
dangling = [
d for d, spec in config.deployments.items()
if spec.program == name and not (d == name and in_deployment)
deployments_to_remove = [
d for d, spec in config.deployments.items() if spec.program == name
]
if dangling:
print(
"Error: programs with active jobs or services cannot be removed.\n"
f" Delete these first: {', '.join(dangling)}"
)
return 1
if in_deployment and name not in deployments_to_remove:
deployments_to_remove.append(name)
# Capture remnant facts BEFORE mutating config: public CNAMEs, and the program
# entry itself — its stack may own persistent data (a DB schema) that survives
# a code delete unless --purge-data drops it via the stack's `teardown`.
public_hosts = _public_hosts(config, deployments_to_remove)
from castle_core.stacks import get_handler
program_spec = config.programs.get(name) if in_programs else None
stack = program_spec.stack if program_spec else None
handler = get_handler(stack)
owns_data = bool(getattr(handler, "owns_data", False)) and program_spec is not None
# Resolve source dir (from the program entry) for the optional --source removal.
source_dir: Path | None = None
if in_programs and config.programs[name].source:
source_dir = Path(config.programs[name].source)
purge_data = getattr(args, "purge_data", False)
print(f"Will remove '{name}' from castle.yaml ({', '.join(where)}).")
if deployments_to_remove:
print(f"Will tear down deployment(s): {', '.join(deployments_to_remove)}")
if args.source and source_dir:
print(f"Will ALSO delete source directory: {source_dir}")
if owns_data and purge_data:
print(f"Will ALSO destroy persistent data (stack: {stack}).")
# Confirm unless --yes.
if not args.yes:
@@ -67,14 +88,38 @@ def run_delete(args: argparse.Namespace) -> int:
print("Aborted (no input). Re-run with --yes to confirm non-interactively.")
return 1
# Remove registry entries.
# Take each deployment offline in its mode, then drop the config entry. Teardown
# is best-effort — the config is removed even if the runtime is already gone.
if deployments_to_remove:
import asyncio
from castle_core.lifecycle import deactivate
for d in deployments_to_remove:
try:
res = asyncio.run(deactivate(d, config, config.root))
if getattr(res, "message", None):
print(f" {res.message}")
except Exception as e:
print(f" warning: teardown of '{d}' failed: {e}")
del config.deployments[d]
if in_programs:
del config.programs[name]
if in_deployment:
del config.deployments[name]
save_config(config)
print(f"Removed '{name}' from castle.yaml ({', '.join(where)}).")
# Converge the runtime: prune orphan units, regenerate the Caddyfile (dropping
# the static route), reload the gateway + tunnel.
if deployments_to_remove:
from castle_core.deploy import deploy
try:
deploy()
print("Reconciled runtime (castle deploy).")
except Exception as e:
print(f"warning: reconcile (castle deploy) failed — run 'castle deploy': {e}")
# Optional: delete the source directory.
if args.source and source_dir:
if source_dir.exists():
@@ -83,16 +128,45 @@ def run_delete(args: argparse.Namespace) -> int:
else:
print(f"Source directory not found (already gone): {source_dir}")
# Warn about runtime artifacts we did NOT touch.
if in_deployment:
print(
f"\nNote: the systemd unit for '{name}' (if deployed) was NOT removed.\n"
f" Run: castle service disable {name}"
)
if shutil.which(name):
print(
f"\nNote: '{name}' is still installed on PATH. To remove it:\n"
f" castle uninstall {name}"
)
# Surface the remnants we don't yet auto-remove.
if public_hosts:
print("\nNote: public DNS record(s) still exist in Cloudflare (not auto-removed):")
for h in public_hosts:
print(f" - {h}")
print(" Remove them in the Cloudflare dashboard for the public zone.")
# Persistent data the stack owns: destroy it on --purge-data, else surface it.
if owns_data:
if purge_data:
import asyncio
try:
res = asyncio.run(handler.teardown(name, program_spec, config.root))
print(f"\n{res.output}")
if res.status != "ok":
print(" warning: data teardown reported an error (see above).")
except Exception as e:
print(f"\nwarning: data teardown failed: {e}")
else:
print(
f"\nNote: this program's persistent data (stack: {stack}) was left "
"intact.\n Re-run with --purge-data to destroy it."
)
return 0
def _public_hosts(config, deployment_names: list[str]) -> list[str]:
"""The Cloudflare CNAMEs (<subdomain>.<public_domain>) of any public deployments
being removed — surfaced so the operator can clean up DNS."""
gw = getattr(config, "gateway", None)
public_domain = getattr(gw, "public_domain", None) if gw else None
if not public_domain:
return []
hosts: list[str] = []
for d in deployment_names:
spec = config.deployments.get(d)
if spec is None or not getattr(spec, "public", False):
continue
sub = getattr(spec, "subdomain", None) or d
hosts.append(f"{sub}.{public_domain}")
return hosts

View File

@@ -30,7 +30,11 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
sub = prog.add_subparsers(dest="program_command")
p = sub.add_parser("list", help="List programs")
p.add_argument("--kind", choices=["service", "job", "tool", "static", "reference"], help="Filter by derived kind")
p.add_argument(
"--kind",
choices=["service", "job", "tool", "static", "reference"],
help="Filter by derived kind",
)
p.add_argument("--stack", help="Filter by stack")
p.add_argument("--json", action="store_true", help="Output as JSON")
@@ -55,6 +59,11 @@ def _build_program_group(subparsers: argparse._SubParsersAction) -> None:
p = sub.add_parser("delete", help="Remove a program from castle.yaml")
_add_name(p, "Program name")
p.add_argument("--source", action="store_true", help="Also delete the source directory")
p.add_argument(
"--purge-data",
action="store_true",
help="Also destroy the program's persistent data (e.g. a supabase app's DB schema)",
)
p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
p = sub.add_parser("run", help="Run a program's declared run command")
@@ -87,7 +96,9 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
p.add_argument("--json", action="store_true", help="Machine-readable output")
sub.add_parser("install", help="Install a tool on PATH").add_argument("name", help="Tool name")
sub.add_parser("uninstall", help="Remove a tool from PATH").add_argument("name", help="Tool name")
sub.add_parser("uninstall", help="Remove a tool from PATH").add_argument(
"name", help="Tool name"
)
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
@@ -134,6 +145,7 @@ def _build_deployment_group(subparsers: argparse._SubParsersAction, kind: str) -
_add_name(p, f"{kind.capitalize()} name")
p.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
p.add_argument("--source", action="store_true", help=argparse.SUPPRESS)
p.add_argument("--purge-data", action="store_true", help=argparse.SUPPRESS)
cap = f"{kind.capitalize()} name"
_add_name(sub.add_parser("deploy", help=f"Deploy this {kind} (unit + gateway)"), cap)
@@ -186,7 +198,11 @@ def build_parser() -> argparse.ArgumentParser:
# Cross-resource overview
p = subparsers.add_parser("list", help="List programs, services, jobs, and tools")
p.add_argument("--kind", choices=["service", "job", "tool", "static", "reference"], help="Filter by derived kind")
p.add_argument(
"--kind",
choices=["service", "job", "tool", "static", "reference"],
help="Filter by derived kind",
)
p.add_argument("--stack", help="Filter by stack")
p.add_argument("--json", action="store_true", help="Output as JSON")

View File

@@ -576,16 +576,19 @@ def _scaffold_supabase(project_dir: Path, name: str, description: str) -> None:
/<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.
shared substrate. Each app is isolated in its **own Postgres schema** (the app
id) rather than sharing `public` — so `castle program build` creates+grants the
schema and tracks migrations per-app, and `castle delete --purge-data` drops
the whole schema cleanly. Rows are further protected by RLS.
"""
ident = name.replace("-", "_") # a safe SQL/JS identifier
table = f"{ident}_entries"
ident = name.replace("-", "_") # a safe SQL/JS identifier — and the app schema
table = "entries" # unqualified; lives in the app's own schema (search_path)
def sub(text: str) -> str:
return (
text.replace("__NAME__", name)
.replace("__IDENT__", ident)
.replace("__SCHEMA__", ident)
.replace("__TABLE__", table)
.replace("__DESC__", description)
)
@@ -606,8 +609,10 @@ substrate: supabase # the shared castle service this app deploys agains
auth: public
# shared: [alice, bob]
# Tables are namespaced by this prefix in the shared `public` schema.
table_prefix: __IDENT___
# This app is isolated in its own Postgres schema (created + exposed through
# PostgREST by `castle program build`). The frontend selects it via
# supabase-js `db: { schema }`.
schema: __SCHEMA__
"""
),
)
@@ -617,11 +622,14 @@ table_prefix: __IDENT___
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.
-- via the versioned migration runner (tracked per-app in
-- <schema>.schema_migrations; only unapplied migrations run). Forward-only —
-- never edit an applied migration; add a new numbered file instead.
--
-- The runner sets search_path to this app's own schema, so unqualified names
-- land there (NOT in public). Keep tables unqualified for schema portability.
create table if not exists public.__TABLE__ (
create table if not exists __TABLE__ (
id bigint generated always as identity primary key,
message text not null check (char_length(message) <= 500),
author text,
@@ -632,13 +640,13 @@ create table if not exists public.__TABLE__ (
-- 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;
alter table __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_read" on __TABLE__;
create policy "__IDENT___public_read" on __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);
drop policy if exists "__IDENT___public_write" on __TABLE__;
create policy "__IDENT___public_write" on __TABLE__ for insert with check (true);
"""
),
)
@@ -672,6 +680,7 @@ serve((_req: Request) => {
window.APP = {
SUPABASE_URL: "https://supabase.lan",
SUPABASE_ANON_KEY: "PASTE_ANON_KEY_HERE",
SCHEMA: "__SCHEMA__", // this app's isolated Postgres schema
TABLE: "__TABLE__",
};
"""
@@ -691,8 +700,8 @@ window.APP = {
<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 { SUPABASE_URL, SUPABASE_ANON_KEY, SCHEMA, TABLE } = window.APP;
const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { db: { schema: SCHEMA } });
const list = document.getElementById("list");
async function refresh() {