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

@@ -135,6 +135,30 @@ class ProgramDetail(ProgramSummary):
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):
"""Health status of a single component."""

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from dataclasses import asdict
from pathlib import Path
from typing import TYPE_CHECKING
from fastapi import APIRouter, HTTPException, status
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.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"])
@@ -31,6 +36,40 @@ def list_stacks() -> list[str]:
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
# 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