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:
@@ -6,7 +6,13 @@ import pytest
|
||||
|
||||
import castle_core.config as C
|
||||
from castle_core import relations as R
|
||||
from castle_core.manifest import ProgramSpec, SystemdDeployment
|
||||
from castle_core.manifest import (
|
||||
CaddyDeployment,
|
||||
ProgramSpec,
|
||||
Requirement,
|
||||
SystemdDeployment,
|
||||
)
|
||||
from castle_core.stacks import tools_for
|
||||
|
||||
|
||||
def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
|
||||
@@ -20,6 +26,12 @@ def _dep(program: str, requires: list | None = None) -> SystemdDeployment:
|
||||
return SystemdDeployment.model_validate(spec)
|
||||
|
||||
|
||||
def _static(program: str) -> CaddyDeployment:
|
||||
return CaddyDeployment.model_validate(
|
||||
{"manager": "caddy", "program": program, "root": "dist"}
|
||||
)
|
||||
|
||||
|
||||
def _cfg(programs: dict, deployments: dict) -> C.CastleConfig:
|
||||
return C.CastleConfig(
|
||||
root=None,
|
||||
@@ -56,7 +68,11 @@ def test_deployment_edge_carries_bind_and_counts_fan_in() -> None:
|
||||
"""A {kind: deployment} requirement becomes an edge (with bind), and the target's
|
||||
fan-in is the count of distinct dependents."""
|
||||
cfg = _cfg(
|
||||
{"web": ProgramSpec(id="web"), "cli": ProgramSpec(id="cli"), "api": ProgramSpec(id="api")},
|
||||
{
|
||||
"web": ProgramSpec(id="web"),
|
||||
"cli": ProgramSpec(id="cli"),
|
||||
"api": ProgramSpec(id="api"),
|
||||
},
|
||||
{
|
||||
"web": _dep("web", requires=[{"ref": "api", "bind": "API_URL"}]),
|
||||
"cli": _dep("cli", requires=[{"ref": "api"}]),
|
||||
@@ -107,3 +123,73 @@ def test_missing_deployment_requirement_is_unmet() -> None:
|
||||
m = R.build_model(cfg, check=True)
|
||||
web = next(n for n in m.nodes if n.name == "web")
|
||||
assert web.unmet == ["deployment:ghost"] and web.functional is False
|
||||
|
||||
|
||||
# --- stack toolchains as requirements ----------------------------------------
|
||||
|
||||
|
||||
def test_stack_toolchain_surfaces_as_tool_requirement() -> None:
|
||||
"""A program's stack contributes its toolchains as {kind: tool} requirements."""
|
||||
prog = ProgramSpec(id="svc", stack="python-fastapi")
|
||||
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
|
||||
reqs = {(r.kind, r.ref) for r in R.requirements_of(cfg, "svc")}
|
||||
assert ("tool", "uv") in reqs
|
||||
|
||||
|
||||
def test_stack_tool_missing_from_service_path_is_unmet(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The drift case: uv resolves in the caller's shell PATH but NOT on the
|
||||
service's curated runtime PATH → unmet *for the service*, even though a bare
|
||||
`which` (what `castle tool list` uses) would report it present."""
|
||||
prog = ProgramSpec(id="svc", stack="python-fastapi")
|
||||
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
|
||||
shell_only = "/home/me/.shell-tools" # a dir the runtime PATH never includes
|
||||
monkeypatch.setenv("PATH", shell_only)
|
||||
monkeypatch.setattr(
|
||||
R.shutil,
|
||||
"which",
|
||||
lambda cmd, path=None: (
|
||||
f"{shell_only}/{cmd}" if path and shell_only in path else None
|
||||
),
|
||||
)
|
||||
m = R.build_model(cfg, check=True)
|
||||
svc = next(n for n in m.nodes if n.name == "svc")
|
||||
assert svc.unmet == ["tool:uv"] and svc.functional is False
|
||||
|
||||
|
||||
def test_stack_tool_present_on_service_path_is_satisfied(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
prog = ProgramSpec(id="svc", stack="python-fastapi")
|
||||
cfg = _cfg({"svc": prog}, {"svc": _dep("svc")})
|
||||
monkeypatch.setattr(R.shutil, "which", lambda cmd, path=None: f"/usr/bin/{cmd}")
|
||||
svc = next(n for n in R.build_model(cfg, check=True).nodes if n.name == "svc")
|
||||
assert svc.functional is True and "tool:uv" not in svc.unmet
|
||||
|
||||
|
||||
def test_build_phase_tool_checked_against_build_path(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A static site's build-only tools (pnpm/node) are checked against the build/dev
|
||||
PATH — a static deployment runs no process, so runtime-PATH drift doesn't apply."""
|
||||
prog = ProgramSpec(id="ui", stack="react-vite")
|
||||
cfg = _cfg({"ui": prog}, {"ui": _static("ui")})
|
||||
dev_dir = "/home/me/.local/share/pnpm"
|
||||
monkeypatch.setenv("PATH", dev_dir)
|
||||
monkeypatch.setattr(
|
||||
R.shutil,
|
||||
"which",
|
||||
lambda cmd, path=None: f"{dev_dir}/{cmd}" if path and dev_dir in path else None,
|
||||
)
|
||||
ui = next(n for n in R.build_model(cfg, check=True).nodes if n.name == "ui")
|
||||
assert ui.functional is True and not [u for u in ui.unmet if u.startswith("tool:")]
|
||||
|
||||
|
||||
def test_hint_for_each_requirement_kind() -> None:
|
||||
uv = tools_for("python-fastapi")[0]
|
||||
assert "astral.sh" in R.hint_for(Requirement(kind="tool", ref="uv"), uv)
|
||||
assert R.hint_for(Requirement(kind="system", ref="pandoc")) == (
|
||||
"sudo apt install pandoc"
|
||||
)
|
||||
assert "api" in R.hint_for(Requirement(kind="deployment", ref="api"))
|
||||
|
||||
Reference in New Issue
Block a user