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 ## Overview
Castle is a personal software platform — a monorepo of independent projects Castle is a personal software platform — a monorepo of independent projects
(services, tools, libraries) managed by the `castle` CLI. Components declare (services, tools, libraries) managed by the `castle` CLI. The registry
**what they do** (expose HTTP, manage via systemd, install to PATH) and roles (`castle.yaml`) has three top-level sections:
are **derived**, not labeled.
- **`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 **Key principle:** Regular projects must never depend on castle. They accept standard
configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway, configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway)
event bus) know about castle internals. know about castle internals.
## Creating Components ## Creating Components
When creating a new service, tool, or frontend, follow the detailed guides: 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/web-apis.md — FastAPI service patterns (config, routes, models, testing)
- @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing) - @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
- @docs/web-frontends.md — React/Vite/TypeScript frontend patterns - @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/`, 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 ## Castle CLI
@@ -47,8 +53,8 @@ The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
```bash ```bash
castle list # List all components castle list # List all components
castle list --role service # Filter by derived role castle list --type service # Filter by category
castle info <component> # Show manifest details (--json for machine-readable) castle info <component> # Show details (--json for machine-readable)
castle create <name> --type service # Scaffold new project castle create <name> --type service # Scaffold new project
castle test [project] # Run tests (one or all) castle test [project] # Run tests (one or all)
castle lint [project] # Run linter (one or all) castle lint [project] # Run linter (one or all)
@@ -93,8 +99,8 @@ Services also support: `uv run <service-name>` to start.
## Key Files ## Key Files
- `castle.yaml` — Component registry (single source of truth) - `castle.yaml` — Component registry (three sections: components, services, jobs)
- `core/src/castle_core/manifest.py` — Pydantic models (ComponentManifest, RunSpec, etc.) - `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/config.py` — Config loader (castle.yaml → CastleConfig)
- `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation - `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation
- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates - `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates

View File

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

View File

@@ -38,9 +38,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
</div> </div>
<div className="flex gap-1 mb-2"> <div className="flex gap-1 mb-2">
{component.roles.map((role) => ( <RoleBadge role={component.category} />
<RoleBadge key={role} role={role} />
))}
</div> </div>
{component.description && ( {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> <span className="text-sm text-[var(--muted)]">{component.description}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{component.roles.map((r) => ( <RoleBadge role={component.category} />
<RoleBadge key={r} role={r} />
))}
</div> </div>
</button> </button>

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ interface ComponentTableProps {
statuses: HealthStatus[] statuses: HealthStatus[]
} }
type SortKey = "id" | "role" | "runner" | "schedule" | "port" | "status" type SortKey = "id" | "category" | "runner" | "schedule" | "port" | "status"
type SortDir = "asc" | "desc" type SortDir = "asc" | "desc"
function statusRank(s: HealthStatus | undefined, installed: boolean | null): number { 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) { export function ComponentTable({ components, statuses }: ComponentTableProps) {
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses]) const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
const allRoles = useMemo(() => { const allCategories = useMemo(() => {
const set = new Set<string>() 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() return Array.from(set).sort()
}, [components]) }, [components])
const [roleFilter, setRoleFilter] = useState<string | null>(null) const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [sortKey, setSortKey] = useState<SortKey>("id") const [sortKey, setSortKey] = useState<SortKey>("id")
const [sortDir, setSortDir] = useState<SortDir>("asc") const [sortDir, setSortDir] = useState<SortDir>("asc")
@@ -52,8 +52,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
const filtered = useMemo(() => { const filtered = useMemo(() => {
let list = components let list = components
if (roleFilter) { if (categoryFilter) {
list = list.filter((c) => c.roles.includes(roleFilter)) list = list.filter((c) => c.category === categoryFilter)
} }
if (search) { if (search) {
const q = search.toLowerCase() const q = search.toLowerCase()
@@ -64,7 +64,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
) )
} }
return list return list
}, [components, roleFilter, search]) }, [components, categoryFilter, search])
const sorted = useMemo(() => { const sorted = useMemo(() => {
const dir = sortDir === "asc" ? 1 : -1 const dir = sortDir === "asc" ? 1 : -1
@@ -72,8 +72,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
switch (sortKey) { switch (sortKey) {
case "id": case "id":
return dir * a.id.localeCompare(b.id) return dir * a.id.localeCompare(b.id)
case "role": case "category":
return dir * (a.roles[0] ?? "").localeCompare(b.roles[0] ?? "") return dir * a.category.localeCompare(b.category)
case "runner": case "runner":
return dir * (a.runner ?? "").localeCompare(b.runner ?? "") return dir * (a.runner ?? "").localeCompare(b.runner ?? "")
case "schedule": case "schedule":
@@ -100,26 +100,26 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
/> />
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <button
onClick={() => setRoleFilter(null)} onClick={() => setCategoryFilter(null)}
className={`text-xs px-2 py-1 rounded transition-colors ${ className={`text-xs px-2 py-1 rounded transition-colors ${
roleFilter === null categoryFilter === null
? "bg-[var(--primary)] text-white" ? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]" : "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`} }`}
> >
All All
</button> </button>
{allRoles.map((role) => ( {allCategories.map((cat) => (
<button <button
key={role} key={cat}
onClick={() => setRoleFilter(roleFilter === role ? null : role)} onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
className={`text-xs px-2 py-1 rounded transition-colors ${ className={`text-xs px-2 py-1 rounded transition-colors ${
roleFilter === role categoryFilter === cat
? "bg-[var(--primary)] text-white" ? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]" : "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`} }`}
> >
{role} {cat}
</button> </button>
))} ))}
</div> </div>
@@ -131,7 +131,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
<thead> <thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left"> <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="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="Runner" sortKey="runner" current={sortKey} dir={sortDir} onSort={toggleSort} />
<SortHeader label="Schedule" sortKey="schedule" 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} /> <SortHeader label="Port" sortKey="port" current={sortKey} dir={sortDir} onSort={toggleSort} />
@@ -233,11 +233,7 @@ function ComponentRow({
)} )}
</td> </td>
<td className="px-3 py-2.5"> <td className="px-3 py-2.5">
<div className="flex gap-1 flex-wrap"> <RoleBadge role={component.category} />
{component.roles.map((role) => (
<RoleBadge key={role} role={role} />
))}
</div>
</td> </td>
<td className="px-3 py-2.5 text-[var(--muted)]"> <td className="px-3 py-2.5 text-[var(--muted)]">
{component.runner ? runnerLabel(component.runner) : "—"} {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 { 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", service: "bg-green-700 text-white",
tool: "bg-blue-700 text-white",
worker: "bg-blue-500 text-white",
job: "bg-purple-700 text-white", job: "bg-purple-700 text-white",
tool: "bg-blue-700 text-white",
frontend: "bg-yellow-600 text-black", frontend: "bg-yellow-600 text-black",
remote: "bg-gray-600 text-gray-200", component: "bg-gray-600 text-gray-200",
containerized: "bg-orange-600 text-black",
} }
export function RoleBadge({ role }: { role: string }) { export function RoleBadge({ role }: { role: string }) {
@@ -16,9 +14,9 @@ export function RoleBadge({ role }: { role: string }) {
<span <span
className={cn( className={cn(
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded", "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} {role}
</span> </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", remote: "Remote",
} }
export const ROLE_DESCRIPTIONS: Record<string, string> = { export const CATEGORY_LABELS: Record<string, string> = {
service: "Exposes HTTP endpoints", 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", tool: "CLI utility installed to PATH",
worker: "Background process (no HTTP)",
job: "Runs on a schedule",
frontend: "Built static assets", frontend: "Built static assets",
remote: "Hosted externally", component: "Software component",
containerized: "Runs in a container", }
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 { export function runnerLabel(runner: string): string {

View File

@@ -20,7 +20,7 @@ export function ComponentDetailPage() {
const { mutate, isPending } = useServiceAction() const { mutate, isPending } = useServiceAction()
const health = statusResp?.statuses.find((s) => s.id === name) const health = statusResp?.statuses.find((s) => s.id === name)
const isDown = health?.status === "down" const isDown = health?.status === "down"
const isTool = component?.roles.includes("tool") ?? false const isTool = component?.category === "tool"
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "") const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
const isGateway = name === "castle-gateway" const isGateway = name === "castle-gateway"
const { data: caddyfile } = useCaddyfile(isGateway) const { data: caddyfile } = useCaddyfile(isGateway)
@@ -28,10 +28,14 @@ export function ComponentDetailPage() {
const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd) const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd)
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null) 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>) => { const handleSave = async (compName: string, config: Record<string, unknown>) => {
setMessage(null) setMessage(null)
try { try {
await apiClient.put(`/config/components/${compName}`, { config }) await apiClient.put(`/config/${configSection}/${compName}`, { config })
setMessage({ type: "ok", text: "Saved to castle.yaml" }) setMessage({ type: "ok", text: "Saved to castle.yaml" })
refetch() refetch()
qc.invalidateQueries({ queryKey: ["components"] }) qc.invalidateQueries({ queryKey: ["components"] })
@@ -43,7 +47,7 @@ export function ComponentDetailPage() {
const handleDelete = async (compName: string) => { const handleDelete = async (compName: string) => {
try { try {
await apiClient.delete(`/config/components/${compName}`) await apiClient.delete(`/config/${configSection}/${compName}`)
qc.invalidateQueries({ queryKey: ["components"] }) qc.invalidateQueries({ queryKey: ["components"] })
navigate("/") navigate("/")
} catch (e: unknown) { } catch (e: unknown) {
@@ -116,10 +120,11 @@ export function ComponentDetailPage() {
</div> </div>
</div> </div>
<div className="flex gap-1.5 mb-6"> <div className="flex items-center gap-3 mb-6">
{component.roles.map((role) => ( <RoleBadge role={component.category} />
<RoleBadge key={role} role={role} /> {component.source && (
))} <span className="text-sm text-[var(--muted)] font-mono">{component.source}</span>
)}
</div> </div>
{message && ( {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 { 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() { export function Dashboard() {
useEventStream() useEventStream()
@@ -7,6 +12,20 @@ export function Dashboard() {
const { data: statusResp } = useStatus() const { data: statusResp } = useStatus()
const { data: gateway } = useGateway() 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 ( return (
<div className="max-w-6xl mx-auto px-6 py-8"> <div className="max-w-6xl mx-auto px-6 py-8">
<div className="mb-8"> <div className="mb-8">
@@ -15,21 +34,79 @@ export function Dashboard() {
Personal software platform Personal software platform
{gateway && ( {gateway && (
<span className="ml-2 text-sm"> <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> </span>
)} )}
</p> </p>
</div> </div>
{isLoading ? ( {isLoading ? (
<p className="text-[var(--muted)]">Loading components...</p> <p className="text-[var(--muted)]">Loading...</p>
) : components ? (
<ComponentTable
components={components}
statuses={statusResp?.statuses ?? []}
/>
) : ( ) : (
<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> </div>
) )

View File

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

View File

@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel from pydantic import BaseModel
from castle_core.config import save_config from castle_core.config import save_config
from castle_core.manifest import ComponentManifest from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_config, get_registry from castle_api.config import get_castle_root, get_config, get_registry
from castle_api.stream import broadcast from castle_api.stream import broadcast
@@ -29,6 +29,8 @@ class ConfigSaveRequest(BaseModel):
class ConfigSaveResponse(BaseModel): class ConfigSaveResponse(BaseModel):
ok: bool ok: bool
component_count: int component_count: int
service_count: int
job_count: int
errors: list[str] errors: list[str]
@@ -42,6 +44,14 @@ class ComponentConfigRequest(BaseModel):
config: dict config: dict
class ServiceConfigRequest(BaseModel):
config: dict
class JobConfigRequest(BaseModel):
config: dict
def _require_repo() -> None: def _require_repo() -> None:
"""Raise 503 if repo is not available.""" """Raise 503 if repo is not available."""
if get_castle_root() is None: if get_castle_root() is None:
@@ -76,22 +86,44 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
detail=f"Invalid YAML: {e}", detail=f"Invalid YAML: {e}",
) )
if not isinstance(data, dict) or "components" not in data: if not isinstance(data, dict):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="YAML must have a 'components' key", detail="YAML must be a mapping",
) )
# Validate each component # Validate components
count = 0 comp_count = 0
for name, comp_data in data.get("components", {}).items(): for name, comp_data in data.get("components", {}).items():
try: try:
comp_data_copy = dict(comp_data) if comp_data else {} comp_data_copy = dict(comp_data) if comp_data else {}
comp_data_copy["id"] = name comp_data_copy["id"] = name
ComponentManifest.model_validate(comp_data_copy) ComponentSpec.model_validate(comp_data_copy)
count += 1 comp_count += 1
except Exception as e: 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: if errors:
raise HTTPException( raise HTTPException(
@@ -105,7 +137,10 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
shutil.copy2(config_path, backup_path) shutil.copy2(config_path, backup_path)
config_path.write_text(request.yaml_content) 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}") @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.""" """Update a single component's config in castle.yaml."""
_require_repo() _require_repo()
# Validate
try: try:
comp_data = dict(request.config) comp_data = dict(request.config)
comp_data["id"] = name comp_data["id"] = name
ComponentManifest.model_validate(comp_data) ComponentSpec.model_validate(comp_data)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -125,7 +159,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
) )
config = get_config() config = get_config()
config.components[name] = ComponentManifest.model_validate( config.components[name] = ComponentSpec.model_validate(
{**request.config, "id": name} {**request.config, "id": name}
) )
save_config(config) save_config(config)
@@ -146,6 +180,80 @@ def delete_component(name: str) -> dict:
return {"ok": True, "component": name, "action": "deleted"} 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) @router.post("/apply", response_model=ApplyResponse)
async def apply_config() -> ApplyResponse: async def apply_config() -> ApplyResponse:
"""Apply config: restart managed services + regenerate and reload gateway.""" """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 fastapi import APIRouter, HTTPException, Query, status
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from castle_core.config import load_config from castle_api.config import get_castle_root
from castle_api.config import settings
router = APIRouter(prefix="/logs", tags=["logs"]) 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"), follow: bool = Query(default=False, description="Stream new lines via SSE"),
) -> StreamingResponse | dict: ) -> StreamingResponse | dict:
"""Get logs for a systemd-managed service.""" """Get logs for a systemd-managed service."""
config = load_config(settings.castle_root) root = get_castle_root()
if name not in config.managed: 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( raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail=f"'{name}' is not a managed service", 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" unit = f"{UNIT_PREFIX}{name}.service"

View File

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

View File

@@ -8,6 +8,7 @@ from pathlib import Path
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from castle_core.generators.caddyfile import generate_caddyfile_from_registry from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_registry from castle_api.config import get_castle_root, get_registry
from castle_api.health import check_all_health 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 # Check if tool is installed on PATH
installed: bool | None = None installed: bool | None = None
if "tool" in deployed.roles: if deployed.category == "tool":
installed = shutil.which(name) is not None installed = shutil.which(name) is not None
return ComponentSummary( return ComponentSummary(
id=name, id=name,
description=deployed.description, description=deployed.description,
roles=deployed.roles, category=deployed.category,
runner=deployed.runner, runner=deployed.runner,
port=deployed.port, port=deployed.port,
health_path=deployed.health_path, 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: def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> ComponentSummary:
"""Build a ComponentSummary from a manifest (for non-deployed components).""" """Build a ComponentSummary from a ServiceSpec (non-deployed)."""
port = None port = None
health_path = None health_path = None
proxy_path = None proxy_path = None
if manifest.expose and manifest.expose.http: if svc.expose and svc.expose.http:
port = manifest.expose.http.internal.port port = svc.expose.http.internal.port
health_path = manifest.expose.http.health_path health_path = svc.expose.http.health_path
if manifest.proxy and manifest.proxy.caddy: if svc.proxy and svc.proxy.caddy:
proxy_path = manifest.proxy.caddy.path_prefix proxy_path = svc.proxy.caddy.path_prefix
managed = bool( managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
)
systemd_info: SystemdInfo | None = None systemd_info: SystemdInfo | None = None
if managed: if managed:
unit_name = f"castle-{name}.service" unit_name = f"castle-{name}.service"
unit_path = str(Path("~/.config/systemd/user") / unit_name) unit_path = str(Path("~/.config/systemd/user") / unit_name)
has_timer = any( systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=False)
getattr(t, "type", None) == "schedule" for t in manifest.triggers
)
systemd_info = SystemdInfo(
unit_name=unit_name,
unit_path=unit_path,
timer=has_timer,
)
schedule = None description = svc.description
for t in manifest.triggers: source = None
if t.type == "schedule": if svc.component and svc.component in config.components:
schedule = t.cron comp = config.components[svc.component]
break if not description:
description = comp.description
source = comp.source
runner = manifest.run.runner if manifest.run else None runner = svc.run.runner
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
return ComponentSummary( return ComponentSummary(
id=name, id=name,
description=manifest.description, description=description,
roles=[r.value for r in manifest.roles], category="service",
runner=runner, runner=runner,
port=port, port=port,
health_path=health_path, health_path=health_path,
proxy_path=proxy_path, proxy_path=proxy_path,
managed=managed, managed=managed,
systemd=systemd_info, systemd=systemd_info,
version=manifest.tool.version if manifest.tool else None, source=source,
source=manifest.tool.source if manifest.tool else None, )
system_dependencies=manifest.tool.system_dependencies if manifest.tool else [],
schedule=schedule,
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, installed=installed,
) )
@@ -134,16 +184,49 @@ def list_components() -> list[ComponentSummary]:
summaries.append(_summary_from_deployed(name, deployed)) summaries.append(_summary_from_deployed(name, deployed))
seen.add(name) seen.add(name)
# Non-deployed components from castle.yaml (if repo available) # Non-deployed from castle.yaml (if repo available)
root = get_castle_root() root = get_castle_root()
if root: if root:
try: try:
from castle_core.config import load_config from castle_core.config import load_config
config = load_config(root) 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: 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: except FileNotFoundError:
pass pass
@@ -158,6 +241,27 @@ def get_component(name: str) -> ComponentDetail:
if name in registry.deployed: if name in registry.deployed:
deployed = registry.deployed[name] deployed = registry.deployed[name]
summary = _summary_from_deployed(name, deployed) summary = _summary_from_deployed(name, deployed)
# Backfill source from castle.yaml component ref
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 = { raw = {
"runner": deployed.runner, "runner": deployed.runner,
"run_cmd": deployed.run_cmd, "run_cmd": deployed.run_cmd,
@@ -166,7 +270,7 @@ def get_component(name: str) -> ComponentDetail:
"health_path": deployed.health_path, "health_path": deployed.health_path,
"proxy_path": deployed.proxy_path, "proxy_path": deployed.proxy_path,
"managed": deployed.managed, "managed": deployed.managed,
"roles": deployed.roles, "category": deployed.category,
} }
return ComponentDetail(**summary.model_dump(), manifest=raw) return ComponentDetail(**summary.model_dump(), manifest=raw)
@@ -176,10 +280,23 @@ def get_component(name: str) -> ComponentDetail:
from castle_core.config import load_config from castle_core.config import load_config
config = load_config(root) config = load_config(root)
if name in config.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: if name in config.components:
manifest = config.components[name] comp = config.components[name]
summary = _summary_from_manifest(name, manifest, root) summary = _summary_from_component(name, comp, root)
raw = manifest.model_dump(mode="json", exclude_none=True) raw = comp.model_dump(mode="json", exclude_none=True)
return ComponentDetail(**summary.model_dump(), manifest=raw) return ComponentDetail(**summary.model_dump(), manifest=raw)
raise HTTPException( raise HTTPException(

View File

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

View File

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

View File

@@ -24,9 +24,26 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
config = { config = {
"gateway": {"port": 9000}, "gateway": {"port": 9000},
"components": { "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": { "test-svc": {
"component": "test-svc-comp",
"description": "Test service", "description": "Test service",
"source": "test-svc",
"run": { "run": {
"runner": "python", "runner": "python",
"tool": "test-svc", "tool": "test-svc",
@@ -40,20 +57,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"proxy": {"caddy": {"path_prefix": "/test-svc"}}, "proxy": {"caddy": {"path_prefix": "/test-svc"}},
"manage": {"systemd": {}}, "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": { "schedule": "0 2 * * *",
"description": "Another test tool",
"tool": {
"source": "test-tool-2",
"version": "2.0.0",
},
}, },
}, },
} }
@@ -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", "TEST_SVC_DATA_DIR": "/data/castle/test-svc",
}, },
description="Test service", description="Test service",
roles=["service"], category="service",
port=19000, port=19000,
health_path="/health", health_path="/health",
proxy_path="/test-svc", proxy_path="/test-svc",

View File

@@ -34,7 +34,7 @@ class TestComponents:
assert svc["health_path"] == "/health" assert svc["health_path"] == "/health"
assert svc["proxy_path"] == "/test-svc" assert svc["proxy_path"] == "/test-svc"
assert svc["managed"] is True assert svc["managed"] is True
assert "service" in svc["roles"] assert svc["category"] == "service"
def test_tool_has_no_port(self, client: TestClient) -> None: def test_tool_has_no_port(self, client: TestClient) -> None:
"""Tool component has no port.""" """Tool component has no port."""
@@ -42,7 +42,15 @@ class TestComponents:
data = response.json() data = response.json()
tool = next(c for c in data if c["id"] == "test-tool") tool = next(c for c in data if c["id"] == "test-tool")
assert tool["port"] is None 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: class TestComponentDetail:

View File

@@ -1,138 +1,31 @@
gateway: gateway:
port: 9000 port: 9000
components: 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: central-context:
description: Content storage API useful to get context into my data dir from anywhere description: Content storage API useful to get context into my data dir from anywhere
on the LAN on the LAN
source: components/central-context 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: notification-bridge:
description: Desktop notification forwarder. This taps into your notifications description: Desktop notification forwarder. This taps into your notifications
system on the desktop and will forward all notifications the the central context system on the desktop and will forward all notifications the the central context
server. server.
source: components/notification-bridge 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: castle-api:
description: Castle API description: Castle API
source: 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: protonmail:
description: ProtonMail email sync via Bridge description: ProtonMail email sync via Bridge
source: components/protonmail 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: install:
path: path:
alias: protonmail alias: protonmail
backup-collect: backup-collect:
description: Collect files from various sources into backup directory description: Collect files from various sources into backup directory
source: components/backup-collect 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: tool:
system_dependencies: system_dependencies:
- rsync - 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: castle-app:
description: Castle web app description: Castle web app
source: app source: app
@@ -249,3 +142,118 @@ components:
install: install:
path: path:
alias: text-extractor 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.config import load_config, save_config
from castle_cli.manifest import ( from castle_cli.manifest import (
CaddySpec, CaddySpec,
ComponentManifest, ComponentSpec,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
@@ -16,6 +16,7 @@ from castle_cli.manifest import (
PathInstallSpec, PathInstallSpec,
ProxySpec, ProxySpec,
RunPython, RunPython,
ServiceSpec,
SystemdSpec, SystemdSpec,
ToolSpec, ToolSpec,
) )
@@ -25,9 +26,9 @@ from castle_cli.templates.scaffold import scaffold_project
def next_available_port(config: object) -> int: def next_available_port(config: object) -> int:
"""Find the next available port starting from 9001 (9000 is reserved for gateway).""" """Find the next available port starting from 9001 (9000 is reserved for gateway)."""
used_ports = set() used_ports = set()
for manifest in config.components.values(): for svc in config.services.values():
if manifest.expose and manifest.expose.http: if svc.expose and svc.expose.http:
used_ports.add(manifest.expose.http.internal.port) used_ports.add(svc.expose.http.internal.port)
# Also reserve gateway port # Also reserve gateway port
used_ports.add(config.gateway.port) used_ports.add(config.gateway.port)
@@ -43,8 +44,8 @@ def run_create(args: argparse.Namespace) -> int:
name = args.name name = args.name
proj_type = args.type proj_type = args.type
if name in config.components: if name in config.components or name in config.services or name in config.jobs:
print(f"Error: component '{name}' already exists in castle.yaml") print(f"Error: '{name}' already exists in castle.yaml")
return 1 return 1
components_dir = config.root / "components" components_dir = config.root / "components"
@@ -72,16 +73,19 @@ def run_create(args: argparse.Namespace) -> int:
port=port, port=port,
) )
# Build manifest entry # Build entries
if proj_type == "service": if proj_type == "service":
manifest = ComponentManifest( # Component for software identity
config.components[name] = ComponentSpec(
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {proj_type}",
source=f"components/{name}", source=f"components/{name}",
run=RunPython( )
runner="python", # Service for deployment
tool=name, config.services[name] = ServiceSpec(
), id=name,
component=name,
run=RunPython(runner="python", tool=name),
expose=ExposeSpec( expose=ExposeSpec(
http=HttpExposeSpec( http=HttpExposeSpec(
internal=HttpInternal(port=port), internal=HttpInternal(port=port),
@@ -92,22 +96,21 @@ def run_create(args: argparse.Namespace) -> int:
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
) )
elif proj_type == "tool": elif proj_type == "tool":
manifest = ComponentManifest( config.components[name] = ComponentSpec(
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {proj_type}",
source=f"components/{name}", source=f"components/{name}",
tool=ToolSpec(source=f"components/{name}/"), tool=ToolSpec(),
install=InstallSpec(path=PathInstallSpec(alias=name)), install=InstallSpec(path=PathInstallSpec(alias=name)),
) )
else: else:
# library or other # library or other
manifest = ComponentManifest( config.components[name] = ComponentSpec(
id=name, id=name,
description=args.description or f"A castle {proj_type}", description=args.description or f"A castle {proj_type}",
source=f"components/{name}", source=f"components/{name}",
) )
config.components[name] = manifest
save_config(config) save_config(config)
print(f"Created {proj_type} '{name}' at {project_dir}") 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 ( from castle_core.generators.systemd import (
generate_timer, generate_timer,
generate_unit_from_deployed, generate_unit_from_deployed,
get_schedule_trigger,
timer_name, timer_name,
unit_name, unit_name,
) )
from castle_core.manifest import ComponentManifest from castle_core.manifest import JobSpec, ServiceSpec
from castle_core.registry import ( from castle_core.registry import (
REGISTRY_PATH, REGISTRY_PATH,
DeployedComponent, DeployedComponent,
@@ -40,15 +39,7 @@ SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
def run_deploy(args: argparse.Namespace) -> int: def run_deploy(args: argparse.Namespace) -> int:
"""Deploy components from castle.yaml to ~/.castle/.""" """Deploy components from castle.yaml to ~/.castle/."""
config = load_config() config = load_config()
component_name = getattr(args, "component", None) target_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())
ensure_dirs() ensure_dirs()
@@ -57,7 +48,7 @@ def run_deploy(args: argparse.Namespace) -> int:
# Load existing registry to preserve components not being redeployed, # Load existing registry to preserve components not being redeployed,
# or start fresh if deploying all # or start fresh if deploying all
if component_name and REGISTRY_PATH.exists(): if target_name and REGISTRY_PATH.exists():
try: try:
existing = load_registry() existing = load_registry()
registry = NodeRegistry(node=node, deployed=dict(existing.deployed)) registry = NodeRegistry(node=node, deployed=dict(existing.deployed))
@@ -67,14 +58,21 @@ def run_deploy(args: argparse.Namespace) -> int:
registry = NodeRegistry(node=node) registry = NodeRegistry(node=node)
deployed_count = 0 deployed_count = 0
for name in names:
manifest = config.components[name]
# Only deploy components with a run spec # Deploy services
if not manifest.run: for name, svc in config.services.items():
if target_name and name != target_name:
continue 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 registry.deployed[name] = deployed
deployed_count += 1 deployed_count += 1
_print_deployed(name, deployed) _print_deployed(name, deployed)
@@ -108,69 +106,100 @@ def _env_prefix(name: str) -> str:
return name.replace("-", "_").upper() return name.replace("-", "_").upper()
def _build_deployed( def _resolve_description(
config: CastleConfig, name: str, manifest: ComponentManifest config: CastleConfig, spec: ServiceSpec | JobSpec
) -> DeployedComponent: ) -> str | None:
"""Build a DeployedComponent from a manifest spec.""" """Get description, falling through to component if referenced."""
run = manifest.run if spec.description:
assert run is not None 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) prefix = _env_prefix(name)
env: dict[str, str] = {} env: dict[str, str] = {}
# Data dir convention (for all managed components) # Data dir convention (for managed services)
if manifest.manage and manifest.manage.systemd: 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) env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
# Port convention (if exposed) # 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 port = None
health_path = None health_path = None
if manifest.expose and manifest.expose.http: if svc.expose and svc.expose.http:
port = manifest.expose.http.internal.port port = svc.expose.http.internal.port
health_path = manifest.expose.http.health_path 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 proxy_path = None
if manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable: if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
proxy_path = manifest.proxy.caddy.path_prefix or f"/{name}" proxy_path = svc.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]
return DeployedComponent( return DeployedComponent(
runner=run.runner, runner=run.runner,
run_cmd=run_cmd, run_cmd=run_cmd,
env=env, env=env,
description=manifest.description, description=_resolve_description(config, svc),
roles=roles, category="service",
port=port, port=port,
health_path=health_path, health_path=health_path,
proxy_path=proxy_path, proxy_path=proxy_path,
schedule=schedule,
managed=managed, 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]: def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
"""Build a run command list from a RunSpec.""" """Build a run command list from a RunSpec."""
match run.runner: 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}"]) cmd.extend(["-p", f"{host_port}:{container_port}"])
for vol in run.volumes: for vol in run.volumes:
cmd.extend(["-v", vol]) cmd.extend(["-v", vol])
# Container env comes from both run.env (container-specific) and deployed env
for key, val in run.env.items(): for key, val in run.env.items():
cmd.extend(["-e", f"{key}={val}"]) cmd.extend(["-e", f"{key}={val}"])
for key, val in env.items(): for key, val in env.items():
@@ -238,13 +266,12 @@ def _copy_app_static(config: CastleConfig) -> None:
if "castle-app" not in config.components: if "castle-app" not in config.components:
return return
manifest = config.components["castle-app"] comp = config.components["castle-app"]
if not (manifest.build and manifest.build.outputs): if not (comp.build and comp.build.outputs):
return return
# Find the source dist directory source_dir = comp.source_dir or "app"
source_dir = manifest.source_dir or "app" for output in comp.build.outputs:
for output in manifest.build.outputs:
src = config.root / source_dir / output src = config.root / source_dir / output
if src.exists(): if src.exists():
dest = STATIC_DIR / "castle-app" dest = STATIC_DIR / "castle-app"
@@ -262,22 +289,29 @@ def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> Non
if not deployed.managed: if not deployed.managed:
continue continue
# Get systemd spec from manifest (for restart policy, exec_reload, etc.) # Get systemd spec from config (services or jobs)
systemd_spec = None systemd_spec = None
if name in config.components: if name in config.services:
manifest = config.components[name] svc = config.services[name]
if manifest.manage and manifest.manage.systemd: if svc.manage and svc.manage.systemd:
systemd_spec = manifest.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 # Generate and write service unit
svc_name = unit_name(name) svc_name = unit_name(name)
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec) svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content) (SYSTEMD_USER_DIR / svc_name).write_text(svc_content)
# Generate timer if scheduled # Generate timer for jobs
if name in config.components: if deployed.schedule:
timer_content = generate_timer(name, config.components[name]) timer_content = generate_timer(
if timer_content: name,
schedule=deployed.schedule,
description=deployed.description,
)
tmr_name = timer_name(name) tmr_name = timer_name(name)
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content) (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.""" """Generate config and enable the gateway service."""
from castle_cli.commands.service import _service_enable from castle_cli.commands.service import _service_enable
if GATEWAY_COMPONENT not in config.managed: if GATEWAY_COMPONENT not in config.services:
print(f"Error: '{GATEWAY_COMPONENT}' not found in castle.yaml or not managed") print(f"Error: '{GATEWAY_COMPONENT}' not found in services section")
return 1 return 1
print("Generating gateway configuration...") print("Generating gateway configuration...")
@@ -101,7 +101,6 @@ def _gateway_reload() -> int:
if result.returncode == 0: if result.returncode == 0:
print("Gateway reloaded.") print("Gateway reloaded.")
else: else:
# Fall back to restart if reload not supported
print("Reload signal sent. Verifying...") print("Reload signal sent. Verifying...")
result = subprocess.run( result = subprocess.run(
["systemctl", "--user", "is-active", GATEWAY_UNIT], ["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: 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() config = load_config()
name = args.project name = args.project
if name not in config.components: # Look up in all sections
print(f"Error: component '{name}' not found in castle.yaml") 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 return 1
manifest = config.components[name]
deployed = _load_deployed_component(name) deployed = _load_deployed_component(name)
if getattr(args, "json", False): if getattr(args, "json", False):
data = manifest.model_dump(exclude_none=True) return _info_json(config, name, component, service, job, deployed)
# 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
# Human-readable output # Human-readable output
print(f"\n{BOLD}{name}{RESET}") print(f"\n{BOLD}{name}{RESET}")
print(f"{'' * 40}") 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 # Determine category
if manifest.source: if service:
print(f" {BOLD}source{RESET}: {manifest.source}") 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 # Run spec
if manifest.run: print(f" {BOLD}runner{RESET}: {spec.run.runner}")
print(f" {BOLD}runner{RESET}: {manifest.run.runner}") if hasattr(spec.run, "tool"):
if hasattr(manifest.run, "tool"): print(f" {BOLD}tool{RESET}: {spec.run.tool}")
print(f" {BOLD}tool{RESET}: {manifest.run.tool}") elif hasattr(spec.run, "argv"):
elif hasattr(manifest.run, "argv"): print(f" {BOLD}argv{RESET}: {spec.run.argv}")
print(f" {BOLD}argv{RESET}: {manifest.run.argv}") elif hasattr(spec.run, "image"):
elif hasattr(manifest.run, "image"): print(f" {BOLD}image{RESET}: {spec.run.image}")
print(f" {BOLD}image{RESET}: {manifest.run.image}")
# Defaults env # Defaults env
if manifest.defaults and manifest.defaults.env: if spec.defaults and spec.defaults.env:
print(f" {BOLD}defaults.env{RESET}:") 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}") print(f" {key}: {val}")
# Expose # Service-specific: expose, proxy, manage
if manifest.expose and manifest.expose.http: if service:
http = manifest.expose.http if service.expose and service.expose.http:
http = service.expose.http
print(f" {BOLD}port{RESET}: {http.internal.port}") print(f" {BOLD}port{RESET}: {http.internal.port}")
if http.health_path: if http.health_path:
print(f" {BOLD}health{RESET}: {http.health_path}") print(f" {BOLD}health{RESET}: {http.health_path}")
if service.proxy and service.proxy.caddy:
# Proxy caddy = service.proxy.caddy
if manifest.proxy and manifest.proxy.caddy:
caddy = manifest.proxy.caddy
if caddy.path_prefix: if caddy.path_prefix:
print(f" {BOLD}path{RESET}: {caddy.path_prefix}") print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
if service.manage and service.manage.systemd:
# Manage sd = service.manage.systemd
if manifest.manage and manifest.manage.systemd:
sd = manifest.manage.systemd
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}") print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
# Install # Job-specific
if manifest.install and manifest.install.path: if job:
pi = manifest.install.path print(f" {BOLD}schedule{RESET}: {job.schedule}")
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else "")) print(f" {BOLD}timezone{RESET}: {job.timezone}")
# 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 ""))
# Deployed state from registry # Deployed state from registry
if deployed: if deployed:
@@ -142,10 +139,16 @@ def run_info(args: argparse.Namespace) -> int:
for key, val in deployed.env.items(): for key, val in deployed.env.items():
print(f" {key}={val}") print(f" {key}={val}")
else: 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 # 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) claude_md = _find_claude_md(config.root, source_dir)
if claude_md: if claude_md:
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}") print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
@@ -156,6 +159,46 @@ def run_info(args: argparse.Namespace) -> int:
return 0 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: def _find_claude_md(root: Path, source_dir: str) -> str | None:
"""Read CLAUDE.md from project directory if it exists.""" """Read CLAUDE.md from project directory if it exists."""
claude_path = root / source_dir / "CLAUDE.md" claude_path = root / source_dir / "CLAUDE.md"

View File

@@ -7,7 +7,6 @@ import json
import logging import logging
from castle_cli.config import load_config from castle_cli.config import load_config
from castle_cli.manifest import Role
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -17,15 +16,15 @@ RESET = "\033[0m"
DIM = "\033[2m" DIM = "\033[2m"
GREEN = "\033[92m" GREEN = "\033[92m"
RED = "\033[91m" RED = "\033[91m"
CYAN = "\033[96m"
MAGENTA = "\033[95m"
YELLOW = "\033[93m"
ROLE_COLORS: dict[str, str] = { CATEGORY_COLORS: dict[str, str] = {
Role.SERVICE: "\033[92m", # green "service": GREEN,
Role.TOOL: "\033[96m", # cyan "job": MAGENTA,
Role.WORKER: "\033[94m", # blue "tool": CYAN,
Role.JOB: "\033[95m", # magenta "frontend": YELLOW,
Role.FRONTEND: "\033[93m", # yellow
Role.REMOTE: "\033[90m", # dim
Role.CONTAINERIZED: "\033[33m", # orange
} }
@@ -41,71 +40,132 @@ def _load_deployed() -> dict[str, object] | None:
def run_list(args: argparse.Namespace) -> int: def run_list(args: argparse.Namespace) -> int:
"""List all components.""" """List all components, services, and jobs."""
config = load_config() config = load_config()
deployed = _load_deployed() deployed = _load_deployed()
components = config.components filter_type = getattr(args, "type", None)
filter_role = getattr(args, "role", None)
if filter_role:
components = {k: v for k, v in components.items() if filter_role in v.roles}
if getattr(args, "json", False): if getattr(args, "json", False):
output = [] return _list_json(config, deployed, filter_type)
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
if not components: any_output = False
print("No components found.")
return 0
# Group by primary role (first in sorted list) # Services
by_role: dict[str, list[tuple[str, object]]] = {} if not filter_type or filter_type == "service":
for name, manifest in components.items(): if config.services:
primary_role = manifest.roles[0].value if manifest.roles else "other" any_output = True
by_role.setdefault(primary_role, []).append((name, manifest)) color = CATEGORY_COLORS["service"]
print(f"\n{BOLD}{color}Services{RESET}")
# 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}")
print(f"{color}{'' * 40}{RESET}") print(f"{color}{'' * 40}{RESET}")
for name, manifest in items: for name, svc in config.services.items():
port_str = "" port_str = ""
if manifest.expose and manifest.expose.http: if svc.expose and svc.expose.http:
port_str = f" :{manifest.expose.http.internal.port}" port_str = f" :{svc.expose.http.internal.port}"
# Show deployed status indicator
if deployed is not None: if deployed is not None:
status = f"{GREEN}{RESET}" if name in deployed else f"{RED}{RESET}" status = f"{GREEN}{RESET}" if name in deployed else f"{RED}{RESET}"
else: else:
status = f"{DIM}?{RESET}" 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}") 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: if deployed is None:
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}") print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")
print() print()
return 0 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: def run_logs(args: argparse.Namespace) -> int:
"""View logs for a component.""" """View logs for a service or job."""
config = load_config() config = load_config()
name = args.name name = args.name
if name not in config.components: # Check services
print(f"Error: component '{name}' not found in castle.yaml") if name in config.services:
return 1 svc = config.services[name]
if svc.run.runner == "container":
manifest = config.components[name]
# Container logs
if manifest.run and manifest.run.runner == "container":
return _container_logs(name, args) return _container_logs(name, args)
# Systemd logs (default for managed services)
if manifest.manage and manifest.manage.systemd:
return _systemd_logs(name, args) 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 return 1

View File

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

View File

@@ -8,26 +8,6 @@ import subprocess
from pathlib import Path from pathlib import Path
from castle_cli.config import ensure_dirs, load_config 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: def run_sync(args: argparse.Namespace) -> int:
@@ -44,11 +24,12 @@ def run_sync(args: argparse.Namespace) -> int:
if result.returncode != 0: if result.returncode != 0:
print("Warning: git submodule update failed (may not be a git repo)") 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 all_ok = True
synced_dirs: set[Path] = set() 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: if not source_dir:
continue continue
project_dir = config.root / source_dir 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(): if project_dir in synced_dirs or not project_dir.is_dir():
continue continue
cmd = _sync_cmd(manifest) # Determine sync command based on project type
if cmd is None: cmd = None
# No runner — check if it's a frontend with a package.json if (project_dir / "pyproject.toml").exists():
if manifest.build and (project_dir / "package.json").exists(): cmd = ["uv", "sync"]
elif (project_dir / "package.json").exists():
pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm" pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm"
cmd = [pm, "install"] cmd = [pm, "install"]
else:
if cmd is None:
continue continue
label = cmd[0] label = cmd[0]
@@ -75,28 +58,33 @@ def run_sync(args: argparse.Namespace) -> int:
print(" OK") print(" OK")
synced_dirs.add(project_dir) 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" uv_path = shutil.which("uv") or "uv"
installed_dirs: set[Path] = set() installed_dirs: set[Path] = set()
for name, manifest in config.components.items(): # Install components with install.path
# Install if: has install.path, or is a python runner service for name, comp in config.components.items():
if not ( if not (comp.install and comp.install.path):
(manifest.install and manifest.install.path)
or (manifest.run and manifest.run.runner == "python")
):
continue continue
source = comp.source_dir
source = manifest.source_dir
if not source: if not source:
continue 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 source_dir = config.root / source
if (source_dir / "pyproject.toml").exists():
# Python package — uv tool install
if source_dir in installed_dirs: if source_dir in installed_dirs:
continue continue
if (source_dir / "pyproject.toml").exists():
print(f"\nInstalling {name}...") print(f"\nInstalling {name}...")
result = subprocess.run( result = subprocess.run(
[uv_path, "tool", "install", "--editable", str(source_dir), "--force"], [uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
@@ -113,21 +101,53 @@ def run_sync(args: argparse.Namespace) -> int:
print(f" {name}: OK") print(f" {name}: OK")
installed_dirs.add(source_dir) 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: if all_ok:
print("\nAll projects synced.") print("\nAll projects synced.")
else: else:
print("\nSync completed with warnings.") print("\nSync completed with warnings.")
return 0 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: if manifest.description:
print(f" {manifest.description}") print(f" {manifest.description}")
print(f" {BOLD}version{RESET}: {t.version}") print(f" {BOLD}version{RESET}: {t.version}")
if t.source: if manifest.source:
print(f" {BOLD}source{RESET}: {t.source}") print(f" {BOLD}source{RESET}: {manifest.source}")
if t.system_dependencies: if t.system_dependencies:
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}") print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")

View File

@@ -21,9 +21,9 @@ def build_parser() -> argparse.ArgumentParser:
# castle list # castle list
list_parser = subparsers.add_parser("list", help="List all components") list_parser = subparsers.add_parser("list", help="List all components")
list_parser.add_argument( list_parser.add_argument(
"--role", "--type",
choices=["service", "tool", "worker", "job", "frontend", "remote", "containerized"], choices=["service", "job", "tool", "frontend"],
help="Filter by role", help="Filter by type",
) )
list_parser.add_argument("--json", action="store_true", help="Output as JSON") 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, BuildSpec,
CaddySpec, CaddySpec,
Capability, Capability,
ComponentManifest, ComponentSpec,
DefaultsSpec, DefaultsSpec,
EnvMap, EnvMap,
ExposeSpec, ExposeSpec,
@@ -13,12 +13,12 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
HttpInternal, HttpInternal,
HttpPublic, HttpPublic,
InstallSpec, InstallSpec,
JobSpec,
ManageSpec, ManageSpec,
PathInstallSpec, PathInstallSpec,
ProxySpec, ProxySpec,
ReadinessHttpGet, ReadinessHttpGet,
RestartPolicy, RestartPolicy,
Role,
RunBase, RunBase,
RunCommand, RunCommand,
RunContainer, RunContainer,
@@ -26,12 +26,8 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
RunPython, RunPython,
RunRemote, RunRemote,
RunSpec, RunSpec,
ServiceSpec,
SystemdSpec, SystemdSpec,
TLSMode, TLSMode,
ToolSpec, ToolSpec,
TriggerEvent,
TriggerManual,
TriggerRequest,
TriggerSchedule,
TriggerSpec,
) )

View File

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

View File

@@ -59,8 +59,8 @@ class TestInfoCommand:
output = capsys.readouterr().out output = capsys.readouterr().out
assert "not found" in output assert "not found" in output
def test_info_json(self, castle_root: Path, capsys) -> None: def test_info_json_service(self, castle_root: Path, capsys) -> None:
"""--json produces valid JSON with manifest fields.""" """--json produces valid JSON with service fields."""
from castle_cli.config import load_config from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("castle_cli.commands.info.load_config") as mock_load:
@@ -73,48 +73,8 @@ class TestInfoCommand:
assert result == 0 assert result == 0
output = capsys.readouterr().out output = capsys.readouterr().out
data = json.loads(output) data = json.loads(output)
assert data["id"] == "test-svc" assert data["category"] == "service"
assert "service" in data["roles"] assert data["service"]["expose"]["http"]["internal"]["port"] == 19000
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"]
def test_info_shows_env(self, castle_root: Path, capsys) -> None: def test_info_shows_env(self, castle_root: Path, capsys) -> None:
"""Info displays environment variables.""" """Info displays environment variables."""
@@ -131,8 +91,8 @@ class TestInfoCommand:
output = capsys.readouterr().out output = capsys.readouterr().out
assert "TEST_SVC_DATA_DIR" in output assert "TEST_SVC_DATA_DIR" in output
def test_info_shows_roles(self, castle_root: Path, capsys) -> None: def test_info_shows_category(self, castle_root: Path, capsys) -> None:
"""Info displays derived roles.""" """Info displays category instead of roles."""
from castle_cli.config import load_config from castle_cli.config import load_config
with patch("castle_cli.commands.info.load_config") as mock_load: with patch("castle_cli.commands.info.load_config") as mock_load:
@@ -144,5 +104,5 @@ class TestInfoCommand:
assert result == 0 assert result == 0
output = capsys.readouterr().out output = capsys.readouterr().out
assert "roles" in output assert "category" in output
assert "service" in output assert "service" in output

View File

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

View File

@@ -9,7 +9,7 @@ from pathlib import Path
import yaml import yaml
from castle_core.manifest import ComponentManifest, Role from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
def find_castle_root() -> Path: def find_castle_root() -> Path:
@@ -47,36 +47,30 @@ class CastleConfig:
root: Path root: Path
gateway: GatewayConfig gateway: GatewayConfig
components: dict[str, ComponentManifest] components: dict[str, ComponentSpec]
services: dict[str, ServiceSpec]
jobs: dict[str, JobSpec]
@property @property
def services(self) -> dict[str, ComponentManifest]: def tools(self) -> dict[str, ComponentSpec]:
"""Return components with the SERVICE role.""" """Return components that are tools (have install.path or tool spec)."""
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."""
return { return {
k: v k: v
for k, v in self.components.items() 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( def resolve_env_vars(env: dict[str, str]) -> dict[str, str]:
env: dict[str, str], manifest: ComponentManifest
) -> dict[str, str]:
"""Resolve ${secret:NAME} references in env values.""" """Resolve ${secret:NAME} references in env values."""
resolved = {} resolved = {}
for key, value in env.items(): for key, value in env.items():
@@ -100,11 +94,25 @@ def _read_secret(name: str) -> str:
return f"<MISSING_SECRET:{name}>" return f"<MISSING_SECRET:{name}>"
def _parse_component(name: str, data: dict) -> ComponentManifest: def _parse_component(name: str, data: dict) -> ComponentSpec:
"""Parse a components: entry directly into a ComponentManifest.""" """Parse a components: entry into a ComponentSpec."""
data_copy = dict(data) data_copy = dict(data)
data_copy["id"] = name 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: 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_data = data.get("gateway", {})
gateway = GatewayConfig(port=gateway_data.get("port", 9000)) 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(): for name, comp_data in data.get("components", {}).items():
components[name] = _parse_component(name, comp_data) 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: 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: def _spec_to_yaml_dict(spec: ComponentSpec | ServiceSpec | JobSpec) -> dict:
"""Serialize a manifest to a YAML-friendly dict, preserving structural presence.""" """Serialize a spec to a YAML-friendly dict, preserving structural presence."""
full = manifest.model_dump(mode="json", exclude_none=True, exclude={"id", "roles"}) exclude_fields = {"id"}
minimal = manifest.model_dump( full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
mode="json", exclude_none=True, exclude={"id", "roles"}, exclude_defaults=True 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: 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: def save_config(config: CastleConfig) -> None:
"""Save castle configuration to castle.yaml.""" """Save castle configuration to castle.yaml."""
data: dict = {"gateway": {"port": config.gateway.port}, "components": {}} data: dict = {"gateway": {"port": config.gateway.port}}
for name, manifest in config.components.items(): if config.components:
data["components"][name] = _manifest_to_yaml_dict(manifest) 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" config_path = config.root / "castle.yaml"
with open(config_path, "w") as f: with open(config_path, "w") as f:

View File

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

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import shutil import shutil
from pathlib import Path 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 from castle_core.registry import DeployedComponent
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" 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" 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: def cron_to_oncalendar(cron: str) -> str:
"""Best-effort conversion of cron expression to systemd OnCalendar. """Best-effort conversion of cron expression to systemd OnCalendar.
@@ -142,17 +134,17 @@ WantedBy={wanted_by}
return unit return unit
def generate_timer(name: str, manifest: ComponentManifest) -> str | None: def generate_timer(
"""Generate a systemd timer unit if the component has a schedule trigger.""" name: str,
trigger = get_schedule_trigger(manifest) schedule: str,
if trigger is None: description: str | None = None,
return None ) -> str:
"""Generate a systemd timer unit from a cron schedule string."""
description = manifest.description or name description = description or name
# Try to convert cron to OnCalendar, fall back to OnUnitActiveSec # Try to convert cron to OnCalendar, fall back to OnUnitActiveSec
on_calendar = cron_to_oncalendar(trigger.cron) on_calendar = cron_to_oncalendar(schedule)
interval_sec = cron_to_interval_sec(trigger.cron) interval_sec = cron_to_interval_sec(schedule)
timer_lines = "" timer_lines = ""
if on_calendar: 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 __future__ import annotations
from enum import Enum from enum import Enum
from typing import Annotated, Literal, Union 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] EnvMap = dict[str, str]
@@ -22,16 +22,6 @@ class TLSMode(str, Enum):
LETSENCRYPT = "letsencrypt" LETSENCRYPT = "letsencrypt"
class Role(str, Enum):
TOOL = "tool"
SERVICE = "service"
WORKER = "worker"
FRONTEND = "frontend"
JOB = "job"
REMOTE = "remote"
CONTAINERIZED = "containerized"
# --------------------- # ---------------------
# Run specs (discriminated union) # 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 # Systemd management
# --------------------- # ---------------------
@@ -166,7 +126,6 @@ class ToolSpec(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
version: str = "1.0.0" version: str = "1.0.0"
source: str | None = None
system_dependencies: list[str] = Field(default_factory=list) 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 = "" id: str = ""
name: str | None = None
description: str | None = None description: str | None = None
source: 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 install: InstallSpec | None = None
tool: ToolSpec | None = None tool: ToolSpec | None = None
expose: ExposeSpec | None = None
proxy: ProxySpec | None = None
build: BuildSpec | None = None build: BuildSpec | None = None
defaults: DefaultsSpec | None = None
provides: list[Capability] = Field(default_factory=list) provides: list[Capability] = Field(default_factory=list)
consumes: list[Capability] = Field(default_factory=list) consumes: list[Capability] = Field(default_factory=list)
tags: list[str] = 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 @property
def source_dir(self) -> str | None: def source_dir(self) -> str | None:
"""Best-effort relative directory for this component's source. """Relative directory for this component's source, or None."""
Resolution order: source → tool.source (strip trailing /).
Returns None if no directory can be determined.
"""
if self.source: if self.source:
return self.source.rstrip("/") return self.source.rstrip("/")
if self.tool and self.tool.source:
return self.tool.source.rstrip("/")
return None 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") @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.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.") raise ValueError("manage.systemd cannot be enabled for runner=remote.")
return self 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] run_cmd: list[str]
env: dict[str, str] = field(default_factory=dict) env: dict[str, str] = field(default_factory=dict)
description: str | None = None description: str | None = None
roles: list[str] = field(default_factory=list) category: str = "service"
port: int | None = None port: int | None = None
health_path: str | None = None health_path: str | None = None
proxy_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", []), run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}), env=comp_data.get("env", {}),
description=comp_data.get("description"), description=comp_data.get("description"),
roles=comp_data.get("roles", []), category=comp_data.get("category", "service"),
port=comp_data.get("port"), port=comp_data.get("port"),
health_path=comp_data.get("health_path"), health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_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 entry["env"] = comp.env
if comp.description: if comp.description:
entry["description"] = comp.description entry["description"] = comp.description
if comp.roles: entry["category"] = comp.category
entry["roles"] = comp.roles
if comp.port is not None: if comp.port is not None:
entry["port"] = comp.port entry["port"] = comp.port
if comp.health_path: if comp.health_path:

View File

@@ -16,9 +16,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
config = { config = {
"gateway": {"port": 18000}, "gateway": {"port": 18000},
"components": { "components": {
"test-tool": {
"description": "Test tool",
"install": {
"path": {"alias": "test-tool"},
},
},
},
"services": {
"test-svc": { "test-svc": {
"component": "test-svc-comp",
"description": "Test service", "description": "Test service",
"source": "test-svc",
"run": { "run": {
"runner": "python", "runner": "python",
"tool": "test-svc", "tool": "test-svc",
@@ -39,11 +47,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
"systemd": {}, "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, resolve_env_vars,
save_config, save_config,
) )
from castle_core.manifest import ComponentManifest, Role from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
class TestLoadConfig: class TestLoadConfig:
"""Tests for loading castle.yaml.""" """Tests for loading castle.yaml."""
def test_load_basic(self, castle_root: Path) -> None: 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) config = load_config(castle_root)
assert isinstance(config, CastleConfig) assert isinstance(config, CastleConfig)
assert config.gateway.port == 18000 assert config.gateway.port == 18000
assert "test-svc" in config.components
assert "test-tool" 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: def test_load_produces_typed_specs(self, castle_root: Path) -> None:
"""Components are ComponentManifest objects.""" """Each section produces the correct spec type."""
config = load_config(castle_root) config = load_config(castle_root)
assert isinstance(config.components["test-svc"], ComponentManifest) assert isinstance(config.components["test-tool"], ComponentSpec)
assert isinstance(config.components["test-tool"], ComponentManifest) assert isinstance(config.services["test-svc"], ServiceSpec)
assert isinstance(config.jobs["test-job"], JobSpec)
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
def test_service_expose(self, castle_root: Path) -> None: def test_service_expose(self, castle_root: Path) -> None:
"""Service has correct expose spec.""" """Service has correct expose spec."""
config = load_config(castle_root) 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.internal.port == 19000
assert svc.expose.http.health_path == "/health" assert svc.expose.http.health_path == "/health"
def test_service_proxy(self, castle_root: Path) -> None: def test_service_proxy(self, castle_root: Path) -> None:
"""Service has correct proxy spec.""" """Service has correct proxy spec."""
config = load_config(castle_root) config = load_config(castle_root)
svc = config.components["test-svc"] svc = config.services["test-svc"]
assert svc.proxy.caddy.path_prefix == "/test-svc" assert svc.proxy.caddy.path_prefix == "/test-svc"
def test_service_run_spec(self, castle_root: Path) -> None: def test_service_run_spec(self, castle_root: Path) -> None:
"""Service has correct RunSpec.""" """Service has correct RunSpec."""
config = load_config(castle_root) config = load_config(castle_root)
svc = config.components["test-svc"] svc = config.services["test-svc"]
assert svc.run.runner == "python" assert svc.run.runner == "python"
assert svc.run.tool == "test-svc" assert svc.run.tool == "test-svc"
assert svc.source == "test-svc"
def test_tool_no_run(self, castle_root: Path) -> None: def test_service_component_ref(self, castle_root: Path) -> None:
"""Tool without run block has no run spec.""" """Service references a component."""
config = load_config(castle_root) config = load_config(castle_root)
tool = config.components["test-tool"] svc = config.services["test-svc"]
assert tool.run is None assert svc.component == "test-svc-comp"
def test_services_property(self, castle_root: Path) -> None: def test_job_schedule(self, castle_root: Path) -> None:
"""Services property filters to SERVICE role.""" """Job has correct schedule."""
config = load_config(castle_root) config = load_config(castle_root)
assert "test-svc" in config.services job = config.jobs["test-job"]
assert "test-tool" not in config.services assert job.schedule == "0 2 * * *"
def test_tools_property(self, castle_root: Path) -> None: 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) config = load_config(castle_root)
assert "test-tool" in config.tools 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: def test_missing_config_raises(self, tmp_path: Path) -> None:
"""Missing castle.yaml raises FileNotFoundError.""" """Missing castle.yaml raises FileNotFoundError."""
@@ -105,11 +87,13 @@ class TestSaveConfig:
assert config2.gateway.port == config.gateway.port assert config2.gateway.port == config.gateway.port
assert set(config2.components.keys()) == set(config.components.keys()) assert set(config2.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: def test_save_adds_component(self, castle_root: Path) -> None:
"""Adding a component and saving persists it.""" """Adding a component and saving persists it."""
config = load_config(castle_root) config = load_config(castle_root)
config.components["new-lib"] = ComponentManifest( config.components["new-lib"] = ComponentSpec(
id="new-lib", description="A new library" id="new-lib", description="A new library"
) )
save_config(config) save_config(config)
@@ -123,7 +107,9 @@ class TestSaveConfig:
config = load_config(castle_root) config = load_config(castle_root)
save_config(config) save_config(config)
config2 = load_config(castle_root) 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: class TestResolveEnvVars:
@@ -131,16 +117,14 @@ class TestResolveEnvVars:
def test_no_vars(self) -> None: def test_no_vars(self) -> None:
"""Plain values pass through unchanged.""" """Plain values pass through unchanged."""
manifest = ComponentManifest(id="test")
env = {"MY_VAR": "plain_value"} env = {"MY_VAR": "plain_value"}
resolved = resolve_env_vars(env, manifest) resolved = resolve_env_vars(env)
assert resolved["MY_VAR"] == "plain_value" assert resolved["MY_VAR"] == "plain_value"
def test_unrecognized_vars_preserved(self) -> None: def test_unrecognized_vars_preserved(self) -> None:
"""Non-secret ${} references pass through unchanged.""" """Non-secret ${} references pass through unchanged."""
manifest = ComponentManifest(id="test")
env = {"MY_VAR": "${unknown_var}"} env = {"MY_VAR": "${unknown_var}"}
resolved = resolve_env_vars(env, manifest) resolved = resolve_env_vars(env)
assert resolved["MY_VAR"] == "${unknown_var}" assert resolved["MY_VAR"] == "${unknown_var}"
def test_resolve_secret( def test_resolve_secret(
@@ -152,9 +136,8 @@ class TestResolveEnvVars:
(secrets_dir / "API_KEY").write_text("my-secret-key\n") (secrets_dir / "API_KEY").write_text("my-secret-key\n")
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir) monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
manifest = ComponentManifest(id="test")
env = {"API_KEY": "${secret:API_KEY}"} env = {"API_KEY": "${secret:API_KEY}"}
resolved = resolve_env_vars(env, manifest) resolved = resolve_env_vars(env)
assert resolved["API_KEY"] == "my-secret-key" assert resolved["API_KEY"] == "my-secret-key"
def test_resolve_missing_secret( def test_resolve_missing_secret(
@@ -165,7 +148,6 @@ class TestResolveEnvVars:
secrets_dir.mkdir() secrets_dir.mkdir()
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir) monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
manifest = ComponentManifest(id="test")
env = {"API_KEY": "${secret:NONEXISTENT}"} env = {"API_KEY": "${secret:NONEXISTENT}"}
resolved = resolve_env_vars(env, manifest) resolved = resolve_env_vars(env)
assert resolved["API_KEY"] == "<MISSING_SECRET:NONEXISTENT>" 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 from __future__ import annotations
@@ -6,171 +6,183 @@ import pytest
from castle_core.manifest import ( from castle_core.manifest import (
BuildSpec, BuildSpec,
CaddySpec, CaddySpec,
ComponentManifest, ComponentSpec,
ExposeSpec, ExposeSpec,
HttpExposeSpec, HttpExposeSpec,
HttpInternal, HttpInternal,
InstallSpec, InstallSpec,
JobSpec,
ManageSpec, ManageSpec,
PathInstallSpec, PathInstallSpec,
ProxySpec, ProxySpec,
Role,
RunCommand, RunCommand,
RunContainer,
RunPython, RunPython,
RunRemote, RunRemote,
ServiceSpec,
SystemdSpec, SystemdSpec,
ToolSpec, ToolSpec,
TriggerSchedule,
) )
class TestRoleDerivation: class TestComponentSpec:
"""Tests for computed role derivation.""" """Tests for component (software catalog) model."""
def test_service_from_expose_http(self) -> None: def test_minimal(self) -> None:
"""Component with expose.http gets SERVICE role.""" """Minimal component just needs an id."""
m = ComponentManifest( c = ComponentSpec(id="bare")
id="svc", assert c.description is None
run=RunPython(runner="python", tool="svc"), assert c.source is None
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))), 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: def test_frontend_component(self) -> None:
"""Component with install.path gets TOOL role.""" """Component with build spec."""
m = ComponentManifest( c = ComponentSpec(
id="mytool", id="my-app",
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"]),
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]), 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: def test_source_dir_from_source(self) -> None:
"""Component with tool spec gets TOOL role.""" """source_dir uses source field."""
m = ComponentManifest( c = ComponentSpec(id="x", source="components/x/")
id="docx2md", assert c.source_dir == "components/x"
tool=ToolSpec(source="docx2md/"),
)
assert Role.TOOL in m.roles
def test_tool_spec_without_install(self) -> None: def test_source_dir_none(self) -> None:
"""Tool spec alone is enough for TOOL role, no install.path needed.""" """source_dir returns None when no source available."""
m = ComponentManifest( c = ComponentSpec(id="x")
id="my-tool", assert c.source_dir is None
tool=ToolSpec(),
)
assert Role.TOOL in m.roles
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: class TestServiceSpec:
"""Component can have multiple roles.""" """Tests for service (long-running daemon) model."""
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
def test_systemd_with_http_is_service_not_worker(self) -> None: def test_basic_service(self) -> None:
"""Systemd + HTTP = SERVICE, not WORKER.""" """Service with run and expose."""
m = ComponentManifest( s = ServiceSpec(
id="svc", id="svc",
run=RunPython(runner="python", tool="svc"), run=RunPython(runner="python", tool="svc"),
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))), 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()), manage=ManageSpec(systemd=SystemdSpec()),
) )
assert Role.SERVICE in m.roles assert s.manage.systemd.enable is True
assert Role.WORKER not in m.roles
class TestConsistencyValidation:
"""Tests for model validation."""
def test_remote_with_systemd_raises(self) -> None: def test_remote_with_systemd_raises(self) -> None:
"""Remote runner + systemd management is invalid.""" """Remote runner + systemd management is invalid."""
with pytest.raises( with pytest.raises(
ValueError, match="manage.systemd cannot be enabled for runner=remote" ValueError, match="manage.systemd cannot be enabled for runner=remote"
): ):
ComponentManifest( ServiceSpec(
id="bad", id="bad",
run=RunRemote(runner="remote", base_url="http://example.com"), run=RunRemote(runner="remote", base_url="http://example.com"),
manage=ManageSpec(systemd=SystemdSpec()), manage=ManageSpec(systemd=SystemdSpec()),
) )
def test_no_run_is_valid(self) -> None: def test_no_run_is_invalid(self) -> None:
"""Component with no run spec is valid (registration-only).""" """Service requires a run spec."""
m = ComponentManifest(id="reg-only", description="Just registered") with pytest.raises(Exception):
assert m.run is None ServiceSpec(id="bad")
assert m.roles == [Role.TOOL]
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: class TestModelSerialization:
"""Tests for model_dump behavior.""" """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.""" """model_dump with exclude_none drops None fields."""
m = ComponentManifest(id="test", description="Test") c = ComponentSpec(id="test", description="Test")
data = m.model_dump(exclude_none=True, exclude={"id", "roles"}) data = c.model_dump(exclude_none=True, exclude={"id"})
assert "description" in data assert "description" in data
assert "run" not in data assert "install" not in data
assert "manage" not in data assert "tool" not in data
def test_dump_service(self) -> None: def test_dump_service(self) -> None:
"""Full service manifest serializes correctly.""" """Full service serializes correctly."""
m = ComponentManifest( s = ServiceSpec(
id="svc", id="svc",
description="A service", description="A service",
run=RunPython(runner="python", tool="svc", cwd="svc"), run=RunPython(runner="python", tool="svc"),
expose=ExposeSpec( expose=ExposeSpec(
http=HttpExposeSpec( http=HttpExposeSpec(
internal=HttpInternal(port=9001), health_path="/health" internal=HttpInternal(port=9001), health_path="/health"
@@ -179,7 +191,7 @@ class TestModelSerialization:
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")), proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
manage=ManageSpec(systemd=SystemdSpec()), 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["run"]["runner"] == "python"
assert data["expose"]["http"]["internal"]["port"] == 9001 assert data["expose"]["http"]["internal"]["port"] == 9001
assert data["proxy"]["caddy"]["path_prefix"] == "/svc" assert data["proxy"]["caddy"]["path_prefix"] == "/svc"

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from castle_core.generators.systemd import ( from castle_core.generators.systemd import (
generate_timer,
generate_unit_from_deployed, generate_unit_from_deployed,
unit_name, unit_name,
) )
@@ -115,3 +116,25 @@ class TestUnitFromDeployed:
) )
unit = generate_unit_from_deployed("my-svc", deployed) unit = generate_unit_from_deployed("my-svc", deployed)
assert "/data/repos/" not in unit 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 # Component Registry
How castle tracks, configures, and manages components. This is the central 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 ## castle.yaml
The single source of truth for all components. Lives at the repo root. The single source of truth for all components. Lives at the repo root.
Three top-level sections:
```yaml ```yaml
gateway: gateway:
port: 9000 port: 9000
components: components:
my-service: my-tool:
description: Does something useful description: Does something useful
source: components/my-tool
install:
path: { alias: my-tool }
tool:
system_dependencies: [pandoc]
services:
my-service:
component: my-service
run: run:
runner: python runner: python
tool: my-service tool: my-service
@@ -25,16 +35,84 @@ components:
caddy: { path_prefix: /my-service } caddy: { path_prefix: /my-service }
manage: manage:
systemd: {} 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 ## Component blocks
(used by `castle sync`) and the deployment resolution (used by `castle deploy`):
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 | | 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` | | `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` | | `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
**Services** use `python`:
```yaml ```yaml
run: run:
runner: python runner: python
tool: my-service # name in [project.scripts] 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 ### `expose` — What it exposes
```yaml ```yaml
expose: expose:
http: http:
internal: internal:
port: 9001 # Required for services port: 9001 # Required for HTTP services
health_path: /health # Used by health polling health_path: /health # Used by health polling
``` ```
Having `expose.http` gives the component the **service** role.
### `proxy` — How to proxy it ### `proxy` — How to proxy it
```yaml ```yaml
@@ -95,7 +157,7 @@ manage:
``` ```
Enables `castle service enable/disable` and `castle logs`. An empty `{}` 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: Full options:
```yaml ```yaml
@@ -107,91 +169,39 @@ manage:
no_new_privileges: true no_new_privileges: true
after: [network.target, castle-other.service] after: [network.target, castle-other.service]
wanted_by: [default.target] wanted_by: [default.target]
exec_reload: "caddy reload ..."
``` ```
### `install` — How to install it ### `defaults` — Default environment
```yaml ```yaml
install: defaults:
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:
env: env:
CENTRAL_CONTEXT_URL: http://localhost:9001
API_KEY: ${secret:MY_API_KEY} API_KEY: ${secret:MY_API_KEY}
``` ```
Castle resolves `${secret:NAME}` by reading `~/.castle/secrets/NAME`. Castle resolves `${secret:NAME}` by reading `~/.castle/secrets/NAME`.
Never store secrets in castle.yaml or project directories. 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 | ### `schedule` — Cron expression (required)
|------|-------------|
| **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` |
A component can have multiple roles. For example, `protonmail` is both a ```yaml
**tool** (installed to PATH) and a **job** (runs on a cron schedule). 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 ## Registering a new component
@@ -207,17 +217,38 @@ castle create my-tool --type tool --description "Does something"
### Manually ### Manually
Add an entry to the `components:` section of `castle.yaml`: Add entries to the appropriate sections of `castle.yaml`:
```yaml ```yaml
# Tool — only needs a component entry
components: components:
my-tool: my-tool:
description: Does something useful description: Does something useful
tool: source: components/my-tool
source: components/my-tool/
install: install:
path: path:
alias: my-tool 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 ## Lifecycle
@@ -254,19 +285,16 @@ uv tool install --editable components/my-tool/ # 4. Install to PATH
### Job lifecycle ### Job lifecycle
Jobs are tools or services with a schedule trigger. They need both `run` Jobs are defined in the `jobs:` section with a `run` spec and `schedule`:
(so castle knows how to execute them) and `manage.systemd` (so systemd
handles the timer):
```yaml ```yaml
my-job: jobs:
my-job:
description: Runs nightly description: Runs nightly
run: run:
runner: command runner: command
argv: ["my-job"] argv: ["my-job"]
triggers: schedule: "0 2 * * *"
- type: schedule
cron: "0 2 * * *"
manage: manage:
systemd: {} systemd: {}
``` ```
@@ -289,14 +317,16 @@ and a `.timer` file.
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes: The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
- `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) - `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
- `TriggerSpec` — union (TriggerSchedule, TriggerManual, TriggerEvent, TriggerRequest)
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec` - `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec`
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal` - `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
Config loading: `core/src/castle_core/config.py``load_config()` parses Config loading: `core/src/castle_core/config.py``load_config()` parses
castle.yaml into `CastleConfig` with typed `components` dict. castle.yaml into `CastleConfig` with typed `components`, `services`, and
`jobs` dicts.
Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer
generation (`systemd.py`) and Caddyfile generation (`caddyfile.py`). 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 Castle service is just a well-behaved Unix daemon that happens to
be registered in a manifest. be registered in a manifest.
3. **Declare capabilities, derive roles.** Components say what they 3. **Section is category.** Components, services, and jobs live in
do (expose HTTP, run on a schedule, install to PATH). Castle infers separate sections of `castle.yaml`. The section determines the
what they are (service, job, tool, frontend). No role labels. category — no role derivation needed.
4. **Language-agnostic above the build line.** Below the build line, 4. **Language-agnostic above the build line.** Below the build line,
every language is different (uv, pnpm, cargo, go). Above it, every language is different (uv, pnpm, cargo, go). Above it,
@@ -149,13 +149,17 @@ answers: "is it working?"
These map to two files: These map to two files:
**`castle.yaml`** (in the repo, version-controlled) — Component specs: **`castle.yaml`** (in the repo, version-controlled) — Three sections:
```yaml ```yaml
components: components:
central-context: central-context:
description: Content storage API description: Content storage API
source: components/central-context source: components/central-context
services:
central-context:
component: central-context
run: run:
runner: python runner: python
tool: central-context tool: central-context
@@ -168,12 +172,29 @@ components:
path_prefix: /central-context path_prefix: /central-context
manage: manage:
systemd: {} 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 Components define *what software exists* (identity, source, install, tools).
data directory, HTTP exposure. Convention-based env vars (`<PREFIX>_PORT`, Services define *how daemons run* (run config, expose, proxy, systemd).
`<PREFIX>_DATA_DIR`) are generated automatically during deploy. Only Jobs define *how scheduled tasks run* (run config, cron schedule, systemd).
non-convention values need `defaults.env`.
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: **`~/.castle/registry.yaml`** (per-node, not in the repo) — Node config:
@@ -189,7 +210,7 @@ deployed:
env: env:
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
CENTRAL_CONTEXT_PORT: "9001" CENTRAL_CONTEXT_PORT: "9001"
roles: [service] category: service
port: 9001 port: 9001
health_path: /health health_path: /health
proxy_path: /central-context proxy_path: /central-context

View File

@@ -317,28 +317,27 @@ uv run ruff format . # Format
components: components:
my-tool: my-tool:
description: Does something useful description: Does something useful
tool: source: components/my-tool
source: components/my-tool/
install: install:
path: path:
alias: my-tool alias: my-tool
``` ```
Tools with system dependencies declare them in the manifest: Tools with system dependencies declare them in the component:
```yaml ```yaml
components: components:
pdf2md: pdf2md:
description: Convert PDF files to Markdown description: Convert PDF files to Markdown
tool: source: components/pdf2md
source: components/pdf2md/
system_dependencies: [pandoc, poppler-utils]
install: install:
path: path:
alias: pdf2md alias: pdf2md
tool:
system_dependencies: [pandoc, poppler-utils]
``` ```
Tools with `install.path` get the **tool** role. They don't need `expose`, Tools live in the `components:` section. If a tool also runs on a schedule,
`proxy`, or `manage` blocks unless castle also runs them (e.g., scheduled jobs). 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 ```yaml
components: components:
my-service: my-service:
source: my-service description: Does something useful
source: components/my-service
services:
my-service:
component: my-service
run: run:
runner: python runner: python
tool: my-service 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 ## Registering as a castle component
A frontend component has a `build` spec (produces static output) and optionally A frontend component has a `build` spec (produces static output). Register it
a `proxy` spec (Caddy serves the built files). No `run` block needed if Caddy in the `components:` section of `castle.yaml`. No `run` block needed if Caddy
handles serving directly from the build output. handles serving directly from the build output.
```yaml ```yaml
@@ -116,45 +116,33 @@ handles serving directly from the build output.
components: components:
my-frontend: my-frontend:
description: Web dashboard description: Web dashboard
source: my-frontend
build: build:
commands: commands:
- ["pnpm", "build"] - ["pnpm", "build"]
outputs: outputs:
- dist/ - 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 ```yaml
components: services:
my-frontend: my-frontend:
description: Web dashboard component: my-frontend
run: run:
runner: node runner: node
script: dev script: dev
package_manager: pnpm package_manager: pnpm
cwd: my-frontend
build:
commands:
- ["pnpm", "build"]
outputs:
- dist/
expose: expose:
http: http:
internal: { port: 5173 } internal: { port: 5173 }
proxy: proxy:
caddy: caddy: { path_prefix: /app }
path_prefix: /app
``` ```
This gives the component both the `frontend` role (from `build`) and the See @docs/component-registry.md for the full registry reference.
`service` role (from `expose.http`) during development.
See @docs/component-registry.md for the full manifest reference and role
derivation rules.
## Serving with Caddy ## Serving with Caddy