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 { useMemo, useState } from "react"
import { Link } from "react-router-dom" 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 type { GatewayInfo, HealthStatus } from "@/types"
import { useApply, useCaddyfile } from "@/services/api/hooks" import { useApply, useCaddyfile } from "@/services/api/hooks"
import { subdomainUrl, detailPath } from "@/lib/labels" 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)]">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)]">Health</th>
<th className="px-4 py-2 font-medium text-[var(--muted)] sr-only">Open</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -145,6 +146,18 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
<HealthBadge status={health?.status ?? "unknown"} latency={health?.latency_ms} /> <HealthBadge status={health?.status ?? "unknown"} latency={health?.latency_ms} />
)} )}
</td> </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> </tr>
) )
})} })}

View File

@@ -1,20 +1,119 @@
import { useMemo } from "react" import { useMemo, useState } from "react"
import type { ServiceSummary, HealthStatus } from "@/types" import type { ServiceSummary, HealthStatus } from "@/types"
import { ServiceCard } from "./ServiceCard" import { ServiceCard } from "./ServiceCard"
import { kindLabel } from "@/lib/labels"
import { cn } from "@/lib/utils"
interface ServiceSectionProps { interface ServiceSectionProps {
services: ServiceSummary[] services: ServiceSummary[]
statuses: HealthStatus[] 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) { export function ServiceSection({ services, statuses }: ServiceSectionProps) {
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses]) 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 ( 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"> <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)} /> <ServiceCard key={svc.id} service={svc} health={statusMap.get(svc.id)} />
))} ))}
</div> </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 { useState } from "react"
import type { JobDetail } from "@/types" 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 { interface Props {
job: JobDetail 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: 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 () => { const handleSave = async () => {
setSaving(true) setSaving(true)
@@ -65,6 +69,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
config.schedule = schedule || undefined config.schedule = schedule || undefined
config.run = applyLauncher(obj(config.run), launcher, runTarget) config.run = applyLauncher(obj(config.run), launcher, runTarget)
config.requires = requiresValue()
const env = merged() const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env } if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
@@ -111,6 +116,7 @@ export function JobFields({ job, onSave, onDelete }: Props) {
/> />
</div> </div>
</Field> </Field>
{requiresEditor}
{envEditor} {envEditor}
<FormFooter <FormFooter
saving={saving} saving={saving}

View File

@@ -2,7 +2,8 @@ import { useState } from "react"
import type { ServiceDetail } from "@/types" import type { ServiceDetail } from "@/types"
import { useGateway } from "@/services/api/hooks" import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels" 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 { interface Props {
service: ServiceDetail 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: 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 () => { const handleSave = async () => {
setSaving(true) setSaving(true)
@@ -111,6 +115,8 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
delete config.proxy delete config.proxy
delete config.public delete config.public
config.requires = requiresValue()
const env = merged() const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env } if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
else if (config.defaults) delete (config.defaults as Obj).env else if (config.defaults) delete (config.defaults as Obj).env
@@ -197,30 +203,25 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
label="Reach" 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.`} 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 <select
value={reach} value={reach}
onChange={(e) => setReach(e.target.value)} onChange={(e) => setReach(e.target.value)}
disabled={!port} 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" 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="off">{port ? `localhost:${port} (local)` : "off"}</option>
<option value="internal">internal</option> <option value="internal">{`${gatewayHost(service.id, domain)} (internal)`}</option>
<option value="public">public</option> <option value="public">{`${publicGatewayHost(service.id, publicDomain)} (public)`}</option>
</select> </select>
<span className="font-mono text-[var(--muted)] text-xs"> {!port && (
{!port <span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
? "set a port to expose" )}
: reach === "off"
? "host:port only"
: reach === "public"
? publicGatewayHost(service.id, publicDomain)
: gatewayHost(service.id, domain)}
</span>
</div> </div>
</Field> </Field>
</> </>
)} )}
{requiresEditor}
{envEditor} {envEditor}
<FormFooter <FormFooter
saving={saving} saving={saving}

View File

@@ -2,7 +2,8 @@ import { useState } from "react"
import type { DeploymentDetail } from "@/types" import type { DeploymentDetail } from "@/types"
import { useGateway } from "@/services/api/hooks" import { useGateway } from "@/services/api/hooks"
import { gatewayHost, publicGatewayHost } from "@/lib/labels" 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 { interface Props {
static_: DeploymentDetail static_: DeploymentDetail
@@ -31,6 +32,9 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
const { element: envEditor, merged } = useEnvSecrets( const { element: envEditor, merged } = useEnvSecrets(
obj(obj(m.defaults).env) as Record<string, string>, obj(obj(m.defaults).env) as Record<string, string>,
) )
const { element: requiresEditor, value: requiresValue } = useRequires(
(m.requires as Requirement[]) ?? [],
)
const handleSave = async () => { const handleSave = async () => {
setSaving(true) setSaving(true)
@@ -43,6 +47,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
config.root = root || "dist" config.root = root || "dist"
config.reach = isPublic ? "public" : "internal" config.reach = isPublic ? "public" : "internal"
delete config.public delete config.public
config.requires = requiresValue()
const env = merged() const env = merged()
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env } if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
else if (config.defaults) delete (config.defaults as Obj).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" 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'.)`} 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 <select
value={isPublic ? "public" : "internal"} value={isPublic ? "public" : "internal"}
onChange={(e) => setIsPublic(e.target.value === "public")} 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)]" 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="internal">{`${gatewayHost(dep.id, domain)} (internal)`}</option>
<option value="public">public</option> <option value="public">{`${publicGatewayHost(dep.id, publicDomain)} (public)`}</option>
</select> </select>
<span className="font-mono text-[var(--muted)] text-xs">
{isPublic ? publicGatewayHost(dep.id, publicDomain) : gatewayHost(dep.id, domain)}
</span>
</div> </div>
</Field> </Field>
{requiresEditor}
{envEditor} {envEditor}
<FormFooter <FormFooter
saving={saving} saving={saving}

View File

@@ -167,6 +167,78 @@ export function useEnvSecrets(initial: Record<string, string>) {
return { element, merged } 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. */ /** Save/Delete footer shared by the typed config forms. */
export function FormFooter({ export function FormFooter({
saving, saving,

View File

@@ -39,12 +39,12 @@ 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 # Substrate a stack's apps depend on — seeded as a deployment `requires` at creation
# relationship graph shows it. This keeps `stack` uncoupled from the runtime model: # so the relationship graph shows it. This keeps `stack` uncoupled from the runtime
# the stack declares the edge once here; the graph only ever reads the encoded # model: the stack declares the edge once here; the graph only ever reads the encoded
# `requires`, never the stack. See docs/relationships.md. # `requires`, never the stack. See docs/relationships.md.
STACK_REQUIRES: dict[str, list[Requirement]] = { 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), 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 "", [])),
) )
# 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": 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.
config.tools[name] = PathDeployment( 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": 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.
@@ -136,6 +141,7 @@ def run_create(args: argparse.Namespace) -> int:
program=name, program=name,
root=static_root or "dist", root=static_root or "dist",
description=description, description=description,
requires=seeded_requires,
) )
elif kind == "service": elif kind == "service":
prefix = name.replace("-", "_").upper() prefix = name.replace("-", "_").upper()
@@ -151,6 +157,7 @@ def run_create(args: argparse.Namespace) -> int:
health_path="/health", health_path="/health",
) )
), ),
requires=seeded_requires,
proxy=True, # expose at <name>.<gateway.domain> proxy=True, # expose at <name>.<gateway.domain>
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
# python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's # python-fastapi scaffold reads env_prefix MY_SERVICE_ — map castle's

View File

@@ -105,12 +105,13 @@ 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.statics["guestbook"] dep = config.statics["guestbook"]
assert dep.manager == "caddy" assert dep.manager == "caddy"
assert dep.root == "public" 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")] assert config.deployments_of("guestbook") == [("guestbook", "static")]
def test_create_duplicate_fails(self, castle_root: Path, capsys: object) -> None: 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) return _public_url(config, target_name, getattr(dep, "http_exposed", False), tport)
def _requires_env( def _requires_env(config: CastleConfig, dep: DeploymentSpec) -> dict[str, str]:
config: CastleConfig, dep: DeploymentSpec, config_key: str """Env generated FROM a deployment's ``requires`` — a ``{ref, bind: VAR}``
) -> dict[str, str]: requirement sets ``VAR`` to the target deployment's URL. Env is derived from the
"""Env generated FROM a deployment's ``requires`` — a ``{kind: deployment, dependency, never scraped back into one (see docs/relationships.md)."""
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)
out: dict[str, str] = {} out: dict[str, str] = {}
for r in reqs: for r in getattr(dep, "requires", []) or []:
if r.kind == "deployment" and r.bind: if r.kind == "deployment" and r.bind:
url = _target_url(config, r.ref) url = _target_url(config, r.ref)
if url: if url:
@@ -676,7 +670,7 @@ def _build_deployed(
raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {} raw_env = dict(dep.defaults.env) if (dep.defaults and dep.defaults.env) else {}
# Env generated from `requires` ({kind: deployment, bind: VAR} → target URL). # Env generated from `requires` ({kind: deployment, bind: VAR} → target URL).
# An explicit defaults.env value always wins — a hand-set var is never clobbered. # 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) raw_env.setdefault(var, url)
public_url = _public_url(config, name, expose, port) public_url = _public_url(config, name, expose, port)
ctx = _env_context( ctx = _env_context(

View File

@@ -284,23 +284,20 @@ class Capability(BaseModel):
class Requirement(BaseModel): class Requirement(BaseModel):
"""A precondition — something that must be true for a program/deployment to be """A precondition — another **deployment** that must exist for this one to be
*functional*. The ``kind`` fixes both the meaning and how it's checked (there is *functional* (``ref`` = the target deployment's name). ``bind`` names the env
no separate purpose tag): 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). A deployment declares these in its ``requires`` list; ``kind`` defaults to
- ``deployment`` — another deployment must exist/run (``ref`` = its name). ``deployment`` (write just ``- ref: foo``). The ``system`` kind is not written
here — a program's host-package preconditions live in ``system_dependencies``,
``version`` is reserved for a future constraint (unused now). ``bind`` (for a and the relationship model synthesizes ``kind: system`` requirements from it for
``deployment`` requirement) names the env var castle projects the target's URL the ``functional?`` check. See docs/relationships.md.
into — env is derived *from* the requirement, never scraped back into it.
See docs/relationships.md. ``system_dependencies`` is the ``kind: system`` case.
""" """
kind: Literal["system", "deployment"] kind: Literal["system", "deployment"] = "deployment"
ref: str ref: str
version: str | None = None
bind: 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). # Per-program dev verb overrides (declared verbs override the stack default).
commands: CommandsSpec | None = None commands: CommandsSpec | None = None
# `requires` is the general precondition relation (see docs/relationships.md). # Host-package preconditions (apt packages / binaries) intrinsic to this
# `system_dependencies` is kept as the `{kind: system}` alias/back-compat; both # software. The relationship model checks these (`which`/`dpkg`) to derive the
# are merged when evaluating what a program requires. # `functional?` light. Deployment-to-deployment dependencies are NOT here — they
requires: list[Requirement] = Field(default_factory=list) # live on the deployment's `requires` (see DeploymentBase). See docs/relationships.md.
system_dependencies: list[str] = Field(default_factory=list) system_dependencies: list[str] = Field(default_factory=list)
install_extras: list[str] = Field(default_factory=list) install_extras: list[str] = Field(default_factory=list)
version: str | None = None version: str | None = None
@@ -431,8 +428,10 @@ class DeploymentBase(BaseModel):
) )
description: str | None = None description: str | None = None
defaults: DefaultsSpec | None = None defaults: DefaultsSpec | None = None
# Runtime preconditions (e.g. another deployment that must exist). See # Deployment-to-deployment preconditions: other deployments this one needs
# docs/relationships.md; merged with the program's `requires` when evaluated. # (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) requires: list[Requirement] = Field(default_factory=list)
# Declared on/off state. `castle apply` converges reality to this: enabled # Declared on/off state. `castle apply` converges reality to this: enabled
# deployments are activated (service started, tool installed, route served), # 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. """The relationship model — derived, never stored. See docs/relationships.md.
Entities: **program**, **deployment**, **repo** (a repo is a git working copy; Entities: **program**, **deployment**, **repo** (a repo is a git working copy;
programs sharing a toplevel form a monorepo). One encoded relation, **`requires`** programs sharing a toplevel form a monorepo). Preconditions come from two encoded
(a precondition, typed by ``kind``: ``system`` = must be installed, ``deployment`` sources: a deployment's **`requires`** (other deployments it needs) and its
= must exist). Everything else — repos, env wiring, fan-in, and the predicates 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. ``functional?`` / ``fresh?`` / ``deployed?`` — is computed here on demand.
Governing rule: *predicates are derived; we encode only the non-derivable.* So this Governing rule: *predicates are derived; we encode only the non-derivable.* So this
module reads the encoded ``requires`` (plus ``system_dependencies`` as its module reads the encoded ``requires`` + ``system_dependencies`` and derives the rest.
``kind: system`` alias) and derives the rest. It does **not** scrape env for It does **not** scrape env for dependencies — env is generated *from* requirements,
dependencies — env is generated *from* requirements, not the reverse. not the reverse.
""" """
from __future__ import annotations 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]: def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
"""The full requirement set for a deployment: its own ``requires`` plus its """The full requirement set for a deployment: its own ``requires`` (deployment
program's ``requires`` and ``system_dependencies`` (the ``kind: system`` alias), dependencies) plus its program's ``system_dependencies`` synthesized as
de-duplicated by (kind, ref).""" ``kind: system`` requirements, de-duplicated by (kind, ref)."""
reqs: list[Requirement] = [] 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): for _kind, dep in config.deployments_named(dep_name):
reqs += list(getattr(dep, "requires", []) or []) reqs += list(getattr(dep, "requires", []) or [])
prog = config.programs.get(_program_of(dep_name, dep)) prog = config.programs.get(_program_of(dep_name, dep))
if prog: if prog:
reqs += list(prog.requires)
reqs += [ reqs += [
Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies 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 import castle_core.config as C
from castle_core import relations as R 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: def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
return SystemdDeployment.model_validate( spec: dict = {
{
"manager": "systemd", "manager": "systemd",
"program": program, "program": program,
"run": {"launcher": "command", "argv": [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: 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")] assert [(r.kind, r.ref) for r in reqs] == [("system", "pandoc")]
def test_requirements_merge_program_and_deployment_deduped() -> None: def test_requirements_merge_system_deps_and_deployment_requires_deduped() -> None:
prog = ProgramSpec( """The requirement set unions the program's system_dependencies (as {kind: system})
id="web", with the deployment's own requires, de-duplicated by (kind, ref)."""
system_dependencies=["pandoc"], prog = ProgramSpec(id="web", system_dependencies=["pandoc"])
requires=[Requirement(kind="deployment", ref="api", bind="API_URL")], # Two identical deployment requires — deduped to one.
) dep = _dep("web", requires=[{"ref": "api"}, {"ref": "api"}])
dep = SystemdDeployment.model_validate(
{
"manager": "systemd",
"program": "web",
"run": {"launcher": "command", "argv": ["web"]},
"requires": [{"kind": "system", "ref": "pandoc"}],
} # dup of program's
)
cfg = _cfg( cfg = _cfg(
{"web": prog, "api": ProgramSpec(id="api")}, {"web": dep, "api": _dep("api")} {"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: def test_deployment_edge_carries_bind_and_counts_fan_in() -> None:
"""A {kind: deployment} requirement becomes an edge (with bind), and the target's """A {kind: deployment} requirement becomes an edge (with bind), and the target's
fan-in is the count of distinct dependents.""" 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( cfg = _cfg(
{"web": consumer, "cli": consumer2, "api": ProgramSpec(id="api")}, {"web": ProgramSpec(id="web"), "cli": ProgramSpec(id="cli"), "api": ProgramSpec(id="api")},
{"web": _dep("web"), "cli": _dep("cli"), "api": _dep("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) m = R.build_model(cfg, check=False)
edge = next(e for e in m.edges if e.src == "web" and e.dst == "api") 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: def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) -> None:
"""`functional?` is derived: a missing system package is unmet; a present """`functional?` is derived: a missing system package is unmet; a present
deployment requirement is satisfied.""" deployment requirement is satisfied."""
prog = ProgramSpec( prog = ProgramSpec(id="web", system_dependencies=["pandoc"])
id="web",
system_dependencies=["pandoc"],
requires=[Requirement(kind="deployment", ref="api")],
)
cfg = _cfg( cfg = _cfg(
{"web": prog, "api": ProgramSpec(id="api")}, {"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.shutil, "which", lambda _: None) # not on PATH
monkeypatch.setattr(R, "_dpkg_installed", lambda _: False) # nor as a package 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: def test_missing_deployment_requirement_is_unmet() -> None:
prog = ProgramSpec(id="web", requires=[Requirement(kind="deployment", ref="ghost")]) cfg = _cfg(
cfg = _cfg({"web": prog}, {"web": _dep("web")}) {"web": ProgramSpec(id="web")},
{"web": _dep("web", requires=[{"ref": "ghost"}])},
)
m = R.build_model(cfg, check=True) m = R.build_model(cfg, check=True)
web = next(n for n in m.nodes if n.name == "web") web = next(n for n in m.nodes if n.name == "web")
assert web.unmet == ["deployment:ghost"] and web.functional is False 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*. on each program's source; several programs sharing one toplevel is a *monorepo*.
Never stored. Never stored.
## The one encoded relation: `requires` ## Encoded preconditions: `requires` + `system_dependencies`
Everything we were calling "substrate", "wiring", or "dependency" is one relation — Everything we were calling "substrate", "wiring", or "dependency" reduces to
**`requires`** ("A must have B to be functional") — with a typed target. The preconditions ("A must have B to be functional"), encoded on the layer each belongs
**kind fixes the meaning and the check**; there is no separate purpose/`for` tag: 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 | | kind | source (where encoded) | means | checked by |
|------|-------|-----------| |------|------------------------|-------|-----------|
| `system` | the host package/binary must be **installed** | `which <ref>` | | `deployment` | the **deployment**'s `requires` | another deployment must **exist** | registry / config |
| `deployment` | another deployment must **exist / be running** | 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 ```yaml
# deployments/<kind>/astro.yaml
requires: requires:
- { kind: system, ref: pandoc } # today's system_dependencies - ref: astro-guru
- { kind: deployment, ref: astro-guru, bind: GURU_URL } - ref: supabase
# - { kind: deployment, ref: litellm, version: ">=1" } # version: FUTURE, unused - { 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 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** 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 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 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 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 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. move as `${public_url}`, one step further. Dependency → env, never env → dependency.