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:
@@ -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>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{services.map((svc) => (
|
||||
<ServiceCard key={svc.id} service={svc} health={statusMap.get(svc.id)} />
|
||||
))}
|
||||
<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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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` (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<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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user