diff --git a/app/src/components/ConvergePanel.tsx b/app/src/components/ConvergePanel.tsx index 639df38..7107f50 100644 --- a/app/src/components/ConvergePanel.tsx +++ b/app/src/components/ConvergePanel.tsx @@ -1,6 +1,6 @@ import { useState } from "react" import { useQueryClient } from "@tanstack/react-query" -import { Check, GitCompare, Loader2, Play } from "lucide-react" +import { AlertTriangle, Check, GitCompare, Loader2, Play } from "lucide-react" import { apiClient } from "@/services/api/client" import type { ApplyResult } from "@/services/api/hooks" @@ -86,6 +86,18 @@ export function ConvergePanel() { )} + {/* Advisory warnings from the render/preflight (missing stack toolchains, + acme prerequisites, tunnel notes) — surfaced so a service that can't + build or start doesn't fail silently at apply time. */} + {plan?.messages + ?.filter((m) => m.startsWith("Warning")) + .map((m) => ( +
+ + {m.replace(/^Warning:\s*/, "")} +
+ ))} + {plan && (
{plan.changed ? ( diff --git a/app/src/components/Layout.tsx b/app/src/components/Layout.tsx index 6eaa6d2..84310db 100644 --- a/app/src/components/Layout.tsx +++ b/app/src/components/Layout.tsx @@ -9,6 +9,7 @@ import { Gauge, Globe, KeyRound, + Layers, LayoutDashboard, Menu, Search, @@ -44,6 +45,7 @@ const NAV: (NavLeaf | NavGroup)[] = [ ], }, { to: "/programs", label: "Programs", icon: SquareCode }, + { to: "/stacks", label: "Stacks", icon: Layers }, { to: "/mesh", label: "Mesh", icon: Share2 }, { to: "/secrets", label: "Secrets", icon: KeyRound }, ] diff --git a/app/src/pages/Stacks.tsx b/app/src/pages/Stacks.tsx new file mode 100644 index 0000000..532a4d6 --- /dev/null +++ b/app/src/pages/Stacks.tsx @@ -0,0 +1,158 @@ +import { useState } from "react" +import { Link } from "react-router-dom" +import { Check, Copy, Layers, X } from "lucide-react" +import { cn } from "@/lib/utils" +import { useStacksStatus, type StackStatus, type ToolStatus } from "@/services/api/hooks" +import { PageHeader } from "@/components/PageHeader" + +// A stack's overall health pill: green when every tool it needs is present, red +// when one is missing, grey when nothing on this node uses it yet. +function StackBadge({ stack }: { stack: StackStatus }) { + const [label, cls] = !stack.in_use + ? (["unused", "bg-gray-700/50 text-gray-400"] as const) + : stack.ok + ? (["ready", "bg-green-800/50 text-green-300"] as const) + : (["missing tools", "bg-red-800/50 text-red-300"] as const) + return ( + + {label} + + ) +} + +// A copy-to-clipboard chip for a tool's install command — the "diagnose + copyable +// hint" contract: we never install for you, but the fix is one click from your shell. +function CopyHint({ text }: { text: string }) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +function ToolRow({ tool }: { tool: ToolStatus }) { + return ( +
+
+ {tool.present ? ( + + ) : ( + + )} + {tool.command} + + {tool.purpose} · {tool.phase} + + {tool.version && ( + + {tool.version} + + )} +
+ {!tool.present && ( +
+ +
+ )} +
+ ) +} + +function StackCard({ stack }: { stack: StackStatus }) { + return ( +
+
+

{stack.name}

+ +
+ +
+ {stack.tools.length > 0 ? ( + stack.tools.map((t) => ) + ) : ( +

No host tools required.

+ )} +
+ + {stack.programs.length > 0 && ( +
+ used by + {stack.programs.map((p) => ( + + {p} + + ))} +
+ )} + + {stack.verbs.length > 0 && ( +
+ {stack.verbs.map((v) => ( + + {v} + + ))} +
+ )} +
+ ) +} + +export function Stacks() { + const { data: stacks, isLoading } = useStacksStatus() + const missing = (stacks ?? []).filter((s) => s.in_use && !s.ok).length + + return ( +
+ 0 ? ( + + {missing} stack{missing !== 1 ? "s" : ""} missing tools + + ) : stacks && stacks.length > 0 ? ( + + all toolchains present + + ) : null + } + /> + + {isLoading ? ( +

Loading...

+ ) : stacks && stacks.length > 0 ? ( +
+ {stacks.map((s) => ( + + ))} +
+ ) : ( +
+ +

No stacks.

+
+ )} +
+ ) +} diff --git a/app/src/router/routes.tsx b/app/src/router/routes.tsx index 3b420b4..15e74a3 100644 --- a/app/src/router/routes.tsx +++ b/app/src/router/routes.tsx @@ -4,6 +4,7 @@ import { Overview } from "@/pages/Overview" import { Services } from "@/pages/Services" import { Scheduled } from "@/pages/Scheduled" import { Tools } from "@/pages/Tools" +import { Stacks } from "@/pages/Stacks" import { Programs } from "@/pages/Programs" import { GatewayPage } from "@/pages/GatewayPage" import { MeshPage } from "@/pages/MeshPage" @@ -25,6 +26,7 @@ export const router = createBrowserRouter([ { path: "services", element: }, { path: "scheduled", element: }, { path: "tools", element: }, + { path: "stacks", element: }, { path: "programs", element: }, { path: "gateway", element: }, { path: "mesh", element: }, diff --git a/app/src/services/api/hooks.ts b/app/src/services/api/hooks.ts index 90d1f53..94d49d1 100644 --- a/app/src/services/api/hooks.ts +++ b/app/src/services/api/hooks.ts @@ -69,6 +69,36 @@ export function useStacks() { }) } +// A host toolchain a stack needs + whether it's present where its programs run. +export interface ToolStatus { + command: string + purpose: string + phase: "run" | "build" | "both" + present: boolean + install_hint: string + version: string | null +} + +// A stack's dependency health — powers the Stacks page. +export interface StackStatus { + name: string + tools: ToolStatus[] + programs: string[] + deployments: string[] + verbs: string[] + has_enabled_deployment: boolean + in_use: boolean + ok: boolean +} + +// Per-stack toolchain health (tools present-where-needed + who uses each stack). +export function useStacksStatus() { + return useQuery({ + queryKey: ["stacks", "status"], + queryFn: () => apiClient.get("/stacks/status"), + }) +} + export function useJob(name: string) { return useQuery({ queryKey: ["jobs", name], diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index f53ab8f..1ea3682 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -135,6 +135,30 @@ class ProgramDetail(ProgramSummary): manifest: dict +class ToolStatusModel(BaseModel): + """One host toolchain a stack needs, and whether it's present where used.""" + + command: str + purpose: str + phase: str # "run" | "build" | "both" + present: bool + install_hint: str + version: str | None = None + + +class StackStatusModel(BaseModel): + """A stack's dependency health — its tools + who uses it. Powers the Stacks page.""" + + name: str + tools: list[ToolStatusModel] + programs: list[str] + deployments: list[str] + verbs: list[str] + has_enabled_deployment: bool + in_use: bool + ok: bool + + class HealthStatus(BaseModel): """Health status of a single component.""" diff --git a/castle-api/src/castle_api/programs.py b/castle-api/src/castle_api/programs.py index b5182bc..b86a13f 100644 --- a/castle-api/src/castle_api/programs.py +++ b/castle-api/src/castle_api/programs.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import asdict from pathlib import Path +from typing import TYPE_CHECKING from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel @@ -20,6 +21,10 @@ from castle_core.stacks import available_actions, available_stacks, run_action from castle_api import stream from castle_api.config import get_config +from castle_api.models import StackStatusModel, ToolStatusModel + +if TYPE_CHECKING: + from castle_core.stack_status import StackStatus programs_router = APIRouter(tags=["programs"]) @@ -31,6 +36,40 @@ def list_stacks() -> list[str]: return available_stacks() +def _stack_model(st: StackStatus) -> StackStatusModel: + return StackStatusModel( + name=st.name, + tools=[ToolStatusModel(**asdict(t)) for t in st.tools], + programs=st.programs, + deployments=st.deployments, + verbs=st.verbs, + has_enabled_deployment=st.has_enabled_deployment, + in_use=st.in_use, + ok=st.ok, + ) + + +@programs_router.get("/stacks/status") +def stacks_status() -> list[StackStatusModel]: + """Every stack's dependency health — tools present-where-needed (run-phase tools + against the service runtime PATH), who uses it, and the fix for anything missing. + The Stacks page renders this; `castle stack list` is its CLI twin.""" + from castle_core.stack_status import all_stack_status + + return [_stack_model(s) for s in all_stack_status(get_config())] + + +@programs_router.get("/stacks/{name}") +def stack_detail(name: str) -> StackStatusModel: + """One stack's dependency detail (tool versions included).""" + from castle_core.stack_status import stack_status + + st = stack_status(get_config(), name) + if st is None: + raise HTTPException(status_code=404, detail=f"No stack '{name}'") + return _stack_model(st) + + # --------------------------------------------------------------------------- # Filesystem browse + adopt — powers the dashboard's "Add program" flow, the # web equivalent of `castle program add `. Programs live on the diff --git a/castle-api/tests/test_stacks_api.py b/castle-api/tests/test_stacks_api.py new file mode 100644 index 0000000..e6e4ee3 --- /dev/null +++ b/castle-api/tests/test_stacks_api.py @@ -0,0 +1,29 @@ +"""Tests for the stack-dependency endpoints (/stacks, /stacks/status, /stacks/{name}).""" + +from fastapi.testclient import TestClient + +from castle_core.stacks import available_stacks + + +class TestStacks: + def test_names_endpoint_stays_a_bare_list(self, client: TestClient) -> None: + """`GET /stacks` keeps its back-compat string[] shape (the create-form select + depends on it) even though the richer status lives at /stacks/status.""" + resp = client.get("/stacks") + assert resp.status_code == 200 + assert resp.json() == available_stacks() + + def test_status_shape(self, client: TestClient) -> None: + resp = client.get("/stacks/status") + assert resp.status_code == 200 + body = resp.json() + assert {s["name"] for s in body} == set(available_stacks()) + st = next(s for s in body if s["name"] == "python-fastapi") + # python-fastapi declares uv; every tool carries its phase + fix. + assert {"in_use", "ok", "tools", "programs", "verbs"} <= st.keys() + uv = next(t for t in st["tools"] if t["command"] == "uv") + assert uv["phase"] == "both" and uv["install_hint"] + + def test_detail_and_404(self, client: TestClient) -> None: + assert client.get("/stacks/python-cli").json()["name"] == "python-cli" + assert client.get("/stacks/nope").status_code == 404 diff --git a/cli/src/castle_cli/commands/doctor.py b/cli/src/castle_cli/commands/doctor.py index 61fb448..a7bfda2 100644 --- a/cli/src/castle_cli/commands/doctor.py +++ b/cli/src/castle_cli/commands/doctor.py @@ -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)), ] diff --git a/cli/src/castle_cli/commands/stack.py b/cli/src/castle_cli/commands/stack.py new file mode 100644 index 0000000..a1a5e88 --- /dev/null +++ b/cli/src/castle_cli/commands/stack.py @@ -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 diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 61ba867..73bb7d9 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -94,6 +94,20 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None: p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help) +def _build_stack_group(subparsers: argparse._SubParsersAction) -> None: + """The `stack` lens — the toolchains each stack needs + whether they're present.""" + grp = subparsers.add_parser("stack", help="Stacks + the toolchains they require") + grp.set_defaults(resource="stack") + sub = grp.add_subparsers(dest="stack_command") + + p = sub.add_parser("list", help="List stacks with their toolchain health") + p.add_argument("--json", action="store_true", help="Machine-readable output") + + p = sub.add_parser("info", help="Show a stack's tools, versions, fixes, and users") + _add_name(p, "Stack name") + p.add_argument("--json", action="store_true", help="Machine-readable output") + + def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None: p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml") _add_name(p, f"{kind.capitalize()} name") @@ -166,6 +180,7 @@ def build_parser() -> argparse.ArgumentParser: _build_deployment_group(subparsers, "service") _build_deployment_group(subparsers, "job") _build_tool_group(subparsers) + _build_stack_group(subparsers) # Gateway (inspection). The gateway is a deployment — start/stop/reload it via # `castle apply` / `castle restart castle-gateway`; this lens just shows routes. @@ -287,6 +302,22 @@ def _dispatch_tool(args: argparse.Namespace) -> int: return 1 +def _dispatch_stack(args: argparse.Namespace) -> int: + sub = args.stack_command + if not sub: + print("Usage: castle stack {list|info}") + return 1 + if sub == "list": + from castle_cli.commands.stack import run_stack_list + + return run_stack_list(args) + if sub == "info": + from castle_cli.commands.stack import run_stack_info + + return run_stack_info(args) + return 1 + + def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int: sub = getattr(args, f"{kind}_command") if not sub: @@ -335,6 +366,8 @@ def main() -> int: return _dispatch_deployment(args, cmd) if cmd == "tool": return _dispatch_tool(args) + if cmd == "stack": + return _dispatch_stack(args) if cmd == "gateway": from castle_cli.commands.gateway import run_gateway diff --git a/cli/tests/test_doctor.py b/cli/tests/test_doctor.py index cec5c25..8f94742 100644 --- a/cli/tests/test_doctor.py +++ b/cli/tests/test_doctor.py @@ -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) diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index bf3db6b..1006934 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -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"), ] diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 0acbd47..33e9a6f 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -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, diff --git a/core/src/castle_core/generators/systemd.py b/core/src/castle_core/generators/systemd.py index d2bfa3a..2d8f135 100644 --- a/core/src/castle_core/generators/systemd.py +++ b/core/src/castle_core/generators/systemd.py @@ -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-.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: diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 84dfa9f..f8d653b 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -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 diff --git a/core/src/castle_core/relations.py b/core/src/castle_core/relations.py index ac2559b..ef7513f 100644 --- a/core/src/castle_core/relations.py +++ b/core/src/castle_core/relations.py @@ -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 [] diff --git a/core/src/castle_core/stack_status.py b/core/src/castle_core/stack_status.py new file mode 100644 index 0000000..f6dd204 --- /dev/null +++ b/core/src/castle_core/stack_status.py @@ -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 ` --version` line (falls back to + ` 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 diff --git a/core/src/castle_core/stacks.py b/core/src/castle_core/stacks.py index 6cd2583..dfb5584 100644 --- a/core/src/castle_core/stacks.py +++ b/core/src/castle_core/stacks.py @@ -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. diff --git a/core/tests/test_relations.py b/core/tests/test_relations.py index 8cf7fc3..7cbb82a 100644 --- a/core/tests/test_relations.py +++ b/core/tests/test_relations.py @@ -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"))