feat: deployment-scoped requires + UI editors, gateway nav, services filter

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) / <name>.<domain> (internal) / <name>.<pubdomain> (public)).
- Services page: sort statics in with systemd services by name, add a
  search + kind-filter bar mirroring the programs page.
This commit is contained in:
2026-07-06 04:00:24 -07:00
parent 20bf78caf1
commit e438f45b54
13 changed files with 329 additions and 132 deletions

View File

@@ -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) {
<th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th>
)}
<th className="px-4 py-2 font-medium text-[var(--muted)]">Health</th>
<th className="px-4 py-2 font-medium text-[var(--muted)] sr-only">Open</th>
</tr>
</thead>
<tbody>
@@ -145,6 +146,18 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
<HealthBadge status={health?.status ?? "unknown"} latency={health?.latency_ms} />
)}
</td>
<td className="px-4 py-2 text-right">
{route.name && (
<Link
to={detailPath(route.name, route.kind)}
title={`Go to ${route.name}`}
className="inline-flex items-center gap-1 text-xs text-[var(--muted)] hover:text-[var(--primary)] transition-colors"
>
<span className="sr-only sm:not-sr-only">Open</span>
<ArrowRight size={13} className="shrink-0" />
</Link>
)}
</td>
</tr>
)
})}

View File

@@ -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<string, string> = {
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<string | null>(null)
const counts = useMemo(() => {
const c: Record<string, number> = {}
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 (
<div>
<div className="mb-4 flex flex-wrap items-center gap-2">
<input
value={search}
onChange={(e) => 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 && (
<div className="flex flex-wrap gap-1.5">
<Chip
label={`All (${services.length})`}
active={kind === null}
activeClass="bg-[var(--primary)] text-white border-[var(--primary)]"
onClick={() => setKind(null)}
/>
{kindsPresent.map((k) => (
<Chip
key={k}
label={`${kindLabel(k)} (${counts[k]})`}
active={kind === k}
activeClass={KIND_ACTIVE[k]}
onClick={() => setKind(kind === k ? null : k)}
/>
))}
</div>
)}
</div>
{filtered.length === 0 ? (
<p className="text-[var(--muted)]">No services match.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((svc) => (
{filtered.map((svc) => (
<ServiceCard key={svc.id} service={svc} health={statusMap.get(svc.id)} />
))}
</div>
)}
</div>
)
}
function Chip({
label,
active,
activeClass,
onClick,
}: {
label: string
active: boolean
activeClass: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className={cn(
"text-xs px-2.5 py-1 rounded-full border transition-colors",
active
? activeClass
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)]",
)}
>
{label}
</button>
)
}

View File

@@ -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<string, string>)
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) {
/>
</div>
</Field>
{requiresEditor}
{envEditor}
<FormFooter
saving={saving}

View File

@@ -2,7 +2,8 @@ import { useState } from "react"
import type { ServiceDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields"
interface Props {
service: ServiceDetail
@@ -78,6 +79,9 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
)
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
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.`}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<select
value={reach}
onChange={(e) => setReach(e.target.value)}
disabled={!port}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)] disabled:opacity-50"
>
<option value="off">off</option>
<option value="internal">internal</option>
<option value="public">public</option>
<option value="off">{port ? `localhost:${port} (local)` : "off"}</option>
<option value="internal">{`${gatewayHost(service.id, domain)} (internal)`}</option>
<option value="public">{`${publicGatewayHost(service.id, publicDomain)} (public)`}</option>
</select>
<span className="font-mono text-[var(--muted)] text-xs">
{!port
? "set a port to expose"
: reach === "off"
? "host:port only"
: reach === "public"
? publicGatewayHost(service.id, publicDomain)
: gatewayHost(service.id, domain)}
</span>
{!port && (
<span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
)}
</div>
</Field>
</>
)}
{requiresEditor}
{envEditor}
<FormFooter
saving={saving}

View File

@@ -2,7 +2,8 @@ import { useState } from "react"
import type { DeploymentDetail } from "@/types"
import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
import { Field, TextField, FormFooter, useEnvSecrets } from "./fields"
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
import type { Requirement } from "./fields"
interface Props {
static_: DeploymentDetail
@@ -31,6 +32,9 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
const { element: envEditor, merged } = useEnvSecrets(
obj(obj(m.defaults).env) as Record<string, string>,
)
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'.)`}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5">
<select
value={isPublic ? "public" : "internal"}
onChange={(e) => setIsPublic(e.target.value === "public")}
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
>
<option value="internal">internal</option>
<option value="public">public</option>
<option value="internal">{`${gatewayHost(dep.id, domain)} (internal)`}</option>
<option value="public">{`${publicGatewayHost(dep.id, publicDomain)} (public)`}</option>
</select>
<span className="font-mono text-[var(--muted)] text-xs">
{isPublic ? publicGatewayHost(dep.id, publicDomain) : gatewayHost(dep.id, domain)}
</span>
</div>
</Field>
{requiresEditor}
{envEditor}
<FormFooter
saving={saving}

View File

@@ -167,6 +167,78 @@ export function useEnvSecrets(initial: Record<string, string>) {
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` (serviceservice 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<Requirement[]>(() =>
(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 = (
<Field
label="Requires"
hint="Other deployments this one depends on (drawn as edges on the graph). Add an env var to project the target's URL into the environment (e.g. API_URL → https://target.<domain>)."
>
<div className="space-y-2">
{reqs.map((r, i) => (
<div key={i} className="flex items-center gap-2">
<input
value={r.ref}
onChange={(e) =>
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`}
/>
<span className="text-[var(--muted)] shrink-0 text-xs"></span>
<input
value={r.bind ?? ""}
onChange={(e) =>
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`}
/>
<button
onClick={() => setReqs((p) => p.filter((_, j) => j !== i))}
className="text-red-400 hover:text-red-300 p-0.5 shrink-0"
title="Remove"
>
<Trash2 size={12} />
</button>
</div>
))}
<button
onClick={() => setReqs((p) => [...p, { ref: "", bind: "" }])}
className="text-xs text-[var(--primary)] hover:underline"
>
+ Add dependency
</button>
</div>
</Field>
)
return { element, value }
}
/** Save/Delete footer shared by the typed config forms. */
export function FormFooter({
saving,

View File

@@ -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 <name>.<gateway.domain>
manage=ManageSpec(systemd=SystemdSpec()),
# python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's

View File

@@ -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:

View File

@@ -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(

View File

@@ -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: <deployment>` (+ 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),

View File

@@ -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
]

View File

@@ -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(
{
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

View File

@@ -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 <ref>` |
| `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/<kind>/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 (`<ref>.<domain>` / 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.