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

@@ -345,15 +345,20 @@ def _check_tls_exposure(config) -> list[Check]:
checks.append(_check_privileged_ports())
# 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:
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)"))
else:
missing = []
if not gw.public_domain:
if need_default and not gw.public_domain:
missing.append("public_domain")
if not gw.tunnel_id:
missing.append("tunnel_id")
@@ -403,20 +408,26 @@ def _check_public_dns(config) -> Check:
"public DNS not automated",
detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual",
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} "
"(else route each host by hand)"
),
)
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:
where = gw.public_domain or "any zone"
return Check(
FAIL,
"public DNS token can't see the zone",
detail=f"{gw.public_domain} not visible",
detail=f"{where} not visible",
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)"
),
)
@@ -436,7 +447,7 @@ def _check_public_dns(config) -> Check:
return Check(
OK,
"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 -----------------------------------------------------------------
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:
from castle_core.config import load_config
@@ -482,6 +526,7 @@ def run_doctor(args: argparse.Namespace) -> int:
sections: list[tuple[str, list[Check]]] = [
("Environment", _check_environment()),
("Configuration", _check_configuration(config)),
("Stacks & dependencies", _check_stacks(config)),
("Runtime", _check_runtime(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)
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:
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml")
_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, "job")
_build_tool_group(subparsers)
_build_stack_group(subparsers)
# Gateway (inspection). The gateway is a deployment — start/stop/reload it via
# `castle apply` / `castle restart castle-gateway`; this lens just shows routes.
@@ -287,6 +302,22 @@ def _dispatch_tool(args: argparse.Namespace) -> int:
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:
sub = getattr(args, f"{kind}_command")
if not sub:
@@ -335,6 +366,8 @@ def main() -> int:
return _dispatch_deployment(args, cmd)
if cmd == "tool":
return _dispatch_tool(args)
if cmd == "stack":
return _dispatch_stack(args)
if cmd == "gateway":
from castle_cli.commands.gateway import run_gateway