Compare commits
10 Commits
a29422d870
...
1767a0a304
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1767a0a304 | ||
|
|
964226d671 | ||
|
|
f8e487071e | ||
|
|
a3ff03fc74 | ||
|
|
9af37541fb | ||
|
|
2967d1fa5e | ||
|
|
e33c505949 | ||
|
|
a9f1e5b099 | ||
|
|
9a8e16143b | ||
|
|
a3d01ca1b3 |
@@ -145,8 +145,9 @@ castle apply # served at my-frontend.<domain>
|
||||
```
|
||||
|
||||
The gateway serves the build **in place** from `<source>/<root>` — no copy, no
|
||||
Node process. Stack: **`docs/stacks/react-vite.md`**. Database-backed apps on the
|
||||
shared Supabase substrate: **`docs/stacks/supabase.md`**.
|
||||
Node process. Stack: **`docs/stacks/react-vite.md`**. Content-driven static sites
|
||||
built by Hugo: **`docs/stacks/hugo.md`**. Database-backed apps on the shared
|
||||
Supabase substrate: **`docs/stacks/supabase.md`**.
|
||||
|
||||
### Adopt an existing repo (no stack needed)
|
||||
|
||||
@@ -324,6 +325,7 @@ Inspect + drive from the CLI: **`castle mesh status`** / **`castle mesh nodes`**
|
||||
| Writing FastAPI services | **`docs/stacks/python-fastapi.md`** |
|
||||
| Writing CLI tools | **`docs/stacks/python-cli.md`** |
|
||||
| Writing React/Vite frontends | **`docs/stacks/react-vite.md`** |
|
||||
| Writing Hugo static sites | **`docs/stacks/hugo.md`** |
|
||||
| Database-backed apps (shared Supabase) | **`docs/stacks/supabase.md`** |
|
||||
| **Developing Castle itself** (CLI/core/api/app, key files, endpoints) | **`docs/developing-castle.md`** |
|
||||
|
||||
|
||||
216
app/src/components/AddProgramForm.tsx
Normal file
216
app/src/components/AddProgramForm.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState } from "react"
|
||||
import { useNavigate } from "react-router-dom"
|
||||
import { X, Folder, ArrowUp, Check, GitBranch } from "lucide-react"
|
||||
import { useBrowse, useAdoptProgram } from "@/services/api/hooks"
|
||||
import { TextField } from "./detail/fields"
|
||||
|
||||
const IS_GIT_URL = /^(https?:\/\/|git@|ssh:\/\/)|\.git$/
|
||||
|
||||
/** Split the typed path into the directory to browse and the trailing partial
|
||||
* segment to filter by — the autocomplete engine. Typing within a directory only
|
||||
* changes the filter (client-side, no refetch); crossing a "/" browses the new dir.
|
||||
* "/data/repos/wid" -> browse "/data/repos", filter "wid"
|
||||
* "/data/repos/" -> browse "/data/repos", filter ""
|
||||
* "wid" -> browse the default repos dir, filter "wid" */
|
||||
function splitPath(raw: string): { dir: string | null; filter: string } {
|
||||
if (raw === "") return { dir: null, filter: "" }
|
||||
if (raw.endsWith("/")) return { dir: raw.length > 1 ? raw.slice(0, -1) : "/", filter: "" }
|
||||
const idx = raw.lastIndexOf("/")
|
||||
if (idx === -1) return { dir: null, filter: raw }
|
||||
if (idx === 0) return { dir: "/", filter: raw.slice(1) }
|
||||
return { dir: raw.slice(0, idx), filter: raw.slice(idx + 1) }
|
||||
}
|
||||
|
||||
/** Adopt an existing repo as a program (the web `castle program add`). Programs
|
||||
* live on the *server's* filesystem, so the picker browses the server's dirs: type
|
||||
* a path (autocompletes as you go), navigate the listing, or paste a git URL. */
|
||||
export function AddProgramForm({
|
||||
existingNames,
|
||||
onCancel,
|
||||
}: {
|
||||
existingNames: string[]
|
||||
onCancel: () => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const adopt = useAdoptProgram()
|
||||
|
||||
// The path bar is the single source of truth — its value is the adopt target.
|
||||
// Empty browses the server's repos dir (surfaced as the placeholder), so the
|
||||
// listing is populated from the first render without seeding the field.
|
||||
const [input, setInput] = useState("")
|
||||
const [name, setName] = useState("")
|
||||
const [description, setDescription] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
|
||||
const target = input.trim()
|
||||
const isGit = IS_GIT_URL.test(target)
|
||||
const { dir, filter } = splitPath(target)
|
||||
const { data: browse, isFetching, error: browseError } = useBrowse(dir, !isGit)
|
||||
|
||||
const entries = (browse?.entries ?? []).filter((e) =>
|
||||
filter ? e.name.toLowerCase().startsWith(filter.toLowerCase()) : true,
|
||||
)
|
||||
|
||||
const suggestedName = target
|
||||
? target.replace(/\/+$/, "").split("/").pop()!.replace(/\.git$/, "")
|
||||
: ""
|
||||
const effectiveName = name.trim() || suggestedName
|
||||
const nameError =
|
||||
name && !/^[a-z0-9][a-z0-9-]*$/.test(name)
|
||||
? "lowercase letters, numbers, hyphens"
|
||||
: effectiveName && existingNames.includes(effectiveName)
|
||||
? "already exists"
|
||||
: ""
|
||||
|
||||
const submit = async () => {
|
||||
if (!target || nameError) return
|
||||
setError("")
|
||||
try {
|
||||
const res = await adopt.mutateAsync({
|
||||
target,
|
||||
name: name.trim() || undefined,
|
||||
description: description.trim() || undefined,
|
||||
})
|
||||
navigate(`/programs/${res.program}`)
|
||||
} catch (e: unknown) {
|
||||
let msg = e instanceof Error ? e.message : String(e)
|
||||
try {
|
||||
msg = JSON.parse((e as Error).message).detail ?? msg
|
||||
} catch {
|
||||
/* keep msg */
|
||||
}
|
||||
setError(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--card)] border border-[var(--primary)] rounded-lg p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">Add program</h3>
|
||||
<button onClick={onCancel} className="text-[var(--muted)] hover:text-[var(--foreground)]">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-400 bg-red-900/20 border border-red-800 rounded px-3 py-1.5">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Editable path bar + live directory listing (or a pasted git URL). */}
|
||||
<div className="border border-[var(--border)] rounded">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-[var(--border)] bg-black/20">
|
||||
<button
|
||||
onClick={() => browse?.parent && setInput(browse.parent + "/")}
|
||||
disabled={isGit || !browse?.parent}
|
||||
title="Up one level"
|
||||
className="text-[var(--muted)] hover:text-[var(--foreground)] disabled:opacity-30 shrink-0"
|
||||
>
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
placeholder={browse?.path ? `${browse.path}/ (or a git URL)` : "/data/repos/… or a git URL"}
|
||||
className="flex-1 min-w-0 bg-transparent text-xs font-mono text-[var(--foreground)] focus:outline-none py-1"
|
||||
/>
|
||||
{isFetching && !isGit && <span className="text-[10px] text-[var(--muted)] shrink-0">…</span>}
|
||||
</div>
|
||||
|
||||
{!isGit && (
|
||||
<div className="max-h-56 overflow-y-auto">
|
||||
{browseError ? (
|
||||
<p className="text-xs text-red-400 px-3 py-3">
|
||||
{(() => {
|
||||
const m = browseError instanceof Error ? browseError.message : ""
|
||||
try {
|
||||
return JSON.parse(m).detail ?? "Cannot read directory"
|
||||
} catch {
|
||||
return m || "Cannot read directory"
|
||||
}
|
||||
})()}
|
||||
</p>
|
||||
) : entries.length > 0 ? (
|
||||
entries.map((e) => (
|
||||
<div
|
||||
key={e.path}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm border-b border-[var(--border)]/50 last:border-0"
|
||||
>
|
||||
<button
|
||||
onClick={() => setInput(e.path + "/")}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 text-left hover:text-[var(--primary)]"
|
||||
title="Open"
|
||||
>
|
||||
{e.is_git ? (
|
||||
<GitBranch size={13} className="shrink-0 text-[var(--primary)]" />
|
||||
) : (
|
||||
<Folder size={13} className="shrink-0 text-[var(--muted)]" />
|
||||
)}
|
||||
<span className="truncate font-mono">{e.name}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setInput(e.path)}
|
||||
className={`shrink-0 px-2 py-0.5 text-xs rounded border transition-colors ${
|
||||
e.is_program
|
||||
? "border-[var(--primary)] text-[var(--primary)] hover:bg-[var(--primary)] hover:text-white"
|
||||
: "border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{target === e.path ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Check size={11} /> picked
|
||||
</span>
|
||||
) : (
|
||||
"pick"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-xs text-[var(--muted)] px-3 py-3">
|
||||
{filter ? "No matching sub-directories." : "No sub-directories."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{target && (
|
||||
<p className="text-xs text-[var(--muted)]">
|
||||
Registering <span className="font-mono text-[var(--foreground)]">{target}</span>
|
||||
{isGit && " (cloned later via castle clone)"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={(v) => setName(v.toLowerCase())}
|
||||
mono
|
||||
placeholder={suggestedName || "program-name"}
|
||||
/>
|
||||
{nameError && <p className="text-xs text-red-400 -mt-2 ml-28 sm:ml-36">{nameError}</p>}
|
||||
|
||||
<TextField label="Description" value={description} onChange={setDescription} />
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!target || !!nameError || adopt.isPending}
|
||||
className="px-4 py-1.5 text-sm rounded bg-green-700 hover:bg-green-600 text-white transition-colors disabled:opacity-40"
|
||||
>
|
||||
{adopt.isPending ? "Registering…" : "Register"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ interface AppItem {
|
||||
function detailPathFor(kind: string, name: string): string {
|
||||
if (kind === "job") return `/jobs/${name}`
|
||||
if (kind === "tool") return `/tools/${name}`
|
||||
if (kind === "reference") return `/map`
|
||||
if (kind === "reference") return `/system`
|
||||
return `/services/${name}`
|
||||
}
|
||||
|
||||
@@ -137,13 +137,13 @@ function PaletteBody({ onClose }: { onClose: () => void }) {
|
||||
const launch = (a: AppItem) => {
|
||||
onClose()
|
||||
if (a.launchUrl) window.open(a.launchUrl, "_blank", "noreferrer")
|
||||
else if (a.mapNodeId) navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`)
|
||||
else if (a.mapNodeId) navigate(`/system?focus=${encodeURIComponent(a.mapNodeId)}`)
|
||||
else navigate(a.detailPath)
|
||||
}
|
||||
const goToMap = (a: AppItem) => {
|
||||
if (!a.mapNodeId) return
|
||||
onClose()
|
||||
navigate(`/map?focus=${encodeURIComponent(a.mapNodeId)}`)
|
||||
navigate(`/system?focus=${encodeURIComponent(a.mapNodeId)}`)
|
||||
}
|
||||
const details = (a: AppItem) => {
|
||||
onClose()
|
||||
|
||||
@@ -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() {
|
||||
</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 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
{plan.changed ? (
|
||||
|
||||
@@ -6,15 +6,16 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Gauge,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
Package,
|
||||
Search,
|
||||
Server,
|
||||
Share2,
|
||||
Map as MapIcon,
|
||||
SquareCode,
|
||||
Wrench,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -32,6 +33,7 @@ type NavGroup = { label: string; icon: LucideIcon; children: NavLeaf[] }
|
||||
// "Deployments" parent. Programs (the catalog) stays top-level.
|
||||
const NAV: (NavLeaf | NavGroup)[] = [
|
||||
{ to: "/", label: "Overview", icon: LayoutDashboard, end: true },
|
||||
{ to: "/system", label: "System", icon: Gauge },
|
||||
{ to: "/gateway", label: "Gateway", icon: Globe },
|
||||
{
|
||||
label: "Deployments",
|
||||
@@ -42,8 +44,8 @@ const NAV: (NavLeaf | NavGroup)[] = [
|
||||
{ to: "/tools", label: "Tools", icon: Wrench },
|
||||
],
|
||||
},
|
||||
{ to: "/programs", label: "Programs", icon: Package },
|
||||
{ to: "/map", label: "System Map", icon: MapIcon },
|
||||
{ to: "/programs", label: "Programs", icon: SquareCode },
|
||||
{ to: "/stacks", label: "Stacks", icon: Layers },
|
||||
{ to: "/mesh", label: "Mesh", icon: Share2 },
|
||||
{ to: "/secrets", label: "Secrets", icon: KeyRound },
|
||||
]
|
||||
|
||||
@@ -45,25 +45,29 @@ export function ProgramCard({
|
||||
<StackBadge stack={program.stack} />
|
||||
</div>
|
||||
|
||||
{program.description && (
|
||||
<p className="text-sm text-[var(--muted)] mb-2">{program.description}</p>
|
||||
)}
|
||||
|
||||
{/* A program has no kind of its own — show its deployments (name · kind).
|
||||
When a deployment shares the program's name (the common 1:1 case) the
|
||||
name is redundant with the card title, so show just the kind badge.
|
||||
Suppressed on kind-specific lenses (e.g. Tools) which already scope to one. */}
|
||||
{showDeployments &&
|
||||
(program.deployments.length > 0 ? (
|
||||
<div className="flex flex-col gap-1 mb-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
{program.deployments.map((d) => (
|
||||
<div key={d.name} className="flex items-center gap-1.5 text-xs">
|
||||
{d.name !== program.id && (
|
||||
<span className="font-mono text-[var(--muted)]">{d.name}</span>
|
||||
)}
|
||||
<KindBadge kind={d.kind} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-[var(--muted)] italic mb-2">no deployment</p>
|
||||
<p className="text-xs text-[var(--muted)] italic">no deployment</p>
|
||||
))}
|
||||
|
||||
{program.description && (
|
||||
<p className="text-sm text-[var(--muted)]">{program.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useServiceAction, useSetEnabled } from "@/services/api/hooks"
|
||||
import { launcherLabel, subdomainUrl } from "@/lib/labels"
|
||||
import { HealthBadge } from "./HealthBadge"
|
||||
import { StackBadge } from "./StackBadge"
|
||||
import { KindBadge } from "./KindBadge"
|
||||
|
||||
interface ServiceCardProps {
|
||||
service: ServiceSummary
|
||||
@@ -27,7 +26,15 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
|
||||
>
|
||||
{service.id}
|
||||
</Link>
|
||||
{health ? (
|
||||
{/* A static site is served from disk by the gateway, so it's inherently
|
||||
"up" — show a green pill in the same slot as a service's health, rather
|
||||
than a separate kind badge, so statics and services read consistently. */}
|
||||
{service.kind === "static" ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full bg-green-800/50 text-green-300">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
||||
static
|
||||
</span>
|
||||
) : health ? (
|
||||
<HealthBadge status={health.status} latency={health.latency_ms} />
|
||||
) : hasHttp ? (
|
||||
<HealthBadge status="unknown" />
|
||||
@@ -35,8 +42,6 @@ export function ServiceCard({ service, health }: ServiceCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{/* A static (caddy-served) "service" is distinguished from a systemd one. */}
|
||||
{service.kind === "static" && <KindBadge kind="static" />}
|
||||
<StackBadge stack={service.stack} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { X } from "lucide-react"
|
||||
import { apiClient } from "@/services/api/client"
|
||||
import { useGateway } from "@/services/api/hooks"
|
||||
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
|
||||
import { Field, TextField } from "./fields"
|
||||
import { Field, TextField, PublicHostRadios } from "./fields"
|
||||
|
||||
const SELECT =
|
||||
"bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)]"
|
||||
@@ -57,6 +57,9 @@ export function CreateDeploymentForm({
|
||||
const [health, setHealth] = useState("/health")
|
||||
const [proxy, setProxy] = useState(true)
|
||||
const [isPublic, setIsPublic] = useState(false)
|
||||
// Optional exact public FQDN (apex / another zone) overriding <name>.<public_domain>.
|
||||
const [publicHost, setPublicHost] = useState("")
|
||||
const [publicMode, setPublicMode] = useState<"default" | "custom">("default")
|
||||
const [schedule, setSchedule] = useState("0 2 * * *")
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [error, setError] = useState("")
|
||||
@@ -81,7 +84,14 @@ export function CreateDeploymentForm({
|
||||
...(description ? { description } : {}),
|
||||
}
|
||||
if (kind === "tool") return { ...base, manager: "path" }
|
||||
if (kind === "static") return { ...base, manager: "caddy", root }
|
||||
if (kind === "static") {
|
||||
const cfg: Record<string, unknown> = { ...base, manager: "caddy", root }
|
||||
if (isPublic) {
|
||||
cfg.reach = "public"
|
||||
if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
// systemd (service or job)
|
||||
const cfg: Record<string, unknown> = {
|
||||
@@ -103,7 +113,10 @@ export function CreateDeploymentForm({
|
||||
}
|
||||
}
|
||||
if (proxy) cfg.proxy = true
|
||||
if (proxy && isPublic) cfg.public = true
|
||||
if (proxy && isPublic) {
|
||||
cfg.public = true
|
||||
if (publicMode === "custom" && publicHost.trim()) cfg.public_host = publicHost.trim()
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
@@ -231,6 +244,17 @@ export function CreateDeploymentForm({
|
||||
</label>
|
||||
</Field>
|
||||
)}
|
||||
{proxy && isPublic && (
|
||||
<Field label="Public address" hint="Where it's published on the internet.">
|
||||
<PublicHostRadios
|
||||
mode={publicMode}
|
||||
onModeChange={setPublicMode}
|
||||
value={publicHost}
|
||||
onChange={setPublicHost}
|
||||
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -239,6 +263,7 @@ export function CreateDeploymentForm({
|
||||
)}
|
||||
|
||||
{kind === "static" && (
|
||||
<>
|
||||
<TextField
|
||||
label="Root"
|
||||
value={root}
|
||||
@@ -247,6 +272,24 @@ export function CreateDeploymentForm({
|
||||
mono
|
||||
placeholder="dist"
|
||||
/>
|
||||
<Field label="Public" hint={`A static site is always served at ${gatewayHost(name || "<name>", domain)}. Enable to also publish it to the internet via the Cloudflare tunnel.`}>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
|
||||
<span className="font-mono text-[var(--muted)]">{isPublic ? "public (via tunnel)" : "internal only"}</span>
|
||||
</label>
|
||||
</Field>
|
||||
{isPublic && (
|
||||
<Field label="Public address" hint="Where it's published on the internet.">
|
||||
<PublicHostRadios
|
||||
mode={publicMode}
|
||||
onModeChange={setPublicMode}
|
||||
value={publicHost}
|
||||
onChange={setPublicHost}
|
||||
defaultHost={publicGatewayHost(name || "<name>", publicDomain)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react"
|
||||
import type { ServiceDetail } from "@/types"
|
||||
import { useGateway } from "@/services/api/hooks"
|
||||
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
|
||||
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
|
||||
import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
|
||||
import type { Requirement } from "./fields"
|
||||
|
||||
interface Props {
|
||||
@@ -77,6 +77,13 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
const [reach, setReach] = useState(
|
||||
(m.reach as string) ?? (m.public === true ? "public" : m.proxy === true ? "internal" : "off"),
|
||||
)
|
||||
// Optional exact public FQDN (apex or another zone) that overrides the default
|
||||
// <name>.<public_domain>. Only applies when reach is public; `publicMode` picks
|
||||
// default vs a custom host, and `publicHost` holds the custom value.
|
||||
const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
|
||||
const [publicMode, setPublicMode] = useState<"default" | "custom">(
|
||||
m.public_host ? "custom" : "default",
|
||||
)
|
||||
|
||||
const { element: envEditor, merged } = useEnvSecrets(obj(obj(m.defaults).env) as Record<string, string>)
|
||||
const { element: requiresEditor, value: requiresValue } = useRequires(
|
||||
@@ -111,6 +118,12 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
}
|
||||
// reach needs a port to route through the gateway; without one it's off.
|
||||
config.reach = port ? reach : "off"
|
||||
// public_host only applies to a public service using a custom domain; else
|
||||
// clear it (send null so the PATCH merge drops any stale override).
|
||||
config.public_host =
|
||||
config.reach === "public" && publicMode === "custom" && publicHost.trim()
|
||||
? publicHost.trim()
|
||||
: null
|
||||
}
|
||||
delete config.proxy
|
||||
delete config.public
|
||||
@@ -201,7 +214,7 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
/>
|
||||
<Field
|
||||
label="Reach"
|
||||
hint={`How far this service is exposed. off: host:port only. internal: ${gatewayHost(service.id, domain)} via the gateway. public: also to the internet via the Cloudflare tunnel.`}
|
||||
hint="How far this service is exposed. off: host:port only. internal: via the gateway. public: also to the internet via the Cloudflare tunnel."
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
@@ -210,15 +223,36 @@ export function ServiceFields({ service, onSave, onDelete }: Props) {
|
||||
disabled={!port}
|
||||
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)] disabled:opacity-50"
|
||||
>
|
||||
<option value="off">{port ? `localhost:${port} (local)` : "off"}</option>
|
||||
<option value="internal">{`${gatewayHost(service.id, domain)} (internal)`}</option>
|
||||
<option value="public">{`${publicGatewayHost(service.id, publicDomain)} (public)`}</option>
|
||||
<option value="off">off</option>
|
||||
<option value="internal">internal</option>
|
||||
<option value="public">public</option>
|
||||
</select>
|
||||
{!port && (
|
||||
<span className="font-mono text-[var(--muted)] text-xs">set a port to expose</span>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
{port && (
|
||||
<Field label="Address" hint="Where this service is reachable.">
|
||||
{reach === "off" && (
|
||||
<span className="font-mono text-xs text-[var(--muted)]">localhost:{port}</span>
|
||||
)}
|
||||
{reach === "internal" && (
|
||||
<span className="font-mono text-xs text-[var(--muted)]">
|
||||
{gatewayHost(service.id, domain)}
|
||||
</span>
|
||||
)}
|
||||
{reach === "public" && (
|
||||
<PublicHostRadios
|
||||
mode={publicMode}
|
||||
onModeChange={setPublicMode}
|
||||
value={publicHost}
|
||||
onChange={setPublicHost}
|
||||
defaultHost={publicGatewayHost(service.id, publicDomain)}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{requiresEditor}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react"
|
||||
import type { DeploymentDetail } from "@/types"
|
||||
import { useGateway } from "@/services/api/hooks"
|
||||
import { gatewayHost, publicGatewayHost } from "@/lib/labels"
|
||||
import { Field, TextField, FormFooter, useEnvSecrets, useRequires } from "./fields"
|
||||
import { Field, TextField, FormFooter, PublicHostRadios, useEnvSecrets, useRequires } from "./fields"
|
||||
import type { Requirement } from "./fields"
|
||||
|
||||
interface Props {
|
||||
@@ -29,6 +29,11 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
const [isPublic, setIsPublic] = useState(
|
||||
((m.reach as string) ?? (m.public === true ? "public" : "internal")) === "public",
|
||||
)
|
||||
// Optional exact public FQDN (apex or another zone) overriding <name>.<public_domain>.
|
||||
const [publicHost, setPublicHost] = useState((m.public_host as string) ?? "")
|
||||
const [publicMode, setPublicMode] = useState<"default" | "custom">(
|
||||
m.public_host ? "custom" : "default",
|
||||
)
|
||||
const { element: envEditor, merged } = useEnvSecrets(
|
||||
obj(obj(m.defaults).env) as Record<string, string>,
|
||||
)
|
||||
@@ -46,6 +51,10 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
config.description = description || null
|
||||
config.root = root || "dist"
|
||||
config.reach = isPublic ? "public" : "internal"
|
||||
// public_host only applies to a public site using a custom domain; else clear
|
||||
// (null) so the merge drops any stale override.
|
||||
config.public_host =
|
||||
isPublic && publicMode === "custom" && publicHost.trim() ? publicHost.trim() : null
|
||||
delete config.public
|
||||
config.requires = requiresValue()
|
||||
const env = merged()
|
||||
@@ -73,7 +82,7 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
/>
|
||||
<Field
|
||||
label="Reach"
|
||||
hint={`How far this static site is served. internal: ${gatewayHost(dep.id, domain)}. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)`}
|
||||
hint="How far this static site is served. internal: via the gateway. public: also to the internet via the Cloudflare tunnel. (A static site is always served, so there's no 'off'.)"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<select
|
||||
@@ -81,11 +90,24 @@ export function StaticFields({ static_: dep, onSave, onDelete }: Props) {
|
||||
onChange={(e) => setIsPublic(e.target.value === "public")}
|
||||
className="bg-black/30 border border-[var(--border)] rounded px-2 py-1 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
>
|
||||
<option value="internal">{`${gatewayHost(dep.id, domain)} (internal)`}</option>
|
||||
<option value="public">{`${publicGatewayHost(dep.id, publicDomain)} (public)`}</option>
|
||||
<option value="internal">internal</option>
|
||||
<option value="public">public</option>
|
||||
</select>
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Address" hint="Where this site is reachable.">
|
||||
{isPublic ? (
|
||||
<PublicHostRadios
|
||||
mode={publicMode}
|
||||
onModeChange={setPublicMode}
|
||||
value={publicHost}
|
||||
onChange={setPublicHost}
|
||||
defaultHost={publicGatewayHost(dep.id, publicDomain)}
|
||||
/>
|
||||
) : (
|
||||
<span className="font-mono text-xs text-[var(--muted)]">{gatewayHost(dep.id, domain)}</span>
|
||||
)}
|
||||
</Field>
|
||||
{requiresEditor}
|
||||
{envEditor}
|
||||
<FormFooter
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { useState } from "react"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import type { DeploymentDetail } from "@/types"
|
||||
import { TextField, FormFooter, useEnvSecrets } from "./fields"
|
||||
import { apiClient, ApiError } from "@/services/api/client"
|
||||
import { Field, TextField, FormFooter } from "./fields"
|
||||
|
||||
type GenKind = "help" | "deep" | "ai"
|
||||
|
||||
interface Props {
|
||||
tool: DeploymentDetail
|
||||
@@ -8,30 +12,94 @@ interface Props {
|
||||
onDelete?: (name: string) => Promise<void>
|
||||
}
|
||||
|
||||
type Obj = Record<string, unknown>
|
||||
const obj = (v: unknown): Obj => (v as Obj) ?? {}
|
||||
|
||||
/** Edit a tool's (path) deployment config. A path deployment has no launcher,
|
||||
* port, or schedule — only a description and env, plus its manager. */
|
||||
* port, or schedule — only a description and its `tool_schema` (the neutral
|
||||
* tool-call definition handed to agents), plus its manager. It has no run env:
|
||||
* a tool is a CLI on PATH, invoked from a shell with castle out of the loop, so
|
||||
* `defaults.env` is never applied (deploy.py only wires env for systemd units). */
|
||||
export function ToolFields({ tool, onSave, onDelete }: Props) {
|
||||
const m = tool.manifest
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [description, setDescription] = useState((m.description as string) ?? "")
|
||||
const { element: envEditor, merged } = useEnvSecrets(
|
||||
obj(obj(m.defaults).env) as Record<string, string>,
|
||||
const [schemaText, setSchemaText] = useState(
|
||||
m.tool_schema ? JSON.stringify(m.tool_schema, null, 2) : "",
|
||||
)
|
||||
const [schemaError, setSchemaError] = useState<string | null>(null)
|
||||
// Which generate action is in-flight (null = idle) — lets each button show its
|
||||
// own spinner/label rather than all three reacting to one shared flag.
|
||||
const [genKind, setGenKind] = useState<GenKind | null>(null)
|
||||
const generating = genKind !== null
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [validation, setValidation] = useState<{ ok: boolean; msg: string } | null>(null)
|
||||
|
||||
const validate = async () => {
|
||||
setValidation(null)
|
||||
setSchemaError(null)
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(schemaText)
|
||||
} catch (e) {
|
||||
setValidation({ ok: false, msg: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` })
|
||||
return
|
||||
}
|
||||
setValidating(true)
|
||||
try {
|
||||
const res = await apiClient.post<{ valid: boolean; errors: string[] }>(
|
||||
"/config/tools/schema/validate",
|
||||
parsed,
|
||||
)
|
||||
setValidation(
|
||||
res.valid
|
||||
? { ok: true, msg: "Schema is valid." }
|
||||
: { ok: false, msg: res.errors.join(" · ") },
|
||||
)
|
||||
} catch (e) {
|
||||
setValidation({ ok: false, msg: e instanceof ApiError ? e.message : String(e) })
|
||||
} finally {
|
||||
setValidating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const generate = async (kind: GenKind) => {
|
||||
setGenKind(kind)
|
||||
setSchemaError(null)
|
||||
setValidation(null)
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (kind === "deep") params.set("deep", "true")
|
||||
if (kind === "ai") params.set("assist", "llm")
|
||||
const qs = params.toString()
|
||||
const res = await apiClient.post<{ schema: unknown }>(
|
||||
`/config/tools/${tool.id}/schema${qs ? `?${qs}` : ""}`,
|
||||
)
|
||||
setSchemaText(JSON.stringify(res.schema, null, 2))
|
||||
} catch (e) {
|
||||
setSchemaError(e instanceof ApiError ? e.message : String(e))
|
||||
} finally {
|
||||
setGenKind(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validate the (optional) tool_schema JSON before saving.
|
||||
let parsedSchema: unknown = undefined
|
||||
if (schemaText.trim()) {
|
||||
try {
|
||||
parsedSchema = JSON.parse(schemaText)
|
||||
} catch (e) {
|
||||
setSchemaError(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
setSchemaError(null)
|
||||
setSaving(true)
|
||||
setSaved(false)
|
||||
try {
|
||||
const config: Record<string, unknown> = JSON.parse(JSON.stringify(m))
|
||||
delete config.id
|
||||
config.description = description || undefined
|
||||
const env = merged()
|
||||
if (Object.keys(env).length > 0) config.defaults = { ...obj(config.defaults), env }
|
||||
else if (config.defaults) delete (config.defaults as Obj).env
|
||||
config.tool_schema = parsedSchema ?? null // null clears (PATCH semantics)
|
||||
await onSave(tool.id, config)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
@@ -43,7 +111,88 @@ export function ToolFields({ tool, onSave, onDelete }: Props) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<TextField label="Description" value={description} onChange={setDescription} />
|
||||
{envEditor}
|
||||
<Field
|
||||
label="Tool schema"
|
||||
hint="Neutral tool-call definition ({name, description, parameters}) handed to agents — rendered to OpenAI or Anthropic on read. Generate it from the tool's --help, then edit freely. Leave empty for none."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => generate("help")}
|
||||
disabled={generating}
|
||||
className="px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{genKind === "help" ? "Generating…" : "Generate from --help"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => generate("deep")}
|
||||
disabled={generating}
|
||||
className="px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
|
||||
title="Also walk subcommands"
|
||||
>
|
||||
Deep
|
||||
</button>
|
||||
<button
|
||||
onClick={() => generate("ai")}
|
||||
disabled={generating}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
|
||||
title="Use the LLM to structure subcommand trees the parser can't (requires LLM assist enabled)"
|
||||
>
|
||||
{genKind === "ai" ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" /> Generating…
|
||||
</>
|
||||
) : (
|
||||
<>✨ Generate with AI</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={validate}
|
||||
disabled={validating || generating || !schemaText.trim()}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded border border-[var(--border)] hover:bg-white/5 transition-colors disabled:opacity-40"
|
||||
title="Deterministically check the schema in the box is a valid tool-call definition"
|
||||
>
|
||||
{validating ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" /> Validating…
|
||||
</>
|
||||
) : (
|
||||
"Validate"
|
||||
)}
|
||||
</button>
|
||||
{schemaText && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSchemaText("")
|
||||
setSchemaError(null)
|
||||
setValidation(null)
|
||||
}}
|
||||
className="text-xs text-red-400 hover:text-red-300"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={schemaText}
|
||||
onChange={(e) => {
|
||||
setSchemaText(e.target.value)
|
||||
setValidation(null)
|
||||
}}
|
||||
spellCheck={false}
|
||||
rows={schemaText ? 12 : 3}
|
||||
placeholder="{ } — generate from --help, or paste an Anthropic tool definition"
|
||||
className="w-full bg-black/30 border border-[var(--border)] rounded px-3 py-2 text-xs font-mono focus:outline-none focus:border-[var(--primary)]"
|
||||
/>
|
||||
{validation && (
|
||||
<p className={`text-xs ${validation.ok ? "text-green-400" : "text-amber-400"}`}>
|
||||
{validation.ok ? "✓ " : "✗ "}
|
||||
{validation.msg}
|
||||
</p>
|
||||
)}
|
||||
{schemaError && <p className="text-xs text-red-400">{schemaError}</p>}
|
||||
</div>
|
||||
</Field>
|
||||
<FormFooter
|
||||
saving={saving}
|
||||
saved={saved}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from "react"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { Trash2, X } from "lucide-react"
|
||||
import { SecretsEditor } from "@/components/SecretsEditor"
|
||||
import { ConfirmModal } from "@/components/ConfirmModal"
|
||||
|
||||
@@ -55,6 +55,53 @@ export function TextField({
|
||||
)
|
||||
}
|
||||
|
||||
/** The public-address sub-control shown when a deployment's reach is `public`:
|
||||
* a default/custom choice. `default` uses `<name>.<public_domain>` (shown
|
||||
* read-only); `custom` reveals a text input for an exact FQDN (apex or another
|
||||
* zone) that becomes the deployment's `public_host`. */
|
||||
export function PublicHostRadios({
|
||||
mode,
|
||||
onModeChange,
|
||||
value,
|
||||
onChange,
|
||||
defaultHost,
|
||||
}: {
|
||||
mode: "default" | "custom"
|
||||
onModeChange: (m: "default" | "custom") => void
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
defaultHost: string
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="radio" checked={mode === "default"} onChange={() => onModeChange("default")} />
|
||||
<span className="font-mono text-xs text-[var(--muted)]">
|
||||
{defaultHost} <span className="opacity-60">· default</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="radio" checked={mode === "custom"} onChange={() => onModeChange("custom")} />
|
||||
<span className="text-sm">custom domain</span>
|
||||
</label>
|
||||
{mode === "custom" && (
|
||||
<div className="ml-6 space-y-1">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="example.com"
|
||||
className={`w-64 ${INPUT} font-mono`}
|
||||
/>
|
||||
<p className="text-xs text-[var(--muted)] leading-snug">
|
||||
An exact hostname — an apex (example.com) or a name in another zone. The
|
||||
Cloudflare token(s) need DNS:Edit on that zone.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A value that is *exactly* a secret ref → fully editable via SecretsEditor.
|
||||
const SECRET_RE = /^\$\{secret:([^}]+)\}$/
|
||||
// A secret ref embedded anywhere in a value (e.g. `neo4j/${secret:PW}`). These
|
||||
@@ -89,6 +136,26 @@ export function useEnvSecrets(initial: Record<string, string>) {
|
||||
const [env, setEnv] = useState<Record<string, string>>(plain)
|
||||
const [secrets, setSecrets] = useState<Record<string, string>>(secretRefs)
|
||||
|
||||
// Inline "add variable" row (replaces a native prompt). `adding` toggles it;
|
||||
// the key is committed only when non-blank and not already an env/secret name.
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [newKey, setNewKey] = useState("")
|
||||
const [newVal, setNewVal] = useState("")
|
||||
const trimmedKey = newKey.trim()
|
||||
const duplicate = trimmedKey !== "" && (trimmedKey in env || trimmedKey in secrets)
|
||||
const canCommit = trimmedKey !== "" && !duplicate
|
||||
|
||||
const cancelNew = () => {
|
||||
setAdding(false)
|
||||
setNewKey("")
|
||||
setNewVal("")
|
||||
}
|
||||
const commitNew = () => {
|
||||
if (!canCommit) return
|
||||
setEnv((p) => ({ ...p, [trimmedKey]: newVal }))
|
||||
cancelNew()
|
||||
}
|
||||
|
||||
const merged = (): Record<string, string> => {
|
||||
const out: Record<string, string> = { ...env }
|
||||
for (const [k, name] of Object.entries(secrets)) out[k] = `\${secret:${name}}`
|
||||
@@ -134,15 +201,60 @@ export function useEnvSecrets(initial: Record<string, string>) {
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={() => {
|
||||
const key = prompt("Variable name:")
|
||||
if (key) setEnv((p) => ({ ...p, [key]: "" }))
|
||||
{adding ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
autoFocus
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitNew()
|
||||
if (e.key === "Escape") cancelNew()
|
||||
}}
|
||||
placeholder="VARIABLE_NAME"
|
||||
className={`w-24 sm:w-56 min-w-0 ${INPUT} text-xs font-mono`}
|
||||
/>
|
||||
<span className="text-[var(--muted)] shrink-0">=</span>
|
||||
<input
|
||||
value={newVal}
|
||||
onChange={(e) => setNewVal(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") commitNew()
|
||||
if (e.key === "Escape") cancelNew()
|
||||
}}
|
||||
placeholder="value"
|
||||
className={`flex-1 min-w-0 ${INPUT} text-xs font-mono`}
|
||||
/>
|
||||
<button
|
||||
onClick={commitNew}
|
||||
disabled={!canCommit}
|
||||
className="px-2 py-1 text-xs rounded bg-blue-700 hover:bg-blue-600 text-white transition-colors disabled:opacity-40 shrink-0"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelNew}
|
||||
className="text-[var(--muted)] hover:text-[var(--foreground)] p-0.5 shrink-0"
|
||||
title="Cancel"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
{duplicate && (
|
||||
<p className="text-xs text-amber-400">
|
||||
<span className="font-mono">{trimmedKey}</span> already exists.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setAdding(true)}
|
||||
className="text-xs text-[var(--primary)] hover:underline"
|
||||
>
|
||||
+ Add variable
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Secrets">
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import { useState } from "react"
|
||||
import { Plus } from "lucide-react"
|
||||
import { usePrograms } from "@/services/api/hooks"
|
||||
import { ProgramList } from "@/components/ProgramList"
|
||||
import { MonorepoBanner } from "@/components/MonorepoBanner"
|
||||
import { PageHeader } from "@/components/PageHeader"
|
||||
import { AddProgramForm } from "@/components/AddProgramForm"
|
||||
|
||||
export function Programs() {
|
||||
const { data: programs, isLoading } = usePrograms()
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<PageHeader title="Programs" subtitle="Software catalog" />
|
||||
<PageHeader
|
||||
title="Programs"
|
||||
subtitle="Software catalog"
|
||||
actions={
|
||||
<button
|
||||
onClick={() => setAdding((a) => !a)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--primary)] transition-colors"
|
||||
>
|
||||
<Plus size={14} /> Add program
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
<MonorepoBanner />
|
||||
|
||||
{adding && (
|
||||
<div className="mb-6 max-w-2xl">
|
||||
<AddProgramForm
|
||||
existingNames={(programs ?? []).map((p) => p.id)}
|
||||
onCancel={() => setAdding(false)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-[var(--muted)]">Loading...</p>
|
||||
) : programs && programs.length > 0 ? (
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -198,10 +198,13 @@ function MapNode({ data }: NodeProps) {
|
||||
</div>
|
||||
{d.sub && <div className="truncate text-[10px] text-[var(--muted)]">{d.sub}</div>}
|
||||
</div>
|
||||
{/* Right handle: drag OUT to connect — to a hub (expose) or another node
|
||||
(declare "requires"). Right target lands incoming dependency lines. */}
|
||||
{/* Right handles: `rs` (source) is what you drag OUT to connect — to a hub
|
||||
(expose) or another node (declare "requires"). `rt` (target) only anchors
|
||||
incoming dependency lines, so it's nudged below `rs`: stacked on top it
|
||||
would swallow the pointer-down (you can't START a drag from a target
|
||||
handle in strict mode) and dragging a fresh line would do nothing. */}
|
||||
<Handle id="rs" type="source" position={Position.Right} style={{ ...HANDLE, opacity: 0.9, background: color }} />
|
||||
<Handle id="rt" type="target" position={Position.Right} style={{ ...HANDLE, opacity: 0 }} />
|
||||
<Handle id="rt" type="target" position={Position.Right} style={{ ...HANDLE, opacity: 0, top: "78%" }} />
|
||||
<Handle id="lt" type="target" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
|
||||
<Handle id="ls" type="source" position={Position.Left} isConnectable={false} style={{ ...HANDLE, opacity: 0 }} />
|
||||
</div>
|
||||
@@ -474,9 +477,13 @@ export function SystemMapPage() {
|
||||
// fallback, but there's no ProgramSpec, so no grip/link.
|
||||
const catalog = new Set((programs ?? []).map((p) => p.id))
|
||||
const routes = gateway?.routes ?? []
|
||||
const exposed = new Set(routes.map((r) => r.name).filter(Boolean) as string[])
|
||||
const publicSet = new Set(
|
||||
routes.filter((r) => r.public_url).map((r) => r.name).filter(Boolean) as string[],
|
||||
// The gateway's authoritative public (tunnel) URL per exposed name, so a public
|
||||
// service's launch link reads as its internet address, not the internal one.
|
||||
// (Only the *address* comes from the gateway; exposure state is `n.reach`.)
|
||||
const publicUrlOf = new Map(
|
||||
routes
|
||||
.filter((r) => r.public_url && r.name)
|
||||
.map((r) => [r.name as string, r.public_url as string]),
|
||||
)
|
||||
|
||||
// Per-node lookups from the graph itself (authoritative for sockets/reach now).
|
||||
@@ -487,11 +494,15 @@ export function SystemMapPage() {
|
||||
// http-exposed service served at <name>.<domain>. TCP/tools/jobs aren't.
|
||||
const domain = gateway?.domain ?? null
|
||||
const launchOf = (n: (typeof graph.nodes)[number]): string | undefined => {
|
||||
if (!domain || !n.reach || n.reach === "off") return undefined
|
||||
if (n.kind === "static") return `https://${n.name}.${domain}`
|
||||
if (n.kind === "service" && n.endpoints.some((e) => e.protocol === "http"))
|
||||
return `https://${n.name}.${domain}`
|
||||
return undefined
|
||||
if (!n.reach || n.reach === "off") return undefined
|
||||
const isHttp =
|
||||
n.kind === "static" ||
|
||||
(n.kind === "service" && n.endpoints.some((e) => e.protocol === "http"))
|
||||
if (!isHttp) return undefined
|
||||
// Public services live at both URLs; prefer the internet one so the link
|
||||
// matches the green Internet edge instead of reading as internal-only.
|
||||
if (n.reach === "public" && publicUrlOf.has(n.name)) return publicUrlOf.get(n.name)
|
||||
return domain ? `https://${n.name}.${domain}` : undefined
|
||||
}
|
||||
|
||||
// Remote launch: <subdomain>.<node-domain>, using the node's own acme domain
|
||||
@@ -745,17 +756,19 @@ export function SystemMapPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Reach is modal: one line per exposed node. public → a single green line to
|
||||
// Internet; internal → a single blue line to Gateway. Dragging a new line to
|
||||
// the other target switches the mode; deleting the line removes exposure.
|
||||
// Reach is modal: one line per exposed node, driven by the node's own `reach`
|
||||
// (the single source of truth). public → a single green line to Internet;
|
||||
// internal → a single blue line to Gateway. Dragging a new line to the other
|
||||
// target switches the mode; deleting the line removes exposure.
|
||||
const reachOf: Record<string, "internal" | "public"> = {}
|
||||
for (const name of exposed) {
|
||||
if (!present.has(name)) continue
|
||||
const isPub = publicSet.has(name)
|
||||
reachOf[name] = isPub ? "public" : "internal"
|
||||
for (const n of graph.nodes) {
|
||||
if (!present.has(n.name)) continue
|
||||
if (n.reach !== "internal" && n.reach !== "public") continue
|
||||
const isPub = n.reach === "public"
|
||||
reachOf[n.name] = isPub ? "public" : "internal"
|
||||
edges.push({
|
||||
id: `exp:${name}`,
|
||||
source: name,
|
||||
id: `exp:${n.name}`,
|
||||
source: n.name,
|
||||
target: isPub ? "__internet__" : "__gateway__",
|
||||
sourceHandle: "rs",
|
||||
targetHandle: "lt",
|
||||
@@ -987,7 +1000,7 @@ export function SystemMapPage() {
|
||||
setFocus(picks.length === 1 ? picks[0].id : null)
|
||||
}, [])
|
||||
|
||||
// "Go to on map" from the command palette (/map?focus=<id>): select + center it.
|
||||
// "Go to on map" from the command palette (/system?focus=<id>): select + center it.
|
||||
useEffect(() => {
|
||||
const f = searchParams.get("focus")
|
||||
if (!f) return
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { createBrowserRouter } from "react-router-dom"
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom"
|
||||
import { Layout } from "@/components/Layout"
|
||||
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,11 +26,14 @@ export const router = createBrowserRouter([
|
||||
{ path: "services", element: <Services /> },
|
||||
{ path: "scheduled", element: <Scheduled /> },
|
||||
{ path: "tools", element: <Tools /> },
|
||||
{ path: "stacks", element: <Stacks /> },
|
||||
{ path: "programs", element: <Programs /> },
|
||||
{ path: "gateway", element: <GatewayPage /> },
|
||||
{ path: "mesh", element: <MeshPage /> },
|
||||
{ path: "secrets", element: <SecretsPage /> },
|
||||
{ path: "map", element: <SystemMapPage /> },
|
||||
{ path: "system", element: <SystemMapPage /> },
|
||||
// Back-compat: the page was formerly at /map. Redirect old links/bookmarks.
|
||||
{ path: "map", element: <Navigate to="/system" replace /> },
|
||||
{ path: "services/:name", element: <ServiceDetailPage /> },
|
||||
{ path: "jobs/:name", element: <ScheduledDetailPage /> },
|
||||
{ path: "tools/:name", element: <ToolDetailPage /> },
|
||||
|
||||
@@ -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) {
|
||||
return useQuery({
|
||||
queryKey: ["jobs", name],
|
||||
@@ -385,6 +415,56 @@ export function useProgramSync() {
|
||||
})
|
||||
}
|
||||
|
||||
// Server-side directory browser for the "Add program" flow. Programs live on the
|
||||
// server's filesystem, so the picker browses the server's dirs (the browser's own
|
||||
// file dialog only sees the client machine). `path` null => the repos dir.
|
||||
export interface BrowseEntry {
|
||||
name: string
|
||||
path: string
|
||||
is_program: boolean
|
||||
is_git: boolean
|
||||
}
|
||||
export interface BrowseResult {
|
||||
path: string
|
||||
parent: string | null
|
||||
repos_dir: string
|
||||
entries: BrowseEntry[]
|
||||
}
|
||||
export function useBrowse(path: string | null, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["browse", path ?? "@repos"],
|
||||
queryFn: () =>
|
||||
apiClient.get<BrowseResult>(
|
||||
`/fs/browse${path ? `?path=${encodeURIComponent(path)}` : ""}`,
|
||||
),
|
||||
enabled,
|
||||
})
|
||||
}
|
||||
|
||||
export interface AdoptResult {
|
||||
ok: boolean
|
||||
program: string
|
||||
source: string
|
||||
stack: string | null
|
||||
repo: string | null
|
||||
commands: string[]
|
||||
is_git_url: boolean
|
||||
}
|
||||
// Adopt an existing repo as a program (the web `castle program add`). Refreshes
|
||||
// the catalog + the derived graph/repo views.
|
||||
export function useAdoptProgram() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: (body: { target: string; name?: string; description?: string }) =>
|
||||
apiClient.post<AdoptResult>("/programs/adopt", body),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["programs"] })
|
||||
qc.invalidateQueries({ queryKey: ["graph"] })
|
||||
qc.invalidateQueries({ queryKey: ["repos"] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useRepos() {
|
||||
return useQuery({
|
||||
queryKey: ["repos"],
|
||||
|
||||
@@ -20,6 +20,15 @@ class Settings(BaseSettings):
|
||||
nats_token: str | None = None
|
||||
mdns_enabled: bool = False
|
||||
|
||||
# LLM assist (off by default). Calls the litellm proxy's OpenAI-compatible
|
||||
# /chat/completions to generate a tool-call schema for CLIs the deterministic
|
||||
# parser can't structure (subcommand trees). The key is NOT stored here — it's
|
||||
# read from the secret backend by name at call time.
|
||||
llm_enabled: bool = False
|
||||
llm_base_url: str = "https://litellm.civil.payne.io/v1"
|
||||
llm_model: str = "qwen"
|
||||
llm_api_key_secret: str = "LITELLM_MASTER_KEY"
|
||||
|
||||
model_config = {
|
||||
"env_prefix": "CASTLE_API_",
|
||||
"env_file": ".env",
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from fastapi import APIRouter, Body, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from castle_core.config import (
|
||||
@@ -454,6 +454,80 @@ def delete_tool(name: str) -> dict:
|
||||
return _delete_deployment(name, kind="tool")
|
||||
|
||||
|
||||
@router.post("/tools/{name}/schema")
|
||||
async def generate_tool_schema(
|
||||
name: str, deep: bool = False, assist: str | None = None
|
||||
) -> dict:
|
||||
"""Generate a *draft* tool-call schema (neutral core) from the tool's ``--help``.
|
||||
|
||||
Not saved — the client reviews/edits it and persists via ``PUT
|
||||
/config/tools/{name}`` (the schema rides in the deployment config as
|
||||
``tool_schema``). Two modes:
|
||||
|
||||
* default (``assist`` unset) — deterministic: parse ``--help``. ``deep`` walks
|
||||
subcommands. 422 if the tool isn't installed / emits no help.
|
||||
* ``assist=llm`` — send the recursive ``--help`` to the litellm proxy for a
|
||||
structured schema (the escape hatch for subcommand trees the parser can only
|
||||
render as a ``command`` string). 503 if LLM assist is disabled; 502 on an
|
||||
upstream/validation failure.
|
||||
"""
|
||||
from castle_core.tool_schema import (
|
||||
ToolSchemaError,
|
||||
collect_tool_help,
|
||||
derive_tool_schema,
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
if config.deployment("tool", name) is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Tool '{name}' not found",
|
||||
)
|
||||
|
||||
if assist == "llm":
|
||||
from castle_api.config import settings
|
||||
from castle_api.llm import LLMAssistError, generate_tool_schema_llm
|
||||
|
||||
if not settings.llm_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="LLM assist is disabled (set CASTLE_API_LLM_ENABLED=true).",
|
||||
)
|
||||
try:
|
||||
help_text = collect_tool_help(config, name)
|
||||
except ToolSchemaError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)
|
||||
)
|
||||
try:
|
||||
schema = await generate_tool_schema_llm(help_text, name)
|
||||
except LLMAssistError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY, detail=str(e)
|
||||
)
|
||||
return {"ok": True, "schema": schema, "assist": "llm"}
|
||||
|
||||
try:
|
||||
schema = derive_tool_schema(config, name, deep=deep)
|
||||
except ToolSchemaError as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(e),
|
||||
)
|
||||
return {"ok": True, "schema": schema}
|
||||
|
||||
|
||||
@router.post("/tools/schema/validate")
|
||||
def validate_tool_schema_endpoint(core: dict = Body(...)) -> dict:
|
||||
"""Deterministically validate a tool-call schema core (no LLM) — the shape and
|
||||
that ``parameters`` is a valid JSON Schema. Lets the UI check a hand-edited
|
||||
schema. Returns ``{valid, errors}``."""
|
||||
from castle_core.tool_schema import validate_tool_schema_core
|
||||
|
||||
errors = validate_tool_schema_core(core)
|
||||
return {"valid": not errors, "errors": errors}
|
||||
|
||||
|
||||
@router.put("/static/{name}")
|
||||
def save_static(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Create/update the *static* frontend named `name`."""
|
||||
|
||||
159
castle-api/src/castle_api/llm.py
Normal file
159
castle-api/src/castle_api/llm.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""LLM assist — generate a tool-call schema from a CLI's --help via the litellm proxy.
|
||||
|
||||
castle's first LLM-backed feature. The deterministic parser
|
||||
(``castle_core.tool_schema``) falls back to a single ``command`` string for
|
||||
subcommand trees (git, castle); this asks an LLM to read the recursive ``--help``
|
||||
and produce a *structured* neutral tool-call core instead.
|
||||
|
||||
Goes through castle's litellm proxy over its OpenAI-compatible
|
||||
``/chat/completions`` (model-agnostic; the fleet standardizes on litellm), using
|
||||
forced tool-calling to constrain the output. Every response is validated
|
||||
deterministically (``validate_tool_schema_core``); on failure the errors are fed
|
||||
back to the model to repair, up to a cap — so a weak model's malformed draft is
|
||||
fixed rather than surfaced. The result is a reviewed draft — never auto-saved.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from castle_core.config import read_secret
|
||||
from castle_core.tool_schema import validate_tool_schema_core
|
||||
|
||||
from castle_api.config import settings
|
||||
|
||||
_TIMEOUT = 60.0
|
||||
# Total attempts = 1 initial + repairs. Sonnet is valid first try; the extra
|
||||
# rounds salvage weaker models (repairing a bad enum, etc.).
|
||||
_MAX_ATTEMPTS = 3
|
||||
_SYSTEM = (
|
||||
"You convert a command-line tool's --help output into a tool-call schema for "
|
||||
"an AI agent. Call the `emit_tool_schema` function exactly once. Produce a "
|
||||
"JSON Schema in `parameters` (type: object) whose properties let an agent "
|
||||
"invoke the tool: one property per meaningful option or subcommand path, each "
|
||||
"typed (boolean for flags, string for valued options, enum as a LIST of the "
|
||||
"allowed values for fixed choices) with a concise description drawn from the "
|
||||
"help. For a tool with subcommands, include a `subcommand` property (an enum "
|
||||
"listing the available subcommands) plus the important shared options. `name` "
|
||||
"must match ^[a-zA-Z0-9_-]{1,64}$. Do not invent options not in the help."
|
||||
)
|
||||
|
||||
_EMIT_TOOL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "emit_tool_schema",
|
||||
"description": "Emit the structured tool-call definition for this CLI.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string", "description": "Sanitized tool name."},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "One-line description of what the tool does.",
|
||||
},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"description": (
|
||||
"A JSON Schema object (type: object with a properties map) "
|
||||
"describing the tool's invocation arguments."
|
||||
),
|
||||
},
|
||||
},
|
||||
"required": ["name", "description", "parameters"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class LLMAssistError(Exception):
|
||||
"""The LLM assist couldn't produce a valid schema (config, upstream, or output)."""
|
||||
|
||||
|
||||
def _extract_args(data: dict) -> dict | None:
|
||||
"""Pull the emit_tool_schema arguments out of a chat-completions response.
|
||||
|
||||
Returns the parsed arguments dict (even if not yet valid — the caller
|
||||
validates), or None if the model didn't call the function / returned non-JSON.
|
||||
"""
|
||||
try:
|
||||
call = data["choices"][0]["message"]["tool_calls"][0]
|
||||
args = call["function"]["arguments"]
|
||||
except (KeyError, IndexError, TypeError):
|
||||
return None
|
||||
if isinstance(args, str):
|
||||
try:
|
||||
args = json.loads(args)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return args if isinstance(args, dict) else None
|
||||
|
||||
|
||||
async def _complete(messages: list[dict], key: str) -> dict:
|
||||
"""One forced-tool-call chat completion against the litellm proxy."""
|
||||
payload = {
|
||||
"model": settings.llm_model,
|
||||
"messages": messages,
|
||||
"tools": [_EMIT_TOOL],
|
||||
"tool_choice": {"type": "function", "function": {"name": "emit_tool_schema"}},
|
||||
}
|
||||
url = f"{settings.llm_base_url.rstrip('/')}/chat/completions"
|
||||
headers = {"Authorization": f"Bearer {key}"}
|
||||
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
||||
try:
|
||||
resp = await client.post(url, json=payload, headers=headers)
|
||||
except httpx.HTTPError as e:
|
||||
raise LLMAssistError(f"litellm request failed: {e}") from e
|
||||
if resp.status_code >= 400:
|
||||
raise LLMAssistError(f"litellm returned {resp.status_code}: {resp.text[:300]}")
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _repair_message(prior: dict, errors: list[str]) -> dict:
|
||||
"""A user turn that shows the invalid schema + its errors and asks for a fix."""
|
||||
return {
|
||||
"role": "user",
|
||||
"content": (
|
||||
"Your previous emit_tool_schema output failed validation:\n"
|
||||
+ "\n".join(f"- {e}" for e in errors)
|
||||
+ "\n\nHere is what you returned:\n"
|
||||
+ json.dumps(prior, indent=2)
|
||||
+ "\n\nCall emit_tool_schema again with every problem fixed. Remember: "
|
||||
"`enum` must be a list of the allowed values, not a count."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def generate_tool_schema_llm(help_text: str, name: str) -> dict:
|
||||
"""Ask the LLM for a structured neutral tool-call core, repairing invalid
|
||||
output against ``validate_tool_schema_core`` until it passes (up to
|
||||
``_MAX_ATTEMPTS``). Raises ``LLMAssistError`` on config/upstream failure or if
|
||||
it can't be made valid."""
|
||||
key = read_secret(settings.llm_api_key_secret)
|
||||
if not key:
|
||||
raise LLMAssistError(
|
||||
f"LLM assist enabled but secret '{settings.llm_api_key_secret}' is unset."
|
||||
)
|
||||
base = [
|
||||
{"role": "system", "content": _SYSTEM},
|
||||
{"role": "user", "content": f"Tool name: {name}\n\n--- help ---\n{help_text}"},
|
||||
]
|
||||
messages = list(base)
|
||||
errors = ["model did not call emit_tool_schema with JSON arguments"]
|
||||
for _attempt in range(_MAX_ATTEMPTS):
|
||||
data = await _complete(messages, key)
|
||||
args = _extract_args(data)
|
||||
if args is not None:
|
||||
# Pin the name we were given so a model name-drift never costs a repair.
|
||||
args["name"] = name
|
||||
errors = validate_tool_schema_core(args)
|
||||
if not errors:
|
||||
return args
|
||||
# Rebuild from base + a single repair turn (avoids provider-specific
|
||||
# tool_call/tool_result threading; each repair is a fresh forced call).
|
||||
messages = [*base, _repair_message(args or {}, errors)]
|
||||
raise LLMAssistError(
|
||||
f"could not produce a valid schema after {_MAX_ATTEMPTS} attempts: "
|
||||
+ "; ".join(errors)
|
||||
)
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -3,14 +3,28 @@
|
||||
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
|
||||
|
||||
from castle_core import git
|
||||
from castle_core.adopt import (
|
||||
AdoptError,
|
||||
build_adopted_program,
|
||||
is_git_url,
|
||||
looks_like_program,
|
||||
)
|
||||
from castle_core.config import write_program_file
|
||||
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"])
|
||||
|
||||
@@ -22,6 +36,137 @@ 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 <path|git-url>`. Programs live on the
|
||||
# server's filesystem, so the picker browses the *server's* dirs (a browser's
|
||||
# native file dialog only sees the client machine).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@programs_router.get("/fs/browse")
|
||||
def browse_filesystem(path: str | None = None) -> dict:
|
||||
"""List sub-directories of ``path`` (default: the repos dir) so the dashboard
|
||||
can browse to a program on the server. Directories only; hidden dirs skipped.
|
||||
Each entry is flagged when it looks adoptable (a project manifest or git repo).
|
||||
"""
|
||||
config = get_config()
|
||||
base = Path(path).expanduser() if path else config.repos_dir
|
||||
try:
|
||||
base = base.resolve()
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid path: {e}")
|
||||
if not base.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Path does not exist: {base}")
|
||||
if not base.is_dir():
|
||||
raise HTTPException(status_code=400, detail=f"Not a directory: {base}")
|
||||
|
||||
try:
|
||||
children = sorted(base.iterdir(), key=lambda p: p.name.lower())
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {base}")
|
||||
|
||||
entries: list[dict] = []
|
||||
for child in children:
|
||||
if child.name.startswith("."):
|
||||
continue
|
||||
# A single unreadable child (can't stat/traverse) shouldn't sink the whole
|
||||
# listing — skip it rather than 403 the directory.
|
||||
try:
|
||||
if not child.is_dir():
|
||||
continue
|
||||
entries.append(
|
||||
{
|
||||
"name": child.name,
|
||||
"path": str(child),
|
||||
"is_program": looks_like_program(child),
|
||||
"is_git": (child / ".git").exists(),
|
||||
}
|
||||
)
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
parent = str(base.parent) if base.parent != base else None
|
||||
return {
|
||||
"path": str(base),
|
||||
"parent": parent,
|
||||
"repos_dir": str(config.repos_dir),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
class AdoptRequest(BaseModel):
|
||||
target: str # a local server path or a git URL
|
||||
name: str | None = None
|
||||
description: str = ""
|
||||
|
||||
|
||||
@programs_router.post("/programs/adopt")
|
||||
def adopt_program(request: AdoptRequest) -> dict:
|
||||
"""Adopt an existing repo as a program (the web `castle program add`).
|
||||
|
||||
``target`` is a local server path or a git URL. Writes just the new program's
|
||||
file; declaring a deployment (service/job/tool/static) stays a separate step.
|
||||
"""
|
||||
target = request.target.strip()
|
||||
if not target:
|
||||
raise HTTPException(status_code=422, detail="A path or git URL is required.")
|
||||
|
||||
config = get_config()
|
||||
try:
|
||||
adopted = build_adopted_program(
|
||||
config, target, name=request.name, description=request.description
|
||||
)
|
||||
except AdoptError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
config.programs[adopted.name] = adopted.spec
|
||||
write_program_file(config, adopted.name)
|
||||
return {
|
||||
"ok": True,
|
||||
"program": adopted.name,
|
||||
"source": adopted.source,
|
||||
"stack": adopted.stack,
|
||||
"repo": adopted.repo,
|
||||
"commands": adopted.commands,
|
||||
"is_git_url": is_git_url(target),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Git sync — pull a program's source working copy up to date (pull only; no
|
||||
# build/apply/restart — converge stays an explicit, separate step). Declared
|
||||
|
||||
@@ -920,15 +920,18 @@ def get_gateway() -> GatewayInfo:
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
# Which local deployments are public → their public URL (<name>.<public_domain>).
|
||||
# Scan ALL deployments, not just `config.services` — a public *static* (caddy)
|
||||
# is not a service, so filtering to services dropped its public_url (calculator).
|
||||
# Which local deployments are public → their public URL. A `public_host`
|
||||
# override (apex / another zone) wins; otherwise <name>.<public_domain>.
|
||||
public_domain = registry.node.public_domain
|
||||
public_names = {
|
||||
name
|
||||
for _k, name, dep in (config.all_deployments() if config else [])
|
||||
if getattr(dep, "public", False)
|
||||
}
|
||||
|
||||
def _public_url(r) -> str | None:
|
||||
if not getattr(r, "public", False):
|
||||
return None
|
||||
if r.public_host:
|
||||
return f"https://{r.public_host}"
|
||||
if public_domain:
|
||||
return f"https://{r.name}.{public_domain}"
|
||||
return None
|
||||
|
||||
remote = {h: r.registry for h, r in mesh_state.all_nodes().items()}
|
||||
routes = [
|
||||
@@ -938,11 +941,7 @@ def get_gateway() -> GatewayInfo:
|
||||
target=r.target,
|
||||
name=r.name,
|
||||
node=r.node or registry.node.hostname,
|
||||
public_url=(
|
||||
f"https://{r.name}.{public_domain}"
|
||||
if public_domain and r.name in public_names
|
||||
else None
|
||||
),
|
||||
public_url=_public_url(r),
|
||||
)
|
||||
for r in compute_routes(registry, config, remote or None)
|
||||
]
|
||||
|
||||
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
|
||||
199
castle-api/tests/test_tool_schema_api.py
Normal file
199
castle-api/tests/test_tool_schema_api.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""POST /config/tools/{name}/schema — derive an Anthropic tool schema from --help,
|
||||
and the tool_schema field round-tripping through the tool save path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import json
|
||||
|
||||
from castle_core.config import load_config
|
||||
|
||||
from castle_api.config import settings
|
||||
|
||||
|
||||
def _completion(args: dict) -> dict:
|
||||
"""A minimal chat-completions response carrying an emit_tool_schema call."""
|
||||
return {
|
||||
"choices": [
|
||||
{"message": {"tool_calls": [
|
||||
{"function": {"name": "emit_tool_schema", "arguments": json.dumps(args)}}
|
||||
]}}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
_BAD_CORE = {
|
||||
"name": "python3", "description": "d",
|
||||
"parameters": {"type": "object", "properties": {"m": {"type": "string", "enum": 3}}},
|
||||
}
|
||||
_GOOD_CORE = {
|
||||
"name": "python3", "description": "d",
|
||||
"parameters": {"type": "object", "properties": {"m": {"type": "string"}}},
|
||||
}
|
||||
|
||||
# A tool whose program falls back to the tool name for its executable; `python3`
|
||||
# is always on PATH, so derive succeeds deterministically.
|
||||
_PY_TOOL = {"program": "python3", "manager": "path"}
|
||||
|
||||
|
||||
class TestGenerateSchema:
|
||||
def test_generate_returns_draft_not_saved(
|
||||
self, client: TestClient, castle_root: Path
|
||||
) -> None:
|
||||
client.put("/config/tools/python3", json={"config": _PY_TOOL})
|
||||
|
||||
r = client.post("/config/tools/python3/schema")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
schema = body["schema"]
|
||||
# The stored draft is the neutral core (name/description/parameters),
|
||||
# rendered to a provider envelope only on read.
|
||||
assert schema["name"] == "python3"
|
||||
assert "parameters" in schema
|
||||
assert schema["parameters"]["properties"]
|
||||
|
||||
# It's a draft: nothing persisted until the client saves it.
|
||||
cfg = load_config(castle_root)
|
||||
assert cfg.tools["python3"].tool_schema is None
|
||||
|
||||
def test_unknown_tool_404(self, client: TestClient) -> None:
|
||||
r = client.post("/config/tools/nope-not-a-tool/schema")
|
||||
assert r.status_code == 404
|
||||
|
||||
def test_validate_endpoint_valid(self, client: TestClient) -> None:
|
||||
r = client.post("/config/tools/schema/validate", json=_GOOD_CORE)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"valid": True, "errors": []}
|
||||
|
||||
def test_validate_endpoint_invalid(self, client: TestClient) -> None:
|
||||
r = client.post("/config/tools/schema/validate", json=_BAD_CORE)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["valid"] is False and body["errors"]
|
||||
|
||||
def test_uninstalled_executable_422(
|
||||
self, client: TestClient, castle_root: Path
|
||||
) -> None:
|
||||
client.put(
|
||||
"/config/tools/ghosttool",
|
||||
json={"config": {"program": "ghosttool", "manager": "path"}},
|
||||
)
|
||||
r = client.post("/config/tools/ghosttool/schema")
|
||||
assert r.status_code == 422
|
||||
assert "PATH" in r.json()["detail"]
|
||||
|
||||
def test_assist_disabled_returns_503(
|
||||
self, client: TestClient, castle_root: Path
|
||||
) -> None:
|
||||
client.put("/config/tools/python3", json={"config": _PY_TOOL})
|
||||
# llm_enabled is False by default.
|
||||
r = client.post("/config/tools/python3/schema?assist=llm")
|
||||
assert r.status_code == 503
|
||||
assert "disabled" in r.json()["detail"]
|
||||
|
||||
def test_assist_success_returns_draft(
|
||||
self, client: TestClient, castle_root: Path, monkeypatch
|
||||
) -> None:
|
||||
client.put("/config/tools/python3", json={"config": _PY_TOOL})
|
||||
|
||||
async def _fake_llm(help_text: str, name: str) -> dict:
|
||||
assert "python3" in help_text # the recursive help was gathered
|
||||
return {
|
||||
"name": name,
|
||||
"description": "the python interpreter",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"script": {"type": "string"}},
|
||||
},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(settings, "llm_enabled", True)
|
||||
monkeypatch.setattr("castle_api.llm.generate_tool_schema_llm", _fake_llm)
|
||||
|
||||
r = client.post("/config/tools/python3/schema?assist=llm")
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["assist"] == "llm"
|
||||
assert body["schema"]["parameters"]["properties"] == {"script": {"type": "string"}}
|
||||
|
||||
def test_assist_upstream_error_returns_502(
|
||||
self, client: TestClient, castle_root: Path, monkeypatch
|
||||
) -> None:
|
||||
client.put("/config/tools/python3", json={"config": _PY_TOOL})
|
||||
|
||||
async def _fail_llm(help_text: str, name: str) -> dict:
|
||||
from castle_api.llm import LLMAssistError
|
||||
|
||||
raise LLMAssistError("litellm returned 500")
|
||||
|
||||
monkeypatch.setattr(settings, "llm_enabled", True)
|
||||
monkeypatch.setattr("castle_api.llm.generate_tool_schema_llm", _fail_llm)
|
||||
|
||||
r = client.post("/config/tools/python3/schema?assist=llm")
|
||||
assert r.status_code == 502
|
||||
assert "litellm" in r.json()["detail"]
|
||||
|
||||
def test_assist_repairs_invalid_then_valid(
|
||||
self, client: TestClient, castle_root: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A malformed first response (bad enum) is fed back and repaired."""
|
||||
client.put("/config/tools/python3", json={"config": _PY_TOOL})
|
||||
calls = {"n": 0}
|
||||
|
||||
async def _fake_complete(messages: list, key: str) -> dict:
|
||||
calls["n"] += 1
|
||||
return _completion(_BAD_CORE if calls["n"] == 1 else _GOOD_CORE)
|
||||
|
||||
monkeypatch.setattr(settings, "llm_enabled", True)
|
||||
monkeypatch.setattr("castle_api.llm.read_secret", lambda name: "k")
|
||||
monkeypatch.setattr("castle_api.llm._complete", _fake_complete)
|
||||
|
||||
r = client.post("/config/tools/python3/schema?assist=llm")
|
||||
assert r.status_code == 200, r.text
|
||||
assert calls["n"] == 2 # repaired on the second attempt
|
||||
props = r.json()["schema"]["parameters"]["properties"]
|
||||
assert props == {"m": {"type": "string"}}
|
||||
|
||||
def test_assist_unrepairable_returns_502(
|
||||
self, client: TestClient, castle_root: Path, monkeypatch
|
||||
) -> None:
|
||||
"""Persistently invalid output exhausts the repair budget → 502."""
|
||||
client.put("/config/tools/python3", json={"config": _PY_TOOL})
|
||||
|
||||
async def _always_bad(messages: list, key: str) -> dict:
|
||||
return _completion(_BAD_CORE)
|
||||
|
||||
monkeypatch.setattr(settings, "llm_enabled", True)
|
||||
monkeypatch.setattr("castle_api.llm.read_secret", lambda name: "k")
|
||||
monkeypatch.setattr("castle_api.llm._complete", _always_bad)
|
||||
|
||||
r = client.post("/config/tools/python3/schema?assist=llm")
|
||||
assert r.status_code == 502
|
||||
assert "attempts" in r.json()["detail"]
|
||||
|
||||
def test_schema_round_trips_through_save(
|
||||
self, client: TestClient, castle_root: Path
|
||||
) -> None:
|
||||
"""Saving a tool with tool_schema persists it (PATCH merge), and clearing
|
||||
it with null drops the field."""
|
||||
client.put("/config/tools/python3", json={"config": _PY_TOOL})
|
||||
schema = client.post("/config/tools/python3/schema").json()["schema"]
|
||||
|
||||
client.put(
|
||||
"/config/tools/python3",
|
||||
json={"config": {"program": "python3", "tool_schema": schema}},
|
||||
)
|
||||
cfg = load_config(castle_root)
|
||||
assert cfg.tools["python3"].tool_schema is not None
|
||||
assert cfg.tools["python3"].tool_schema["name"] == "python3"
|
||||
|
||||
# Clearing with null removes it (default None).
|
||||
client.put(
|
||||
"/config/tools/python3",
|
||||
json={"config": {"program": "python3", "tool_schema": None}},
|
||||
)
|
||||
cfg = load_config(castle_root)
|
||||
assert cfg.tools["python3"].tool_schema is None
|
||||
@@ -3,125 +3,43 @@
|
||||
`castle program create` makes new code from a stack. `castle program add` adopts code that
|
||||
already exists — a local path, or a git URL to clone. It detects sensible dev
|
||||
verb commands so a non-castle project becomes usable without writing them by hand.
|
||||
|
||||
The adopt logic itself lives in ``castle_core.adopt`` so the CLI and the API
|
||||
(`POST /programs/adopt`, the dashboard's "Add program") behave identically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.adopt import AdoptError, build_adopted_program
|
||||
|
||||
from castle_cli.config import load_config, save_config
|
||||
from castle_cli.manifest import BuildSpec, CommandsSpec, ProgramSpec
|
||||
|
||||
|
||||
def _is_git_url(s: str) -> bool:
|
||||
return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
|
||||
|
||||
|
||||
def _detect(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
|
||||
"""Detect (stack, commands) for a source dir.
|
||||
|
||||
Returns a stack name when the project fits a known one (so it inherits those
|
||||
defaults), otherwise an explicit commands map. `add` adopts source only; the
|
||||
deployment (and thus kind) is declared separately, so no kind is inferred here.
|
||||
"""
|
||||
commands: dict[str, list[list[str]]] = {}
|
||||
|
||||
pyproject = src / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
try:
|
||||
data = tomllib.loads(pyproject.read_text())
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
data = {}
|
||||
deps = " ".join(data.get("project", {}).get("dependencies", []))
|
||||
stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
|
||||
return stack, commands
|
||||
|
||||
if (src / "Cargo.toml").exists():
|
||||
commands = {
|
||||
"build": [["cargo", "build", "--release"]],
|
||||
"test": [["cargo", "test"]],
|
||||
"lint": [["cargo", "clippy"]],
|
||||
"run": [["cargo", "run"]],
|
||||
}
|
||||
return None, commands
|
||||
|
||||
if (src / "package.json").exists():
|
||||
commands = {
|
||||
"build": [["pnpm", "build"]],
|
||||
"test": [["pnpm", "test"]],
|
||||
"lint": [["pnpm", "lint"]],
|
||||
}
|
||||
return None, commands
|
||||
|
||||
if (src / "Makefile").exists() or (src / "makefile").exists():
|
||||
commands = {"build": [["make"]], "test": [["make", "test"]]}
|
||||
return None, commands
|
||||
|
||||
return None, commands
|
||||
|
||||
|
||||
def run_add(args: argparse.Namespace) -> int:
|
||||
"""Adopt an existing repo as a program."""
|
||||
config = load_config()
|
||||
target = args.target
|
||||
|
||||
repo_url: str | None = None
|
||||
source: str | None = None
|
||||
|
||||
if _is_git_url(target):
|
||||
repo_url = target
|
||||
name = args.name or Path(target.rstrip("/")).name.removesuffix(".git")
|
||||
# Default local clone location; cloned later via `castle clone`.
|
||||
source = str(config.repos_dir / name)
|
||||
src_path = Path(source)
|
||||
else:
|
||||
src_path = Path(target).expanduser().resolve()
|
||||
if not src_path.exists():
|
||||
print(f"Error: path does not exist: {src_path}")
|
||||
return 1
|
||||
source = str(src_path)
|
||||
name = args.name or src_path.name
|
||||
|
||||
if name in config.programs or config.deployments_named(name):
|
||||
print(f"Error: '{name}' already exists in castle.yaml")
|
||||
return 1
|
||||
|
||||
# Detect verbs from the working copy if we have one on disk. `kind` is derived
|
||||
# from a deployment, not stored on the program — so `castle add` adopts the
|
||||
# source only; declare a deployment separately (castle service/job create).
|
||||
stack: str | None = None
|
||||
detected_commands: dict[str, list[list[str]]] = {}
|
||||
if src_path.exists():
|
||||
stack, detected_commands = _detect(src_path)
|
||||
|
||||
prog = ProgramSpec(
|
||||
id=name,
|
||||
description=args.description or f"Adopted from {target}",
|
||||
source=source,
|
||||
stack=stack,
|
||||
repo=repo_url,
|
||||
try:
|
||||
adopted = build_adopted_program(
|
||||
config, args.target, name=args.name, description=args.description
|
||||
)
|
||||
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
|
||||
if detected_commands:
|
||||
build_cmds = detected_commands.pop("build", None)
|
||||
if build_cmds:
|
||||
prog.build = BuildSpec(commands=build_cmds)
|
||||
if detected_commands:
|
||||
prog.commands = CommandsSpec.model_validate(detected_commands)
|
||||
except AdoptError as e:
|
||||
print(f"Error: {e}")
|
||||
return 1
|
||||
|
||||
config.programs[name] = prog
|
||||
config.programs[adopted.name] = adopted.spec
|
||||
save_config(config)
|
||||
|
||||
print(f"Adopted '{name}' as a program.")
|
||||
print(f" source: {source}")
|
||||
if repo_url:
|
||||
print(f" repo: {repo_url} (run 'castle clone {name}' to fetch it)")
|
||||
if stack:
|
||||
print(f" stack: {stack} (verbs inherited from stack defaults)")
|
||||
elif detected_commands:
|
||||
print(f" commands detected: {', '.join(sorted(detected_commands))}")
|
||||
print(f"Adopted '{adopted.name}' as a program.")
|
||||
print(f" source: {adopted.source}")
|
||||
if adopted.repo:
|
||||
print(f" repo: {adopted.repo} (run 'castle clone {adopted.name}' to fetch it)")
|
||||
if adopted.stack:
|
||||
print(f" stack: {adopted.stack} (verbs inherited from stack defaults)")
|
||||
elif adopted.commands:
|
||||
print(f" commands detected: {', '.join(adopted.commands)}")
|
||||
else:
|
||||
print(" no stack/commands detected — declare verbs in castle.yaml as needed")
|
||||
return 0
|
||||
|
||||
@@ -48,6 +48,8 @@ def run_apply(args: argparse.Namespace) -> int:
|
||||
_line(_C["activate"], "would activate ", result.activated)
|
||||
_line(_C["restart"], "would restart ", result.restarted)
|
||||
_line(_C["deactivate"], "would deactivate", result.deactivated)
|
||||
if result.gateway_changed:
|
||||
print(f" {_C['restart']}would reload {_C['reset']} gateway routes")
|
||||
return 0
|
||||
|
||||
print("\n\033[1mApplied\033[0m")
|
||||
@@ -57,4 +59,6 @@ def run_apply(args: argparse.Namespace) -> int:
|
||||
_line(_C["activate"], "activated ", result.activated)
|
||||
_line(_C["restart"], "restarted ", result.restarted)
|
||||
_line(_C["deactivate"], "deactivated", result.deactivated)
|
||||
if result.gateway_changed:
|
||||
print(f" {_C['restart']}reloaded {_C['reset']} gateway routes")
|
||||
return 0
|
||||
|
||||
@@ -29,14 +29,16 @@ STACK_DEFAULTS: dict[str, str] = {
|
||||
"python-cli": "tool",
|
||||
"react-vite": "static",
|
||||
"supabase": "static",
|
||||
"hugo": "static",
|
||||
}
|
||||
|
||||
# Static build output per stack, for `static` (caddy) deployments. The gateway
|
||||
# serves this dir in place at <name>.<gateway.domain> (no service, no process).
|
||||
# A supabase app ships a raw `public/`; react-vite builds to `dist/`.
|
||||
# A supabase app ships a raw `public/`; react-vite builds to `dist/`; hugo to `public/`.
|
||||
STACK_BUILD_OUTPUTS: dict[str, str] = {
|
||||
"supabase": "public",
|
||||
"react-vite": "dist",
|
||||
"hugo": "public",
|
||||
}
|
||||
|
||||
# Substrate a stack's apps depend on — seeded as a deployment `requires` at creation
|
||||
@@ -180,6 +182,10 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
print(" # edit migrations/, functions/, public/ — targets the shared substrate")
|
||||
print(f" castle program build {name} # apply migrations to the substrate")
|
||||
print(f" castle apply # serve at {name}.<gateway.domain>")
|
||||
elif stack == "hugo":
|
||||
print(" # edit content/, layouts/ (or add a theme under themes/)")
|
||||
print(f" castle program build {name} # hugo --gc --minify -> public/")
|
||||
print(f" castle apply {name} # serve at {name}.<gateway.domain>")
|
||||
elif stack:
|
||||
print(" uv sync")
|
||||
if kind == "service":
|
||||
|
||||
@@ -163,17 +163,20 @@ def run_delete(args: argparse.Namespace) -> int:
|
||||
|
||||
|
||||
def _public_hosts(config, deployments: list[tuple[str, str]]) -> list[str]:
|
||||
"""The Cloudflare CNAMEs (<subdomain>.<public_domain>) of any public deployments
|
||||
being removed — surfaced so the operator can clean up DNS."""
|
||||
"""The Cloudflare CNAMEs (a ``public_host`` override, else
|
||||
<subdomain>.<public_domain>) of any public deployments being removed —
|
||||
surfaced so the operator can clean up DNS."""
|
||||
gw = getattr(config, "gateway", None)
|
||||
public_domain = getattr(gw, "public_domain", None) if gw else None
|
||||
if not public_domain:
|
||||
return []
|
||||
hosts: list[str] = []
|
||||
for kind, d in deployments:
|
||||
spec = config.deployment(kind, d)
|
||||
if spec is None or not getattr(spec, "public", False):
|
||||
continue
|
||||
override = getattr(spec, "public_host", None)
|
||||
if override:
|
||||
hosts.append(override)
|
||||
elif public_domain:
|
||||
sub = getattr(spec, "subdomain", None) or d
|
||||
hosts.append(f"{sub}.{public_domain}")
|
||||
return hosts
|
||||
|
||||
@@ -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)),
|
||||
]
|
||||
|
||||
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
|
||||
@@ -13,9 +13,14 @@ import argparse
|
||||
import json
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from castle_cli.config import load_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from castle_core.config import CastleConfig
|
||||
from castle_core.manifest import ProgramSpec
|
||||
|
||||
BOLD = "\033[1m"
|
||||
DIM = "\033[2m"
|
||||
RESET = "\033[0m"
|
||||
@@ -24,16 +29,16 @@ GREY = "\033[90m"
|
||||
CYAN = "\033[96m"
|
||||
|
||||
|
||||
def _is_tool(config: object, name: str) -> bool:
|
||||
def _is_tool(config: CastleConfig, name: str) -> bool:
|
||||
return any(kind == "tool" for _, kind in config.deployments_of(name))
|
||||
|
||||
|
||||
def _tool_programs(config: object) -> dict:
|
||||
def _tool_programs(config: CastleConfig) -> dict:
|
||||
"""Programs with a tool (path) deployment, name-sorted."""
|
||||
return {name: comp for name, comp in sorted(config.programs.items()) if _is_tool(config, name)}
|
||||
|
||||
|
||||
def _executables(comp: object) -> list[str]:
|
||||
def _executables(comp: ProgramSpec) -> list[str]:
|
||||
"""The console scripts a tool exposes, from its pyproject `[project.scripts]`.
|
||||
|
||||
This is the command(s) to actually run — the source of truth even when the
|
||||
@@ -54,7 +59,16 @@ def _executables(comp: object) -> list[str]:
|
||||
return [comp.id]
|
||||
|
||||
|
||||
def _tool_record(config: object, name: str, comp: object, installed: bool) -> dict:
|
||||
def _tool_record(
|
||||
config: CastleConfig, name: str, comp: ProgramSpec, installed: bool, fmt: str = "openai"
|
||||
) -> dict:
|
||||
# The tool-call schema (for handing this CLI to an agent) is stored neutral on
|
||||
# the path deployment; render it into the requested envelope for the --json
|
||||
# context payload. Feed default is openai (litellm-native).
|
||||
from castle_core.tool_schema import render_tool_schema
|
||||
|
||||
dep = config.deployment("tool", name)
|
||||
core = getattr(dep, "tool_schema", None)
|
||||
return {
|
||||
"name": name,
|
||||
"executables": _executables(comp),
|
||||
@@ -63,6 +77,7 @@ def _tool_record(config: object, name: str, comp: object, installed: bool) -> di
|
||||
"stack": comp.stack,
|
||||
"source": comp.source,
|
||||
"system_dependencies": comp.system_dependencies,
|
||||
"tool_schema": render_tool_schema(core, fmt) if core else None,
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +87,10 @@ def run_tool_list(args: argparse.Namespace) -> int:
|
||||
|
||||
config = load_config()
|
||||
tools = _tool_programs(config)
|
||||
fmt = getattr(args, "format", "openai")
|
||||
records = [
|
||||
_tool_record(config, name, comp, tool_installed(name)) for name, comp in tools.items()
|
||||
_tool_record(config, name, comp, tool_installed(name), fmt)
|
||||
for name, comp in tools.items()
|
||||
]
|
||||
|
||||
if getattr(args, "json", False):
|
||||
@@ -112,7 +129,7 @@ def run_tool_info(args: argparse.Namespace) -> int:
|
||||
|
||||
comp = config.programs[name]
|
||||
installed = tool_installed(name)
|
||||
record = _tool_record(config, name, comp, installed)
|
||||
record = _tool_record(config, name, comp, installed, getattr(args, "format", "openai"))
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(record, indent=2))
|
||||
|
||||
@@ -81,12 +81,31 @@ def _build_tool_group(subparsers: argparse._SubParsersAction) -> None:
|
||||
grp.set_defaults(resource="tool")
|
||||
sub = grp.add_subparsers(dest="tool_command")
|
||||
|
||||
fmt_help = "Render each tool_schema in this envelope (default: openai)"
|
||||
fmt_choices = ("openai", "anthropic", "neutral")
|
||||
|
||||
p = sub.add_parser("list", help="List tools with their executable + description")
|
||||
p.add_argument("--json", action="store_true", help="Machine-readable output")
|
||||
p.add_argument("--format", choices=fmt_choices, default="openai", help=fmt_help)
|
||||
|
||||
p = sub.add_parser("info", help="Show a tool's executable, description, install state")
|
||||
_add_name(p, "Tool name")
|
||||
p.add_argument("--json", action="store_true", help="Machine-readable output")
|
||||
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:
|
||||
@@ -161,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.
|
||||
@@ -282,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:
|
||||
@@ -330,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
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -20,6 +22,8 @@ def scaffold_project(
|
||||
_scaffold_tool(project_dir, name, package_name, description)
|
||||
elif stack == "supabase":
|
||||
_scaffold_supabase(project_dir, name, description)
|
||||
elif stack == "hugo":
|
||||
_scaffold_hugo(project_dir, name, description)
|
||||
else:
|
||||
raise ValueError(f"No scaffold template for stack: {stack}")
|
||||
|
||||
@@ -771,6 +775,200 @@ Auth/WebCrypto apps should get their own HTTPS host route (secure context).
|
||||
)
|
||||
|
||||
|
||||
def _hugo_new_site(project_dir: Path) -> bool:
|
||||
"""Create the canonical site skeleton with Hugo's own scaffolder.
|
||||
|
||||
Delegates the standard structure (archetypes/, the content/layouts/static/…
|
||||
dir tree, hugo.toml) to `hugo new site` rather than reinventing it. Returns
|
||||
False if the hugo binary isn't present at create time — the caller then falls
|
||||
back to a minimal hand-written skeleton (the build needs hugo regardless)."""
|
||||
if shutil.which("hugo") is None:
|
||||
return False
|
||||
# `hugo new site` requires an empty/absent target; create runs before git init,
|
||||
# so project_dir doesn't exist yet.
|
||||
result = subprocess.run(
|
||||
["hugo", "new", "site", str(project_dir), "--format", "toml"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _scaffold_hugo(project_dir: Path, name: str, description: str) -> None:
|
||||
"""Scaffold a Hugo site that builds and serves theme-less out of the box.
|
||||
|
||||
The canonical skeleton comes from Hugo's own `hugo new site`; on top of it we
|
||||
overlay the pieces a bare skeleton lacks — the layouts needed to render a home
|
||||
page without a theme, sample content, and a castle-flavored hugo.toml/README.
|
||||
|
||||
`castle program build` runs `hugo --gc --minify` → `public/`, which a caddy
|
||||
deployment serves in place at <name>.<gateway.domain>. baseURL is `/` so assets
|
||||
resolve at the root of the site's own subdomain. A theme (with its own asset
|
||||
pipeline) can be dropped under themes/ later; such themes declare a two-step
|
||||
`build.commands` in the program spec, which overrides the stack default."""
|
||||
|
||||
def sub(text: str) -> str:
|
||||
return text.replace("__NAME__", name).replace("__DESC__", description)
|
||||
|
||||
# Prefer Hugo's native scaffolder; fall back to the bits it would have made.
|
||||
if not _hugo_new_site(project_dir):
|
||||
_write(
|
||||
project_dir / "archetypes" / "default.md",
|
||||
"""+++
|
||||
date = '{{ .Date }}'
|
||||
draft = true
|
||||
title = '{{ replace .File.ContentBaseName "-" " " | title }}'
|
||||
+++
|
||||
""",
|
||||
)
|
||||
|
||||
# --- hugo.toml — overwrite Hugo's example.org default; baseURL '/' serves at
|
||||
# the subdomain root ---
|
||||
_write(
|
||||
project_dir / "hugo.toml",
|
||||
sub(
|
||||
"""baseURL = "/"
|
||||
languageCode = "en-us"
|
||||
title = "__NAME__"
|
||||
|
||||
[params]
|
||||
description = "__DESC__"
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# --- layouts/_default/baseof.html — the shared page skeleton (blocks) ---
|
||||
_write(
|
||||
project_dir / "layouts" / "_default" / "baseof.html",
|
||||
sub(
|
||||
"""<!DOCTYPE html>
|
||||
<html lang="{{ .Site.LanguageCode | default "en" }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{ block "title" . }}{{ .Site.Title }}{{ end }}</title>
|
||||
<meta name="description" content="{{ .Site.Params.description }}">
|
||||
</head>
|
||||
<body>
|
||||
<header><a href="{{ "/" | relURL }}">{{ .Site.Title }}</a></header>
|
||||
<main>{{ block "main" . }}{{ end }}</main>
|
||||
<footer>© {{ now.Year }} {{ .Site.Title }}</footer>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
# --- layouts/index.html — the home page ---
|
||||
_write(
|
||||
project_dir / "layouts" / "index.html",
|
||||
"""{{ define "main" }}
|
||||
{{ .Content }}
|
||||
<ul>
|
||||
{{ range (where .Site.RegularPages "Type" "posts") }}
|
||||
<li>
|
||||
<a href="{{ .RelPermalink }}">{{ .Title }}</a>
|
||||
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
|
||||
</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
""",
|
||||
)
|
||||
|
||||
# --- layouts/_default/{single,list}.html — content pages ---
|
||||
_write(
|
||||
project_dir / "layouts" / "_default" / "single.html",
|
||||
"""{{ define "title" }}{{ .Title }} · {{ .Site.Title }}{{ end }}
|
||||
{{ define "main" }}
|
||||
<article>
|
||||
<h1>{{ .Title }}</h1>
|
||||
<time datetime="{{ .Date.Format "2006-01-02" }}">{{ .Date.Format "Jan 2, 2006" }}</time>
|
||||
{{ .Content }}
|
||||
</article>
|
||||
{{ end }}
|
||||
""",
|
||||
)
|
||||
_write(
|
||||
project_dir / "layouts" / "_default" / "list.html",
|
||||
"""{{ define "title" }}{{ .Title }} · {{ .Site.Title }}{{ end }}
|
||||
{{ define "main" }}
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ .Content }}
|
||||
<ul>
|
||||
{{ range .Pages }}
|
||||
<li><a href="{{ .RelPermalink }}">{{ .Title }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
""",
|
||||
)
|
||||
|
||||
# --- content — home + an example post ---
|
||||
_write(
|
||||
project_dir / "content" / "_index.md",
|
||||
sub(
|
||||
"""---
|
||||
title: "__NAME__"
|
||||
---
|
||||
|
||||
Welcome to **__NAME__** — a Hugo site on castle. Edit `content/_index.md` and
|
||||
add posts under `content/posts/`.
|
||||
"""
|
||||
),
|
||||
)
|
||||
_write(
|
||||
project_dir / "content" / "posts" / "hello.md",
|
||||
"""---
|
||||
title: "Hello, world"
|
||||
date: 2024-01-01
|
||||
---
|
||||
|
||||
Your first post. Edit me in `content/posts/`, or run `hugo new posts/next.md`.
|
||||
""",
|
||||
)
|
||||
|
||||
# --- .gitignore — build output is regenerated, never committed ---
|
||||
_write(
|
||||
project_dir / ".gitignore",
|
||||
"/public/\n/resources/\n.hugo_build.lock\n",
|
||||
)
|
||||
|
||||
# --- README ---
|
||||
_write(
|
||||
project_dir / "README.md",
|
||||
sub(
|
||||
"""# __NAME__
|
||||
|
||||
__DESC__
|
||||
|
||||
A [Hugo](https://gohugo.io) static site managed by castle.
|
||||
|
||||
## Develop
|
||||
|
||||
hugo server -D # live preview at http://localhost:1313
|
||||
|
||||
## Build & serve
|
||||
|
||||
castle program build __NAME__ # hugo --gc --minify -> public/
|
||||
castle apply __NAME__ # serve at __NAME__.<gateway.domain>
|
||||
|
||||
## Themes
|
||||
|
||||
Drop a theme under `themes/` (often a git submodule) and set `theme` in
|
||||
`hugo.toml`. A theme with an asset pipeline (Tailwind, etc.) needs a pre-build
|
||||
step; declare it in `programs/__NAME__.yaml` so it overrides the stack default:
|
||||
|
||||
build:
|
||||
commands:
|
||||
- [pnpm, build]
|
||||
- [hugo, --gc, --minify]
|
||||
outputs: [public]
|
||||
"""
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _write(path: Path, content: str) -> None:
|
||||
"""Write content to a file, creating parent directories."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -6,6 +6,7 @@ requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"pyyaml>=6.0.0",
|
||||
"pydantic>=2.0.0",
|
||||
"jsonschema>=4.0.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
157
core/src/castle_core/adopt.py
Normal file
157
core/src/castle_core/adopt.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""Adopt an existing repo as a program — shared by the CLI (`castle program add`)
|
||||
and the API (`POST /programs/adopt`).
|
||||
|
||||
`create` scaffolds new code from a stack; `add` adopts code that already exists —
|
||||
a local path, or a git URL to clone later. Keeping the target parsing, stack /
|
||||
command sniffing, and ProgramSpec build here means both front-ends behave
|
||||
identically (the logic used to live only in the CLI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.config import CastleConfig
|
||||
from castle_core.manifest import BuildSpec, CommandsSpec, ProgramSpec
|
||||
|
||||
|
||||
class AdoptError(ValueError):
|
||||
"""A program can't be adopted (bad path, or the name already exists)."""
|
||||
|
||||
|
||||
def is_git_url(s: str) -> bool:
|
||||
return s.startswith(("http://", "https://", "git@", "ssh://")) or s.endswith(".git")
|
||||
|
||||
|
||||
def looks_like_program(src: Path) -> bool:
|
||||
"""Whether a directory holds something castle can adopt (a project manifest or
|
||||
a git repo). Used to flag candidates in the filesystem browser."""
|
||||
return (
|
||||
(src / ".git").exists()
|
||||
or (src / "pyproject.toml").exists()
|
||||
or (src / "Cargo.toml").exists()
|
||||
or (src / "package.json").exists()
|
||||
or (src / "Makefile").exists()
|
||||
or (src / "makefile").exists()
|
||||
)
|
||||
|
||||
|
||||
def detect_stack_commands(src: Path) -> tuple[str | None, dict[str, list[list[str]]]]:
|
||||
"""Detect (stack, commands) for a source dir.
|
||||
|
||||
Returns a stack name when the project fits a known one (so it inherits those
|
||||
defaults), otherwise an explicit commands map. Adoption takes source only; the
|
||||
deployment (and thus kind) is declared separately, so no kind is inferred here.
|
||||
"""
|
||||
commands: dict[str, list[list[str]]] = {}
|
||||
|
||||
pyproject = src / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
try:
|
||||
data = tomllib.loads(pyproject.read_text())
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
data = {}
|
||||
deps = " ".join(data.get("project", {}).get("dependencies", []))
|
||||
stack = "python-fastapi" if ("fastapi" in deps or "uvicorn" in deps) else "python-cli"
|
||||
return stack, commands
|
||||
|
||||
if (src / "Cargo.toml").exists():
|
||||
commands = {
|
||||
"build": [["cargo", "build", "--release"]],
|
||||
"test": [["cargo", "test"]],
|
||||
"lint": [["cargo", "clippy"]],
|
||||
"run": [["cargo", "run"]],
|
||||
}
|
||||
return None, commands
|
||||
|
||||
if (src / "package.json").exists():
|
||||
commands = {
|
||||
"build": [["pnpm", "build"]],
|
||||
"test": [["pnpm", "test"]],
|
||||
"lint": [["pnpm", "lint"]],
|
||||
}
|
||||
return None, commands
|
||||
|
||||
if (src / "Makefile").exists() or (src / "makefile").exists():
|
||||
commands = {"build": [["make"]], "test": [["make", "test"]]}
|
||||
return None, commands
|
||||
|
||||
return None, commands
|
||||
|
||||
|
||||
@dataclass
|
||||
class Adopted:
|
||||
"""The result of building a program spec from an adopt target — the caller
|
||||
persists it (``config.programs[name] = spec``; then save/write)."""
|
||||
|
||||
name: str
|
||||
spec: ProgramSpec
|
||||
source: str
|
||||
stack: str | None = None
|
||||
repo: str | None = None
|
||||
commands: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_adopted_program(
|
||||
config: CastleConfig,
|
||||
target: str,
|
||||
name: str | None = None,
|
||||
description: str = "",
|
||||
) -> Adopted:
|
||||
"""Build (but do not save) a ProgramSpec adopting ``target``.
|
||||
|
||||
``target`` is a local path or a git URL. Raises :class:`AdoptError` if a local
|
||||
path doesn't exist or the resolved name already exists in the config. Does not
|
||||
mutate ``config`` — the caller assigns ``config.programs[name]`` and persists.
|
||||
"""
|
||||
repo_url: str | None = None
|
||||
|
||||
if is_git_url(target):
|
||||
repo_url = target
|
||||
name = name or Path(target.rstrip("/")).name.removesuffix(".git")
|
||||
# Default local clone location; cloned later via `castle clone`.
|
||||
source = str(config.repos_dir / name)
|
||||
src_path = Path(source)
|
||||
else:
|
||||
src_path = Path(target).expanduser().resolve()
|
||||
if not src_path.exists():
|
||||
raise AdoptError(f"path does not exist: {src_path}")
|
||||
source = str(src_path)
|
||||
name = name or src_path.name
|
||||
|
||||
if name in config.programs or config.deployments_named(name):
|
||||
raise AdoptError(f"'{name}' already exists in castle.yaml")
|
||||
|
||||
# Detect verbs from the working copy if we have one on disk. `kind` is derived
|
||||
# from a deployment, not stored on the program — so adoption takes source only;
|
||||
# declare a deployment separately (castle service/job create).
|
||||
stack: str | None = None
|
||||
detected: dict[str, list[list[str]]] = {}
|
||||
if src_path.exists():
|
||||
stack, detected = detect_stack_commands(src_path)
|
||||
|
||||
spec = ProgramSpec(
|
||||
id=name,
|
||||
description=description or f"Adopted from {target}",
|
||||
source=source,
|
||||
stack=stack,
|
||||
repo=repo_url,
|
||||
)
|
||||
# `build` is declared via BuildSpec; every other verb via CommandsSpec.
|
||||
if detected:
|
||||
build_cmds = detected.pop("build", None)
|
||||
if build_cmds:
|
||||
spec.build = BuildSpec(commands=build_cmds)
|
||||
if detected:
|
||||
spec.commands = CommandsSpec.model_validate(detected)
|
||||
|
||||
return Adopted(
|
||||
name=name,
|
||||
spec=spec,
|
||||
source=source,
|
||||
stack=stack,
|
||||
repo=repo_url,
|
||||
commands=sorted(detected),
|
||||
)
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,13 +87,26 @@ class ApplyResult:
|
||||
pruned: list[str] = field(default_factory=list)
|
||||
messages: list[str] = field(default_factory=list)
|
||||
registry: NodeRegistry | None = None
|
||||
# Gateway routing (the Caddyfile / cloudflared ingress) differs from what's live:
|
||||
# a caddy static/proxy route was added, removed, or had its root/reach changed.
|
||||
# Tracked separately because such a delta touches no systemd unit, so the
|
||||
# activate/restart/deactivate reconcile never classifies it as a change. apply()
|
||||
# rewrites the artifacts and reloads the gateway regardless; this flag just lets
|
||||
# the summary report it instead of a false "already converged".
|
||||
gateway_changed: bool = False
|
||||
# True for a `--plan` run: the diff was computed but nothing was written or
|
||||
# activated. Lets callers render "would activate…" vs "activated…".
|
||||
planned: bool = False
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.activated or self.restarted or self.deactivated or self.pruned)
|
||||
return bool(
|
||||
self.activated
|
||||
or self.restarted
|
||||
or self.deactivated
|
||||
or self.pruned
|
||||
or self.gateway_changed
|
||||
)
|
||||
|
||||
|
||||
def deploy(target_name: str | None = None, root: Path | None = None) -> DeployResult:
|
||||
@@ -205,6 +219,48 @@ def _unit_bytes(name: str, kind: str) -> str | None:
|
||||
return path.read_text() if path.exists() else None
|
||||
|
||||
|
||||
def _desired_registry(config: CastleConfig, target_name: str | None) -> NodeRegistry:
|
||||
"""The registry ``deploy()`` would write for this (optionally scoped) run.
|
||||
|
||||
Mirrors deploy()'s registry build: a scoped run merges the updated target over
|
||||
the existing on-disk registry; a full run starts fresh. Used to predict
|
||||
gateway-route deltas without writing anything."""
|
||||
node = _node_config(config)
|
||||
if target_name and REGISTRY_PATH.exists():
|
||||
try:
|
||||
registry = NodeRegistry(node=node, deployed=dict(load_registry().deployed))
|
||||
except (FileNotFoundError, ValueError):
|
||||
registry = NodeRegistry(node=node)
|
||||
else:
|
||||
registry = NodeRegistry(node=node)
|
||||
for _kind, name, dep in config.all_deployments():
|
||||
if target_name and name != target_name:
|
||||
continue
|
||||
deployed = _build_deployed(config, name, dep, [])
|
||||
deployed.name = name
|
||||
registry.put(deployed)
|
||||
return registry
|
||||
|
||||
|
||||
def _gateway_would_change(config: CastleConfig, target_name: str | None) -> bool:
|
||||
"""Whether applying would rewrite the gateway's routing artifacts — the
|
||||
Caddyfile or the cloudflared ingress — vs. what's on disk.
|
||||
|
||||
A pure caddy route change (new static, changed ``root``/``reach``, toggled
|
||||
public) touches no systemd unit, so the activate/restart/deactivate reconcile
|
||||
can't see it; without this the summary reports "already converged" despite a
|
||||
live routing change. Compared before ``deploy()`` rewrites the artifacts, so it
|
||||
reflects the pre-apply delta for both the plan and the real run."""
|
||||
registry = _desired_registry(config, target_name)
|
||||
caddyfile = SPECS_DIR / "Caddyfile"
|
||||
current_caddy = caddyfile.read_text() if caddyfile.exists() else None
|
||||
if generate_caddyfile_from_registry(registry) != current_caddy:
|
||||
return True
|
||||
tunnel_path = SPECS_DIR / "cloudflared.yml"
|
||||
current_tunnel = tunnel_path.read_text() if tunnel_path.exists() else None
|
||||
return generate_tunnel_config(registry) != current_tunnel
|
||||
|
||||
|
||||
def apply(
|
||||
target_name: str | None = None,
|
||||
root: Path | None = None,
|
||||
@@ -264,11 +320,17 @@ def apply(
|
||||
deployed={NodeRegistry.key(k, n): d for (k, n), d in desired.items()},
|
||||
)
|
||||
)
|
||||
# Gateway routing lives in the Caddyfile / cloudflared ingress, not a systemd
|
||||
# unit, so _classify above can't see a route-only change. Detect it here against
|
||||
# the on-disk artifacts (before deploy() rewrites them) so both the plan and the
|
||||
# real run report it instead of a false "already converged".
|
||||
result.gateway_changed = _gateway_would_change(config, target_name)
|
||||
|
||||
if plan:
|
||||
# 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))
|
||||
@@ -279,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,
|
||||
@@ -312,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,
|
||||
@@ -391,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)
|
||||
@@ -399,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} "
|
||||
@@ -652,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,
|
||||
@@ -785,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,
|
||||
|
||||
@@ -40,6 +40,10 @@ class GatewayRoute:
|
||||
target: str # static: serve dir; proxy: "localhost:PORT"/base_url; remote: "host:PORT"
|
||||
name: str | None = None # backing program/service
|
||||
node: str | None = None
|
||||
public: bool = False # also served under public_domain
|
||||
# Optional exact public-facing FQDN override (apex allowed). When set, the
|
||||
# public name is this instead of the derived <address>.<public_domain>.
|
||||
public_host: str | None = None
|
||||
|
||||
@property
|
||||
def is_host(self) -> bool:
|
||||
@@ -66,15 +70,15 @@ def service_proxy_targets(name: str, dep: SystemdDeployment) -> ProxyTargets:
|
||||
|
||||
def _local_routes(
|
||||
config: CastleConfig | None, registry: NodeRegistry
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Each local deployment's route as ``(name, kind, target)``, name-sorted.
|
||||
) -> list[tuple[str, str, str, bool, str | None]]:
|
||||
"""Each local deployment's route as ``(name, kind, target, public, public_host)``.
|
||||
|
||||
``kind`` is ``static`` (a caddy deployment — file-serve a built dir) or
|
||||
``proxy`` (a proxied systemd process). Prefers ``castle.yaml``
|
||||
(``config.deployments``) so a regenerated Caddyfile reflects the current spec;
|
||||
falls back to the deployed registry snapshot when config isn't available.
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
out: list[tuple[str, str, str, bool, str | None]] = []
|
||||
if config is not None:
|
||||
# Only HTTP-exposed kinds route: static (file-serve) and service (proxy).
|
||||
# jobs/tools/references never do.
|
||||
@@ -86,20 +90,22 @@ def _local_routes(
|
||||
if kind == "static" and isinstance(dep, CaddyDeployment):
|
||||
src = _program_source(config, dep.program)
|
||||
if src is not None:
|
||||
out.append((name, "static", str(src / dep.root)))
|
||||
pub_host = dep.public_host if dep.public else None
|
||||
out.append((name, "static", str(src / dep.root), bool(dep.public), pub_host))
|
||||
elif kind == "service" and isinstance(dep, SystemdDeployment):
|
||||
expose, port, base_url = service_proxy_targets(name, dep)
|
||||
if expose and (port or base_url):
|
||||
out.append((name, "proxy", base_url or f"localhost:{port}"))
|
||||
pub_host = dep.public_host if dep.public else None
|
||||
out.append((name, "proxy", base_url or f"localhost:{port}", bool(dep.public), pub_host))
|
||||
return out
|
||||
# No config → route from the deployed registry snapshot.
|
||||
for _kind, name, d in registry.all():
|
||||
if not d.enabled:
|
||||
continue
|
||||
if d.static_root:
|
||||
out.append((name, "static", d.static_root))
|
||||
out.append((name, "static", d.static_root, d.public, d.public_host))
|
||||
elif d.subdomain and (d.port or d.base_url):
|
||||
out.append((name, "proxy", d.base_url or f"localhost:{d.port}"))
|
||||
out.append((name, "proxy", d.base_url or f"localhost:{d.port}", d.public, d.public_host))
|
||||
return out
|
||||
|
||||
|
||||
@@ -140,8 +146,8 @@ def compute_routes(
|
||||
# built dir; everything else that's exposed reverse-proxies its port/base_url.
|
||||
# (Static frontends are `runner: static` services now — no separate program
|
||||
# branch, so routing derives from one place.)
|
||||
for name, kind, target in _local_routes(config, registry):
|
||||
routes.append(GatewayRoute(name, kind, target, name, node))
|
||||
for name, kind, target, is_public, pub_host in _local_routes(config, registry):
|
||||
routes.append(GatewayRoute(name, kind, target, name, node, is_public, pub_host))
|
||||
|
||||
if remote_registries:
|
||||
routes.extend(_remote_routes(config, registry, remote_registries))
|
||||
@@ -237,6 +243,34 @@ def _host_static_block(label: str, host: str, serve_dir: str) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def _public_site_block(host: str, kind: str, target: str) -> list[str]:
|
||||
"""A standalone Caddy site for a custom ``public_host`` (apex or another zone).
|
||||
|
||||
Unlike the ``*.<public_domain>`` wildcard site, an apex/foreign host isn't
|
||||
covered by a wildcard cert, so it gets its own site. Caddy issues that exact
|
||||
host's cert via the global ``acme_dns`` (DNS-01) — provided the gateway's
|
||||
Cloudflare token can edit the host's zone."""
|
||||
if kind == "static":
|
||||
body = [
|
||||
f" root * {target}",
|
||||
" try_files {path} /index.html",
|
||||
" file_server",
|
||||
]
|
||||
elif kind == "remote":
|
||||
body = [
|
||||
f" reverse_proxy {target} {{",
|
||||
" lb_try_duration 1s",
|
||||
" fail_duration 30s",
|
||||
" transport http {",
|
||||
" dial_timeout 2s",
|
||||
" }",
|
||||
" }",
|
||||
]
|
||||
else:
|
||||
body = [f" reverse_proxy {target}"]
|
||||
return [f"{host} {{", *body, "}", ""]
|
||||
|
||||
|
||||
# Castle's own control plane: the dashboard frontend and the API it calls. These
|
||||
# names are the subdomains they're published at in acme mode, and the pair served
|
||||
# on the :<port> site in off mode (no domain → no subdomains).
|
||||
@@ -307,6 +341,34 @@ def generate_caddyfile_from_registry(
|
||||
lines += _host_matcher_block(r.name or r.address, host, r.target)
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
# Public exposure: public deployments are also reachable by their public
|
||||
# name so LAN clients can use it directly. Two shapes:
|
||||
# - default → <address>.<public_domain>, all served by one wildcard site
|
||||
# (*.<public_domain>, its own wildcard cert), when a node-wide
|
||||
# public_domain distinct from the internal zone is configured.
|
||||
# - override → an exact `public_host` (an apex or a name in another zone),
|
||||
# which a wildcard can't cover, so each gets a standalone site with its
|
||||
# own cert (DNS-01 via the global acme_dns).
|
||||
public_domain = node.public_domain
|
||||
public_routes = [r for r in routes if r.public]
|
||||
default_pub = [r for r in public_routes if not r.public_host]
|
||||
custom_pub = [r for r in public_routes if r.public_host]
|
||||
if public_domain and public_domain != domain and default_pub:
|
||||
lines.append(f"*.{public_domain} {{")
|
||||
for r in default_pub:
|
||||
host = f"{r.address}.{public_domain}"
|
||||
label = f"{r.name or r.address}_pub"
|
||||
if r.kind == "static":
|
||||
lines += _host_static_block(label, host, r.target)
|
||||
elif r.kind == "remote":
|
||||
lines += _host_remote_block(label, host, r.target)
|
||||
else:
|
||||
lines += _host_matcher_block(label, host, r.target)
|
||||
lines.append("}")
|
||||
lines.append("")
|
||||
for r in custom_pub:
|
||||
if r.public_host: # always true (custom_pub filter); narrows the type
|
||||
lines += _public_site_block(r.public_host, r.kind, r.target)
|
||||
# Redirect the bare gateway port to the dashboard subdomain.
|
||||
lines += [
|
||||
f":{gw_port} {{",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
"""Reconcile public DNS (Cloudflare CNAMEs) for tunnel-exposed services.
|
||||
|
||||
Castle owns the CNAMEs in the public zone that point at its Cloudflare tunnel:
|
||||
on deploy it creates one per public service and deletes any that point at this
|
||||
tunnel but no longer correspond to a public service. It **only ever touches
|
||||
records whose content is `<tunnel_id>.cfargotunnel.com`** — never other records in
|
||||
the zone — so a hand-managed A/CNAME in the same zone is safe.
|
||||
Castle owns the CNAMEs — across every zone the token can see — that point at its
|
||||
Cloudflare tunnel: on deploy it creates one per public host (each routed to the
|
||||
accessible zone whose name is its longest suffix, so apex and multi-zone hosts both
|
||||
work) and deletes any that point at this tunnel but no longer correspond to a
|
||||
public service. It **only ever touches records whose content is
|
||||
`<tunnel_id>.cfargotunnel.com`** — never other records in a zone — so a
|
||||
hand-managed A/CNAME in the same zone is safe.
|
||||
|
||||
Needs a Cloudflare API token with **DNS:Edit** on the public zone (Cloudflare's
|
||||
"Edit zone DNS" template — that single permission both resolves the zone by name
|
||||
Needs a Cloudflare API token with **DNS:Edit** on every target zone (Cloudflare's
|
||||
"Edit zone DNS" template — that single permission both lists the accessible zones
|
||||
and edits records; no separate Zone:Read is needed), stored at
|
||||
`~/.castle/secrets/CLOUDFLARE_PUBLIC_DNS_TOKEN`. Absent → this is a no-op and the
|
||||
caller falls back to surfacing the manual `cloudflared tunnel route dns` hints.
|
||||
@@ -43,65 +45,100 @@ def _api(token: str, method: str, path: str, body: dict | None = None) -> dict:
|
||||
return json.loads(resp.read())
|
||||
|
||||
|
||||
def _zone_for(host: str, zones: list[dict]) -> dict | None:
|
||||
"""The visible zone whose name is the longest suffix of ``host`` (or None).
|
||||
|
||||
Longest-suffix so an apex (``example.com`` in zone ``example.com``) and a
|
||||
subdomain in any accessible zone both resolve, even when zones nest.
|
||||
"""
|
||||
matches = [
|
||||
z
|
||||
for z in zones
|
||||
if host == z["name"] or host.endswith("." + z["name"])
|
||||
]
|
||||
return max(matches, key=lambda z: len(z["name"])) if matches else None
|
||||
|
||||
|
||||
def reconcile_public_dns(
|
||||
public_domain: str | None,
|
||||
tunnel_id: str | None,
|
||||
desired_hosts: list[str],
|
||||
messages: list[str],
|
||||
token: str | None = None,
|
||||
) -> bool:
|
||||
"""Make the public zone's tunnel CNAMEs exactly `desired_hosts`.
|
||||
"""Make the tunnel CNAMEs across every accessible zone exactly `desired_hosts`.
|
||||
|
||||
Creates missing CNAMEs (proxied → the tunnel) and deletes castle-managed ones
|
||||
(content == `<tunnel_id>.cfargotunnel.com`) not in `desired_hosts`. Never
|
||||
touches records pointing elsewhere.
|
||||
Each desired host is routed to the accessible zone whose name is its longest
|
||||
suffix (so apex hosts and hosts in different zones are handled), then per zone
|
||||
castle creates missing CNAMEs (proxied → the tunnel; Cloudflare flattens apex
|
||||
CNAMEs) and deletes castle-managed ones (content == `<tunnel_id>.cfargotunnel.com`)
|
||||
no longer desired. Never touches records pointing elsewhere. Scanning every
|
||||
visible zone also cleans up stale CNAMEs after a host moves zones or all public
|
||||
services are removed.
|
||||
|
||||
Returns True if reconciliation was attempted (a token was configured) — the
|
||||
caller then suppresses the manual route hints — or False if skipped (no token /
|
||||
no tunnel / no public domain), so the caller can fall back to those hints.
|
||||
no tunnel), so the caller can fall back to those hints.
|
||||
"""
|
||||
token = token or public_dns_token()
|
||||
if not (token and public_domain and tunnel_id):
|
||||
if not (token and tunnel_id):
|
||||
return False
|
||||
target = f"{tunnel_id}.cfargotunnel.com"
|
||||
try:
|
||||
zres = (_api(token, "GET", f"/zones?name={public_domain}").get("result")) or []
|
||||
if not zres:
|
||||
zones = (_api(token, "GET", "/zones?per_page=50").get("result")) or []
|
||||
if not zones:
|
||||
messages.append(
|
||||
f"Warning: DNS token can't see zone '{public_domain}' — public "
|
||||
"CNAMEs not reconciled. The token needs DNS:Edit (Cloudflare's "
|
||||
f"'Edit zone DNS' template) scoped to {public_domain}."
|
||||
"Warning: DNS token can't see any zone — public CNAMEs not "
|
||||
"reconciled. The token needs DNS:Edit (Cloudflare's 'Edit zone "
|
||||
"DNS' template) on the target zone(s)."
|
||||
)
|
||||
return False
|
||||
zone_id = zres[0]["id"]
|
||||
# Castle-managed set = existing CNAMEs whose content is our tunnel.
|
||||
return True
|
||||
|
||||
# Route each desired host to its zone (longest-suffix match). Hosts with no
|
||||
# accessible zone can't be created — surface them rather than silently drop.
|
||||
desired_by_zone: dict[str, set[str]] = {z["id"]: set() for z in zones}
|
||||
for host in desired_hosts:
|
||||
z = _zone_for(host, zones)
|
||||
if z is None:
|
||||
messages.append(
|
||||
f"Warning: no accessible Cloudflare zone for public host "
|
||||
f"'{host}' — its CNAME was not created. The DNS token needs "
|
||||
f"DNS:Edit on that host's zone."
|
||||
)
|
||||
continue
|
||||
desired_by_zone[z["id"]].add(host)
|
||||
|
||||
created: list[str] = []
|
||||
removed: list[str] = []
|
||||
# Reconcile every visible zone (not just those with desired hosts) so a
|
||||
# CNAME orphaned by a host moving zones / going internal is cleaned up.
|
||||
for z in zones:
|
||||
zone_id = z["id"]
|
||||
recs = _api(
|
||||
token, "GET", f"/zones/{zone_id}/dns_records?type=CNAME&per_page=100"
|
||||
).get("result") or []
|
||||
managed = {r["name"]: r["id"] for r in recs if r.get("content") == target}
|
||||
|
||||
desired = set(desired_hosts)
|
||||
created = sorted(desired - set(managed))
|
||||
removed = sorted(set(managed) - desired)
|
||||
for host in created:
|
||||
desired = desired_by_zone[zone_id]
|
||||
for host in sorted(desired - set(managed)):
|
||||
_api(
|
||||
token,
|
||||
"POST",
|
||||
f"/zones/{zone_id}/dns_records",
|
||||
{"type": "CNAME", "name": host, "content": target, "proxied": True},
|
||||
)
|
||||
for host in removed:
|
||||
created.append(host)
|
||||
for host in sorted(set(managed) - desired):
|
||||
_api(token, "DELETE", f"/zones/{zone_id}/dns_records/{managed[host]}")
|
||||
removed.append(host)
|
||||
|
||||
if created or removed:
|
||||
parts = []
|
||||
if created:
|
||||
parts.append(f"+{len(created)} ({', '.join(created)})")
|
||||
parts.append(f"+{len(created)} ({', '.join(sorted(created))})")
|
||||
if removed:
|
||||
parts.append(f"-{len(removed)} ({', '.join(removed)})")
|
||||
parts.append(f"-{len(removed)} ({', '.join(sorted(removed))})")
|
||||
messages.append(f"Public DNS reconciled: {' '.join(parts)}")
|
||||
else:
|
||||
messages.append(f"Public DNS up to date ({len(desired)} CNAME(s)).")
|
||||
messages.append(f"Public DNS up to date ({len(desired_hosts)} CNAME(s)).")
|
||||
return True
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")[:200]
|
||||
|
||||
@@ -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-<name>.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:
|
||||
|
||||
@@ -37,21 +37,42 @@ def tunnel_credentials_path(tunnel_id: str) -> Path:
|
||||
return TUNNEL_CREDENTIALS_DIR / f"{tunnel_id}.json"
|
||||
|
||||
|
||||
def public_fqdn(d: Deployment, node) -> str | None:
|
||||
"""The public-facing hostname for a deployment, or None if it has none.
|
||||
|
||||
A deployment may override its public name with an exact FQDN (``public_host`` —
|
||||
an apex like ``example.com`` or a name in another zone); otherwise it publishes
|
||||
at ``<subdomain>.<public_domain>`` using the node-wide default public domain.
|
||||
None when neither an override nor a default public domain is available.
|
||||
"""
|
||||
if d.public_host:
|
||||
return d.public_host
|
||||
if node.public_domain and d.subdomain:
|
||||
return f"{d.subdomain}.{node.public_domain}"
|
||||
return None
|
||||
|
||||
|
||||
def public_deployments(registry: NodeRegistry) -> list[tuple[str, Deployment]]:
|
||||
"""The deployed services flagged public (and actually routed), name-sorted."""
|
||||
return sorted(
|
||||
(
|
||||
(name, d)
|
||||
for _kind, name, d in registry.all()
|
||||
if d.public and d.subdomain
|
||||
),
|
||||
key=lambda nd: nd[0],
|
||||
)
|
||||
|
||||
|
||||
def public_hostnames(registry: NodeRegistry) -> list[str]:
|
||||
"""The public hostnames that need a DNS route (``<sub>.<public_domain>``)."""
|
||||
dom = registry.node.public_domain
|
||||
if not dom:
|
||||
return []
|
||||
return [f"{d.subdomain}.{dom}" for _, d in public_deployments(registry)]
|
||||
"""The public hostnames that need a DNS route.
|
||||
|
||||
Each is either a per-deployment ``public_host`` override or the default
|
||||
``<sub>.<public_domain>``; deployments with neither are skipped.
|
||||
"""
|
||||
node = registry.node
|
||||
hosts = [public_fqdn(d, node) for _, d in public_deployments(registry)]
|
||||
return [h for h in hosts if h]
|
||||
|
||||
|
||||
def generate_tunnel_config(registry: NodeRegistry) -> str | None:
|
||||
@@ -62,7 +83,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
|
||||
removes any stale config and leaves the tunnel down.
|
||||
"""
|
||||
node = registry.node
|
||||
if not (node.tunnel_id and node.public_domain and node.gateway_domain):
|
||||
# A public deployment needs a tunnel + an internal host to bridge to; the
|
||||
# node-wide public_domain is only the *default* public name, so it isn't
|
||||
# required (a deployment may carry its own public_host override instead).
|
||||
if not (node.tunnel_id and node.gateway_domain):
|
||||
return None
|
||||
pubs = public_deployments(registry)
|
||||
if not pubs:
|
||||
@@ -70,7 +94,10 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
|
||||
|
||||
ingress: list[dict] = []
|
||||
for _name, d in pubs:
|
||||
public_host = f"{d.subdomain}.{node.public_domain}"
|
||||
public_host = public_fqdn(d, node)
|
||||
if not public_host:
|
||||
# public but no override and no default public domain — nothing to map.
|
||||
continue
|
||||
internal_host = f"{d.subdomain}.{node.gateway_domain}"
|
||||
ingress.append(
|
||||
{
|
||||
@@ -84,6 +111,9 @@ def generate_tunnel_config(registry: NodeRegistry) -> str | None:
|
||||
},
|
||||
}
|
||||
)
|
||||
if not ingress:
|
||||
# Every public deployment was skipped (no override, no default domain).
|
||||
return None
|
||||
# Cloudflared requires a terminal catch-all; anything unmapped is refused.
|
||||
ingress.append({"service": "http_status:404"})
|
||||
|
||||
|
||||
@@ -33,6 +33,28 @@ class Reach(str, Enum):
|
||||
PUBLIC = "public"
|
||||
|
||||
|
||||
def _validate_public_host(host: str | None, reach: Reach) -> None:
|
||||
"""Validate an optional ``public_host`` override on an exposable deployment.
|
||||
|
||||
A ``public_host`` only makes sense for a publicly-projected deployment, and
|
||||
must be a bare hostname (no scheme, path, port, or whitespace) — it becomes a
|
||||
tunnel ingress ``hostname`` and a Caddy site address verbatim.
|
||||
"""
|
||||
if host is None:
|
||||
return
|
||||
if reach != Reach.PUBLIC:
|
||||
raise ValueError(
|
||||
f"public_host is only valid with reach: public (got reach: {reach.value})"
|
||||
)
|
||||
bad = any(c.isspace() for c in host) or any(
|
||||
tok in host for tok in ("://", "/", ":")
|
||||
)
|
||||
if not host or host != host.strip(".") or bad:
|
||||
raise ValueError(
|
||||
f"public_host must be a bare hostname (e.g. example.com), got {host!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Launch specs — how systemd starts a process (discriminated union on `launcher`)
|
||||
# ---------------------
|
||||
@@ -263,13 +285,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
|
||||
|
||||
@@ -419,6 +442,11 @@ class SystemdDeployment(DeploymentBase):
|
||||
expose: ExposeSpec | None = None
|
||||
# How far this process is exposed (off | internal | public). See `Reach`.
|
||||
reach: Reach = Reach.OFF
|
||||
# Optional public hostname override (an exact FQDN, e.g. `api.example.com` or an
|
||||
# apex `example.com`). Only meaningful with `reach: public`; when set it is the
|
||||
# public-facing name instead of the derived `<name>.<gateway.public_domain>`.
|
||||
# Unset → the node-wide public domain is the default. See docs/tunnel-setup.md.
|
||||
public_host: str | None = None
|
||||
manage: ManageSpec | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
@@ -447,6 +475,7 @@ class SystemdDeployment(DeploymentBase):
|
||||
"reach: public for a raw-TCP service isn't supported yet "
|
||||
"(see docs/tcp-exposure.md step 5); use reach: internal"
|
||||
)
|
||||
_validate_public_host(self.public_host, self.reach)
|
||||
return self
|
||||
|
||||
# Derived, read-only back-compat accessors (not serialized) so existing
|
||||
@@ -487,11 +516,15 @@ class CaddyDeployment(DeploymentBase):
|
||||
# A static site is inherently served at its subdomain, so `reach` is
|
||||
# `internal` or `public` (never `off`). `public` = also project via the tunnel.
|
||||
reach: Reach = Reach.INTERNAL
|
||||
# Optional public hostname override (exact FQDN, apex allowed). Only meaningful
|
||||
# with `reach: public`; see SystemdDeployment.public_host.
|
||||
public_host: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_reach(self) -> CaddyDeployment:
|
||||
if self.reach == Reach.OFF:
|
||||
raise ValueError("a static (caddy) deployment is always served; reach must be internal|public")
|
||||
_validate_public_host(self.public_host, self.reach)
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -506,6 +539,11 @@ class PathDeployment(DeploymentBase):
|
||||
"""
|
||||
|
||||
manager: Literal["path"]
|
||||
# An Anthropic Messages-API tool definition ({name, description, input_schema})
|
||||
# for handing this CLI to an agent. Derived from the tool's ``--help`` (see
|
||||
# ``castle_core.tool_schema``) but editable/overridable — a stored draft the
|
||||
# completion-context builder reads. None → not yet generated.
|
||||
tool_schema: dict | None = None
|
||||
|
||||
|
||||
class RemoteDeployment(DeploymentBase):
|
||||
|
||||
@@ -83,6 +83,9 @@ class Deployment:
|
||||
# Also projected to the public internet via the tunnel at
|
||||
# <subdomain>.<gateway.public_domain>. Requires subdomain.
|
||||
public: bool = False
|
||||
# Optional public hostname override (exact FQDN, apex allowed). When set it is
|
||||
# the public-facing name instead of the derived <subdomain>.<public_domain>.
|
||||
public_host: str | None = None
|
||||
# Raw-TCP exposure port (postgres, redis, …). Set → reachable at
|
||||
# <name>.<gateway.domain>:<tcp_port> via bind + wildcard DNS (no Caddy route).
|
||||
tcp_port: int | None = None
|
||||
@@ -186,6 +189,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
health_path=comp_data.get("health_path"),
|
||||
subdomain=comp_data.get("subdomain"),
|
||||
public=comp_data.get("public", False),
|
||||
public_host=comp_data.get("public_host"),
|
||||
tcp_port=comp_data.get("tcp_port"),
|
||||
static_root=comp_data.get("static_root"),
|
||||
base_url=comp_data.get("base_url"),
|
||||
@@ -263,6 +267,8 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
entry["subdomain"] = comp.subdomain
|
||||
if comp.public:
|
||||
entry["public"] = comp.public
|
||||
if comp.public_host:
|
||||
entry["public_host"] = comp.public_host
|
||||
if comp.tcp_port is not None:
|
||||
entry["tcp_port"] = comp.tcp_port
|
||||
if comp.static_root:
|
||||
|
||||
@@ -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 []
|
||||
|
||||
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 = ""
|
||||
|
||||
|
||||
@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.
|
||||
|
||||
@@ -111,6 +135,19 @@ class StackHandler:
|
||||
# stacks whose `teardown` actually destroys something.
|
||||
owns_data: bool = False
|
||||
|
||||
# The dev verbs this stack advertises — what `available_actions` offers and
|
||||
# `run_action` will dispatch. Defaults to every stack verb (the python/react/
|
||||
# supabase handlers implement them all); a narrow stack like hugo (build-only,
|
||||
# no native lint/test/type-check) overrides this so callers aren't offered verbs
|
||||
# that would only error. `check` composes the sub-verbs, so a handler that drops
|
||||
# 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
|
||||
|
||||
@@ -170,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)
|
||||
@@ -278,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 /
|
||||
@@ -374,6 +438,67 @@ class ReactViteHandler(StackHandler):
|
||||
)
|
||||
|
||||
|
||||
class HugoHandler(StackHandler):
|
||||
"""Handler for the hugo stack — a static site built by the Hugo generator.
|
||||
|
||||
Hugo has one real dev verb: **build** (`hugo --gc --minify` → `public/`), which
|
||||
a `caddy` deployment then serves in place at `<name>.<gateway.domain>`. There is
|
||||
no native lint/test/type-check, so `provides` is narrowed to the verbs that do
|
||||
something. A theme with an asset pipeline (e.g. Blowfish + Tailwind) declares its
|
||||
own two-step `build.commands` (`pnpm build` then `hugo …`), which override this
|
||||
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)
|
||||
rc, output = await _run(["hugo", "--gc", "--minify"], src)
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="build",
|
||||
status="ok" if rc == 0 else "error",
|
||||
output=output,
|
||||
)
|
||||
|
||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Build the site in place. The gateway serves it directly from
|
||||
<source>/<build.outputs[0]> (default `public/`) — no copy step."""
|
||||
result = await self.build(name, comp, root)
|
||||
if result.status != "ok":
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="install",
|
||||
status="error",
|
||||
output=f"Build failed:\n{result.output}",
|
||||
)
|
||||
outputs = comp.build.outputs if comp.build else []
|
||||
dist = _source_dir(comp, root) / (outputs[0] if outputs else "public")
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="install",
|
||||
status="ok",
|
||||
output=f"Built; served in place from {dist}",
|
||||
)
|
||||
|
||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||
"""Static sites have no install footprint — served in place. Deactivating one
|
||||
means dropping its gateway route, not deleting build output."""
|
||||
return ActionResult(
|
||||
program=name,
|
||||
action="uninstall",
|
||||
status="ok",
|
||||
output=f"{name}: served in place; nothing to uninstall.",
|
||||
)
|
||||
|
||||
|
||||
def _migration_version(path: Path) -> str:
|
||||
"""The version key of a migration file — the leading token before '_'.
|
||||
|
||||
@@ -466,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)."""
|
||||
@@ -655,6 +794,7 @@ HANDLERS: dict[str, StackHandler] = {
|
||||
"python-fastapi": PythonHandler(),
|
||||
"react-vite": ReactViteHandler(),
|
||||
"supabase": SupabaseHandler(),
|
||||
"hugo": HugoHandler(),
|
||||
}
|
||||
|
||||
|
||||
@@ -672,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.
|
||||
|
||||
@@ -687,12 +835,12 @@ def _declared_commands(comp: ProgramSpec, verb: str) -> list[list[str]] | None:
|
||||
|
||||
|
||||
def _stack_provides(comp: ProgramSpec, verb: str) -> bool:
|
||||
"""Whether the program's stack handler can run this verb."""
|
||||
return (
|
||||
bool(comp.source)
|
||||
and verb in _STACK_VERBS
|
||||
and get_handler(comp.stack) is not None
|
||||
)
|
||||
"""Whether the program's stack handler advertises this verb.
|
||||
|
||||
Consults the handler's ``provides`` set (default: all of ``_STACK_VERBS``) so a
|
||||
build-only stack like hugo doesn't offer lint/test/type-check it can't run."""
|
||||
handler = get_handler(comp.stack)
|
||||
return bool(comp.source) and handler is not None and verb in handler.provides
|
||||
|
||||
|
||||
def is_available(comp: ProgramSpec, verb: str) -> bool:
|
||||
|
||||
393
core/src/castle_core/tool_schema.py
Normal file
393
core/src/castle_core/tool_schema.py
Normal file
@@ -0,0 +1,393 @@
|
||||
"""Derive LLM tool-call schemas from a CLI tool's ``--help``.
|
||||
|
||||
castle's in-process version of the standalone ``toolify`` tool: given a tool (a
|
||||
program with a ``path`` deployment), resolve its executable, run ``--help``, and
|
||||
build a tool-call definition.
|
||||
|
||||
Two parameter shapes, chosen automatically:
|
||||
|
||||
* **structured** — one typed property per option/positional, parsed from a
|
||||
standard argparse/click ``--help``, each carrying an ``x-cli`` executor hint.
|
||||
Used when the help is recognizable *and* the tool has no subcommands.
|
||||
* **command** — a single ``command`` string, full ``--help`` as description. The
|
||||
universal fallback for non-standard help (``jq``) and subcommand trees.
|
||||
|
||||
Schemas are built and stored in a **neutral** core (``{name, description,
|
||||
parameters}``); ``render_tool_schema`` wraps that in the Anthropic or OpenAI
|
||||
envelope on read. castle's feed defaults to OpenAI (litellm-native).
|
||||
|
||||
The extraction is intentionally duplicated from ``toolify`` rather than shared —
|
||||
``toolify`` is a standalone program that must never depend on castle. No LLM is
|
||||
used; the output is a deterministic function of the tool's ``--help``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from castle_core.config import CastleConfig
|
||||
|
||||
__all__ = [
|
||||
"ToolSchemaError",
|
||||
"derive_tool_schema",
|
||||
"render_tool_schema",
|
||||
"tool_executable",
|
||||
]
|
||||
|
||||
_NAME_OK = re.compile(r"[^a-zA-Z0-9_-]")
|
||||
_HELP_TIMEOUT = 10
|
||||
_MAX_SUBCOMMANDS = 40
|
||||
_SKIP_OPTS = {"help", "version"}
|
||||
_CMD_HEADING = re.compile(
|
||||
r"^\s*(commands|subcommands|available commands)\s*:?\s*$", re.IGNORECASE
|
||||
)
|
||||
_OPT_HEADING = re.compile(r"^\s*(options|optional arguments)\s*:?\s*$", re.IGNORECASE)
|
||||
_POS_HEADING = re.compile(r"^\s*positional arguments\s*:?\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
class ToolSchemaError(Exception):
|
||||
"""The tool's executable couldn't be resolved or produced no help."""
|
||||
|
||||
|
||||
def _sanitize_name(raw: str) -> str:
|
||||
name = _NAME_OK.sub("_", raw).strip("_") or "tool"
|
||||
return name[:64]
|
||||
|
||||
|
||||
def _run_help(argv: list[str]) -> tuple[int | None, str]:
|
||||
"""Run ``argv + ['--help']`` → ``(returncode, help_text)``; never raises."""
|
||||
for flag in ("--help", "-h"):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[*argv, flag],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
stdin=subprocess.DEVNULL,
|
||||
timeout=_HELP_TIMEOUT,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
continue
|
||||
text = proc.stdout.strip() or proc.stderr.strip()
|
||||
if text:
|
||||
return proc.returncode, text
|
||||
return None, ""
|
||||
|
||||
|
||||
def _section_entries(help_text: str, heading_re: re.Pattern[str]) -> list[list[str]]:
|
||||
"""Entries (each a list of lines) under headings matching ``heading_re``. A
|
||||
row at the section's minimum indent starts an entry; deeper rows continue it."""
|
||||
out: list[list[str]] = []
|
||||
lines = help_text.splitlines()
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
if not heading_re.match(lines[i]):
|
||||
i += 1
|
||||
continue
|
||||
i += 1
|
||||
body: list[str] = []
|
||||
while i < len(lines) and (not lines[i].strip() or lines[i][:1].isspace()):
|
||||
if lines[i].strip():
|
||||
body.append(lines[i])
|
||||
i += 1
|
||||
if not body:
|
||||
continue
|
||||
term = min(len(ln) - len(ln.lstrip()) for ln in body)
|
||||
cur: list[str] = []
|
||||
for ln in body:
|
||||
if (len(ln) - len(ln.lstrip())) == term:
|
||||
if cur:
|
||||
out.append(cur)
|
||||
cur = [ln]
|
||||
else:
|
||||
cur.append(ln)
|
||||
if cur:
|
||||
out.append(cur)
|
||||
return out
|
||||
|
||||
|
||||
def _extract_subcommands(help_text: str) -> list[str]:
|
||||
"""Subcommand names, or [] for a flat tool. Only click ``Commands:`` entries
|
||||
and argparse ``{a,b,c}`` choice rows count — a plain positional does not."""
|
||||
cmds: list[str] = []
|
||||
for entry in _section_entries(help_text, _CMD_HEADING):
|
||||
word = re.match(r"^([A-Za-z][\w-]*)\b", entry[0].strip())
|
||||
if word:
|
||||
cmds.append(word.group(1))
|
||||
for entry in _section_entries(help_text, _POS_HEADING):
|
||||
choice = re.match(r"^\{([^}]+)\}", entry[0].strip())
|
||||
if choice:
|
||||
cmds.extend(p.strip() for p in choice.group(1).split(","))
|
||||
seen: list[str] = []
|
||||
for c in cmds:
|
||||
if c and c not in seen:
|
||||
seen.append(c)
|
||||
return seen
|
||||
|
||||
|
||||
def _entry_head_and_desc(entry: list[str]) -> tuple[str, str]:
|
||||
m = re.match(r"^\s*(.*?)(?:\s{2,}(.*))?$", entry[0])
|
||||
head = (m.group(1) if m else entry[0]).strip()
|
||||
desc_parts = [m.group(2)] if m and m.group(2) else []
|
||||
desc_parts += [ln.strip() for ln in entry[1:]]
|
||||
return head, " ".join(p for p in desc_parts if p).strip()
|
||||
|
||||
|
||||
def _parse_option(entry: list[str]) -> tuple[str, dict] | None:
|
||||
head, desc = _entry_head_and_desc(entry)
|
||||
flags: list[str] = []
|
||||
metavar: str | None = None
|
||||
for tok in head.split(", "):
|
||||
fm = re.match(r"^(-{1,2}[\w-]+)(?:[ =](.+))?$", tok.strip())
|
||||
if not fm:
|
||||
continue
|
||||
flags.append(fm.group(1))
|
||||
if fm.group(2):
|
||||
metavar = fm.group(2).strip()
|
||||
longs = [f for f in flags if f.startswith("--")]
|
||||
canonical = longs[-1] if longs else (flags[0] if flags else None)
|
||||
if not canonical:
|
||||
return None
|
||||
key = canonical.lstrip("-").replace("-", "_")
|
||||
if key in _SKIP_OPTS:
|
||||
return None
|
||||
prop: dict = {}
|
||||
if metavar and metavar.startswith("{") and metavar.endswith("}"):
|
||||
prop["type"] = "string"
|
||||
prop["enum"] = [v.strip() for v in metavar[1:-1].split(",")]
|
||||
elif metavar:
|
||||
prop["type"] = "string"
|
||||
else:
|
||||
prop["type"] = "boolean"
|
||||
if desc:
|
||||
prop["description"] = desc
|
||||
prop["x-cli"] = {"flag": canonical, "value": bool(metavar)}
|
||||
return key, prop
|
||||
|
||||
|
||||
def _parse_positional(entry: list[str], order: int) -> tuple[str, dict] | None:
|
||||
head, desc = _entry_head_and_desc(entry)
|
||||
if head.startswith("{"):
|
||||
return None
|
||||
nm = re.match(r"^([A-Za-z][\w-]*)", head)
|
||||
if not nm:
|
||||
return None
|
||||
key = nm.group(1).replace("-", "_")
|
||||
prop: dict = {"type": "string"}
|
||||
if desc:
|
||||
prop["description"] = desc
|
||||
prop["x-cli"] = {"positional": True, "order": order}
|
||||
return key, prop
|
||||
|
||||
|
||||
def _summary(help_text: str, fallback: str) -> str:
|
||||
for para in re.split(r"\n\s*\n", help_text):
|
||||
p = para.strip()
|
||||
if not p or p.lower().startswith("usage:") or p.rstrip().endswith(":"):
|
||||
continue
|
||||
return " ".join(p.split())
|
||||
return fallback
|
||||
|
||||
|
||||
def _structured_core(name: str, help_text: str) -> dict | None:
|
||||
props: dict = {}
|
||||
required: list[str] = []
|
||||
for order, entry in enumerate(_section_entries(help_text, _POS_HEADING)):
|
||||
parsed = _parse_positional(entry, order)
|
||||
if parsed:
|
||||
key, prop = parsed
|
||||
props[key] = prop
|
||||
required.append(key)
|
||||
for entry in _section_entries(help_text, _OPT_HEADING):
|
||||
parsed = _parse_option(entry)
|
||||
if parsed:
|
||||
key, prop = parsed
|
||||
props[key] = prop
|
||||
if not props:
|
||||
return None
|
||||
parameters: dict = {"type": "object", "properties": props}
|
||||
if required:
|
||||
parameters["required"] = required
|
||||
return {
|
||||
"name": _sanitize_name(name),
|
||||
"description": _summary(help_text, f"Run the `{name}` command-line tool."),
|
||||
"parameters": parameters,
|
||||
}
|
||||
|
||||
|
||||
def _command_core(name: str, argv: list[str], help_text: str, deep: bool) -> dict:
|
||||
parts = [
|
||||
f"Run the `{name}` command-line tool. Provide the arguments in the "
|
||||
f"`command` parameter; the executable name is prepended automatically. "
|
||||
f"Below is the tool's help output.",
|
||||
f"\n$ {name} --help\n{help_text}",
|
||||
]
|
||||
if deep:
|
||||
for sub in _extract_subcommands(help_text)[:_MAX_SUBCOMMANDS]:
|
||||
rc, sub_help = _run_help([*argv, sub])
|
||||
if rc == 0 and sub_help:
|
||||
parts.append(f"\n$ {name} {sub} --help\n{sub_help}")
|
||||
return {
|
||||
"name": _sanitize_name(name),
|
||||
"description": "\n".join(parts),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
f"Arguments to pass to `{name}`. The full command line run "
|
||||
f"is `{name} ` followed by this string. Do not include the "
|
||||
f"leading `{name}`."
|
||||
),
|
||||
}
|
||||
},
|
||||
"required": ["command"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def tool_executable(config: CastleConfig, name: str) -> str:
|
||||
"""The console script to invoke for tool ``name`` — its first
|
||||
``[project.scripts]`` key (source of truth even when uninstalled), else the
|
||||
program name. Mirrors the CLI's tools lens."""
|
||||
comp = config.programs.get(name)
|
||||
src = getattr(comp, "source", None) if comp else None
|
||||
if src:
|
||||
pyproject = Path(src) / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
try:
|
||||
data = tomllib.loads(pyproject.read_text())
|
||||
scripts = data.get("project", {}).get("scripts", {})
|
||||
if scripts:
|
||||
return sorted(scripts.keys())[0]
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
pass
|
||||
return name
|
||||
|
||||
|
||||
def collect_tool_help(config: CastleConfig, name: str) -> str:
|
||||
"""The full recursive ``--help`` text for tool ``name`` — top-level plus each
|
||||
subcommand's help, the raw material an LLM assist reads to structure a
|
||||
subcommand tree. Deterministic; raises ``ToolSchemaError`` if unresolved.
|
||||
"""
|
||||
exe_name = tool_executable(config, name)
|
||||
exe = shutil.which(exe_name)
|
||||
if exe is None:
|
||||
raise ToolSchemaError(
|
||||
f"`{exe_name}` is not on PATH — install the tool (enable it, then "
|
||||
f"`castle apply`) before generating its schema."
|
||||
)
|
||||
_, help_text = _run_help([exe])
|
||||
if not help_text:
|
||||
raise ToolSchemaError(f"`{exe_name} --help` produced no output.")
|
||||
parts = [f"$ {exe_name} --help\n{help_text}"]
|
||||
for sub in _extract_subcommands(help_text)[:_MAX_SUBCOMMANDS]:
|
||||
rc, sub_help = _run_help([exe, sub])
|
||||
if rc == 0 and sub_help:
|
||||
parts.append(f"\n$ {exe_name} {sub} --help\n{sub_help}")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def validate_tool_schema_core(core: object) -> list[str]:
|
||||
"""Deterministically validate a neutral tool-call core.
|
||||
|
||||
Returns a list of human-readable error strings (empty ⇒ valid). Checks the
|
||||
``{name, description, parameters}`` shape *and* that ``parameters`` is a valid
|
||||
JSON Schema (via ``jsonschema`` meta-validation, which catches malformed
|
||||
properties like an ``enum`` that isn't a list — the defect weak models hit).
|
||||
Shared by the LLM repair loop, the ``validate`` endpoint, and the accept gate.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
if not isinstance(core, dict):
|
||||
return ["schema must be a JSON object"]
|
||||
name = core.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
errors.append("`name` must be a non-empty string")
|
||||
elif not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", name):
|
||||
errors.append("`name` must match ^[a-zA-Z0-9_-]{1,64}$")
|
||||
if not isinstance(core.get("description"), str):
|
||||
errors.append("`description` must be a string")
|
||||
|
||||
params = core.get("parameters")
|
||||
if not isinstance(params, dict):
|
||||
errors.append("`parameters` must be an object")
|
||||
return errors
|
||||
if params.get("type") != "object":
|
||||
errors.append('`parameters.type` must be "object"')
|
||||
props = params.get("properties")
|
||||
if not isinstance(props, dict):
|
||||
errors.append("`parameters.properties` must be an object")
|
||||
elif not props:
|
||||
errors.append("`parameters.properties` is empty — no arguments captured")
|
||||
|
||||
try:
|
||||
import jsonschema
|
||||
from jsonschema.exceptions import SchemaError
|
||||
|
||||
try:
|
||||
jsonschema.Draft202012Validator.check_schema(params)
|
||||
except SchemaError as e:
|
||||
loc = "/".join(str(p) for p in e.absolute_path) or "parameters"
|
||||
errors.append(f"invalid JSON Schema at `{loc}`: {e.message}")
|
||||
except ImportError: # jsonschema not installed — structural checks stand alone
|
||||
pass
|
||||
return errors
|
||||
|
||||
|
||||
def is_tool_schema_core(obj: object) -> bool:
|
||||
"""True if ``obj`` is a valid neutral tool-call core (no validation errors)."""
|
||||
return not validate_tool_schema_core(obj)
|
||||
|
||||
|
||||
def derive_tool_schema(config: CastleConfig, name: str, deep: bool = False) -> dict:
|
||||
"""Derive the neutral tool-call core for tool ``name`` from its ``--help``.
|
||||
|
||||
Structured params when the help is standard and flat; the command-string
|
||||
fallback for non-standard help / subcommand trees. Raises ``ToolSchemaError``
|
||||
if the executable isn't on PATH or emits no help.
|
||||
"""
|
||||
exe_name = tool_executable(config, name)
|
||||
exe = shutil.which(exe_name)
|
||||
if exe is None:
|
||||
raise ToolSchemaError(
|
||||
f"`{exe_name}` is not on PATH — install the tool (enable it, then "
|
||||
f"`castle apply`) before generating its schema."
|
||||
)
|
||||
_, help_text = _run_help([exe])
|
||||
if not help_text:
|
||||
raise ToolSchemaError(f"`{exe_name} --help` produced no output.")
|
||||
if not _extract_subcommands(help_text):
|
||||
structured = _structured_core(exe_name, help_text)
|
||||
if structured:
|
||||
return structured
|
||||
return _command_core(exe_name, [exe], help_text, deep)
|
||||
|
||||
|
||||
def render_tool_schema(core: dict, fmt: str = "openai") -> dict:
|
||||
"""Wrap a neutral core in a provider envelope.
|
||||
|
||||
``openai`` (default, litellm-native) → ``{type: function, function: {…}}``;
|
||||
``anthropic`` → ``{name, description, input_schema}``; ``neutral`` → as stored.
|
||||
"""
|
||||
if fmt == "neutral":
|
||||
return core
|
||||
if fmt == "anthropic":
|
||||
return {
|
||||
"name": core["name"],
|
||||
"description": core["description"],
|
||||
"input_schema": core["parameters"],
|
||||
}
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": core["name"],
|
||||
"description": core["description"],
|
||||
"parameters": core["parameters"],
|
||||
},
|
||||
}
|
||||
@@ -11,10 +11,31 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from castle_core.deploy import _render_unit_preview, apply
|
||||
import castle_core.deploy as deploy_mod
|
||||
from castle_core.config import load_config
|
||||
from castle_core.deploy import (
|
||||
_desired_registry,
|
||||
_gateway_would_change,
|
||||
_render_unit_preview,
|
||||
apply,
|
||||
generate_caddyfile_from_registry,
|
||||
)
|
||||
from castle_core.registry import Deployment
|
||||
|
||||
|
||||
def _add_static(castle_root: Path, name: str = "test-static") -> None:
|
||||
"""Write a caddy (static) program + deployment into an existing castle root."""
|
||||
(castle_root / "programs" / f"{name}.yaml").write_text(
|
||||
f"description: Static {name}\nsource: {castle_root / name}\n"
|
||||
)
|
||||
statics = castle_root / "deployments" / "statics"
|
||||
statics.mkdir(parents=True, exist_ok=True)
|
||||
(statics / f"{name}.yaml").write_text(
|
||||
f"program: {name}\nmanager: caddy\nroot: public\nreach: internal\n"
|
||||
)
|
||||
(castle_root / name / "public").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _plan(castle_root: Path, active: dict[str, bool]):
|
||||
"""Run apply(plan=True) with is_active stubbed to `active` (default False)."""
|
||||
with patch(
|
||||
@@ -68,6 +89,49 @@ class TestApplyPlan:
|
||||
assert result.changed is True
|
||||
|
||||
|
||||
class TestGatewayChange:
|
||||
"""A caddy route change touches no systemd unit, so the activate/restart/
|
||||
deactivate reconcile can't see it. `gateway_changed` catches it by diffing the
|
||||
would-be Caddyfile/tunnel config against disk — otherwise a new/changed static
|
||||
route reports a false 'already converged'.
|
||||
|
||||
SPECS_DIR is the real ~/.castle path (unpatched by the fixtures), so these
|
||||
redirect it to a temp dir to stay hermetic and never touch the live Caddyfile.
|
||||
"""
|
||||
|
||||
def test_new_route_reports_gateway_changed(
|
||||
self, castle_root: Path, tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""A static whose route isn't on disk yet → gateway_changed, even when the
|
||||
assets already exist so the deployment itself classifies 'unchanged'."""
|
||||
monkeypatch.setattr(deploy_mod, "SPECS_DIR", tmp_path / "specs")
|
||||
_add_static(castle_root)
|
||||
|
||||
# Static is 'active' (built) → _classify buckets it 'unchanged'; the route is
|
||||
# still absent from the (missing) Caddyfile, so the gateway did change.
|
||||
result = _plan(castle_root, active={"test-static": True})
|
||||
|
||||
assert "test-static" in result.unchanged
|
||||
assert result.gateway_changed is True
|
||||
assert result.changed is True
|
||||
|
||||
def test_converged_caddyfile_is_not_changed(
|
||||
self, castle_root: Path, tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
"""When the on-disk Caddyfile already matches the desired one, no change."""
|
||||
specs = tmp_path / "specs"
|
||||
specs.mkdir(parents=True, exist_ok=True)
|
||||
monkeypatch.setattr(deploy_mod, "SPECS_DIR", specs)
|
||||
_add_static(castle_root)
|
||||
config = load_config(castle_root)
|
||||
|
||||
(specs / "Caddyfile").write_text(
|
||||
generate_caddyfile_from_registry(_desired_registry(config, None))
|
||||
)
|
||||
|
||||
assert _gateway_would_change(config, None) is False
|
||||
|
||||
|
||||
def test_render_unit_preview_none_for_non_systemd() -> None:
|
||||
"""Non-systemd managers have no unit file — preview is None (never 'restart').
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ def _make_registry(
|
||||
gateway_tls: str | None = None,
|
||||
gateway_domain: str | None = None,
|
||||
acme_email: str | None = None,
|
||||
public_domain: str | None = None,
|
||||
) -> NodeRegistry:
|
||||
reg = NodeRegistry(
|
||||
node=NodeConfig(
|
||||
@@ -49,6 +50,7 @@ def _make_registry(
|
||||
gateway_tls=gateway_tls,
|
||||
gateway_domain=gateway_domain,
|
||||
acme_email=acme_email,
|
||||
public_domain=public_domain,
|
||||
),
|
||||
)
|
||||
for name, d in (deployed or {}).items():
|
||||
@@ -68,9 +70,14 @@ def _dep(port: int, *, expose: bool, name: str | None = None, launcher: str = "p
|
||||
)
|
||||
|
||||
|
||||
def _acme(deployed: dict[str, Deployment], domain: str | None = "example.com") -> NodeRegistry:
|
||||
def _acme(
|
||||
deployed: dict[str, Deployment],
|
||||
domain: str | None = "example.com",
|
||||
public_domain: str | None = None,
|
||||
) -> NodeRegistry:
|
||||
return _make_registry(
|
||||
gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com", deployed=deployed
|
||||
gateway_tls="acme", gateway_domain=domain, acme_email="p@e.com",
|
||||
public_domain=public_domain, deployed=deployed,
|
||||
)
|
||||
|
||||
|
||||
@@ -133,6 +140,52 @@ class TestAcmeMode:
|
||||
assert "file_server" in cf
|
||||
|
||||
|
||||
class TestPublicExposure:
|
||||
"""Public deployments are served under the public zone; a `public_host`
|
||||
override (apex / other zone) gets its own standalone site with its own cert."""
|
||||
|
||||
def _static_pub(self, name: str, public_host: str | None = None) -> Deployment:
|
||||
return Deployment(
|
||||
manager="caddy", run_cmd=[], subdomain=name,
|
||||
static_root=f"/data/repos/{name}/public", public=True, public_host=public_host,
|
||||
)
|
||||
|
||||
def test_default_public_uses_wildcard_site(self) -> None:
|
||||
reg = _acme({"blog": self._static_pub("blog")}, public_domain="pub.example.org")
|
||||
cf = generate_caddyfile_from_registry(reg)
|
||||
assert "*.pub.example.org {" in cf
|
||||
assert "@host_blog_pub host blog.pub.example.org" in cf
|
||||
|
||||
def test_public_host_override_is_standalone_apex_site(self) -> None:
|
||||
reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")},
|
||||
public_domain="pub.example.org")
|
||||
cf = generate_caddyfile_from_registry(reg)
|
||||
# An explicit apex site (not under the *.pub wildcard) so Caddy issues its
|
||||
# own cert via DNS-01; file_server serves the same local dir directly.
|
||||
assert "payne.io {" in cf
|
||||
assert "root * /data/repos/payne-io/public" in cf
|
||||
# The override host must NOT appear as a *.pub.example.org subdomain.
|
||||
assert "payne-io.pub.example.org" not in cf
|
||||
|
||||
def test_override_and_default_coexist(self) -> None:
|
||||
reg = _acme(
|
||||
{"blog": self._static_pub("blog"),
|
||||
"payne-io": self._static_pub("payne-io", "payne.io")},
|
||||
public_domain="pub.example.org",
|
||||
)
|
||||
cf = generate_caddyfile_from_registry(reg)
|
||||
assert "*.pub.example.org {" in cf # default wildcard block still present
|
||||
assert "@host_blog_pub host blog.pub.example.org" in cf
|
||||
assert "payne.io {" in cf # standalone apex site
|
||||
|
||||
def test_public_host_without_default_domain(self) -> None:
|
||||
# No node-wide public_domain: only the override host gets a site.
|
||||
reg = _acme({"payne-io": self._static_pub("payne-io", "payne.io")})
|
||||
cf = generate_caddyfile_from_registry(reg)
|
||||
assert "payne.io {" in cf
|
||||
assert "*.None" not in cf
|
||||
|
||||
|
||||
class TestOffMode:
|
||||
"""No domain → HTTP-only control plane on :<port>: dashboard at / + /api → castle-api.
|
||||
Other services are port-only (not routed)."""
|
||||
|
||||
110
core/tests/test_dns.py
Normal file
110
core/tests/test_dns.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Tests for multi-zone public DNS (Cloudflare CNAME) reconciliation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import castle_core.generators.dns as dns
|
||||
from castle_core.generators.dns import _zone_for, reconcile_public_dns
|
||||
|
||||
TID = "tid-abc"
|
||||
TARGET = f"{TID}.cfargotunnel.com"
|
||||
|
||||
ZONES = [
|
||||
{"id": "z_payne", "name": "payne.io"},
|
||||
{"id": "z_ex", "name": "example.org"},
|
||||
]
|
||||
|
||||
|
||||
class _FakeCloudflare:
|
||||
"""A minimal fake of the Cloudflare API used by reconcile_public_dns.
|
||||
|
||||
``records`` maps zone_id -> {name: (record_id, content)}. Records POST/DELETE
|
||||
calls so tests can assert exactly what castle created/removed.
|
||||
"""
|
||||
|
||||
def __init__(self, records: dict[str, dict[str, tuple[str, str]]]):
|
||||
self.records = records
|
||||
self.created: list[tuple[str, str]] = [] # (zone_id, name)
|
||||
self.deleted: list[tuple[str, str]] = [] # (zone_id, record_id)
|
||||
self._n = 0
|
||||
|
||||
def api(self, token: str, method: str, path: str, body: dict | None = None) -> dict:
|
||||
if method == "GET" and path.startswith("/zones?"):
|
||||
return {"result": ZONES}
|
||||
if method == "GET" and "/dns_records" in path:
|
||||
zone_id = path.split("/zones/")[1].split("/")[0]
|
||||
recs = self.records.get(zone_id, {})
|
||||
return {
|
||||
"result": [
|
||||
{"id": rid, "name": name, "content": content, "type": "CNAME"}
|
||||
for name, (rid, content) in recs.items()
|
||||
]
|
||||
}
|
||||
if method == "POST":
|
||||
zone_id = path.split("/zones/")[1].split("/")[0]
|
||||
assert body is not None
|
||||
self.created.append((zone_id, body["name"]))
|
||||
return {"result": {"id": f"new{self._n}"}}
|
||||
if method == "DELETE":
|
||||
zone_id = path.split("/zones/")[1].split("/")[0]
|
||||
rid = path.rsplit("/", 1)[1]
|
||||
self.deleted.append((zone_id, rid))
|
||||
return {"result": {"id": rid}}
|
||||
raise AssertionError(f"unexpected call {method} {path}")
|
||||
|
||||
|
||||
def _run(fake: _FakeCloudflare, desired: list[str], monkeypatch) -> list[str]:
|
||||
monkeypatch.setattr(dns, "_api", fake.api)
|
||||
messages: list[str] = []
|
||||
ok = reconcile_public_dns(TID, desired, messages, token="tok")
|
||||
assert ok is True
|
||||
return messages
|
||||
|
||||
|
||||
def test_longest_suffix_routes_host_to_its_zone() -> None:
|
||||
assert _zone_for("payne.io", ZONES)["id"] == "z_payne" # apex
|
||||
assert _zone_for("api.payne.io", ZONES)["id"] == "z_payne" # subdomain
|
||||
assert _zone_for("app.example.org", ZONES)["id"] == "z_ex"
|
||||
assert _zone_for("nope.other.net", ZONES) is None # no visible zone
|
||||
|
||||
|
||||
def test_creates_apex_and_subdomain_in_correct_zones(monkeypatch) -> None:
|
||||
fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}})
|
||||
_run(fake, ["payne.io", "app.example.org"], monkeypatch)
|
||||
assert set(fake.created) == {("z_payne", "payne.io"), ("z_ex", "app.example.org")}
|
||||
assert fake.deleted == []
|
||||
|
||||
|
||||
def test_deletes_managed_cname_no_longer_desired(monkeypatch) -> None:
|
||||
# z_payne has a stale castle-managed CNAME + a hand-managed one pointing elsewhere.
|
||||
fake = _FakeCloudflare(records={
|
||||
"z_payne": {
|
||||
"payne.io": ("r1", TARGET), # castle-managed, still desired
|
||||
"old.payne.io": ("r2", TARGET), # castle-managed, now stale → delete
|
||||
"keep.payne.io": ("r3", "other.example.com"), # NOT ours → never touched
|
||||
},
|
||||
"z_ex": {},
|
||||
})
|
||||
_run(fake, ["payne.io"], monkeypatch)
|
||||
assert fake.created == []
|
||||
assert fake.deleted == [("z_payne", "r2")] # only the stale managed one
|
||||
|
||||
|
||||
def test_empty_desired_cleans_all_managed(monkeypatch) -> None:
|
||||
fake = _FakeCloudflare(records={
|
||||
"z_payne": {"payne.io": ("r1", TARGET)},
|
||||
"z_ex": {"a.example.org": ("r2", TARGET)},
|
||||
})
|
||||
_run(fake, [], monkeypatch)
|
||||
assert set(fake.deleted) == {("z_payne", "r1"), ("z_ex", "r2")}
|
||||
|
||||
|
||||
def test_no_token_returns_false(monkeypatch) -> None:
|
||||
monkeypatch.setattr(dns, "public_dns_token", lambda: None)
|
||||
assert reconcile_public_dns(TID, ["payne.io"], [], token=None) is False
|
||||
|
||||
|
||||
def test_unresolvable_host_warns_but_still_reconciles_others(monkeypatch) -> None:
|
||||
fake = _FakeCloudflare(records={"z_payne": {}, "z_ex": {}})
|
||||
msgs = _run(fake, ["payne.io", "x.unknown.net"], monkeypatch)
|
||||
assert fake.created == [("z_payne", "payne.io")]
|
||||
assert any("unknown.net" in m for m in msgs)
|
||||
@@ -176,6 +176,40 @@ class TestSystemdDeployment:
|
||||
}
|
||||
)
|
||||
|
||||
def test_public_host_override_accepted_with_reach_public(self) -> None:
|
||||
base = dict(
|
||||
manager="systemd",
|
||||
run=RunCommand(launcher="command", argv=["x"]),
|
||||
expose={"http": {"internal": {"port": 9001}}},
|
||||
)
|
||||
s = SystemdDeployment.model_validate(
|
||||
{**base, "reach": "public", "public_host": "payne.io"}
|
||||
)
|
||||
assert s.public_host == "payne.io"
|
||||
|
||||
def test_public_host_requires_reach_public(self) -> None:
|
||||
"""public_host without reach: public is a no-op; reject it at load."""
|
||||
base = dict(
|
||||
manager="systemd",
|
||||
run=RunCommand(launcher="command", argv=["x"]),
|
||||
expose={"http": {"internal": {"port": 9001}}},
|
||||
)
|
||||
with pytest.raises(ValueError, match="public_host is only valid"):
|
||||
SystemdDeployment.model_validate(
|
||||
{**base, "reach": "internal", "public_host": "payne.io"}
|
||||
)
|
||||
|
||||
def test_public_host_must_be_bare_hostname(self) -> None:
|
||||
base = dict(
|
||||
manager="systemd",
|
||||
run=RunCommand(launcher="command", argv=["x"]),
|
||||
expose={"http": {"internal": {"port": 9001}}},
|
||||
reach="public",
|
||||
)
|
||||
for bad in ("https://payne.io", "payne.io/path", "payne.io:443", "payne .io"):
|
||||
with pytest.raises(ValueError, match="bare hostname"):
|
||||
SystemdDeployment.model_validate({**base, "public_host": bad})
|
||||
|
||||
def test_no_run_is_invalid(self) -> None:
|
||||
"""A systemd deployment requires a run (launch) spec."""
|
||||
with pytest.raises(Exception):
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -46,6 +46,31 @@ class TestSupabaseStackResolution:
|
||||
assert "install" in actions and "uninstall" in actions
|
||||
|
||||
|
||||
class TestHugoStackResolution:
|
||||
def test_hugo_provides_only_build_verbs(self) -> None:
|
||||
"""Hugo is build-only: it resolves build/install/uninstall but NOT the
|
||||
lint/test/type-check/check verbs it has no native tooling for."""
|
||||
p = ProgramSpec.model_validate({"source": "/tmp/x", "stack": "hugo"})
|
||||
actions = available_actions(p)
|
||||
assert "build" in actions
|
||||
assert "install" in actions and "uninstall" in actions
|
||||
for absent in ("lint", "test", "type-check", "check"):
|
||||
assert absent not in actions
|
||||
|
||||
def test_declared_command_still_overrides_hugo(self) -> None:
|
||||
"""A hugo program can still declare a verb the stack doesn't provide —
|
||||
declared commands are resolved regardless of the handler's `provides`."""
|
||||
p = ProgramSpec.model_validate(
|
||||
{
|
||||
"source": "/tmp/x",
|
||||
"stack": "hugo",
|
||||
"commands": {"test": [["htmltest"]]},
|
||||
}
|
||||
)
|
||||
actions = available_actions(p)
|
||||
assert "test" in actions and "build" in actions
|
||||
|
||||
|
||||
class TestResolution:
|
||||
def test_stack_only_program_unchanged(self) -> None:
|
||||
"""A program with a stack and no commands resolves all stack verbs."""
|
||||
@@ -61,7 +86,11 @@ class TestResolution:
|
||||
p = ProgramSpec.model_validate(
|
||||
{
|
||||
"source": "/tmp/y",
|
||||
"commands": {"lint": [["make", "lint"]], "test": [["make", "test"]], "run": [["./bin/y"]]},
|
||||
"commands": {
|
||||
"lint": [["make", "lint"]],
|
||||
"test": [["make", "test"]],
|
||||
"run": [["./bin/y"]],
|
||||
},
|
||||
}
|
||||
)
|
||||
actions = available_actions(p)
|
||||
@@ -79,15 +108,24 @@ class TestResolution:
|
||||
def test_hybrid_override_one_verb(self) -> None:
|
||||
"""A stack program can override a single verb; the rest fall back to stack."""
|
||||
p = ProgramSpec.model_validate(
|
||||
{"source": "/tmp/z", "stack": "python-cli", "commands": {"test": [["pytest", "-x"]]}}
|
||||
{
|
||||
"source": "/tmp/z",
|
||||
"stack": "python-cli",
|
||||
"commands": {"test": [["pytest", "-x"]]},
|
||||
}
|
||||
)
|
||||
assert _declared_commands(p, "test") == [["pytest", "-x"]]
|
||||
assert _declared_commands(p, "build") is None # build still comes from the stack
|
||||
assert (
|
||||
_declared_commands(p, "build") is None
|
||||
) # build still comes from the stack
|
||||
|
||||
def test_build_declared_via_buildspec(self) -> None:
|
||||
"""`build` is declared through BuildSpec.commands, not CommandsSpec."""
|
||||
p = ProgramSpec.model_validate(
|
||||
{"source": "/tmp/w", "build": {"commands": [["make"]], "outputs": ["dist/"]}}
|
||||
{
|
||||
"source": "/tmp/w",
|
||||
"build": {"commands": [["make"]], "outputs": ["dist/"]},
|
||||
}
|
||||
)
|
||||
assert _declared_commands(p, "build") == [["make"]]
|
||||
assert "build" in available_actions(p)
|
||||
|
||||
185
core/tests/test_tool_schema.py
Normal file
185
core/tests/test_tool_schema.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Tests for castle_core.tool_schema — deriving neutral tool-call cores from --help."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from castle_core.tool_schema import (
|
||||
ToolSchemaError,
|
||||
_command_core,
|
||||
_extract_subcommands,
|
||||
_sanitize_name,
|
||||
_structured_core,
|
||||
collect_tool_help,
|
||||
derive_tool_schema,
|
||||
is_tool_schema_core,
|
||||
render_tool_schema,
|
||||
tool_executable,
|
||||
validate_tool_schema_core,
|
||||
)
|
||||
|
||||
FLAT_HELP = """\
|
||||
usage: widget [-h] [--deep] [--mode {a,b}] target
|
||||
|
||||
Do a widget thing.
|
||||
|
||||
positional arguments:
|
||||
target What to widget
|
||||
|
||||
options:
|
||||
-h, --help show this help message and exit
|
||||
--deep Recurse
|
||||
--mode {a,b} Pick a mode
|
||||
"""
|
||||
|
||||
SUBCMD_HELP = "positional arguments:\n {build,deploy}\n build Build\n"
|
||||
|
||||
|
||||
def _fake_config(programs: dict) -> object:
|
||||
return SimpleNamespace(programs=programs)
|
||||
|
||||
|
||||
class TestPure:
|
||||
def test_sanitize_name(self) -> None:
|
||||
assert _sanitize_name("my.tool v2") == "my_tool_v2"
|
||||
assert len(_sanitize_name("x" * 100)) == 64
|
||||
|
||||
def test_flat_tool_has_no_subcommands(self) -> None:
|
||||
assert _extract_subcommands(FLAT_HELP) == []
|
||||
|
||||
def test_argparse_choice_row_subcommands(self) -> None:
|
||||
assert _extract_subcommands(SUBCMD_HELP) == ["build", "deploy"]
|
||||
|
||||
def test_structured_core_typed_params(self) -> None:
|
||||
core = _structured_core("widget", FLAT_HELP)
|
||||
assert core is not None
|
||||
props = core["parameters"]["properties"]
|
||||
assert set(props) == {"target", "deep", "mode"}
|
||||
assert props["deep"]["type"] == "boolean"
|
||||
assert props["mode"]["enum"] == ["a", "b"]
|
||||
assert core["parameters"]["required"] == ["target"]
|
||||
|
||||
def test_structured_none_without_options(self) -> None:
|
||||
assert _structured_core("x", "free-form help\n") is None
|
||||
|
||||
def test_command_core_shape(self) -> None:
|
||||
core = _command_core("jq", ["/usr/bin/jq"], "Usage: jq ...", deep=False)
|
||||
assert core["parameters"]["required"] == ["command"]
|
||||
|
||||
|
||||
class TestRender:
|
||||
def test_render_openai_default(self) -> None:
|
||||
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
|
||||
out = render_tool_schema(core)
|
||||
assert out["type"] == "function"
|
||||
assert out["function"]["parameters"] == core["parameters"]
|
||||
|
||||
def test_render_anthropic(self) -> None:
|
||||
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
|
||||
out = render_tool_schema(core, "anthropic")
|
||||
assert set(out) == {"name", "description", "input_schema"}
|
||||
assert out["input_schema"] == core["parameters"]
|
||||
|
||||
def test_render_neutral_is_identity(self) -> None:
|
||||
core = _command_core("jq", ["/usr/bin/jq"], "help", deep=False)
|
||||
assert render_tool_schema(core, "neutral") is core
|
||||
|
||||
|
||||
class TestResolution:
|
||||
def test_tool_executable_falls_back_to_name(self) -> None:
|
||||
cfg = _fake_config({"widget": SimpleNamespace(source=None)})
|
||||
assert tool_executable(cfg, "widget") == "widget"
|
||||
|
||||
def test_tool_executable_reads_pyproject_scripts(self, tmp_path) -> None:
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
'[project.scripts]\nintent-router = "x:main"\n'
|
||||
)
|
||||
cfg = _fake_config({"r": SimpleNamespace(source=str(tmp_path))})
|
||||
assert tool_executable(cfg, "r") == "intent-router"
|
||||
|
||||
|
||||
class TestDerive:
|
||||
def test_missing_executable_raises(self) -> None:
|
||||
cfg = _fake_config({"nope": SimpleNamespace(source=None)})
|
||||
with pytest.raises(ToolSchemaError, match="not on PATH"):
|
||||
derive_tool_schema(cfg, "nope")
|
||||
|
||||
def test_derive_real_tool_returns_neutral_core(self) -> None:
|
||||
cfg = _fake_config({"python3": SimpleNamespace(source=None)})
|
||||
core = derive_tool_schema(cfg, "python3")
|
||||
assert core["name"] == "python3"
|
||||
assert "parameters" in core # neutral shape, not input_schema
|
||||
assert core["description"]
|
||||
|
||||
|
||||
class TestLLMAssistHelpers:
|
||||
"""Deterministic helpers that feed / validate the (castle-api) LLM assist."""
|
||||
|
||||
def test_collect_tool_help_real_tool(self) -> None:
|
||||
cfg = _fake_config({"python3": SimpleNamespace(source=None)})
|
||||
help_text = collect_tool_help(cfg, "python3")
|
||||
assert help_text and "$ python3 --help" in help_text
|
||||
|
||||
def test_collect_tool_help_missing_raises(self) -> None:
|
||||
cfg = _fake_config({"nope": SimpleNamespace(source=None)})
|
||||
with pytest.raises(ToolSchemaError, match="not on PATH"):
|
||||
collect_tool_help(cfg, "nope")
|
||||
|
||||
def test_is_tool_schema_core(self) -> None:
|
||||
good = {
|
||||
"name": "jq",
|
||||
"description": "process json",
|
||||
"parameters": {"type": "object", "properties": {"filter": {"type": "string"}}},
|
||||
}
|
||||
assert is_tool_schema_core(good) is True
|
||||
assert is_tool_schema_core({"name": "x", "description": "y"}) is False # no params
|
||||
assert is_tool_schema_core("nope") is False
|
||||
|
||||
|
||||
class TestValidate:
|
||||
"""Deterministic validation — shape + JSON-Schema meta-validation."""
|
||||
|
||||
_GOOD = {
|
||||
"name": "jq",
|
||||
"description": "process json",
|
||||
"parameters": {"type": "object", "properties": {"filter": {"type": "string"}}},
|
||||
}
|
||||
|
||||
def test_valid_returns_no_errors(self) -> None:
|
||||
assert validate_tool_schema_core(self._GOOD) == []
|
||||
|
||||
def test_malformed_enum_is_caught(self) -> None:
|
||||
"""The qwen defect: `enum` as a count, not a list — a JSON-Schema error."""
|
||||
bad = {
|
||||
"name": "search",
|
||||
"description": "d",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"sub": {"type": "string", "enum": 4}},
|
||||
},
|
||||
}
|
||||
errors = validate_tool_schema_core(bad)
|
||||
assert errors and any("JSON Schema" in e for e in errors)
|
||||
|
||||
def test_missing_parameters(self) -> None:
|
||||
assert validate_tool_schema_core({"name": "x", "description": "y"})
|
||||
|
||||
def test_empty_properties(self) -> None:
|
||||
errors = validate_tool_schema_core(
|
||||
{"name": "x", "description": "y", "parameters": {"type": "object", "properties": {}}}
|
||||
)
|
||||
assert any("empty" in e for e in errors)
|
||||
|
||||
def test_bad_name_char(self) -> None:
|
||||
errors = validate_tool_schema_core(
|
||||
{**self._GOOD, "name": "has space"}
|
||||
)
|
||||
assert any("name" in e for e in errors)
|
||||
|
||||
def test_parameters_not_object_type(self) -> None:
|
||||
errors = validate_tool_schema_core(
|
||||
{"name": "x", "description": "y", "parameters": {"type": "array", "properties": {}}}
|
||||
)
|
||||
assert errors
|
||||
@@ -80,3 +80,43 @@ def test_public_static_frontend_gets_ingress() -> None:
|
||||
hosts = {r["hostname"] for r in yaml.safe_load(generate_tunnel_config(reg))["ingress"]
|
||||
if "hostname" in r}
|
||||
assert hosts == {"guestbook.pub.payne.io"}
|
||||
|
||||
|
||||
def test_public_host_override_used_as_ingress_hostname() -> None:
|
||||
# An apex `public_host` overrides <sub>.<public_domain> for the public name,
|
||||
# but the origin still bridges to the internal <sub>.<gateway_domain> host.
|
||||
reg = _registry(deployed={
|
||||
"payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
|
||||
static_root="/data/repos/payne-io/public", public=True,
|
||||
public_host="payne.io"),
|
||||
})
|
||||
cfg = yaml.safe_load(generate_tunnel_config(reg))
|
||||
rules = {r["hostname"]: r for r in cfg["ingress"] if "hostname" in r}
|
||||
assert set(rules) == {"payne.io"}
|
||||
assert rules["payne.io"]["originRequest"]["httpHostHeader"] == "payne-io.civil.payne.io"
|
||||
assert public_hostnames(reg) == ["payne.io"]
|
||||
|
||||
|
||||
def test_public_host_default_and_override_coexist() -> None:
|
||||
reg = _registry(deployed={
|
||||
"app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
|
||||
subdomain="app", public=True),
|
||||
"payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
|
||||
static_root="/d/public", public=True, public_host="payne.io"),
|
||||
})
|
||||
assert set(public_hostnames(reg)) == {"app.pub.payne.io", "payne.io"}
|
||||
|
||||
|
||||
def test_public_host_works_without_default_public_domain() -> None:
|
||||
# A deployment with its own public_host publishes even if the node has no
|
||||
# default public_domain; the plain public service (no override) is skipped.
|
||||
reg = _registry(public_domain=None, deployed={
|
||||
"app": Deployment(manager="systemd", launcher="python", run_cmd=["x"], port=9001,
|
||||
subdomain="app", public=True),
|
||||
"payne-io": Deployment(manager="caddy", run_cmd=[], subdomain="payne-io",
|
||||
static_root="/d/public", public=True, public_host="payne.io"),
|
||||
})
|
||||
assert public_hostnames(reg) == ["payne.io"]
|
||||
cfg = yaml.safe_load(generate_tunnel_config(reg))
|
||||
hosts = {r["hostname"] for r in cfg["ingress"] if "hostname" in r}
|
||||
assert hosts == {"payne.io"}
|
||||
|
||||
@@ -370,6 +370,26 @@ to the backend root, so root-relative asset URLs and `window.location`-derived
|
||||
WebSocket URLs just work (the failure mode of the old prefix-stripping `handle_path`
|
||||
routes is gone). Caddy proxies WebSocket upgrades transparently.
|
||||
|
||||
#### `public_host` — Publish on a different domain / apex (opt-in)
|
||||
|
||||
`gateway.public_domain` is the **default** public zone. To project a specific
|
||||
deployment on a *different* domain, or at an **apex** (`payne.io`, which can't be a
|
||||
`<name>.<zone>` subdomain), set an exact `public_host` FQDN on the deployment (only
|
||||
valid with `reach: public` / `public: true`):
|
||||
|
||||
```yaml
|
||||
reach: public
|
||||
public_host: payne.io # exact hostname; overrides <name>.<public_domain>
|
||||
```
|
||||
|
||||
The tunnel origin still bridges to the internal `<name>.<gateway.domain>` host, and
|
||||
the gateway also serves the custom host **LAN-direct** with its own DNS-01 cert. The
|
||||
public CNAME is reconciled into whichever accessible Cloudflare zone is the host's
|
||||
longest suffix, so the `CLOUDFLARE_PUBLIC_DNS_TOKEN` (and the gateway's
|
||||
`CLOUDFLARE_API_TOKEN`, for the cert) must have `DNS:Edit` on that zone. A
|
||||
deployment with `public_host` publishes even with no node-wide `public_domain`. Full
|
||||
prerequisites (tokens + LAN DNS for the apex): @docs/tunnel-setup.md.
|
||||
|
||||
**Gateway routes — one concept, three target kinds.** The gateway maps a public
|
||||
**address** (always a subdomain host, `<name>.<domain>`) to a **target**:
|
||||
|
||||
|
||||
96
docs/stacks/hugo.md
Normal file
96
docs/stacks/hugo.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# Hugo static sites in Castle
|
||||
|
||||
> **This is a stack — creation-time guidance for writing _new_ sites.**
|
||||
> A stack is a template + conventions, not a runtime requirement. `castle program
|
||||
> create --stack hugo` scaffolds from it (via Hugo's own `hugo new site`) and seeds
|
||||
> the program's default build verb. An existing Hugo site adopted with `castle
|
||||
> program add` doesn't need this stack — it declares its own `commands:` /
|
||||
> `build:`. See @docs/registry.md for `commands:`, `stack:` (optional), and `repo:`.
|
||||
|
||||
How to build, serve, and manage [Hugo](https://gohugo.io) sites as castle programs.
|
||||
|
||||
## Stack
|
||||
|
||||
- Generator: Hugo (extended recommended — needed for SCSS/asset processing)
|
||||
- Build: `hugo --gc --minify` → `public/`
|
||||
- Served: `manager: caddy` static deployment, in place at `<name>.<gateway.domain>`
|
||||
- Package manager (only if a theme needs an asset pipeline): pnpm
|
||||
|
||||
Hugo has one meaningful dev verb — **build**. It has no native lint/test/type-check,
|
||||
so the stack advertises only `build` / `install` / `uninstall`; `castle check` and
|
||||
friends aren't offered (a site can still declare its own, e.g. an HTML linter, under
|
||||
`commands:` — a declared verb always wins over the stack).
|
||||
|
||||
## Create a new site
|
||||
|
||||
```bash
|
||||
castle program create my-site --stack hugo --description "My site"
|
||||
cd /data/repos/my-site
|
||||
castle program build my-site # hugo --gc --minify -> public/
|
||||
castle apply my-site # serve at my-site.<gateway.domain>
|
||||
```
|
||||
|
||||
The scaffold delegates the canonical skeleton to `hugo new site` (archetypes/,
|
||||
content/, layouts/, static/, themes/, hugo.toml) and overlays the pieces a bare
|
||||
skeleton lacks: minimal `layouts/` so the site **builds and serves without a
|
||||
theme**, an example `content/posts/hello.md`, a castle-flavored `hugo.toml`
|
||||
(`baseURL = "/"`, so assets resolve at the root of the site's own subdomain), and a
|
||||
`.gitignore` for the regenerated `public/` and `resources/`.
|
||||
|
||||
Develop with the live server:
|
||||
|
||||
```bash
|
||||
hugo server -D # http://localhost:1313, rebuilds on save
|
||||
```
|
||||
|
||||
## Adding a theme
|
||||
|
||||
Drop a theme under `themes/` (usually a git submodule) and set `theme` in
|
||||
`hugo.toml`:
|
||||
|
||||
```bash
|
||||
git submodule add https://github.com/<owner>/<theme>.git themes/<theme>
|
||||
```
|
||||
|
||||
Themes with an **asset pipeline** (e.g. Blowfish + Tailwind) need a pre-build step
|
||||
before `hugo`. Declare it as a two-step `build` in `programs/<name>.yaml` — a
|
||||
declared `build.commands` overrides the stack's single-step default:
|
||||
|
||||
```yaml
|
||||
build:
|
||||
commands:
|
||||
- [pnpm, build] # compile the theme's CSS/JS
|
||||
- [hugo, --gc, --minify] # render the site -> public/
|
||||
outputs: [public]
|
||||
```
|
||||
|
||||
One-time setup those themes expect (run once in the source tree):
|
||||
|
||||
```bash
|
||||
git submodule update --init --recursive
|
||||
cd themes/<theme> && pnpm install
|
||||
```
|
||||
|
||||
## Deployment shape
|
||||
|
||||
`castle program create --stack hugo` writes:
|
||||
|
||||
- **`programs/<name>.yaml`** — `source`, `stack: hugo`, `build.outputs: [public]`.
|
||||
- **`deployments/statics/<name>.yaml`** — `manager: caddy`, `root: public`,
|
||||
`reach: internal` (flip to `public` to also expose over the tunnel).
|
||||
|
||||
The gateway serves `<source>/public` in place — no copy, no Node/Hugo process at
|
||||
runtime. `castle program build` regenerates `public/`; `castle apply` renders the
|
||||
route and reloads the gateway.
|
||||
|
||||
## Adopting an existing Hugo site
|
||||
|
||||
No stack needed — adopt the repo and declare how it builds:
|
||||
|
||||
```bash
|
||||
castle program add /path/to/site --name my-site
|
||||
```
|
||||
|
||||
Then set `build.commands` (as above) and add a `manager: caddy` deployment. This is
|
||||
how a site with a bespoke build (submodule theme + Tailwind) is wired without the
|
||||
scaffold. See @docs/registry.md.
|
||||
@@ -129,6 +129,49 @@ Without the token, `castle apply` instead prints the exact command to run per ho
|
||||
cloudflared tunnel route dns <tunnel-id> <name>.pub.payne.io
|
||||
```
|
||||
|
||||
### Publishing on a different domain (`public_host`)
|
||||
|
||||
By default a public deployment is projected at `<name>.<gateway.public_domain>` —
|
||||
`public_domain` is the *default* public zone. To publish a specific deployment on a
|
||||
**different** domain, or at an **apex** (e.g. `payne.io`, which can't be expressed
|
||||
as `<name>.<zone>`), set an exact `public_host` on the deployment:
|
||||
|
||||
```yaml
|
||||
# deployments/statics/payne-io.yaml
|
||||
manager: caddy
|
||||
program: payne-io
|
||||
root: public
|
||||
reach: public
|
||||
public_host: payne.io # exact FQDN — overrides <name>.<public_domain>
|
||||
```
|
||||
|
||||
`public_host` is an exact hostname (no scheme/port/path) and only applies with
|
||||
`reach: public`. When set:
|
||||
|
||||
- **Tunnel ingress** maps that hostname to the tunnel; the origin still bridges to
|
||||
the deployment's *internal* host (`<name>.<gateway.domain>`), so Caddy routes it
|
||||
and the internal wildcard cert validates — same as the default path.
|
||||
- **Public DNS** reconcile routes the CNAME into whichever accessible Cloudflare
|
||||
zone is the longest suffix of the host (so `payne.io` lands in zone `payne.io`,
|
||||
`x.other.org` in `other.org`). It reconciles across **every** zone the
|
||||
`CLOUDFLARE_PUBLIC_DNS_TOKEN` can see, so the token must have `DNS:Edit` on each
|
||||
target zone. Cloudflare flattens the apex CNAME automatically.
|
||||
- **LAN-direct HTTPS.** The gateway also serves the custom host directly as its own
|
||||
Caddy site, obtaining that host's cert via DNS-01 (the global `acme_dns`). This
|
||||
requires the gateway's `CLOUDFLARE_API_TOKEN` to have `DNS:Edit` on the host's
|
||||
zone too, and LAN DNS to resolve the host to this node — for an apex add e.g.
|
||||
`address=/payne.io/<node-ip>` on the LAN resolver (the `*.<gateway.domain>`
|
||||
wildcard doesn't cover a foreign apex).
|
||||
|
||||
A deployment with `public_host` publishes even if no node-wide `public_domain` is
|
||||
configured. Prerequisites in one place, for `payne-io` → `https://payne.io`:
|
||||
|
||||
| need | why |
|
||||
|------|-----|
|
||||
| `CLOUDFLARE_PUBLIC_DNS_TOKEN` has `DNS:Edit` on `payne.io` | the proxied apex CNAME → tunnel |
|
||||
| `CLOUDFLARE_API_TOKEN` (gateway) has `DNS:Edit` on `payne.io` | the LAN-direct apex cert (DNS-01) |
|
||||
| LAN DNS `address=/payne.io/<node-ip>` | LAN browsers resolve the apex to the gateway |
|
||||
|
||||
## The part that isn't the tunnel
|
||||
|
||||
Reachability is the easy half. Anything public also needs, per service:
|
||||
|
||||
132
uv.lock
generated
132
uv.lock
generated
@@ -40,6 +40,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "attrs"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "castle-api"
|
||||
version = "0.1.0"
|
||||
@@ -119,6 +128,7 @@ name = "castle-core"
|
||||
version = "0.1.0"
|
||||
source = { editable = "core" }
|
||||
dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
@@ -131,6 +141,7 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "jsonschema", specifier = ">=4.0.0" },
|
||||
{ name = "pydantic", specifier = ">=2.0.0" },
|
||||
{ name = "pyyaml", specifier = ">=6.0.0" },
|
||||
]
|
||||
@@ -297,6 +308,33 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "4.26.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "referencing" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2025.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "referencing" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.3"
|
||||
@@ -562,6 +600,100 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.37.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
{ name = "rpds-py" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rpds-py"
|
||||
version = "2026.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.2"
|
||||
|
||||
Reference in New Issue
Block a user