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:
@@ -89,6 +89,7 @@ USER_TOOL_PATH_DIRS = [
|
||||
Path.home() / ".local" / "bin",
|
||||
Path.home() / ".local" / "share" / "pnpm" / "bin",
|
||||
Path.home() / ".local" / "share" / "pnpm",
|
||||
Path.home() / ".deno" / "bin", # deno's installer target (supabase edge fns)
|
||||
Path("/usr/local/go/bin"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,6 +17,19 @@ UNIT_PREFIX = "castle-"
|
||||
SECRET_ENV_DIR = SECRETS_DIR / "env"
|
||||
|
||||
|
||||
def runtime_path(path_prepend: list[str] | tuple[str, ...] = ()) -> str:
|
||||
"""The PATH a castle service runs with: resolved toolchain dirs (e.g. a pinned
|
||||
node bin) + the user tool dirs that exist + system bins. This is the single
|
||||
definition of a service's runtime PATH — the unit generator writes it into
|
||||
``Environment=PATH`` and the dependency checker (``relations``) probes tools
|
||||
against it, so the two can never disagree about where a service finds its tools.
|
||||
"""
|
||||
dirs = list(path_prepend)
|
||||
dirs += [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
|
||||
dirs += ["/usr/local/bin", "/usr/bin", "/bin"]
|
||||
return ":".join(dirs)
|
||||
|
||||
|
||||
def unit_basename(name: str, kind: str = "service") -> str:
|
||||
"""The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a
|
||||
service and a job can share a name (`castle-<name>.service` vs
|
||||
@@ -127,10 +140,7 @@ def generate_unit_from_deployed(
|
||||
# ${PATH} across Environment= lines, so a service that overrides PATH must
|
||||
# spell out the full value, tool dirs included.
|
||||
if "PATH" not in deployed.env:
|
||||
dirs = list(deployed.path_prepend) # resolved toolchain (e.g. pinned node bin)
|
||||
dirs += [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
|
||||
dirs += ["/usr/local/bin", "/usr/bin", "/bin"]
|
||||
env_lines += f'Environment="PATH={":".join(dirs)}"\n'
|
||||
env_lines += f'Environment="PATH={runtime_path(deployed.path_prepend)}"\n'
|
||||
if env_file is not None:
|
||||
env_lines += f"EnvironmentFile={env_file}\n"
|
||||
|
||||
@@ -176,7 +186,7 @@ SuccessExitStatus=143
|
||||
|
||||
# Post-start hooks (e.g. OpenBao auto-unseal). `-` prefix → failure is ignored,
|
||||
# so a hiccup in the hook never fails the unit.
|
||||
for cmd in (sd.exec_start_post if sd else []):
|
||||
for cmd in sd.exec_start_post if sd else []:
|
||||
argv = cmd.split()
|
||||
resolved = shutil.which(argv[0])
|
||||
if resolved:
|
||||
|
||||
@@ -263,13 +263,14 @@ class Requirement(BaseModel):
|
||||
requirement, never scraped back into it.
|
||||
|
||||
A deployment declares these in its ``requires`` list; ``kind`` defaults to
|
||||
``deployment`` (write just ``- ref: foo``). The ``system`` kind is not written
|
||||
here — a program's host-package preconditions live in ``system_dependencies``,
|
||||
and the relationship model synthesizes ``kind: system`` requirements from it for
|
||||
the ``functional?`` check. See docs/relationships.md.
|
||||
``deployment`` (write just ``- ref: foo``). The ``system`` and ``tool`` kinds are
|
||||
not written here — a program's host-package preconditions live in
|
||||
``system_dependencies`` (synthesized as ``kind: system``), and its stack's
|
||||
toolchains (``uv``/``pnpm``/``hugo``/…) are synthesized as ``kind: tool`` — both by
|
||||
the relationship model for the ``functional?`` check. See docs/relationships.md.
|
||||
"""
|
||||
|
||||
kind: Literal["system", "deployment"] = "deployment"
|
||||
kind: Literal["system", "deployment", "tool"] = "deployment"
|
||||
ref: str
|
||||
bind: str | None = None
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ not the reverse.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
@@ -23,8 +24,10 @@ from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core import git
|
||||
from castle_core.config import CastleConfig
|
||||
from castle_core.config import USER_TOOL_PATH_DIRS, CastleConfig
|
||||
from castle_core.generators.systemd import runtime_path
|
||||
from castle_core.manifest import Requirement, SystemdDeployment
|
||||
from castle_core.stacks import ToolRequirement, tools_for
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -131,11 +134,12 @@ def derive_repos(config: CastleConfig) -> dict[str, Repo]:
|
||||
|
||||
def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
|
||||
"""The full requirement set for a deployment: its own ``requires`` (deployment
|
||||
dependencies) plus its program's ``system_dependencies`` synthesized as
|
||||
``kind: system`` requirements, de-duplicated by (kind, ref)."""
|
||||
dependencies), its program's ``system_dependencies`` synthesized as
|
||||
``kind: system`` requirements, and its stack's toolchains synthesized as
|
||||
``kind: tool`` requirements — de-duplicated by (kind, ref)."""
|
||||
reqs: list[Requirement] = []
|
||||
# A bare name may span kinds — union their requirements (plus their program's
|
||||
# host-package deps as the synthesized `kind: system` set).
|
||||
# A bare name may span kinds — union their requirements (plus each program's
|
||||
# host-package deps as `kind: system` and its stack's toolchains as `kind: tool`).
|
||||
for _kind, dep in config.deployments_named(dep_name):
|
||||
reqs += list(getattr(dep, "requires", []) or [])
|
||||
prog = config.programs.get(_program_of(dep_name, dep))
|
||||
@@ -143,6 +147,9 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
|
||||
reqs += [
|
||||
Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies
|
||||
]
|
||||
reqs += [
|
||||
Requirement(kind="tool", ref=t.command) for t in tools_for(prog.stack)
|
||||
]
|
||||
seen: set[tuple[str, str]] = set()
|
||||
out: list[Requirement] = []
|
||||
for r in reqs:
|
||||
@@ -152,6 +159,20 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
|
||||
return out
|
||||
|
||||
|
||||
def stack_tools_of(config: CastleConfig, dep_name: str) -> dict[str, ToolRequirement]:
|
||||
"""command → its :class:`ToolRequirement`, for every stack toolchain the
|
||||
deployment(s) named ``dep_name`` need. The metadata (phase, install hint) behind
|
||||
the ``kind: tool`` requirements ``requirements_of`` synthesizes — used to check
|
||||
them (phase picks the PATH) and to hint a fix."""
|
||||
meta: dict[str, ToolRequirement] = {}
|
||||
for _kind, dep in config.deployments_named(dep_name):
|
||||
prog = config.programs.get(_program_of(dep_name, dep))
|
||||
if prog and prog.stack:
|
||||
for t in tools_for(prog.stack):
|
||||
meta.setdefault(t.command, t)
|
||||
return meta
|
||||
|
||||
|
||||
def _dpkg_installed(pkg: str) -> bool:
|
||||
"""Whether a dpkg package is installed (the authoritative 'installed' check on
|
||||
Debian/Ubuntu). Returns False where dpkg isn't available."""
|
||||
@@ -162,8 +183,41 @@ def _dpkg_installed(pkg: str) -> bool:
|
||||
return r.returncode == 0 and "install ok installed" in r.stdout
|
||||
|
||||
|
||||
def _check(config: CastleConfig, req: Requirement) -> bool:
|
||||
"""Is a single requirement satisfied? (The check is fixed by its kind.)"""
|
||||
def _build_path() -> str:
|
||||
"""The PATH a *build/dev* verb runs with — the caller's own PATH plus the user
|
||||
tool dirs (mirrors ``stacks._build_env``, minus the per-program node pin resolved
|
||||
separately). Build-phase tools (pnpm, hugo, node) are checked against this."""
|
||||
dirs = [str(d) for d in USER_TOOL_PATH_DIRS if d.exists()]
|
||||
return ":".join([*dirs, os.environ.get("PATH", "")])
|
||||
|
||||
|
||||
def _tool_available(dep: object, tool: ToolRequirement) -> bool:
|
||||
"""Is a stack tool present *where the deployment needs it*? A ``run``/``both``
|
||||
tool of a systemd service must be on the **service's runtime PATH** (the curated
|
||||
unit PATH, which can differ from your shell — the drift this catches); every
|
||||
other case (build-only tools, or a non-service deployment like a static site) is
|
||||
checked against the build/dev PATH."""
|
||||
runs_service = isinstance(dep, SystemdDeployment)
|
||||
if tool.phase in ("run", "both") and runs_service:
|
||||
defaults = getattr(dep, "defaults", None)
|
||||
env_path = (defaults.env.get("PATH") if defaults else None) or None
|
||||
# `path_prepend` (resolved toolchain) lives on the registry deployment, not
|
||||
# the manifest one; absent here it's the base runtime PATH, which is what a
|
||||
# service without a pinned toolchain actually runs with.
|
||||
path = env_path or runtime_path(list(getattr(dep, "path_prepend", []) or ()))
|
||||
return shutil.which(tool.command, path=path) is not None
|
||||
return shutil.which(tool.command, path=_build_path()) is not None
|
||||
|
||||
|
||||
def _check(
|
||||
config: CastleConfig,
|
||||
req: Requirement,
|
||||
dep: object | None = None,
|
||||
tool: ToolRequirement | None = None,
|
||||
) -> bool:
|
||||
"""Is a single requirement satisfied? The check is fixed by its ``kind``. For a
|
||||
``tool`` requirement, ``dep`` and ``tool`` supply the deployment context and the
|
||||
stack-tool metadata (phase decides which PATH to probe)."""
|
||||
if req.kind == "system":
|
||||
# `system_dependencies` holds PACKAGE names, not executables. A PATH lookup
|
||||
# only coincides with 'installed' when the package name equals its command
|
||||
@@ -173,9 +227,24 @@ def _check(config: CastleConfig, req: Requirement) -> bool:
|
||||
return shutil.which(req.ref) is not None or _dpkg_installed(req.ref)
|
||||
if req.kind == "deployment":
|
||||
return bool(config.deployments_named(req.ref))
|
||||
if req.kind == "tool":
|
||||
# No metadata (unknown stack) → don't raise a false alarm.
|
||||
return tool is None or _tool_available(dep, tool)
|
||||
return True
|
||||
|
||||
|
||||
def hint_for(req: Requirement, tool: ToolRequirement | None = None) -> str:
|
||||
"""A copyable next step for an *unmet* requirement — the piece that makes a
|
||||
diagnostic actionable. ``tool`` carries a stack tool's precise install command."""
|
||||
if req.kind == "tool":
|
||||
return tool.install_hint if tool else f"install {req.ref}"
|
||||
if req.kind == "system":
|
||||
return f"sudo apt install {req.ref}"
|
||||
if req.kind == "deployment":
|
||||
return f"create & apply the '{req.ref}' deployment"
|
||||
return ""
|
||||
|
||||
|
||||
# Well-known TCP ports → a friendlier protocol label (display heuristic only).
|
||||
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
|
||||
|
||||
@@ -229,11 +298,12 @@ def build_model(
|
||||
|
||||
nodes: list[Node] = []
|
||||
for _nk, name, dep in config.all_deployments():
|
||||
tmeta = stack_tools_of(config, name) if check else {}
|
||||
unmet = (
|
||||
[
|
||||
f"{r.kind}:{r.ref}"
|
||||
for r in requirements_of(config, name)
|
||||
if not _check(config, r)
|
||||
if not _check(config, r, dep=dep, tool=tmeta.get(r.ref))
|
||||
]
|
||||
if check
|
||||
else []
|
||||
|
||||
136
core/src/castle_core/stack_status.py
Normal file
136
core/src/castle_core/stack_status.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Stack dependency status — the derived, per-stack health the `castle stack`
|
||||
command, the ``GET /stacks`` API, and the dashboard Stacks page all render.
|
||||
|
||||
A *stack* declares the host toolchains it needs (``stacks.ToolRequirement``); this
|
||||
module answers, for each stack: which programs/deployments use it, whether its
|
||||
tools are present *where the using deployments need them* (run-phase tools against
|
||||
a service's runtime PATH — the drift the plain ``which`` a shell does misses), and
|
||||
the copyable fix when one is missing. It's the single source of truth so the CLI,
|
||||
API, and UI never disagree.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from castle_core.config import CastleConfig
|
||||
from castle_core.generators.systemd import runtime_path
|
||||
from castle_core.relations import _build_path, _tool_available
|
||||
from castle_core.stacks import ToolRequirement, available_stacks, get_handler, tools_for
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolStatus:
|
||||
command: str
|
||||
purpose: str
|
||||
phase: str # run | build | both
|
||||
present: bool # resolvable where the using deployments need it
|
||||
install_hint: str # copyable fix (shown when absent)
|
||||
version: str | None = None # best-effort `--version`, when present
|
||||
|
||||
|
||||
@dataclass
|
||||
class StackStatus:
|
||||
name: str
|
||||
tools: list[ToolStatus] = field(default_factory=list)
|
||||
programs: list[str] = field(default_factory=list) # programs on this stack
|
||||
deployments: list[str] = field(default_factory=list) # their deployments
|
||||
verbs: list[str] = field(default_factory=list) # dev verbs the stack provides
|
||||
has_enabled_deployment: bool = False # ≥1 enabled deployment uses it
|
||||
|
||||
@property
|
||||
def in_use(self) -> bool:
|
||||
return bool(self.programs)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""All needed tools present (vacuously true for a stack with no tools)."""
|
||||
return all(t.present for t in self.tools)
|
||||
|
||||
|
||||
def _version(command: str, path: str) -> str | None:
|
||||
"""Best-effort tool version — the first `<cmd> --version` line (falls back to
|
||||
`<cmd> version` for tools like hugo). Never raises; returns None on any trouble."""
|
||||
exe = shutil.which(command, path=path)
|
||||
if not exe:
|
||||
return None
|
||||
for argv in ([exe, "--version"], [exe, "version"]):
|
||||
try:
|
||||
r = subprocess.run(argv, capture_output=True, text=True, timeout=2)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
continue
|
||||
out = (r.stdout or r.stderr or "").strip()
|
||||
if r.returncode == 0 and out:
|
||||
return out.splitlines()[0].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _tool_status(
|
||||
tool: ToolRequirement,
|
||||
using_deps: list[object],
|
||||
*,
|
||||
with_version: bool,
|
||||
) -> ToolStatus:
|
||||
# Present where it's needed: a run/both tool must resolve for EVERY using
|
||||
# deployment (a service's runtime PATH); with no deployments, check generically.
|
||||
if using_deps:
|
||||
present = all(_tool_available(dep, tool) for dep in using_deps)
|
||||
elif tool.phase in ("run", "both"):
|
||||
present = shutil.which(tool.command, path=runtime_path()) is not None
|
||||
else:
|
||||
present = shutil.which(tool.command, path=_build_path()) is not None
|
||||
# A version is only meaningful when the tool is present; probe the path that
|
||||
# matched (runtime for run/both, build otherwise) so it reflects what's used.
|
||||
probe_path = runtime_path() if tool.phase in ("run", "both") else _build_path()
|
||||
version = _version(tool.command, probe_path) if (present and with_version) else None
|
||||
return ToolStatus(
|
||||
command=tool.command,
|
||||
purpose=tool.purpose,
|
||||
phase=tool.phase,
|
||||
present=present,
|
||||
install_hint=tool.install_hint,
|
||||
version=version,
|
||||
)
|
||||
|
||||
|
||||
def stack_status(
|
||||
config: CastleConfig, name: str, *, with_version: bool = True
|
||||
) -> StackStatus | None:
|
||||
"""The dependency status of one stack, or None if castle has no such handler."""
|
||||
handler = get_handler(name)
|
||||
if handler is None:
|
||||
return None
|
||||
programs = sorted(p for p, c in config.programs.items() if c.stack == name)
|
||||
deps: list[tuple[str, object]] = [] # (deployment-name, spec)
|
||||
enabled = False
|
||||
for _kind, dep_name, dep in config.all_deployments():
|
||||
prog = config.programs.get(dep.program or dep_name)
|
||||
if prog and prog.stack == name:
|
||||
deps.append((dep_name, dep))
|
||||
enabled = enabled or getattr(dep, "enabled", True)
|
||||
dep_specs = [d for _n, d in deps]
|
||||
return StackStatus(
|
||||
name=name,
|
||||
tools=[
|
||||
_tool_status(t, dep_specs, with_version=with_version)
|
||||
for t in tools_for(name)
|
||||
],
|
||||
programs=programs,
|
||||
deployments=sorted(n for n, _d in deps),
|
||||
verbs=sorted(handler.provides),
|
||||
has_enabled_deployment=enabled,
|
||||
)
|
||||
|
||||
|
||||
def all_stack_status(
|
||||
config: CastleConfig, *, with_version: bool = True
|
||||
) -> list[StackStatus]:
|
||||
"""Dependency status for every stack castle knows — the Stacks catalog."""
|
||||
out = []
|
||||
for name in available_stacks():
|
||||
st = stack_status(config, name, with_version=with_version)
|
||||
if st is not None:
|
||||
out.append(st)
|
||||
return out
|
||||
@@ -42,6 +42,30 @@ class ActionResult:
|
||||
output: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolRequirement:
|
||||
"""A host toolchain a stack needs — the declarative counterpart to the argv each
|
||||
handler hard-codes (``uv sync``, ``pnpm build``, …). Made explicit so castle can
|
||||
*check* whether the tool is present (on the box and, for run-phase tools, on the
|
||||
service's own PATH) and, when it isn't, show a copyable fix instead of failing
|
||||
mid-subprocess with a raw ``command not found``.
|
||||
|
||||
- ``phase`` — when the tool is needed. ``build`` tools run only at
|
||||
``castle apply``/build time (checked against the build env); ``run`` tools must
|
||||
also be on the *running service's* PATH (the curated systemd env, which can
|
||||
drift from your shell); ``both`` is checked in both places.
|
||||
- ``install_hint`` — the exact command a user can copy to install it.
|
||||
"""
|
||||
|
||||
command: (
|
||||
str # the executable, e.g. "uv" | "pnpm" | "node" | "hugo" | "deno" | "psql"
|
||||
)
|
||||
purpose: str # human phrase, e.g. "build & run Python programs"
|
||||
phase: str # "run" | "build" | "both"
|
||||
install_hint: str # copyable install command
|
||||
version_min: str | None = None
|
||||
|
||||
|
||||
def _build_env(node_source: Path | None = None) -> dict[str, str]:
|
||||
"""Build a subprocess env with user tool dirs on PATH.
|
||||
|
||||
@@ -119,6 +143,11 @@ class StackHandler:
|
||||
# lint/type-check/test also drops `check` implicitly.
|
||||
provides: set[str] = _STACK_VERBS
|
||||
|
||||
# The host toolchains this stack needs, declared so castle can check them and
|
||||
# hint a fix. Empty by default (a stackless / declared-command program depends on
|
||||
# nothing castle can name); each real handler overrides it. See `tools_for`.
|
||||
tools: tuple[ToolRequirement, ...] = ()
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -178,6 +207,15 @@ class StackHandler:
|
||||
class PythonHandler(StackHandler):
|
||||
"""Handler for python-cli and python-fastapi stacks."""
|
||||
|
||||
tools = (
|
||||
ToolRequirement(
|
||||
command="uv",
|
||||
purpose="build & run Python programs",
|
||||
phase="both",
|
||||
install_hint="curl -LsSf https://astral.sh/uv/install.sh | sh",
|
||||
),
|
||||
)
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
rc, output = await _run(["uv", "sync"], src)
|
||||
@@ -286,6 +324,24 @@ def _pnpm(*args: str) -> list[str]:
|
||||
class ReactViteHandler(StackHandler):
|
||||
"""Handler for react-vite stack."""
|
||||
|
||||
# Build-phase only: the output is a static `dist/` the gateway serves in place,
|
||||
# so no node/pnpm process runs at serve time. node is resolved per-program from
|
||||
# its pin (see toolchains); the hint names nvm, the ecosystem-standard installer.
|
||||
tools = (
|
||||
ToolRequirement(
|
||||
command="node",
|
||||
purpose="build the frontend (Vite/React toolchain)",
|
||||
phase="build",
|
||||
install_hint="nvm install --lts # or match the program's .node-version",
|
||||
),
|
||||
ToolRequirement(
|
||||
command="pnpm",
|
||||
purpose="install deps & run the Vite build",
|
||||
phase="build",
|
||||
install_hint="npm install -g pnpm # or: corepack enable pnpm",
|
||||
),
|
||||
)
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
# Build against the gateway serve root so absolute asset URLs resolve at /
|
||||
@@ -393,6 +449,14 @@ class HugoHandler(StackHandler):
|
||||
default per the usual declared-command-wins resolution."""
|
||||
|
||||
provides = {"build", "install", "uninstall"}
|
||||
tools = (
|
||||
ToolRequirement(
|
||||
command="hugo",
|
||||
purpose="build the static site",
|
||||
phase="build",
|
||||
install_hint="sudo apt install hugo # or: snap install hugo",
|
||||
),
|
||||
)
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
src = _source_dir(comp, root)
|
||||
@@ -527,6 +591,20 @@ class SupabaseHandler(StackHandler):
|
||||
"""
|
||||
|
||||
owns_data = True
|
||||
tools = (
|
||||
ToolRequirement(
|
||||
command="psql",
|
||||
purpose="run schema migrations against the substrate DB",
|
||||
phase="both",
|
||||
install_hint="sudo apt install postgresql-client",
|
||||
),
|
||||
ToolRequirement(
|
||||
command="deno",
|
||||
purpose="serve/deploy edge functions",
|
||||
phase="both",
|
||||
install_hint="curl -fsSL https://deno.land/install.sh | sh",
|
||||
),
|
||||
)
|
||||
|
||||
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Apply unapplied migrations into the app's own schema (idempotent)."""
|
||||
@@ -734,6 +812,14 @@ def available_stacks() -> list[str]:
|
||||
return sorted(HANDLERS)
|
||||
|
||||
|
||||
def tools_for(stack: str | None) -> tuple[ToolRequirement, ...]:
|
||||
"""The host toolchains a stack declares it needs (empty for an unknown/absent
|
||||
stack). The single source of truth for the dependency checks in `relations`,
|
||||
`castle stack`, `castle doctor`, and the dashboard Stacks page."""
|
||||
handler = get_handler(stack)
|
||||
return handler.tools if handler is not None else ()
|
||||
|
||||
|
||||
def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
|
||||
"""Declared argv-lists for a verb, or None.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user