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:
@@ -7,7 +7,14 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from castle_cli.commands.doctor import FAIL, OK, WARN, _check_configuration, run_doctor
|
||||
from castle_cli.commands.doctor import (
|
||||
FAIL,
|
||||
OK,
|
||||
WARN,
|
||||
_check_configuration,
|
||||
_check_stacks,
|
||||
run_doctor,
|
||||
)
|
||||
|
||||
|
||||
class TestDoctor:
|
||||
@@ -85,3 +92,57 @@ class TestDataDirChecks:
|
||||
warn = next(c for c in _check_configuration(cfg) if "overrides castle.yaml" in c.label)
|
||||
assert warn.status == WARN
|
||||
assert "CASTLE_DATA_DIR" in warn.detail
|
||||
|
||||
|
||||
class TestStackChecks:
|
||||
"""The stacks section: a stack's toolchain must be present where its programs
|
||||
run. Missing tooling for an enabled deployment is a FAIL with a copyable fix."""
|
||||
|
||||
def _fastapi_cfg(self):
|
||||
import castle_core.config as C
|
||||
from castle_core.manifest import ProgramSpec, SystemdDeployment
|
||||
|
||||
prog = ProgramSpec(id="svc", stack="python-fastapi")
|
||||
dep = SystemdDeployment.model_validate(
|
||||
{
|
||||
"manager": "systemd",
|
||||
"program": "svc",
|
||||
"run": {"launcher": "command", "argv": ["svc"]},
|
||||
}
|
||||
)
|
||||
return C.CastleConfig(
|
||||
root=None,
|
||||
gateway=C.GatewayConfig(port=9000),
|
||||
repo=None,
|
||||
programs={"svc": prog},
|
||||
deployments={"svc": dep},
|
||||
)
|
||||
|
||||
def test_present_tool_is_ok(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import castle_core.stack_status as SS
|
||||
|
||||
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
|
||||
fa = next(
|
||||
c for c in _check_stacks(self._fastapi_cfg()) if c.label.startswith("python-fastapi")
|
||||
)
|
||||
assert fa.status == OK
|
||||
|
||||
def test_missing_tool_for_enabled_deployment_fails_with_hint(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
import castle_core.stack_status as SS
|
||||
|
||||
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: False)
|
||||
fa = next(
|
||||
c for c in _check_stacks(self._fastapi_cfg()) if c.label.startswith("python-fastapi")
|
||||
)
|
||||
assert fa.status == FAIL
|
||||
assert "uv" in fa.detail and fa.hint # names the tool + offers a fix
|
||||
|
||||
def test_unused_stacks_are_skipped(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No react-vite program → no pnpm nag."""
|
||||
import castle_core.stack_status as SS
|
||||
|
||||
monkeypatch.setattr(SS, "_tool_available", lambda dep, tool: True)
|
||||
labels = [c.label for c in _check_stacks(self._fastapi_cfg())]
|
||||
assert not any(label.startswith("react-vite") for label in labels)
|
||||
|
||||
Reference in New Issue
Block a user