+
+ )
+
+ return { element, value }
+}
+
/** Save/Delete footer shared by the typed config forms. */
export function FormFooter({
saving,
diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py
index 3282d0e..6bbb966 100644
--- a/cli/src/castle_cli/commands/create.py
+++ b/cli/src/castle_cli/commands/create.py
@@ -39,12 +39,12 @@ STACK_BUILD_OUTPUTS: dict[str, str] = {
"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
+# Substrate a stack's apps depend on — seeded as a deployment `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")],
+ "supabase": [Requirement(ref="supabase")],
}
@@ -120,13 +120,18 @@ def run_create(args: argparse.Namespace) -> int:
source=str(project_dir),
stack=stack,
build=build,
- # Seed the stack's substrate dependency (e.g. supabase) as a real `requires`.
- requires=list(STACK_REQUIRES.get(stack or "", [])),
)
+ # The stack's substrate dependency (e.g. supabase) is a deployment-to-deployment
+ # `requires` — seeded on the deployment so the relationship graph shows the edge.
+ seeded_requires = list(STACK_REQUIRES.get(stack or "", []))
if kind == "tool":
# A PATH-managed deployment: installed via `uv tool install`, no unit/route.
config.tools[name] = PathDeployment(
- id=name, manager="path", program=name, description=description
+ id=name,
+ manager="path",
+ program=name,
+ description=description,
+ requires=seeded_requires,
)
elif kind == "static":
# A caddy-managed static deployment: no systemd unit, served from the build dir.
@@ -136,6 +141,7 @@ def run_create(args: argparse.Namespace) -> int:
program=name,
root=static_root or "dist",
description=description,
+ requires=seeded_requires,
)
elif kind == "service":
prefix = name.replace("-", "_").upper()
@@ -151,6 +157,7 @@ def run_create(args: argparse.Namespace) -> int:
health_path="/health",
)
),
+ requires=seeded_requires,
proxy=True, # expose at .
manage=ManageSpec(systemd=SystemdSpec()),
# python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's
diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py
index 6218a15..93ca825 100644
--- a/cli/tests/test_create.py
+++ b/cli/tests/test_create.py
@@ -105,12 +105,13 @@ class TestCreateCommand:
comp = config.programs["guestbook"]
assert comp.stack == "supabase"
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.statics["guestbook"]
assert dep.manager == "caddy"
assert dep.root == "public"
+ # The supabase stack seeds a `requires` on the substrate (on the deployment)
+ # so the graph shows the dependency (stack stays uncoupled — it just declares
+ # the edge once).
+ assert [(r.kind, r.ref) for r in dep.requires] == [("deployment", "supabase")]
assert config.deployments_of("guestbook") == [("guestbook", "static")]
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None:
diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py
index 2a2b0e4..9cfc4fe 100644
--- a/core/src/castle_core/deploy.py
+++ b/core/src/castle_core/deploy.py
@@ -503,18 +503,12 @@ def _target_url(config: CastleConfig, target_name: str) -> str | None:
return _public_url(config, target_name, getattr(dep, "http_exposed", False), tport)
-def _requires_env(
- config: CastleConfig, dep: DeploymentSpec, config_key: str
-) -> dict[str, str]:
- """Env generated FROM a deployment's ``requires`` — a ``{kind: deployment,
- bind: VAR}`` requirement sets ``VAR`` to the target's URL. Env is derived from
- the dependency, never scraped back into one (see docs/relationships.md)."""
- prog = config.programs.get(config_key)
- reqs = list(getattr(dep, "requires", []) or [])
- if prog:
- reqs += list(prog.requires)
+def _requires_env(config: CastleConfig, dep: DeploymentSpec) -> dict[str, str]:
+ """Env generated FROM a deployment's ``requires`` — a ``{ref, bind: VAR}``
+ requirement sets ``VAR`` to the target deployment's URL. Env is derived from the
+ dependency, never scraped back into one (see docs/relationships.md)."""
out: dict[str, str] = {}
- for r in reqs:
+ for r in getattr(dep, "requires", []) or []:
if r.kind == "deployment" and r.bind:
url = _target_url(config, r.ref)
if url:
@@ -676,7 +670,7 @@ def _build_deployed(
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
# Env generated from `requires` ({kind: deployment, bind: VAR} → target URL).
# An explicit defaults.env value always wins — a hand-set var is never clobbered.
- for var, url in _requires_env(config, dep, config_key).items():
+ for var, url in _requires_env(config, dep).items():
raw_env.setdefault(var, url)
public_url = _public_url(config, name, expose, port)
ctx = _env_context(
diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py
index ad60ff3..82a4ee5 100644
--- a/core/src/castle_core/manifest.py
+++ b/core/src/castle_core/manifest.py
@@ -284,23 +284,20 @@ class Capability(BaseModel):
class Requirement(BaseModel):
- """A precondition — something that must be true for a program/deployment to be
- *functional*. The ``kind`` fixes both the meaning and how it's checked (there is
- no separate purpose tag):
+ """A precondition — another **deployment** that must exist for this one to be
+ *functional* (``ref`` = the target deployment's name). ``bind`` names the env
+ var castle projects the target's URL into — env is derived *from* the
+ requirement, never scraped back into it.
- - ``system`` — a host package/binary must be installed (``ref`` = package).
- - ``deployment`` — another deployment must exist/run (``ref`` = its name).
-
- ``version`` is reserved for a future constraint (unused now). ``bind`` (for a
- ``deployment`` requirement) names the env var castle projects the target's URL
- into — env is derived *from* the requirement, never scraped back into it.
-
- See docs/relationships.md. ``system_dependencies`` is the ``kind: system`` case.
+ A deployment declares these in its ``requires`` list; ``kind`` defaults to
+ ``deployment`` (write just ``- ref: foo``). The ``system`` kind is not written
+ here — a program's host-package preconditions live in ``system_dependencies``,
+ and the relationship model synthesizes ``kind: system`` requirements from it for
+ the ``functional?`` check. See docs/relationships.md.
"""
- kind: Literal["system", "deployment"]
+ kind: Literal["system", "deployment"] = "deployment"
ref: str
- version: str | None = None
bind: str | None = None
@@ -384,10 +381,10 @@ class ProgramSpec(BaseModel):
# Per-program dev verb overrides (declared verbs override the stack default).
commands: CommandsSpec | None = None
- # `requires` is the general precondition relation (see docs/relationships.md).
- # `system_dependencies` is kept as the `{kind: system}` alias/back-compat; both
- # are merged when evaluating what a program requires.
- requires: list[Requirement] = Field(default_factory=list)
+ # Host-package preconditions (apt packages / binaries) intrinsic to this
+ # software. The relationship model checks these (`which`/`dpkg`) to derive the
+ # `functional?` light. Deployment-to-deployment dependencies are NOT here — they
+ # live on the deployment's `requires` (see DeploymentBase). See docs/relationships.md.
system_dependencies: list[str] = Field(default_factory=list)
install_extras: list[str] = Field(default_factory=list)
version: str | None = None
@@ -431,8 +428,10 @@ class DeploymentBase(BaseModel):
)
description: str | None = None
defaults: DefaultsSpec | None = None
- # Runtime preconditions (e.g. another deployment that must exist). See
- # docs/relationships.md; merged with the program's `requires` when evaluated.
+ # Deployment-to-deployment preconditions: other deployments this one needs
+ # (e.g. a frontend that requires its API + the supabase substrate). Each entry
+ # is `- ref: ` (+ optional `bind: ENV_VAR` to project the target's
+ # URL into env). Drives the relationship graph's edges. See docs/relationships.md.
requires: list[Requirement] = Field(default_factory=list)
# Declared on/off state. `castle apply` converges reality to this: enabled
# deployments are activated (service started, tool installed, route served),
diff --git a/core/src/castle_core/relations.py b/core/src/castle_core/relations.py
index be36f27..5a822c5 100644
--- a/core/src/castle_core/relations.py
+++ b/core/src/castle_core/relations.py
@@ -1,15 +1,17 @@
"""The relationship model — derived, never stored. See docs/relationships.md.
Entities: **program**, **deployment**, **repo** (a repo is a git working copy;
-programs sharing a toplevel form a monorepo). One encoded relation, **`requires`**
-(a precondition, typed by ``kind``: ``system`` = must be installed, ``deployment``
-= must exist). Everything else — repos, env wiring, fan-in, and the predicates
+programs sharing a toplevel form a monorepo). Preconditions come from two encoded
+sources: a deployment's **`requires`** (other deployments it needs) and its
+program's **`system_dependencies`** (host packages). These are unified here into one
+requirement set (typed by ``kind``: ``deployment`` = must exist, ``system`` = must be
+installed). Everything else — repos, env wiring, fan-in, and the predicates
``functional?`` / ``fresh?`` / ``deployed?`` — is computed here on demand.
Governing rule: *predicates are derived; we encode only the non-derivable.* So this
-module reads the encoded ``requires`` (plus ``system_dependencies`` as its
-``kind: system`` alias) and derives the rest. It does **not** scrape env for
-dependencies — env is generated *from* requirements, not the reverse.
+module reads the encoded ``requires`` + ``system_dependencies`` and derives the rest.
+It does **not** scrape env for dependencies — env is generated *from* requirements,
+not the reverse.
"""
from __future__ import annotations
@@ -115,16 +117,16 @@ def derive_repos(config: CastleConfig) -> dict[str, Repo]:
def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
- """The full requirement set for a deployment: its own ``requires`` plus its
- program's ``requires`` and ``system_dependencies`` (the ``kind: system`` alias),
- de-duplicated by (kind, ref)."""
+ """The full requirement set for a deployment: its own ``requires`` (deployment
+ dependencies) plus its program's ``system_dependencies`` synthesized as
+ ``kind: system`` requirements, de-duplicated by (kind, ref)."""
reqs: list[Requirement] = []
- # A bare name may span kinds — union their requirements (plus their program's).
+ # A bare name may span kinds — union their requirements (plus their program's
+ # host-package deps as the synthesized `kind: system` set).
for _kind, dep in config.deployments_named(dep_name):
reqs += list(getattr(dep, "requires", []) or [])
prog = config.programs.get(_program_of(dep_name, dep))
if prog:
- reqs += list(prog.requires)
reqs += [
Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies
]
diff --git a/core/tests/test_relations.py b/core/tests/test_relations.py
index b709a93..8cf7fc3 100644
--- a/core/tests/test_relations.py
+++ b/core/tests/test_relations.py
@@ -6,17 +6,18 @@ import pytest
import castle_core.config as C
from castle_core import relations as R
-from castle_core.manifest import ProgramSpec, Requirement, SystemdDeployment
+from castle_core.manifest import ProgramSpec, SystemdDeployment
-def _dep(program: str) -> SystemdDeployment:
- return SystemdDeployment.model_validate(
- {
- "manager": "systemd",
- "program": program,
- "run": {"launcher": "command", "argv": [program]},
- }
- )
+def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
+ spec: dict = {
+ "manager": "systemd",
+ "program": program,
+ "run": {"launcher": "command", "argv": [program]},
+ }
+ if requires is not None:
+ spec["requires"] = requires
+ return SystemdDeployment.model_validate(spec)
def _cfg(programs: dict, deployments: dict) -> C.CastleConfig:
@@ -38,20 +39,12 @@ def test_system_dependencies_is_the_system_requirement_alias() -> None:
assert [(r.kind, r.ref) for r in reqs] == [("system", "pandoc")]
-def test_requirements_merge_program_and_deployment_deduped() -> None:
- prog = ProgramSpec(
- id="web",
- system_dependencies=["pandoc"],
- requires=[Requirement(kind="deployment", ref="api", bind="API_URL")],
- )
- dep = SystemdDeployment.model_validate(
- {
- "manager": "systemd",
- "program": "web",
- "run": {"launcher": "command", "argv": ["web"]},
- "requires": [{"kind": "system", "ref": "pandoc"}],
- } # dup of program's
- )
+def test_requirements_merge_system_deps_and_deployment_requires_deduped() -> None:
+ """The requirement set unions the program's system_dependencies (as {kind: system})
+ with the deployment's own requires, de-duplicated by (kind, ref)."""
+ prog = ProgramSpec(id="web", system_dependencies=["pandoc"])
+ # Two identical deployment requires — deduped to one.
+ dep = _dep("web", requires=[{"ref": "api"}, {"ref": "api"}])
cfg = _cfg(
{"web": prog, "api": ProgramSpec(id="api")}, {"web": dep, "api": _dep("api")}
)
@@ -62,15 +55,13 @@ def test_requirements_merge_program_and_deployment_deduped() -> None:
def test_deployment_edge_carries_bind_and_counts_fan_in() -> None:
"""A {kind: deployment} requirement becomes an edge (with bind), and the target's
fan-in is the count of distinct dependents."""
- consumer = ProgramSpec(
- id="web", requires=[Requirement(kind="deployment", ref="api", bind="API_URL")]
- )
- consumer2 = ProgramSpec(
- id="cli", requires=[Requirement(kind="deployment", ref="api")]
- )
cfg = _cfg(
- {"web": consumer, "cli": consumer2, "api": ProgramSpec(id="api")},
- {"web": _dep("web"), "cli": _dep("cli"), "api": _dep("api")},
+ {"web": ProgramSpec(id="web"), "cli": ProgramSpec(id="cli"), "api": ProgramSpec(id="api")},
+ {
+ "web": _dep("web", requires=[{"ref": "api", "bind": "API_URL"}]),
+ "cli": _dep("cli", requires=[{"ref": "api"}]),
+ "api": _dep("api"),
+ },
)
m = R.build_model(cfg, check=False)
edge = next(e for e in m.edges if e.src == "web" and e.dst == "api")
@@ -82,14 +73,10 @@ def test_deployment_edge_carries_bind_and_counts_fan_in() -> None:
def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) -> None:
"""`functional?` is derived: a missing system package is unmet; a present
deployment requirement is satisfied."""
- prog = ProgramSpec(
- id="web",
- system_dependencies=["pandoc"],
- requires=[Requirement(kind="deployment", ref="api")],
- )
+ prog = ProgramSpec(id="web", system_dependencies=["pandoc"])
cfg = _cfg(
{"web": prog, "api": ProgramSpec(id="api")},
- {"web": _dep("web"), "api": _dep("api")},
+ {"web": _dep("web", requires=[{"ref": "api"}]), "api": _dep("api")},
)
monkeypatch.setattr(R.shutil, "which", lambda _: None) # not on PATH
monkeypatch.setattr(R, "_dpkg_installed", lambda _: False) # nor as a package
@@ -113,8 +100,10 @@ def test_system_requirement_satisfied_by_package_not_on_path(
def test_missing_deployment_requirement_is_unmet() -> None:
- prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")])
- cfg = _cfg({"web": prog}, {"web": _dep("web")})
+ cfg = _cfg(
+ {"web": ProgramSpec(id="web")},
+ {"web": _dep("web", requires=[{"ref": "ghost"}])},
+ )
m = R.build_model(cfg, check=True)
web = next(n for n in m.nodes if n.name == "web")
assert web.unmet == ["deployment:ghost"] and web.functional is False
diff --git a/docs/relationships.md b/docs/relationships.md
index 52e6833..99aa915 100644
--- a/docs/relationships.md
+++ b/docs/relationships.md
@@ -28,25 +28,36 @@ abstraction, in that order.
on each program's source; several programs sharing one toplevel is a *monorepo*.
Never stored.
-## The one encoded relation: `requires`
+## Encoded preconditions: `requires` + `system_dependencies`
-Everything we were calling "substrate", "wiring", or "dependency" is one relation —
-**`requires`** ("A must have B to be functional") — with a typed target. The
-**kind fixes the meaning and the check**; there is no separate purpose/`for` tag:
+Everything we were calling "substrate", "wiring", or "dependency" reduces to
+preconditions ("A must have B to be functional"), encoded on the layer each belongs
+to. The relationship model unifies them into one requirement set with a typed target;
+the **kind fixes the meaning and the check**:
-| kind | means | checked by |
-|------|-------|-----------|
-| `system` | the host package/binary must be **installed** | `which ` |
-| `deployment` | another deployment must **exist / be running** | registry / config |
+| kind | source (where encoded) | means | checked by |
+|------|------------------------|-------|-----------|
+| `deployment` | the **deployment**'s `requires` | another deployment must **exist** | registry / config |
+| `system` | the **program**'s `system_dependencies` | the host package/binary must be **installed** | `which` / `dpkg` |
+
+A **deployment** declares the deployments it depends on. `kind` defaults to
+`deployment`, so an entry is just a `ref` (+ optional `bind`):
```yaml
+# deployments//astro.yaml
requires:
- - { kind: system, ref: pandoc } # today's system_dependencies
- - { kind: deployment, ref: astro-guru, bind: GURU_URL }
- # - { kind: deployment, ref: litellm, version: ">=1" } # version: FUTURE, unused
+ - ref: astro-guru
+ - ref: supabase
+ - { ref: litellm, bind: LITELLM_URL } # bind: project the target's URL into env
```
-`system_dependencies` is exactly the `{kind: system}` case and is kept as an alias.
+A **program**'s host-package preconditions stay on the program as
+`system_dependencies` (a plain list of package names); the model synthesizes the
+`{kind: system}` requirements from it for the `functional?` check. This split keeps
+each precondition on its natural layer — a deployment-ref is node-level wiring
+(belongs on the deployment), a host package is intrinsic to the software (belongs on
+the program). There is no `requires` on the program, and no `kind: system` written
+into a deployment's `requires`.
Only encode a `requires` edge that is **not derivable** and that **castle itself
must traverse** for an operation (status, bring-up order, group ops). Do **not**
@@ -58,7 +69,7 @@ one package ecosystem.
Reading dependencies out of env strings is unstable (formats vary; a static
frontend's API URL is baked into its bundle and invisible). The stable direction is
-the reverse: from an encoded `{kind: deployment}` requirement castle **generates**
+the reverse: from an encoded `{ref, bind}` deployment requirement castle **generates**
the wiring env — it knows the target's address (`.` / its port) and
projects it into the consumer's env, optionally under the var named by `bind`. Same
move as `${public_url}`, one step further. Dependency → env, never env → dependency.