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 { useState } from "react"
import { useQueryClient } from "@tanstack/react-query" 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 { apiClient } from "@/services/api/client"
import type { ApplyResult } from "@/services/api/hooks" import type { ApplyResult } from "@/services/api/hooks"
@@ -86,6 +86,18 @@ export function ConvergePanel() {
</div> </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 && ( {plan && (
<div className="mt-3 space-y-1"> <div className="mt-3 space-y-1">
{plan.changed ? ( {plan.changed ? (

View File

@@ -9,6 +9,7 @@ import {
Gauge, Gauge,
Globe, Globe,
KeyRound, KeyRound,
Layers,
LayoutDashboard, LayoutDashboard,
Menu, Menu,
Search, Search,
@@ -44,6 +45,7 @@ const NAV: (NavLeaf | NavGroup)[] = [
], ],
}, },
{ to: "/programs", label: "Programs", icon: SquareCode }, { to: "/programs", label: "Programs", icon: SquareCode },
{ to: "/stacks", label: "Stacks", icon: Layers },
{ to: "/mesh", label: "Mesh", icon: Share2 }, { to: "/mesh", label: "Mesh", icon: Share2 },
{ to: "/secrets", label: "Secrets", icon: KeyRound }, { 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 { Services } from "@/pages/Services"
import { Scheduled } from "@/pages/Scheduled" import { Scheduled } from "@/pages/Scheduled"
import { Tools } from "@/pages/Tools" import { Tools } from "@/pages/Tools"
import { Stacks } from "@/pages/Stacks"
import { Programs } from "@/pages/Programs" import { Programs } from "@/pages/Programs"
import { GatewayPage } from "@/pages/GatewayPage" import { GatewayPage } from "@/pages/GatewayPage"
import { MeshPage } from "@/pages/MeshPage" import { MeshPage } from "@/pages/MeshPage"
@@ -25,6 +26,7 @@ export const router = createBrowserRouter([
{ path: "services", element: <Services /> }, { path: "services", element: <Services /> },
{ path: "scheduled", element: <Scheduled /> }, { path: "scheduled", element: <Scheduled /> },
{ path: "tools", element: <Tools /> }, { path: "tools", element: <Tools /> },
{ path: "stacks", element: <Stacks /> },
{ path: "programs", element: <Programs /> }, { path: "programs", element: <Programs /> },
{ path: "gateway", element: <GatewayPage /> }, { path: "gateway", element: <GatewayPage /> },
{ path: "mesh", element: <MeshPage /> }, { 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) { export function useJob(name: string) {
return useQuery({ return useQuery({
queryKey: ["jobs", name], queryKey: ["jobs", name],

View File

@@ -135,6 +135,30 @@ class ProgramDetail(ProgramSummary):
manifest: dict manifest: dict
class ToolStatusModel(BaseModel):
"""One host toolchain a stack needs, and whether it's present where used."""
command: str
purpose: str
phase: str # "run" | "build" | "both"
present: bool
install_hint: str
version: str | None = None
class StackStatusModel(BaseModel):
"""A stack's dependency health — its tools + who uses it. Powers the Stacks page."""
name: str
tools: list[ToolStatusModel]
programs: list[str]
deployments: list[str]
verbs: list[str]
has_enabled_deployment: bool
in_use: bool
ok: bool
class HealthStatus(BaseModel): class HealthStatus(BaseModel):
"""Health status of a single component.""" """Health status of a single component."""

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import asdict from dataclasses import asdict
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
@@ -20,6 +21,10 @@ from castle_core.stacks import available_actions, available_stacks, run_action
from castle_api import stream from castle_api import stream
from castle_api.config import get_config from castle_api.config import get_config
from castle_api.models import StackStatusModel, ToolStatusModel
if TYPE_CHECKING:
from castle_core.stack_status import StackStatus
programs_router = APIRouter(tags=["programs"]) programs_router = APIRouter(tags=["programs"])
@@ -31,6 +36,40 @@ def list_stacks() -> list[str]:
return available_stacks() return available_stacks()
def _stack_model(st: StackStatus) -> StackStatusModel:
return StackStatusModel(
name=st.name,
tools=[ToolStatusModel(**asdict(t)) for t in st.tools],
programs=st.programs,
deployments=st.deployments,
verbs=st.verbs,
has_enabled_deployment=st.has_enabled_deployment,
in_use=st.in_use,
ok=st.ok,
)
@programs_router.get("/stacks/status")
def stacks_status() -> list[StackStatusModel]:
"""Every stack's dependency health — tools present-where-needed (run-phase tools
against the service runtime PATH), who uses it, and the fix for anything missing.
The Stacks page renders this; `castle stack list` is its CLI twin."""
from castle_core.stack_status import all_stack_status
return [_stack_model(s) for s in all_stack_status(get_config())]
@programs_router.get("/stacks/{name}")
def stack_detail(name: str) -> StackStatusModel:
"""One stack's dependency detail (tool versions included)."""
from castle_core.stack_status import stack_status
st = stack_status(get_config(), name)
if st is None:
raise HTTPException(status_code=404, detail=f"No stack '{name}'")
return _stack_model(st)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Filesystem browse + adopt — powers the dashboard's "Add program" flow, the # Filesystem browse + adopt — powers the dashboard's "Add program" flow, the
# web equivalent of `castle program add <path|git-url>`. Programs live on the # web equivalent of `castle program add <path|git-url>`. Programs live on the

View File

@@ -0,0 +1,29 @@
"""Tests for the stack-dependency endpoints (/stacks, /stacks/status, /stacks/{name})."""
from fastapi.testclient import TestClient
from castle_core.stacks import available_stacks
class TestStacks:
def test_names_endpoint_stays_a_bare_list(self, client: TestClient) -> None:
"""`GET /stacks` keeps its back-compat string[] shape (the create-form select
depends on it) even though the richer status lives at /stacks/status."""
resp = client.get("/stacks")
assert resp.status_code == 200
assert resp.json() == available_stacks()
def test_status_shape(self, client: TestClient) -> None:
resp = client.get("/stacks/status")
assert resp.status_code == 200
body = resp.json()
assert {s["name"] for s in body} == set(available_stacks())
st = next(s for s in body if s["name"] == "python-fastapi")
# python-fastapi declares uv; every tool carries its phase + fix.
assert {"in_use", "ok", "tools", "programs", "verbs"} <= st.keys()
uv = next(t for t in st["tools"] if t["command"] == "uv")
assert uv["phase"] == "both" and uv["install_hint"]
def test_detail_and_404(self, client: TestClient) -> None:
assert client.get("/stacks/python-cli").json()["name"] == "python-cli"
assert client.get("/stacks/nope").status_code == 404

View File

@@ -345,15 +345,20 @@ def _check_tls_exposure(config) -> list[Check]:
checks.append(_check_privileged_ports()) checks.append(_check_privileged_ports())
# Public exposure (only relevant if a deployment opts in). # Public exposure (only relevant if a deployment opts in).
public = [n for _k, n, d in config.all_deployments() if getattr(d, "public", False)] public_specs = [(n, d) for _k, n, d in config.all_deployments() if getattr(d, "public", False)]
public = [n for n, _d in public_specs]
if public: if public:
from castle_core.lifecycle import is_active from castle_core.lifecycle import is_active
if gw.public_domain and gw.tunnel_id: # A public deployment gets its public name from its own `public_host`
# override or the node-wide `public_domain`; the default domain is only
# required if some public deployment relies on it.
need_default = any(not getattr(d, "public_host", None) for _n, d in public_specs)
if gw.tunnel_id and (gw.public_domain or not need_default):
checks.append(Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)")) checks.append(Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)"))
else: else:
missing = [] missing = []
if not gw.public_domain: if need_default and not gw.public_domain:
missing.append("public_domain") missing.append("public_domain")
if not gw.tunnel_id: if not gw.tunnel_id:
missing.append("tunnel_id") missing.append("tunnel_id")
@@ -403,20 +408,26 @@ def _check_public_dns(config) -> Check:
"public DNS not automated", "public DNS not automated",
detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual", detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual",
hint=( hint=(
f"add a Cloudflare token with DNS:Edit on {gw.public_domain} " f"add a Cloudflare token with DNS:Edit on "
f"{gw.public_domain or 'the public zone(s)'} "
f"('Edit zone DNS' template) → ~/.castle/secrets/{PUBLIC_DNS_TOKEN} " f"('Edit zone DNS' template) → ~/.castle/secrets/{PUBLIC_DNS_TOKEN} "
"(else route each host by hand)" "(else route each host by hand)"
), ),
) )
try: try:
zres = (_api(token, "GET", f"/zones?name={gw.public_domain}").get("result")) or [] # With a node-wide public_domain, probe that specific zone; otherwise
# (custom public_host hosts only) confirm the token can list *some* zone —
# reconcile resolves each host's zone by longest-suffix match at apply.
query = f"/zones?name={gw.public_domain}" if gw.public_domain else "/zones?per_page=1"
zres = (_api(token, "GET", query).get("result")) or []
if not zres: if not zres:
where = gw.public_domain or "any zone"
return Check( return Check(
FAIL, FAIL,
"public DNS token can't see the zone", "public DNS token can't see the zone",
detail=f"{gw.public_domain} not visible", detail=f"{where} not visible",
hint=( hint=(
f"token needs DNS:Edit scoped to {gw.public_domain}, in that " f"token needs DNS:Edit scoped to {where}, in that "
"zone's account ('Edit zone DNS' template)" "zone's account ('Edit zone DNS' template)"
), ),
) )
@@ -436,7 +447,7 @@ def _check_public_dns(config) -> Check:
return Check( return Check(
OK, OK,
"public DNS token valid", "public DNS token valid",
detail=f"can reach {gw.public_domain} + its records", detail=f"can reach {gw.public_domain or 'its zones'} + records",
) )
@@ -459,6 +470,39 @@ def _check_privileged_ports() -> Check:
# --- Driver ----------------------------------------------------------------- # --- Driver -----------------------------------------------------------------
def _check_stacks(config: object) -> list[Check]:
"""Stack toolchains: is each *in-use* stack's host tooling present where its
programs need it (run-phase tools against the service's runtime PATH)? A missing
tool for an enabled deployment is a FAIL (its service can't build/run); missing
for a not-yet-enabled program is a WARN. Unused stacks are skipped — no nagging
about pnpm when there are no frontends."""
from castle_core.stack_status import all_stack_status
checks: list[Check] = []
for st in all_stack_status(config, with_version=False):
if not st.in_use or not st.tools:
continue
n = len(st.programs)
label = f"{st.name} ({n} program{'s' if n != 1 else ''})"
missing = [t for t in st.tools if not t.present]
if not missing:
present = ", ".join(t.command for t in st.tools)
checks.append(Check(OK, label, detail=f"{present} present"))
continue
status = FAIL if st.has_enabled_deployment else WARN
checks.append(
Check(
status,
label,
detail="missing: " + ", ".join(t.command for t in missing),
hint=missing[0].install_hint,
)
)
if not checks:
checks.append(Check(OK, "no stack toolchains in use"))
return checks
def run_doctor(args: argparse.Namespace) -> int: def run_doctor(args: argparse.Namespace) -> int:
from castle_core.config import load_config from castle_core.config import load_config
@@ -482,6 +526,7 @@ def run_doctor(args: argparse.Namespace) -> int:
sections: list[tuple[str, list[Check]]] = [ sections: list[tuple[str, list[Check]]] = [
("Environment", _check_environment()), ("Environment", _check_environment()),
("Configuration", _check_configuration(config)), ("Configuration", _check_configuration(config)),
("Stacks & dependencies", _check_stacks(config)),
("Runtime", _check_runtime(config)), ("Runtime", _check_runtime(config)),
("TLS & exposure", _check_tls_exposure(config)), ("TLS & exposure", _check_tls_exposure(config)),
] ]

View File

@@ -0,0 +1,110 @@
"""castle stack — the stacks lens (toolchains a program's stack needs).
A *stack* (python-fastapi, react-vite, hugo, …) is creation-time guidance that
also carries the **host toolchains** its programs need to build and run (`uv`,
`pnpm`, `hugo`, …). This lens makes those dependencies visible and tells you
whether they're present *where the running service needs them* — the drift a bare
`which` in your shell misses — with a copyable fix when one is absent.
"""
from __future__ import annotations
import argparse
import json
from dataclasses import asdict
from typing import TYPE_CHECKING
from castle_cli.config import load_config
if TYPE_CHECKING:
from castle_core.stack_status import StackStatus
BOLD = "\033[1m"
DIM = "\033[2m"
RESET = "\033[0m"
GREEN = "\033[92m"
RED = "\033[91m"
GREY = "\033[90m"
CYAN = "\033[96m"
def _record(st: StackStatus) -> dict:
d = asdict(st)
d["in_use"] = st.in_use
d["ok"] = st.ok
return d
def run_stack_list(args: argparse.Namespace) -> int:
"""List stacks with their toolchain health, program count, and dev verbs."""
from castle_core.stack_status import all_stack_status
config = load_config()
# Skip per-tool version probes for the list (a subprocess per tool) — keep it snappy.
stacks = all_stack_status(config, with_version=False)
if getattr(args, "json", False):
print(json.dumps([_record(s) for s in stacks], indent=2))
return 0
print(f"\n{BOLD}Stacks{RESET}")
print("" * 64)
width = max((len(s.name) for s in stacks), default=0)
for s in stacks:
if not s.tools:
dot = f"{GREY}{RESET}"
else:
dot = f"{GREEN}{RESET}" if s.ok else f"{RED}{RESET}"
missing = [t.command for t in s.tools if not t.present]
tools = ", ".join(
(f"{RED}{t.command}{RESET}" if not t.present else t.command) for t in s.tools
)
used = (
f"{len(s.programs)} program{'s' if len(s.programs) != 1 else ''}"
if s.in_use
else f"{GREY}unused{RESET}"
)
tail = f" {DIM}{used}{RESET}"
tools_str = f" {DIM}tools:{RESET} {tools}" if tools else ""
print(f" {dot} {BOLD}{s.name:<{width}}{RESET}{tools_str}{tail}")
if missing:
print(f" {RED}missing:{RESET} {', '.join(missing)}")
print()
return 0
def run_stack_info(args: argparse.Namespace) -> int:
"""Show one stack: each tool's presence + version + fix, and who uses it."""
from castle_core.stack_status import stack_status
config = load_config()
name = args.name
st = stack_status(config, name)
if st is None:
from castle_core.stacks import available_stacks
print(f"Error: no stack '{name}'. Known: {', '.join(available_stacks())}")
return 1
if getattr(args, "json", False):
print(json.dumps(_record(st), indent=2))
return 0
print(f"\n{BOLD}{st.name}{RESET}")
print("" * 48)
if st.verbs:
print(f" {BOLD}verbs{RESET}: {', '.join(st.verbs)}")
print(f" {BOLD}used by{RESET}: ", end="")
print(", ".join(st.programs) if st.programs else f"{GREY}nothing{RESET}")
print(f"\n {BOLD}toolchain{RESET}")
if not st.tools:
print(f" {GREY}no host tools required{RESET}")
for t in st.tools:
mark = f"{GREEN}{RESET}" if t.present else f"{RED}{RESET}"
ver = f" {DIM}{t.version}{RESET}" if t.version else ""
print(f" {mark} {BOLD}{t.command}{RESET} {DIM}{t.purpose} ({t.phase}){RESET}{ver}")
if not t.present:
print(f" {CYAN}{t.install_hint}{RESET}")
print()
return 0

View File

@@ -94,6 +94,20 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help) p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help)
def _build_stack_group(subparsers: argparse._SubParsersAction) -> None:
"""The `stack` lens — the toolchains each stack needs + whether they're present."""
grp = subparsers.add_parser("stack", help="Stacks + the toolchains they require")
grp.set_defaults(resource="stack")
sub = grp.add_subparsers(dest="stack_command")
p = sub.add_parser("list", help="List stacks with their toolchain health")
p.add_argument("--json", action="store_true", help="Machine-readable output")
p = sub.add_parser("info", help="Show a stack's tools, versions, fixes, and users")
_add_name(p, "Stack name")
p.add_argument("--json", action="store_true", help="Machine-readable output")
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None: def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml") p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml")
_add_name(p, f"{kind.capitalize()} name") _add_name(p, f"{kind.capitalize()} name")
@@ -166,6 +180,7 @@ def build_parser() -> argparse.ArgumentParser:
_build_deployment_group(subparsers, "service") _build_deployment_group(subparsers, "service")
_build_deployment_group(subparsers, "job") _build_deployment_group(subparsers, "job")
_build_tool_group(subparsers) _build_tool_group(subparsers)
_build_stack_group(subparsers)
# Gateway (inspection). The gateway is a deployment — start/stop/reload it via # Gateway (inspection). The gateway is a deployment — start/stop/reload it via
# `castle apply` / `castle restart castle-gateway`; this lens just shows routes. # `castle apply` / `castle restart castle-gateway`; this lens just shows routes.
@@ -287,6 +302,22 @@ def _dispatch_tool(args: argparse.Namespace) -> int:
return 1 return 1
def _dispatch_stack(args: argparse.Namespace) -> int:
sub = args.stack_command
if not sub:
print("Usage: castle stack {list|info}")
return 1
if sub == "list":
from castle_cli.commands.stack import run_stack_list
return run_stack_list(args)
if sub == "info":
from castle_cli.commands.stack import run_stack_info
return run_stack_info(args)
return 1
def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int: def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
sub = getattr(args, f"{kind}_command") sub = getattr(args, f"{kind}_command")
if not sub: if not sub:
@@ -335,6 +366,8 @@ def main() -> int:
return _dispatch_deployment(args, cmd) return _dispatch_deployment(args, cmd)
if cmd == "tool": if cmd == "tool":
return _dispatch_tool(args) return _dispatch_tool(args)
if cmd == "stack":
return _dispatch_stack(args)
if cmd == "gateway": if cmd == "gateway":
from castle_cli.commands.gateway import run_gateway from castle_cli.commands.gateway import run_gateway

View File

@@ -7,7 +7,14 @@ from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
from castle_cli.commands.doctor import FAIL, OK, WARN, _check_configuration, run_doctor from castle_cli.commands.doctor import (
FAIL,
OK,
WARN,
_check_configuration,
_check_stacks,
run_doctor,
)
class TestDoctor: class TestDoctor:
@@ -85,3 +92,57 @@ class TestDataDirChecks:
warn = next(c for c in _check_configuration(cfg) if "overrides castle.yaml" in c.label) warn = next(c for c in _check_configuration(cfg) if "overrides castle.yaml" in c.label)
assert warn.status == WARN assert warn.status == WARN
assert "CASTLE_DATA_DIR" in warn.detail assert "CASTLE_DATA_DIR" in warn.detail
class TestStackChecks:
"""The stacks section: a stack's toolchain must be present where its programs
run. Missing tooling for an enabled deployment is a FAIL with a copyable fix."""
def _fastapi_cfg(self):
import castle_core.config as C
from castle_core.manifest import ProgramSpec, SystemdDeployment
prog = ProgramSpec(id="svc", stack="python-fastapi")
dep = SystemdDeployment.model_validate(
{
"manager": "systemd",
"program": "svc",
"run": {"launcher": "command", "argv": ["svc"]},
}
)
return C.CastleConfig(
root=None,
gateway=C.GatewayConfig(port=9000),
repo=None,
programs={"svc": prog},
deployments={"svc": dep},
)
def test_present_tool_is_ok(self, monkeypatch: pytest.MonkeyPatch) -> None:
import castle_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
fa = next(
c for c in _check_stacks(self._fastapi_cfg()) if c.label.startswith("python-fastapi")
)
assert fa.status == OK
def test_missing_tool_for_enabled_deployment_fails_with_hint(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
import castle_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: False)
fa = next(
c for c in _check_stacks(self._fastapi_cfg()) if c.label.startswith("python-fastapi")
)
assert fa.status == FAIL
assert "uv" in fa.detail and fa.hint # names the tool + offers a fix
def test_unused_stacks_are_skipped(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""No react-vite program → no pnpm nag."""
import castle_core.stack_status as SS
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
labels = [c.label for c in _check_stacks(self._fastapi_cfg())]
assert not any(label.startswith("react-vite") for label in labels)

View File

@@ -89,6 +89,7 @@ USER_TOOL_PATH_DIRS = [
Path.home() / ".local" / "bin", Path.home() / ".local" / "bin",
Path.home() / ".local" / "share" / "pnpm" / "bin", Path.home() / ".local" / "share" / "pnpm" / "bin",
Path.home() / ".local" / "share" / "pnpm", Path.home() / ".local" / "share" / "pnpm",
Path.home() / ".deno" / "bin", # deno's installer target (supabase edge fns)
Path("/usr/local/go/bin"), Path("/usr/local/go/bin"),
] ]

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import os import os
import shutil import shutil
import subprocess import subprocess
from collections.abc import Sequence
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@@ -329,6 +330,7 @@ def apply(
# No writes: for systemd, predict the new unit bytes by rendering to a string # No writes: for systemd, predict the new unit bytes by rendering to a string
# so "would restart" is accurate; other managers never restart. # so "would restart" is accurate; other managers never restart.
result.planned = True result.planned = True
_stack_preflight(config, items, result.messages)
for k, n, _ in items: for k, n, _ in items:
after = _render_unit_preview(config, n, desired[(k, n)], k) after = _render_unit_preview(config, n, desired[(k, n)], k)
_record(result, n, _classify((k, n), after)) _record(result, n, _classify((k, n), after))
@@ -339,6 +341,7 @@ def apply(
deploy_result = deploy(target_name, root) deploy_result = deploy(target_name, root)
result.messages = list(deploy_result.messages) result.messages = list(deploy_result.messages)
result.registry = deploy_result.registry result.registry = deploy_result.registry
_stack_preflight(config, items, result.messages)
# Materialize TLS cert files before (re)starting so a TLS service finds them on # Materialize TLS cert files before (re)starting so a TLS service finds them on
# start. On a fresh node the gateway reload above only kicks off ACME issuance, # start. On a fresh node the gateway reload above only kicks off ACME issuance,
@@ -372,6 +375,32 @@ def apply(
return result return result
def _stack_preflight(
config: CastleConfig,
items: Sequence[tuple[str, str, object]],
messages: list[str],
) -> None:
"""Warn (never fail) when an enabled deployment's stack toolchain is missing
*where it runs* — the moment drift actually bites: a service whose `uv`/`pnpm`
isn't on its runtime PATH won't build or start. Mirrors `_acme_preflight`: an
advisory message, no writes, no gate. The exact fix comes from the tool's hint."""
from castle_core.relations import _tool_available
from castle_core.stacks import tools_for
for _k, n, spec in items:
if not getattr(spec, "enabled", True):
continue
prog = config.programs.get(getattr(spec, "program", None) or n)
if not prog or not prog.stack:
continue
for tool in tools_for(prog.stack):
if not _tool_available(spec, tool):
messages.append(
f"Warning: {n} ({prog.stack}) needs '{tool.command}' but it's "
f"missing where the service runs — {tool.install_hint}"
)
def _record(result: ApplyResult, name: str, action: str) -> None: def _record(result: ApplyResult, name: str, action: str) -> None:
{ {
"activate": result.activated, "activate": result.activated,
@@ -451,7 +480,7 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
config_path.unlink() config_path.unlink()
messages.append("No public services — removed cloudflared config.") messages.append("No public services — removed cloudflared config.")
# Still reconcile so any CNAMEs castle created earlier are cleaned up. # Still reconcile so any CNAMEs castle created earlier are cleaned up.
reconcile_public_dns(node.public_domain, node.tunnel_id, [], messages) reconcile_public_dns(node.tunnel_id, [], messages)
return return
config_path.write_text(content) config_path.write_text(content)
@@ -459,7 +488,7 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)") messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)")
# Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual # Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual
# `cloudflared tunnel route dns` commands when no DNS token is configured. # `cloudflared tunnel route dns` commands when no DNS token is configured.
if not reconcile_public_dns(node.public_domain, node.tunnel_id, hosts, messages): if not reconcile_public_dns(node.tunnel_id, hosts, messages):
for h in hosts: for h in hosts:
messages.append( messages.append(
f" public: {h} " f" public: {h} "
@@ -712,6 +741,7 @@ def _build_deployed(
stack=stack, stack=stack,
subdomain=name, subdomain=name,
public=bool(dep.public), public=bool(dep.public),
public_host=(dep.public_host if dep.public else None),
static_root=static_root, static_root=static_root,
managed=False, managed=False,
enabled=dep.enabled, enabled=dep.enabled,
@@ -845,6 +875,7 @@ def _build_deployed(
health_path=health_path, health_path=health_path,
subdomain=(name if expose else None), subdomain=(name if expose else None),
public=bool(dep.public and expose), public=bool(dep.public and expose),
public_host=(dep.public_host if (dep.public and expose) else None),
tcp_port=tcp_port, tcp_port=tcp_port,
schedule=getattr(dep, "schedule", None), schedule=getattr(dep, "schedule", None),
managed=managed, managed=managed,

View File

@@ -17,6 +17,19 @@ UNIT_PREFIX = "castle-"
SECRET_ENV_DIR = SECRETS_DIR / "env" SECRET_ENV_DIR = SECRETS_DIR / "env"
def runtime_path(path_prepend: list[str] | tuple[str, ...] = ()) -> str:
"""The PATH a castle service runs with: resolved toolchain dirs (e.g. a pinned
node bin) + the user tool dirs that exist + system bins. This is the single
definition of a service's runtime PATH — the unit generator writes it into
``Environment=PATH`` and the dependency checker (``relations``) probes tools
against it, so the two can never disagree about where a service finds its tools.
"""
dirs = list(path_prepend)
dirs += [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
dirs += ["/usr/local/bin", "/usr/bin", "/bin"]
return ":".join(dirs)
def unit_basename(name: str, kind: str = "service") -> str: def unit_basename(name: str, kind: str = "service") -> str:
"""The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a """The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a
service and a job can share a name (`castle-<name>.service` vs service and a job can share a name (`castle-<name>.service` vs
@@ -127,10 +140,7 @@ def generate_unit_from_deployed(
# ${PATH} across Environment= lines, so a service that overrides PATH must # ${PATH} across Environment= lines, so a service that overrides PATH must
# spell out the full value, tool dirs included. # spell out the full value, tool dirs included.
if "PATH" not in deployed.env: if "PATH" not in deployed.env:
dirs = list(deployed.path_prepend) # resolved toolchain (e.g. pinned node bin) env_lines += f'Environment="PATH={runtime_path(deployed.path_prepend)}"\n'
dirs += [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
dirs += ["/usr/local/bin", "/usr/bin", "/bin"]
env_lines += f'Environment="PATH={":".join(dirs)}"\n'
if env_file is not None: if env_file is not None:
env_lines += f"EnvironmentFile={env_file}\n" env_lines += f"EnvironmentFile={env_file}\n"
@@ -176,7 +186,7 @@ SuccessExitStatus=143
# Post-start hooks (e.g. OpenBao auto-unseal). `-` prefix → failure is ignored, # Post-start hooks (e.g. OpenBao auto-unseal). `-` prefix → failure is ignored,
# so a hiccup in the hook never fails the unit. # so a hiccup in the hook never fails the unit.
for cmd in (sd.exec_start_post if sd else []): for cmd in sd.exec_start_post if sd else []:
argv = cmd.split() argv = cmd.split()
resolved = shutil.which(argv[0]) resolved = shutil.which(argv[0])
if resolved: if resolved:

View File

@@ -263,13 +263,14 @@ class Requirement(BaseModel):
requirement, never scraped back into it. requirement, never scraped back into it.
A deployment declares these in its ``requires`` list; ``kind`` defaults to A deployment declares these in its ``requires`` list; ``kind`` defaults to
``deployment`` (write just ``- ref: foo``). The ``system`` kind is not written ``deployment`` (write just ``- ref: foo``). The ``system`` and ``tool`` kinds are
here — a program's host-package preconditions live in ``system_dependencies``, not written here — a program's host-package preconditions live in
and the relationship model synthesizes ``kind: system`` requirements from it for ``system_dependencies`` (synthesized as ``kind: system``), and its stack's
the ``functional?`` check. See docs/relationships.md. toolchains (``uv``/``pnpm``/``hugo``/…) are synthesized as ``kind: tool`` — both by
the relationship model for the ``functional?`` check. See docs/relationships.md.
""" """
kind: Literal["system", "deployment"] = "deployment" kind: Literal["system", "deployment", "tool"] = "deployment"
ref: str ref: str
bind: str | None = None bind: str | None = None

View File

@@ -16,6 +16,7 @@ not the reverse.
from __future__ import annotations from __future__ import annotations
import os
import shutil import shutil
import subprocess import subprocess
from collections import Counter from collections import Counter
@@ -23,8 +24,10 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from castle_core import git from castle_core import git
from castle_core.config import CastleConfig from castle_core.config import USER_TOOL_PATH_DIRS, CastleConfig
from castle_core.generators.systemd import runtime_path
from castle_core.manifest import Requirement, SystemdDeployment from castle_core.manifest import Requirement, SystemdDeployment
from castle_core.stacks import ToolRequirement, tools_for
@dataclass @dataclass
@@ -131,11 +134,12 @@ 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`` (deployment """The full requirement set for a deployment: its own ``requires`` (deployment
dependencies) plus its program's ``system_dependencies`` synthesized as dependencies), its program's ``system_dependencies`` synthesized as
``kind: system`` requirements, de-duplicated by (kind, ref).""" ``kind: system`` requirements, and its stack's toolchains synthesized as
``kind: tool`` 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 each program's
# host-package deps as the synthesized `kind: system` set). # host-package deps as `kind: system` and its stack's toolchains as `kind: tool`).
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))
@@ -143,6 +147,9 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
reqs += [ reqs += [
Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies
] ]
reqs += [
Requirement(kind="tool", ref=t.command) for t in tools_for(prog.stack)
]
seen: set[tuple[str, str]] = set() seen: set[tuple[str, str]] = set()
out: list[Requirement] = [] out: list[Requirement] = []
for r in reqs: for r in reqs:
@@ -152,6 +159,20 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
return out return out
def stack_tools_of(config: CastleConfig, dep_name: str) -> dict[str, ToolRequirement]:
"""command → its :class:`ToolRequirement`, for every stack toolchain the
deployment(s) named ``dep_name`` need. The metadata (phase, install hint) behind
the ``kind: tool`` requirements ``requirements_of`` synthesizes — used to check
them (phase picks the PATH) and to hint a fix."""
meta: dict[str, ToolRequirement] = {}
for _kind, dep in config.deployments_named(dep_name):
prog = config.programs.get(_program_of(dep_name, dep))
if prog and prog.stack:
for t in tools_for(prog.stack):
meta.setdefault(t.command, t)
return meta
def _dpkg_installed(pkg: str) -> bool: def _dpkg_installed(pkg: str) -> bool:
"""Whether a dpkg package is installed (the authoritative 'installed' check on """Whether a dpkg package is installed (the authoritative 'installed' check on
Debian/Ubuntu). Returns False where dpkg isn't available.""" Debian/Ubuntu). Returns False where dpkg isn't available."""
@@ -162,8 +183,41 @@ def _dpkg_installed(pkg: str) -> bool:
return r.returncode == 0 and "install ok installed" in r.stdout return r.returncode == 0 and "install ok installed" in r.stdout
def _check(config: CastleConfig, req: Requirement) -> bool: def _build_path() -> str:
"""Is a single requirement satisfied? (The check is fixed by its kind.)""" """The PATH a *build/dev* verb runs with — the caller's own PATH plus the user
tool dirs (mirrors ``stacks._build_env``, minus the per-program node pin resolved
separately). Build-phase tools (pnpm, hugo, node) are checked against this."""
dirs = [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
return ":".join([*dirs, os.environ.get("PATH", "")])
def _tool_available(dep: object, tool: ToolRequirement) -> bool:
"""Is a stack tool present *where the deployment needs it*? A ``run``/``both``
tool of a systemd service must be on the **service's runtime PATH** (the curated
unit PATH, which can differ from your shell — the drift this catches); every
other case (build-only tools, or a non-service deployment like a static site) is
checked against the build/dev PATH."""
runs_service = isinstance(dep, SystemdDeployment)
if tool.phase in ("run", "both") and runs_service:
defaults = getattr(dep, "defaults", None)
env_path = (defaults.env.get("PATH") if defaults else None) or None
# `path_prepend` (resolved toolchain) lives on the registry deployment, not
# the manifest one; absent here it's the base runtime PATH, which is what a
# service without a pinned toolchain actually runs with.
path = env_path or runtime_path(list(getattr(dep, "path_prepend", []) or ()))
return shutil.which(tool.command, path=path) is not None
return shutil.which(tool.command, path=_build_path()) is not None
def _check(
config: CastleConfig,
req: Requirement,
dep: object | None = None,
tool: ToolRequirement | None = None,
) -> bool:
"""Is a single requirement satisfied? The check is fixed by its ``kind``. For a
``tool`` requirement, ``dep`` and ``tool`` supply the deployment context and the
stack-tool metadata (phase decides which PATH to probe)."""
if req.kind == "system": if req.kind == "system":
# `system_dependencies` holds PACKAGE names, not executables. A PATH lookup # `system_dependencies` holds PACKAGE names, not executables. A PATH lookup
# only coincides with 'installed' when the package name equals its command # only coincides with 'installed' when the package name equals its command
@@ -173,9 +227,24 @@ def _check(config: CastleConfig, req: Requirement) -> bool:
return shutil.which(req.ref) is not None or _dpkg_installed(req.ref) return shutil.which(req.ref) is not None or _dpkg_installed(req.ref)
if req.kind == "deployment": if req.kind == "deployment":
return bool(config.deployments_named(req.ref)) return bool(config.deployments_named(req.ref))
if req.kind == "tool":
# No metadata (unknown stack) → don't raise a false alarm.
return tool is None or _tool_available(dep, tool)
return True return True
def hint_for(req: Requirement, tool: ToolRequirement | None = None) -> str:
"""A copyable next step for an *unmet* requirement — the piece that makes a
diagnostic actionable. ``tool`` carries a stack tool's precise install command."""
if req.kind == "tool":
return tool.install_hint if tool else f"install {req.ref}"
if req.kind == "system":
return f"sudo apt install {req.ref}"
if req.kind == "deployment":
return f"create & apply the '{req.ref}' deployment"
return ""
# Well-known TCP ports → a friendlier protocol label (display heuristic only). # Well-known TCP ports → a friendlier protocol label (display heuristic only).
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"} _TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
@@ -229,11 +298,12 @@ def build_model(
nodes: list[Node] = [] nodes: list[Node] = []
for _nk, name, dep in config.all_deployments(): for _nk, name, dep in config.all_deployments():
tmeta = stack_tools_of(config, name) if check else {}
unmet = ( unmet = (
[ [
f"{r.kind}:{r.ref}" f"{r.kind}:{r.ref}"
for r in requirements_of(config, name) for r in requirements_of(config, name)
if not _check(config, r) if not _check(config, r, dep=dep, tool=tmeta.get(r.ref))
] ]
if check if check
else [] else []

View File

@@ -0,0 +1,136 @@
"""Stack dependency status — the derived, per-stack health the `castle stack`
command, the ``GET /stacks`` API, and the dashboard Stacks page all render.
A *stack* declares the host toolchains it needs (``stacks.ToolRequirement``); this
module answers, for each stack: which programs/deployments use it, whether its
tools are present *where the using deployments need them* (run-phase tools against
a service's runtime PATH — the drift the plain ``which`` a shell does misses), and
the copyable fix when one is missing. It's the single source of truth so the CLI,
API, and UI never disagree.
"""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass, field
from castle_core.config import CastleConfig
from castle_core.generators.systemd import runtime_path
from castle_core.relations import _build_path, _tool_available
from castle_core.stacks import ToolRequirement, available_stacks, get_handler, tools_for
@dataclass
class ToolStatus:
command: str
purpose: str
phase: str # run | build | both
present: bool # resolvable where the using deployments need it
install_hint: str # copyable fix (shown when absent)
version: str | None = None # best-effort `--version`, when present
@dataclass
class StackStatus:
name: str
tools: list[ToolStatus] = field(default_factory=list)
programs: list[str] = field(default_factory=list) # programs on this stack
deployments: list[str] = field(default_factory=list) # their deployments
verbs: list[str] = field(default_factory=list) # dev verbs the stack provides
has_enabled_deployment: bool = False # ≥1 enabled deployment uses it
@property
def in_use(self) -> bool:
return bool(self.programs)
@property
def ok(self) -> bool:
"""All needed tools present (vacuously true for a stack with no tools)."""
return all(t.present for t in self.tools)
def _version(command: str, path: str) -> str | None:
"""Best-effort tool version — the first `<cmd> --version` line (falls back to
`<cmd> version` for tools like hugo). Never raises; returns None on any trouble."""
exe = shutil.which(command, path=path)
if not exe:
return None
for argv in ([exe, "--version"], [exe, "version"]):
try:
r = subprocess.run(argv, capture_output=True, text=True, timeout=2)
except (OSError, subprocess.SubprocessError):
continue
out = (r.stdout or r.stderr or "").strip()
if r.returncode == 0 and out:
return out.splitlines()[0].strip()
return None
def _tool_status(
tool: ToolRequirement,
using_deps: list[object],
*,
with_version: bool,
) -> ToolStatus:
# Present where it's needed: a run/both tool must resolve for EVERY using
# deployment (a service's runtime PATH); with no deployments, check generically.
if using_deps:
present = all(_tool_available(dep, tool) for dep in using_deps)
elif tool.phase in ("run", "both"):
present = shutil.which(tool.command, path=runtime_path()) is not None
else:
present = shutil.which(tool.command, path=_build_path()) is not None
# A version is only meaningful when the tool is present; probe the path that
# matched (runtime for run/both, build otherwise) so it reflects what's used.
probe_path = runtime_path() if tool.phase in ("run", "both") else _build_path()
version = _version(tool.command, probe_path) if (present and with_version) else None
return ToolStatus(
command=tool.command,
purpose=tool.purpose,
phase=tool.phase,
present=present,
install_hint=tool.install_hint,
version=version,
)
def stack_status(
config: CastleConfig, name: str, *, with_version: bool = True
) -> StackStatus | None:
"""The dependency status of one stack, or None if castle has no such handler."""
handler = get_handler(name)
if handler is None:
return None
programs = sorted(p for p, c in config.programs.items() if c.stack == name)
deps: list[tuple[str, object]] = [] # (deployment-name, spec)
enabled = False
for _kind, dep_name, dep in config.all_deployments():
prog = config.programs.get(dep.program or dep_name)
if prog and prog.stack == name:
deps.append((dep_name, dep))
enabled = enabled or getattr(dep, "enabled", True)
dep_specs = [d for _n, d in deps]
return StackStatus(
name=name,
tools=[
_tool_status(t, dep_specs, with_version=with_version)
for t in tools_for(name)
],
programs=programs,
deployments=sorted(n for n, _d in deps),
verbs=sorted(handler.provides),
has_enabled_deployment=enabled,
)
def all_stack_status(
config: CastleConfig, *, with_version: bool = True
) -> list[StackStatus]:
"""Dependency status for every stack castle knows — the Stacks catalog."""
out = []
for name in available_stacks():
st = stack_status(config, name, with_version=with_version)
if st is not None:
out.append(st)
return out

View File

@@ -42,6 +42,30 @@ class ActionResult:
output: str = "" output: str = ""
@dataclass(frozen=True)
class ToolRequirement:
"""A host toolchain a stack needs — the declarative counterpart to the argv each
handler hard-codes (``uv sync``, ``pnpm build``, …). Made explicit so castle can
*check* whether the tool is present (on the box and, for run-phase tools, on the
service's own PATH) and, when it isn't, show a copyable fix instead of failing
mid-subprocess with a raw ``command not found``.
- ``phase`` — when the tool is needed. ``build`` tools run only at
``castle apply``/build time (checked against the build env); ``run`` tools must
also be on the *running service's* PATH (the curated systemd env, which can
drift from your shell); ``both`` is checked in both places.
- ``install_hint`` — the exact command a user can copy to install it.
"""
command: (
str # the executable, e.g. "uv" | "pnpm" | "node" | "hugo" | "deno" | "psql"
)
purpose: str # human phrase, e.g. "build & run Python programs"
phase: str # "run" | "build" | "both"
install_hint: str # copyable install command
version_min: str | None = None
def _build_env(node_source: Path | None = None) -> dict[str, str]: def _build_env(node_source: Path | None = None) -> dict[str, str]:
"""Build a subprocess env with user tool dirs on PATH. """Build a subprocess env with user tool dirs on PATH.
@@ -119,6 +143,11 @@ class StackHandler:
# lint/type-check/test also drops `check` implicitly. # lint/type-check/test also drops `check` implicitly.
provides: set[str] = _STACK_VERBS provides: set[str] = _STACK_VERBS
# The host toolchains this stack needs, declared so castle can check them and
# hint a fix. Empty by default (a stackless / declared-command program depends on
# nothing castle can name); each real handler overrides it. See `tools_for`.
tools: tuple[ToolRequirement, ...] = ()
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
raise NotImplementedError raise NotImplementedError
@@ -178,6 +207,15 @@ class StackHandler:
class PythonHandler(StackHandler): class PythonHandler(StackHandler):
"""Handler for python-cli and python-fastapi stacks.""" """Handler for python-cli and python-fastapi stacks."""
tools = (
ToolRequirement(
command="uv",
purpose="build & run Python programs",
phase="both",
install_hint="curl -LsSf https://astral.sh/uv/install.sh | sh",
),
)
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
rc, output = await _run(["uv", "sync"], src) rc, output = await _run(["uv", "sync"], src)
@@ -286,6 +324,24 @@ def _pnpm(*args: str) -> list[str]:
class ReactViteHandler(StackHandler): class ReactViteHandler(StackHandler):
"""Handler for react-vite stack.""" """Handler for react-vite stack."""
# Build-phase only: the output is a static `dist/` the gateway serves in place,
# so no node/pnpm process runs at serve time. node is resolved per-program from
# its pin (see toolchains); the hint names nvm, the ecosystem-standard installer.
tools = (
ToolRequirement(
command="node",
purpose="build the frontend (Vite/React toolchain)",
phase="build",
install_hint="nvm install --lts # or match the program's .node-version",
),
ToolRequirement(
command="pnpm",
purpose="install deps & run the Vite build",
phase="build",
install_hint="npm install -g pnpm # or: corepack enable pnpm",
),
)
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
# Build against the gateway serve root so absolute asset URLs resolve at / # Build against the gateway serve root so absolute asset URLs resolve at /
@@ -393,6 +449,14 @@ class HugoHandler(StackHandler):
default per the usual declared-command-wins resolution.""" default per the usual declared-command-wins resolution."""
provides = {"build", "install", "uninstall"} provides = {"build", "install", "uninstall"}
tools = (
ToolRequirement(
command="hugo",
purpose="build the static site",
phase="build",
install_hint="sudo apt install hugo # or: snap install hugo",
),
)
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
src = _source_dir(comp, root) src = _source_dir(comp, root)
@@ -527,6 +591,20 @@ class SupabaseHandler(StackHandler):
""" """
owns_data = True owns_data = True
tools = (
ToolRequirement(
command="psql",
purpose="run schema migrations against the substrate DB",
phase="both",
install_hint="sudo apt install postgresql-client",
),
ToolRequirement(
command="deno",
purpose="serve/deploy edge functions",
phase="both",
install_hint="curl -fsSL https://deno.land/install.sh | sh",
),
)
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult: async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
"""Apply unapplied migrations into the app's own schema (idempotent).""" """Apply unapplied migrations into the app's own schema (idempotent)."""
@@ -734,6 +812,14 @@ def available_stacks() -> list[str]:
return sorted(HANDLERS) return sorted(HANDLERS)
def tools_for(stack: str | None) -> tuple[ToolRequirement, ...]:
"""The host toolchains a stack declares it needs (empty for an unknown/absent
stack). The single source of truth for the dependency checks in `relations`,
`castle stack`, `castle doctor`, and the dashboard Stacks page."""
handler = get_handler(stack)
return handler.tools if handler is not None else ()
def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None: def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
"""Declared argv-lists for a verb, or None. """Declared argv-lists for a verb, or None.

View File

@@ -6,7 +6,13 @@ 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, SystemdDeployment from castle_core.manifest import (
CaddyDeployment,
ProgramSpec,
Requirement,
SystemdDeployment,
)
from castle_core.stacks import tools_for
def _dep(program: str, requires: list | None = None) -> SystemdDeployment: def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
@@ -20,6 +26,12 @@ def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
return SystemdDeployment.model_validate(spec) return SystemdDeployment.model_validate(spec)
def _static(program: str) -> CaddyDeployment:
return CaddyDeployment.model_validate(
{"manager": "caddy", "program": program, "root": "dist"}
)
def _cfg(programs: dict, deployments: dict) -> C.CastleConfig: def _cfg(programs: dict, deployments: dict) -> C.CastleConfig:
return C.CastleConfig( return C.CastleConfig(
root=None, root=None,
@@ -56,7 +68,11 @@ 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."""
cfg = _cfg( cfg = _cfg(
{"web": ProgramSpec(id="web"), "cli": ProgramSpec(id="cli"), "api": ProgramSpec(id="api")}, {
"web": ProgramSpec(id="web"),
"cli": ProgramSpec(id="cli"),
"api": ProgramSpec(id="api"),
},
{ {
"web": _dep("web", requires=[{"ref": "api", "bind": "API_URL"}]), "web": _dep("web", requires=[{"ref": "api", "bind": "API_URL"}]),
"cli": _dep("cli", requires=[{"ref": "api"}]), "cli": _dep("cli", requires=[{"ref": "api"}]),
@@ -107,3 +123,73 @@ def test_missing_deployment_requirement_is_unmet() -> None:
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
# --- stack toolchains as requirements ----------------------------------------
def test_stack_toolchain_surfaces_as_tool_requirement() -> None:
"""A program's stack contributes its toolchains as {kind: tool} requirements."""
prog = ProgramSpec(id="svc", stack="python-fastapi")
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
reqs = {(r.kind, r.ref) for r in R.requirements_of(cfg, "svc")}
assert ("tool", "uv") in reqs
def test_stack_tool_missing_from_service_path_is_unmet(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The drift case: uv resolves in the caller's shell PATH but NOT on the
service's curated runtime PATH → unmet *for the service*, even though a bare
`which` (what `castle tool list` uses) would report it present."""
prog = ProgramSpec(id="svc", stack="python-fastapi")
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
shell_only = "/home/me/.shell-tools" # a dir the runtime PATH never includes
monkeypatch.setenv("PATH", shell_only)
monkeypatch.setattr(
R.shutil,
"which",
lambda cmd, path=None: (
f"{shell_only}/{cmd}" if path and shell_only in path else None
),
)
m = R.build_model(cfg, check=True)
svc = next(n for n in m.nodes if n.name == "svc")
assert svc.unmet == ["tool:uv"] and svc.functional is False
def test_stack_tool_present_on_service_path_is_satisfied(
monkeypatch: pytest.MonkeyPatch,
) -> None:
prog = ProgramSpec(id="svc", stack="python-fastapi")
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
monkeypatch.setattr(R.shutil, "which", lambda cmd, path=None: f"/usr/bin/{cmd}")
svc = next(n for n in R.build_model(cfg, check=True).nodes if n.name == "svc")
assert svc.functional is True and "tool:uv" not in svc.unmet
def test_build_phase_tool_checked_against_build_path(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A static site's build-only tools (pnpm/node) are checked against the build/dev
PATH — a static deployment runs no process, so runtime-PATH drift doesn't apply."""
prog = ProgramSpec(id="ui", stack="react-vite")
cfg = _cfg({"ui": prog}, {"ui": _static("ui")})
dev_dir = "/home/me/.local/share/pnpm"
monkeypatch.setenv("PATH", dev_dir)
monkeypatch.setattr(
R.shutil,
"which",
lambda cmd, path=None: f"{dev_dir}/{cmd}" if path and dev_dir in path else None,
)
ui = next(n for n in R.build_model(cfg, check=True).nodes if n.name == "ui")
assert ui.functional is True and not [u for u in ui.unmet if u.startswith("tool:")]
def test_hint_for_each_requirement_kind() -> None:
uv = tools_for("python-fastapi")[0]
assert "astral.sh" in R.hint_for(Requirement(kind="tool", ref="uv"), uv)
assert R.hint_for(Requirement(kind="system", ref="pandoc")) == (
"sudo apt install pandoc"
)
assert "api" in R.hint_for(Requirement(kind="deployment", ref="api"))