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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user