diff --git a/cli/src/castle_cli/commands/delete.py b/cli/src/castle_cli/commands/delete.py index ab4bc9c..75fd244 100644 --- a/cli/src/castle_cli/commands/delete.py +++ b/cli/src/castle_cli/commands/delete.py @@ -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 (.) 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 diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index f27e2d3..200c34b 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -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") diff --git a/cli/src/castle_cli/templates/scaffold.py b/cli/src/castle_cli/templates/scaffold.py index fc115ff..d0420b9 100644 --- a/cli/src/castle_cli/templates/scaffold.py +++ b/cli/src/castle_cli/templates/scaffold.py @@ -576,16 +576,19 @@ def _scaffold_supabase(project_dir: Path, name: str, description: str) -> None: // 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_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 = {