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 { 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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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` (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. */
|
/** Save/Delete footer shared by the typed config forms. */
|
||||||
export function FormFooter({
|
export function FormFooter({
|
||||||
saving,
|
saving,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user