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:
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { useQueryClient } from "@tanstack/react-query"
|
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 { apiClient } from "@/services/api/client"
|
||||||
import type { ApplyResult } from "@/services/api/hooks"
|
import type { ApplyResult } from "@/services/api/hooks"
|
||||||
|
|
||||||
@@ -86,6 +86,18 @@ export function ConvergePanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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) => (
|
||||||
|
<div key={m} className="mt-2 flex items-start gap-1.5 text-sm text-amber-400">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0" />
|
||||||
|
<span>{m.replace(/^Warning:\s*/, "")}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{plan && (
|
{plan && (
|
||||||
<div className="mt-3 space-y-1">
|
<div className="mt-3 space-y-1">
|
||||||
{plan.changed ? (
|
{plan.changed ? (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Gauge,
|
Gauge,
|
||||||
Globe,
|
Globe,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
|
Layers,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
Menu,
|
Menu,
|
||||||
Search,
|
Search,
|
||||||
@@ -44,6 +45,7 @@ const NAV: (NavLeaf | NavGroup)[] = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ to: "/programs", label: "Programs", icon: SquareCode },
|
{ to: "/programs", label: "Programs", icon: SquareCode },
|
||||||
|
{ to: "/stacks", label: "Stacks", icon: Layers },
|
||||||
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
||||||
{ to: "/secrets", label: "Secrets", icon: KeyRound },
|
{ to: "/secrets", label: "Secrets", icon: KeyRound },
|
||||||
]
|
]
|
||||||
|
|||||||
158
app/src/pages/Stacks.tsx
Normal file
158
app/src/pages/Stacks.tsx
Normal file
@@ -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 (
|
||||||
|
<span className={cn("inline-flex items-center text-xs px-2 py-0.5 rounded-full", cls)}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard?.writeText(text)
|
||||||
|
setCopied(true)
|
||||||
|
window.setTimeout(() => setCopied(false), 1200)
|
||||||
|
}}
|
||||||
|
className="group inline-flex items-center gap-1.5 rounded border border-[var(--border)] bg-black/20 px-2 py-1 font-mono text-xs text-[var(--muted)] hover:border-[var(--primary)] hover:text-[var(--foreground)] transition-colors"
|
||||||
|
title="Copy install command"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={12} className="text-green-400" /> : <Copy size={12} />}
|
||||||
|
<span className="truncate">{text}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolRow({ tool }: { tool: ToolStatus }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1 py-1.5">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{tool.present ? (
|
||||||
|
<Check size={14} className="shrink-0 text-green-400" />
|
||||||
|
) : (
|
||||||
|
<X size={14} className="shrink-0 text-red-400" />
|
||||||
|
)}
|
||||||
|
<span className="font-mono font-medium">{tool.command}</span>
|
||||||
|
<span className="text-xs text-[var(--muted)]">
|
||||||
|
{tool.purpose} · {tool.phase}
|
||||||
|
</span>
|
||||||
|
{tool.version && (
|
||||||
|
<span className="ml-auto truncate max-w-[45%] text-xs text-[var(--muted)]" title={tool.version}>
|
||||||
|
{tool.version}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!tool.present && (
|
||||||
|
<div className="pl-6">
|
||||||
|
<CopyHint text={tool.install_hint} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StackCard({ stack }: { stack: StackStatus }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg border bg-[var(--card)] p-4",
|
||||||
|
stack.in_use ? "border-[var(--border)]" : "border-[var(--border)] opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="font-semibold">{stack.name}</h2>
|
||||||
|
<StackBadge stack={stack} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 divide-y divide-[var(--border)]">
|
||||||
|
{stack.tools.length > 0 ? (
|
||||||
|
stack.tools.map((t) => <ToolRow key={t.command} tool={t} />)
|
||||||
|
) : (
|
||||||
|
<p className="py-1.5 text-sm text-[var(--muted)]">No host tools required.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{stack.programs.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-[var(--muted)]">used by</span>
|
||||||
|
{stack.programs.map((p) => (
|
||||||
|
<Link
|
||||||
|
key={p}
|
||||||
|
to={`/programs/${p}`}
|
||||||
|
className="rounded bg-black/20 px-1.5 py-0.5 font-mono hover:text-[var(--primary)]"
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{stack.verbs.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-[var(--muted)]">
|
||||||
|
{stack.verbs.map((v) => (
|
||||||
|
<span key={v} className="rounded-full border border-[var(--border)] px-1.5 py-0.5">
|
||||||
|
{v}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Stacks() {
|
||||||
|
const { data: stacks, isLoading } = useStacksStatus()
|
||||||
|
const missing = (stacks ?? []).filter((s) => s.in_use && !s.ok).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<PageHeader
|
||||||
|
title="Stacks"
|
||||||
|
subtitle="The toolchains each stack needs, and whether they're present where your services run"
|
||||||
|
actions={
|
||||||
|
missing > 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-red-400">
|
||||||
|
<X size={14} /> {missing} stack{missing !== 1 ? "s" : ""} missing tools
|
||||||
|
</span>
|
||||||
|
) : stacks && stacks.length > 0 ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-sm text-green-400">
|
||||||
|
<Check size={14} /> all toolchains present
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-[var(--muted)]">Loading...</p>
|
||||||
|
) : stacks && stacks.length > 0 ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
{stacks.map((s) => (
|
||||||
|
<StackCard key={s.name} stack={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-2 py-16 text-[var(--muted)]">
|
||||||
|
<Layers size={28} />
|
||||||
|
<p>No stacks.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { Overview } from "@/pages/Overview"
|
|||||||
import { Services } from "@/pages/Services"
|
import { Services } from "@/pages/Services"
|
||||||
import { Scheduled } from "@/pages/Scheduled"
|
import { Scheduled } from "@/pages/Scheduled"
|
||||||
import { Tools } from "@/pages/Tools"
|
import { Tools } from "@/pages/Tools"
|
||||||
|
import { Stacks } from "@/pages/Stacks"
|
||||||
import { Programs } from "@/pages/Programs"
|
import { Programs } from "@/pages/Programs"
|
||||||
import { GatewayPage } from "@/pages/GatewayPage"
|
import { GatewayPage } from "@/pages/GatewayPage"
|
||||||
import { MeshPage } from "@/pages/MeshPage"
|
import { MeshPage } from "@/pages/MeshPage"
|
||||||
@@ -25,6 +26,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: "services", element: <Services /> },
|
{ path: "services", element: <Services /> },
|
||||||
{ path: "scheduled", element: <Scheduled /> },
|
{ path: "scheduled", element: <Scheduled /> },
|
||||||
{ path: "tools", element: <Tools /> },
|
{ path: "tools", element: <Tools /> },
|
||||||
|
{ path: "stacks", element: <Stacks /> },
|
||||||
{ path: "programs", element: <Programs /> },
|
{ path: "programs", element: <Programs /> },
|
||||||
{ path: "gateway", element: <GatewayPage /> },
|
{ path: "gateway", element: <GatewayPage /> },
|
||||||
{ path: "mesh", element: <MeshPage /> },
|
{ path: "mesh", element: <MeshPage /> },
|
||||||
|
|||||||
@@ -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<StackStatus[]>("/stacks/status"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useJob(name: string) {
|
export function useJob(name: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["jobs", name],
|
queryKey: ["jobs", name],
|
||||||
|
|||||||
@@ -135,6 +135,30 @@ class ProgramDetail(ProgramSummary):
|
|||||||
manifest: dict
|
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):
|
class HealthStatus(BaseModel):
|
||||||
"""Health status of a single component."""
|
"""Health status of a single component."""
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from pydantic import BaseModel
|
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 import stream
|
||||||
from castle_api.config import get_config
|
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"])
|
programs_router = APIRouter(tags=["programs"])
|
||||||
|
|
||||||
@@ -31,6 +36,40 @@ def list_stacks() -> list[str]:
|
|||||||
return available_stacks()
|
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
|
# Filesystem browse + adopt — powers the dashboard's "Add program" flow, the
|
||||||
# web equivalent of `castle program add <path|git-url>`. Programs live on the
|
# web equivalent of `castle program add <path|git-url>`. Programs live on the
|
||||||
|
|||||||
29
castle-api/tests/test_stacks_api.py
Normal file
29
castle-api/tests/test_stacks_api.py
Normal file
@@ -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
|
||||||
@@ -345,15 +345,20 @@ def _check_tls_exposure(config) -> list[Check]:
|
|||||||
checks.append(_check_privileged_ports())
|
checks.append(_check_privileged_ports())
|
||||||
|
|
||||||
# Public exposure (only relevant if a deployment opts in).
|
# 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:
|
if public:
|
||||||
from castle_core.lifecycle import is_active
|
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)"))
|
checks.append(Check(OK, "tunnel configured", detail=f"{len(public)} public service(s)"))
|
||||||
else:
|
else:
|
||||||
missing = []
|
missing = []
|
||||||
if not gw.public_domain:
|
if need_default and not gw.public_domain:
|
||||||
missing.append("public_domain")
|
missing.append("public_domain")
|
||||||
if not gw.tunnel_id:
|
if not gw.tunnel_id:
|
||||||
missing.append("tunnel_id")
|
missing.append("tunnel_id")
|
||||||
@@ -403,20 +408,26 @@ def _check_public_dns(config) -> Check:
|
|||||||
"public DNS not automated",
|
"public DNS not automated",
|
||||||
detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual",
|
detail=f"no {PUBLIC_DNS_TOKEN} secret — CNAMEs are manual",
|
||||||
hint=(
|
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} "
|
f"('Edit zone DNS' template) → ~/.castle/secrets/{PUBLIC_DNS_TOKEN} "
|
||||||
"(else route each host by hand)"
|
"(else route each host by hand)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
try:
|
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:
|
if not zres:
|
||||||
|
where = gw.public_domain or "any zone"
|
||||||
return Check(
|
return Check(
|
||||||
FAIL,
|
FAIL,
|
||||||
"public DNS token can't see the zone",
|
"public DNS token can't see the zone",
|
||||||
detail=f"{gw.public_domain} not visible",
|
detail=f"{where} not visible",
|
||||||
hint=(
|
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)"
|
"zone's account ('Edit zone DNS' template)"
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -436,7 +447,7 @@ def _check_public_dns(config) -> Check:
|
|||||||
return Check(
|
return Check(
|
||||||
OK,
|
OK,
|
||||||
"public DNS token valid",
|
"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 -----------------------------------------------------------------
|
# --- 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:
|
def run_doctor(args: argparse.Namespace) -> int:
|
||||||
from castle_core.config import load_config
|
from castle_core.config import load_config
|
||||||
|
|
||||||
@@ -482,6 +526,7 @@ def run_doctor(args: argparse.Namespace) -> int:
|
|||||||
sections: list[tuple[str, list[Check]]] = [
|
sections: list[tuple[str, list[Check]]] = [
|
||||||
("Environment", _check_environment()),
|
("Environment", _check_environment()),
|
||||||
("Configuration", _check_configuration(config)),
|
("Configuration", _check_configuration(config)),
|
||||||
|
("Stacks & dependencies", _check_stacks(config)),
|
||||||
("Runtime", _check_runtime(config)),
|
("Runtime", _check_runtime(config)),
|
||||||
("TLS & exposure", _check_tls_exposure(config)),
|
("TLS & exposure", _check_tls_exposure(config)),
|
||||||
]
|
]
|
||||||
|
|||||||
110
cli/src/castle_cli/commands/stack.py
Normal file
110
cli/src/castle_cli/commands/stack.py
Normal file
@@ -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
|
||||||
@@ -94,6 +94,20 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
|
|||||||
p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help)
|
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:
|
def _add_service_create(sub: argparse._SubParsersAction, kind: str) -> None:
|
||||||
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml")
|
p = sub.add_parser("create", help=f"Create a {kind} in castle.yaml")
|
||||||
_add_name(p, f"{kind.capitalize()} name")
|
_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, "service")
|
||||||
_build_deployment_group(subparsers, "job")
|
_build_deployment_group(subparsers, "job")
|
||||||
_build_tool_group(subparsers)
|
_build_tool_group(subparsers)
|
||||||
|
_build_stack_group(subparsers)
|
||||||
|
|
||||||
# Gateway (inspection). The gateway is a deployment — start/stop/reload it via
|
# Gateway (inspection). The gateway is a deployment — start/stop/reload it via
|
||||||
# `castle apply` / `castle restart castle-gateway`; this lens just shows routes.
|
# `castle apply` / `castle restart castle-gateway`; this lens just shows routes.
|
||||||
@@ -287,6 +302,22 @@ def _dispatch_tool(args: argparse.Namespace) -> int:
|
|||||||
return 1
|
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:
|
def _dispatch_deployment(args: argparse.Namespace, kind: str) -> int:
|
||||||
sub = getattr(args, f"{kind}_command")
|
sub = getattr(args, f"{kind}_command")
|
||||||
if not sub:
|
if not sub:
|
||||||
@@ -335,6 +366,8 @@ def main() -> int:
|
|||||||
return _dispatch_deployment(args, cmd)
|
return _dispatch_deployment(args, cmd)
|
||||||
if cmd == "tool":
|
if cmd == "tool":
|
||||||
return _dispatch_tool(args)
|
return _dispatch_tool(args)
|
||||||
|
if cmd == "stack":
|
||||||
|
return _dispatch_stack(args)
|
||||||
if cmd == "gateway":
|
if cmd == "gateway":
|
||||||
from castle_cli.commands.gateway import run_gateway
|
from castle_cli.commands.gateway import run_gateway
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,14 @@ from pathlib import Path
|
|||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
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:
|
class TestDoctor:
|
||||||
@@ -85,3 +92,57 @@ class TestDataDirChecks:
|
|||||||
warn = next(c for c in _check_configuration(cfg) if "overrides castle.yaml" in c.label)
|
warn = next(c for c in _check_configuration(cfg) if "overrides castle.yaml" in c.label)
|
||||||
assert warn.status == WARN
|
assert warn.status == WARN
|
||||||
assert "CASTLE_DATA_DIR" in warn.detail
|
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)
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ USER_TOOL_PATH_DIRS = [
|
|||||||
Path.home() / ".local" / "bin",
|
Path.home() / ".local" / "bin",
|
||||||
Path.home() / ".local" / "share" / "pnpm" / "bin",
|
Path.home() / ".local" / "share" / "pnpm" / "bin",
|
||||||
Path.home() / ".local" / "share" / "pnpm",
|
Path.home() / ".local" / "share" / "pnpm",
|
||||||
|
Path.home() / ".deno" / "bin", # deno's installer target (supabase edge fns)
|
||||||
Path("/usr/local/go/bin"),
|
Path("/usr/local/go/bin"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from collections.abc import Sequence
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -329,6 +330,7 @@ def apply(
|
|||||||
# No writes: for systemd, predict the new unit bytes by rendering to a string
|
# No writes: for systemd, predict the new unit bytes by rendering to a string
|
||||||
# so "would restart" is accurate; other managers never restart.
|
# so "would restart" is accurate; other managers never restart.
|
||||||
result.planned = True
|
result.planned = True
|
||||||
|
_stack_preflight(config, items, result.messages)
|
||||||
for k, n, _ in items:
|
for k, n, _ in items:
|
||||||
after = _render_unit_preview(config, n, desired[(k, n)], k)
|
after = _render_unit_preview(config, n, desired[(k, n)], k)
|
||||||
_record(result, n, _classify((k, n), after))
|
_record(result, n, _classify((k, n), after))
|
||||||
@@ -339,6 +341,7 @@ def apply(
|
|||||||
deploy_result = deploy(target_name, root)
|
deploy_result = deploy(target_name, root)
|
||||||
result.messages = list(deploy_result.messages)
|
result.messages = list(deploy_result.messages)
|
||||||
result.registry = deploy_result.registry
|
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
|
# 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,
|
# start. On a fresh node the gateway reload above only kicks off ACME issuance,
|
||||||
@@ -372,6 +375,32 @@ def apply(
|
|||||||
return result
|
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:
|
def _record(result: ApplyResult, name: str, action: str) -> None:
|
||||||
{
|
{
|
||||||
"activate": result.activated,
|
"activate": result.activated,
|
||||||
@@ -451,7 +480,7 @@ def _write_tunnel_config(registry: NodeRegistry, messages: list[str]) -> None:
|
|||||||
config_path.unlink()
|
config_path.unlink()
|
||||||
messages.append("No public services — removed cloudflared config.")
|
messages.append("No public services — removed cloudflared config.")
|
||||||
# Still reconcile so any CNAMEs castle created earlier are cleaned up.
|
# 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
|
return
|
||||||
|
|
||||||
config_path.write_text(content)
|
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)")
|
messages.append(f"Tunnel config written: {config_path} ({len(hosts)} public)")
|
||||||
# Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual
|
# Reconcile the public CNAMEs to the tunnel. Falls back to surfacing the manual
|
||||||
# `cloudflared tunnel route dns` commands when no DNS token is configured.
|
# `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:
|
for h in hosts:
|
||||||
messages.append(
|
messages.append(
|
||||||
f" public: {h} "
|
f" public: {h} "
|
||||||
@@ -712,6 +741,7 @@ def _build_deployed(
|
|||||||
stack=stack,
|
stack=stack,
|
||||||
subdomain=name,
|
subdomain=name,
|
||||||
public=bool(dep.public),
|
public=bool(dep.public),
|
||||||
|
public_host=(dep.public_host if dep.public else None),
|
||||||
static_root=static_root,
|
static_root=static_root,
|
||||||
managed=False,
|
managed=False,
|
||||||
enabled=dep.enabled,
|
enabled=dep.enabled,
|
||||||
@@ -845,6 +875,7 @@ def _build_deployed(
|
|||||||
health_path=health_path,
|
health_path=health_path,
|
||||||
subdomain=(name if expose else None),
|
subdomain=(name if expose else None),
|
||||||
public=bool(dep.public and expose),
|
public=bool(dep.public and expose),
|
||||||
|
public_host=(dep.public_host if (dep.public and expose) else None),
|
||||||
tcp_port=tcp_port,
|
tcp_port=tcp_port,
|
||||||
schedule=getattr(dep, "schedule", None),
|
schedule=getattr(dep, "schedule", None),
|
||||||
managed=managed,
|
managed=managed,
|
||||||
|
|||||||
@@ -17,6 +17,19 @@ UNIT_PREFIX = "castle-"
|
|||||||
SECRET_ENV_DIR = SECRETS_DIR / "env"
|
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:
|
def unit_basename(name: str, kind: str = "service") -> str:
|
||||||
"""The systemd unit stem for a deployment. Jobs carry a ``-job`` marker so a
|
"""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
|
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
|
# ${PATH} across Environment= lines, so a service that overrides PATH must
|
||||||
# spell out the full value, tool dirs included.
|
# spell out the full value, tool dirs included.
|
||||||
if "PATH" not in deployed.env:
|
if "PATH" not in deployed.env:
|
||||||
dirs = list(deployed.path_prepend) # resolved toolchain (e.g. pinned node bin)
|
env_lines += f'Environment="PATH={runtime_path(deployed.path_prepend)}"\n'
|
||||||
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'
|
|
||||||
if env_file is not None:
|
if env_file is not None:
|
||||||
env_lines += f"EnvironmentFile={env_file}\n"
|
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,
|
# Post-start hooks (e.g. OpenBao auto-unseal). `-` prefix → failure is ignored,
|
||||||
# so a hiccup in the hook never fails the unit.
|
# 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()
|
argv = cmd.split()
|
||||||
resolved = shutil.which(argv[0])
|
resolved = shutil.which(argv[0])
|
||||||
if resolved:
|
if resolved:
|
||||||
|
|||||||
@@ -263,13 +263,14 @@ class Requirement(BaseModel):
|
|||||||
requirement, never scraped back into it.
|
requirement, never scraped back into it.
|
||||||
|
|
||||||
A deployment declares these in its ``requires`` list; ``kind`` defaults to
|
A deployment declares these in its ``requires`` list; ``kind`` defaults to
|
||||||
``deployment`` (write just ``- ref: foo``). The ``system`` kind is not written
|
``deployment`` (write just ``- ref: foo``). The ``system`` and ``tool`` kinds are
|
||||||
here — a program's host-package preconditions live in ``system_dependencies``,
|
not written here — a program's host-package preconditions live in
|
||||||
and the relationship model synthesizes ``kind: system`` requirements from it for
|
``system_dependencies`` (synthesized as ``kind: system``), and its stack's
|
||||||
the ``functional?`` check. See docs/relationships.md.
|
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
|
ref: str
|
||||||
bind: str | None = None
|
bind: str | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ not the reverse.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
@@ -23,8 +24,10 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from castle_core import git
|
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.manifest import Requirement, SystemdDeployment
|
||||||
|
from castle_core.stacks import ToolRequirement, tools_for
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -131,11 +134,12 @@ def derive_repos(config: CastleConfig) -> dict[str, Repo]:
|
|||||||
|
|
||||||
def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
|
def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
|
||||||
"""The full requirement set for a deployment: its own ``requires`` (deployment
|
"""The full requirement set for a deployment: its own ``requires`` (deployment
|
||||||
dependencies) plus its program's ``system_dependencies`` synthesized as
|
dependencies), its program's ``system_dependencies`` synthesized as
|
||||||
``kind: system`` requirements, de-duplicated by (kind, ref)."""
|
``kind: system`` requirements, and its stack's toolchains synthesized as
|
||||||
|
``kind: tool`` requirements — de-duplicated by (kind, ref)."""
|
||||||
reqs: list[Requirement] = []
|
reqs: list[Requirement] = []
|
||||||
# A bare name may span kinds — union their requirements (plus their program's
|
# A bare name may span kinds — union their requirements (plus each program's
|
||||||
# host-package deps as the synthesized `kind: system` set).
|
# host-package deps as `kind: system` and its stack's toolchains as `kind: tool`).
|
||||||
for _kind, dep in config.deployments_named(dep_name):
|
for _kind, dep in config.deployments_named(dep_name):
|
||||||
reqs += list(getattr(dep, "requires", []) or [])
|
reqs += list(getattr(dep, "requires", []) or [])
|
||||||
prog = config.programs.get(_program_of(dep_name, dep))
|
prog = config.programs.get(_program_of(dep_name, dep))
|
||||||
@@ -143,6 +147,9 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
|
|||||||
reqs += [
|
reqs += [
|
||||||
Requirement(kind="system", ref=pkg) for pkg in prog.system_dependencies
|
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()
|
seen: set[tuple[str, str]] = set()
|
||||||
out: list[Requirement] = []
|
out: list[Requirement] = []
|
||||||
for r in reqs:
|
for r in reqs:
|
||||||
@@ -152,6 +159,20 @@ def requirements_of(config: CastleConfig, dep_name: str) -> list[Requirement]:
|
|||||||
return out
|
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:
|
def _dpkg_installed(pkg: str) -> bool:
|
||||||
"""Whether a dpkg package is installed (the authoritative 'installed' check on
|
"""Whether a dpkg package is installed (the authoritative 'installed' check on
|
||||||
Debian/Ubuntu). Returns False where dpkg isn't available."""
|
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
|
return r.returncode == 0 and "install ok installed" in r.stdout
|
||||||
|
|
||||||
|
|
||||||
def _check(config: CastleConfig, req: Requirement) -> bool:
|
def _build_path() -> str:
|
||||||
"""Is a single requirement satisfied? (The check is fixed by its kind.)"""
|
"""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":
|
if req.kind == "system":
|
||||||
# `system_dependencies` holds PACKAGE names, not executables. A PATH lookup
|
# `system_dependencies` holds PACKAGE names, not executables. A PATH lookup
|
||||||
# only coincides with 'installed' when the package name equals its command
|
# 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)
|
return shutil.which(req.ref) is not None or _dpkg_installed(req.ref)
|
||||||
if req.kind == "deployment":
|
if req.kind == "deployment":
|
||||||
return bool(config.deployments_named(req.ref))
|
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
|
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).
|
# Well-known TCP ports → a friendlier protocol label (display heuristic only).
|
||||||
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
|
_TCP_PROTOCOL = {5432: "pg", 7687: "bolt", 1883: "mqtt", 6379: "redis"}
|
||||||
|
|
||||||
@@ -229,11 +298,12 @@ def build_model(
|
|||||||
|
|
||||||
nodes: list[Node] = []
|
nodes: list[Node] = []
|
||||||
for _nk, name, dep in config.all_deployments():
|
for _nk, name, dep in config.all_deployments():
|
||||||
|
tmeta = stack_tools_of(config, name) if check else {}
|
||||||
unmet = (
|
unmet = (
|
||||||
[
|
[
|
||||||
f"{r.kind}:{r.ref}"
|
f"{r.kind}:{r.ref}"
|
||||||
for r in requirements_of(config, name)
|
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
|
if check
|
||||||
else []
|
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 = ""
|
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]:
|
def _build_env(node_source: Path | None = None) -> dict[str, str]:
|
||||||
"""Build a subprocess env with user tool dirs on PATH.
|
"""Build a subprocess env with user tool dirs on PATH.
|
||||||
|
|
||||||
@@ -119,6 +143,11 @@ class StackHandler:
|
|||||||
# lint/type-check/test also drops `check` implicitly.
|
# lint/type-check/test also drops `check` implicitly.
|
||||||
provides: set[str] = _STACK_VERBS
|
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:
|
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@@ -178,6 +207,15 @@ class StackHandler:
|
|||||||
class PythonHandler(StackHandler):
|
class PythonHandler(StackHandler):
|
||||||
"""Handler for python-cli and python-fastapi stacks."""
|
"""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:
|
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["uv", "sync"], src)
|
rc, output = await _run(["uv", "sync"], src)
|
||||||
@@ -286,6 +324,24 @@ def _pnpm(*args: str) -> list[str]:
|
|||||||
class ReactViteHandler(StackHandler):
|
class ReactViteHandler(StackHandler):
|
||||||
"""Handler for react-vite stack."""
|
"""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:
|
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
# Build against the gateway serve root so absolute asset URLs resolve at /
|
# 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."""
|
default per the usual declared-command-wins resolution."""
|
||||||
|
|
||||||
provides = {"build", "install", "uninstall"}
|
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:
|
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
@@ -527,6 +591,20 @@ class SupabaseHandler(StackHandler):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
owns_data = True
|
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:
|
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
"""Apply unapplied migrations into the app's own schema (idempotent)."""
|
"""Apply unapplied migrations into the app's own schema (idempotent)."""
|
||||||
@@ -734,6 +812,14 @@ def available_stacks() -> list[str]:
|
|||||||
return sorted(HANDLERS)
|
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:
|
def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
|
||||||
"""Declared argv-lists for a verb, or None.
|
"""Declared argv-lists for a verb, or None.
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ import pytest
|
|||||||
|
|
||||||
import castle_core.config as C
|
import castle_core.config as C
|
||||||
from castle_core import relations as R
|
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:
|
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)
|
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:
|
def _cfg(programs: dict, deployments: dict) -> C.CastleConfig:
|
||||||
return C.CastleConfig(
|
return C.CastleConfig(
|
||||||
root=None,
|
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
|
"""A {kind: deployment} requirement becomes an edge (with bind), and the target's
|
||||||
fan-in is the count of distinct dependents."""
|
fan-in is the count of distinct dependents."""
|
||||||
cfg = _cfg(
|
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"}]),
|
"web": _dep("web", requires=[{"ref": "api", "bind": "API_URL"}]),
|
||||||
"cli": _dep("cli", requires=[{"ref": "api"}]),
|
"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)
|
m = R.build_model(cfg, check=True)
|
||||||
web = next(n for n in m.nodes if n.name == "web")
|
web = next(n for n in m.nodes if n.name == "web")
|
||||||
assert web.unmet == ["deployment:ghost"] and web.functional is False
|
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