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

@@ -11,6 +11,7 @@ from __future__ import annotations
import os
import shutil
import subprocess
from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path
@@ -329,6 +330,7 @@ def apply(
# No writes: for systemd, predict the new unit bytes by rendering to a string
# so "would restart" is accurate; other managers never restart.
result.planned = True
_stack_preflight(config, items, result.messages)
for k, n, _ in items:
after = _render_unit_preview(config, n, desired[(k, n)], k)
_record(result, n, _classify((k, n), after))
@@ -339,6 +341,7 @@ def apply(
deploy_result = deploy(target_name, root)
result.messages = list(deploy_result.messages)
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
# start. On a fresh node the gateway reload above only kicks off ACME issuance,
@@ -372,6 +375,32 @@ def apply(
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:
{
"activate": result.activated,
@@ -451,7 +480,7 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
config_path.unlink()
messages.append("No public services — removed cloudflared config.")
# 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
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)")
# Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual
# `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:
messages.append(
f" public: {h} "
@@ -712,6 +741,7 @@ def _build_deployed(
stack=stack,
subdomain=name,
public=bool(dep.public),
public_host=(dep.public_host if dep.public else None),
static_root=static_root,
managed=False,
enabled=dep.enabled,
@@ -845,6 +875,7 @@ def _build_deployed(
health_path=health_path,
subdomain=(name if expose else None),
public=bool(dep.public and expose),
public_host=(dep.public_host if (dep.public and expose) else None),
tcp_port=tcp_port,
schedule=getattr(dep, "schedule", None),
managed=managed,