feat: supabase stack seeds a requires on the substrate at create

A stack's substrate dependency now becomes a real encoded `requires` at
`castle program create` time (STACK_REQUIRES), so the relationship graph shows it.
This keeps `stack` uncoupled from the runtime model — the stack declares the edge
once at creation; the graph only ever reads the encoded `requires`, never the stack.
This commit is contained in:
2026-07-05 15:24:18 -07:00
parent b9d38be707
commit d14b9eafa7
3 changed files with 19 additions and 1 deletions

View File

@@ -16,6 +16,7 @@ from castle_cli.manifest import (
ManageSpec, ManageSpec,
PathDeployment, PathDeployment,
ProgramSpec, ProgramSpec,
Requirement,
RunPython, RunPython,
SystemdDeployment, SystemdDeployment,
SystemdSpec, SystemdSpec,
@@ -38,6 +39,14 @@ STACK_BUILD_OUTPUTS: dict[str, str] = {
"react-vite": "dist", "react-vite": "dist",
} }
# Substrate a stack's apps depend on — seeded as a `requires` at creation so the
# relationship graph shows it. This keeps `stack` uncoupled from the runtime model:
# the stack declares the edge once here; the graph only ever reads the encoded
# `requires`, never the stack. See docs/relationships.md.
STACK_REQUIRES: dict[str, list[Requirement]] = {
"supabase": [Requirement(kind="deployment", ref="supabase")],
}
def next_available_port(config: object) -> int: def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway).""" """Find the next available port starting from 9001 (9000 is reserved for gateway)."""
@@ -111,6 +120,8 @@ def run_create(args: argparse.Namespace) -> int:
source=str(project_dir), source=str(project_dir),
stack=stack, stack=stack,
build=build, build=build,
# Seed the stack's substrate dependency (e.g. supabase) as a real `requires`.
requires=list(STACK_REQUIRES.get(stack or "", [])),
) )
if kind == "tool": if kind == "tool":
# A PATH-managed deployment: installed via `uv tool install`, no unit/route. # A PATH-managed deployment: installed via `uv tool install`, no unit/route.
@@ -120,7 +131,10 @@ def run_create(args: argparse.Namespace) -> int:
elif kind == "static": elif kind == "static":
# A caddy-managed static deployment: no systemd unit, served from the build dir. # A caddy-managed static deployment: no systemd unit, served from the build dir.
config.deployments[name] = CaddyDeployment( config.deployments[name] = CaddyDeployment(
id=name, manager="caddy", program=name, root=static_root or "dist", id=name,
manager="caddy",
program=name,
root=static_root or "dist",
description=description, description=description,
) )
elif kind == "service": elif kind == "service":

View File

@@ -21,6 +21,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
Reach, Reach,
ReadinessHttpGet, ReadinessHttpGet,
RemoteDeployment, RemoteDeployment,
Requirement,
RestartPolicy, RestartPolicy,
RunCommand, RunCommand,
RunCompose, RunCompose,

View File

@@ -107,6 +107,9 @@ class TestCreateCommand:
comp = config.programs["guestbook"] comp = config.programs["guestbook"]
assert comp.stack == "supabase" assert comp.stack == "supabase"
assert comp.build is not None and comp.build.outputs == ["public"] assert comp.build is not None and comp.build.outputs == ["public"]
# The supabase stack seeds a `requires` on the substrate so the graph shows
# the dependency (stack stays uncoupled — it just declares the edge once).
assert [(r.kind, r.ref) for r in comp.requires] == [("deployment", "supabase")]
dep = config.deployments["guestbook"] dep = config.deployments["guestbook"]
assert dep.manager == "caddy" assert dep.manager == "caddy"
assert dep.root == "public" assert dep.root == "public"