From e438f45b54f075321803ab898b80ffb2211b57c1 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Mon, 6 Jul 2026 04:00:24 -0700 Subject: [PATCH] feat: deployment-scoped requires + UI editors, gateway nav, services filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Model: - Drop `requires` from ProgramSpec; requires is now deployment-only. Collapse Requirement to {ref, bind} with kind defaulting to "deployment" (drop the unused `version`). System-package preconditions stay on the program as `system_dependencies`, synthesized into {kind: system} for the functional check. relations.py/deploy.py read only the deployment's requires. - create.py seeds a stack's substrate dependency onto the deployment, not the program. Docs + tests updated. UI: - Add a `requires` editor (useRequires) on service, job, and static detail pages — edit service→service deps (ref + optional bind env var). - Restore the gateway route table "Open" column: a kind-aware in-app link to each route's deployment. - Reformat the reach control: name baked into each option (localhost:PORT (local) / . (internal) / . (public)). - Services page: sort statics in with systemd services by name, add a search + kind-filter bar mirroring the programs page. --- app/src/components/GatewayPanel.tsx | 15 ++- app/src/components/ServiceSection.tsx | 109 +++++++++++++++++++- app/src/components/detail/JobFields.tsx | 8 +- app/src/components/detail/ServiceFields.tsx | 29 +++--- app/src/components/detail/StaticFields.tsx | 17 +-- app/src/components/detail/fields.tsx | 72 +++++++++++++ cli/src/castle_cli/commands/create.py | 21 ++-- cli/tests/test_create.py | 7 +- core/src/castle_core/deploy.py | 18 ++-- core/src/castle_core/manifest.py | 37 ++++--- core/src/castle_core/relations.py | 24 +++-- core/tests/test_relations.py | 67 +++++------- docs/relationships.md | 37 ++++--- 13 files changed, 329 insertions(+), 132 deletions(-) diff --git a/app/src/components/GatewayPanel.tsx b/app/src/components/GatewayPanel.tsx index d891b02..2b13f44 100644 --- a/app/src/components/GatewayPanel.tsx +++ b/app/src/components/GatewayPanel.tsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react" import { Link } from "react-router-dom" -import { Globe, RefreshCw, FileText, ExternalLink, Cable } from "lucide-react" +import { Globe, RefreshCw, FileText, ExternalLink, Cable, ArrowRight } from "lucide-react" import type { GatewayInfo, HealthStatus } from "@/types" import { useApply, useCaddyfile } from "@/services/api/hooks" import { subdomainUrl, detailPath } from "@/lib/labels" @@ -74,6 +74,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { Node )} Health + Open @@ -145,6 +146,18 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) { )} + + {route.name && ( + + Open + + + )} + ) })} diff --git a/app/src/components/ServiceSection.tsx b/app/src/components/ServiceSection.tsx index cac1335..89ba08e 100644 --- a/app/src/components/ServiceSection.tsx +++ b/app/src/components/ServiceSection.tsx @@ -1,20 +1,119 @@ -import { useMemo } from "react" +import { useMemo, useState } from "react" import type { ServiceSummary, HealthStatus } from "@/types" import { ServiceCard } from "./ServiceCard" +import { kindLabel } from "@/lib/labels" +import { cn } from "@/lib/utils" interface ServiceSectionProps { services: ServiceSummary[] statuses: HealthStatus[] } +// The services page mixes systemd services and caddy statics — both URL-reachable. +const KIND_ORDER = ["service", "static"] + +// Active chip color per kind — mirrors KindBadge so the filter reads as the badge. +const KIND_ACTIVE: Record = { + service: "bg-green-700 text-white border-green-600", + static: "bg-cyan-700 text-white border-cyan-600", +} + export function ServiceSection({ services, statuses }: ServiceSectionProps) { const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses]) + const [search, setSearch] = useState("") + const [kind, setKind] = useState(null) + + const counts = useMemo(() => { + const c: Record = {} + for (const s of services) { + const k = s.kind ?? "service" + c[k] = (c[k] ?? 0) + 1 + } + return c + }, [services]) + const kindsPresent = KIND_ORDER.filter((k) => counts[k]) + + // Sort by name so statics and systemd services interleave alphabetically rather + // than clumping by kind (the registry lists them per-kind store). + const filtered = useMemo(() => { + let base = [...services].sort((a, b) => a.id.localeCompare(b.id)) + if (kind) base = base.filter((s) => (s.kind ?? "service") === kind) + if (search) { + const q = search.toLowerCase() + base = base.filter( + (s) => + s.id.toLowerCase().includes(q) || + (s.description?.toLowerCase().includes(q) ?? false), + ) + } + return base + }, [services, search, kind]) return ( -
- {services.map((svc) => ( - - ))} +
+
+ setSearch(e.target.value)} + placeholder="Filter services..." + className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56" + /> + {kindsPresent.length > 1 && ( +
+ setKind(null)} + /> + {kindsPresent.map((k) => ( + setKind(kind === k ? null : k)} + /> + ))} +
+ )} +
+ + {filtered.length === 0 ? ( +

No services match.

+ ) : ( +
+ {filtered.map((svc) => ( + + ))} +
+ )}
) } + +function Chip({ + label, + active, + activeClass, + onClick, +}: { + label: string + active: boolean + activeClass: string + onClick: () => void +}) { + return ( + + ) +} diff --git a/app/src/components/detail/JobFields.tsx b/app/src/components/detail/JobFields.tsx index d3b7bb9..b0006af 100644 --- a/app/src/components/detail/JobFields.tsx +++ b/app/src/components/detail/JobFields.tsx @@ -1,6 +1,7 @@ import { useState } from "react" import type { JobDetail } from "@/types" -import { Field, TextField, FormFooter, useEnvSecrets } from "./fields" +import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields" +import type { Requirement } from "./fields" interface Props { job: JobDetail @@ -54,6 +55,9 @@ export function JobFields({ job, onSave, onDelete }: Props) { ) const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record) + const { element: requiresEditor, value: requiresValue } = useRequires( + (m.requires as Requirement[]) ?? [], + ) const handleSave = async () => { setSaving(true) @@ -65,6 +69,7 @@ export function JobFields({ job, onSave, onDelete }: Props) { config.schedule = schedule || undefined config.run = applyLauncher(obj(config.run), launcher, runTarget) + config.requires = requiresValue() const env = merged() if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env } @@ -111,6 +116,7 @@ export function JobFields({ job, onSave, onDelete }: Props) { />
+ {requiresEditor} {envEditor} ) + const { element: requiresEditor, value: requiresValue } = useRequires( + (m.requires as Requirement[]) ?? [], + ) const handleSave = async () => { setSaving(true) @@ -111,6 +115,8 @@ export function ServiceFields({ service, onSave, onDelete }: Props) { delete config.proxy delete config.public + config.requires = requiresValue() + const env = merged() if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env } else if (config.defaults) delete (config.defaults as Obj).env @@ -197,30 +203,25 @@ export function ServiceFields({ service, onSave, onDelete }: Props) { label="Reach" hint={`How far this service is exposed. off: host:port only. internal: ${gatewayHost(service.id, domain)} via the gateway. public: also to the internet via the Cloudflare tunnel.`} > -
+
- - {!port - ? "set a port to expose" - : reach === "off" - ? "host:port only" - : reach === "public" - ? publicGatewayHost(service.id, publicDomain) - : gatewayHost(service.id, domain)} - + {!port && ( + set a port to expose + )}
)} + {requiresEditor} {envEditor} , ) + const { element: requiresEditor, value: requiresValue } = useRequires( + (m.requires as Requirement[]) ?? [], + ) const handleSave = async () => { setSaving(true) @@ -43,6 +47,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) { config.root = root || "dist" config.reach = isPublic ? "public" : "internal" delete config.public + config.requires = requiresValue() const env = merged() if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env } else if (config.defaults) delete (config.defaults as Obj).env @@ -70,20 +75,18 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) { label="Reach" hint={`How far this static site is served. internal: ${gatewayHost(dep.id, domain)}. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)`} > -
+
- - {isPublic ? publicGatewayHost(dep.id, publicDomain) : gatewayHost(dep.id, domain)} -
+ {requiresEditor} {envEditor} ) { return { element, merged } } +/** A deployment-to-deployment requirement as stored in `requires`. `kind` defaults + * to "deployment" on the backend, so the editor only surfaces ref + optional bind. */ +export interface Requirement { + kind?: string + ref: string + bind?: string +} + +/** Hook for editing a deployment's `requires` (service→service dependencies). + * Returns the editor element plus a `value()` that yields the requirement list to + * save (empty entries dropped; bind omitted when blank). */ +export function useRequires(initial: Requirement[]) { + const [reqs, setReqs] = useState(() => + (initial ?? []).map((r) => ({ ref: r.ref ?? "", bind: r.bind ?? "" })), + ) + + const value = (): Requirement[] => + reqs + .filter((r) => r.ref.trim()) + .map((r) => + r.bind?.trim() + ? { ref: r.ref.trim(), bind: r.bind.trim() } + : { ref: r.ref.trim() }, + ) + + const element = ( + +
+ {reqs.map((r, i) => ( +
+ + setReqs((p) => p.map((x, j) => (j === i ? { ...x, ref: e.target.value } : x))) + } + placeholder="deployment name" + className={`w-32 sm:w-48 min-w-0 ${INPUT} text-xs font-mono`} + /> + + + setReqs((p) => p.map((x, j) => (j === i ? { ...x, bind: e.target.value } : x))) + } + placeholder="ENV_VAR (optional)" + className={`flex-1 min-w-0 ${INPUT} text-xs font-mono`} + /> + +
+ ))} + +
+
+ ) + + 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.