Add stack dependency management

Stacks declare host toolchains (uv/pnpm/node/hugo/deno/psql) that can drift
from what's installed and, worse, from what's on a running service's PATH.
Make those dependencies first-class and visible.

Model (core):
- stacks.ToolRequirement + StackHandler.tools declare each stack's toolchains
  (command, purpose, phase, install_hint); tools_for() is the single source.
- relations synthesizes them as `kind: tool` requirements so functional?/graph
  account for them; checked runtime-env-aware (run-phase tools of a systemd
  service are probed against the service's PATH, not the shell) via a shared
  generators.systemd.runtime_path helper the unit generator also uses, so the
  checker can't drift from the generator. hint_for() makes every unmet
  requirement actionable.
- stack_status: the derived per-stack health the CLI/API/UI all render.
- config: add ~/.deno/bin to USER_TOOL_PATH_DIRS so deno (supabase edge fns)
  is found by services and the check, same as ~/.local/bin and the pnpm dirs.

Surfaces:
- castle stack list|info (new resource) + GET /stacks/status, /stacks/{name}
  (GET /stacks stays a bare name list for the create-form select).
- castle doctor gains a "Stacks & dependencies" section (FAIL for an enabled
  deployment's missing tool, WARN otherwise, unused stacks skipped).
- castle apply preflight warns (advisory, like _acme_preflight) when a tool is
  missing where a service runs; ConvergePanel renders those warnings.
- Dashboard Stacks page: per-stack tool checklist with versions + copyable
  install hints, program links, and verb chips.

Tests: relations (drift + hints), doctor (ok/fail/skip), /stacks endpoints.
This commit is contained in:
2026-07-12 16:04:48 -07:00
parent f8e487071e
commit 964226d671
20 changed files with 998 additions and 32 deletions

View File

@@ -1,6 +1,6 @@
import { useState } from "react"
import { useQueryClient } from "@tanstack/react-query"
import { Check, GitCompare, Loader2, Play } from "lucide-react"
import { AlertTriangle, Check, GitCompare, Loader2, Play } from "lucide-react"
import { apiClient } from "@/services/api/client"
import type { ApplyResult } from "@/services/api/hooks"
@@ -86,6 +86,18 @@ export function ConvergePanel() {
</div>
)}
{/* Advisory warnings from the render/preflight (missing stack toolchains,
acme prerequisites, tunnel notes) — surfaced so a service that can't
build or start doesn't fail silently at apply time. */}
{plan?.messages
?.filter((m) => m.startsWith("Warning"))
.map((m) => (
<div key={m} className="mt-2 flex items-start gap-1.5 text-sm text-amber-400">
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
<span>{m.replace(/^Warning:\s*/, "")}</span>
</div>
))}
{plan && (
<div className="mt-3 space-y-1">
{plan.changed ? (

View File

@@ -9,6 +9,7 @@ import {
Gauge,
Globe,
KeyRound,
Layers,
LayoutDashboard,
Menu,
Search,
@@ -44,6 +45,7 @@ const NAV: (NavLeaf | NavGroup)[] = [
],
},
{ to: "/programs", label: "Programs", icon: SquareCode },
{ to: "/stacks", label: "Stacks", icon: Layers },
{ to: "/mesh", label: "Mesh", icon: Share2 },
{ to: "/secrets", label: "Secrets", icon: KeyRound },
]

158
app/src/pages/Stacks.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { useState } from "react"
import { Link } from "react-router-dom"
import { Check, Copy, Layers, X } from "lucide-react"
import { cn } from "@/lib/utils"
import { useStacksStatus, type StackStatus, type ToolStatus } from "@/services/api/hooks"
import { PageHeader } from "@/components/PageHeader"
// A stack's overall health pill: green when every tool it needs is present, red
// when one is missing, grey when nothing on this node uses it yet.
function StackBadge({ stack }: { stack: StackStatus }) {
const [label, cls] = !stack.in_use
? (["unused", "bg-gray-700/50 text-gray-400"] as const)
: stack.ok
? (["ready", "bg-green-800/50 text-green-300"] as const)
: (["missing tools", "bg-red-800/50 text-red-300"] as const)
return (
<span className={cn("inline-flex items-center text-xs px-2 py-0.5 rounded-full", cls)}>
{label}
</span>
)
}
// A copy-to-clipboard chip for a tool's install command — the "diagnose + copyable
// hint" contract: we never install for you, but the fix is one click from your shell.
function CopyHint({ text }: { text: string }) {
const [copied, setCopied] = useState(false)
return (
<button
onClick={() => {
void navigator.clipboard?.writeText(text)
setCopied(true)
window.setTimeout(() => setCopied(false), 1200)
}}
className="group inline-flex items-center gap-1.5 rounded border border-[var(--border)] bg-black/20 px-2 py-1 font-mono text-xs text-[var(--muted)] hover:border-[var(--primary)] hover:text-[var(--foreground)] transition-colors"
title="Copy install command"
>
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
<span className="truncate">{text}</span>
</button>
)
}
function ToolRow({ tool }: { tool: ToolStatus }) {
return (
<div className="flex flex-col gap-1 py-1.5">
<div className="flex items-center gap-2 text-sm">
{tool.present ? (
<Check size={14} className="shrink-0 text-green-400" />
) : (
<X size={14} className="shrink-0 text-red-400" />
)}
<span className="font-mono font-medium">{tool.command}</span>
<span className="text-xs text-[var(--muted)]">
{tool.purpose} · {tool.phase}
</span>
{tool.version && (
<span className="ml-auto truncate max-w-[45%] text-xs text-[var(--muted)]" title={tool.version}>
{tool.version}
</span>
)}
</div>
{!tool.present && (
<div className="pl-6">
<CopyHint text={tool.install_hint} />
</div>
)}
</div>
)
}
function StackCard({ stack }: { stack: StackStatus }) {
return (
<div
className={cn(
"rounded-lg border bg-[var(--card)] p-4",
stack.in_use ? "border-[var(--border)]" : "border-[var(--border)] opacity-60",
)}
>
<div className="flex items-center justify-between gap-2">
<h2 className="font-semibold">{stack.name}</h2>
<StackBadge stack={stack} />
</div>
<div className="mt-3 divide-y divide-[var(--border)]">
{stack.tools.length > 0 ? (
stack.tools.map((t) => <ToolRow key={t.command} tool={t} />)
) : (
<p className="py-1.5 text-sm text-[var(--muted)]">No host tools required.</p>
)}
</div>
{stack.programs.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
<span className="text-[var(--muted)]">used by</span>
{stack.programs.map((p) => (
<Link
key={p}
to={`/programs/${p}`}
className="rounded bg-black/20 px-1.5 py-0.5 font-mono hover:text-[var(--primary)]"
>
{p}
</Link>
))}
</div>
)}
{stack.verbs.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-[var(--muted)]">
{stack.verbs.map((v) => (
<span key={v} className="rounded-full border border-[var(--border)] px-1.5 py-0.5">
{v}
</span>
))}
</div>
)}
</div>
)
}
export function Stacks() {
const { data: stacks, isLoading } = useStacksStatus()
const missing = (stacks ?? []).filter((s) => s.in_use && !s.ok).length
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<PageHeader
title="Stacks"
subtitle="The toolchains each stack needs, and whether they're present where your services run"
actions={
missing > 0 ? (
<span className="inline-flex items-center gap-1.5 text-sm text-red-400">
<X size={14} /> {missing} stack{missing !== 1 ? "s" : ""} missing tools
</span>
) : stacks && stacks.length > 0 ? (
<span className="inline-flex items-center gap-1.5 text-sm text-green-400">
<Check size={14} /> all toolchains present
</span>
) : null
}
/>
{isLoading ? (
<p className="text-[var(--muted)]">Loading...</p>
) : stacks && stacks.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
{stacks.map((s) => (
<StackCard key={s.name} stack={s} />
))}
</div>
) : (
<div className="flex flex-col items-center gap-2 py-16 text-[var(--muted)]">
<Layers size={28} />
<p>No stacks.</p>
</div>
)}
</div>
)
}

View File

@@ -4,6 +4,7 @@ import { Overview } from "@/pages/Overview"
import { Services } from "@/pages/Services"
import { Scheduled } from "@/pages/Scheduled"
import { Tools } from "@/pages/Tools"
import { Stacks } from "@/pages/Stacks"
import { Programs } from "@/pages/Programs"
import { GatewayPage } from "@/pages/GatewayPage"
import { MeshPage } from "@/pages/MeshPage"
@@ -25,6 +26,7 @@ export const router = createBrowserRouter([
{ path: "services", element: <Services /> },
{ path: "scheduled", element: <Scheduled /> },
{ path: "tools", element: <Tools /> },
{ path: "stacks", element: <Stacks /> },
{ path: "programs", element: <Programs /> },
{ path: "gateway", element: <GatewayPage /> },
{ path: "mesh", element: <MeshPage /> },

View File

@@ -69,6 +69,36 @@ export function useStacks() {
})
}
// A host toolchain a stack needs + whether it's present where its programs run.
export interface ToolStatus {
command: string
purpose: string
phase: "run" | "build" | "both"
present: boolean
install_hint: string
version: string | null
}
// A stack's dependency health — powers the Stacks page.
export interface StackStatus {
name: string
tools: ToolStatus[]
programs: string[]
deployments: string[]
verbs: string[]
has_enabled_deployment: boolean
in_use: boolean
ok: boolean
}
// Per-stack toolchain health (tools present-where-needed + who uses each stack).
export function useStacksStatus() {
return useQuery({
queryKey: ["stacks", "status"],
queryFn: () => apiClient.get<StackStatus[]>("/stacks/status"),
})
}
export function useJob(name: string) {
return useQuery({
queryKey: ["jobs", name],