refactor: Decouple roles.

This commit is contained in:
2026-02-23 01:49:24 -08:00
parent 72d35f2641
commit eeaa5045d0
55 changed files with 2144 additions and 1276 deletions

View File

@@ -6,19 +6,25 @@ with code in this repository.
## Overview
Castle is a personal software platform — a monorepo of independent projects
(services, tools, libraries) managed by the `castle` CLI. Components declare
**what they do** (expose HTTP, manage via systemd, install to PATH) and roles
are **derived**, not labeled.
(services, tools, libraries) managed by the `castle` CLI. The registry
(`castle.yaml`) has three top-level sections:
- **`components:`** — Software catalog (source, install, tool metadata, build)
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
The section determines the category — no role derivation. Services and jobs
can reference a component via `component:` for description fallthrough.
**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,
event bus) know about castle internals.
configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway)
know about castle internals.
## Creating Components
When creating a new service, tool, or frontend, follow the detailed guides:
- @docs/component-registry.md — Manifest architecture, castle.yaml structure, role derivation, lifecycle
- @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle
- @docs/web-apis.md — FastAPI service patterns (config, routes, models, testing)
- @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
- @docs/web-frontends.md — React/Vite/TypeScript frontend patterns
@@ -39,7 +45,7 @@ cd components/my-tool && uv sync
```
The `castle create` command scaffolds the project under `components/`,
generates a CLAUDE.md, and registers it in `castle.yaml` as a `ComponentManifest`.
generates a CLAUDE.md, and registers it in `castle.yaml`.
## Castle CLI
@@ -47,8 +53,8 @@ The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
```bash
castle list # List all components
castle list --role service # Filter by derived role
castle info <component> # Show manifest details (--json for machine-readable)
castle list --type service # Filter by category
castle info <component> # Show details (--json for machine-readable)
castle create <name> --type service # Scaffold new project
castle test [project] # Run tests (one or all)
castle lint [project] # Run linter (one or all)
@@ -93,8 +99,8 @@ Services also support: `uv run <service-name>` to start.
## Key Files
- `castle.yaml` — Component registry (single source of truth)
- `core/src/castle_core/manifest.py` — Pydantic models (ComponentManifest, RunSpec, etc.)
- `castle.yaml` — Component registry (three sections: components, services, jobs)
- `core/src/castle_core/manifest.py` — Pydantic models (ComponentSpec, ServiceSpec, JobSpec, RunSpec)
- `core/src/castle_core/config.py` — Config loader (castle.yaml → CastleConfig)
- `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation
- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates

View File

@@ -27,15 +27,13 @@ const TEMPLATES: Record<string, Record<string, unknown>> = {
path: { alias: "" },
},
},
worker: {
job: {
run: {
runner: "command",
argv: [""],
cwd: "",
},
manage: {
systemd: {},
},
schedule: "0 2 * * *",
},
empty: {},
}
@@ -145,7 +143,7 @@ export function AddComponent({ onAdd, existingNames }: AddComponentProps) {
>
<option value="service">Service (FastAPI + systemd + Caddy)</option>
<option value="tool">Tool (PATH install)</option>
<option value="worker">Worker (systemd, no HTTP)</option>
<option value="job">Job (scheduled task)</option>
<option value="empty">Empty</option>
</select>
</Field>

View File

@@ -38,9 +38,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
</div>
<div className="flex gap-1 mb-2">
{component.roles.map((role) => (
<RoleBadge key={role} role={role} />
))}
<RoleBadge role={component.category} />
</div>
{component.description && (

View File

@@ -25,9 +25,7 @@ export function ComponentEditor({ component, onSave, onDelete }: ComponentEditor
<span className="text-sm text-[var(--muted)]">{component.description}</span>
</div>
<div className="flex items-center gap-1.5">
{component.roles.map((r) => (
<RoleBadge key={r} role={r} />
))}
<RoleBadge role={component.category} />
</div>
</button>

View File

@@ -63,7 +63,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
try {
const config: Record<string, unknown> = { ...m }
delete config.id
delete config.roles
delete config.category
config.description = description || undefined
// Merge plain env + secret references back together

View File

@@ -1,16 +1,8 @@
import type { ComponentSummary, HealthStatus } from "@/types"
import { CATEGORY_LABELS } from "@/lib/labels"
import { ComponentCard } from "./ComponentCard"
const ROLE_ORDER = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"]
const ROLE_LABELS: Record<string, string> = {
service: "Services",
tool: "Tools",
worker: "Workers",
job: "Jobs",
frontend: "Frontends",
remote: "Remote",
containerized: "Containers",
}
const CATEGORY_ORDER = ["service", "job", "tool", "frontend", "component"]
interface ComponentGridProps {
components: ComponentSummary[]
@@ -20,24 +12,24 @@ interface ComponentGridProps {
export function ComponentGrid({ components, statuses }: ComponentGridProps) {
const statusMap = new Map(statuses.map((s) => [s.id, s]))
// Group by primary role
// Group by category
const groups = new Map<string, ComponentSummary[]>()
for (const comp of components) {
const primary = comp.roles[0] ?? "tool"
const list = groups.get(primary) ?? []
const cat = comp.category
const list = groups.get(cat) ?? []
list.push(comp)
groups.set(primary, list)
groups.set(cat, list)
}
return (
<div className="space-y-8">
{ROLE_ORDER.map((role) => {
const items = groups.get(role)
{CATEGORY_ORDER.map((cat) => {
const items = groups.get(cat)
if (!items?.length) return null
return (
<section key={role}>
<section key={cat}>
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
{ROLE_LABELS[role] ?? role}
{CATEGORY_LABELS[cat] ?? cat}
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{items.map((comp) => (

View File

@@ -12,7 +12,7 @@ interface ComponentTableProps {
statuses: HealthStatus[]
}
type SortKey = "id" | "role" | "runner" | "schedule" | "port" | "status"
type SortKey = "id" | "category" | "runner" | "schedule" | "port" | "status"
type SortDir = "asc" | "desc"
function statusRank(s: HealthStatus | undefined, installed: boolean | null): number {
@@ -30,13 +30,13 @@ function statusRank(s: HealthStatus | undefined, installed: boolean | null): num
export function ComponentTable({ components, statuses }: ComponentTableProps) {
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
const allRoles = useMemo(() => {
const allCategories = useMemo(() => {
const set = new Set<string>()
for (const c of components) for (const r of c.roles) set.add(r)
for (const c of components) set.add(c.category)
return Array.from(set).sort()
}, [components])
const [roleFilter, setRoleFilter] = useState<string | null>(null)
const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("id")
const [sortDir, setSortDir] = useState<SortDir>("asc")
@@ -52,8 +52,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
const filtered = useMemo(() => {
let list = components
if (roleFilter) {
list = list.filter((c) => c.roles.includes(roleFilter))
if (categoryFilter) {
list = list.filter((c) => c.category === categoryFilter)
}
if (search) {
const q = search.toLowerCase()
@@ -64,7 +64,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
)
}
return list
}, [components, roleFilter, search])
}, [components, categoryFilter, search])
const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1
@@ -72,8 +72,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
switch (sortKey) {
case "id":
return dir * a.id.localeCompare(b.id)
case "role":
return dir * (a.roles[0] ?? "").localeCompare(b.roles[0] ?? "")
case "category":
return dir * a.category.localeCompare(b.category)
case "runner":
return dir * (a.runner ?? "").localeCompare(b.runner ?? "")
case "schedule":
@@ -100,26 +100,26 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
/>
<div className="flex items-center gap-1.5">
<button
onClick={() => setRoleFilter(null)}
onClick={() => setCategoryFilter(null)}
className={`text-xs px-2 py-1 rounded transition-colors ${
roleFilter === null
categoryFilter === null
? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
All
</button>
{allRoles.map((role) => (
{allCategories.map((cat) => (
<button
key={role}
onClick={() => setRoleFilter(roleFilter === role ? null : role)}
key={cat}
onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
className={`text-xs px-2 py-1 rounded transition-colors ${
roleFilter === role
categoryFilter === cat
? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
{role}
{cat}
</button>
))}
</div>
@@ -131,7 +131,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Roles" sortKey="role" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Category" sortKey="category" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Runner" sortKey="runner" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Schedule" sortKey="schedule" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Port" sortKey="port" current={sortKey} dir={sortDir} onSort={toggleSort} />
@@ -233,11 +233,7 @@ function ComponentRow({
)}
</td>
<td className="px-3 py-2.5">
<div className="flex gap-1 flex-wrap">
{component.roles.map((role) => (
<RoleBadge key={role} role={role} />
))}
</div>
<RoleBadge role={component.category} />
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{component.runner ? runnerLabel(component.runner) : "—"}

View File

@@ -0,0 +1,105 @@
import { useMemo } from "react"
import { Link } from "react-router-dom"
import { RefreshCw } from "lucide-react"
import type { ComponentSummary } from "@/types"
import { useServiceAction } from "@/services/api/hooks"
import { SectionHeader } from "./SectionHeader"
import { SortHeader, useSort } from "./SortHeader"
type JobSortKey = "id" | "timer"
interface JobSectionProps {
jobs: ComponentSummary[]
}
export function JobSection({ jobs }: JobSectionProps) {
const { sortKey, sortDir, toggleSort } = useSort<JobSortKey>("id")
const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1
return [...jobs].sort((a, b) => {
switch (sortKey) {
case "id":
return dir * a.id.localeCompare(b.id)
case "timer": {
const aTimer = a.systemd?.timer ? 1 : 0
const bTimer = b.systemd?.timer ? 1 : 0
return dir * (aTimer - bTimer)
}
default:
return 0
}
})
}, [jobs, sortKey, sortDir])
return (
<section>
<SectionHeader category="job" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Schedule</th>
<SortHeader label="Timer" sortKey="timer" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((job) => (
<JobRow key={job.id} job={job} />
))}
</tbody>
</table>
</div>
</section>
)
}
function JobRow({ job }: { job: ComponentSummary }) {
const { mutate, isPending } = useServiceAction()
const hasTimer = job.systemd?.timer ?? false
return (
<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">
<Link
to={`/component/${job.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{job.id}
</Link>
{job.description && (
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">
{job.description}
</p>
)}
</td>
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
{job.schedule ?? "—"}
</td>
<td className="px-3 py-2.5">
{hasTimer ? (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded-full bg-purple-900/40 text-purple-400 border border-purple-800/50">
<span className="w-1.5 h-1.5 rounded-full bg-purple-400" />
active
</span>
) : (
<span className="text-[var(--muted)]"></span>
)}
</td>
<td className="px-3 py-2.5">
{job.managed && (
<button
onClick={() => mutate({ name: job.id, action: "restart" })}
disabled={isPending}
className="p-1 rounded hover:bg-blue-800/30 text-blue-400 transition-colors disabled:opacity-40"
title="Restart"
>
<RefreshCw size={14} />
</button>
)}
</td>
</tr>
)
}

View File

@@ -1,14 +1,12 @@
import { cn } from "@/lib/utils"
import { ROLE_DESCRIPTIONS } from "@/lib/labels"
import { CATEGORY_DESCRIPTIONS } from "@/lib/labels"
const roleColors: Record<string, string> = {
const categoryColors: Record<string, string> = {
service: "bg-green-700 text-white",
tool: "bg-blue-700 text-white",
worker: "bg-blue-500 text-white",
job: "bg-purple-700 text-white",
tool: "bg-blue-700 text-white",
frontend: "bg-yellow-600 text-black",
remote: "bg-gray-600 text-gray-200",
containerized: "bg-orange-600 text-black",
component: "bg-gray-600 text-gray-200",
}
export function RoleBadge({ role }: { role: string }) {
@@ -16,9 +14,9 @@ export function RoleBadge({ role }: { role: string }) {
<span
className={cn(
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
roleColors[role] ?? "bg-gray-600 text-gray-200",
categoryColors[role] ?? "bg-gray-600 text-gray-200",
)}
title={ROLE_DESCRIPTIONS[role]}
title={CATEGORY_DESCRIPTIONS[role]}
>
{role}
</span>

View File

@@ -0,0 +1,11 @@
import { SECTION_HEADERS } from "@/lib/labels"
export function SectionHeader({ category }: { category: string }) {
const info = SECTION_HEADERS[category]
return (
<div className="mb-3">
<h2 className="text-lg font-semibold">{info?.title ?? category}</h2>
<p className="text-sm text-[var(--muted)]">{info?.subtitle}</p>
</div>
)
}

View File

@@ -0,0 +1,24 @@
import { useMemo } from "react"
import type { ComponentSummary, HealthStatus } from "@/types"
import { ComponentCard } from "./ComponentCard"
import { SectionHeader } from "./SectionHeader"
interface ServiceSectionProps {
services: ComponentSummary[]
statuses: HealthStatus[]
}
export function ServiceSection({ services, statuses }: ServiceSectionProps) {
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
return (
<section>
<SectionHeader category="service" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{services.map((svc) => (
<ComponentCard key={svc.id} component={svc} health={statusMap.get(svc.id)} />
))}
</div>
</section>
)
}

View File

@@ -0,0 +1,48 @@
import { useState } from "react"
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"
export type SortDir = "asc" | "desc"
export function SortHeader<K extends string>({
label,
sortKey,
current,
dir,
onSort,
}: {
label: string
sortKey: K
current: K
dir: SortDir
onSort: (key: K) => void
}) {
const active = current === sortKey
const Icon = active ? (dir === "asc" ? ArrowUp : ArrowDown) : ArrowUpDown
return (
<th className="px-3 py-2 font-medium text-[var(--muted)]">
<button
onClick={() => onSort(sortKey)}
className="flex items-center gap-1 hover:text-[var(--foreground)] transition-colors"
>
{label}
<Icon size={12} className={active ? "text-[var(--foreground)]" : ""} />
</button>
</th>
)
}
export function useSort<K extends string>(defaultKey: K, defaultDir: SortDir = "asc") {
const [sortKey, setSortKey] = useState<K>(defaultKey)
const [sortDir, setSortDir] = useState<SortDir>(defaultDir)
const toggleSort = (key: K) => {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"))
} else {
setSortKey(key)
setSortDir("asc")
}
}
return { sortKey, sortDir, toggleSort } as const
}

View File

@@ -0,0 +1,120 @@
import { useMemo } from "react"
import { Link } from "react-router-dom"
import { Download, Trash2 } from "lucide-react"
import type { ComponentSummary } from "@/types"
import { useToolAction } from "@/services/api/hooks"
import { SectionHeader } from "./SectionHeader"
import { SortHeader, useSort } from "./SortHeader"
type ToolSortKey = "id" | "status"
function statusRank(installed: boolean | null): number {
if (installed === false) return 0
if (installed === true) return 1
return 2
}
interface ToolSectionProps {
tools: ComponentSummary[]
}
export function ToolSection({ tools }: ToolSectionProps) {
const { sortKey, sortDir, toggleSort } = useSort<ToolSortKey>("id")
const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1
return [...tools].sort((a, b) => {
switch (sortKey) {
case "id":
return dir * a.id.localeCompare(b.id)
case "status":
return dir * (statusRank(a.installed) - statusRank(b.installed))
default:
return 0
}
})
}, [tools, sortKey, sortDir])
return (
<section>
<SectionHeader category="tool" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Description</th>
<SortHeader label="Status" sortKey="status" current={sortKey} dir={sortDir} onSort={toggleSort} />
<th className="px-3 py-2 font-medium text-[var(--muted)]">Actions</th>
</tr>
</thead>
<tbody>
{sorted.map((tool) => (
<ToolRow key={tool.id} tool={tool} />
))}
</tbody>
</table>
</div>
</section>
)
}
function ToolRow({ tool }: { tool: ComponentSummary }) {
const { mutate, isPending } = useToolAction()
return (
<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">
<Link
to={`/component/${tool.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{tool.id}
</Link>
</td>
<td className="px-3 py-2.5 text-[var(--muted)] truncate max-w-xs">
{tool.description ?? "—"}
</td>
<td className="px-3 py-2.5">
{tool.installed !== null ? (
tool.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>
)
) : (
<span className="text-[var(--muted)]"></span>
)}
</td>
<td className="px-3 py-2.5">
{tool.installed !== null && (
tool.installed ? (
<button
onClick={() => mutate({ name: tool.id, action: "uninstall" })}
disabled={isPending}
className="p-1 rounded hover:bg-red-800/30 text-red-400 transition-colors disabled:opacity-40"
title="Uninstall from PATH"
>
<Trash2 size={14} />
</button>
) : (
<button
onClick={() => mutate({ name: tool.id, action: "install" })}
disabled={isPending}
className="p-1 rounded hover:bg-green-800/30 text-green-400 transition-colors disabled:opacity-40"
title="Install to PATH"
>
<Download size={14} />
</button>
)
)}
</td>
</tr>
)
}

View File

@@ -6,14 +6,28 @@ export const RUNNER_LABELS: Record<string, string> = {
remote: "Remote",
}
export const ROLE_DESCRIPTIONS: Record<string, string> = {
service: "Exposes HTTP endpoints",
export const CATEGORY_LABELS: Record<string, string> = {
service: "Services",
job: "Jobs",
tool: "Tools",
frontend: "Frontends",
component: "Components",
}
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
service: "Long-running daemon",
job: "Scheduled task",
tool: "CLI utility installed to PATH",
worker: "Background process (no HTTP)",
job: "Runs on a schedule",
frontend: "Built static assets",
remote: "Hosted externally",
containerized: "Runs in a container",
component: "Software component",
}
export const SECTION_HEADERS: Record<string, { title: string; subtitle: string }> = {
service: { title: "Services", subtitle: "Long-running daemons managed by systemd" },
job: { title: "Jobs", subtitle: "Scheduled tasks with cron timers" },
tool: { title: "Tools", subtitle: "CLI utilities installed to PATH" },
frontend: { title: "Frontends", subtitle: "Built web applications" },
component: { title: "Other", subtitle: "Software catalog entries" },
}
export function runnerLabel(runner: string): string {

View File

@@ -20,7 +20,7 @@ export function ComponentDetailPage() {
const { mutate, isPending } = useServiceAction()
const health = statusResp?.statuses.find((s) => s.id === name)
const isDown = health?.status === "down"
const isTool = component?.roles.includes("tool") ?? false
const isTool = component?.category === "tool"
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
const isGateway = name === "castle-gateway"
const { data: caddyfile } = useCaddyfile(isGateway)
@@ -28,10 +28,14 @@ export function ComponentDetailPage() {
const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd)
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
const configSection = component?.category === "service" ? "services"
: component?.category === "job" ? "jobs"
: "components"
const handleSave = async (compName: string, config: Record<string, unknown>) => {
setMessage(null)
try {
await apiClient.put(`/config/components/${compName}`, { config })
await apiClient.put(`/config/${configSection}/${compName}`, { config })
setMessage({ type: "ok", text: "Saved to castle.yaml" })
refetch()
qc.invalidateQueries({ queryKey: ["components"] })
@@ -43,7 +47,7 @@ export function ComponentDetailPage() {
const handleDelete = async (compName: string) => {
try {
await apiClient.delete(`/config/components/${compName}`)
await apiClient.delete(`/config/${configSection}/${compName}`)
qc.invalidateQueries({ queryKey: ["components"] })
navigate("/")
} catch (e: unknown) {
@@ -116,10 +120,11 @@ export function ComponentDetailPage() {
</div>
</div>
<div className="flex gap-1.5 mb-6">
{component.roles.map((role) => (
<RoleBadge key={role} role={role} />
))}
<div className="flex items-center gap-3 mb-6">
<RoleBadge role={component.category} />
{component.source && (
<span className="text-sm text-[var(--muted)] font-mono">{component.source}</span>
)}
</div>
{message && (

View File

@@ -1,5 +1,10 @@
import { ComponentTable } from "@/components/ComponentTable"
import { useMemo } from "react"
import { Link } from "react-router-dom"
import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks"
import { ServiceSection } from "@/components/ServiceSection"
import { JobSection } from "@/components/JobSection"
import { ToolSection } from "@/components/ToolSection"
import { SectionHeader } from "@/components/SectionHeader"
export function Dashboard() {
useEventStream()
@@ -7,6 +12,20 @@ export function Dashboard() {
const { data: statusResp } = useStatus()
const { data: gateway } = useGateway()
const { services, jobs, tools, frontends, other } = useMemo(() => {
const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components }
for (const c of components ?? []) {
if (c.category === "service") s.services!.push(c)
else if (c.category === "job") s.jobs!.push(c)
else if (c.category === "tool") s.tools!.push(c)
else if (c.category === "frontend") s.frontends!.push(c)
else s.other!.push(c)
}
return { services: s.services!, jobs: s.jobs!, tools: s.tools!, frontends: s.frontends!, other: s.other! }
}, [components])
const statuses = statusResp?.statuses ?? []
return (
<div className="max-w-6xl mx-auto px-6 py-8">
<div className="mb-8">
@@ -15,21 +34,79 @@ export function Dashboard() {
Personal software platform
{gateway && (
<span className="ml-2 text-sm">
&middot; {gateway.component_count} components &middot; port {gateway.port}
&middot; {services.length} services &middot; {jobs.length} jobs
&middot; {tools.length} tools &middot; port {gateway.port}
</span>
)}
</p>
</div>
{isLoading ? (
<p className="text-[var(--muted)]">Loading components...</p>
) : components ? (
<ComponentTable
components={components}
statuses={statusResp?.statuses ?? []}
/>
<p className="text-[var(--muted)]">Loading...</p>
) : (
<p className="text-red-400">Failed to load components</p>
<div className="space-y-10">
{services.length > 0 && (
<ServiceSection services={services} statuses={statuses} />
)}
{jobs.length > 0 && (
<JobSection jobs={jobs} />
)}
{tools.length > 0 && (
<ToolSection tools={tools} />
)}
{frontends.length > 0 && (
<section>
<SectionHeader category="frontend" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<tbody>
{frontends.map((fe) => (
<tr key={fe.id} className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/component/${fe.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{fe.id}
</Link>
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{fe.description ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{other.length > 0 && (
<section>
<SectionHeader category="component" />
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<tbody>
{other.map((c) => (
<tr key={c.id} className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
<td className="px-3 py-2.5">
<Link
to={`/component/${c.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{c.id}
</Link>
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{c.description ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
</div>
)}
</div>
)

View File

@@ -7,7 +7,7 @@ export interface SystemdInfo {
export interface ComponentSummary {
id: string
description: string | null
roles: string[]
category: string
runner: string | null
port: number | null
health_path: string | null

View File

@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from castle_core.config import save_config
from castle_core.manifest import ComponentManifest
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_config, get_registry
from castle_api.stream import broadcast
@@ -29,6 +29,8 @@ class ConfigSaveRequest(BaseModel):
class ConfigSaveResponse(BaseModel):
ok: bool
component_count: int
service_count: int
job_count: int
errors: list[str]
@@ -42,6 +44,14 @@ class ComponentConfigRequest(BaseModel):
config: dict
class ServiceConfigRequest(BaseModel):
config: dict
class JobConfigRequest(BaseModel):
config: dict
def _require_repo() -> None:
"""Raise 503 if repo is not available."""
if get_castle_root() is None:
@@ -76,22 +86,44 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
detail=f"Invalid YAML: {e}",
)
if not isinstance(data, dict) or "components" not in data:
if not isinstance(data, dict):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="YAML must have a 'components' key",
detail="YAML must be a mapping",
)
# Validate each component
count = 0
# Validate components
comp_count = 0
for name, comp_data in data.get("components", {}).items():
try:
comp_data_copy = dict(comp_data) if comp_data else {}
comp_data_copy["id"] = name
ComponentManifest.model_validate(comp_data_copy)
count += 1
ComponentSpec.model_validate(comp_data_copy)
comp_count += 1
except Exception as e:
errors.append(f"{name}: {e}")
errors.append(f"components.{name}: {e}")
# Validate services
svc_count = 0
for name, svc_data in data.get("services", {}).items():
try:
svc_data_copy = dict(svc_data) if svc_data else {}
svc_data_copy["id"] = name
ServiceSpec.model_validate(svc_data_copy)
svc_count += 1
except Exception as e:
errors.append(f"services.{name}: {e}")
# Validate jobs
job_count = 0
for name, job_data in data.get("jobs", {}).items():
try:
job_data_copy = dict(job_data) if job_data else {}
job_data_copy["id"] = name
JobSpec.model_validate(job_data_copy)
job_count += 1
except Exception as e:
errors.append(f"jobs.{name}: {e}")
if errors:
raise HTTPException(
@@ -105,7 +137,10 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
shutil.copy2(config_path, backup_path)
config_path.write_text(request.yaml_content)
return ConfigSaveResponse(ok=True, component_count=count, errors=[])
return ConfigSaveResponse(
ok=True, component_count=comp_count, service_count=svc_count,
job_count=job_count, errors=[],
)
@router.put("/components/{name}")
@@ -113,11 +148,10 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
"""Update a single component's config in castle.yaml."""
_require_repo()
# Validate
try:
comp_data = dict(request.config)
comp_data["id"] = name
ComponentManifest.model_validate(comp_data)
ComponentSpec.model_validate(comp_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -125,7 +159,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
)
config = get_config()
config.components[name] = ComponentManifest.model_validate(
config.components[name] = ComponentSpec.model_validate(
{**request.config, "id": name}
)
save_config(config)
@@ -146,6 +180,80 @@ def delete_component(name: str) -> dict:
return {"ok": True, "component": name, "action": "deleted"}
@router.put("/services/{name}")
def save_service(name: str, request: ServiceConfigRequest) -> dict:
"""Update a single service's config in castle.yaml."""
_require_repo()
try:
svc_data = dict(request.config)
svc_data["id"] = name
ServiceSpec.model_validate(svc_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid service config: {e}",
)
config = get_config()
config.services[name] = ServiceSpec.model_validate(
{**request.config, "id": name}
)
save_config(config)
return {"ok": True, "service": name}
@router.delete("/services/{name}")
def delete_service(name: str) -> dict:
"""Remove a service from castle.yaml."""
config = get_config()
if name not in config.services:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Service '{name}' not found",
)
del config.services[name]
save_config(config)
return {"ok": True, "service": name, "action": "deleted"}
@router.put("/jobs/{name}")
def save_job(name: str, request: JobConfigRequest) -> dict:
"""Update a single job's config in castle.yaml."""
_require_repo()
try:
job_data = dict(request.config)
job_data["id"] = name
JobSpec.model_validate(job_data)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid job config: {e}",
)
config = get_config()
config.jobs[name] = JobSpec.model_validate(
{**request.config, "id": name}
)
save_config(config)
return {"ok": True, "job": name}
@router.delete("/jobs/{name}")
def delete_job(name: str) -> dict:
"""Remove a job from castle.yaml."""
config = get_config()
if name not in config.jobs:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Job '{name}' not found",
)
del config.jobs[name]
save_config(config)
return {"ok": True, "job": name, "action": "deleted"}
@router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse:
"""Apply config: restart managed services + regenerate and reload gateway."""

View File

@@ -8,9 +8,7 @@ from collections.abc import AsyncGenerator
from fastapi import APIRouter, HTTPException, Query, status
from starlette.responses import StreamingResponse
from castle_core.config import load_config
from castle_api.config import settings
from castle_api.config import get_castle_root
router = APIRouter(prefix="/logs", tags=["logs"])
@@ -24,12 +22,25 @@ async def get_logs(
follow: bool = Query(default=False, description="Stream new lines via SSE"),
) -> StreamingResponse | dict:
"""Get logs for a systemd-managed service."""
config = load_config(settings.castle_root)
if name not in config.managed:
root = get_castle_root()
if root:
from castle_core.config import load_config
config = load_config(root)
is_managed = (
(name in config.services and config.services[name].manage is not None)
or (name in config.jobs and config.jobs[name].manage is not None)
)
if not is_managed:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service",
)
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Castle root not available",
)
unit = f"{UNIT_PREFIX}{name}.service"

View File

@@ -16,7 +16,7 @@ class ComponentSummary(BaseModel):
id: str
description: str | None = None
roles: list[str]
category: str
runner: str | None = None
port: int | None = None
health_path: str | None = None

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_registry
from castle_api.health import check_all_health
@@ -39,13 +40,13 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
# Check if tool is installed on PATH
installed: bool | None = None
if "tool" in deployed.roles:
if deployed.category == "tool":
installed = shutil.which(name) is not None
return ComponentSummary(
id=name,
description=deployed.description,
roles=deployed.roles,
category=deployed.category,
runner=deployed.runner,
port=deployed.port,
health_path=deployed.health_path,
@@ -57,67 +58,116 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
)
def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a manifest (for non-deployed components)."""
def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> ComponentSummary:
"""Build a ComponentSummary from a ServiceSpec (non-deployed)."""
port = None
health_path = None
proxy_path = None
if manifest.expose and manifest.expose.http:
port = manifest.expose.http.internal.port
health_path = manifest.expose.http.health_path
if manifest.proxy and manifest.proxy.caddy:
proxy_path = manifest.proxy.caddy.path_prefix
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(
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
)
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
has_timer = any(
getattr(t, "type", None) == "schedule" for t in manifest.triggers
)
systemd_info = SystemdInfo(
unit_name=unit_name,
unit_path=unit_path,
timer=has_timer,
)
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=False)
schedule = None
for t in manifest.triggers:
if t.type == "schedule":
schedule = t.cron
break
description = svc.description
source = None
if svc.component and svc.component in config.components:
comp = config.components[svc.component]
if not description:
description = comp.description
source = comp.source
runner = manifest.run.runner if manifest.run else None
if runner is None and manifest.tool and manifest.tool.source:
source_dir = root / manifest.tool.source
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
runner = "command"
installed: bool | None = None
if manifest.install and manifest.install.path:
alias = manifest.install.path.alias or name
installed = shutil.which(alias) is not None
runner = svc.run.runner
return ComponentSummary(
id=name,
description=manifest.description,
roles=[r.value for r in manifest.roles],
description=description,
category="service",
runner=runner,
port=port,
health_path=health_path,
proxy_path=proxy_path,
managed=managed,
systemd=systemd_info,
version=manifest.tool.version if manifest.tool else None,
source=manifest.tool.source if manifest.tool else None,
system_dependencies=manifest.tool.system_dependencies if manifest.tool else [],
schedule=schedule,
source=source,
)
def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSummary:
"""Build a ComponentSummary from a JobSpec (non-deployed)."""
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
systemd_info: SystemdInfo | None = None
if managed:
unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name)
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=True)
description = job.description
source = None
if job.component and job.component in config.components:
comp = config.components[job.component]
if not description:
description = comp.description
source = comp.source
return ComponentSummary(
id=name,
description=description,
category="job",
runner=job.run.runner,
managed=managed,
systemd=systemd_info,
schedule=job.schedule,
source=source,
)
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
# Determine category
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:
category = "tool"
elif is_frontend:
category = "frontend"
else:
category = "component"
source = comp.source
# Infer runner from source directory
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
return ComponentSummary(
id=name,
description=comp.description,
category=category,
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,
)
@@ -134,16 +184,49 @@ def list_components() -> list[ComponentSummary]:
summaries.append(_summary_from_deployed(name, deployed))
seen.add(name)
# Non-deployed components from castle.yaml (if repo available)
# Non-deployed from castle.yaml (if repo available)
root = get_castle_root()
if root:
try:
from castle_core.config import load_config
config = load_config(root)
for name, manifest in config.components.items():
# Services not in registry
for name, svc in config.services.items():
if name not in seen:
summaries.append(_summary_from_manifest(name, manifest, root))
summaries.append(_summary_from_service(name, svc, config))
seen.add(name)
# Jobs not in registry
for name, job in config.jobs.items():
if name not in seen:
summaries.append(_summary_from_job(name, job, config))
seen.add(name)
# Backfill source from component refs for deployed items
for s in summaries:
if s.source is None and s.id in config.components:
s.source = config.components[s.id].source
elif s.source is None:
# Check if a service/job references a component
ref = None
if s.id in config.services:
ref = config.services[s.id].component
elif s.id in config.jobs:
ref = config.jobs[s.id].component
if ref and ref in config.components:
s.source = config.components[ref].source
# Components (tools/frontends) — always listed, even if a
# service/job with the same name exists. A component is
# "what software exists", services/jobs are "how it runs".
for name, comp in config.components.items():
summary = _summary_from_component(name, comp, root)
# Skip if this exact category is already represented
# (e.g. a deployed tool already in the list)
if not any(s.id == name and s.category == summary.category for s in summaries):
summaries.append(summary)
except FileNotFoundError:
pass
@@ -158,6 +241,27 @@ def get_component(name: str) -> ComponentDetail:
if name in registry.deployed:
deployed = registry.deployed[name]
summary = _summary_from_deployed(name, deployed)
# Backfill source from castle.yaml component ref
root = get_castle_root()
if root and summary.source is None:
try:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
summary.source = config.components[name].source
else:
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.components:
summary.source = config.components[ref].source
except FileNotFoundError:
pass
raw = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
@@ -166,7 +270,7 @@ def get_component(name: str) -> ComponentDetail:
"health_path": deployed.health_path,
"proxy_path": deployed.proxy_path,
"managed": deployed.managed,
"roles": deployed.roles,
"category": deployed.category,
}
return ComponentDetail(**summary.model_dump(), manifest=raw)
@@ -176,10 +280,23 @@ def get_component(name: str) -> ComponentDetail:
from castle_core.config import load_config
config = load_config(root)
if name in config.services:
svc = config.services[name]
summary = _summary_from_service(name, svc, config)
raw = svc.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
if name in config.jobs:
job = config.jobs[name]
summary = _summary_from_job(name, job, config)
raw = job.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
if name in config.components:
manifest = config.components[name]
summary = _summary_from_manifest(name, manifest, root)
raw = manifest.model_dump(mode="json", exclude_none=True)
comp = config.components[name]
summary = _summary_from_component(name, comp, root)
raw = comp.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw)
raise HTTPException(

View File

@@ -131,21 +131,29 @@ def get_unit(name: str) -> dict[str, str | None]:
registry = get_registry()
deployed = registry.deployed[name]
# Get systemd spec from manifest if repo available
# Get systemd spec from config if repo available
systemd_spec = None
schedule = None
description = None
root = get_castle_root()
manifest = None
if root:
from castle_core.config import load_config
config = load_config(root)
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
description = svc.description
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
schedule = job.schedule
description = job.description
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
timer = generate_timer(name, manifest) if manifest else None
timer = generate_timer(name, schedule, description) if schedule else None
return {"service": unit, "timer": timer}

View File

@@ -7,7 +7,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, status
from castle_core.manifest import ComponentManifest
from castle_core.manifest import ComponentSpec
from castle_api.config import get_config
from castle_api.models import ToolDetail, ToolSummary
@@ -15,20 +15,25 @@ from castle_api.models import ToolDetail, ToolSummary
router = APIRouter(tags=["tools"])
def _is_tool(comp: ComponentSpec) -> bool:
"""Check if a component is a tool (has install.path or tool spec)."""
return bool((comp.install and comp.install.path) or comp.tool)
def _tool_summary(
name: str, manifest: ComponentManifest, root: Path | None = None
name: str, comp: ComponentSpec, root: Path | None = None
) -> ToolSummary:
"""Build a ToolSummary from a manifest that has a tool spec."""
t = manifest.tool
assert t is not None
"""Build a ToolSummary from a ComponentSpec that is a tool."""
t = comp.tool
installed = bool(
manifest.install and manifest.install.path and manifest.install.path.enable
comp.install and comp.install.path and comp.install.path.enable
)
# Infer runner from run block or source directory
runner = manifest.run.runner if manifest.run else None
if runner is None and t.source and root:
source_dir = root / t.source
# Infer runner from source directory
runner = None
source = comp.source
if source and root:
source_dir = root / source
if (source_dir / "pyproject.toml").exists():
runner = "python"
elif source_dir.is_file():
@@ -36,11 +41,11 @@ def _tool_summary(
return ToolSummary(
id=name,
description=manifest.description,
source=t.source,
version=t.version,
description=comp.description,
source=source,
version=t.version if t else None,
runner=runner,
system_dependencies=t.system_dependencies,
system_dependencies=t.system_dependencies if t else [],
installed=installed,
)
@@ -49,12 +54,12 @@ def _tool_summary(
def list_tools() -> list[ToolSummary]:
"""List all registered tools (requires repo access)."""
config = get_config()
tools = {k: v for k, v in config.components.items() if v.tool}
tools = {k: v for k, v in config.components.items() if _is_tool(v)}
return sorted(
[
_tool_summary(name, manifest, config.root)
for name, manifest in tools.items()
_tool_summary(name, comp, config.root)
for name, comp in tools.items()
],
key=lambda t: t.id,
)
@@ -71,14 +76,14 @@ def get_tool(name: str) -> ToolDetail:
detail=f"Component '{name}' not found",
)
manifest = config.components[name]
if not manifest.tool:
comp = config.components[name]
if not _is_tool(comp):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a tool",
)
summary = _tool_summary(name, manifest, config.root)
summary = _tool_summary(name, comp, config.root)
return ToolDetail(**summary.model_dump())
@@ -91,16 +96,16 @@ async def install_tool(name: str) -> dict:
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
manifest = config.components[name]
if not manifest.tool or not manifest.tool.source:
comp = config.components[name]
if not comp.source:
raise HTTPException(
status_code=400, detail=f"'{name}' has no tool source to install"
status_code=400, detail=f"'{name}' has no source to install"
)
source_dir = config.root / manifest.tool.source
source_dir = config.root / comp.source
if not (source_dir / "pyproject.toml").exists():
raise HTTPException(
status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}"
status_code=400, detail=f"No pyproject.toml in {comp.source}"
)
proc = await asyncio.create_subprocess_exec(
@@ -131,12 +136,12 @@ async def uninstall_tool(name: str) -> dict:
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
)
manifest = config.components[name]
if not manifest.tool or not manifest.tool.source:
raise HTTPException(status_code=400, detail=f"'{name}' has no tool source")
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 / manifest.tool.source
source_dir = config.root / comp.source
pkg_name = source_dir.name
pyproject = source_dir / "pyproject.toml"
if pyproject.exists():

View File

@@ -24,9 +24,26 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
config = {
"gateway": {"port": 9000},
"components": {
"test-tool": {
"description": "Test tool",
"source": "test-tool",
"install": {"path": {"alias": "test-tool"}},
"tool": {
"system_dependencies": ["pandoc"],
},
},
"test-tool-2": {
"description": "Another test tool",
"source": "test-tool-2",
"tool": {
"version": "2.0.0",
},
},
},
"services": {
"test-svc": {
"component": "test-svc-comp",
"description": "Test service",
"source": "test-svc",
"run": {
"runner": "python",
"tool": "test-svc",
@@ -40,20 +57,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"proxy": {"caddy": {"path_prefix": "/test-svc"}},
"manage": {"systemd": {}},
},
"test-tool": {
"description": "Test tool",
"install": {"path": {"alias": "test-tool"}},
"tool": {
"source": "test-tool",
"system_dependencies": ["pandoc"],
},
"jobs": {
"test-job": {
"description": "Test job",
"run": {
"runner": "command",
"argv": ["test-job"],
},
"test-tool-2": {
"description": "Another test tool",
"tool": {
"source": "test-tool-2",
"version": "2.0.0",
},
"schedule": "0 2 * * *",
},
},
}
@@ -80,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
"TEST_SVC_DATA_DIR": "/data/castle/test-svc",
},
description="Test service",
roles=["service"],
category="service",
port=19000,
health_path="/health",
proxy_path="/test-svc",

View File

@@ -34,7 +34,7 @@ class TestComponents:
assert svc["health_path"] == "/health"
assert svc["proxy_path"] == "/test-svc"
assert svc["managed"] is True
assert "service" in svc["roles"]
assert svc["category"] == "service"
def test_tool_has_no_port(self, client: TestClient) -> None:
"""Tool component has no port."""
@@ -42,7 +42,15 @@ class TestComponents:
data = response.json()
tool = next(c for c in data if c["id"] == "test-tool")
assert tool["port"] is None
assert "tool" in tool["roles"]
assert tool["category"] == "tool"
def test_job_has_schedule(self, client: TestClient) -> None:
"""Job component has schedule."""
response = client.get("/components")
data = response.json()
job = next(c for c in data if c["id"] == "test-job")
assert job["category"] == "job"
assert job["schedule"] == "0 2 * * *"
class TestComponentDetail:

View File

@@ -1,138 +1,31 @@
gateway:
port: 9000
components:
castle-gateway:
description: Caddy reverse proxy gateway
run:
runner: command
argv:
- caddy
- run
- --config
- /home/payne/.castle/generated/Caddyfile
- --adapter
- caddyfile
manage:
systemd:
exec_reload: caddy reload --config /home/payne/.castle/generated/Caddyfile
--adapter caddyfile
expose:
http:
internal:
port: 9000
health_path: /
central-context:
description: Content storage API useful to get context into my data dir from anywhere
on the LAN
source: components/central-context
run:
runner: python
tool: central-context
manage:
systemd: {}
expose:
http:
internal:
port: 9001
health_path: /health
proxy:
caddy:
path_prefix: /central-context
notification-bridge:
description: Desktop notification forwarder. This taps into your notifications
system on the desktop and will forward all notifications the the central context
server.
source: components/notification-bridge
run:
runner: python
tool: notification-bridge
defaults:
env:
CENTRAL_CONTEXT_URL: http://localhost:9001
BUCKET_NAME: notifications
PORT: '9002'
manage:
systemd: {}
expose:
http:
internal:
port: 9002
health_path: /health
proxy:
caddy:
path_prefix: /notifications
castle-api:
description: Castle API
source: castle-api
run:
runner: python
tool: castle-api
manage:
systemd: {}
expose:
http:
internal:
port: 9020
health_path: /health
proxy:
caddy:
path_prefix: /api
protonmail:
description: ProtonMail email sync via Bridge
source: components/protonmail
run:
runner: command
argv:
- protonmail
- sync
defaults:
env:
PROTONMAIL_USERNAME: paul@payne.io
PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY}
triggers:
- type: schedule
cron: '*/5 * * * *'
timezone: America/Los_Angeles
manage:
systemd: {}
install:
path:
alias: protonmail
backup-collect:
description: Collect files from various sources into backup directory
source: components/backup-collect
run:
runner: command
argv:
- backup-collect
defaults:
env:
DBACKUP: /data/backup
triggers:
- type: schedule
cron: 0 2 * * *
timezone: America/Los_Angeles
manage:
systemd: {}
tool:
system_dependencies:
- rsync
backup-data:
description: Nightly restic backup of /data to /storage
run:
runner: command
argv:
- backup-data
defaults:
env:
RESTIC_REPOSITORY: /storage/restic-data
RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password
triggers:
- type: schedule
cron: 30 3 * * *
timezone: America/Los_Angeles
manage:
systemd: {}
castle-app:
description: Castle web app
source: app
@@ -249,3 +142,118 @@ components:
install:
path:
alias: text-extractor
services:
castle-gateway:
description: Caddy reverse proxy gateway
run:
runner: command
argv:
- caddy
- run
- --config
- /home/payne/.castle/generated/Caddyfile
- --adapter
- caddyfile
manage:
systemd:
exec_reload: caddy reload --config /home/payne/.castle/generated/Caddyfile
--adapter caddyfile
expose:
http:
internal:
port: 9000
health_path: /
central-context:
component: central-context
run:
runner: python
tool: central-context
manage:
systemd: {}
expose:
http:
internal:
port: 9001
health_path: /health
proxy:
caddy:
path_prefix: /central-context
notification-bridge:
component: notification-bridge
run:
runner: python
tool: notification-bridge
defaults:
env:
CENTRAL_CONTEXT_URL: http://localhost:9001
BUCKET_NAME: notifications
PORT: '9002'
manage:
systemd: {}
expose:
http:
internal:
port: 9002
health_path: /health
proxy:
caddy:
path_prefix: /notifications
castle-api:
component: castle-api
run:
runner: python
tool: castle-api
manage:
systemd: {}
expose:
http:
internal:
port: 9020
health_path: /health
proxy:
caddy:
path_prefix: /api
jobs:
protonmail-sync:
component: protonmail
description: Sync ProtonMail email
run:
runner: command
argv:
- protonmail
- sync
schedule: '*/5 * * * *'
defaults:
env:
PROTONMAIL_USERNAME: paul@payne.io
PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY}
manage:
systemd: {}
backup-collect:
component: backup-collect
description: Collect files from various sources into backup directory
run:
runner: command
argv:
- backup-collect
schedule: 0 2 * * *
defaults:
env:
DBACKUP: /data/backup
manage:
systemd: {}
backup-data:
description: Nightly restic backup of /data to /storage
run:
runner: command
argv:
- backup-data
schedule: 30 3 * * *
defaults:
env:
RESTIC_REPOSITORY: /storage/restic-data
RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password
manage:
systemd: {}

View File

@@ -7,7 +7,7 @@ import argparse
from castle_cli.config import load_config, save_config
from castle_cli.manifest import (
CaddySpec,
ComponentManifest,
ComponentSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
@@ -16,6 +16,7 @@ from castle_cli.manifest import (
PathInstallSpec,
ProxySpec,
RunPython,
ServiceSpec,
SystemdSpec,
ToolSpec,
)
@@ -25,9 +26,9 @@ from castle_cli.templates.scaffold import scaffold_project
def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
used_ports = set()
for manifest in config.components.values():
if manifest.expose and manifest.expose.http:
used_ports.add(manifest.expose.http.internal.port)
for svc in config.services.values():
if svc.expose and svc.expose.http:
used_ports.add(svc.expose.http.internal.port)
# Also reserve gateway port
used_ports.add(config.gateway.port)
@@ -43,8 +44,8 @@ def run_create(args: argparse.Namespace) -> int:
name = args.name
proj_type = args.type
if name in config.components:
print(f"Error: component '{name}' already exists in castle.yaml")
if name in config.components or name in config.services or name in config.jobs:
print(f"Error: '{name}' already exists in castle.yaml")
return 1
components_dir = config.root / "components"
@@ -72,16 +73,19 @@ def run_create(args: argparse.Namespace) -> int:
port=port,
)
# Build manifest entry
# Build entries
if proj_type == "service":
manifest = ComponentManifest(
# Component for software identity
config.components[name] = ComponentSpec(
id=name,
description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
run=RunPython(
runner="python",
tool=name,
),
)
# Service for deployment
config.services[name] = ServiceSpec(
id=name,
component=name,
run=RunPython(runner="python", tool=name),
expose=ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=port),
@@ -92,22 +96,21 @@ def run_create(args: argparse.Namespace) -> int:
manage=ManageSpec(systemd=SystemdSpec()),
)
elif proj_type == "tool":
manifest = ComponentManifest(
config.components[name] = ComponentSpec(
id=name,
description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
tool=ToolSpec(source=f"components/{name}/"),
tool=ToolSpec(),
install=InstallSpec(path=PathInstallSpec(alias=name)),
)
else:
# library or other
manifest = ComponentManifest(
config.components[name] = ComponentSpec(
id=name,
description=args.description or f"A castle {proj_type}",
source=f"components/{name}",
)
config.components[name] = manifest
save_config(config)
print(f"Created {proj_type} '{name}' at {project_dir}")

View File

@@ -19,11 +19,10 @@ from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
from castle_core.manifest import ComponentManifest
from castle_core.manifest import JobSpec, ServiceSpec
from castle_core.registry import (
REGISTRY_PATH,
DeployedComponent,
@@ -40,15 +39,7 @@ SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
def run_deploy(args: argparse.Namespace) -> int:
"""Deploy components from castle.yaml to ~/.castle/."""
config = load_config()
component_name = getattr(args, "component", None)
if component_name:
if component_name not in config.components:
print(f"Error: component '{component_name}' not found in castle.yaml")
return 1
names = [component_name]
else:
names = list(config.components.keys())
target_name = getattr(args, "component", None)
ensure_dirs()
@@ -57,7 +48,7 @@ def run_deploy(args: argparse.Namespace) -> int:
# Load existing registry to preserve components not being redeployed,
# or start fresh if deploying all
if component_name and REGISTRY_PATH.exists():
if target_name and REGISTRY_PATH.exists():
try:
existing = load_registry()
registry = NodeRegistry(node=node, deployed=dict(existing.deployed))
@@ -67,14 +58,21 @@ def run_deploy(args: argparse.Namespace) -> int:
registry = NodeRegistry(node=node)
deployed_count = 0
for name in names:
manifest = config.components[name]
# Only deploy components with a run spec
if not manifest.run:
# Deploy services
for name, svc in config.services.items():
if target_name and name != target_name:
continue
deployed = _build_deployed_service(config, name, svc)
registry.deployed[name] = deployed
deployed_count += 1
_print_deployed(name, deployed)
deployed = _build_deployed(config, name, manifest)
# Deploy jobs
for name, job in config.jobs.items():
if target_name and name != target_name:
continue
deployed = _build_deployed_job(config, name, job)
registry.deployed[name] = deployed
deployed_count += 1
_print_deployed(name, deployed)
@@ -108,69 +106,100 @@ def _env_prefix(name: str) -> str:
return name.replace("-", "_").upper()
def _build_deployed(
config: CastleConfig, name: str, manifest: ComponentManifest
) -> DeployedComponent:
"""Build a DeployedComponent from a manifest spec."""
run = manifest.run
assert run is not None
def _resolve_description(
config: CastleConfig, spec: ServiceSpec | JobSpec
) -> str | None:
"""Get description, falling through to component if referenced."""
if spec.description:
return spec.description
if spec.component and spec.component in config.components:
return config.components[spec.component].description
return None
# 1. Convention-based env vars
def _build_deployed_service(
config: CastleConfig, name: str, svc: ServiceSpec
) -> DeployedComponent:
"""Build a DeployedComponent from a ServiceSpec."""
run = svc.run
prefix = _env_prefix(name)
env: dict[str, str] = {}
# Data dir convention (for all managed components)
if manifest.manage and manifest.manage.systemd:
# Data dir convention (for managed services)
managed = True # Services are always managed by default
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
managed = False
if managed:
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
# Port convention (if exposed)
if manifest.expose and manifest.expose.http:
env[f"{prefix}_PORT"] = str(manifest.expose.http.internal.port)
# 2. Merge defaults.env (overrides conventions)
if manifest.defaults and manifest.defaults.env:
env.update(manifest.defaults.env)
# 3. Resolve secrets
env = resolve_env_vars(env, manifest)
# 4. Build run_cmd
run_cmd = _build_run_cmd(run, env)
# 5. Extract metadata
port = None
health_path = None
if manifest.expose and manifest.expose.http:
port = manifest.expose.http.internal.port
health_path = manifest.expose.http.health_path
if svc.expose and svc.expose.http:
port = svc.expose.http.internal.port
env[f"{prefix}_PORT"] = str(port)
health_path = svc.expose.http.health_path
# Merge defaults.env (overrides conventions)
if svc.defaults and svc.defaults.env:
env.update(svc.defaults.env)
# Resolve secrets
env = resolve_env_vars(env)
# Build run_cmd
run_cmd = _build_run_cmd(run, env)
# Proxy path
proxy_path = None
if manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable:
proxy_path = manifest.proxy.caddy.path_prefix or f"/{name}"
schedule = None
sched_trigger = get_schedule_trigger(manifest)
if sched_trigger:
schedule = sched_trigger.cron
managed = bool(manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable)
roles = [r.value for r in manifest.roles]
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=manifest.description,
roles=roles,
description=_resolve_description(config, svc),
category="service",
port=port,
health_path=health_path,
proxy_path=proxy_path,
schedule=schedule,
managed=managed,
)
def _build_deployed_job(
config: CastleConfig, name: str, job: JobSpec
) -> DeployedComponent:
"""Build a DeployedComponent from a JobSpec."""
run = job.run
prefix = _env_prefix(name)
env: dict[str, str] = {}
# Data dir convention
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
# Merge defaults.env (overrides conventions)
if job.defaults and job.defaults.env:
env.update(job.defaults.env)
# Resolve secrets
env = resolve_env_vars(env)
# Build run_cmd
run_cmd = _build_run_cmd(run, env)
return DeployedComponent(
runner=run.runner,
run_cmd=run_cmd,
env=env,
description=_resolve_description(config, job),
category="job",
schedule=job.schedule,
managed=True,
)
def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
"""Build a run command list from a RunSpec."""
match run.runner:
@@ -199,7 +228,6 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
cmd.extend(["-p", f"{host_port}:{container_port}"])
for vol in run.volumes:
cmd.extend(["-v", vol])
# Container env comes from both run.env (container-specific) and deployed env
for key, val in run.env.items():
cmd.extend(["-e", f"{key}={val}"])
for key, val in env.items():
@@ -238,13 +266,12 @@ def _copy_app_static(config: CastleConfig) -> None:
if "castle-app" not in config.components:
return
manifest = config.components["castle-app"]
if not (manifest.build and manifest.build.outputs):
comp = config.components["castle-app"]
if not (comp.build and comp.build.outputs):
return
# Find the source dist directory
source_dir = manifest.source_dir or "app"
for output in manifest.build.outputs:
source_dir = comp.source_dir or "app"
for output in comp.build.outputs:
src = config.root / source_dir / output
if src.exists():
dest = STATIC_DIR / "castle-app"
@@ -262,22 +289,29 @@ def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> Non
if not deployed.managed:
continue
# Get systemd spec from manifest (for restart policy, exec_reload, etc.)
# Get systemd spec from config (services or jobs)
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
# Generate and write service unit
svc_name = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content)
# Generate timer if scheduled
if name in config.components:
timer_content = generate_timer(name, config.components[name])
if timer_content:
# Generate timer for jobs
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
tmr_name = timer_name(name)
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)

View File

@@ -69,8 +69,8 @@ def _gateway_start(config: CastleConfig) -> int:
"""Generate config and enable the gateway service."""
from castle_cli.commands.service import _service_enable
if GATEWAY_COMPONENT not in config.managed:
print(f"Error: '{GATEWAY_COMPONENT}' not found in castle.yaml or not managed")
if GATEWAY_COMPONENT not in config.services:
print(f"Error: '{GATEWAY_COMPONENT}' not found in services section")
return 1
print("Generating gateway configuration...")
@@ -101,7 +101,6 @@ def _gateway_reload() -> int:
if result.returncode == 0:
print("Gateway reloaded.")
else:
# Fall back to restart if reload not supported
print("Reload signal sent. Verifying...")
result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT],

View File

@@ -29,105 +29,102 @@ def _load_deployed_component(name: str) -> object | None:
def run_info(args: argparse.Namespace) -> int:
"""Show detailed info for a component."""
"""Show detailed info for a component, service, or job."""
config = load_config()
name = args.project
if name not in config.components:
print(f"Error: component '{name}' not found in castle.yaml")
# Look up in all sections
component = config.components.get(name)
service = config.services.get(name)
job = config.jobs.get(name)
if not component and not service and not job:
print(f"Error: '{name}' not found in castle.yaml")
return 1
manifest = config.components[name]
deployed = _load_deployed_component(name)
if getattr(args, "json", False):
data = manifest.model_dump(exclude_none=True)
# Include CLAUDE.md content if it exists
claude_md = _find_claude_md(config.root, manifest.source_dir or name)
if claude_md:
data["claude_md"] = claude_md
if deployed:
data["deployed"] = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"port": deployed.port,
"managed": deployed.managed,
}
print(json.dumps(data, indent=2))
return 0
return _info_json(config, name, component, service, job, deployed)
# Human-readable output
print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}")
print(f" {BOLD}roles{RESET}: {', '.join(r.value for r in manifest.roles)}")
if manifest.description:
print(f" {BOLD}description{RESET}: {manifest.description}")
# Source
if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}")
# Determine category
if service:
print(f" {BOLD}category{RESET}: service")
elif job:
print(f" {BOLD}category{RESET}: job")
elif component:
if component.tool or (component.install and component.install.path):
print(f" {BOLD}category{RESET}: tool")
elif component.build:
print(f" {BOLD}category{RESET}: frontend")
else:
print(f" {BOLD}category{RESET}: component")
# Component info
if component:
if component.description:
print(f" {BOLD}description{RESET}: {component.description}")
if component.source:
print(f" {BOLD}source{RESET}: {component.source}")
if component.install and component.install.path:
pi = component.install.path
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
if component.tool:
t = component.tool
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
if component.tags:
print(f" {BOLD}tags{RESET}: {', '.join(component.tags)}")
# Service info
spec = service or job
if spec:
desc = spec.description
if not desc and spec.component and spec.component in config.components:
desc = config.components[spec.component].description
if desc and not (component and component.description == desc):
print(f" {BOLD}description{RESET}: {desc}")
if spec.component:
print(f" {BOLD}component{RESET}: {spec.component}")
# Run spec
if manifest.run:
print(f" {BOLD}runner{RESET}: {manifest.run.runner}")
if hasattr(manifest.run, "tool"):
print(f" {BOLD}tool{RESET}: {manifest.run.tool}")
elif hasattr(manifest.run, "argv"):
print(f" {BOLD}argv{RESET}: {manifest.run.argv}")
elif hasattr(manifest.run, "image"):
print(f" {BOLD}image{RESET}: {manifest.run.image}")
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
if hasattr(spec.run, "tool"):
print(f" {BOLD}tool{RESET}: {spec.run.tool}")
elif hasattr(spec.run, "argv"):
print(f" {BOLD}argv{RESET}: {spec.run.argv}")
elif hasattr(spec.run, "image"):
print(f" {BOLD}image{RESET}: {spec.run.image}")
# Defaults env
if manifest.defaults and manifest.defaults.env:
if spec.defaults and spec.defaults.env:
print(f" {BOLD}defaults.env{RESET}:")
for key, val in manifest.defaults.env.items():
for key, val in spec.defaults.env.items():
print(f" {key}: {val}")
# Expose
if manifest.expose and manifest.expose.http:
http = manifest.expose.http
# Service-specific: expose, proxy, manage
if service:
if service.expose and service.expose.http:
http = service.expose.http
print(f" {BOLD}port{RESET}: {http.internal.port}")
if http.health_path:
print(f" {BOLD}health{RESET}: {http.health_path}")
# Proxy
if manifest.proxy and manifest.proxy.caddy:
caddy = manifest.proxy.caddy
if service.proxy and service.proxy.caddy:
caddy = service.proxy.caddy
if caddy.path_prefix:
print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
# Manage
if manifest.manage and manifest.manage.systemd:
sd = manifest.manage.systemd
if service.manage and service.manage.systemd:
sd = service.manage.systemd
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
# Install
if manifest.install and manifest.install.path:
pi = manifest.install.path
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
# Tool
if manifest.tool:
t = manifest.tool
if t.source:
print(f" {BOLD}tool.source{RESET}: {t.source}")
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
# Tags
if manifest.tags:
print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}")
# Capabilities
if manifest.provides:
print(f" {BOLD}provides{RESET}:")
for cap in manifest.provides:
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
if manifest.consumes:
print(f" {BOLD}consumes{RESET}:")
for cap in manifest.consumes:
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
# Job-specific
if job:
print(f" {BOLD}schedule{RESET}: {job.schedule}")
print(f" {BOLD}timezone{RESET}: {job.timezone}")
# Deployed state from registry
if deployed:
@@ -142,10 +139,16 @@ def run_info(args: argparse.Namespace) -> int:
for key, val in deployed.env.items():
print(f" {key}={val}")
else:
print(f"\n {DIM}not deployed (run 'castle deploy {name}'){RESET}")
print(f"\n {DIM}not deployed (run 'castle deploy'){RESET}")
# Show CLAUDE.md if it exists
source_dir = manifest.source_dir or name
source_dir = None
if component and component.source_dir:
source_dir = component.source_dir
elif spec and spec.component and spec.component in config.components:
source_dir = config.components[spec.component].source_dir
if source_dir:
claude_md = _find_claude_md(config.root, source_dir)
if claude_md:
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
@@ -156,6 +159,46 @@ def run_info(args: argparse.Namespace) -> int:
return 0
def _info_json(
config: object,
name: str,
component: object | None,
service: object | None,
job: object | None,
deployed: object | None,
) -> int:
"""Output JSON info."""
data: dict = {"name": name}
if component:
data["component"] = component.model_dump(exclude_none=True, exclude={"id"})
if service:
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
data["category"] = "service"
if job:
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
data["category"] = "job"
if not service and not job and component:
if component.tool or (component.install and component.install.path):
data["category"] = "tool"
elif component.build:
data["category"] = "frontend"
else:
data["category"] = "component"
if deployed:
data["deployed"] = {
"runner": deployed.runner,
"run_cmd": deployed.run_cmd,
"env": deployed.env,
"port": deployed.port,
"managed": deployed.managed,
}
print(json.dumps(data, indent=2))
return 0
def _find_claude_md(root: Path, source_dir: str) -> str | None:
"""Read CLAUDE.md from project directory if it exists."""
claude_path = root / source_dir / "CLAUDE.md"

View File

@@ -7,7 +7,6 @@ import json
import logging
from castle_cli.config import load_config
from castle_cli.manifest import Role
log = logging.getLogger(__name__)
@@ -17,15 +16,15 @@ RESET = "\033[0m"
DIM = "\033[2m"
GREEN = "\033[92m"
RED = "\033[91m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
YELLOW = "\033[93m"
ROLE_COLORS: dict[str, str] = {
Role.SERVICE: "\033[92m", # green
Role.TOOL: "\033[96m", # cyan
Role.WORKER: "\033[94m", # blue
Role.JOB: "\033[95m", # magenta
Role.FRONTEND: "\033[93m", # yellow
Role.REMOTE: "\033[90m", # dim
Role.CONTAINERIZED: "\033[33m", # orange
CATEGORY_COLORS: dict[str, str] = {
"service": GREEN,
"job": MAGENTA,
"tool": CYAN,
"frontend": YELLOW,
}
@@ -41,71 +40,132 @@ def _load_deployed() -> dict[str, object] | None:
def run_list(args: argparse.Namespace) -> int:
"""List all components."""
"""List all components, services, and jobs."""
config = load_config()
deployed = _load_deployed()
components = config.components
filter_role = getattr(args, "role", None)
if filter_role:
components = {k: v for k, v in components.items() if filter_role in v.roles}
filter_type = getattr(args, "type", None)
if getattr(args, "json", False):
output = []
for name, manifest in components.items():
entry: dict = {
"name": name,
"roles": [r.value for r in manifest.roles],
"deployed": deployed is not None and name in deployed,
}
if manifest.description:
entry["description"] = manifest.description
if manifest.expose and manifest.expose.http:
entry["port"] = manifest.expose.http.internal.port
if deployed and name in deployed:
dep = deployed[name]
if dep.port is not None:
entry["port"] = dep.port
output.append(entry)
print(json.dumps(output, indent=2))
return 0
return _list_json(config, deployed, filter_type)
if not components:
print("No components found.")
return 0
any_output = False
# Group by primary role (first in sorted list)
by_role: dict[str, list[tuple[str, object]]] = {}
for name, manifest in components.items():
primary_role = manifest.roles[0].value if manifest.roles else "other"
by_role.setdefault(primary_role, []).append((name, manifest))
# Display order
role_order = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"]
for role_name in role_order:
items = by_role.get(role_name, [])
if not items:
continue
color = ROLE_COLORS.get(role_name, "")
print(f"\n{BOLD}{color}{role_name}s{RESET}")
# Services
if not filter_type or filter_type == "service":
if config.services:
any_output = True
color = CATEGORY_COLORS["service"]
print(f"\n{BOLD}{color}Services{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, manifest in items:
for name, svc in config.services.items():
port_str = ""
if manifest.expose and manifest.expose.http:
port_str = f" :{manifest.expose.http.internal.port}"
if svc.expose and svc.expose.http:
port_str = f" :{svc.expose.http.internal.port}"
# Show deployed status indicator
if deployed is not None:
status = f"{GREEN}{RESET}" if name in deployed else f"{RED}{RESET}"
else:
status = f"{DIM}?{RESET}"
desc = f" {DIM}{manifest.description}{RESET}" if manifest.description else ""
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}")
# Jobs
if not filter_type or filter_type == "job":
if config.jobs:
any_output = True
color = CATEGORY_COLORS["job"]
print(f"\n{BOLD}{color}Jobs{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, job in config.jobs.items():
if deployed is not None:
status = f"{GREEN}{RESET}" if name in deployed else f"{RED}{RESET}"
else:
status = f"{DIM}?{RESET}"
desc = f" {DIM}{job.description}{RESET}" if job.description else ""
sched = f" {DIM}[{job.schedule}]{RESET}"
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
# Tools
if not filter_type or filter_type == "tool":
tools = config.tools
if tools:
any_output = True
color = CATEGORY_COLORS["tool"]
print(f"\n{BOLD}{color}Tools{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, comp in tools.items():
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
print(f" {BOLD}{name}{RESET}{desc}")
# Frontends
if not filter_type or filter_type == "frontend":
frontends = config.frontends
if frontends:
any_output = True
color = CATEGORY_COLORS["frontend"]
print(f"\n{BOLD}{color}Frontends{RESET}")
print(f"{color}{'' * 40}{RESET}")
for name, comp in frontends.items():
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
print(f" {BOLD}{name}{RESET}{desc}")
if not any_output:
print("No components found.")
if deployed is None:
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")
print()
return 0
def _list_json(
config: object, deployed: dict | None, filter_type: str | None
) -> int:
"""Output JSON list of all entries."""
output = []
if not filter_type or filter_type == "service":
for name, svc in config.services.items():
entry: dict = {
"name": name,
"category": "service",
"deployed": deployed is not None and name in deployed,
}
if svc.description:
entry["description"] = svc.description
if svc.expose and svc.expose.http:
entry["port"] = svc.expose.http.internal.port
output.append(entry)
if not filter_type or filter_type == "job":
for name, job in config.jobs.items():
entry = {
"name": name,
"category": "job",
"deployed": deployed is not None and name in deployed,
"schedule": job.schedule,
}
if job.description:
entry["description"] = job.description
output.append(entry)
if not filter_type or filter_type == "tool":
for name, comp in config.tools.items():
entry = {"name": name, "category": "tool"}
if comp.description:
entry["description"] = comp.description
output.append(entry)
if not filter_type or filter_type == "frontend":
for name, comp in config.frontends.items():
entry = {"name": name, "category": "frontend"}
if comp.description:
entry["description"] = comp.description
output.append(entry)
print(json.dumps(output, indent=2))
return 0

View File

@@ -10,25 +10,22 @@ from castle_cli.config import load_config
def run_logs(args: argparse.Namespace) -> int:
"""View logs for a component."""
"""View logs for a service or job."""
config = load_config()
name = args.name
if name not in config.components:
print(f"Error: component '{name}' not found in castle.yaml")
return 1
manifest = config.components[name]
# Container logs
if manifest.run and manifest.run.runner == "container":
# Check services
if name in config.services:
svc = config.services[name]
if svc.run.runner == "container":
return _container_logs(name, args)
# Systemd logs (default for managed services)
if manifest.manage and manifest.manage.systemd:
return _systemd_logs(name, args)
print(f"Error: '{name}' has no log source (not systemd-managed or containerized)")
# Check jobs
if name in config.jobs:
return _systemd_logs(name, args)
print(f"Error: '{name}' not found in services or jobs")
return 1

View File

@@ -9,7 +9,6 @@ from castle_core.generators.systemd import (
SYSTEMD_USER_DIR,
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
@@ -21,6 +20,9 @@ from castle_cli.config import (
load_config,
)
# Re-export for use by other commands
UNIT_PREFIX = "castle-"
def _install_unit(uname: str, content: str) -> None:
"""Write a systemd unit file."""
@@ -76,7 +78,6 @@ def run_services(args: argparse.Namespace) -> int:
def _service_enable(config: CastleConfig, name: str) -> int:
"""Enable and start a single service (or timer for scheduled jobs)."""
# Require registry
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
@@ -93,22 +94,29 @@ def _service_enable(config: CastleConfig, name: str) -> int:
ensure_dirs()
# Get systemd spec from manifest for restart policy etc.
# Get systemd spec from config (services or jobs)
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
# Generate and install the service unit from registry
svc_unit = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
_install_unit(svc_unit, svc_content)
# Check for timer (still uses manifest for schedule config)
manifest = config.components.get(name)
timer_content = generate_timer(name, manifest) if manifest else None
if timer_content:
# Check for timer (jobs have schedule)
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
tmr_unit = timer_name(name)
_install_unit(tmr_unit, timer_content)
@@ -156,7 +164,6 @@ def _service_disable(name: str) -> int:
print(f"Disabling {name}...")
# Stop and disable timer if exists
timer_path = SYSTEMD_USER_DIR / tmr_unit
if timer_path.exists():
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
@@ -172,30 +179,11 @@ def _service_disable(name: str) -> int:
def _service_status(config: CastleConfig) -> int:
"""Show status of all managed services."""
"""Show status of all managed services and jobs."""
print("\nCastle Services")
print("=" * 50)
for name, manifest in config.managed.items():
is_scheduled = get_schedule_trigger(manifest) is not None
if is_scheduled:
tmr_unit = timer_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", tmr_unit],
capture_output=True,
text=True,
)
status = result.stdout.strip()
if status in ("active", "waiting"):
color = "\033[92m"
elif status == "inactive":
color = "\033[90m"
else:
color = "\033[91m"
reset = "\033[0m"
print(f" {color}{status:10s}{reset} {name} (timer)")
else:
for name, svc in config.services.items():
svc_unit = unit_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", svc_unit],
@@ -212,36 +200,61 @@ def _service_status(config: CastleConfig) -> int:
reset = "\033[0m"
port_str = ""
if manifest.expose and manifest.expose.http:
port_str = f":{manifest.expose.http.internal.port}"
if svc.expose and svc.expose.http:
port_str = f":{svc.expose.http.internal.port}"
print(f" {color}{status:10s}{reset} {name}{port_str}")
if config.jobs:
print(f"\n{'' * 50}")
print("Jobs")
for name in config.jobs:
tmr_unit = timer_name(name)
result = subprocess.run(
["systemctl", "--user", "is-active", tmr_unit],
capture_output=True,
text=True,
)
status = result.stdout.strip()
if status in ("active", "waiting"):
color = "\033[92m"
elif status == "inactive":
color = "\033[90m"
else:
color = "\033[91m"
reset = "\033[0m"
print(f" {color}{status:10s}{reset} {name} (timer)")
print()
return 0
def _service_dry_run(config: CastleConfig, name: str) -> int:
"""Print the generated systemd unit(s) without installing."""
# Try registry first, fall back to showing what deploy would generate
if REGISTRY_PATH.exists():
registry = load_registry()
if name in registry.deployed:
deployed = registry.deployed[name]
systemd_spec = None
if name in config.components:
manifest = config.components[name]
if manifest.manage and manifest.manage.systemd:
systemd_spec = manifest.manage.systemd
if name in config.services:
svc = config.services[name]
if svc.manage and svc.manage.systemd:
systemd_spec = svc.manage.systemd
elif name in config.jobs:
job = config.jobs[name]
if job.manage and job.manage.systemd:
systemd_spec = job.manage.systemd
svc_unit = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
print(f"# {svc_unit}")
print(svc_content)
manifest = config.components.get(name)
if manifest:
timer_content = generate_timer(name, manifest)
if timer_content:
if deployed.schedule:
timer_content = generate_timer(
name,
schedule=deployed.schedule,
description=deployed.description,
)
print(f"# {timer_name(name)}")
print(timer_content)
return 0
@@ -252,14 +265,12 @@ def _service_dry_run(config: CastleConfig, name: str) -> int:
def _services_start(config: CastleConfig) -> int:
"""Start all managed services and gateway."""
# Require registry
if not REGISTRY_PATH.exists():
print("Error: no registry found. Run 'castle deploy' first.")
return 1
ensure_dirs()
# Generate Caddyfile from registry
from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
@@ -268,7 +279,13 @@ def _services_start(config: CastleConfig) -> int:
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
print(f"Generated {caddyfile_path}")
for name in config.managed:
for name in config.services:
if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue
_service_enable(config, name)
for name in config.jobs:
if name not in registry.deployed:
print(f" {name}: skipped (not in registry, run 'castle deploy')")
continue
@@ -279,14 +296,17 @@ def _services_start(config: CastleConfig) -> int:
def _services_stop(config: CastleConfig) -> int:
"""Stop all managed services and gateway."""
for name, manifest in config.managed.items():
is_scheduled = get_schedule_trigger(manifest) is not None
if is_scheduled:
"""Stop all managed services and jobs."""
for name in config.jobs:
tmr_unit = timer_name(name)
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")
for name in config.services:
svc_unit = unit_name(name)
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
print(f" {name}: stopped")
return 0

View File

@@ -8,26 +8,6 @@ import subprocess
from pathlib import Path
from castle_cli.config import ensure_dirs, load_config
from castle_cli.manifest import ComponentManifest
def _sync_cmd(manifest: ComponentManifest) -> list[str] | None:
"""Derive the sync command from the manifest's runner."""
run = manifest.run
if run is None:
# No runner — check for build commands (frontends)
if manifest.build and manifest.build.commands:
# Frontends declare build commands; infer from source dir at call site
return None
return None
match run.runner:
case "python":
return ["uv", "sync"]
case "node":
return [run.package_manager, "install"]
case _:
return None
def run_sync(args: argparse.Namespace) -> int:
@@ -44,11 +24,12 @@ def run_sync(args: argparse.Namespace) -> int:
if result.returncode != 0:
print("Warning: git submodule update failed (may not be a git repo)")
# Sync dependencies in each project
# Sync dependencies in each component's source directory
all_ok = True
synced_dirs: set[Path] = set()
for name, manifest in config.components.items():
source_dir = manifest.source_dir
for name, comp in config.components.items():
source_dir = comp.source_dir
if not source_dir:
continue
project_dir = config.root / source_dir
@@ -56,13 +37,15 @@ def run_sync(args: argparse.Namespace) -> int:
if project_dir in synced_dirs or not project_dir.is_dir():
continue
cmd = _sync_cmd(manifest)
if cmd is None:
# No runner — check if it's a frontend with a package.json
if manifest.build and (project_dir / "package.json").exists():
# Determine sync command based on project type
cmd = None
if (project_dir / "pyproject.toml").exists():
cmd = ["uv", "sync"]
elif (project_dir / "package.json").exists():
pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm"
cmd = [pm, "install"]
else:
if cmd is None:
continue
label = cmd[0]
@@ -75,28 +58,33 @@ def run_sync(args: argparse.Namespace) -> int:
print(" OK")
synced_dirs.add(project_dir)
# Install components as uv tools or symlinks
# Install components and python-runner services as uv tools
uv_path = shutil.which("uv") or "uv"
installed_dirs: set[Path] = set()
for name, manifest in config.components.items():
# Install if: has install.path, or is a python runner service
if not (
(manifest.install and manifest.install.path)
or (manifest.run and manifest.run.runner == "python")
):
# Install components with install.path
for name, comp in config.components.items():
if not (comp.install and comp.install.path):
continue
source = manifest.source_dir
source = comp.source_dir
if not source:
continue
_try_install(config.root / source, name, comp, uv_path, installed_dirs)
# Install python-runner services
for name, svc in config.services.items():
if svc.run.runner != "python":
continue
# Find source from component reference
source = None
if svc.component and svc.component in config.components:
source = config.components[svc.component].source_dir
if not source:
continue
source_dir = config.root / source
if (source_dir / "pyproject.toml").exists():
# Python package — uv tool install
if source_dir in installed_dirs:
continue
if (source_dir / "pyproject.toml").exists():
print(f"\nInstalling {name}...")
result = subprocess.run(
[uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
@@ -113,21 +101,53 @@ def run_sync(args: argparse.Namespace) -> int:
print(f" {name}: OK")
installed_dirs.add(source_dir)
elif source_dir.is_file():
# Script file — symlink to ~/.local/bin/
alias = name
if manifest.install and manifest.install.path and manifest.install.path.alias:
alias = manifest.install.path.alias
if not shutil.which(alias):
link = Path.home() / ".local" / "bin" / alias
link.parent.mkdir(parents=True, exist_ok=True)
if not link.exists():
link.symlink_to(source_dir)
print(f"\n Linked {alias}{source_dir}")
if all_ok:
print("\nAll projects synced.")
else:
print("\nSync completed with warnings.")
return 0
def _try_install(
source_dir: Path,
name: str,
comp: object,
uv_path: str,
installed_dirs: set[Path],
) -> bool:
"""Try to install a component. Returns True if installed."""
if source_dir in installed_dirs:
return False
if (source_dir / "pyproject.toml").exists():
print(f"\nInstalling {name}...")
result = subprocess.run(
[uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
capture_output=True,
text=True,
)
if result.returncode != 0:
if "already installed" in result.stderr.lower():
print(f" {name}: already installed")
else:
print(f" Warning: {result.stderr.strip()}")
return False
else:
print(f" {name}: OK")
installed_dirs.add(source_dir)
return True
elif source_dir.is_file():
alias = name
if comp.install and comp.install.path and comp.install.path.alias:
alias = comp.install.path.alias
if not shutil.which(alias):
link = Path.home() / ".local" / "bin" / alias
link.parent.mkdir(parents=True, exist_ok=True)
if not link.exists():
link.symlink_to(source_dir)
print(f"\n Linked {alias}{source_dir}")
return True
return False

View File

@@ -67,8 +67,8 @@ def _tool_info(name: str) -> int:
if manifest.description:
print(f" {manifest.description}")
print(f" {BOLD}version{RESET}: {t.version}")
if t.source:
print(f" {BOLD}source{RESET}: {t.source}")
if manifest.source:
print(f" {BOLD}source{RESET}: {manifest.source}")
if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")

View File

@@ -21,9 +21,9 @@ def build_parser() -> argparse.ArgumentParser:
# castle list
list_parser = subparsers.add_parser("list", help="List all components")
list_parser.add_argument(
"--role",
choices=["service", "tool", "worker", "job", "frontend", "remote", "containerized"],
help="Filter by role",
"--type",
choices=["service", "job", "tool", "frontend"],
help="Filter by type",
)
list_parser.add_argument("--json", action="store_true", help="Output as JSON")

View File

@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
BuildSpec,
CaddySpec,
Capability,
ComponentManifest,
ComponentSpec,
DefaultsSpec,
EnvMap,
ExposeSpec,
@@ -13,12 +13,12 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
HttpInternal,
HttpPublic,
InstallSpec,
JobSpec,
ManageSpec,
PathInstallSpec,
ProxySpec,
ReadinessHttpGet,
RestartPolicy,
Role,
RunBase,
RunCommand,
RunContainer,
@@ -26,12 +26,8 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
RunPython,
RunRemote,
RunSpec,
ServiceSpec,
SystemdSpec,
TLSMode,
ToolSpec,
TriggerEvent,
TriggerManual,
TriggerRequest,
TriggerSchedule,
TriggerSpec,
)

View File

@@ -16,9 +16,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
config = {
"gateway": {"port": 18000},
"components": {
"test-tool": {
"description": "Test tool",
"install": {
"path": {"alias": "test-tool"},
},
},
},
"services": {
"test-svc": {
"component": "test-svc-comp",
"description": "Test service",
"source": "test-svc",
"run": {
"runner": "python",
"tool": "test-svc",
@@ -39,11 +47,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"systemd": {},
},
},
"test-tool": {
"description": "Test tool",
"install": {
"path": {"alias": "test-tool"},
},
"jobs": {
"test-job": {
"description": "Test job",
"run": {
"runner": "command",
"argv": ["test-job"],
},
"schedule": "0 2 * * *",
},
},
}

View File

@@ -7,7 +7,6 @@ from pathlib import Path
from unittest.mock import patch
from castle_cli.config import load_config
from castle_cli.manifest import Role
class TestCreateCommand:
@@ -42,11 +41,12 @@ class TestCreateCommand:
assert (project_dir / "tests" / "test_health.py").exists()
assert (project_dir / "CLAUDE.md").exists()
# Verify registered as ComponentManifest
# Verify registered as ComponentSpec + ServiceSpec
assert "my-api" in config.components
manifest = config.components["my-api"]
assert Role.SERVICE in manifest.roles
assert manifest.expose.http.internal.port == 9050
assert "my-api" in config.services
svc = config.services["my-api"]
assert svc.expose.http.internal.port == 9050
assert svc.component == "my-api"
mock_save.assert_called_once()
def test_create_tool(self, castle_root: Path) -> None:
@@ -60,17 +60,18 @@ class TestCreateCommand:
from castle_cli.commands.create import run_create
args = Namespace(name="my-tool", type="tool", description="My tool", port=None)
args = Namespace(name="my-tool2", type="tool", description="My tool", port=None)
result = run_create(args)
assert result == 0
project_dir = castle_root / "components" / "my-tool"
project_dir = castle_root / "components" / "my-tool2"
assert project_dir.exists()
assert (project_dir / "src" / "my_tool" / "main.py").exists()
assert (project_dir / "src" / "my_tool2" / "main.py").exists()
assert (project_dir / "CLAUDE.md").exists()
assert "my-tool" in config.components
manifest = config.components["my-tool"]
assert Role.TOOL in manifest.roles
assert "my-tool2" in config.components
comp = config.components["my-tool2"]
assert comp.tool is not None
assert comp.install is not None
def test_create_library(self, castle_root: Path) -> None:
"""Create a new library project."""
@@ -101,6 +102,7 @@ class TestCreateCommand:
from castle_cli.commands.create import run_create
# test-svc exists in the services section
args = Namespace(
name="test-svc",
type="service",
@@ -130,8 +132,8 @@ class TestCreateCommand:
)
run_create(args)
manifest = config.components["auto-port-svc"]
port = manifest.expose.http.internal.port
svc = config.services["auto-port-svc"]
port = svc.expose.http.internal.port
# Port 18000 is gateway, 19000 is test-svc, so next should be 9001+
assert port is not None
assert port not in (18000, 19000)

View File

@@ -59,8 +59,8 @@ class TestInfoCommand:
output = capsys.readouterr().out
assert "not found" in output
def test_info_json(self, castle_root: Path, capsys) -> None:
"""--json produces valid JSON with manifest fields."""
def test_info_json_service(self, castle_root: Path, capsys) -> None:
"""--json produces valid JSON with service fields."""
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
@@ -73,48 +73,8 @@ class TestInfoCommand:
assert result == 0
output = capsys.readouterr().out
data = json.loads(output)
assert data["id"] == "test-svc"
assert "service" in data["roles"]
assert data["expose"]["http"]["internal"]["port"] == 19000
def test_info_shows_claude_md(self, castle_root: Path, capsys) -> None:
"""Info shows CLAUDE.md content when present."""
project_dir = castle_root / "test-svc"
project_dir.mkdir(exist_ok=True)
(project_dir / "CLAUDE.md").write_text("# Test Service\n\nSome docs here.")
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="test-svc", json=False))
assert result == 0
output = capsys.readouterr().out
assert "Some docs here" in output
def test_info_json_includes_claude_md(self, castle_root: Path, capsys) -> None:
"""--json includes claude_md field when CLAUDE.md exists."""
project_dir = castle_root / "test-svc"
project_dir.mkdir(exist_ok=True)
(project_dir / "CLAUDE.md").write_text("# Docs\n")
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
mock_load.return_value = load_config(castle_root)
from castle_cli.commands.info import run_info
result = run_info(Namespace(project="test-svc", json=True))
assert result == 0
data = json.loads(capsys.readouterr().out)
assert "claude_md" in data
assert "# Docs" in data["claude_md"]
assert data["category"] == "service"
assert data["service"]["expose"]["http"]["internal"]["port"] == 19000
def test_info_shows_env(self, castle_root: Path, capsys) -> None:
"""Info displays environment variables."""
@@ -131,8 +91,8 @@ class TestInfoCommand:
output = capsys.readouterr().out
assert "TEST_SVC_DATA_DIR" in output
def test_info_shows_roles(self, castle_root: Path, capsys) -> None:
"""Info displays derived roles."""
def test_info_shows_category(self, castle_root: Path, capsys) -> None:
"""Info displays category instead of roles."""
from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load:
@@ -144,5 +104,5 @@ class TestInfoCommand:
assert result == 0
output = capsys.readouterr().out
assert "roles" in output
assert "category" in output
assert "service" in output

View File

@@ -19,7 +19,7 @@ class TestListCommand:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(role=None, json=False)
args = Namespace(type=None, json=False)
result = run_list(args)
assert result == 0
@@ -27,27 +27,13 @@ class TestListCommand:
assert "test-svc" in captured.out
assert "test-tool" in captured.out
def test_list_filter_role(self, castle_root: Path, capsys: object) -> None:
"""List filtered by role."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(role="tool", json=False)
result = run_list(args)
assert result == 0
captured = capsys.readouterr() # type: ignore[attr-defined]
assert "test-tool" in captured.out
assert "test-svc" not in captured.out
def test_list_filter_service(self, castle_root: Path, capsys: object) -> None:
"""List filtered to services."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(role="service", json=False)
args = Namespace(type="service", json=False)
result = run_list(args)
assert result == 0
@@ -55,13 +41,41 @@ class TestListCommand:
assert "test-svc" in captured.out
assert "test-tool" not in captured.out
def test_list_filter_tool(self, castle_root: Path, capsys: object) -> None:
"""List filtered to tools."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(type="tool", json=False)
result = run_list(args)
assert result == 0
captured = capsys.readouterr() # type: ignore[attr-defined]
assert "test-tool" in captured.out
assert "test-svc" not in captured.out
def test_list_filter_job(self, castle_root: Path, capsys: object) -> None:
"""List filtered to jobs."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(type="job", json=False)
result = run_list(args)
assert result == 0
captured = capsys.readouterr() # type: ignore[attr-defined]
assert "test-job" in captured.out
assert "test-svc" not in captured.out
def test_list_json(self, castle_root: Path, capsys: object) -> None:
"""List output as JSON."""
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
from castle_cli.config import load_config
mock_load.return_value = load_config(castle_root)
args = Namespace(role=None, json=True)
args = Namespace(type=None, json=True)
result = run_list(args)
assert result == 0
@@ -71,5 +85,4 @@ class TestListCommand:
assert "test-svc" in names
assert "test-tool" in names
svc = next(p for p in data if p["name"] == "test-svc")
assert "roles" in svc
assert "service" in svc["roles"]
assert svc["category"] == "service"

View File

@@ -9,7 +9,7 @@ from pathlib import Path
import yaml
from castle_core.manifest import ComponentManifest, Role
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
def find_castle_root() -> Path:
@@ -47,36 +47,30 @@ class CastleConfig:
root: Path
gateway: GatewayConfig
components: dict[str, ComponentManifest]
components: dict[str, ComponentSpec]
services: dict[str, ServiceSpec]
jobs: dict[str, JobSpec]
@property
def services(self) -> dict[str, ComponentManifest]:
"""Return components with the SERVICE role."""
return {k: v for k, v in self.components.items() if Role.SERVICE in v.roles}
@property
def tools(self) -> dict[str, ComponentManifest]:
"""Return components with the TOOL role."""
return {k: v for k, v in self.components.items() if Role.TOOL in v.roles}
@property
def workers(self) -> dict[str, ComponentManifest]:
"""Return components with the WORKER role."""
return {k: v for k, v in self.components.items() if Role.WORKER in v.roles}
@property
def managed(self) -> dict[str, ComponentManifest]:
"""Return components managed by systemd."""
def tools(self) -> dict[str, ComponentSpec]:
"""Return components that are tools (have install.path or tool spec)."""
return {
k: v
for k, v in self.components.items()
if v.manage and v.manage.systemd and v.manage.systemd.enable
if (v.install and v.install.path) or v.tool
}
@property
def frontends(self) -> dict[str, ComponentSpec]:
"""Return components that are frontends (have build outputs)."""
return {
k: v
for k, v in self.components.items()
if v.build and (v.build.outputs or v.build.commands)
}
def resolve_env_vars(
env: dict[str, str], manifest: ComponentManifest
) -> dict[str, str]:
def resolve_env_vars(env: dict[str, str]) -> dict[str, str]:
"""Resolve ${secret:NAME} references in env values."""
resolved = {}
for key, value in env.items():
@@ -100,11 +94,25 @@ def _read_secret(name: str) -> str:
return f"<MISSING_SECRET:{name}>"
def _parse_component(name: str, data: dict) -> ComponentManifest:
"""Parse a components: entry directly into a ComponentManifest."""
def _parse_component(name: str, data: dict) -> ComponentSpec:
"""Parse a components: entry into a ComponentSpec."""
data_copy = dict(data)
data_copy["id"] = name
return ComponentManifest.model_validate(data_copy)
return ComponentSpec.model_validate(data_copy)
def _parse_service(name: str, data: dict) -> ServiceSpec:
"""Parse a services: entry into a ServiceSpec."""
data_copy = dict(data)
data_copy["id"] = name
return ServiceSpec.model_validate(data_copy)
def _parse_job(name: str, data: dict) -> JobSpec:
"""Parse a jobs: entry into a JobSpec."""
data_copy = dict(data)
data_copy["id"] = name
return JobSpec.model_validate(data_copy)
def load_config(root: Path | None = None) -> CastleConfig:
@@ -122,11 +130,25 @@ def load_config(root: Path | None = None) -> CastleConfig:
gateway_data = data.get("gateway", {})
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
components: dict[str, ComponentManifest] = {}
components: dict[str, ComponentSpec] = {}
for name, comp_data in data.get("components", {}).items():
components[name] = _parse_component(name, comp_data)
return CastleConfig(root=root, gateway=gateway, components=components)
services: dict[str, ServiceSpec] = {}
for name, svc_data in data.get("services", {}).items():
services[name] = _parse_service(name, svc_data)
jobs: dict[str, JobSpec] = {}
for name, job_data in data.get("jobs", {}).items():
jobs[name] = _parse_job(name, job_data)
return CastleConfig(
root=root,
gateway=gateway,
components=components,
services=services,
jobs=jobs,
)
def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object:
@@ -165,11 +187,12 @@ _STRUCTURAL_KEYS = {
}
def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict:
"""Serialize a manifest to a YAML-friendly dict, preserving structural presence."""
full = manifest.model_dump(mode="json", exclude_none=True, exclude={"id", "roles"})
minimal = manifest.model_dump(
mode="json", exclude_none=True, exclude={"id", "roles"}, exclude_defaults=True
def _spec_to_yaml_dict(spec: ComponentSpec | ServiceSpec | JobSpec) -> dict:
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
exclude_fields = {"id"}
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
minimal = spec.model_dump(
mode="json", exclude_none=True, exclude=exclude_fields, exclude_defaults=True
)
def merge(full_val: object, min_val: object | None, key: str = "") -> object:
@@ -203,10 +226,22 @@ def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict:
def save_config(config: CastleConfig) -> None:
"""Save castle configuration to castle.yaml."""
data: dict = {"gateway": {"port": config.gateway.port}, "components": {}}
data: dict = {"gateway": {"port": config.gateway.port}}
for name, manifest in config.components.items():
data["components"][name] = _manifest_to_yaml_dict(manifest)
if config.components:
data["components"] = {}
for name, spec in config.components.items():
data["components"][name] = _spec_to_yaml_dict(spec)
if config.services:
data["services"] = {}
for name, spec in config.services.items():
data["services"][name] = _spec_to_yaml_dict(spec)
if config.jobs:
data["jobs"] = {}
for name, spec in config.jobs.items():
data["jobs"][name] = _spec_to_yaml_dict(spec)
config_path = config.root / "castle.yaml"
with open(config_path, "w") as f:

View File

@@ -8,7 +8,6 @@ from castle_core.generators.systemd import (
cron_to_oncalendar,
generate_timer,
generate_unit_from_deployed,
get_schedule_trigger,
timer_name,
unit_name,
)
@@ -19,7 +18,6 @@ __all__ = [
"generate_caddyfile_from_registry",
"generate_timer",
"generate_unit_from_deployed",
"get_schedule_trigger",
"timer_name",
"unit_name",
]

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import shutil
from pathlib import Path
from castle_core.manifest import ComponentManifest, RestartPolicy, SystemdSpec
from castle_core.manifest import RestartPolicy, SystemdSpec
from castle_core.registry import DeployedComponent
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
@@ -22,14 +22,6 @@ def timer_name(service_name: str) -> str:
return f"{UNIT_PREFIX}{service_name}.timer"
def get_schedule_trigger(manifest: ComponentManifest) -> object | None:
"""Return the schedule trigger if one exists, else None."""
for t in manifest.triggers:
if getattr(t, "type", None) == "schedule":
return t
return None
def cron_to_oncalendar(cron: str) -> str:
"""Best-effort conversion of cron expression to systemd OnCalendar.
@@ -142,17 +134,17 @@ WantedBy={wanted_by}
return unit
def generate_timer(name: str, manifest: ComponentManifest) -> str | None:
"""Generate a systemd timer unit if the component has a schedule trigger."""
trigger = get_schedule_trigger(manifest)
if trigger is None:
return None
description = manifest.description or name
def generate_timer(
name: str,
schedule: str,
description: str | None = None,
) -> str:
"""Generate a systemd timer unit from a cron schedule string."""
description = description or name
# Try to convert cron to OnCalendar, fall back to OnUnitActiveSec
on_calendar = cron_to_oncalendar(trigger.cron)
interval_sec = cron_to_interval_sec(trigger.cron)
on_calendar = cron_to_oncalendar(schedule)
interval_sec = cron_to_interval_sec(schedule)
timer_lines = ""
if on_calendar:

View File

@@ -1,11 +1,11 @@
"""Castle component manifest — Pydantic models for the component registry."""
"""Castle manifest models — component specs, service specs, job specs."""
from __future__ import annotations
from enum import Enum
from typing import Annotated, Literal, Union
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
from pydantic import BaseModel, ConfigDict, Field, model_validator
EnvMap = dict[str, str]
@@ -22,16 +22,6 @@ class TLSMode(str, Enum):
LETSENCRYPT = "letsencrypt"
class Role(str, Enum):
TOOL = "tool"
SERVICE = "service"
WORKER = "worker"
FRONTEND = "frontend"
JOB = "job"
REMOTE = "remote"
CONTAINERIZED = "containerized"
# ---------------------
# Run specs (discriminated union)
# ---------------------
@@ -82,36 +72,6 @@ RunSpec = Annotated[
]
# ---------------------
# Triggers
# ---------------------
class TriggerManual(BaseModel):
type: Literal["manual"] = "manual"
class TriggerSchedule(BaseModel):
type: Literal["schedule"] = "schedule"
cron: str
timezone: str = "America/Los_Angeles"
class TriggerEvent(BaseModel):
type: Literal["event"] = "event"
source: str
topic: str | None = None
queue: str | None = None
class TriggerRequest(BaseModel):
type: Literal["request"] = "request"
protocol: Literal["http", "https", "grpc"] = "http"
TriggerSpec = Union[TriggerManual, TriggerSchedule, TriggerEvent, TriggerRequest]
# ---------------------
# Systemd management
# ---------------------
@@ -166,7 +126,6 @@ class ToolSpec(BaseModel):
model_config = ConfigDict(extra="ignore")
version: str = "1.0.0"
source: str | None = None
system_dependencies: list[str] = Field(default_factory=list)
@@ -238,90 +197,77 @@ class DefaultsSpec(BaseModel):
# ---------------------
# Component manifest
# Component spec — software identity
# ---------------------
class ComponentManifest(BaseModel):
class ComponentSpec(BaseModel):
"""Software catalog entry — what exists."""
id: str = ""
name: str | None = None
description: str | None = None
source: str | None = None
run: RunSpec | None = None
triggers: list[TriggerSpec] = Field(default_factory=list)
manage: ManageSpec | None = None
install: InstallSpec | None = None
tool: ToolSpec | None = None
expose: ExposeSpec | None = None
proxy: ProxySpec | None = None
build: BuildSpec | None = None
defaults: DefaultsSpec | None = None
provides: list[Capability] = Field(default_factory=list)
consumes: list[Capability] = Field(default_factory=list)
tags: list[str] = Field(default_factory=list)
@computed_field # type: ignore[prop-decorator]
@property
def roles(self) -> list[Role]:
roles: set[Role] = set()
if self.run:
if self.run.runner == "remote":
roles.add(Role.REMOTE)
if self.run.runner == "container":
roles.add(Role.CONTAINERIZED)
if self.install and self.install.path and self.install.path.enable:
roles.add(Role.TOOL)
if self.tool:
roles.add(Role.TOOL)
if self.expose and self.expose.http:
roles.add(Role.SERVICE)
if (
self.manage
and self.manage.systemd
and self.manage.systemd.enable
and not (self.expose and self.expose.http)
):
roles.add(Role.WORKER)
if self.build and (self.build.outputs or self.build.commands):
roles.add(Role.FRONTEND)
if any(getattr(t, "type", None) == "schedule" for t in self.triggers):
roles.add(Role.JOB)
if not roles:
roles.add(Role.TOOL)
return sorted(roles, key=lambda r: r.value)
@property
def source_dir(self) -> str | None:
"""Best-effort relative directory for this component's source.
Resolution order: source → tool.source (strip trailing /).
Returns None if no directory can be determined.
"""
"""Relative directory for this component's source, or None."""
if self.source:
return self.source.rstrip("/")
if self.tool and self.tool.source:
return self.tool.source.rstrip("/")
return None
# ---------------------
# Service spec — long-running daemon
# ---------------------
class ServiceSpec(BaseModel):
"""Long-running daemon deployment config."""
id: str = ""
component: str | None = None
description: str | None = None
run: RunSpec
expose: ExposeSpec | None = None
proxy: ProxySpec | None = None
manage: ManageSpec | None = None
defaults: DefaultsSpec | None = None
@model_validator(mode="after")
def _basic_consistency(self) -> ComponentManifest:
def _validate_consistency(self) -> ServiceSpec:
if self.manage and self.manage.systemd and self.manage.systemd.enable:
if self.run and self.run.runner == "remote":
if self.run.runner == "remote":
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
return self
# ---------------------
# Job spec — scheduled task
# ---------------------
class JobSpec(BaseModel):
"""Scheduled task that runs periodically and exits."""
id: str = ""
component: str | None = None
description: str | None = None
run: RunSpec
schedule: str
timezone: str = "America/Los_Angeles"
manage: ManageSpec | None = None
defaults: DefaultsSpec | None = None

View File

@@ -35,7 +35,7 @@ class DeployedComponent:
run_cmd: list[str]
env: dict[str, str] = field(default_factory=dict)
description: str | None = None
roles: list[str] = field(default_factory=list)
category: str = "service"
port: int | None = None
health_path: str | None = None
proxy_path: str | None = None
@@ -82,7 +82,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}),
description=comp_data.get("description"),
roles=comp_data.get("roles", []),
category=comp_data.get("category", "service"),
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_path"),
@@ -120,8 +120,7 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
entry["env"] = comp.env
if comp.description:
entry["description"] = comp.description
if comp.roles:
entry["roles"] = comp.roles
entry["category"] = comp.category
if comp.port is not None:
entry["port"] = comp.port
if comp.health_path:

View File

@@ -16,9 +16,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
config = {
"gateway": {"port": 18000},
"components": {
"test-tool": {
"description": "Test tool",
"install": {
"path": {"alias": "test-tool"},
},
},
},
"services": {
"test-svc": {
"component": "test-svc-comp",
"description": "Test service",
"source": "test-svc",
"run": {
"runner": "python",
"tool": "test-svc",
@@ -39,11 +47,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"systemd": {},
},
},
"test-tool": {
"description": "Test tool",
"install": {
"path": {"alias": "test-tool"},
},
"jobs": {
"test-job": {
"description": "Test job",
"run": {
"runner": "command",
"argv": ["test-job"],
},
"schedule": "0 2 * * *",
},
},
}

View File

@@ -11,82 +11,64 @@ from castle_core.config import (
resolve_env_vars,
save_config,
)
from castle_core.manifest import ComponentManifest, Role
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
class TestLoadConfig:
"""Tests for loading castle.yaml."""
def test_load_basic(self, castle_root: Path) -> None:
"""Load a castle.yaml."""
"""Load a castle.yaml with three sections."""
config = load_config(castle_root)
assert isinstance(config, CastleConfig)
assert config.gateway.port == 18000
assert "test-svc" in config.components
assert "test-tool" in config.components
assert "test-svc" in config.services
assert "test-job" in config.jobs
def test_load_produces_manifests(self, castle_root: Path) -> None:
"""Components are ComponentManifest objects."""
def test_load_produces_typed_specs(self, castle_root: Path) -> None:
"""Each section produces the correct spec type."""
config = load_config(castle_root)
assert isinstance(config.components["test-svc"], ComponentManifest)
assert isinstance(config.components["test-tool"], ComponentManifest)
def test_service_roles(self, castle_root: Path) -> None:
"""Service with expose.http gets SERVICE role."""
config = load_config(castle_root)
svc = config.components["test-svc"]
assert Role.SERVICE in svc.roles
def test_tool_roles(self, castle_root: Path) -> None:
"""Tool with install.path gets TOOL role."""
config = load_config(castle_root)
tool = config.components["test-tool"]
assert Role.TOOL in tool.roles
assert isinstance(config.components["test-tool"], ComponentSpec)
assert isinstance(config.services["test-svc"], ServiceSpec)
assert isinstance(config.jobs["test-job"], JobSpec)
def test_service_expose(self, castle_root: Path) -> None:
"""Service has correct expose spec."""
config = load_config(castle_root)
svc = config.components["test-svc"]
svc = config.services["test-svc"]
assert svc.expose.http.internal.port == 19000
assert svc.expose.http.health_path == "/health"
def test_service_proxy(self, castle_root: Path) -> None:
"""Service has correct proxy spec."""
config = load_config(castle_root)
svc = config.components["test-svc"]
svc = config.services["test-svc"]
assert svc.proxy.caddy.path_prefix == "/test-svc"
def test_service_run_spec(self, castle_root: Path) -> None:
"""Service has correct RunSpec."""
config = load_config(castle_root)
svc = config.components["test-svc"]
svc = config.services["test-svc"]
assert svc.run.runner == "python"
assert svc.run.tool == "test-svc"
assert svc.source == "test-svc"
def test_tool_no_run(self, castle_root: Path) -> None:
"""Tool without run block has no run spec."""
def test_service_component_ref(self, castle_root: Path) -> None:
"""Service references a component."""
config = load_config(castle_root)
tool = config.components["test-tool"]
assert tool.run is None
svc = config.services["test-svc"]
assert svc.component == "test-svc-comp"
def test_services_property(self, castle_root: Path) -> None:
"""Services property filters to SERVICE role."""
def test_job_schedule(self, castle_root: Path) -> None:
"""Job has correct schedule."""
config = load_config(castle_root)
assert "test-svc" in config.services
assert "test-tool" not in config.services
job = config.jobs["test-job"]
assert job.schedule == "0 2 * * *"
def test_tools_property(self, castle_root: Path) -> None:
"""Tools property filters to TOOL role."""
"""Tools property filters to components with install.path or tool."""
config = load_config(castle_root)
assert "test-tool" in config.tools
assert "test-svc" not in config.tools
def test_managed_property(self, castle_root: Path) -> None:
"""Managed property returns systemd-managed components."""
config = load_config(castle_root)
assert "test-svc" in config.managed
assert "test-tool" not in config.managed
def test_missing_config_raises(self, tmp_path: Path) -> None:
"""Missing castle.yaml raises FileNotFoundError."""
@@ -105,11 +87,13 @@ class TestSaveConfig:
assert config2.gateway.port == config.gateway.port
assert set(config2.components.keys()) == set(config.components.keys())
assert set(config2.services.keys()) == set(config.services.keys())
assert set(config2.jobs.keys()) == set(config.jobs.keys())
def test_save_adds_component(self, castle_root: Path) -> None:
"""Adding a component and saving persists it."""
config = load_config(castle_root)
config.components["new-lib"] = ComponentManifest(
config.components["new-lib"] = ComponentSpec(
id="new-lib", description="A new library"
)
save_config(config)
@@ -123,7 +107,9 @@ class TestSaveConfig:
config = load_config(castle_root)
save_config(config)
config2 = load_config(castle_root)
assert "test-svc" in config2.managed
svc = config2.services["test-svc"]
assert svc.manage is not None
assert svc.manage.systemd is not None
class TestResolveEnvVars:
@@ -131,16 +117,14 @@ class TestResolveEnvVars:
def test_no_vars(self) -> None:
"""Plain values pass through unchanged."""
manifest = ComponentManifest(id="test")
env = {"MY_VAR": "plain_value"}
resolved = resolve_env_vars(env, manifest)
resolved = resolve_env_vars(env)
assert resolved["MY_VAR"] == "plain_value"
def test_unrecognized_vars_preserved(self) -> None:
"""Non-secret ${} references pass through unchanged."""
manifest = ComponentManifest(id="test")
env = {"MY_VAR": "${unknown_var}"}
resolved = resolve_env_vars(env, manifest)
resolved = resolve_env_vars(env)
assert resolved["MY_VAR"] == "${unknown_var}"
def test_resolve_secret(
@@ -152,9 +136,8 @@ class TestResolveEnvVars:
(secrets_dir / "API_KEY").write_text("my-secret-key\n")
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
manifest = ComponentManifest(id="test")
env = {"API_KEY": "${secret:API_KEY}"}
resolved = resolve_env_vars(env, manifest)
resolved = resolve_env_vars(env)
assert resolved["API_KEY"] == "my-secret-key"
def test_resolve_missing_secret(
@@ -165,7 +148,6 @@ class TestResolveEnvVars:
secrets_dir.mkdir()
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
manifest = ComponentManifest(id="test")
env = {"API_KEY": "${secret:NONEXISTENT}"}
resolved = resolve_env_vars(env, manifest)
resolved = resolve_env_vars(env)
assert resolved["API_KEY"] == "<MISSING_SECRET:NONEXISTENT>"

View File

@@ -1,4 +1,4 @@
"""Tests for castle manifest — role derivation, validation."""
"""Tests for castle manifest models."""
from __future__ import annotations
@@ -6,171 +6,183 @@ import pytest
from castle_core.manifest import (
BuildSpec,
CaddySpec,
ComponentManifest,
ComponentSpec,
ExposeSpec,
HttpExposeSpec,
HttpInternal,
InstallSpec,
JobSpec,
ManageSpec,
PathInstallSpec,
ProxySpec,
Role,
RunCommand,
RunContainer,
RunPython,
RunRemote,
ServiceSpec,
SystemdSpec,
ToolSpec,
TriggerSchedule,
)
class TestRoleDerivation:
"""Tests for computed role derivation."""
class TestComponentSpec:
"""Tests for component (software catalog) model."""
def test_service_from_expose_http(self) -> None:
"""Component with expose.http gets SERVICE role."""
m = ComponentManifest(
id="svc",
run=RunPython(runner="python", tool="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
def test_minimal(self) -> None:
"""Minimal component just needs an id."""
c = ComponentSpec(id="bare")
assert c.description is None
assert c.source is None
assert c.install is None
assert c.tool is None
assert c.build is None
def test_tool_component(self) -> None:
"""Component with tool and install specs."""
c = ComponentSpec(
id="my-tool",
description="A tool",
source="my-tool/",
tool=ToolSpec(),
install=InstallSpec(path=PathInstallSpec(alias="my-tool")),
)
assert Role.SERVICE in m.roles
assert c.source == "my-tool/"
assert c.install.path.alias == "my-tool"
def test_tool_from_install_path(self) -> None:
"""Component with install.path gets TOOL role."""
m = ComponentManifest(
id="mytool",
install=InstallSpec(path=PathInstallSpec(alias="mytool")),
)
assert Role.TOOL in m.roles
def test_worker_from_systemd_without_http(self) -> None:
"""Component managed by systemd but no HTTP gets WORKER role."""
m = ComponentManifest(
id="worker",
run=RunCommand(runner="command", argv=["worker-bin"]),
manage=ManageSpec(systemd=SystemdSpec()),
)
assert Role.WORKER in m.roles
assert Role.SERVICE not in m.roles
def test_container_role(self) -> None:
"""Container runner gets CONTAINERIZED role."""
m = ComponentManifest(
id="container",
run=RunContainer(runner="container", image="redis:7"),
)
assert Role.CONTAINERIZED in m.roles
def test_remote_role(self) -> None:
"""Remote runner gets REMOTE role."""
m = ComponentManifest(
id="remote",
run=RunRemote(runner="remote", base_url="http://example.com"),
)
assert Role.REMOTE in m.roles
def test_job_from_schedule_trigger(self) -> None:
"""Component with schedule trigger gets JOB role."""
m = ComponentManifest(
id="job",
run=RunCommand(runner="command", argv=["backup"]),
triggers=[TriggerSchedule(cron="0 * * * *")],
)
assert Role.JOB in m.roles
def test_frontend_from_build(self) -> None:
"""Component with build outputs gets FRONTEND role."""
m = ComponentManifest(
id="frontend",
run=RunCommand(runner="command", argv=["serve"]),
def test_frontend_component(self) -> None:
"""Component with build spec."""
c = ComponentSpec(
id="my-app",
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
)
assert Role.FRONTEND in m.roles
assert c.build.outputs == ["dist/"]
def test_tool_from_tool_spec(self) -> None:
"""Component with tool spec gets TOOL role."""
m = ComponentManifest(
id="docx2md",
tool=ToolSpec(source="docx2md/"),
)
assert Role.TOOL in m.roles
def test_source_dir_from_source(self) -> None:
"""source_dir uses source field."""
c = ComponentSpec(id="x", source="components/x/")
assert c.source_dir == "components/x"
def test_tool_spec_without_install(self) -> None:
"""Tool spec alone is enough for TOOL role, no install.path needed."""
m = ComponentManifest(
id="my-tool",
tool=ToolSpec(),
)
assert Role.TOOL in m.roles
def test_source_dir_none(self) -> None:
"""source_dir returns None when no source available."""
c = ComponentSpec(id="x")
assert c.source_dir is None
def test_fallback_to_tool(self) -> None:
"""Component with no indicators defaults to TOOL."""
m = ComponentManifest(id="bare")
assert m.roles == [Role.TOOL]
def test_multiple_roles(self) -> None:
"""Component can have multiple roles."""
m = ComponentManifest(
id="multi",
run=RunPython(runner="python", tool="multi"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
install=InstallSpec(path=PathInstallSpec(alias="multi")),
)
assert Role.SERVICE in m.roles
assert Role.TOOL in m.roles
class TestServiceSpec:
"""Tests for service (long-running daemon) model."""
def test_systemd_with_http_is_service_not_worker(self) -> None:
"""Systemd + HTTP = SERVICE, not WORKER."""
m = ComponentManifest(
def test_basic_service(self) -> None:
"""Service with run and expose."""
s = ServiceSpec(
id="svc",
run=RunPython(runner="python", tool="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
)
assert s.run.runner == "python"
assert s.expose.http.internal.port == 8000
def test_service_with_component_ref(self) -> None:
"""Service can reference a component."""
s = ServiceSpec(
id="svc",
component="my-component",
run=RunPython(runner="python", tool="svc"),
)
assert s.component == "my-component"
def test_service_with_proxy(self) -> None:
"""Service with proxy spec."""
s = ServiceSpec(
id="svc",
run=RunPython(runner="python", tool="svc"),
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
)
assert s.proxy.caddy.path_prefix == "/svc"
def test_service_with_manage(self) -> None:
"""Service with systemd management."""
s = ServiceSpec(
id="svc",
run=RunCommand(runner="command", argv=["bin"]),
manage=ManageSpec(systemd=SystemdSpec()),
)
assert Role.SERVICE in m.roles
assert Role.WORKER not in m.roles
class TestConsistencyValidation:
"""Tests for model validation."""
assert s.manage.systemd.enable is True
def test_remote_with_systemd_raises(self) -> None:
"""Remote runner + systemd management is invalid."""
with pytest.raises(
ValueError, match="manage.systemd cannot be enabled for runner=remote"
):
ComponentManifest(
ServiceSpec(
id="bad",
run=RunRemote(runner="remote", base_url="http://example.com"),
manage=ManageSpec(systemd=SystemdSpec()),
)
def test_no_run_is_valid(self) -> None:
"""Component with no run spec is valid (registration-only)."""
m = ComponentManifest(id="reg-only", description="Just registered")
assert m.run is None
assert m.roles == [Role.TOOL]
def test_no_run_is_invalid(self) -> None:
"""Service requires a run spec."""
with pytest.raises(Exception):
ServiceSpec(id="bad")
class TestJobSpec:
"""Tests for job (scheduled task) model."""
def test_basic_job(self) -> None:
"""Job with run and schedule."""
j = JobSpec(
id="my-job",
run=RunCommand(runner="command", argv=["backup"]),
schedule="0 2 * * *",
)
assert j.schedule == "0 2 * * *"
assert j.timezone == "America/Los_Angeles"
def test_job_with_component_ref(self) -> None:
"""Job can reference a component."""
j = JobSpec(
id="sync",
component="protonmail",
run=RunCommand(runner="command", argv=["protonmail", "sync"]),
schedule="*/5 * * * *",
)
assert j.component == "protonmail"
def test_job_requires_schedule(self) -> None:
"""Job without schedule is invalid."""
with pytest.raises(Exception):
JobSpec(
id="bad",
run=RunCommand(runner="command", argv=["x"]),
)
def test_job_custom_timezone(self) -> None:
"""Job with custom timezone."""
j = JobSpec(
id="job",
run=RunCommand(runner="command", argv=["x"]),
schedule="0 0 * * *",
timezone="UTC",
)
assert j.timezone == "UTC"
class TestModelSerialization:
"""Tests for model_dump behavior."""
def test_dump_excludes_none(self) -> None:
def test_dump_component_excludes_none(self) -> None:
"""model_dump with exclude_none drops None fields."""
m = ComponentManifest(id="test", description="Test")
data = m.model_dump(exclude_none=True, exclude={"id", "roles"})
c = ComponentSpec(id="test", description="Test")
data = c.model_dump(exclude_none=True, exclude={"id"})
assert "description" in data
assert "run" not in data
assert "manage" not in data
assert "install" not in data
assert "tool" not in data
def test_dump_service(self) -> None:
"""Full service manifest serializes correctly."""
m = ComponentManifest(
"""Full service serializes correctly."""
s = ServiceSpec(
id="svc",
description="A service",
run=RunPython(runner="python", tool="svc", cwd="svc"),
run=RunPython(runner="python", tool="svc"),
expose=ExposeSpec(
http=HttpExposeSpec(
internal=HttpInternal(port=9001), health_path="/health"
@@ -179,7 +191,7 @@ class TestModelSerialization:
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
manage=ManageSpec(systemd=SystemdSpec()),
)
data = m.model_dump(exclude_none=True, exclude={"id", "roles"})
data = s.model_dump(exclude_none=True, exclude={"id"})
assert data["run"]["runner"] == "python"
assert data["expose"]["http"]["internal"]["port"] == 9001
assert data["proxy"]["caddy"]["path_prefix"] == "/svc"

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed,
unit_name,
)
@@ -115,3 +116,25 @@ class TestUnitFromDeployed:
)
unit = generate_unit_from_deployed("my-svc", deployed)
assert "/data/repos/" not in unit
class TestGenerateTimer:
"""Tests for timer generation from schedule strings."""
def test_daily_timer(self) -> None:
"""Daily cron produces OnCalendar timer."""
timer = generate_timer("my-job", schedule="0 2 * * *", description="Nightly")
assert "Description=Castle timer: Nightly" in timer
assert "OnCalendar=*-*-* 02:00:00" in timer
assert "WantedBy=timers.target" in timer
def test_interval_timer(self) -> None:
"""*/N minute cron produces OnUnitActiveSec timer."""
timer = generate_timer("sync", schedule="*/5 * * * *")
assert "OnUnitActiveSec=300s" in timer
assert "OnBootSec=60" in timer
def test_fallback_description(self) -> None:
"""Timer uses name when no description given."""
timer = generate_timer("my-job", schedule="0 0 * * *")
assert "Description=Castle timer: my-job" in timer

View File

@@ -1,19 +1,29 @@
# Component Registry
How castle tracks, configures, and manages components. This is the central
reference for `castle.yaml` structure and the manifest architecture.
reference for `castle.yaml` structure and the registry architecture.
## castle.yaml
The single source of truth for all components. Lives at the repo root.
Three top-level sections:
```yaml
gateway:
port: 9000
components:
my-service:
my-tool:
description: Does something useful
source: components/my-tool
install:
path: { alias: my-tool }
tool:
system_dependencies: [pandoc]
services:
my-service:
component: my-service
run:
runner: python
tool: my-service
@@ -25,16 +35,84 @@ components:
caddy: { path_prefix: /my-service }
manage:
systemd: {}
jobs:
my-job:
component: my-tool
run:
runner: command
argv: [my-tool, sync]
schedule: "0 2 * * *"
manage:
systemd: {}
```
## Manifest blocks
### Section semantics
Each component declares **what it does** through these optional blocks:
| Section | Purpose | Category |
|---------|---------|----------|
| `components:` | Software catalog — what exists | tool, frontend, component |
| `services:` | Long-running daemons — how they run | service |
| `jobs:` | Scheduled tasks — when they run | job |
### `run` — How to start it
Services and jobs can reference a component via `component:` for description
fallthrough and source code linking. They can also exist independently
(e.g., `castle-gateway` runs Caddy — not our software).
Discriminated union on `runner`. The runner encodes both the language/toolchain
(used by `castle sync`) and the deployment resolution (used by `castle deploy`):
## Component blocks
Components define **what software exists** — identity, source, tools, builds.
### `source` — Where the source lives
```yaml
source: components/my-tool
```
Relative path from repo root to the project directory.
### `install` — How to install it
```yaml
install:
path:
alias: my-tool # Command name in PATH
```
Creates a shim so the tool is available system-wide after
`uv tool install --editable .`.
### `tool` — Tool metadata
```yaml
tool:
version: "1.0.0"
system_dependencies: [pandoc, poppler-utils]
```
This block provides metadata for `castle tool list` and the dashboard.
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.
### `build` — How to build it
```yaml
build:
commands:
- ["pnpm", "build"]
outputs:
- dist/
```
Components with build outputs are categorized as **frontends** in the UI.
## Service blocks
Services define **how long-running daemons are deployed**.
### `run` — How to start it (required)
Discriminated union on `runner`:
| Runner | Sync | Deploy | Key fields |
|--------|------|--------|------------|
@@ -44,38 +122,22 @@ Discriminated union on `runner`. The runner encodes both the language/toolchain
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
**Services** use `python`:
```yaml
run:
runner: python
tool: my-service # name in [project.scripts]
```
**Tools invoked by castle** (jobs, scheduled tasks) use `command`:
```yaml
run:
runner: command
argv: ["protonmail", "sync"]
cwd: protonmail
env:
PROTONMAIL_USERNAME: user@example.com
```
**Standalone tools** that users invoke directly often have no `run` block at
all — castle just installs them to PATH.
### `expose` — What it exposes
```yaml
expose:
http:
internal:
port: 9001 # Required for services
port: 9001 # Required for HTTP services
health_path: /health # Used by health polling
```
Having `expose.http` gives the component the **service** role.
### `proxy` — How to proxy it
```yaml
@@ -95,7 +157,7 @@ manage:
```
Enables `castle service enable/disable` and `castle logs`. An empty `{}`
uses defaults (enable=true, restart=on-failure, restart_sec=5).
uses defaults (enable=true, restart=on-failure, restart_sec=2).
Full options:
```yaml
@@ -107,91 +169,39 @@ manage:
no_new_privileges: true
after: [network.target, castle-other.service]
wanted_by: [default.target]
exec_reload: "caddy reload ..."
```
### `install` — How to install it
### `defaults` — Default environment
```yaml
install:
path:
alias: my-tool # Command name in PATH
```
Creates a shim so the tool is available system-wide after
`uv tool install --editable .`.
### `tool` — Tool metadata
```yaml
tool:
source: components/my-tool/ # Source directory
version: "1.0.0"
system_dependencies: [pandoc, poppler-utils]
```
This block provides metadata for `castle tool list` and the dashboard.
It's separate from `install` (which handles PATH registration) and `run`
(which handles execution).
The install method (uv tool install vs symlink) is inferred from the source
directory: if `pyproject.toml` exists, it's a Python package; if the source
is a file, it's symlinked.
### `build` — How to build it
```yaml
build:
commands:
- ["pnpm", "build"]
outputs:
- dist/
```
Having build outputs gives the component the **frontend** role.
### `triggers` — What triggers it
```yaml
triggers:
- type: schedule
cron: "*/5 * * * *"
timezone: America/Los_Angeles # default
```
Having a schedule trigger gives the component the **job** role.
Castle generates a systemd .timer file alongside the .service unit.
Other trigger types: `manual`, `event` (source + topic), `request` (protocol).
### `env` with secrets
Environment variables can reference secrets stored in `~/.castle/secrets/`:
```yaml
run:
defaults:
env:
CENTRAL_CONTEXT_URL: http://localhost:9001
API_KEY: ${secret:MY_API_KEY}
```
Castle resolves `${secret:NAME}` by reading `~/.castle/secrets/NAME`.
Never store secrets in castle.yaml or project directories.
## Role derivation
## Job blocks
Roles are **computed** from manifest declarations, never set manually:
Jobs define **how scheduled tasks run**. Same blocks as services plus
`schedule` and `timezone`.
| Role | Derived when |
|------|-------------|
| **service** | Has `expose.http` |
| **tool** | Has `install.path` or has `tool` spec (fallback) |
| **worker** | Has `manage.systemd` but no `expose.http` |
| **job** | Has trigger with `type: schedule` |
| **frontend** | Has `build` with outputs or commands |
| **containerized** | Runner is `container` |
| **remote** | Runner is `remote` |
### `schedule` — Cron expression (required)
A component can have multiple roles. For example, `protonmail` is both a
**tool** (installed to PATH) and a **job** (runs on a cron schedule).
```yaml
schedule: "*/5 * * * *"
timezone: America/Los_Angeles # default
```
Castle generates a systemd `.timer` file alongside the `.service` unit.
### Other blocks
Jobs also support `run` (required), `manage`, and `defaults` — same
semantics as services.
## Registering a new component
@@ -207,17 +217,38 @@ castle create my-tool --type tool --description "Does something"
### Manually
Add an entry to the `components:` section of `castle.yaml`:
Add entries to the appropriate sections of `castle.yaml`:
```yaml
# Tool — only needs a component entry
components:
my-tool:
description: Does something useful
tool:
source: components/my-tool/
source: components/my-tool
install:
path:
alias: my-tool
# Service — needs both component and service entries
components:
my-service:
description: Does something useful
source: components/my-service
services:
my-service:
component: my-service
run:
runner: python
tool: my-service
expose:
http:
internal: { port: 9001 }
health_path: /health
proxy:
caddy: { path_prefix: /my-service }
manage:
systemd: {}
```
## Lifecycle
@@ -254,19 +285,16 @@ uv tool install --editable components/my-tool/ # 4. Install to PATH
### Job lifecycle
Jobs are tools or services with a schedule trigger. They need both `run`
(so castle knows how to execute them) and `manage.systemd` (so systemd
handles the timer):
Jobs are defined in the `jobs:` section with a `run` spec and `schedule`:
```yaml
jobs:
my-job:
description: Runs nightly
run:
runner: command
argv: ["my-job"]
triggers:
- type: schedule
cron: "0 2 * * *"
schedule: "0 2 * * *"
manage:
systemd: {}
```
@@ -289,14 +317,16 @@ and a `.timer` file.
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `ComponentManifest` — top-level model, has `roles` computed property
- `ComponentSpec` — software catalog entry (source, install, tool, build)
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
- `JobSpec` — scheduled task (run, schedule, manage, defaults)
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
- `TriggerSpec` — union (TriggerSchedule, TriggerManual, TriggerEvent, TriggerRequest)
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec`
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
Config loading: `core/src/castle_core/config.py``load_config()` parses
castle.yaml into `CastleConfig` with typed `components` dict.
castle.yaml into `CastleConfig` with typed `components`, `services`, and
`jobs` dicts.
Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer
generation (`systemd.py`) and Caddyfile generation (`caddyfile.py`).

View File

@@ -20,9 +20,9 @@ is self-sufficient. The mesh is optional.
Castle service is just a well-behaved Unix daemon that happens to
be registered in a manifest.
3. **Declare capabilities, derive roles.** Components say what they
do (expose HTTP, run on a schedule, install to PATH). Castle infers
what they are (service, job, tool, frontend). No role labels.
3. **Section is category.** Components, services, and jobs live in
separate sections of `castle.yaml`. The section determines the
category — no role derivation needed.
4. **Language-agnostic above the build line.** Below the build line,
every language is different (uv, pnpm, cargo, go). Above it,
@@ -149,13 +149,17 @@ answers: "is it working?"
These map to two files:
**`castle.yaml`** (in the repo, version-controlled) — Component specs:
**`castle.yaml`** (in the repo, version-controlled) — Three sections:
```yaml
components:
central-context:
description: Content storage API
source: components/central-context
services:
central-context:
component: central-context
run:
runner: python
tool: central-context
@@ -168,12 +172,29 @@ components:
path_prefix: /central-context
manage:
systemd: {}
jobs:
backup-collect:
component: backup-collect
run:
runner: command
argv: [backup-collect]
schedule: "0 2 * * *"
manage:
systemd: {}
```
The spec says what the component *is* and what it *needs* — a port, a
data directory, HTTP exposure. Convention-based env vars (`<PREFIX>_PORT`,
`<PREFIX>_DATA_DIR`) are generated automatically during deploy. Only
non-convention values need `defaults.env`.
Components define *what software exists* (identity, source, install, tools).
Services define *how daemons run* (run config, expose, proxy, systemd).
Jobs define *how scheduled tasks run* (run config, cron schedule, systemd).
Services and jobs can reference a component via `component:` for description
fallthrough and source code linking. They can also exist independently
(e.g., `castle-gateway` runs Caddy — not our software).
Convention-based env vars (`<PREFIX>_PORT`, `<PREFIX>_DATA_DIR`) are
generated automatically during deploy. Only non-convention values need
`defaults.env`.
**`~/.castle/registry.yaml`** (per-node, not in the repo) — Node config:
@@ -189,7 +210,7 @@ deployed:
env:
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
CENTRAL_CONTEXT_PORT: "9001"
roles: [service]
category: service
port: 9001
health_path: /health
proxy_path: /central-context

View File

@@ -317,28 +317,27 @@ uv run ruff format . # Format
components:
my-tool:
description: Does something useful
tool:
source: components/my-tool/
source: components/my-tool
install:
path:
alias: my-tool
```
Tools with system dependencies declare them in the manifest:
Tools with system dependencies declare them in the component:
```yaml
components:
pdf2md:
description: Convert PDF files to Markdown
tool:
source: components/pdf2md/
system_dependencies: [pandoc, poppler-utils]
source: components/pdf2md
install:
path:
alias: pdf2md
tool:
system_dependencies: [pandoc, poppler-utils]
```
Tools with `install.path` get the **tool** role. They don't need `expose`,
`proxy`, or `manage` blocks unless castle also runs them (e.g., scheduled jobs).
Tools live in the `components:` section. If a tool also runs on a schedule,
add a separate entry in the `jobs:` section referencing the component.
See @docs/component-registry.md for the full manifest reference.
See @docs/component-registry.md for the full registry reference.

View File

@@ -101,7 +101,12 @@ Castle passes config via env vars in castle.yaml:
```yaml
components:
my-service:
source: my-service
description: Does something useful
source: components/my-service
services:
my-service:
component: my-service
run:
runner: python
tool: my-service

View File

@@ -107,8 +107,8 @@ The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files.
## Registering as a castle component
A frontend component has a `build` spec (produces static output) and optionally
a `proxy` spec (Caddy serves the built files). No `run` block needed if Caddy
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
handles serving directly from the build output.
```yaml
@@ -116,45 +116,33 @@ handles serving directly from the build output.
components:
my-frontend:
description: Web dashboard
source: my-frontend
build:
commands:
- ["pnpm", "build"]
outputs:
- dist/
proxy:
caddy:
path_prefix: /app
```
For development with Vite's dev server, add a `run` block:
For production, Caddy serves the static files — add a service entry with a
proxy spec:
```yaml
components:
services:
my-frontend:
description: Web dashboard
component: my-frontend
run:
runner: node
script: dev
package_manager: pnpm
cwd: my-frontend
build:
commands:
- ["pnpm", "build"]
outputs:
- dist/
expose:
http:
internal: { port: 5173 }
proxy:
caddy:
path_prefix: /app
caddy: { path_prefix: /app }
```
This gives the component both the `frontend` role (from `build`) and the
`service` role (from `expose.http`) during development.
See @docs/component-registry.md for the full manifest reference and role
derivation rules.
See @docs/component-registry.md for the full registry reference.
## Serving with Caddy