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.
196 lines
7.6 KiB
Python
196 lines
7.6 KiB
Python
"""Tests for the relationship model (core/src/castle_core/relations.py)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
import castle_core.config as C
|
|
from castle_core import relations as R
|
|
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:
|
|
spec: dict = {
|
|
"manager": "systemd",
|
|
"program": program,
|
|
"run": {"launcher": "command", "argv": [program]},
|
|
}
|
|
if requires is not None:
|
|
spec["requires"] = requires
|
|
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,
|
|
gateway=C.GatewayConfig(port=9000),
|
|
repo=None,
|
|
programs=programs,
|
|
deployments=deployments,
|
|
)
|
|
|
|
|
|
def test_system_dependencies_is_the_system_requirement_alias() -> None:
|
|
"""`system_dependencies` surfaces as a {kind: system} requirement."""
|
|
cfg = _cfg(
|
|
{"t": ProgramSpec(id="t", system_dependencies=["pandoc"])}, {"t": _dep("t")}
|
|
)
|
|
reqs = R.requirements_of(cfg, "t")
|
|
assert [(r.kind, r.ref) for r in reqs] == [("system", "pandoc")]
|
|
|
|
|
|
def test_requirements_merge_system_deps_and_deployment_requires_deduped() -> None:
|
|
"""The requirement set unions the program's system_dependencies (as {kind: system})
|
|
with the deployment's own requires, de-duplicated by (kind, ref)."""
|
|
prog = ProgramSpec(id="web", system_dependencies=["pandoc"])
|
|
# Two identical deployment requires — deduped to one.
|
|
dep = _dep("web", requires=[{"ref": "api"}, {"ref": "api"}])
|
|
cfg = _cfg(
|
|
{"web": prog, "api": ProgramSpec(id="api")}, {"web": dep, "api": _dep("api")}
|
|
)
|
|
kinds = {(r.kind, r.ref) for r in R.requirements_of(cfg, "web")}
|
|
assert kinds == {("system", "pandoc"), ("deployment", "api")} # deduped
|
|
|
|
|
|
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": _dep("web", requires=[{"ref": "api", "bind": "API_URL"}]),
|
|
"cli": _dep("cli", requires=[{"ref": "api"}]),
|
|
"api": _dep("api"),
|
|
},
|
|
)
|
|
m = R.build_model(cfg, check=False)
|
|
edge = next(e for e in m.edges if e.src == "web" and e.dst == "api")
|
|
assert edge.kind == "deployment" and edge.bind == "API_URL"
|
|
api = next(n for n in m.nodes if n.name == "api")
|
|
assert api.depended_on_by == 2 # web + cli
|
|
|
|
|
|
def test_functional_predicate_reports_unmet(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""`functional?` is derived: a missing system package is unmet; a present
|
|
deployment requirement is satisfied."""
|
|
prog = ProgramSpec(id="web", system_dependencies=["pandoc"])
|
|
cfg = _cfg(
|
|
{"web": prog, "api": ProgramSpec(id="api")},
|
|
{"web": _dep("web", requires=[{"ref": "api"}]), "api": _dep("api")},
|
|
)
|
|
monkeypatch.setattr(R.shutil, "which", lambda _: None) # not on PATH
|
|
monkeypatch.setattr(R, "_dpkg_installed", lambda _: False) # nor as a package
|
|
m = R.build_model(cfg, check=True)
|
|
web = next(n for n in m.nodes if n.name == "web")
|
|
assert web.unmet == ["system:pandoc"] # deployment:api exists → satisfied
|
|
assert web.functional is False
|
|
|
|
|
|
def test_system_requirement_satisfied_by_package_not_on_path(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A package name that isn't a command (poppler-utils) is satisfied when the
|
|
package is installed — a PATH lookup alone would wrongly report it unmet."""
|
|
prog = ProgramSpec(id="t", system_dependencies=["poppler-utils"])
|
|
cfg = _cfg({"t": prog}, {"t": _dep("t")})
|
|
monkeypatch.setattr(R.shutil, "which", lambda _: None) # no `poppler-utils` binary
|
|
monkeypatch.setattr(R, "_dpkg_installed", lambda pkg: pkg == "poppler-utils")
|
|
t = next(n for n in R.build_model(cfg, check=True).nodes if n.name == "t")
|
|
assert t.functional is True and t.unmet == []
|
|
|
|
|
|
def test_missing_deployment_requirement_is_unmet() -> None:
|
|
cfg = _cfg(
|
|
{"web": ProgramSpec(id="web")},
|
|
{"web": _dep("web", requires=[{"ref": "ghost"}])},
|
|
)
|
|
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"))
|