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