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:
@@ -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)),
|
||||
]
|
||||
|
||||
110
cli/src/castle_cli/commands/stack.py
Normal file
110
cli/src/castle_cli/commands/stack.py
Normal 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
|
||||
Reference in New Issue
Block a user