Refactor component terminology to programs in config and manifest
- Updated the terminology from "components" to "programs" across the codebase, including in config loading, saving, and manifest specifications. - Introduced a new `stacks.py` file to handle lifecycle actions for development stacks, implementing handlers for Python and React Vite stacks. - Adjusted tests to reflect the new program structure and ensure proper functionality. - Revised documentation to align with the new terminology and structure, ensuring clarity on the purpose and configuration of programs, services, and jobs.
This commit is contained in:
30
CLAUDE.md
30
CLAUDE.md
@@ -9,21 +9,21 @@ Castle is a personal software platform — a monorepo of independent projects
|
|||||||
(services, tools, libraries) managed by the `castle` CLI. The registry
|
(services, tools, libraries) managed by the `castle` CLI. The registry
|
||||||
(`castle.yaml`) has three top-level sections:
|
(`castle.yaml`) has three top-level sections:
|
||||||
|
|
||||||
- **`components:`** — Software catalog (source, install, tool metadata, build)
|
- **`programs:`** — Software catalog (source, install, tool metadata, build)
|
||||||
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
|
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
|
||||||
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
|
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
|
||||||
|
|
||||||
Each component has a **stack** (development toolchain: python-fastapi,
|
Each program has a **stack** (development toolchain: python-fastapi,
|
||||||
python-cli, react-vite) and a **behavior** (runtime role: daemon, tool,
|
python-cli, react-vite) and a **behavior** (runtime role: daemon, tool,
|
||||||
frontend). Scheduling, systemd management, and proxying are orthogonal
|
frontend). Scheduling, systemd management, and proxying are orthogonal
|
||||||
operations. Services and jobs reference a component via `component:` for
|
operations. Services and jobs reference a program via `component:` for
|
||||||
description fallthrough.
|
description fallthrough.
|
||||||
|
|
||||||
**Key principle:** Regular projects must never depend on castle. They accept standard
|
**Key principle:** Regular projects must never depend on castle. They accept standard
|
||||||
configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway)
|
configuration (data dir, port, URLs) via env vars. Only castle programs (CLI, gateway)
|
||||||
know about castle internals.
|
know about castle internals.
|
||||||
|
|
||||||
## Creating Components
|
## Creating Programs
|
||||||
|
|
||||||
When creating a new service, tool, or frontend, follow the detailed guides:
|
When creating a new service, tool, or frontend, follow the detailed guides:
|
||||||
|
|
||||||
@@ -37,17 +37,17 @@ When creating a new service, tool, or frontend, follow the detailed guides:
|
|||||||
```bash
|
```bash
|
||||||
# Daemon (python-fastapi)
|
# Daemon (python-fastapi)
|
||||||
castle create my-service --stack python-fastapi --description "Does something"
|
castle create my-service --stack python-fastapi --description "Does something"
|
||||||
cd components/my-service && uv sync
|
cd programs/my-service && uv sync
|
||||||
uv run my-service # starts on auto-assigned port
|
uv run my-service # starts on auto-assigned port
|
||||||
castle service enable my-service # register with systemd
|
castle service enable my-service # register with systemd
|
||||||
castle gateway reload # update reverse proxy routes
|
castle gateway reload # update reverse proxy routes
|
||||||
|
|
||||||
# Tool (python-cli)
|
# Tool (python-cli)
|
||||||
castle create my-tool --stack python-cli --description "Does something"
|
castle create my-tool --stack python-cli --description "Does something"
|
||||||
cd components/my-tool && uv sync
|
cd programs/my-tool && uv sync
|
||||||
```
|
```
|
||||||
|
|
||||||
The `castle create` command scaffolds the project under `components/`,
|
The `castle create` command scaffolds the project under `programs/`,
|
||||||
generates a CLAUDE.md, and registers it in `castle.yaml`.
|
generates a CLAUDE.md, and registers it in `castle.yaml`.
|
||||||
|
|
||||||
## Castle CLI
|
## Castle CLI
|
||||||
@@ -55,17 +55,17 @@ generates a CLAUDE.md, and registers it in `castle.yaml`.
|
|||||||
The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
|
The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
castle list # List all components
|
castle list # List all programs, services, and jobs
|
||||||
castle list --behavior daemon # Filter by behavior
|
castle list --behavior daemon # Filter by behavior
|
||||||
castle list --stack python-cli # Filter by stack
|
castle list --stack python-cli # Filter by stack
|
||||||
castle info <component> # Show details (--json for machine-readable)
|
castle info <name> # Show details (--json for machine-readable)
|
||||||
castle create <name> --stack python-fastapi # Scaffold new project
|
castle create <name> --stack python-fastapi # Scaffold new project
|
||||||
castle deploy [component] # Deploy to runtime (registry + systemd + Caddyfile)
|
castle deploy [name] # Deploy to runtime (registry + systemd + Caddyfile)
|
||||||
castle test [project] # Run tests (one or all)
|
castle test [project] # Run tests (one or all)
|
||||||
castle lint [project] # Run linter (one or all)
|
castle lint [project] # Run linter (one or all)
|
||||||
castle sync # Update submodules + uv sync all
|
castle sync # Update submodules + uv sync all
|
||||||
castle run <component> # Run component in foreground
|
castle run <name> # Run service in foreground
|
||||||
castle logs <component> [-f] [-n 50] # View component logs
|
castle logs <name> [-f] [-n 50] # View service/job logs
|
||||||
castle tool list # List all tools
|
castle tool list # List all tools
|
||||||
castle tool info <name> # Show tool details
|
castle tool info <name> # Show tool details
|
||||||
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
|
castle gateway start|stop|reload|status # Manage Caddy reverse proxy
|
||||||
@@ -156,8 +156,8 @@ Services also support: `uv run <service-name>` to start.
|
|||||||
|
|
||||||
## Key Files
|
## Key Files
|
||||||
|
|
||||||
- `castle.yaml` — Component registry (three sections: components, services, jobs)
|
- `castle.yaml` — Registry (three sections: programs, services, jobs)
|
||||||
- `core/src/castle_core/manifest.py` — Pydantic models (ComponentSpec, ServiceSpec, JobSpec, RunSpec)
|
- `core/src/castle_core/manifest.py` — Pydantic models (ProgramSpec, ServiceSpec, JobSpec, RunSpec)
|
||||||
- `core/src/castle_core/config.py` — Config loader (castle.yaml → CastleConfig)
|
- `core/src/castle_core/config.py` — Config loader (castle.yaml → CastleConfig)
|
||||||
- `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation
|
- `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation
|
||||||
- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates
|
- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates
|
||||||
|
|||||||
12
TODO.md
12
TODO.md
@@ -19,3 +19,15 @@
|
|||||||
- Add amplifier to make a component builder
|
- Add amplifier to make a component builder
|
||||||
|
|
||||||
- What's this about? "I need to add the MQTT env vars to the systemd unit. Let me add them via a drop-in override so the deploy command doesn't overwrite them."
|
- What's this about? "I need to add the MQTT env vars to the systemd unit. Let me add them via a drop-in override so the deploy command doesn't overwrite them."
|
||||||
|
|
||||||
|
|
||||||
|
- python-tool
|
||||||
|
- golang-tool
|
||||||
|
- rust-tool
|
||||||
|
- bash-tool
|
||||||
|
- python-daemon
|
||||||
|
- golang-daemon
|
||||||
|
- rust-daemon
|
||||||
|
- react-frontend
|
||||||
|
- python-webserver
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-react"
|
import { ExternalLink, Play, RefreshCw, Server, Square, Terminal } from "lucide-react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
import type { ServiceSummary, HealthStatus } from "@/types"
|
||||||
import { useServiceAction } from "@/services/api/hooks"
|
import { useServiceAction } from "@/services/api/hooks"
|
||||||
import { runnerLabel } from "@/lib/labels"
|
import { runnerLabel } from "@/lib/labels"
|
||||||
import { HealthBadge } from "./HealthBadge"
|
import { HealthBadge } from "./HealthBadge"
|
||||||
import { BehaviorBadge } from "./BehaviorBadge"
|
|
||||||
import { StackBadge } from "./StackBadge"
|
import { StackBadge } from "./StackBadge"
|
||||||
|
|
||||||
interface ComponentCardProps {
|
interface ComponentCardProps {
|
||||||
component: ComponentSummary
|
component: ServiceSummary
|
||||||
health?: HealthStatus
|
health?: HealthStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,7 +38,6 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1.5 mb-2">
|
<div className="flex gap-1.5 mb-2">
|
||||||
<BehaviorBadge behavior={component.behavior} />
|
|
||||||
<StackBadge stack={component.stack} />
|
<StackBadge stack={component.stack} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Check, Loader2, Save, Trash2 } from "lucide-react"
|
import { Check, Loader2, Save, Trash2 } from "lucide-react"
|
||||||
import type { ComponentDetail } from "@/types"
|
import type { AnyDetail } from "@/types"
|
||||||
import { runnerLabel } from "@/lib/labels"
|
import { runnerLabel } from "@/lib/labels"
|
||||||
import { SecretsEditor } from "./SecretsEditor"
|
import { SecretsEditor } from "./SecretsEditor"
|
||||||
|
|
||||||
interface ComponentFieldsProps {
|
interface ComponentFieldsProps {
|
||||||
component: ComponentDetail
|
component: AnyDetail
|
||||||
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
||||||
onDelete?: (name: string) => Promise<void>
|
onDelete?: (name: string) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -122,7 +122,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(component.managed || port) && (
|
{("managed" in component && component.managed || port) && (
|
||||||
<Field label="Port">
|
<Field label="Port">
|
||||||
<input
|
<input
|
||||||
value={port}
|
value={port}
|
||||||
@@ -133,7 +133,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(component.managed || healthPath) && (
|
{("managed" in component && component.managed || healthPath) && (
|
||||||
<Field label="Health path">
|
<Field label="Health path">
|
||||||
<input
|
<input
|
||||||
value={healthPath}
|
value={healthPath}
|
||||||
@@ -205,11 +205,13 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Field label="Systemd">
|
{"managed" in component && (
|
||||||
<span className="text-sm text-[var(--muted)]">
|
<Field label="Systemd">
|
||||||
{component.managed ? "Yes" : "No"}
|
<span className="text-sm text-[var(--muted)]">
|
||||||
</span>
|
{component.managed ? "Yes" : "No"}
|
||||||
</Field>
|
</span>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
|
<div className="flex items-center justify-between pt-3 border-t border-[var(--border)]">
|
||||||
{onDelete ? (
|
{onDelete ? (
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
|
||||||
import { BEHAVIOR_LABELS } from "@/lib/labels"
|
|
||||||
import { ComponentCard } from "./ComponentCard"
|
|
||||||
|
|
||||||
const BEHAVIOR_ORDER = ["daemon", "tool", "frontend"]
|
|
||||||
|
|
||||||
interface ComponentGridProps {
|
|
||||||
components: ComponentSummary[]
|
|
||||||
statuses: HealthStatus[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComponentGrid({ components, statuses }: ComponentGridProps) {
|
|
||||||
const statusMap = new Map(statuses.map((s) => [s.id, s]))
|
|
||||||
|
|
||||||
// Group by behavior
|
|
||||||
const groups = new Map<string, ComponentSummary[]>()
|
|
||||||
for (const comp of components) {
|
|
||||||
const key = comp.behavior ?? "other"
|
|
||||||
const list = groups.get(key) ?? []
|
|
||||||
list.push(comp)
|
|
||||||
groups.set(key, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-8">
|
|
||||||
{BEHAVIOR_ORDER.map((key) => {
|
|
||||||
const items = groups.get(key)
|
|
||||||
if (!items?.length) return null
|
|
||||||
return (
|
|
||||||
<section key={key}>
|
|
||||||
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
|
|
||||||
{BEHAVIOR_LABELS[key] ?? key}
|
|
||||||
</h2>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{items.map((comp) => (
|
|
||||||
<ComponentCard
|
|
||||||
key={comp.id}
|
|
||||||
component={comp}
|
|
||||||
health={statusMap.get(comp.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,18 @@
|
|||||||
import { useMemo, useState } from "react"
|
import { useMemo, useState } from "react"
|
||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, Plug, Unplug } from "lucide-react"
|
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"
|
||||||
import type { ComponentSummary } from "@/types"
|
import type { ProgramSummary } from "@/types"
|
||||||
import { useToolAction } from "@/services/api/hooks"
|
|
||||||
import { BehaviorBadge } from "./BehaviorBadge"
|
import { BehaviorBadge } from "./BehaviorBadge"
|
||||||
import { StackBadge } from "./StackBadge"
|
import { StackBadge } from "./StackBadge"
|
||||||
|
import { ProgramActions } from "./ProgramActions"
|
||||||
|
|
||||||
interface ComponentTableProps {
|
interface ComponentTableProps {
|
||||||
components: ComponentSummary[]
|
components: ProgramSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = "id" | "stack" | "behavior" | "status"
|
type SortKey = "id" | "stack" | "behavior"
|
||||||
type SortDir = "asc" | "desc"
|
type SortDir = "asc" | "desc"
|
||||||
|
|
||||||
function installedRank(installed: boolean | null): number {
|
|
||||||
if (installed === false) return 0
|
|
||||||
if (installed === true) return 1
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ComponentTable({ components }: ComponentTableProps) {
|
export function ComponentTable({ components }: ComponentTableProps) {
|
||||||
const [search, setSearch] = useState("")
|
const [search, setSearch] = useState("")
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("id")
|
const [sortKey, setSortKey] = useState<SortKey>("id")
|
||||||
@@ -53,8 +47,6 @@ export function ComponentTable({ components }: ComponentTableProps) {
|
|||||||
return dir * (a.stack ?? "").localeCompare(b.stack ?? "")
|
return dir * (a.stack ?? "").localeCompare(b.stack ?? "")
|
||||||
case "behavior":
|
case "behavior":
|
||||||
return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "")
|
return dir * (a.behavior ?? "").localeCompare(b.behavior ?? "")
|
||||||
case "status":
|
|
||||||
return dir * (installedRank(a.installed) - installedRank(b.installed))
|
|
||||||
default:
|
default:
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
@@ -67,7 +59,7 @@ export function ComponentTable({ components }: ComponentTableProps) {
|
|||||||
<input
|
<input
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="Filter components..."
|
placeholder="Filter programs..."
|
||||||
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
|
className="bg-black/30 border border-[var(--border)] rounded px-3 py-1.5 text-sm focus:outline-none focus:border-[var(--primary)] w-56"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -79,21 +71,17 @@ export function ComponentTable({ components }: ComponentTableProps) {
|
|||||||
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
<SortHeader label="Stack" sortKey="stack" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
<SortHeader label="Stack" sortKey="stack" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
<SortHeader label="Behavior" sortKey="behavior" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
<SortHeader label="Behavior" sortKey="behavior" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||||
<SortHeader label="Status" sortKey="status" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
|
||||||
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
|
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sorted.map((comp) => (
|
{sorted.map((comp) => (
|
||||||
<ComponentRow
|
<ComponentRow key={comp.id} component={comp} />
|
||||||
key={comp.id}
|
|
||||||
component={comp}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
{sorted.length === 0 && (
|
{sorted.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-3 py-6 text-center text-[var(--muted)]">
|
<td colSpan={4} className="px-3 py-6 text-center text-[var(--muted)]">
|
||||||
No components match.
|
No programs match.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -132,33 +120,12 @@ function SortHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InstalledBadge({ installed }: { installed: boolean }) {
|
function ComponentRow({ component }: { component: ProgramSummary }) {
|
||||||
return installed ? (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-green-900/40 text-green-400 border border-green-800/50">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-green-400" />
|
|
||||||
installed
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-zinc-800/40 text-[var(--muted)] border border-[var(--border)]">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-zinc-500" />
|
|
||||||
not installed
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function ComponentRow({
|
|
||||||
component,
|
|
||||||
}: {
|
|
||||||
component: ComponentSummary
|
|
||||||
}) {
|
|
||||||
const isTool = component.installed !== null
|
|
||||||
const { mutate: toolAction, isPending: toolPending } = useToolAction()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
|
<tr className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<Link
|
<Link
|
||||||
to={`/components/${component.id}`}
|
to={`/programs/${component.id}`}
|
||||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{component.id}
|
{component.id}
|
||||||
@@ -176,38 +143,7 @@ function ComponentRow({
|
|||||||
<BehaviorBadge behavior={component.behavior} />
|
<BehaviorBadge behavior={component.behavior} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
{isTool ? (
|
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} compact />
|
||||||
<InstalledBadge installed={component.installed!} />
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--muted)]">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2.5">
|
|
||||||
{isTool ? (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{component.installed ? (
|
|
||||||
<button
|
|
||||||
onClick={() => toolAction({ name: component.id, action: "uninstall" })}
|
|
||||||
disabled={toolPending}
|
|
||||||
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
|
|
||||||
title="Uninstall from PATH"
|
|
||||||
>
|
|
||||||
<Unplug size={14} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => toolAction({ name: component.id, action: "install" })}
|
|
||||||
disabled={toolPending}
|
|
||||||
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
|
|
||||||
title="Install to PATH"
|
|
||||||
>
|
|
||||||
<Plug size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-[var(--muted)]">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useImperativeHandle, useRef, useState, forwardRef } from "react"
|
||||||
import { apiClient } from "@/services/api/client"
|
import { apiClient } from "@/services/api/client"
|
||||||
|
|
||||||
interface LogViewerProps {
|
interface LogViewerProps {
|
||||||
@@ -7,7 +7,14 @@ interface LogViewerProps {
|
|||||||
follow?: boolean
|
follow?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
|
export interface LogViewerHandle {
|
||||||
|
clear: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogViewer = forwardRef<LogViewerHandle, LogViewerProps>(function LogViewer(
|
||||||
|
{ name, lines = 50, follow = true },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
const [logs, setLogs] = useState<string[]>([])
|
const [logs, setLogs] = useState<string[]>([])
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
const bottomRef = useRef<HTMLDivElement>(null)
|
const bottomRef = useRef<HTMLDivElement>(null)
|
||||||
@@ -40,6 +47,8 @@ export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
|
|||||||
return () => es.close()
|
return () => es.close()
|
||||||
}, [name, lines, follow])
|
}, [name, lines, follow])
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({ clear: () => setLogs([]) }), [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
bottomRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||||
}, [logs])
|
}, [logs])
|
||||||
@@ -68,4 +77,4 @@ export function LogViewer({ name, lines = 50, follow = true }: LogViewerProps) {
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
})
|
||||||
|
|||||||
167
app/src/components/ProgramActions.tsx
Normal file
167
app/src/components/ProgramActions.tsx
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
FileCheck,
|
||||||
|
FlaskConical,
|
||||||
|
Hammer,
|
||||||
|
Loader2,
|
||||||
|
Plug,
|
||||||
|
ShieldCheck,
|
||||||
|
Sparkles,
|
||||||
|
Unplug,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { useProgramAction } from "@/services/api/hooks"
|
||||||
|
|
||||||
|
interface ActionConfig {
|
||||||
|
icon: LucideIcon
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
hoverBg: string
|
||||||
|
borderColor: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_CONFIG: Record<string, ActionConfig> = {
|
||||||
|
build: { icon: Hammer, label: "Build", color: "text-blue-400", hoverBg: "hover:bg-blue-800/30", borderColor: "border-blue-800" },
|
||||||
|
test: { icon: FlaskConical, label: "Test", color: "text-purple-400", hoverBg: "hover:bg-purple-800/30", borderColor: "border-purple-800" },
|
||||||
|
lint: { icon: Sparkles, label: "Lint", color: "text-amber-400", hoverBg: "hover:bg-amber-800/30", borderColor: "border-amber-800" },
|
||||||
|
"type-check": { icon: FileCheck, label: "Type Check", color: "text-cyan-400", hoverBg: "hover:bg-cyan-800/30", borderColor: "border-cyan-800" },
|
||||||
|
check: { icon: ShieldCheck, label: "Check All", color: "text-green-400", hoverBg: "hover:bg-green-800/30", borderColor: "border-green-800" },
|
||||||
|
install: { icon: Plug, label: "Install", color: "text-green-400", hoverBg: "hover:bg-green-800/30", borderColor: "border-green-800" },
|
||||||
|
uninstall: { icon: Unplug, label: "Uninstall", color: "text-red-400", hoverBg: "hover:bg-red-800/30", borderColor: "border-red-800" },
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
|
||||||
|
|
||||||
|
interface ProgramActionsProps {
|
||||||
|
name: string
|
||||||
|
actions: string[]
|
||||||
|
installed?: boolean | null
|
||||||
|
compact?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleActions(
|
||||||
|
actions: string[],
|
||||||
|
installed: boolean | null | undefined,
|
||||||
|
compact: boolean,
|
||||||
|
): string[] {
|
||||||
|
if (compact) {
|
||||||
|
// Table: only install/uninstall based on state
|
||||||
|
if (installed === true) return actions.includes("uninstall") ? ["uninstall"] : []
|
||||||
|
if (installed === false) return actions.includes("install") ? ["install"] : []
|
||||||
|
// null — show install if available (frontends, etc.)
|
||||||
|
return actions.includes("install") ? ["install"] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail page: dev actions always, install/uninstall based on state
|
||||||
|
const visible: string[] = []
|
||||||
|
for (const a of DEV_ACTIONS) {
|
||||||
|
if (actions.includes(a)) visible.push(a)
|
||||||
|
}
|
||||||
|
if (installed === true) {
|
||||||
|
if (actions.includes("uninstall")) visible.push("uninstall")
|
||||||
|
} else if (installed === false) {
|
||||||
|
if (actions.includes("install")) visible.push("install")
|
||||||
|
} else {
|
||||||
|
// null — show both if available
|
||||||
|
if (actions.includes("install")) visible.push("install")
|
||||||
|
if (actions.includes("uninstall")) visible.push("uninstall")
|
||||||
|
}
|
||||||
|
return visible
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgramActions({ name, actions, installed, compact }: ProgramActionsProps) {
|
||||||
|
const { mutate, isPending } = useProgramAction()
|
||||||
|
const [runningAction, setRunningAction] = useState<string | null>(null)
|
||||||
|
const [output, setOutput] = useState<{ action: string; text: string; ok: boolean } | null>(null)
|
||||||
|
|
||||||
|
const visible = visibleActions(actions, installed, !!compact)
|
||||||
|
|
||||||
|
const handleAction = (action: string) => {
|
||||||
|
setRunningAction(action)
|
||||||
|
setOutput(null)
|
||||||
|
mutate(
|
||||||
|
{ name, action },
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
setRunningAction(null)
|
||||||
|
// Show output for dev actions on detail page
|
||||||
|
if (!compact && DEV_ACTIONS.includes(action) && data.output) {
|
||||||
|
setOutput({ action, text: data.output, ok: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setRunningAction(null)
|
||||||
|
if (!compact && DEV_ACTIONS.includes(action)) {
|
||||||
|
// Extract detail from API error JSON
|
||||||
|
let text = String(err)
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse((err as Error).message)
|
||||||
|
text = parsed.detail ?? text
|
||||||
|
} catch {
|
||||||
|
text = (err as Error).message ?? text
|
||||||
|
}
|
||||||
|
setOutput({ action, text, ok: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visible.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap">
|
||||||
|
{visible.map((action) => {
|
||||||
|
const config = ACTION_CONFIG[action]
|
||||||
|
if (!config) return null
|
||||||
|
const Icon = config.icon
|
||||||
|
const isRunning = isPending && runningAction === action
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
disabled={isPending}
|
||||||
|
className={`p-1 rounded ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
|
||||||
|
title={config.label}
|
||||||
|
>
|
||||||
|
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
onClick={() => handleAction(action)}
|
||||||
|
disabled={isPending}
|
||||||
|
className={`flex items-center gap-1.5 px-2 py-1 text-sm rounded border ${config.borderColor} ${config.color} ${config.hoverBg} transition-colors disabled:opacity-40`}
|
||||||
|
>
|
||||||
|
{isRunning ? <Loader2 size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||||
|
{config.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{output && !compact && (
|
||||||
|
<div className={`mt-3 rounded-lg border overflow-hidden ${output.ok ? "border-[var(--border)]" : "border-red-800"}`}>
|
||||||
|
<div className={`flex items-center justify-between px-3 py-1.5 text-xs ${output.ok ? "text-green-400 bg-green-900/20" : "text-red-400 bg-red-900/20"}`}>
|
||||||
|
<span>{ACTION_CONFIG[output.action]?.label ?? output.action} — {output.ok ? "ok" : "error"}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setOutput(null)}
|
||||||
|
className="text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||||
|
>
|
||||||
|
dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="p-3 text-xs font-mono text-gray-300 overflow-x-auto max-h-64 overflow-y-auto bg-black/40">
|
||||||
|
{output.text}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Link } from "react-router-dom"
|
import { Link } from "react-router-dom"
|
||||||
import { Play, RefreshCw, Square } from "lucide-react"
|
import { Play, RefreshCw, Square } from "lucide-react"
|
||||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
import type { JobSummary, HealthStatus } from "@/types"
|
||||||
import { useServiceAction } from "@/services/api/hooks"
|
import { useServiceAction } from "@/services/api/hooks"
|
||||||
import { SectionHeader } from "./SectionHeader"
|
import { SectionHeader } from "./SectionHeader"
|
||||||
import { StackBadge } from "./StackBadge"
|
import { StackBadge } from "./StackBadge"
|
||||||
|
|
||||||
interface ScheduledSectionProps {
|
interface ScheduledSectionProps {
|
||||||
jobs: ComponentSummary[]
|
jobs: JobSummary[]
|
||||||
statuses: HealthStatus[]
|
statuses: HealthStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ export function ScheduledSection({ jobs, statuses }: ScheduledSectionProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduledRow({ job, health }: { job: ComponentSummary; health?: HealthStatus }) {
|
function ScheduledRow({ job, health }: { job: JobSummary; health?: HealthStatus }) {
|
||||||
const { mutate, isPending } = useServiceAction()
|
const { mutate, isPending } = useServiceAction()
|
||||||
const isDown = health?.status === "down"
|
const isDown = health?.status === "down"
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
import type { ServiceSummary, HealthStatus } from "@/types"
|
||||||
import { ComponentCard } from "./ComponentCard"
|
import { ComponentCard } from "./ComponentCard"
|
||||||
import { SectionHeader } from "./SectionHeader"
|
import { SectionHeader } from "./SectionHeader"
|
||||||
|
|
||||||
interface ServiceSectionProps {
|
interface ServiceSectionProps {
|
||||||
services: ComponentSummary[]
|
services: ServiceSummary[]
|
||||||
statuses: HealthStatus[]
|
statuses: HealthStatus[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { useNavigate } from "react-router-dom"
|
|||||||
import { useQueryClient } from "@tanstack/react-query"
|
import { useQueryClient } from "@tanstack/react-query"
|
||||||
import { Check } from "lucide-react"
|
import { Check } from "lucide-react"
|
||||||
import { apiClient } from "@/services/api/client"
|
import { apiClient } from "@/services/api/client"
|
||||||
import type { ComponentDetail } from "@/types"
|
import type { AnyDetail } from "@/types"
|
||||||
import { ComponentFields } from "@/components/ComponentFields"
|
import { ComponentFields } from "@/components/ComponentFields"
|
||||||
|
|
||||||
interface ConfigPanelProps {
|
interface ConfigPanelProps {
|
||||||
component: ComponentDetail
|
component: AnyDetail
|
||||||
configSection: "services" | "jobs" | "components"
|
configSection: "services" | "jobs" | "programs"
|
||||||
onRefetch: () => void
|
onRefetch: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export function ConfigPanel({ component, configSection, onRefetch }: ConfigPanel
|
|||||||
await apiClient.put(`/config/${configSection}/${compName}`, { config })
|
await apiClient.put(`/config/${configSection}/${compName}`, { config })
|
||||||
setMessage({ type: "ok", text: "Saved to castle.yaml" })
|
setMessage({ type: "ok", text: "Saved to castle.yaml" })
|
||||||
onRefetch()
|
onRefetch()
|
||||||
qc.invalidateQueries({ queryKey: ["components"] })
|
qc.invalidateQueries({ queryKey: [configSection] })
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
setMessage({ type: "error", text: msg })
|
setMessage({ type: "error", text: msg })
|
||||||
@@ -33,7 +33,7 @@ export function ConfigPanel({ component, configSection, onRefetch }: ConfigPanel
|
|||||||
const handleDelete = async (compName: string) => {
|
const handleDelete = async (compName: string) => {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/config/${configSection}/${compName}`)
|
await apiClient.delete(`/config/${configSection}/${compName}`)
|
||||||
qc.invalidateQueries({ queryKey: ["components"] })
|
qc.invalidateQueries({ queryKey: [configSection] })
|
||||||
navigate("/")
|
navigate("/")
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const msg = e instanceof Error ? e.message : String(e)
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export const STACK_LABELS: Record<string, string> = {
|
|||||||
export const SECTION_HEADERS: Record<string, { title: string; subtitle: string }> = {
|
export const SECTION_HEADERS: Record<string, { title: string; subtitle: string }> = {
|
||||||
service: { title: "Services", subtitle: "Long-running processes" },
|
service: { title: "Services", subtitle: "Long-running processes" },
|
||||||
scheduled: { title: "Scheduled Jobs", subtitle: "Systemd timers" },
|
scheduled: { title: "Scheduled Jobs", subtitle: "Systemd timers" },
|
||||||
component: { title: "Components", subtitle: "Software catalog" },
|
program: { title: "Programs", subtitle: "Software catalog" },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runnerLabel(runner: string): string {
|
export function runnerLabel(runner: string): string {
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { Plug, Unplug } from "lucide-react"
|
import { useProgram, useEventStream, useToolDetail } from "@/services/api/hooks"
|
||||||
import { useComponent, useEventStream, useToolDetail, useToolAction } from "@/services/api/hooks"
|
|
||||||
import { runnerLabel } from "@/lib/labels"
|
import { runnerLabel } from "@/lib/labels"
|
||||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||||
import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
||||||
|
import { ProgramActions } from "@/components/ProgramActions"
|
||||||
|
|
||||||
export function ComponentDetailPage() {
|
export function ComponentDetailPage() {
|
||||||
useEventStream()
|
useEventStream()
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
|
const { data: component, isLoading, error, refetch } = useProgram(name ?? "")
|
||||||
const isTool = component?.behavior === "tool"
|
const isTool = component?.behavior === "tool"
|
||||||
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
||||||
const { mutate: toolAction, isPending: toolPending } = useToolAction()
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -23,7 +22,7 @@ export function ComponentDetailPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||||
<DetailHeader backTo="/" backLabel="Back" name={name ?? ""} />
|
<DetailHeader backTo="/" backLabel="Back" name={name ?? ""} />
|
||||||
<p className="text-red-400">Component not found</p>
|
<p className="text-red-400">Program not found</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -32,33 +31,13 @@ export function ComponentDetailPage() {
|
|||||||
<div className="max-w-3xl mx-auto px-6 py-8">
|
<div className="max-w-3xl mx-auto px-6 py-8">
|
||||||
<DetailHeader
|
<DetailHeader
|
||||||
backTo="/"
|
backTo="/"
|
||||||
backLabel="Back to Components"
|
backLabel="Back to Programs"
|
||||||
name={component.id}
|
name={component.id}
|
||||||
behavior={component.behavior}
|
behavior={component.behavior}
|
||||||
stack={component.stack}
|
stack={component.stack}
|
||||||
source={component.source}
|
source={component.source}
|
||||||
>
|
>
|
||||||
{isTool && (
|
<ProgramActions name={component.id} actions={component.actions} installed={component.installed} />
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{component.installed ? (
|
|
||||||
<button
|
|
||||||
onClick={() => toolAction({ name: component.id, action: "uninstall" })}
|
|
||||||
disabled={toolPending}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-red-800 text-red-400 hover:bg-red-800/20 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Unplug size={14} /> Uninstall
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => toolAction({ name: component.id, action: "install" })}
|
|
||||||
disabled={toolPending}
|
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-green-800 text-green-400 hover:bg-green-800/20 transition-colors disabled:opacity-40"
|
|
||||||
>
|
|
||||||
<Plug size={14} /> Install to PATH
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</DetailHeader>
|
</DetailHeader>
|
||||||
|
|
||||||
{component.description && (
|
{component.description && (
|
||||||
@@ -121,7 +100,7 @@ export function ComponentDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfigPanel component={component} configSection="components" onRefetch={refetch} />
|
<ConfigPanel component={component} configSection="programs" onRefetch={refetch} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function ComponentRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error || !component) {
|
if (error || !component) {
|
||||||
return <Navigate to={`/components/${name}`} replace />
|
return <Navigate to={`/programs/${name}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (component.managed && !component.schedule) {
|
if (component.managed && !component.schedule) {
|
||||||
@@ -20,8 +20,8 @@ export function ComponentRedirect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (component.managed && component.schedule) {
|
if (component.managed && component.schedule) {
|
||||||
return <Navigate to={`/scheduled/${name}`} replace />
|
return <Navigate to={`/jobs/${name}`} replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <Navigate to={`/components/${name}`} replace />
|
return <Navigate to={`/programs/${name}`} replace />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { useMemo } from "react"
|
import { useServices, useJobs, usePrograms, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
|
||||||
import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
|
|
||||||
import { GatewayPanel } from "@/components/GatewayPanel"
|
import { GatewayPanel } from "@/components/GatewayPanel"
|
||||||
import { MeshPanel } from "@/components/MeshPanel"
|
import { MeshPanel } from "@/components/MeshPanel"
|
||||||
import { NodeBar } from "@/components/NodeBar"
|
import { NodeBar } from "@/components/NodeBar"
|
||||||
@@ -11,20 +10,16 @@ import { SectionHeader } from "@/components/SectionHeader"
|
|||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
useEventStream()
|
useEventStream()
|
||||||
const { data: components, isLoading } = useComponents()
|
const { data: services, isLoading: loadingServices } = useServices()
|
||||||
|
const { data: jobs, isLoading: loadingJobs } = useJobs()
|
||||||
|
const { data: programs, isLoading: loadingPrograms } = usePrograms()
|
||||||
const { data: statusResp } = useStatus()
|
const { data: statusResp } = useStatus()
|
||||||
const { data: gateway } = useGateway()
|
const { data: gateway } = useGateway()
|
||||||
const { data: nodes } = useNodes()
|
const { data: nodes } = useNodes()
|
||||||
const { data: mesh } = useMeshStatus()
|
const { data: mesh } = useMeshStatus()
|
||||||
|
|
||||||
const { services, scheduled, catalog } = useMemo(() => {
|
|
||||||
const svc = (components ?? []).filter((c) => c.category === "service")
|
|
||||||
const sch = (components ?? []).filter((c) => c.category === "job")
|
|
||||||
const cat = (components ?? []).filter((c) => c.category === "component")
|
|
||||||
return { services: svc, scheduled: sch, catalog: cat }
|
|
||||||
}, [components])
|
|
||||||
|
|
||||||
const statuses = statusResp?.statuses ?? []
|
const statuses = statusResp?.statuses ?? []
|
||||||
|
const isLoading = loadingServices || loadingJobs || loadingPrograms
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||||
@@ -51,16 +46,16 @@ export function Dashboard() {
|
|||||||
<p className="text-[var(--muted)]">Loading...</p>
|
<p className="text-[var(--muted)]">Loading...</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
{services.length > 0 && (
|
{services && services.length > 0 && (
|
||||||
<ServiceSection services={services} statuses={statuses} />
|
<ServiceSection services={services} statuses={statuses} />
|
||||||
)}
|
)}
|
||||||
{scheduled.length > 0 && (
|
{jobs && jobs.length > 0 && (
|
||||||
<ScheduledSection jobs={scheduled} statuses={statuses} />
|
<ScheduledSection jobs={jobs} statuses={statuses} />
|
||||||
)}
|
)}
|
||||||
{catalog.length > 0 && (
|
{programs && programs.length > 0 && (
|
||||||
<section>
|
<section>
|
||||||
<SectionHeader section="component" />
|
<SectionHeader section="program" />
|
||||||
<ComponentTable components={catalog} />
|
<ComponentTable components={programs} />
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { useRef } from "react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { Clock } from "lucide-react"
|
import { Clock, Trash2 } from "lucide-react"
|
||||||
import { useComponent, useStatus, useEventStream } from "@/services/api/hooks"
|
import { useJob, useStatus, useEventStream } from "@/services/api/hooks"
|
||||||
import { LogViewer } from "@/components/LogViewer"
|
import { LogViewer, type LogViewerHandle } from "@/components/LogViewer"
|
||||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||||
import { ServiceControls } from "@/components/detail/ServiceControls"
|
import { ServiceControls } from "@/components/detail/ServiceControls"
|
||||||
import { SystemdPanel } from "@/components/detail/SystemdPanel"
|
import { SystemdPanel } from "@/components/detail/SystemdPanel"
|
||||||
@@ -9,8 +10,9 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
|||||||
|
|
||||||
export function ScheduledDetailPage() {
|
export function ScheduledDetailPage() {
|
||||||
useEventStream()
|
useEventStream()
|
||||||
|
const logRef = useRef<LogViewerHandle>(null)
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
|
const { data: component, isLoading, error, refetch } = useJob(name ?? "")
|
||||||
const { data: statusResp } = useStatus()
|
const { data: statusResp } = useStatus()
|
||||||
const health = statusResp?.statuses.find((s) => s.id === name)
|
const health = statusResp?.statuses.find((s) => s.id === name)
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ export function ScheduledDetailPage() {
|
|||||||
backTo="/"
|
backTo="/"
|
||||||
backLabel="Back to Jobs"
|
backLabel="Back to Jobs"
|
||||||
name={component.id}
|
name={component.id}
|
||||||
behavior={component.behavior}
|
behavior="tool"
|
||||||
stack={component.stack}
|
stack={component.stack}
|
||||||
source={component.source}
|
source={component.source}
|
||||||
>
|
>
|
||||||
@@ -73,8 +75,16 @@ export function ScheduledDetailPage() {
|
|||||||
|
|
||||||
{component.managed && (
|
{component.managed && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-3">Logs</h2>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<LogViewer name={component.id} />
|
<h2 className="text-lg font-semibold">Logs</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => logRef.current?.clear()}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<LogViewer ref={logRef} name={component.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import { useRef } from "react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { Server, ExternalLink, Terminal } from "lucide-react"
|
import { Server, ExternalLink, Terminal, Trash2 } from "lucide-react"
|
||||||
import { useComponent, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
|
import { useService, useStatus, useEventStream, useCaddyfile } from "@/services/api/hooks"
|
||||||
import { runnerLabel } from "@/lib/labels"
|
import { runnerLabel } from "@/lib/labels"
|
||||||
import { HealthBadge } from "@/components/HealthBadge"
|
import { HealthBadge } from "@/components/HealthBadge"
|
||||||
import { LogViewer } from "@/components/LogViewer"
|
import { LogViewer, type LogViewerHandle } from "@/components/LogViewer"
|
||||||
import { DetailHeader } from "@/components/detail/DetailHeader"
|
import { DetailHeader } from "@/components/detail/DetailHeader"
|
||||||
import { ServiceControls } from "@/components/detail/ServiceControls"
|
import { ServiceControls } from "@/components/detail/ServiceControls"
|
||||||
import { SystemdPanel } from "@/components/detail/SystemdPanel"
|
import { SystemdPanel } from "@/components/detail/SystemdPanel"
|
||||||
@@ -11,8 +12,9 @@ import { ConfigPanel } from "@/components/detail/ConfigPanel"
|
|||||||
|
|
||||||
export function ServiceDetailPage() {
|
export function ServiceDetailPage() {
|
||||||
useEventStream()
|
useEventStream()
|
||||||
|
const logRef = useRef<LogViewerHandle>(null)
|
||||||
const { name } = useParams<{ name: string }>()
|
const { name } = useParams<{ name: string }>()
|
||||||
const { data: component, isLoading, error, refetch } = useComponent(name ?? "")
|
const { data: component, isLoading, error, refetch } = useService(name ?? "")
|
||||||
const { data: statusResp } = useStatus()
|
const { data: statusResp } = useStatus()
|
||||||
const health = statusResp?.statuses.find((s) => s.id === name)
|
const health = statusResp?.statuses.find((s) => s.id === name)
|
||||||
const isGateway = name === "castle-gateway"
|
const isGateway = name === "castle-gateway"
|
||||||
@@ -41,7 +43,7 @@ export function ServiceDetailPage() {
|
|||||||
backTo="/"
|
backTo="/"
|
||||||
backLabel="Back to Services"
|
backLabel="Back to Services"
|
||||||
name={component.id}
|
name={component.id}
|
||||||
behavior={component.behavior}
|
behavior="daemon"
|
||||||
stack={component.stack}
|
stack={component.stack}
|
||||||
source={component.source}
|
source={component.source}
|
||||||
>
|
>
|
||||||
@@ -129,8 +131,16 @@ export function ServiceDetailPage() {
|
|||||||
|
|
||||||
{component.managed && (
|
{component.managed && (
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h2 className="text-lg font-semibold mb-3">Logs</h2>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<LogViewer name={component.id} />
|
<h2 className="text-lg font-semibold">Logs</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => logRef.current?.clear()}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border border-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)] transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} /> Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<LogViewer ref={logRef} name={component.id} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const router = createBrowserRouter([
|
|||||||
element: <ScheduledDetailPage />,
|
element: <ScheduledDetailPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/components/:name",
|
path: "/programs/:name",
|
||||||
element: <ComponentDetailPage />,
|
element: <ComponentDetailPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,8 +2,13 @@ import { useEffect } from "react"
|
|||||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
import { apiClient } from "./client"
|
import { apiClient } from "./client"
|
||||||
import type {
|
import type {
|
||||||
ComponentSummary,
|
|
||||||
ComponentDetail,
|
ComponentDetail,
|
||||||
|
ServiceSummary,
|
||||||
|
ServiceDetail,
|
||||||
|
JobSummary,
|
||||||
|
JobDetail,
|
||||||
|
ProgramSummary,
|
||||||
|
ProgramDetail,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
GatewayInfo,
|
GatewayInfo,
|
||||||
ServiceActionResponse,
|
ServiceActionResponse,
|
||||||
@@ -15,13 +20,7 @@ import type {
|
|||||||
ToolDetail,
|
ToolDetail,
|
||||||
} from "@/types"
|
} from "@/types"
|
||||||
|
|
||||||
export function useComponents() {
|
// Legacy compat hook — used by ConfigEditorPage and ComponentRedirect
|
||||||
return useQuery({
|
|
||||||
queryKey: ["components"],
|
|
||||||
queryFn: () => apiClient.get<ComponentSummary[]>("/components"),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useComponent(name: string) {
|
export function useComponent(name: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["components", name],
|
queryKey: ["components", name],
|
||||||
@@ -30,6 +29,51 @@ export function useComponent(name: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useServices() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["services"],
|
||||||
|
queryFn: () => apiClient.get<ServiceSummary[]>("/services"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useService(name: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["services", name],
|
||||||
|
queryFn: () => apiClient.get<ServiceDetail>(`/services/${name}`),
|
||||||
|
enabled: !!name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJobs() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["jobs"],
|
||||||
|
queryFn: () => apiClient.get<JobSummary[]>("/jobs"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJob(name: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["jobs", name],
|
||||||
|
queryFn: () => apiClient.get<JobDetail>(`/jobs/${name}`),
|
||||||
|
enabled: !!name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePrograms() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["programs"],
|
||||||
|
queryFn: () => apiClient.get<ProgramSummary[]>("/programs"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProgram(name: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["programs", name],
|
||||||
|
queryFn: () => apiClient.get<ProgramDetail>(`/programs/${name}`),
|
||||||
|
enabled: !!name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function useStatus() {
|
export function useStatus() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["status"],
|
queryKey: ["status"],
|
||||||
@@ -97,15 +141,15 @@ export function useServiceAction() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToolAction() {
|
export function useProgramAction() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ name, action }: { name: string; action: "install" | "uninstall" }) =>
|
mutationFn: ({ name, action }: { name: string; action: string }) =>
|
||||||
apiClient.post<{ component: string; action: string; status: string }>(
|
apiClient.post<{ component: string; action: string; status: string; output: string }>(
|
||||||
`/tools/${name}/${action}`,
|
`/programs/${name}/${action}`,
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: ["components"] })
|
qc.invalidateQueries({ queryKey: ["programs"] })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -171,9 +215,10 @@ export function useEventStream() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
es.addEventListener("service-action", () => {
|
es.addEventListener("service-action", () => {
|
||||||
// Health event already pushes correct status; just refetch components
|
// Health event already pushes correct status; just refetch services/jobs
|
||||||
// in case the action changed what's available
|
// in case the action changed what's available
|
||||||
qc.invalidateQueries({ queryKey: ["components"] })
|
qc.invalidateQueries({ queryKey: ["services"] })
|
||||||
|
qc.invalidateQueries({ queryKey: ["jobs"] })
|
||||||
})
|
})
|
||||||
|
|
||||||
es.addEventListener("mesh", () => {
|
es.addEventListener("mesh", () => {
|
||||||
|
|||||||
@@ -4,9 +4,65 @@ export interface SystemdInfo {
|
|||||||
timer: boolean
|
timer: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceSummary {
|
||||||
|
id: string
|
||||||
|
description: string | null
|
||||||
|
stack: string | null
|
||||||
|
runner: string | null
|
||||||
|
port: number | null
|
||||||
|
health_path: string | null
|
||||||
|
proxy_path: string | null
|
||||||
|
managed: boolean
|
||||||
|
systemd: SystemdInfo | null
|
||||||
|
source: string | null
|
||||||
|
node: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceDetail extends ServiceSummary {
|
||||||
|
manifest: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobSummary {
|
||||||
|
id: string
|
||||||
|
description: string | null
|
||||||
|
stack: string | null
|
||||||
|
runner: string | null
|
||||||
|
schedule: string | null
|
||||||
|
managed: boolean
|
||||||
|
systemd: SystemdInfo | null
|
||||||
|
source: string | null
|
||||||
|
node: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobDetail extends JobSummary {
|
||||||
|
manifest: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgramSummary {
|
||||||
|
id: string
|
||||||
|
description: string | null
|
||||||
|
behavior: string | null
|
||||||
|
stack: string | null
|
||||||
|
runner: string | null
|
||||||
|
version: string | null
|
||||||
|
source: string | null
|
||||||
|
system_dependencies: string[]
|
||||||
|
installed: boolean | null
|
||||||
|
actions: string[]
|
||||||
|
node: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgramDetail extends ProgramSummary {
|
||||||
|
manifest: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union for shared detail components (ConfigPanel, ComponentFields)
|
||||||
|
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
|
||||||
|
|
||||||
|
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
|
||||||
export interface ComponentSummary {
|
export interface ComponentSummary {
|
||||||
id: string
|
id: string
|
||||||
category: "component" | "service" | "job" | null
|
category: "program" | "service" | "job" | null
|
||||||
description: string | null
|
description: string | null
|
||||||
behavior: string | null
|
behavior: string | null
|
||||||
stack: string | null
|
stack: string | null
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, status
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from castle_core.config import save_config
|
from castle_core.config import save_config
|
||||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||||
|
|
||||||
from castle_api.config import get_castle_root, get_config, get_registry
|
from castle_api.config import get_castle_root, get_config, get_registry
|
||||||
from castle_api.stream import broadcast
|
from castle_api.stream import broadcast
|
||||||
@@ -28,7 +28,7 @@ class ConfigSaveRequest(BaseModel):
|
|||||||
|
|
||||||
class ConfigSaveResponse(BaseModel):
|
class ConfigSaveResponse(BaseModel):
|
||||||
ok: bool
|
ok: bool
|
||||||
component_count: int
|
program_count: int
|
||||||
service_count: int
|
service_count: int
|
||||||
job_count: int
|
job_count: int
|
||||||
errors: list[str]
|
errors: list[str]
|
||||||
@@ -40,7 +40,7 @@ class ApplyResponse(BaseModel):
|
|||||||
errors: list[str]
|
errors: list[str]
|
||||||
|
|
||||||
|
|
||||||
class ComponentConfigRequest(BaseModel):
|
class ProgramConfigRequest(BaseModel):
|
||||||
config: dict
|
config: dict
|
||||||
|
|
||||||
|
|
||||||
@@ -92,16 +92,17 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
|||||||
detail="YAML must be a mapping",
|
detail="YAML must be a mapping",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Validate components
|
# Validate programs
|
||||||
comp_count = 0
|
prog_count = 0
|
||||||
for name, comp_data in data.get("components", {}).items():
|
programs_data = data.get("programs") or data.get("components") or {}
|
||||||
|
for name, comp_data in programs_data.items():
|
||||||
try:
|
try:
|
||||||
comp_data_copy = dict(comp_data) if comp_data else {}
|
comp_data_copy = dict(comp_data) if comp_data else {}
|
||||||
comp_data_copy["id"] = name
|
comp_data_copy["id"] = name
|
||||||
ComponentSpec.model_validate(comp_data_copy)
|
ProgramSpec.model_validate(comp_data_copy)
|
||||||
comp_count += 1
|
prog_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
errors.append(f"components.{name}: {e}")
|
errors.append(f"programs.{name}: {e}")
|
||||||
|
|
||||||
# Validate services
|
# Validate services
|
||||||
svc_count = 0
|
svc_count = 0
|
||||||
@@ -138,46 +139,46 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
|||||||
config_path.write_text(request.yaml_content)
|
config_path.write_text(request.yaml_content)
|
||||||
|
|
||||||
return ConfigSaveResponse(
|
return ConfigSaveResponse(
|
||||||
ok=True, component_count=comp_count, service_count=svc_count,
|
ok=True, program_count=prog_count, service_count=svc_count,
|
||||||
job_count=job_count, errors=[],
|
job_count=job_count, errors=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/components/{name}")
|
@router.put("/programs/{name}")
|
||||||
def save_component(name: str, request: ComponentConfigRequest) -> dict:
|
def save_program(name: str, request: ProgramConfigRequest) -> dict:
|
||||||
"""Update a single component's config in castle.yaml."""
|
"""Update a single program's config in castle.yaml."""
|
||||||
_require_repo()
|
_require_repo()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
comp_data = dict(request.config)
|
prog_data = dict(request.config)
|
||||||
comp_data["id"] = name
|
prog_data["id"] = name
|
||||||
ComponentSpec.model_validate(comp_data)
|
ProgramSpec.model_validate(prog_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
detail=f"Invalid component config: {e}",
|
detail=f"Invalid program config: {e}",
|
||||||
)
|
)
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
config.components[name] = ComponentSpec.model_validate(
|
config.programs[name] = ProgramSpec.model_validate(
|
||||||
{**request.config, "id": name}
|
{**request.config, "id": name}
|
||||||
)
|
)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return {"ok": True, "component": name}
|
return {"ok": True, "program": name}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/components/{name}")
|
@router.delete("/programs/{name}")
|
||||||
def delete_component(name: str) -> dict:
|
def delete_program(name: str) -> dict:
|
||||||
"""Remove a component from castle.yaml."""
|
"""Remove a program from castle.yaml."""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
if name not in config.components:
|
if name not in config.programs:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Component '{name}' not found",
|
detail=f"Program '{name}' not found",
|
||||||
)
|
)
|
||||||
del config.components[name]
|
del config.programs[name]
|
||||||
save_config(config)
|
save_config(config)
|
||||||
return {"ok": True, "component": name, "action": "deleted"}
|
return {"ok": True, "program": name, "action": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/services/{name}")
|
@router.put("/services/{name}")
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ class ComponentSummary(BaseModel):
|
|||||||
"""Summary of a single component."""
|
"""Summary of a single component."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
category: str | None = None # "component", "service", or "job"
|
category: str | None = None # "program", "service", or "job"
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
behavior: str | None = None
|
behavior: str | None = None
|
||||||
stack: str | None = None
|
stack: str | None = None
|
||||||
@@ -39,6 +39,70 @@ class ComponentDetail(ComponentSummary):
|
|||||||
manifest: dict
|
manifest: dict
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceSummary(BaseModel):
|
||||||
|
"""Summary of a service (long-running daemon)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
description: str | None = None
|
||||||
|
stack: str | None = None
|
||||||
|
runner: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
health_path: str | None = None
|
||||||
|
proxy_path: str | None = None
|
||||||
|
managed: bool = False
|
||||||
|
systemd: SystemdInfo | None = None
|
||||||
|
source: str | None = None
|
||||||
|
node: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceDetail(ServiceSummary):
|
||||||
|
"""Full detail for a service, including raw manifest."""
|
||||||
|
|
||||||
|
manifest: dict
|
||||||
|
|
||||||
|
|
||||||
|
class JobSummary(BaseModel):
|
||||||
|
"""Summary of a job (scheduled task)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
description: str | None = None
|
||||||
|
stack: str | None = None
|
||||||
|
runner: str | None = None
|
||||||
|
schedule: str | None = None
|
||||||
|
managed: bool = False
|
||||||
|
systemd: SystemdInfo | None = None
|
||||||
|
source: str | None = None
|
||||||
|
node: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class JobDetail(JobSummary):
|
||||||
|
"""Full detail for a job, including raw manifest."""
|
||||||
|
|
||||||
|
manifest: dict
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramSummary(BaseModel):
|
||||||
|
"""Summary of a program (software catalog entry)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
description: str | None = None
|
||||||
|
behavior: str | None = None
|
||||||
|
stack: str | None = None
|
||||||
|
runner: str | None = None
|
||||||
|
version: str | None = None
|
||||||
|
source: str | None = None
|
||||||
|
system_dependencies: list[str] = []
|
||||||
|
installed: bool | None = None
|
||||||
|
actions: list[str] = []
|
||||||
|
node: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProgramDetail(ProgramSummary):
|
||||||
|
"""Full detail for a program, including raw manifest."""
|
||||||
|
|
||||||
|
manifest: dict
|
||||||
|
|
||||||
|
|
||||||
class HealthStatus(BaseModel):
|
class HealthStatus(BaseModel):
|
||||||
"""Health status of a single component."""
|
"""Health status of a single component."""
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from fastapi import APIRouter, HTTPException, status
|
|||||||
|
|
||||||
from castle_core.config import GENERATED_DIR
|
from castle_core.config import GENERATED_DIR
|
||||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||||
|
from castle_core.stacks import available_actions
|
||||||
|
|
||||||
from castle_api.config import get_castle_root, get_registry
|
from castle_api.config import get_castle_root, get_registry
|
||||||
from castle_api.mesh import mesh_state
|
from castle_api.mesh import mesh_state
|
||||||
@@ -20,6 +21,12 @@ from castle_api.models import (
|
|||||||
ComponentSummary,
|
ComponentSummary,
|
||||||
GatewayInfo,
|
GatewayInfo,
|
||||||
GatewayRoute,
|
GatewayRoute,
|
||||||
|
JobDetail,
|
||||||
|
JobSummary,
|
||||||
|
ProgramDetail,
|
||||||
|
ProgramSummary,
|
||||||
|
ServiceDetail,
|
||||||
|
ServiceSummary,
|
||||||
StatusResponse,
|
StatusResponse,
|
||||||
SystemdInfo,
|
SystemdInfo,
|
||||||
)
|
)
|
||||||
@@ -88,8 +95,8 @@ def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> Compon
|
|||||||
description = svc.description
|
description = svc.description
|
||||||
source = None
|
source = None
|
||||||
stack = None
|
stack = None
|
||||||
if svc.component and svc.component in config.components:
|
if svc.component and svc.component in config.programs:
|
||||||
comp = config.components[svc.component]
|
comp = config.programs[svc.component]
|
||||||
if not description:
|
if not description:
|
||||||
description = comp.description
|
description = comp.description
|
||||||
source = comp.source
|
source = comp.source
|
||||||
@@ -126,8 +133,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
|
|||||||
description = job.description
|
description = job.description
|
||||||
source = None
|
source = None
|
||||||
stack = None
|
stack = None
|
||||||
if job.component and job.component in config.components:
|
if job.component and job.component in config.programs:
|
||||||
comp = config.components[job.component]
|
comp = config.programs[job.component]
|
||||||
if not description:
|
if not description:
|
||||||
description = comp.description
|
description = comp.description
|
||||||
source = comp.source
|
source = comp.source
|
||||||
@@ -147,8 +154,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
|
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
|
||||||
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
|
"""Build a ComponentSummary from a ProgramSpec (tools/frontends)."""
|
||||||
# Determine behavior
|
# Determine behavior
|
||||||
is_tool = bool((comp.install and comp.install.path) or comp.tool)
|
is_tool = bool((comp.install and comp.install.path) or comp.tool)
|
||||||
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
|
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
|
||||||
@@ -178,7 +185,7 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
|
|||||||
|
|
||||||
return ComponentSummary(
|
return ComponentSummary(
|
||||||
id=name,
|
id=name,
|
||||||
category="component",
|
category="program",
|
||||||
description=comp.description,
|
description=comp.description,
|
||||||
behavior=behavior,
|
behavior=behavior,
|
||||||
stack=comp.stack,
|
stack=comp.stack,
|
||||||
@@ -190,6 +197,454 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Typed builder functions — one per concept
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_systemd_info(name: str, timer: bool = False) -> SystemdInfo:
|
||||||
|
unit_name = f"castle-{name}.service"
|
||||||
|
unit_path = str(Path("~/.config/systemd/user") / unit_name)
|
||||||
|
return SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=timer)
|
||||||
|
|
||||||
|
|
||||||
|
def _backfill_source(name: str, config: object) -> str | None:
|
||||||
|
"""Resolve source path from program ref in config."""
|
||||||
|
if name in config.programs:
|
||||||
|
return config.programs[name].source
|
||||||
|
ref = None
|
||||||
|
if name in config.services:
|
||||||
|
ref = config.services[name].component
|
||||||
|
elif name in config.jobs:
|
||||||
|
ref = config.jobs[name].component
|
||||||
|
if ref and ref in config.programs:
|
||||||
|
return config.programs[ref].source
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
||||||
|
"""Build a ServiceSummary from a DeployedComponent."""
|
||||||
|
systemd_info = _make_systemd_info(name) if deployed.managed else None
|
||||||
|
return ServiceSummary(
|
||||||
|
id=name,
|
||||||
|
description=deployed.description,
|
||||||
|
stack=deployed.stack,
|
||||||
|
runner=deployed.runner,
|
||||||
|
port=deployed.port,
|
||||||
|
health_path=deployed.health_path,
|
||||||
|
proxy_path=deployed.proxy_path,
|
||||||
|
managed=deployed.managed,
|
||||||
|
systemd=systemd_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service_from_spec(
|
||||||
|
name: str, svc: ServiceSpec, config: object
|
||||||
|
) -> ServiceSummary:
|
||||||
|
"""Build a ServiceSummary from a ServiceSpec."""
|
||||||
|
port = None
|
||||||
|
health_path = None
|
||||||
|
proxy_path = None
|
||||||
|
if svc.expose and svc.expose.http:
|
||||||
|
port = svc.expose.http.internal.port
|
||||||
|
health_path = svc.expose.http.health_path
|
||||||
|
if svc.proxy and svc.proxy.caddy:
|
||||||
|
proxy_path = svc.proxy.caddy.path_prefix
|
||||||
|
|
||||||
|
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
|
||||||
|
systemd_info = _make_systemd_info(name) if managed else None
|
||||||
|
|
||||||
|
description = svc.description
|
||||||
|
source = None
|
||||||
|
stack = None
|
||||||
|
if svc.component and svc.component in config.programs:
|
||||||
|
comp = config.programs[svc.component]
|
||||||
|
if not description:
|
||||||
|
description = comp.description
|
||||||
|
source = comp.source
|
||||||
|
stack = comp.stack
|
||||||
|
|
||||||
|
return ServiceSummary(
|
||||||
|
id=name,
|
||||||
|
description=description,
|
||||||
|
stack=stack,
|
||||||
|
runner=svc.run.runner,
|
||||||
|
port=port,
|
||||||
|
health_path=health_path,
|
||||||
|
proxy_path=proxy_path,
|
||||||
|
managed=managed,
|
||||||
|
systemd=systemd_info,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
||||||
|
"""Build a JobSummary from a DeployedComponent."""
|
||||||
|
systemd_info = (
|
||||||
|
_make_systemd_info(name, timer=True) if deployed.managed else None
|
||||||
|
)
|
||||||
|
return JobSummary(
|
||||||
|
id=name,
|
||||||
|
description=deployed.description,
|
||||||
|
stack=deployed.stack,
|
||||||
|
runner=deployed.runner,
|
||||||
|
schedule=deployed.schedule,
|
||||||
|
managed=deployed.managed,
|
||||||
|
systemd=systemd_info,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
|
||||||
|
"""Build a JobSummary from a JobSpec."""
|
||||||
|
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
|
||||||
|
systemd_info = _make_systemd_info(name, timer=True) if managed else None
|
||||||
|
|
||||||
|
description = job.description
|
||||||
|
source = None
|
||||||
|
stack = None
|
||||||
|
if job.component and job.component in config.programs:
|
||||||
|
comp = config.programs[job.component]
|
||||||
|
if not description:
|
||||||
|
description = comp.description
|
||||||
|
source = comp.source
|
||||||
|
stack = comp.stack
|
||||||
|
|
||||||
|
return JobSummary(
|
||||||
|
id=name,
|
||||||
|
description=description,
|
||||||
|
stack=stack,
|
||||||
|
runner=job.run.runner,
|
||||||
|
schedule=job.schedule,
|
||||||
|
managed=managed,
|
||||||
|
systemd=systemd_info,
|
||||||
|
source=source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _program_from_spec(
|
||||||
|
name: str, comp: ProgramSpec, root: Path, config: object | None = None
|
||||||
|
) -> ProgramSummary:
|
||||||
|
"""Build a ProgramSummary from a ProgramSpec."""
|
||||||
|
is_tool = bool((comp.install and comp.install.path) or comp.tool)
|
||||||
|
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
|
||||||
|
|
||||||
|
if is_tool:
|
||||||
|
behavior = "tool"
|
||||||
|
elif is_frontend:
|
||||||
|
behavior = "frontend"
|
||||||
|
else:
|
||||||
|
behavior = None
|
||||||
|
|
||||||
|
# Derive behavior from backing service/job
|
||||||
|
if behavior is None and config is not None:
|
||||||
|
svc_components = {
|
||||||
|
s.component for s in config.services.values() if s.component
|
||||||
|
}
|
||||||
|
job_components = {
|
||||||
|
j.component for j in config.jobs.values() if j.component
|
||||||
|
}
|
||||||
|
if name in svc_components or name in config.services:
|
||||||
|
behavior = "daemon"
|
||||||
|
elif name in job_components or name in config.jobs:
|
||||||
|
behavior = "tool"
|
||||||
|
|
||||||
|
source = comp.source
|
||||||
|
runner = None
|
||||||
|
if source:
|
||||||
|
source_dir = root / source
|
||||||
|
if (source_dir / "pyproject.toml").exists():
|
||||||
|
runner = "python"
|
||||||
|
elif source_dir.is_file():
|
||||||
|
runner = "command"
|
||||||
|
|
||||||
|
installed: bool | None = None
|
||||||
|
if comp.install and comp.install.path:
|
||||||
|
alias = comp.install.path.alias or name
|
||||||
|
installed = shutil.which(alias) is not None
|
||||||
|
elif config is not None:
|
||||||
|
# Daemons: check if the service's run.tool binary is on PATH
|
||||||
|
svc = config.services.get(name)
|
||||||
|
if svc and hasattr(svc.run, "tool"):
|
||||||
|
installed = shutil.which(svc.run.tool) is not None
|
||||||
|
|
||||||
|
return ProgramSummary(
|
||||||
|
id=name,
|
||||||
|
description=comp.description,
|
||||||
|
behavior=behavior,
|
||||||
|
stack=comp.stack,
|
||||||
|
runner=runner,
|
||||||
|
version=comp.tool.version if comp.tool else None,
|
||||||
|
source=source,
|
||||||
|
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
|
||||||
|
installed=installed,
|
||||||
|
actions=available_actions(comp),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Typed endpoints — /services, /jobs, /programs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/services", response_model=list[ServiceSummary], tags=["services-data"])
|
||||||
|
def list_services(include_remote: bool = False) -> list[ServiceSummary]:
|
||||||
|
"""List all services — deployed from registry, non-deployed from castle.yaml."""
|
||||||
|
registry = get_registry()
|
||||||
|
hostname = registry.node.hostname
|
||||||
|
summaries: list[ServiceSummary] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
# Deployed services (non-scheduled)
|
||||||
|
for name, deployed in registry.deployed.items():
|
||||||
|
if deployed.schedule:
|
||||||
|
continue
|
||||||
|
s = _service_from_deployed(name, deployed)
|
||||||
|
s.node = hostname
|
||||||
|
# Backfill source
|
||||||
|
root = get_castle_root()
|
||||||
|
if root and s.source is None:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
s.source = _backfill_source(name, config)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
summaries.append(s)
|
||||||
|
seen.add(name)
|
||||||
|
|
||||||
|
# Non-deployed from castle.yaml
|
||||||
|
root = get_castle_root()
|
||||||
|
if root:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
for name, svc in config.services.items():
|
||||||
|
if name not in seen:
|
||||||
|
s = _service_from_spec(name, svc, config)
|
||||||
|
s.node = hostname
|
||||||
|
summaries.append(s)
|
||||||
|
seen.add(name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remote
|
||||||
|
if include_remote:
|
||||||
|
for remote_host, remote in mesh_state.all_nodes().items():
|
||||||
|
for name, d in remote.registry.deployed.items():
|
||||||
|
if not d.schedule and name not in seen:
|
||||||
|
s = _service_from_deployed(name, d)
|
||||||
|
s.node = remote_host
|
||||||
|
summaries.append(s)
|
||||||
|
seen.add(name)
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/services/{name}",
|
||||||
|
response_model=ServiceDetail,
|
||||||
|
tags=["services-data"],
|
||||||
|
)
|
||||||
|
def get_service(name: str) -> ServiceDetail:
|
||||||
|
"""Get detailed info for a single service."""
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
if name in registry.deployed and not registry.deployed[name].schedule:
|
||||||
|
deployed = registry.deployed[name]
|
||||||
|
summary = _service_from_deployed(name, deployed)
|
||||||
|
root = get_castle_root()
|
||||||
|
if root and summary.source is None:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
summary.source = _backfill_source(name, config)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
raw = {
|
||||||
|
"runner": deployed.runner,
|
||||||
|
"run_cmd": deployed.run_cmd,
|
||||||
|
"env": deployed.env,
|
||||||
|
"port": deployed.port,
|
||||||
|
"health_path": deployed.health_path,
|
||||||
|
"proxy_path": deployed.proxy_path,
|
||||||
|
"managed": deployed.managed,
|
||||||
|
"behavior": deployed.behavior,
|
||||||
|
"stack": deployed.stack,
|
||||||
|
}
|
||||||
|
return ServiceDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
|
root = get_castle_root()
|
||||||
|
if root:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
if name in config.services:
|
||||||
|
svc = config.services[name]
|
||||||
|
summary = _service_from_spec(name, svc, config)
|
||||||
|
raw = svc.model_dump(mode="json", exclude_none=True)
|
||||||
|
return ServiceDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Service '{name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs", response_model=list[JobSummary], tags=["jobs-data"])
|
||||||
|
def list_jobs(include_remote: bool = False) -> list[JobSummary]:
|
||||||
|
"""List all jobs — deployed from registry, non-deployed from castle.yaml."""
|
||||||
|
registry = get_registry()
|
||||||
|
hostname = registry.node.hostname
|
||||||
|
summaries: list[JobSummary] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
# Deployed jobs (scheduled)
|
||||||
|
for name, deployed in registry.deployed.items():
|
||||||
|
if not deployed.schedule:
|
||||||
|
continue
|
||||||
|
s = _job_from_deployed(name, deployed)
|
||||||
|
s.node = hostname
|
||||||
|
root = get_castle_root()
|
||||||
|
if root and s.source is None:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
s.source = _backfill_source(name, config)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
summaries.append(s)
|
||||||
|
seen.add(name)
|
||||||
|
|
||||||
|
# Non-deployed from castle.yaml
|
||||||
|
root = get_castle_root()
|
||||||
|
if root:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
for name, job in config.jobs.items():
|
||||||
|
if name not in seen:
|
||||||
|
s = _job_from_spec(name, job, config)
|
||||||
|
s.node = hostname
|
||||||
|
summaries.append(s)
|
||||||
|
seen.add(name)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remote
|
||||||
|
if include_remote:
|
||||||
|
for remote_host, remote in mesh_state.all_nodes().items():
|
||||||
|
for name, d in remote.registry.deployed.items():
|
||||||
|
if d.schedule and name not in seen:
|
||||||
|
s = _job_from_deployed(name, d)
|
||||||
|
s.node = remote_host
|
||||||
|
summaries.append(s)
|
||||||
|
seen.add(name)
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/jobs/{name}", response_model=JobDetail, tags=["jobs-data"])
|
||||||
|
def get_job(name: str) -> JobDetail:
|
||||||
|
"""Get detailed info for a single job."""
|
||||||
|
registry = get_registry()
|
||||||
|
|
||||||
|
if name in registry.deployed and registry.deployed[name].schedule:
|
||||||
|
deployed = registry.deployed[name]
|
||||||
|
summary = _job_from_deployed(name, deployed)
|
||||||
|
root = get_castle_root()
|
||||||
|
if root and summary.source is None:
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
summary.source = _backfill_source(name, config)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
raw = {
|
||||||
|
"runner": deployed.runner,
|
||||||
|
"run_cmd": deployed.run_cmd,
|
||||||
|
"env": deployed.env,
|
||||||
|
"managed": deployed.managed,
|
||||||
|
"schedule": deployed.schedule,
|
||||||
|
"behavior": deployed.behavior,
|
||||||
|
"stack": deployed.stack,
|
||||||
|
}
|
||||||
|
return JobDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
|
root = get_castle_root()
|
||||||
|
if root:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
if name in config.jobs:
|
||||||
|
job = config.jobs[name]
|
||||||
|
summary = _job_from_spec(name, job, config)
|
||||||
|
raw = job.model_dump(mode="json", exclude_none=True)
|
||||||
|
return JobDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Job '{name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/programs", response_model=list[ProgramSummary], tags=["programs"])
|
||||||
|
def list_programs() -> list[ProgramSummary]:
|
||||||
|
"""List all programs from the software catalog (castle.yaml programs section)."""
|
||||||
|
root = get_castle_root()
|
||||||
|
if not root:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
hostname = get_registry().node.hostname
|
||||||
|
summaries: list[ProgramSummary] = []
|
||||||
|
|
||||||
|
for name, comp in config.programs.items():
|
||||||
|
summary = _program_from_spec(name, comp, root, config)
|
||||||
|
if summary.behavior is None:
|
||||||
|
continue
|
||||||
|
summary.node = hostname
|
||||||
|
summaries.append(summary)
|
||||||
|
|
||||||
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/programs/{name}", response_model=ProgramDetail, tags=["programs"])
|
||||||
|
def get_program(name: str) -> ProgramDetail:
|
||||||
|
"""Get detailed info for a single program."""
|
||||||
|
root = get_castle_root()
|
||||||
|
if root:
|
||||||
|
from castle_core.config import load_config
|
||||||
|
|
||||||
|
config = load_config(root)
|
||||||
|
if name in config.programs:
|
||||||
|
comp = config.programs[name]
|
||||||
|
summary = _program_from_spec(name, comp, root, config)
|
||||||
|
raw = comp.model_dump(mode="json", exclude_none=True)
|
||||||
|
return ProgramDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Program '{name}' not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Legacy /components endpoint (compat shim)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/components", response_model=list[ComponentSummary])
|
@router.get("/components", response_model=list[ComponentSummary])
|
||||||
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
||||||
"""List all components — deployed from registry, non-deployed from castle.yaml.
|
"""List all components — deployed from registry, non-deployed from castle.yaml.
|
||||||
@@ -232,33 +687,37 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
|||||||
summaries.append(s)
|
summaries.append(s)
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
|
|
||||||
# Backfill source from component refs for deployed items
|
# Backfill source from program refs for deployed items
|
||||||
for s in summaries:
|
for s in summaries:
|
||||||
if s.source is None and s.id in config.components:
|
if s.source is None and s.id in config.programs:
|
||||||
s.source = config.components[s.id].source
|
s.source = config.programs[s.id].source
|
||||||
elif s.source is None:
|
elif s.source is None:
|
||||||
# Check if a service/job references a component
|
# Check if a service/job references a program
|
||||||
ref = None
|
ref = None
|
||||||
if s.id in config.services:
|
if s.id in config.services:
|
||||||
ref = config.services[s.id].component
|
ref = config.services[s.id].component
|
||||||
elif s.id in config.jobs:
|
elif s.id in config.jobs:
|
||||||
ref = config.jobs[s.id].component
|
ref = config.jobs[s.id].component
|
||||||
if ref and ref in config.components:
|
if ref and ref in config.programs:
|
||||||
s.source = config.components[ref].source
|
s.source = config.programs[ref].source
|
||||||
|
|
||||||
# Components (tools/frontends) not already represented by a
|
# Programs — always include entries from the programs
|
||||||
# service or job entry. Skip if the component has no
|
# section as catalog items. Derive behavior from backing
|
||||||
# distinct behavior (e.g. it's just the software identity
|
# service/job when the program has no install/build spec.
|
||||||
# behind a service).
|
svc_components = {s.component for s in config.services.values() if s.component}
|
||||||
for name, comp in config.components.items():
|
job_components = {j.component for j in config.jobs.values() if j.component}
|
||||||
if name in seen:
|
|
||||||
continue
|
for name, comp in config.programs.items():
|
||||||
summary = _summary_from_component(name, comp, root)
|
summary = _summary_from_program(name, comp, root)
|
||||||
if summary.behavior is None:
|
if summary.behavior is None:
|
||||||
continue
|
if name in svc_components or name in config.services:
|
||||||
|
summary.behavior = "daemon"
|
||||||
|
elif name in job_components or name in config.jobs:
|
||||||
|
summary.behavior = "tool"
|
||||||
|
else:
|
||||||
|
continue
|
||||||
summary.node = local_hostname
|
summary.node = local_hostname
|
||||||
summaries.append(summary)
|
summaries.append(summary)
|
||||||
seen.add(name)
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -297,23 +756,23 @@ def get_component(name: str) -> ComponentDetail:
|
|||||||
deployed = registry.deployed[name]
|
deployed = registry.deployed[name]
|
||||||
summary = _summary_from_deployed(name, deployed)
|
summary = _summary_from_deployed(name, deployed)
|
||||||
|
|
||||||
# Backfill source from castle.yaml component ref
|
# Backfill source from castle.yaml program ref
|
||||||
root = get_castle_root()
|
root = get_castle_root()
|
||||||
if root and summary.source is None:
|
if root and summary.source is None:
|
||||||
try:
|
try:
|
||||||
from castle_core.config import load_config
|
from castle_core.config import load_config
|
||||||
|
|
||||||
config = load_config(root)
|
config = load_config(root)
|
||||||
if name in config.components:
|
if name in config.programs:
|
||||||
summary.source = config.components[name].source
|
summary.source = config.programs[name].source
|
||||||
else:
|
else:
|
||||||
ref = None
|
ref = None
|
||||||
if name in config.services:
|
if name in config.services:
|
||||||
ref = config.services[name].component
|
ref = config.services[name].component
|
||||||
elif name in config.jobs:
|
elif name in config.jobs:
|
||||||
ref = config.jobs[name].component
|
ref = config.jobs[name].component
|
||||||
if ref and ref in config.components:
|
if ref and ref in config.programs:
|
||||||
summary.source = config.components[ref].source
|
summary.source = config.programs[ref].source
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -349,9 +808,9 @@ def get_component(name: str) -> ComponentDetail:
|
|||||||
raw = job.model_dump(mode="json", exclude_none=True)
|
raw = job.model_dump(mode="json", exclude_none=True)
|
||||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
if name in config.components:
|
if name in config.programs:
|
||||||
comp = config.components[name]
|
comp = config.programs[name]
|
||||||
summary = _summary_from_component(name, comp, root)
|
summary = _summary_from_program(name, comp, root)
|
||||||
raw = comp.model_dump(mode="json", exclude_none=True)
|
raw = comp.model_dump(mode="json", exclude_none=True)
|
||||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"""Tools router — browse and inspect tool components."""
|
"""Tools router and program actions."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
|
|
||||||
from castle_core.manifest import ComponentSpec
|
from castle_core.manifest import ProgramSpec
|
||||||
|
from castle_core.stacks import available_actions, get_handler
|
||||||
|
|
||||||
from castle_api.config import get_config
|
from castle_api.config import get_config
|
||||||
from castle_api.models import ToolDetail, ToolSummary
|
from castle_api.models import ToolDetail, ToolSummary
|
||||||
@@ -15,15 +15,15 @@ from castle_api.models import ToolDetail, ToolSummary
|
|||||||
router = APIRouter(tags=["tools"])
|
router = APIRouter(tags=["tools"])
|
||||||
|
|
||||||
|
|
||||||
def _is_tool(comp: ComponentSpec) -> bool:
|
def _is_tool(comp: ProgramSpec) -> bool:
|
||||||
"""Check if a component is a tool (has install.path or tool spec)."""
|
"""Check if a component is a tool (has install.path or tool spec)."""
|
||||||
return bool((comp.install and comp.install.path) or comp.tool)
|
return bool((comp.install and comp.install.path) or comp.tool)
|
||||||
|
|
||||||
|
|
||||||
def _tool_summary(
|
def _tool_summary(
|
||||||
name: str, comp: ComponentSpec, root: Path | None = None
|
name: str, comp: ProgramSpec, root: Path | None = None
|
||||||
) -> ToolSummary:
|
) -> ToolSummary:
|
||||||
"""Build a ToolSummary from a ComponentSpec that is a tool."""
|
"""Build a ToolSummary from a ProgramSpec that is a tool."""
|
||||||
t = comp.tool
|
t = comp.tool
|
||||||
installed = bool(
|
installed = bool(
|
||||||
comp.install and comp.install.path and comp.install.path.enable
|
comp.install and comp.install.path and comp.install.path.enable
|
||||||
@@ -54,7 +54,7 @@ def _tool_summary(
|
|||||||
def list_tools() -> list[ToolSummary]:
|
def list_tools() -> list[ToolSummary]:
|
||||||
"""List all registered tools (requires repo access)."""
|
"""List all registered tools (requires repo access)."""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
tools = {k: v for k, v in config.components.items() if _is_tool(v)}
|
tools = {k: v for k, v in config.programs.items() if _is_tool(v)}
|
||||||
|
|
||||||
return sorted(
|
return sorted(
|
||||||
[
|
[
|
||||||
@@ -70,13 +70,13 @@ def get_tool(name: str) -> ToolDetail:
|
|||||||
"""Get detailed info for a single tool."""
|
"""Get detailed info for a single tool."""
|
||||||
config = get_config()
|
config = get_config()
|
||||||
|
|
||||||
if name not in config.components:
|
if name not in config.programs:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Component '{name}' not found",
|
detail=f"Component '{name}' not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
comp = config.components[name]
|
comp = config.programs[name]
|
||||||
if not _is_tool(comp):
|
if not _is_tool(comp):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
@@ -87,82 +87,54 @@ def get_tool(name: str) -> ToolDetail:
|
|||||||
return ToolDetail(**summary.model_dump())
|
return ToolDetail(**summary.model_dump())
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tools/{name}/install")
|
# ---------------------------------------------------------------------------
|
||||||
async def install_tool(name: str) -> dict:
|
# Unified program action endpoint
|
||||||
"""Install a tool to PATH via uv tool install."""
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_VALID_ACTIONS = {"build", "test", "lint", "type-check", "check", "install", "uninstall"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/programs/{name}/{action}")
|
||||||
|
async def program_action(name: str, action: str) -> dict:
|
||||||
|
"""Run a lifecycle action on a program via its stack handler."""
|
||||||
|
if action not in _VALID_ACTIONS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
|
||||||
|
|
||||||
config = get_config()
|
config = get_config()
|
||||||
if name not in config.components:
|
if name not in config.programs:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
comp = config.components[name]
|
comp = config.programs[name]
|
||||||
if not comp.source:
|
if not comp.source:
|
||||||
|
raise HTTPException(status_code=400, detail=f"'{name}' has no source directory")
|
||||||
|
|
||||||
|
actions = available_actions(comp)
|
||||||
|
if action not in actions:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail=f"'{name}' has no source to install"
|
status_code=400,
|
||||||
|
detail=f"Action '{action}' not available for '{name}' (stack: {comp.stack})",
|
||||||
)
|
)
|
||||||
|
|
||||||
source_dir = config.root / comp.source
|
handler = get_handler(comp.stack)
|
||||||
if not (source_dir / "pyproject.toml").exists():
|
if handler is None:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400, detail=f"No pyproject.toml in {comp.source}"
|
status_code=400, detail=f"No handler for stack '{comp.stack}'"
|
||||||
)
|
)
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
# Map hyphenated action names to method names (type-check → type_check)
|
||||||
"uv",
|
method_name = action.replace("-", "_")
|
||||||
"tool",
|
method = getattr(handler, method_name)
|
||||||
"install",
|
|
||||||
"--editable",
|
|
||||||
str(source_dir),
|
|
||||||
"--force",
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
stdout, stderr = await proc.communicate()
|
|
||||||
output = (stdout or stderr or b"").decode().strip()
|
|
||||||
|
|
||||||
if proc.returncode != 0:
|
result = await method(name, comp, config.root)
|
||||||
raise HTTPException(status_code=500, detail=output or "Install failed")
|
|
||||||
|
|
||||||
return {"component": name, "action": "install", "status": "ok"}
|
if result.status != "ok":
|
||||||
|
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
|
||||||
|
|
||||||
|
return {
|
||||||
@router.post("/tools/{name}/uninstall")
|
"component": result.component,
|
||||||
async def uninstall_tool(name: str) -> dict:
|
"action": result.action,
|
||||||
"""Uninstall a tool from PATH via uv tool uninstall."""
|
"status": result.status,
|
||||||
config = get_config()
|
"output": result.output,
|
||||||
if name not in config.components:
|
}
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
|
||||||
)
|
|
||||||
|
|
||||||
comp = config.components[name]
|
|
||||||
if not comp.source:
|
|
||||||
raise HTTPException(status_code=400, detail=f"'{name}' has no source")
|
|
||||||
|
|
||||||
# uv tool uninstall uses the package name from pyproject.toml
|
|
||||||
source_dir = config.root / comp.source
|
|
||||||
pkg_name = source_dir.name
|
|
||||||
pyproject = source_dir / "pyproject.toml"
|
|
||||||
if pyproject.exists():
|
|
||||||
import tomllib
|
|
||||||
|
|
||||||
with open(pyproject, "rb") as f:
|
|
||||||
data = tomllib.load(f)
|
|
||||||
pkg_name = data.get("project", {}).get("name", pkg_name)
|
|
||||||
|
|
||||||
proc = await asyncio.create_subprocess_exec(
|
|
||||||
"uv",
|
|
||||||
"tool",
|
|
||||||
"uninstall",
|
|
||||||
pkg_name,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
)
|
|
||||||
stdout, stderr = await proc.communicate()
|
|
||||||
output = (stdout or stderr or b"").decode().strip()
|
|
||||||
|
|
||||||
if proc.returncode != 0:
|
|
||||||
raise HTTPException(status_code=500, detail=output or "Uninstall failed")
|
|
||||||
|
|
||||||
return {"component": name, "action": "uninstall", "status": "ok"}
|
|
||||||
|
|||||||
@@ -71,6 +71,169 @@ class TestComponentDetail:
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestServicesList:
|
||||||
|
"""GET /services endpoint tests."""
|
||||||
|
|
||||||
|
def test_returns_deployed_services(self, client: TestClient) -> None:
|
||||||
|
"""Returns deployed services from registry."""
|
||||||
|
response = client.get("/services")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [s["id"] for s in data]
|
||||||
|
assert "test-svc" in names
|
||||||
|
|
||||||
|
def test_service_has_port_and_health(self, client: TestClient) -> None:
|
||||||
|
"""Service summary includes port and health info."""
|
||||||
|
response = client.get("/services")
|
||||||
|
data = response.json()
|
||||||
|
svc = next(s for s in data if s["id"] == "test-svc")
|
||||||
|
assert svc["port"] == 19000
|
||||||
|
assert svc["health_path"] == "/health"
|
||||||
|
assert svc["proxy_path"] == "/test-svc"
|
||||||
|
assert svc["managed"] is True
|
||||||
|
|
||||||
|
def test_no_schedule_field(self, client: TestClient) -> None:
|
||||||
|
"""ServiceSummary does not have schedule field."""
|
||||||
|
response = client.get("/services")
|
||||||
|
data = response.json()
|
||||||
|
svc = next(s for s in data if s["id"] == "test-svc")
|
||||||
|
assert "schedule" not in svc
|
||||||
|
|
||||||
|
def test_no_installed_field(self, client: TestClient) -> None:
|
||||||
|
"""ServiceSummary does not have installed field."""
|
||||||
|
response = client.get("/services")
|
||||||
|
data = response.json()
|
||||||
|
svc = next(s for s in data if s["id"] == "test-svc")
|
||||||
|
assert "installed" not in svc
|
||||||
|
|
||||||
|
def test_excludes_jobs(self, client: TestClient) -> None:
|
||||||
|
"""Jobs (scheduled items) are not in the services list."""
|
||||||
|
response = client.get("/services")
|
||||||
|
data = response.json()
|
||||||
|
names = [s["id"] for s in data]
|
||||||
|
assert "test-job" not in names
|
||||||
|
|
||||||
|
|
||||||
|
class TestServiceDetail:
|
||||||
|
"""GET /services/{name} endpoint tests."""
|
||||||
|
|
||||||
|
def test_get_service(self, client: TestClient) -> None:
|
||||||
|
"""Returns detailed info for a service."""
|
||||||
|
response = client.get("/services/test-svc")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == "test-svc"
|
||||||
|
assert "manifest" in data
|
||||||
|
assert data["manifest"]["runner"] == "python"
|
||||||
|
|
||||||
|
def test_not_found(self, client: TestClient) -> None:
|
||||||
|
"""Returns 404 for unknown service."""
|
||||||
|
response = client.get("/services/nonexistent")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobsList:
|
||||||
|
"""GET /jobs endpoint tests."""
|
||||||
|
|
||||||
|
def test_returns_jobs(self, client: TestClient) -> None:
|
||||||
|
"""Returns jobs from castle.yaml."""
|
||||||
|
response = client.get("/jobs")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [j["id"] for j in data]
|
||||||
|
assert "test-job" in names
|
||||||
|
|
||||||
|
def test_job_has_schedule(self, client: TestClient) -> None:
|
||||||
|
"""Job summary includes schedule."""
|
||||||
|
response = client.get("/jobs")
|
||||||
|
data = response.json()
|
||||||
|
job = next(j for j in data if j["id"] == "test-job")
|
||||||
|
assert job["schedule"] == "0 2 * * *"
|
||||||
|
|
||||||
|
def test_no_port_field(self, client: TestClient) -> None:
|
||||||
|
"""JobSummary does not have port field."""
|
||||||
|
response = client.get("/jobs")
|
||||||
|
data = response.json()
|
||||||
|
job = next(j for j in data if j["id"] == "test-job")
|
||||||
|
assert "port" not in job
|
||||||
|
|
||||||
|
def test_excludes_services(self, client: TestClient) -> None:
|
||||||
|
"""Services (non-scheduled) are not in the jobs list."""
|
||||||
|
response = client.get("/jobs")
|
||||||
|
data = response.json()
|
||||||
|
names = [j["id"] for j in data]
|
||||||
|
assert "test-svc" not in names
|
||||||
|
|
||||||
|
|
||||||
|
class TestJobDetail:
|
||||||
|
"""GET /jobs/{name} endpoint tests."""
|
||||||
|
|
||||||
|
def test_get_job(self, client: TestClient) -> None:
|
||||||
|
"""Returns detailed info for a job."""
|
||||||
|
response = client.get("/jobs/test-job")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == "test-job"
|
||||||
|
assert "manifest" in data
|
||||||
|
assert data["schedule"] == "0 2 * * *"
|
||||||
|
|
||||||
|
def test_not_found(self, client: TestClient) -> None:
|
||||||
|
"""Returns 404 for unknown job."""
|
||||||
|
response = client.get("/jobs/nonexistent")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestProgramsList:
|
||||||
|
"""GET /programs endpoint tests."""
|
||||||
|
|
||||||
|
def test_returns_programs(self, client: TestClient) -> None:
|
||||||
|
"""Returns programs from castle.yaml."""
|
||||||
|
response = client.get("/programs")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
names = [p["id"] for p in data]
|
||||||
|
assert "test-tool" in names
|
||||||
|
|
||||||
|
def test_program_has_behavior(self, client: TestClient) -> None:
|
||||||
|
"""Program summary includes behavior."""
|
||||||
|
response = client.get("/programs")
|
||||||
|
data = response.json()
|
||||||
|
tool = next(p for p in data if p["id"] == "test-tool")
|
||||||
|
assert tool["behavior"] == "tool"
|
||||||
|
|
||||||
|
def test_no_port_field(self, client: TestClient) -> None:
|
||||||
|
"""ProgramSummary does not have port field."""
|
||||||
|
response = client.get("/programs")
|
||||||
|
data = response.json()
|
||||||
|
tool = next(p for p in data if p["id"] == "test-tool")
|
||||||
|
assert "port" not in tool
|
||||||
|
|
||||||
|
def test_no_schedule_field(self, client: TestClient) -> None:
|
||||||
|
"""ProgramSummary does not have schedule field."""
|
||||||
|
response = client.get("/programs")
|
||||||
|
data = response.json()
|
||||||
|
tool = next(p for p in data if p["id"] == "test-tool")
|
||||||
|
assert "schedule" not in tool
|
||||||
|
|
||||||
|
|
||||||
|
class TestProgramDetail:
|
||||||
|
"""GET /programs/{name} endpoint tests."""
|
||||||
|
|
||||||
|
def test_get_program(self, client: TestClient) -> None:
|
||||||
|
"""Returns detailed info for a program."""
|
||||||
|
response = client.get("/programs/test-tool")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["id"] == "test-tool"
|
||||||
|
assert "manifest" in data
|
||||||
|
assert data["behavior"] == "tool"
|
||||||
|
|
||||||
|
def test_not_found(self, client: TestClient) -> None:
|
||||||
|
"""Returns 404 for unknown program."""
|
||||||
|
response = client.get("/programs/nonexistent")
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
class TestGateway:
|
class TestGateway:
|
||||||
"""Gateway info endpoint tests."""
|
"""Gateway info endpoint tests."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
gateway:
|
gateway:
|
||||||
port: 9000
|
port: 9000
|
||||||
|
|
||||||
components:
|
programs:
|
||||||
central-context:
|
central-context:
|
||||||
description: Content storage API useful to get context into my data dir from anywhere
|
description: Content storage API useful to get context into my data dir from anywhere
|
||||||
on the LAN
|
on the LAN
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import argparse
|
|||||||
from castle_cli.config import load_config, save_config
|
from castle_cli.config import load_config, save_config
|
||||||
from castle_cli.manifest import (
|
from castle_cli.manifest import (
|
||||||
CaddySpec,
|
CaddySpec,
|
||||||
ComponentSpec,
|
ProgramSpec,
|
||||||
ExposeSpec,
|
ExposeSpec,
|
||||||
HttpExposeSpec,
|
HttpExposeSpec,
|
||||||
HttpInternal,
|
HttpInternal,
|
||||||
@@ -52,15 +52,15 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
stack = args.stack
|
stack = args.stack
|
||||||
behavior = STACK_DEFAULTS.get(stack)
|
behavior = STACK_DEFAULTS.get(stack)
|
||||||
|
|
||||||
if name in config.components or name in config.services or name in config.jobs:
|
if name in config.programs or name in config.services or name in config.jobs:
|
||||||
print(f"Error: '{name}' already exists in castle.yaml")
|
print(f"Error: '{name}' already exists in castle.yaml")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
components_dir = config.root / "components"
|
programs_dir = config.root / "programs"
|
||||||
components_dir.mkdir(exist_ok=True)
|
programs_dir.mkdir(exist_ok=True)
|
||||||
project_dir = components_dir / name
|
project_dir = programs_dir / name
|
||||||
if project_dir.exists():
|
if project_dir.exists():
|
||||||
print(f"Error: directory 'components/{name}' already exists")
|
print(f"Error: directory 'programs/{name}' already exists")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# Determine port for daemons
|
# Determine port for daemons
|
||||||
@@ -77,17 +77,17 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
name=name,
|
name=name,
|
||||||
package_name=package_name,
|
package_name=package_name,
|
||||||
stack=stack,
|
stack=stack,
|
||||||
description=args.description or f"A castle {stack} component",
|
description=args.description or f"A castle {stack} program",
|
||||||
port=port,
|
port=port,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build entries
|
# Build entries
|
||||||
if behavior == "daemon":
|
if behavior == "daemon":
|
||||||
# Component for software identity
|
# Program for software identity
|
||||||
config.components[name] = ComponentSpec(
|
config.programs[name] = ProgramSpec(
|
||||||
id=name,
|
id=name,
|
||||||
description=args.description or f"A castle {stack} component",
|
description=args.description or f"A castle {stack} program",
|
||||||
source=f"components/{name}",
|
source=f"programs/{name}",
|
||||||
stack=stack,
|
stack=stack,
|
||||||
)
|
)
|
||||||
# Service for deployment
|
# Service for deployment
|
||||||
@@ -105,31 +105,31 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
manage=ManageSpec(systemd=SystemdSpec()),
|
manage=ManageSpec(systemd=SystemdSpec()),
|
||||||
)
|
)
|
||||||
elif behavior == "tool":
|
elif behavior == "tool":
|
||||||
config.components[name] = ComponentSpec(
|
config.programs[name] = ProgramSpec(
|
||||||
id=name,
|
id=name,
|
||||||
description=args.description or f"A castle {stack} component",
|
description=args.description or f"A castle {stack} program",
|
||||||
source=f"components/{name}",
|
source=f"programs/{name}",
|
||||||
stack=stack,
|
stack=stack,
|
||||||
tool=ToolSpec(),
|
tool=ToolSpec(),
|
||||||
install=InstallSpec(path=PathInstallSpec(alias=name)),
|
install=InstallSpec(path=PathInstallSpec(alias=name)),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# frontend or other
|
# frontend or other
|
||||||
config.components[name] = ComponentSpec(
|
config.programs[name] = ProgramSpec(
|
||||||
id=name,
|
id=name,
|
||||||
description=args.description or f"A castle {stack} component",
|
description=args.description or f"A castle {stack} program",
|
||||||
source=f"components/{name}",
|
source=f"programs/{name}",
|
||||||
stack=stack,
|
stack=stack,
|
||||||
)
|
)
|
||||||
|
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
print(f"Created {stack} component '{name}' at {project_dir}")
|
print(f"Created {stack} program '{name}' at {project_dir}")
|
||||||
if port:
|
if port:
|
||||||
print(f" Port: {port}")
|
print(f" Port: {port}")
|
||||||
print(" Registered in castle.yaml")
|
print(" Registered in castle.yaml")
|
||||||
print("\nNext steps:")
|
print("\nNext steps:")
|
||||||
print(f" cd components/{name}")
|
print(f" cd programs/{name}")
|
||||||
print(" uv sync")
|
print(" uv sync")
|
||||||
if behavior == "daemon":
|
if behavior == "daemon":
|
||||||
print(f" uv run {name} # starts on port {port}")
|
print(f" uv run {name} # starts on port {port}")
|
||||||
|
|||||||
@@ -37,16 +37,16 @@ SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
|
|||||||
|
|
||||||
|
|
||||||
def run_deploy(args: argparse.Namespace) -> int:
|
def run_deploy(args: argparse.Namespace) -> int:
|
||||||
"""Deploy components from castle.yaml to ~/.castle/."""
|
"""Deploy from castle.yaml to ~/.castle/."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
target_name = getattr(args, "component", None)
|
target_name = getattr(args, "name", None)
|
||||||
|
|
||||||
ensure_dirs()
|
ensure_dirs()
|
||||||
|
|
||||||
# Build node config
|
# Build node config
|
||||||
node = NodeConfig(castle_root=str(config.root), gateway_port=config.gateway.port)
|
node = NodeConfig(castle_root=str(config.root), gateway_port=config.gateway.port)
|
||||||
|
|
||||||
# Load existing registry to preserve components not being redeployed,
|
# Load existing registry to preserve entries not being redeployed,
|
||||||
# or start fresh if deploying all
|
# or start fresh if deploying all
|
||||||
if target_name and REGISTRY_PATH.exists():
|
if target_name and REGISTRY_PATH.exists():
|
||||||
try:
|
try:
|
||||||
@@ -96,24 +96,24 @@ def run_deploy(args: argparse.Namespace) -> int:
|
|||||||
# Reload systemd daemon
|
# Reload systemd daemon
|
||||||
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
|
||||||
|
|
||||||
print(f"\nDeployed {deployed_count} component(s).")
|
print(f"\nDeployed {deployed_count} item(s).")
|
||||||
print("Run 'castle services start' to start all services.")
|
print("Run 'castle services start' to start all services.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def _env_prefix(name: str) -> str:
|
def _env_prefix(name: str) -> str:
|
||||||
"""Derive env var prefix from component name: central-context → CENTRAL_CONTEXT."""
|
"""Derive env var prefix from name: central-context → CENTRAL_CONTEXT."""
|
||||||
return name.replace("-", "_").upper()
|
return name.replace("-", "_").upper()
|
||||||
|
|
||||||
|
|
||||||
def _resolve_description(
|
def _resolve_description(
|
||||||
config: CastleConfig, spec: ServiceSpec | JobSpec
|
config: CastleConfig, spec: ServiceSpec | JobSpec
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Get description, falling through to component if referenced."""
|
"""Get description, falling through to program if referenced."""
|
||||||
if spec.description:
|
if spec.description:
|
||||||
return spec.description
|
return spec.description
|
||||||
if spec.component and spec.component in config.components:
|
if spec.component and spec.component in config.programs:
|
||||||
return config.components[spec.component].description
|
return config.programs[spec.component].description
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -155,10 +155,10 @@ def _build_deployed_service(
|
|||||||
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
|
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
|
||||||
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
|
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
|
||||||
|
|
||||||
# Resolve stack from referenced component
|
# Resolve stack from referenced program
|
||||||
stack = None
|
stack = None
|
||||||
if svc.component and svc.component in config.components:
|
if svc.component and svc.component in config.programs:
|
||||||
stack = config.components[svc.component].stack
|
stack = config.programs[svc.component].stack
|
||||||
|
|
||||||
return DeployedComponent(
|
return DeployedComponent(
|
||||||
runner=run.runner,
|
runner=run.runner,
|
||||||
@@ -195,10 +195,10 @@ def _build_deployed_job(
|
|||||||
# Build run_cmd
|
# Build run_cmd
|
||||||
run_cmd = _build_run_cmd(run, env)
|
run_cmd = _build_run_cmd(run, env)
|
||||||
|
|
||||||
# Resolve stack from referenced component
|
# Resolve stack from referenced program
|
||||||
stack = None
|
stack = None
|
||||||
if job.component and job.component in config.components:
|
if job.component and job.component in config.programs:
|
||||||
stack = config.components[job.component].stack
|
stack = config.programs[job.component].stack
|
||||||
|
|
||||||
return DeployedComponent(
|
return DeployedComponent(
|
||||||
runner=run.runner,
|
runner=run.runner,
|
||||||
@@ -275,10 +275,10 @@ def _print_deployed(name: str, deployed: DeployedComponent) -> None:
|
|||||||
|
|
||||||
def _copy_app_static(config: CastleConfig) -> None:
|
def _copy_app_static(config: CastleConfig) -> None:
|
||||||
"""Copy castle-app build output to ~/.castle/static/castle-app/."""
|
"""Copy castle-app build output to ~/.castle/static/castle-app/."""
|
||||||
if "castle-app" not in config.components:
|
if "castle-app" not in config.programs:
|
||||||
return
|
return
|
||||||
|
|
||||||
comp = config.components["castle-app"]
|
comp = config.programs["castle-app"]
|
||||||
if not (comp.build and comp.build.outputs):
|
if not (comp.build and comp.build.outputs):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -3,37 +3,44 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import subprocess
|
import asyncio
|
||||||
from pathlib import Path
|
|
||||||
|
from castle_core.stacks import get_handler
|
||||||
|
|
||||||
from castle_cli.config import CastleConfig, load_config
|
from castle_cli.config import CastleConfig, load_config
|
||||||
|
|
||||||
|
|
||||||
def _get_project_dir(config: CastleConfig, project_name: str) -> Path:
|
def _run_action(config: CastleConfig, project_name: str, action: str) -> bool:
|
||||||
"""Get the directory for a component."""
|
"""Run a stack action for a single project. Returns True on success."""
|
||||||
if project_name not in config.components:
|
if project_name not in config.programs:
|
||||||
raise ValueError(f"Unknown component: {project_name}")
|
print(f"Unknown component: {project_name}")
|
||||||
manifest = config.components[project_name]
|
return False
|
||||||
working_dir = manifest.source_dir or project_name
|
|
||||||
return config.root / working_dir
|
|
||||||
|
|
||||||
|
comp = config.programs[project_name]
|
||||||
|
if not comp.source:
|
||||||
|
print(f" {project_name}: no source directory, skipping")
|
||||||
|
return True
|
||||||
|
|
||||||
def _has_pyproject(project_dir: Path) -> bool:
|
handler = get_handler(comp.stack)
|
||||||
"""Check if a project directory has a pyproject.toml."""
|
if handler is None:
|
||||||
return (project_dir / "pyproject.toml").exists()
|
print(f" {project_name}: unsupported stack '{comp.stack}', skipping")
|
||||||
|
return True
|
||||||
|
|
||||||
|
method_name = action.replace("-", "_")
|
||||||
def _run_in_project(project_dir: Path, cmd: list[str], label: str) -> bool:
|
method = getattr(handler, method_name, None)
|
||||||
"""Run a command in a project directory. Returns True on success."""
|
if method is None:
|
||||||
if not _has_pyproject(project_dir):
|
print(f" {project_name}: action '{action}' not supported")
|
||||||
return True # Skip projects without pyproject.toml
|
return False
|
||||||
|
|
||||||
print(f"\n{'─' * 40}")
|
print(f"\n{'─' * 40}")
|
||||||
print(f" {label}: {project_dir.name}")
|
print(f" {action}: {project_name}")
|
||||||
print(f"{'─' * 40}")
|
print(f"{'─' * 40}")
|
||||||
|
|
||||||
result = subprocess.run(cmd, cwd=project_dir)
|
result = asyncio.run(method(project_name, comp, config.root))
|
||||||
return result.returncode == 0
|
if result.output:
|
||||||
|
print(result.output)
|
||||||
|
|
||||||
|
return result.status == "ok"
|
||||||
|
|
||||||
|
|
||||||
def run_test(args: argparse.Namespace) -> int:
|
def run_test(args: argparse.Namespace) -> int:
|
||||||
@@ -41,34 +48,23 @@ def run_test(args: argparse.Namespace) -> int:
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
if args.project:
|
if args.project:
|
||||||
project_dir = _get_project_dir(config, args.project)
|
success = _run_action(config, args.project, "test")
|
||||||
tests_dir = project_dir / "tests"
|
|
||||||
if not tests_dir.exists():
|
|
||||||
print(f"No tests directory found for {args.project}")
|
|
||||||
return 1
|
|
||||||
success = _run_in_project(
|
|
||||||
project_dir,
|
|
||||||
["uv", "run", "pytest", "tests/", "-v"],
|
|
||||||
"Testing",
|
|
||||||
)
|
|
||||||
return 0 if success else 1
|
return 0 if success else 1
|
||||||
|
|
||||||
# Run all
|
# Run all
|
||||||
all_passed = True
|
all_passed = True
|
||||||
for name, manifest in config.components.items():
|
for name, comp in config.programs.items():
|
||||||
working_dir = manifest.source_dir or name
|
if not comp.source:
|
||||||
project_dir = config.root / working_dir
|
|
||||||
tests_dir = project_dir / "tests"
|
|
||||||
if not tests_dir.exists():
|
|
||||||
continue
|
continue
|
||||||
if not _has_pyproject(project_dir):
|
handler = get_handler(comp.stack)
|
||||||
|
if handler is None:
|
||||||
continue
|
continue
|
||||||
success = _run_in_project(
|
# Skip projects without a tests directory (python) or test script (node)
|
||||||
project_dir,
|
source_dir = config.root / comp.source
|
||||||
["uv", "run", "pytest", "tests/", "-v"],
|
if comp.stack in ("python-cli", "python-fastapi"):
|
||||||
"Testing",
|
if not (source_dir / "tests").exists():
|
||||||
)
|
continue
|
||||||
if not success:
|
if not _run_action(config, name, "test"):
|
||||||
all_passed = False
|
all_passed = False
|
||||||
|
|
||||||
if all_passed:
|
if all_passed:
|
||||||
@@ -83,27 +79,18 @@ def run_lint(args: argparse.Namespace) -> int:
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
if args.project:
|
if args.project:
|
||||||
project_dir = _get_project_dir(config, args.project)
|
success = _run_action(config, args.project, "lint")
|
||||||
success = _run_in_project(
|
|
||||||
project_dir,
|
|
||||||
["uv", "run", "ruff", "check", "."],
|
|
||||||
"Linting",
|
|
||||||
)
|
|
||||||
return 0 if success else 1
|
return 0 if success else 1
|
||||||
|
|
||||||
# Run all
|
# Run all
|
||||||
all_passed = True
|
all_passed = True
|
||||||
for name, manifest in config.components.items():
|
for name, comp in config.programs.items():
|
||||||
working_dir = manifest.source_dir or name
|
if not comp.source:
|
||||||
project_dir = config.root / working_dir
|
|
||||||
if not _has_pyproject(project_dir):
|
|
||||||
continue
|
continue
|
||||||
success = _run_in_project(
|
handler = get_handler(comp.stack)
|
||||||
project_dir,
|
if handler is None:
|
||||||
["uv", "run", "ruff", "check", "."],
|
continue
|
||||||
"Linting",
|
if not _run_action(config, name, "lint"):
|
||||||
)
|
|
||||||
if not success:
|
|
||||||
all_passed = False
|
all_passed = False
|
||||||
|
|
||||||
if all_passed:
|
if all_passed:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""castle info - show detailed component information."""
|
"""castle info - show detailed program information."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ GREEN = "\033[92m"
|
|||||||
RED = "\033[91m"
|
RED = "\033[91m"
|
||||||
|
|
||||||
|
|
||||||
def _load_deployed_component(name: str) -> object | None:
|
def _load_deployed_program(name: str) -> object | None:
|
||||||
"""Try to load a specific deployed component from registry."""
|
"""Try to load a specific deployed program from registry."""
|
||||||
try:
|
try:
|
||||||
from castle_core.registry import load_registry
|
from castle_core.registry import load_registry
|
||||||
|
|
||||||
@@ -29,23 +29,23 @@ def _load_deployed_component(name: str) -> object | None:
|
|||||||
|
|
||||||
|
|
||||||
def run_info(args: argparse.Namespace) -> int:
|
def run_info(args: argparse.Namespace) -> int:
|
||||||
"""Show detailed info for a component, service, or job."""
|
"""Show detailed info for a program, service, or job."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
name = args.project
|
name = args.project
|
||||||
|
|
||||||
# Look up in all sections
|
# Look up in all sections
|
||||||
component = config.components.get(name)
|
program = config.programs.get(name)
|
||||||
service = config.services.get(name)
|
service = config.services.get(name)
|
||||||
job = config.jobs.get(name)
|
job = config.jobs.get(name)
|
||||||
|
|
||||||
if not component and not service and not job:
|
if not program and not service and not job:
|
||||||
print(f"Error: '{name}' not found in castle.yaml")
|
print(f"Error: '{name}' not found in castle.yaml")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
deployed = _load_deployed_component(name)
|
deployed = _load_deployed_program(name)
|
||||||
|
|
||||||
if getattr(args, "json", False):
|
if getattr(args, "json", False):
|
||||||
return _info_json(config, name, component, service, job, deployed)
|
return _info_json(config, name, program, service, job, deployed)
|
||||||
|
|
||||||
# Human-readable output
|
# Human-readable output
|
||||||
print(f"\n{BOLD}{name}{RESET}")
|
print(f"\n{BOLD}{name}{RESET}")
|
||||||
@@ -56,51 +56,51 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
print(f" {BOLD}behavior{RESET}: daemon")
|
print(f" {BOLD}behavior{RESET}: daemon")
|
||||||
elif job:
|
elif job:
|
||||||
print(f" {BOLD}behavior{RESET}: tool")
|
print(f" {BOLD}behavior{RESET}: tool")
|
||||||
elif component:
|
elif program:
|
||||||
if component.tool or (component.install and component.install.path):
|
if program.tool or (program.install and program.install.path):
|
||||||
print(f" {BOLD}behavior{RESET}: tool")
|
print(f" {BOLD}behavior{RESET}: tool")
|
||||||
elif component.build:
|
elif program.build:
|
||||||
print(f" {BOLD}behavior{RESET}: frontend")
|
print(f" {BOLD}behavior{RESET}: frontend")
|
||||||
else:
|
else:
|
||||||
print(f" {BOLD}behavior{RESET}: —")
|
print(f" {BOLD}behavior{RESET}: —")
|
||||||
|
|
||||||
# Show stack
|
# Show stack
|
||||||
stack = None
|
stack = None
|
||||||
if component and component.stack:
|
if program and program.stack:
|
||||||
stack = component.stack
|
stack = program.stack
|
||||||
elif service and service.component and service.component in config.components:
|
elif service and service.component and service.component in config.programs:
|
||||||
stack = config.components[service.component].stack
|
stack = config.programs[service.component].stack
|
||||||
elif job and job.component and job.component in config.components:
|
elif job and job.component and job.component in config.programs:
|
||||||
stack = config.components[job.component].stack
|
stack = config.programs[job.component].stack
|
||||||
if stack:
|
if stack:
|
||||||
print(f" {BOLD}stack{RESET}: {stack}")
|
print(f" {BOLD}stack{RESET}: {stack}")
|
||||||
|
|
||||||
# Component info
|
# Program info
|
||||||
if component:
|
if program:
|
||||||
if component.description:
|
if program.description:
|
||||||
print(f" {BOLD}description{RESET}: {component.description}")
|
print(f" {BOLD}description{RESET}: {program.description}")
|
||||||
if component.source:
|
if program.source:
|
||||||
print(f" {BOLD}source{RESET}: {component.source}")
|
print(f" {BOLD}source{RESET}: {program.source}")
|
||||||
if component.install and component.install.path:
|
if program.install and program.install.path:
|
||||||
pi = component.install.path
|
pi = program.install.path
|
||||||
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
|
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
|
||||||
if component.tool:
|
if program.tool:
|
||||||
t = component.tool
|
t = program.tool
|
||||||
if t.system_dependencies:
|
if t.system_dependencies:
|
||||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
||||||
if component.tags:
|
if program.tags:
|
||||||
print(f" {BOLD}tags{RESET}: {', '.join(component.tags)}")
|
print(f" {BOLD}tags{RESET}: {', '.join(program.tags)}")
|
||||||
|
|
||||||
# Service info
|
# Service info
|
||||||
spec = service or job
|
spec = service or job
|
||||||
if spec:
|
if spec:
|
||||||
desc = spec.description
|
desc = spec.description
|
||||||
if not desc and spec.component and spec.component in config.components:
|
if not desc and spec.component and spec.component in config.programs:
|
||||||
desc = config.components[spec.component].description
|
desc = config.programs[spec.component].description
|
||||||
if desc and not (component and component.description == desc):
|
if desc and not (program and program.description == desc):
|
||||||
print(f" {BOLD}description{RESET}: {desc}")
|
print(f" {BOLD}description{RESET}: {desc}")
|
||||||
if spec.component:
|
if spec.component:
|
||||||
print(f" {BOLD}component{RESET}: {spec.component}")
|
print(f" {BOLD}program{RESET}: {spec.component}")
|
||||||
|
|
||||||
# Run spec
|
# Run spec
|
||||||
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
|
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
|
||||||
@@ -154,10 +154,10 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
# Show CLAUDE.md if it exists
|
# Show CLAUDE.md if it exists
|
||||||
source_dir = None
|
source_dir = None
|
||||||
if component and component.source_dir:
|
if program and program.source_dir:
|
||||||
source_dir = component.source_dir
|
source_dir = program.source_dir
|
||||||
elif spec and spec.component and spec.component in config.components:
|
elif spec and spec.component and spec.component in config.programs:
|
||||||
source_dir = config.components[spec.component].source_dir
|
source_dir = config.programs[spec.component].source_dir
|
||||||
|
|
||||||
if source_dir:
|
if source_dir:
|
||||||
claude_md = _find_claude_md(config.root, source_dir)
|
claude_md = _find_claude_md(config.root, source_dir)
|
||||||
@@ -173,7 +173,7 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
def _info_json(
|
def _info_json(
|
||||||
config: object,
|
config: object,
|
||||||
name: str,
|
name: str,
|
||||||
component: object | None,
|
program: object | None,
|
||||||
service: object | None,
|
service: object | None,
|
||||||
job: object | None,
|
job: object | None,
|
||||||
deployed: object | None,
|
deployed: object | None,
|
||||||
@@ -181,28 +181,28 @@ def _info_json(
|
|||||||
"""Output JSON info."""
|
"""Output JSON info."""
|
||||||
data: dict = {"name": name}
|
data: dict = {"name": name}
|
||||||
|
|
||||||
if component:
|
if program:
|
||||||
data["component"] = component.model_dump(exclude_none=True, exclude={"id"})
|
data["program"] = program.model_dump(exclude_none=True, exclude={"id"})
|
||||||
if service:
|
if service:
|
||||||
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
|
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
|
||||||
data["behavior"] = "daemon"
|
data["behavior"] = "daemon"
|
||||||
if job:
|
if job:
|
||||||
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
|
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
|
||||||
data["behavior"] = "tool"
|
data["behavior"] = "tool"
|
||||||
if not service and not job and component:
|
if not service and not job and program:
|
||||||
if component.tool or (component.install and component.install.path):
|
if program.tool or (program.install and program.install.path):
|
||||||
data["behavior"] = "tool"
|
data["behavior"] = "tool"
|
||||||
elif component.build:
|
elif program.build:
|
||||||
data["behavior"] = "frontend"
|
data["behavior"] = "frontend"
|
||||||
|
|
||||||
# Resolve stack
|
# Resolve stack
|
||||||
stack = None
|
stack = None
|
||||||
if component and component.stack:
|
if program and program.stack:
|
||||||
stack = component.stack
|
stack = program.stack
|
||||||
elif service and service.component and service.component in config.components:
|
elif service and service.component and service.component in config.programs:
|
||||||
stack = config.components[service.component].stack
|
stack = config.programs[service.component].stack
|
||||||
elif job and job.component and job.component in config.components:
|
elif job and job.component and job.component in config.programs:
|
||||||
stack = config.components[job.component].stack
|
stack = config.programs[job.component].stack
|
||||||
if stack:
|
if stack:
|
||||||
data["stack"] = stack
|
data["stack"] = stack
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""castle list - show all registered components."""
|
"""castle list - show all registered programs, services, and jobs."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -50,27 +50,27 @@ def _load_deployed() -> dict[str, object] | None:
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_stack(config: object, name: str) -> str | None:
|
def _resolve_stack(config: object, name: str) -> str | None:
|
||||||
"""Resolve stack from component reference or direct component."""
|
"""Resolve stack from program reference or direct program."""
|
||||||
# Check services for component ref
|
# Check services for program ref
|
||||||
if name in config.services:
|
if name in config.services:
|
||||||
svc = config.services[name]
|
svc = config.services[name]
|
||||||
comp_name = svc.component
|
comp_name = svc.component
|
||||||
if comp_name and comp_name in config.components:
|
if comp_name and comp_name in config.programs:
|
||||||
return config.components[comp_name].stack
|
return config.programs[comp_name].stack
|
||||||
# Check jobs for component ref
|
# Check jobs for program ref
|
||||||
if name in config.jobs:
|
if name in config.jobs:
|
||||||
job = config.jobs[name]
|
job = config.jobs[name]
|
||||||
comp_name = job.component
|
comp_name = job.component
|
||||||
if comp_name and comp_name in config.components:
|
if comp_name and comp_name in config.programs:
|
||||||
return config.components[comp_name].stack
|
return config.programs[comp_name].stack
|
||||||
# Direct component
|
# Direct program
|
||||||
if name in config.components:
|
if name in config.programs:
|
||||||
return config.components[name].stack
|
return config.programs[name].stack
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def run_list(args: argparse.Namespace) -> int:
|
def run_list(args: argparse.Namespace) -> int:
|
||||||
"""List all components, services, and jobs."""
|
"""List all programs, services, and jobs."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
deployed = _load_deployed()
|
deployed = _load_deployed()
|
||||||
|
|
||||||
@@ -123,12 +123,12 @@ def run_list(args: argparse.Namespace) -> int:
|
|||||||
sched = f" {DIM}[{job.schedule}]{RESET}"
|
sched = f" {DIM}[{job.schedule}]{RESET}"
|
||||||
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
|
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
|
||||||
|
|
||||||
# Components (tools, frontends, etc.)
|
# Programs (tools, frontends, etc.)
|
||||||
show_tools = not filter_behavior or filter_behavior == "tool"
|
show_tools = not filter_behavior or filter_behavior == "tool"
|
||||||
show_frontends = not filter_behavior or filter_behavior == "frontend"
|
show_frontends = not filter_behavior or filter_behavior == "frontend"
|
||||||
|
|
||||||
if show_tools or show_frontends:
|
if show_tools or show_frontends:
|
||||||
# Collect non-daemon components
|
# Collect non-daemon programs
|
||||||
comps: dict[str, tuple[str, str | None, str | None]] = {}
|
comps: dict[str, tuple[str, str | None, str | None]] = {}
|
||||||
|
|
||||||
if show_tools:
|
if show_tools:
|
||||||
@@ -147,7 +147,7 @@ def run_list(args: argparse.Namespace) -> int:
|
|||||||
if comps:
|
if comps:
|
||||||
any_output = True
|
any_output = True
|
||||||
color = CYAN
|
color = CYAN
|
||||||
print(f"\n{BOLD}{color}Components{RESET}")
|
print(f"\n{BOLD}{color}Programs{RESET}")
|
||||||
print(f"{color}{'─' * 40}{RESET}")
|
print(f"{color}{'─' * 40}{RESET}")
|
||||||
for name, (behavior, stack, description) in comps.items():
|
for name, (behavior, stack, description) in comps.items():
|
||||||
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
|
stack_str = f" {DIM}{stack}{RESET}" if stack else ""
|
||||||
@@ -156,7 +156,7 @@ def run_list(args: argparse.Namespace) -> int:
|
|||||||
print(f" {BOLD}{name}{RESET}{stack_str}{behavior_str}{desc}")
|
print(f" {BOLD}{name}{RESET}{stack_str}{behavior_str}{desc}")
|
||||||
|
|
||||||
if not any_output:
|
if not any_output:
|
||||||
print("No components found.")
|
print("No programs found.")
|
||||||
|
|
||||||
if deployed is None:
|
if deployed is None:
|
||||||
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")
|
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ def run_sync(args: argparse.Namespace) -> int:
|
|||||||
all_ok = True
|
all_ok = True
|
||||||
synced_dirs: set[Path] = set()
|
synced_dirs: set[Path] = set()
|
||||||
|
|
||||||
for name, comp in config.components.items():
|
for name, comp in config.programs.items():
|
||||||
source_dir = comp.source_dir
|
source_dir = comp.source_dir
|
||||||
if not source_dir:
|
if not source_dir:
|
||||||
continue
|
continue
|
||||||
@@ -63,7 +63,7 @@ def run_sync(args: argparse.Namespace) -> int:
|
|||||||
installed_dirs: set[Path] = set()
|
installed_dirs: set[Path] = set()
|
||||||
|
|
||||||
# Install components with install.path
|
# Install components with install.path
|
||||||
for name, comp in config.components.items():
|
for name, comp in config.programs.items():
|
||||||
if not (comp.install and comp.install.path):
|
if not (comp.install and comp.install.path):
|
||||||
continue
|
continue
|
||||||
source = comp.source_dir
|
source = comp.source_dir
|
||||||
@@ -77,8 +77,8 @@ def run_sync(args: argparse.Namespace) -> int:
|
|||||||
continue
|
continue
|
||||||
# Find source from component reference
|
# Find source from component reference
|
||||||
source = None
|
source = None
|
||||||
if svc.component and svc.component in config.components:
|
if svc.component and svc.component in config.programs:
|
||||||
source = config.components[svc.component].source_dir
|
source = config.programs[svc.component].source_dir
|
||||||
if not source:
|
if not source:
|
||||||
continue
|
continue
|
||||||
source_dir = config.root / source
|
source_dir = config.root / source
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ def run_tool(args: argparse.Namespace) -> int:
|
|||||||
def _tool_list() -> int:
|
def _tool_list() -> int:
|
||||||
"""List all registered tools."""
|
"""List all registered tools."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
tools = {k: v for k, v in config.components.items() if v.tool}
|
tools = {k: v for k, v in config.programs.items() if v.tool}
|
||||||
|
|
||||||
if not tools:
|
if not tools:
|
||||||
print("No tools registered.")
|
print("No tools registered.")
|
||||||
@@ -51,11 +51,11 @@ def _tool_list() -> int:
|
|||||||
def _tool_info(name: str) -> int:
|
def _tool_info(name: str) -> int:
|
||||||
"""Show detailed info about a tool, including .md documentation."""
|
"""Show detailed info about a tool, including .md documentation."""
|
||||||
config = load_config()
|
config = load_config()
|
||||||
if name not in config.components:
|
if name not in config.programs:
|
||||||
print(f"Error: '{name}' not found")
|
print(f"Error: '{name}' not found")
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
manifest = config.components[name]
|
manifest = config.programs[name]
|
||||||
if not manifest.tool:
|
if not manifest.tool:
|
||||||
print(f"Error: '{name}' is not a tool")
|
print(f"Error: '{name}' is not a tool")
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
||||||
|
|
||||||
# castle list
|
# castle list
|
||||||
list_parser = subparsers.add_parser("list", help="List all components")
|
list_parser = subparsers.add_parser("list", help="List all programs, services, and jobs")
|
||||||
list_parser.add_argument(
|
list_parser.add_argument(
|
||||||
"--behavior",
|
"--behavior",
|
||||||
choices=["daemon", "tool", "frontend"],
|
choices=["daemon", "tool", "frontend"],
|
||||||
@@ -43,8 +43,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
create_parser.add_argument("--description", default="", help="Project description")
|
create_parser.add_argument("--description", default="", help="Project description")
|
||||||
create_parser.add_argument("--port", type=int, help="Port number (daemons only)")
|
create_parser.add_argument("--port", type=int, help="Port number (daemons only)")
|
||||||
# castle info
|
# castle info
|
||||||
info_parser = subparsers.add_parser("info", help="Show component details")
|
info_parser = subparsers.add_parser("info", help="Show program details")
|
||||||
info_parser.add_argument("project", help="Component name")
|
info_parser.add_argument("project", help="Program, service, or job name")
|
||||||
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
info_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||||
|
|
||||||
# castle test
|
# castle test
|
||||||
@@ -91,25 +91,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
services_sub.add_parser("stop", help="Stop all services and gateway")
|
services_sub.add_parser("stop", help="Stop all services and gateway")
|
||||||
|
|
||||||
# castle logs
|
# castle logs
|
||||||
logs_parser = subparsers.add_parser("logs", help="View component logs")
|
logs_parser = subparsers.add_parser("logs", help="View service/job logs")
|
||||||
logs_parser.add_argument("name", help="Component name")
|
logs_parser.add_argument("name", help="Service or job name")
|
||||||
logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow log output")
|
logs_parser.add_argument("-f", "--follow", action="store_true", help="Follow log output")
|
||||||
logs_parser.add_argument(
|
logs_parser.add_argument(
|
||||||
"-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
|
"-n", "--lines", type=int, default=50, help="Number of lines to show (default: 50)"
|
||||||
)
|
)
|
||||||
|
|
||||||
# castle run
|
# castle run
|
||||||
run_parser = subparsers.add_parser("run", help="Run a component in the foreground")
|
run_parser = subparsers.add_parser("run", help="Run a service in the foreground")
|
||||||
run_parser.add_argument("name", help="Component name")
|
run_parser.add_argument("name", help="Service name")
|
||||||
run_parser.add_argument(
|
run_parser.add_argument(
|
||||||
"extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the component"
|
"extra", nargs=argparse.REMAINDER, help="Extra arguments passed to the service"
|
||||||
)
|
)
|
||||||
|
|
||||||
# castle deploy
|
# castle deploy
|
||||||
deploy_parser = subparsers.add_parser(
|
deploy_parser = subparsers.add_parser(
|
||||||
"deploy", help="Deploy components to ~/.castle/ (spec → runtime)"
|
"deploy", help="Deploy to ~/.castle/ (spec → runtime)"
|
||||||
)
|
)
|
||||||
deploy_parser.add_argument("component", nargs="?", help="Component to deploy (default: all)")
|
deploy_parser.add_argument("name", nargs="?", help="Service or job to deploy (default: all)")
|
||||||
|
|
||||||
# castle tool
|
# castle tool
|
||||||
tool_parser = subparsers.add_parser("tool", help="Manage tools")
|
tool_parser = subparsers.add_parser("tool", help="Manage tools")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
|||||||
BuildSpec,
|
BuildSpec,
|
||||||
CaddySpec,
|
CaddySpec,
|
||||||
Capability,
|
Capability,
|
||||||
ComponentSpec,
|
ProgramSpec,
|
||||||
DefaultsSpec,
|
DefaultsSpec,
|
||||||
EnvMap,
|
EnvMap,
|
||||||
ExposeSpec,
|
ExposeSpec,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class TestCreateCommand:
|
|||||||
result = run_create(args)
|
result = run_create(args)
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
project_dir = castle_root / "components" / "my-api"
|
project_dir = castle_root / "programs" / "my-api"
|
||||||
assert project_dir.exists()
|
assert project_dir.exists()
|
||||||
assert (project_dir / "pyproject.toml").exists()
|
assert (project_dir / "pyproject.toml").exists()
|
||||||
assert (project_dir / "src" / "my_api" / "main.py").exists()
|
assert (project_dir / "src" / "my_api" / "main.py").exists()
|
||||||
@@ -41,8 +41,8 @@ class TestCreateCommand:
|
|||||||
assert (project_dir / "tests" / "test_health.py").exists()
|
assert (project_dir / "tests" / "test_health.py").exists()
|
||||||
assert (project_dir / "CLAUDE.md").exists()
|
assert (project_dir / "CLAUDE.md").exists()
|
||||||
|
|
||||||
# Verify registered as ComponentSpec + ServiceSpec
|
# Verify registered as ProgramSpec + ServiceSpec
|
||||||
assert "my-api" in config.components
|
assert "my-api" in config.programs
|
||||||
assert "my-api" in config.services
|
assert "my-api" in config.services
|
||||||
svc = config.services["my-api"]
|
svc = config.services["my-api"]
|
||||||
assert svc.expose.http.internal.port == 9050
|
assert svc.expose.http.internal.port == 9050
|
||||||
@@ -64,12 +64,12 @@ class TestCreateCommand:
|
|||||||
result = run_create(args)
|
result = run_create(args)
|
||||||
|
|
||||||
assert result == 0
|
assert result == 0
|
||||||
project_dir = castle_root / "components" / "my-tool2"
|
project_dir = castle_root / "programs" / "my-tool2"
|
||||||
assert project_dir.exists()
|
assert project_dir.exists()
|
||||||
assert (project_dir / "src" / "my_tool2" / "main.py").exists()
|
assert (project_dir / "src" / "my_tool2" / "main.py").exists()
|
||||||
assert (project_dir / "CLAUDE.md").exists()
|
assert (project_dir / "CLAUDE.md").exists()
|
||||||
assert "my-tool2" in config.components
|
assert "my-tool2" in config.programs
|
||||||
comp = config.components["my-tool2"]
|
comp = config.programs["my-tool2"]
|
||||||
assert comp.tool is not None
|
assert comp.tool is not None
|
||||||
assert comp.install is not None
|
assert comp.install is not None
|
||||||
|
|
||||||
|
|||||||
5793
components/browser/uv.lock
generated
Normal file
5793
components/browser/uv.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
components/docx-extractor/uv.lock
generated
Normal file
8
components/docx-extractor/uv.lock
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "docx-extractor"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = { editable = "." }
|
||||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||||
|
|
||||||
|
|
||||||
def find_castle_root() -> Path:
|
def find_castle_root() -> Path:
|
||||||
@@ -47,25 +47,25 @@ class CastleConfig:
|
|||||||
|
|
||||||
root: Path
|
root: Path
|
||||||
gateway: GatewayConfig
|
gateway: GatewayConfig
|
||||||
components: dict[str, ComponentSpec]
|
programs: dict[str, ProgramSpec]
|
||||||
services: dict[str, ServiceSpec]
|
services: dict[str, ServiceSpec]
|
||||||
jobs: dict[str, JobSpec]
|
jobs: dict[str, JobSpec]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def tools(self) -> dict[str, ComponentSpec]:
|
def tools(self) -> dict[str, ProgramSpec]:
|
||||||
"""Return components that are tools (have install.path or tool spec)."""
|
"""Return programs that are tools (have install.path or tool spec)."""
|
||||||
return {
|
return {
|
||||||
k: v
|
k: v
|
||||||
for k, v in self.components.items()
|
for k, v in self.programs.items()
|
||||||
if (v.install and v.install.path) or v.tool
|
if (v.install and v.install.path) or v.tool
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def frontends(self) -> dict[str, ComponentSpec]:
|
def frontends(self) -> dict[str, ProgramSpec]:
|
||||||
"""Return components that are frontends (have build outputs)."""
|
"""Return programs that are frontends (have build outputs)."""
|
||||||
return {
|
return {
|
||||||
k: v
|
k: v
|
||||||
for k, v in self.components.items()
|
for k, v in self.programs.items()
|
||||||
if v.build and (v.build.outputs or v.build.commands)
|
if v.build and (v.build.outputs or v.build.commands)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,11 +94,11 @@ def _read_secret(name: str) -> str:
|
|||||||
return f"<MISSING_SECRET:{name}>"
|
return f"<MISSING_SECRET:{name}>"
|
||||||
|
|
||||||
|
|
||||||
def _parse_component(name: str, data: dict) -> ComponentSpec:
|
def _parse_program(name: str, data: dict) -> ProgramSpec:
|
||||||
"""Parse a components: entry into a ComponentSpec."""
|
"""Parse a programs: entry into a ProgramSpec."""
|
||||||
data_copy = dict(data)
|
data_copy = dict(data)
|
||||||
data_copy["id"] = name
|
data_copy["id"] = name
|
||||||
return ComponentSpec.model_validate(data_copy)
|
return ProgramSpec.model_validate(data_copy)
|
||||||
|
|
||||||
|
|
||||||
def _parse_service(name: str, data: dict) -> ServiceSpec:
|
def _parse_service(name: str, data: dict) -> ServiceSpec:
|
||||||
@@ -130,9 +130,11 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
|||||||
gateway_data = data.get("gateway", {})
|
gateway_data = data.get("gateway", {})
|
||||||
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
|
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
|
||||||
|
|
||||||
components: dict[str, ComponentSpec] = {}
|
programs: dict[str, ProgramSpec] = {}
|
||||||
for name, comp_data in data.get("components", {}).items():
|
# Support both "programs:" and legacy "components:" key
|
||||||
components[name] = _parse_component(name, comp_data)
|
programs_data = data.get("programs") or data.get("components") or {}
|
||||||
|
for name, comp_data in programs_data.items():
|
||||||
|
programs[name] = _parse_program(name, comp_data)
|
||||||
|
|
||||||
services: dict[str, ServiceSpec] = {}
|
services: dict[str, ServiceSpec] = {}
|
||||||
for name, svc_data in data.get("services", {}).items():
|
for name, svc_data in data.get("services", {}).items():
|
||||||
@@ -145,7 +147,7 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
|||||||
return CastleConfig(
|
return CastleConfig(
|
||||||
root=root,
|
root=root,
|
||||||
gateway=gateway,
|
gateway=gateway,
|
||||||
components=components,
|
programs=programs,
|
||||||
services=services,
|
services=services,
|
||||||
jobs=jobs,
|
jobs=jobs,
|
||||||
)
|
)
|
||||||
@@ -187,7 +189,7 @@ _STRUCTURAL_KEYS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _spec_to_yaml_dict(spec: ComponentSpec | ServiceSpec | JobSpec) -> dict:
|
def _spec_to_yaml_dict(spec: ProgramSpec | ServiceSpec | JobSpec) -> dict:
|
||||||
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
|
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
|
||||||
exclude_fields = {"id"}
|
exclude_fields = {"id"}
|
||||||
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
|
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
|
||||||
@@ -228,10 +230,10 @@ def save_config(config: CastleConfig) -> None:
|
|||||||
"""Save castle configuration to castle.yaml."""
|
"""Save castle configuration to castle.yaml."""
|
||||||
data: dict = {"gateway": {"port": config.gateway.port}}
|
data: dict = {"gateway": {"port": config.gateway.port}}
|
||||||
|
|
||||||
if config.components:
|
if config.programs:
|
||||||
data["components"] = {}
|
data["programs"] = {}
|
||||||
for name, spec in config.components.items():
|
for name, spec in config.programs.items():
|
||||||
data["components"][name] = _spec_to_yaml_dict(spec)
|
data["programs"][name] = _spec_to_yaml_dict(spec)
|
||||||
|
|
||||||
if config.services:
|
if config.services:
|
||||||
data["services"] = {}
|
data["services"] = {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Castle manifest models — component specs, service specs, job specs."""
|
"""Castle manifest models — program specs, service specs, job specs."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -197,11 +197,11 @@ class DefaultsSpec(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# Component spec — software identity
|
# Program spec — software identity
|
||||||
# ---------------------
|
# ---------------------
|
||||||
|
|
||||||
|
|
||||||
class ComponentSpec(BaseModel):
|
class ProgramSpec(BaseModel):
|
||||||
"""Software catalog entry — what exists."""
|
"""Software catalog entry — what exists."""
|
||||||
|
|
||||||
id: str = ""
|
id: str = ""
|
||||||
|
|||||||
260
core/src/castle_core/stacks.py
Normal file
260
core/src/castle_core/stacks.py
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
"""Stack protocol — lifecycle actions for each development stack."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from castle_core.config import STATIC_DIR
|
||||||
|
from castle_core.manifest import ProgramSpec
|
||||||
|
|
||||||
|
DEV_ACTIONS = ["build", "test", "lint", "type-check", "check"]
|
||||||
|
INSTALL_ACTIONS = ["install", "uninstall"]
|
||||||
|
ALL_ACTIONS = DEV_ACTIONS + INSTALL_ACTIONS
|
||||||
|
|
||||||
|
# User-local tool directories that may not be on the systemd service PATH.
|
||||||
|
_EXTRA_PATH_DIRS = [
|
||||||
|
Path.home() / ".local" / "share" / "pnpm",
|
||||||
|
Path.home() / ".local" / "bin",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActionResult:
|
||||||
|
"""Result of a stack lifecycle action."""
|
||||||
|
|
||||||
|
component: str
|
||||||
|
action: str
|
||||||
|
status: str # "ok" | "error"
|
||||||
|
output: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def _build_env() -> dict[str, str]:
|
||||||
|
"""Build a subprocess env with user tool dirs on PATH."""
|
||||||
|
env = os.environ.copy()
|
||||||
|
extra = ":".join(str(d) for d in _EXTRA_PATH_DIRS if d.exists())
|
||||||
|
if extra:
|
||||||
|
env["PATH"] = extra + ":" + env.get("PATH", "")
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(cmd: list[str], cwd: Path) -> tuple[int, str]:
|
||||||
|
"""Run a subprocess and return (returncode, combined output)."""
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
cwd=cwd,
|
||||||
|
env=_build_env(),
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
stdout, _ = await proc.communicate()
|
||||||
|
return proc.returncode or 0, (stdout or b"").decode()
|
||||||
|
|
||||||
|
|
||||||
|
def _source_dir(comp: ProgramSpec, root: Path) -> Path:
|
||||||
|
"""Resolve source directory, raising ValueError if absent."""
|
||||||
|
if not comp.source:
|
||||||
|
raise ValueError("No source directory")
|
||||||
|
return root / comp.source
|
||||||
|
|
||||||
|
|
||||||
|
class StackHandler:
|
||||||
|
"""Base class — subclasses implement each lifecycle action."""
|
||||||
|
|
||||||
|
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
"""Composite: lint + type-check + test. Runs all, reports first failure."""
|
||||||
|
for action_fn, action_name in [
|
||||||
|
(self.lint, "lint"),
|
||||||
|
(self.type_check, "type-check"),
|
||||||
|
(self.test, "test"),
|
||||||
|
]:
|
||||||
|
result = await action_fn(name, comp, root)
|
||||||
|
if result.status != "ok":
|
||||||
|
return ActionResult(
|
||||||
|
component=name,
|
||||||
|
action="check",
|
||||||
|
status="error",
|
||||||
|
output=f"{action_name} failed:\n{result.output}",
|
||||||
|
)
|
||||||
|
return ActionResult(component=name, action="check", status="ok")
|
||||||
|
|
||||||
|
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class PythonHandler(StackHandler):
|
||||||
|
"""Handler for python-cli and python-fastapi stacks."""
|
||||||
|
|
||||||
|
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(["uv", "sync"], src)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="build", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
if not (src / "tests").exists():
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="test", status="ok",
|
||||||
|
output="No tests directory found, skipping.",
|
||||||
|
)
|
||||||
|
rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="test", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(["uv", "run", "ruff", "check", "."], src)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(["uv", "run", "pyright"], src)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(
|
||||||
|
["uv", "tool", "install", "--editable", str(src), "--force"], src
|
||||||
|
)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="install", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
pkg_name = src.name
|
||||||
|
pyproject = src / "pyproject.toml"
|
||||||
|
if pyproject.exists():
|
||||||
|
with open(pyproject, "rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
pkg_name = data.get("project", {}).get("name", pkg_name)
|
||||||
|
rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="uninstall", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ReactViteHandler(StackHandler):
|
||||||
|
"""Handler for react-vite stack."""
|
||||||
|
|
||||||
|
async def build(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(["pnpm", "build"], src)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="build", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(["pnpm", "test"], src)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="test", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(["pnpm", "lint"], src)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
rc, output = await _run(["pnpm", "type-check"], src)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
|
||||||
|
)
|
||||||
|
|
||||||
|
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
"""Build and copy static assets to ~/.castle/static/{name}/."""
|
||||||
|
result = await self.build(name, comp, root)
|
||||||
|
if result.status != "ok":
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="install", status="error",
|
||||||
|
output=f"Build failed:\n{result.output}",
|
||||||
|
)
|
||||||
|
|
||||||
|
src = _source_dir(comp, root)
|
||||||
|
outputs = comp.build.outputs if comp.build else []
|
||||||
|
if not outputs:
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="install", status="error",
|
||||||
|
output="No build outputs configured.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for output_dir in outputs:
|
||||||
|
src_path = src / output_dir
|
||||||
|
if src_path.exists():
|
||||||
|
dest = STATIC_DIR / name
|
||||||
|
if dest.exists():
|
||||||
|
shutil.rmtree(dest)
|
||||||
|
shutil.copytree(src_path, dest)
|
||||||
|
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="install", status="ok",
|
||||||
|
output=f"Built and deployed to {STATIC_DIR / name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
|
"""Remove static assets from ~/.castle/static/{name}/."""
|
||||||
|
dest = STATIC_DIR / name
|
||||||
|
if dest.exists():
|
||||||
|
shutil.rmtree(dest)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="uninstall", status="ok",
|
||||||
|
output=f"Removed {dest}",
|
||||||
|
)
|
||||||
|
return ActionResult(
|
||||||
|
component=name, action="uninstall", status="ok",
|
||||||
|
output=f"Nothing to remove ({dest} does not exist)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
HANDLERS: dict[str, StackHandler] = {
|
||||||
|
"python-cli": PythonHandler(),
|
||||||
|
"python-fastapi": PythonHandler(),
|
||||||
|
"react-vite": ReactViteHandler(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_handler(stack: str | None) -> StackHandler | None:
|
||||||
|
"""Get the handler for a given stack, or None if unsupported."""
|
||||||
|
if stack is None:
|
||||||
|
return None
|
||||||
|
return HANDLERS.get(stack)
|
||||||
|
|
||||||
|
|
||||||
|
def available_actions(comp: ProgramSpec) -> list[str]:
|
||||||
|
"""Return the list of actions available for a program."""
|
||||||
|
if not comp.source:
|
||||||
|
return []
|
||||||
|
handler = get_handler(comp.stack)
|
||||||
|
if handler is None:
|
||||||
|
return []
|
||||||
|
return list(ALL_ACTIONS)
|
||||||
@@ -11,7 +11,7 @@ from castle_core.config import (
|
|||||||
resolve_env_vars,
|
resolve_env_vars,
|
||||||
save_config,
|
save_config,
|
||||||
)
|
)
|
||||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
from castle_core.manifest import ProgramSpec, JobSpec, ServiceSpec
|
||||||
|
|
||||||
|
|
||||||
class TestLoadConfig:
|
class TestLoadConfig:
|
||||||
@@ -22,14 +22,14 @@ class TestLoadConfig:
|
|||||||
config = load_config(castle_root)
|
config = load_config(castle_root)
|
||||||
assert isinstance(config, CastleConfig)
|
assert isinstance(config, CastleConfig)
|
||||||
assert config.gateway.port == 18000
|
assert config.gateway.port == 18000
|
||||||
assert "test-tool" in config.components
|
assert "test-tool" in config.programs
|
||||||
assert "test-svc" in config.services
|
assert "test-svc" in config.services
|
||||||
assert "test-job" in config.jobs
|
assert "test-job" in config.jobs
|
||||||
|
|
||||||
def test_load_produces_typed_specs(self, castle_root: Path) -> None:
|
def test_load_produces_typed_specs(self, castle_root: Path) -> None:
|
||||||
"""Each section produces the correct spec type."""
|
"""Each section produces the correct spec type."""
|
||||||
config = load_config(castle_root)
|
config = load_config(castle_root)
|
||||||
assert isinstance(config.components["test-tool"], ComponentSpec)
|
assert isinstance(config.programs["test-tool"], ProgramSpec)
|
||||||
assert isinstance(config.services["test-svc"], ServiceSpec)
|
assert isinstance(config.services["test-svc"], ServiceSpec)
|
||||||
assert isinstance(config.jobs["test-job"], JobSpec)
|
assert isinstance(config.jobs["test-job"], JobSpec)
|
||||||
|
|
||||||
@@ -86,21 +86,21 @@ class TestSaveConfig:
|
|||||||
config2 = load_config(castle_root)
|
config2 = load_config(castle_root)
|
||||||
|
|
||||||
assert config2.gateway.port == config.gateway.port
|
assert config2.gateway.port == config.gateway.port
|
||||||
assert set(config2.components.keys()) == set(config.components.keys())
|
assert set(config2.programs.keys()) == set(config.programs.keys())
|
||||||
assert set(config2.services.keys()) == set(config.services.keys())
|
assert set(config2.services.keys()) == set(config.services.keys())
|
||||||
assert set(config2.jobs.keys()) == set(config.jobs.keys())
|
assert set(config2.jobs.keys()) == set(config.jobs.keys())
|
||||||
|
|
||||||
def test_save_adds_component(self, castle_root: Path) -> None:
|
def test_save_adds_component(self, castle_root: Path) -> None:
|
||||||
"""Adding a component and saving persists it."""
|
"""Adding a component and saving persists it."""
|
||||||
config = load_config(castle_root)
|
config = load_config(castle_root)
|
||||||
config.components["new-lib"] = ComponentSpec(
|
config.programs["new-lib"] = ProgramSpec(
|
||||||
id="new-lib", description="A new library"
|
id="new-lib", description="A new library"
|
||||||
)
|
)
|
||||||
save_config(config)
|
save_config(config)
|
||||||
|
|
||||||
config2 = load_config(castle_root)
|
config2 = load_config(castle_root)
|
||||||
assert "new-lib" in config2.components
|
assert "new-lib" in config2.programs
|
||||||
assert config2.components["new-lib"].description == "A new library"
|
assert config2.programs["new-lib"].description == "A new library"
|
||||||
|
|
||||||
def test_preserves_manage_systemd(self, castle_root: Path) -> None:
|
def test_preserves_manage_systemd(self, castle_root: Path) -> None:
|
||||||
"""Roundtrip preserves manage.systemd even with all defaults."""
|
"""Roundtrip preserves manage.systemd even with all defaults."""
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import pytest
|
|||||||
from castle_core.manifest import (
|
from castle_core.manifest import (
|
||||||
BuildSpec,
|
BuildSpec,
|
||||||
CaddySpec,
|
CaddySpec,
|
||||||
ComponentSpec,
|
ProgramSpec,
|
||||||
ExposeSpec,
|
ExposeSpec,
|
||||||
HttpExposeSpec,
|
HttpExposeSpec,
|
||||||
HttpInternal,
|
HttpInternal,
|
||||||
@@ -24,12 +24,12 @@ from castle_core.manifest import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestComponentSpec:
|
class TestProgramSpec:
|
||||||
"""Tests for component (software catalog) model."""
|
"""Tests for component (software catalog) model."""
|
||||||
|
|
||||||
def test_minimal(self) -> None:
|
def test_minimal(self) -> None:
|
||||||
"""Minimal component just needs an id."""
|
"""Minimal component just needs an id."""
|
||||||
c = ComponentSpec(id="bare")
|
c = ProgramSpec(id="bare")
|
||||||
assert c.description is None
|
assert c.description is None
|
||||||
assert c.source is None
|
assert c.source is None
|
||||||
assert c.install is None
|
assert c.install is None
|
||||||
@@ -38,7 +38,7 @@ class TestComponentSpec:
|
|||||||
|
|
||||||
def test_tool_component(self) -> None:
|
def test_tool_component(self) -> None:
|
||||||
"""Component with tool and install specs."""
|
"""Component with tool and install specs."""
|
||||||
c = ComponentSpec(
|
c = ProgramSpec(
|
||||||
id="my-tool",
|
id="my-tool",
|
||||||
description="A tool",
|
description="A tool",
|
||||||
source="my-tool/",
|
source="my-tool/",
|
||||||
@@ -50,7 +50,7 @@ class TestComponentSpec:
|
|||||||
|
|
||||||
def test_frontend_component(self) -> None:
|
def test_frontend_component(self) -> None:
|
||||||
"""Component with build spec."""
|
"""Component with build spec."""
|
||||||
c = ComponentSpec(
|
c = ProgramSpec(
|
||||||
id="my-app",
|
id="my-app",
|
||||||
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
|
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
|
||||||
)
|
)
|
||||||
@@ -58,12 +58,12 @@ class TestComponentSpec:
|
|||||||
|
|
||||||
def test_source_dir_from_source(self) -> None:
|
def test_source_dir_from_source(self) -> None:
|
||||||
"""source_dir uses source field."""
|
"""source_dir uses source field."""
|
||||||
c = ComponentSpec(id="x", source="components/x/")
|
c = ProgramSpec(id="x", source="components/x/")
|
||||||
assert c.source_dir == "components/x"
|
assert c.source_dir == "components/x"
|
||||||
|
|
||||||
def test_source_dir_none(self) -> None:
|
def test_source_dir_none(self) -> None:
|
||||||
"""source_dir returns None when no source available."""
|
"""source_dir returns None when no source available."""
|
||||||
c = ComponentSpec(id="x")
|
c = ProgramSpec(id="x")
|
||||||
assert c.source_dir is None
|
assert c.source_dir is None
|
||||||
|
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ class TestModelSerialization:
|
|||||||
|
|
||||||
def test_dump_component_excludes_none(self) -> None:
|
def test_dump_component_excludes_none(self) -> None:
|
||||||
"""model_dump with exclude_none drops None fields."""
|
"""model_dump with exclude_none drops None fields."""
|
||||||
c = ComponentSpec(id="test", description="Test")
|
c = ProgramSpec(id="test", description="Test")
|
||||||
data = c.model_dump(exclude_none=True, exclude={"id"})
|
data = c.model_dump(exclude_none=True, exclude={"id"})
|
||||||
assert "description" in data
|
assert "description" in data
|
||||||
assert "install" not in data
|
assert "install" not in data
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
# Component Registry
|
# Registry
|
||||||
|
|
||||||
How castle tracks, configures, and manages components. This is the central
|
How castle tracks, configures, and manages programs, services, and jobs.
|
||||||
reference for `castle.yaml` structure and the registry architecture.
|
This is the central reference for `castle.yaml` structure and the registry
|
||||||
|
architecture.
|
||||||
|
|
||||||
## castle.yaml
|
## castle.yaml
|
||||||
|
|
||||||
The single source of truth for all components. Lives at the repo root.
|
The single source of truth. Lives at the repo root. Three top-level sections:
|
||||||
Three top-level sections:
|
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
gateway:
|
gateway:
|
||||||
port: 9000
|
port: 9000
|
||||||
|
|
||||||
components:
|
programs:
|
||||||
my-tool:
|
my-tool:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: components/my-tool
|
source: programs/my-tool
|
||||||
install:
|
install:
|
||||||
path: { alias: my-tool }
|
path: { alias: my-tool }
|
||||||
tool:
|
tool:
|
||||||
@@ -51,22 +51,22 @@ jobs:
|
|||||||
|
|
||||||
| Section | Purpose | Category |
|
| Section | Purpose | Category |
|
||||||
|---------|---------|----------|
|
|---------|---------|----------|
|
||||||
| `components:` | Software catalog — what exists | tool, frontend, component |
|
| `programs:` | Software catalog — what exists | tool, frontend, program |
|
||||||
| `services:` | Long-running daemons — how they run | service |
|
| `services:` | Long-running daemons — how they run | service |
|
||||||
| `jobs:` | Scheduled tasks — when they run | job |
|
| `jobs:` | Scheduled tasks — when they run | job |
|
||||||
|
|
||||||
Services and jobs can reference a component via `component:` for description
|
Services and jobs can reference a program via `component:` for description
|
||||||
fallthrough and source code linking. They can also exist independently
|
fallthrough and source code linking. They can also exist independently
|
||||||
(e.g., `castle-gateway` runs Caddy — not our software).
|
(e.g., `castle-gateway` runs Caddy — not our software).
|
||||||
|
|
||||||
## Component blocks
|
## Program blocks
|
||||||
|
|
||||||
Components define **what software exists** — identity, source, tools, builds.
|
Programs define **what software exists** — identity, source, tools, builds.
|
||||||
|
|
||||||
### `source` — Where the source lives
|
### `source` — Where the source lives
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
source: components/my-tool
|
source: programs/my-tool
|
||||||
```
|
```
|
||||||
|
|
||||||
Relative path from repo root to the project directory.
|
Relative path from repo root to the project directory.
|
||||||
@@ -92,7 +92,7 @@ tool:
|
|||||||
|
|
||||||
This block provides metadata for `castle tool list` and the dashboard.
|
This block provides metadata for `castle tool list` and the dashboard.
|
||||||
It's separate from `install` (which handles PATH registration). The source
|
It's separate from `install` (which handles PATH registration). The source
|
||||||
directory is set via the top-level `source` field on the component, not here.
|
directory is set via the top-level `source` field on the program, not here.
|
||||||
|
|
||||||
### `build` — How to build it
|
### `build` — How to build it
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ Castle generates a systemd `.timer` file alongside the `.service` unit.
|
|||||||
Jobs also support `run` (required), `manage`, and `defaults` — same
|
Jobs also support `run` (required), `manage`, and `defaults` — same
|
||||||
semantics as services.
|
semantics as services.
|
||||||
|
|
||||||
## Registering a new component
|
## Registering a new program
|
||||||
|
|
||||||
### Via `castle create` (recommended)
|
### Via `castle create` (recommended)
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ semantics as services.
|
|||||||
# Service — scaffolds project, assigns port, registers in castle.yaml
|
# Service — scaffolds project, assigns port, registers in castle.yaml
|
||||||
castle create my-service --stack python-fastapi --description "Does something"
|
castle create my-service --stack python-fastapi --description "Does something"
|
||||||
|
|
||||||
# Tool — scaffolds under components/
|
# Tool — scaffolds under programs/
|
||||||
castle create my-tool --stack python-cli --description "Does something"
|
castle create my-tool --stack python-cli --description "Does something"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -220,20 +220,20 @@ castle create my-tool --stack python-cli --description "Does something"
|
|||||||
Add entries to the appropriate sections of `castle.yaml`:
|
Add entries to the appropriate sections of `castle.yaml`:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# Tool — only needs a component entry
|
# Tool — only needs a program entry
|
||||||
components:
|
programs:
|
||||||
my-tool:
|
my-tool:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: components/my-tool
|
source: programs/my-tool
|
||||||
install:
|
install:
|
||||||
path:
|
path:
|
||||||
alias: my-tool
|
alias: my-tool
|
||||||
|
|
||||||
# Service — needs both component and service entries
|
# Service — needs both program and service entries
|
||||||
components:
|
programs:
|
||||||
my-service:
|
my-service:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: components/my-service
|
source: programs/my-service
|
||||||
|
|
||||||
services:
|
services:
|
||||||
my-service:
|
my-service:
|
||||||
@@ -257,7 +257,7 @@ services:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
castle create my-service --stack python-fastapi # 1. Scaffold + register
|
castle create my-service --stack python-fastapi # 1. Scaffold + register
|
||||||
cd components/my-service && uv sync # 2. Install deps
|
cd programs/my-service && uv sync # 2. Install deps
|
||||||
# ... implement ...
|
# ... implement ...
|
||||||
castle test my-service # 3. Run tests
|
castle test my-service # 3. Run tests
|
||||||
castle service enable my-service # 4. Generate systemd unit, start
|
castle service enable my-service # 4. Generate systemd unit, start
|
||||||
@@ -277,10 +277,10 @@ castle service disable my-service # Stop and remove systemd unit
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
castle create my-tool --stack python-cli # 1. Scaffold + register
|
castle create my-tool --stack python-cli # 1. Scaffold + register
|
||||||
cd components/my-tool && uv sync # 2. Install deps
|
cd programs/my-tool && uv sync # 2. Install deps
|
||||||
# ... implement ...
|
# ... implement ...
|
||||||
castle test my-tool # 3. Run tests
|
castle test my-tool # 3. Run tests
|
||||||
uv tool install --editable components/my-tool/ # 4. Install to PATH
|
uv tool install --editable programs/my-tool/ # 4. Install to PATH
|
||||||
```
|
```
|
||||||
|
|
||||||
### Job lifecycle
|
### Job lifecycle
|
||||||
@@ -306,7 +306,7 @@ and a `.timer` file.
|
|||||||
|
|
||||||
| What | Where |
|
| What | Where |
|
||||||
|------|-------|
|
|------|-------|
|
||||||
| Component registry | `castle.yaml` (repo root) |
|
| Registry | `castle.yaml` (repo root) |
|
||||||
| Service data | `/data/castle/<name>/` |
|
| Service data | `/data/castle/<name>/` |
|
||||||
| Secrets | `~/.castle/secrets/<NAME>` |
|
| Secrets | `~/.castle/secrets/<NAME>` |
|
||||||
| Generated Caddyfile | `~/.castle/generated/Caddyfile` |
|
| Generated Caddyfile | `~/.castle/generated/Caddyfile` |
|
||||||
@@ -317,7 +317,7 @@ and a `.timer` file.
|
|||||||
|
|
||||||
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
|
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
|
||||||
|
|
||||||
- `ComponentSpec` — software catalog entry (source, install, tool, build)
|
- `ProgramSpec` — software catalog entry (source, install, tool, build)
|
||||||
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
|
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
|
||||||
- `JobSpec` — scheduled task (run, schedule, manage, defaults)
|
- `JobSpec` — scheduled task (run, schedule, manage, defaults)
|
||||||
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
|
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
|
||||||
@@ -325,7 +325,7 @@ The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
|
|||||||
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
|
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
|
||||||
|
|
||||||
Config loading: `core/src/castle_core/config.py` — `load_config()` parses
|
Config loading: `core/src/castle_core/config.py` — `load_config()` parses
|
||||||
castle.yaml into `CastleConfig` with typed `components`, `services`, and
|
castle.yaml into `CastleConfig` with typed `programs`, `services`, and
|
||||||
`jobs` dicts.
|
`jobs` dicts.
|
||||||
|
|
||||||
Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer
|
Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ These map to two files:
|
|||||||
**`castle.yaml`** (in the repo, version-controlled) — Three sections:
|
**`castle.yaml`** (in the repo, version-controlled) — Three sections:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
components:
|
programs:
|
||||||
central-context:
|
central-context:
|
||||||
description: Content storage API
|
description: Content storage API
|
||||||
source: components/central-context
|
source: components/central-context
|
||||||
@@ -185,7 +185,7 @@ jobs:
|
|||||||
systemd: {}
|
systemd: {}
|
||||||
```
|
```
|
||||||
|
|
||||||
Components define *what software exists* (identity, source, install, tools).
|
Programs define *what software exists* (identity, source, install, tools).
|
||||||
Services define *how daemons run* (run config, expose, proxy, systemd).
|
Services define *how daemons run* (run config, expose, proxy, systemd).
|
||||||
Jobs define *how scheduled tasks run* (run config, cron schedule, systemd).
|
Jobs define *how scheduled tasks run* (run config, cron schedule, systemd).
|
||||||
|
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ uv run ruff format . # Format
|
|||||||
## Registering in castle.yaml
|
## Registering in castle.yaml
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
components:
|
programs:
|
||||||
my-tool:
|
my-tool:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: components/my-tool
|
source: components/my-tool
|
||||||
@@ -326,7 +326,7 @@ components:
|
|||||||
Tools with system dependencies declare them in the component:
|
Tools with system dependencies declare them in the component:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
components:
|
programs:
|
||||||
pdf2md:
|
pdf2md:
|
||||||
description: Convert PDF files to Markdown
|
description: Convert PDF files to Markdown
|
||||||
source: components/pdf2md
|
source: components/pdf2md
|
||||||
@@ -337,7 +337,7 @@ components:
|
|||||||
system_dependencies: [pandoc, poppler-utils]
|
system_dependencies: [pandoc, poppler-utils]
|
||||||
```
|
```
|
||||||
|
|
||||||
Tools live in the `components:` section. If a tool also runs on a schedule,
|
Tools live in the `programs:` section. If a tool also runs on a schedule,
|
||||||
add a separate entry in the `jobs:` section referencing the component.
|
add a separate entry in the `jobs:` section referencing the component.
|
||||||
|
|
||||||
See @docs/component-registry.md for the full registry reference.
|
See @docs/component-registry.md for the full registry reference.
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ settings = Settings()
|
|||||||
Castle passes config via env vars in castle.yaml:
|
Castle passes config via env vars in castle.yaml:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
components:
|
programs:
|
||||||
my-service:
|
my-service:
|
||||||
description: Does something useful
|
description: Does something useful
|
||||||
source: components/my-service
|
source: components/my-service
|
||||||
|
|||||||
@@ -108,12 +108,12 @@ The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files.
|
|||||||
## Registering as a castle component
|
## Registering as a castle component
|
||||||
|
|
||||||
A frontend component has a `build` spec (produces static output). Register it
|
A frontend component has a `build` spec (produces static output). Register it
|
||||||
in the `components:` section of `castle.yaml`. No `run` block needed if Caddy
|
in the `programs:` section of `castle.yaml`. No `run` block needed if Caddy
|
||||||
handles serving directly from the build output.
|
handles serving directly from the build output.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# castle.yaml
|
# castle.yaml
|
||||||
components:
|
programs:
|
||||||
my-frontend:
|
my-frontend:
|
||||||
description: Web dashboard
|
description: Web dashboard
|
||||||
source: components/my-frontend
|
source: components/my-frontend
|
||||||
|
|||||||
Reference in New Issue
Block a user