refactor: Decouple roles.
This commit is contained in:
28
CLAUDE.md
28
CLAUDE.md
@@ -6,19 +6,25 @@ with code in this repository.
|
||||
## Overview
|
||||
|
||||
Castle is a personal software platform — a monorepo of independent projects
|
||||
(services, tools, libraries) managed by the `castle` CLI. Components declare
|
||||
**what they do** (expose HTTP, manage via systemd, install to PATH) and roles
|
||||
are **derived**, not labeled.
|
||||
(services, tools, libraries) managed by the `castle` CLI. The registry
|
||||
(`castle.yaml`) has three top-level sections:
|
||||
|
||||
- **`components:`** — Software catalog (source, install, tool metadata, build)
|
||||
- **`services:`** — Long-running daemons (run, expose, proxy, systemd)
|
||||
- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer)
|
||||
|
||||
The section determines the category — no role derivation. Services and jobs
|
||||
can reference a component via `component:` for description fallthrough.
|
||||
|
||||
**Key principle:** Regular projects must never depend on castle. They accept standard
|
||||
configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway,
|
||||
event bus) know about castle internals.
|
||||
configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway)
|
||||
know about castle internals.
|
||||
|
||||
## Creating Components
|
||||
|
||||
When creating a new service, tool, or frontend, follow the detailed guides:
|
||||
|
||||
- @docs/component-registry.md — Manifest architecture, castle.yaml structure, role derivation, lifecycle
|
||||
- @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle
|
||||
- @docs/web-apis.md — FastAPI service patterns (config, routes, models, testing)
|
||||
- @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
|
||||
- @docs/web-frontends.md — React/Vite/TypeScript frontend patterns
|
||||
@@ -39,7 +45,7 @@ cd components/my-tool && uv sync
|
||||
```
|
||||
|
||||
The `castle create` command scaffolds the project under `components/`,
|
||||
generates a CLAUDE.md, and registers it in `castle.yaml` as a `ComponentManifest`.
|
||||
generates a CLAUDE.md, and registers it in `castle.yaml`.
|
||||
|
||||
## Castle CLI
|
||||
|
||||
@@ -47,8 +53,8 @@ The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`.
|
||||
|
||||
```bash
|
||||
castle list # List all components
|
||||
castle list --role service # Filter by derived role
|
||||
castle info <component> # Show manifest details (--json for machine-readable)
|
||||
castle list --type service # Filter by category
|
||||
castle info <component> # Show details (--json for machine-readable)
|
||||
castle create <name> --type service # Scaffold new project
|
||||
castle test [project] # Run tests (one or all)
|
||||
castle lint [project] # Run linter (one or all)
|
||||
@@ -93,8 +99,8 @@ Services also support: `uv run <service-name>` to start.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `castle.yaml` — Component registry (single source of truth)
|
||||
- `core/src/castle_core/manifest.py` — Pydantic models (ComponentManifest, RunSpec, etc.)
|
||||
- `castle.yaml` — Component registry (three sections: components, services, jobs)
|
||||
- `core/src/castle_core/manifest.py` — Pydantic models (ComponentSpec, ServiceSpec, JobSpec, RunSpec)
|
||||
- `core/src/castle_core/config.py` — Config loader (castle.yaml → CastleConfig)
|
||||
- `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation
|
||||
- `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates
|
||||
|
||||
@@ -27,15 +27,13 @@ const TEMPLATES: Record<string, Record<string, unknown>> = {
|
||||
path: { alias: "" },
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
job: {
|
||||
run: {
|
||||
runner: "command",
|
||||
argv: [""],
|
||||
cwd: "",
|
||||
},
|
||||
manage: {
|
||||
systemd: {},
|
||||
},
|
||||
schedule: "0 2 * * *",
|
||||
},
|
||||
empty: {},
|
||||
}
|
||||
@@ -145,7 +143,7 @@ export function AddComponent({ onAdd, existingNames }: AddComponentProps) {
|
||||
>
|
||||
<option value="service">Service (FastAPI + systemd + Caddy)</option>
|
||||
<option value="tool">Tool (PATH install)</option>
|
||||
<option value="worker">Worker (systemd, no HTTP)</option>
|
||||
<option value="job">Job (scheduled task)</option>
|
||||
<option value="empty">Empty</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
@@ -38,9 +38,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1 mb-2">
|
||||
{component.roles.map((role) => (
|
||||
<RoleBadge key={role} role={role} />
|
||||
))}
|
||||
<RoleBadge role={component.category} />
|
||||
</div>
|
||||
|
||||
{component.description && (
|
||||
|
||||
@@ -25,9 +25,7 @@ export function ComponentEditor({ component, onSave, onDelete }: ComponentEditor
|
||||
<span className="text-sm text-[var(--muted)]">{component.description}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{component.roles.map((r) => (
|
||||
<RoleBadge key={r} role={r} />
|
||||
))}
|
||||
<RoleBadge role={component.category} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields
|
||||
try {
|
||||
const config: Record<string, unknown> = { ...m }
|
||||
delete config.id
|
||||
delete config.roles
|
||||
delete config.category
|
||||
config.description = description || undefined
|
||||
|
||||
// Merge plain env + secret references back together
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
import type { ComponentSummary, HealthStatus } from "@/types"
|
||||
import { CATEGORY_LABELS } from "@/lib/labels"
|
||||
import { ComponentCard } from "./ComponentCard"
|
||||
|
||||
const ROLE_ORDER = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"]
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
service: "Services",
|
||||
tool: "Tools",
|
||||
worker: "Workers",
|
||||
job: "Jobs",
|
||||
frontend: "Frontends",
|
||||
remote: "Remote",
|
||||
containerized: "Containers",
|
||||
}
|
||||
const CATEGORY_ORDER = ["service", "job", "tool", "frontend", "component"]
|
||||
|
||||
interface ComponentGridProps {
|
||||
components: ComponentSummary[]
|
||||
@@ -20,24 +12,24 @@ interface ComponentGridProps {
|
||||
export function ComponentGrid({ components, statuses }: ComponentGridProps) {
|
||||
const statusMap = new Map(statuses.map((s) => [s.id, s]))
|
||||
|
||||
// Group by primary role
|
||||
// Group by category
|
||||
const groups = new Map<string, ComponentSummary[]>()
|
||||
for (const comp of components) {
|
||||
const primary = comp.roles[0] ?? "tool"
|
||||
const list = groups.get(primary) ?? []
|
||||
const cat = comp.category
|
||||
const list = groups.get(cat) ?? []
|
||||
list.push(comp)
|
||||
groups.set(primary, list)
|
||||
groups.set(cat, list)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{ROLE_ORDER.map((role) => {
|
||||
const items = groups.get(role)
|
||||
{CATEGORY_ORDER.map((cat) => {
|
||||
const items = groups.get(cat)
|
||||
if (!items?.length) return null
|
||||
return (
|
||||
<section key={role}>
|
||||
<section key={cat}>
|
||||
<h2 className="text-lg font-semibold mb-3 text-[var(--muted)]">
|
||||
{ROLE_LABELS[role] ?? role}
|
||||
{CATEGORY_LABELS[cat] ?? cat}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((comp) => (
|
||||
|
||||
@@ -12,7 +12,7 @@ interface ComponentTableProps {
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
type SortKey = "id" | "role" | "runner" | "schedule" | "port" | "status"
|
||||
type SortKey = "id" | "category" | "runner" | "schedule" | "port" | "status"
|
||||
type SortDir = "asc" | "desc"
|
||||
|
||||
function statusRank(s: HealthStatus | undefined, installed: boolean | null): number {
|
||||
@@ -30,13 +30,13 @@ function statusRank(s: HealthStatus | undefined, installed: boolean | null): num
|
||||
|
||||
export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses])
|
||||
const allRoles = useMemo(() => {
|
||||
const allCategories = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const c of components) for (const r of c.roles) set.add(r)
|
||||
for (const c of components) set.add(c.category)
|
||||
return Array.from(set).sort()
|
||||
}, [components])
|
||||
|
||||
const [roleFilter, setRoleFilter] = useState<string | null>(null)
|
||||
const [categoryFilter, setCategoryFilter] = useState<string | null>(null)
|
||||
const [search, setSearch] = useState("")
|
||||
const [sortKey, setSortKey] = useState<SortKey>("id")
|
||||
const [sortDir, setSortDir] = useState<SortDir>("asc")
|
||||
@@ -52,8 +52,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let list = components
|
||||
if (roleFilter) {
|
||||
list = list.filter((c) => c.roles.includes(roleFilter))
|
||||
if (categoryFilter) {
|
||||
list = list.filter((c) => c.category === categoryFilter)
|
||||
}
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
@@ -64,7 +64,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
)
|
||||
}
|
||||
return list
|
||||
}, [components, roleFilter, search])
|
||||
}, [components, categoryFilter, search])
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const dir = sortDir === "asc" ? 1 : -1
|
||||
@@ -72,8 +72,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
switch (sortKey) {
|
||||
case "id":
|
||||
return dir * a.id.localeCompare(b.id)
|
||||
case "role":
|
||||
return dir * (a.roles[0] ?? "").localeCompare(b.roles[0] ?? "")
|
||||
case "category":
|
||||
return dir * a.category.localeCompare(b.category)
|
||||
case "runner":
|
||||
return dir * (a.runner ?? "").localeCompare(b.runner ?? "")
|
||||
case "schedule":
|
||||
@@ -100,26 +100,26 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
onClick={() => setRoleFilter(null)}
|
||||
onClick={() => setCategoryFilter(null)}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
roleFilter === null
|
||||
categoryFilter === null
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{allRoles.map((role) => (
|
||||
{allCategories.map((cat) => (
|
||||
<button
|
||||
key={role}
|
||||
onClick={() => setRoleFilter(roleFilter === role ? null : role)}
|
||||
key={cat}
|
||||
onClick={() => setCategoryFilter(categoryFilter === cat ? null : cat)}
|
||||
className={`text-xs px-2 py-1 rounded transition-colors ${
|
||||
roleFilter === role
|
||||
categoryFilter === cat
|
||||
? "bg-[var(--primary)] text-white"
|
||||
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
{role}
|
||||
{cat}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -131,7 +131,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) {
|
||||
<thead>
|
||||
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
|
||||
<SortHeader label="Name" sortKey="id" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Roles" sortKey="role" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Category" sortKey="category" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Runner" sortKey="runner" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Schedule" sortKey="schedule" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
<SortHeader label="Port" sortKey="port" current={sortKey} dir={sortDir} onSort={toggleSort} />
|
||||
@@ -233,11 +233,7 @@ function ComponentRow({
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{component.roles.map((role) => (
|
||||
<RoleBadge key={role} role={role} />
|
||||
))}
|
||||
</div>
|
||||
<RoleBadge role={component.category} />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{component.runner ? runnerLabel(component.runner) : "—"}
|
||||
|
||||
105
app/src/components/JobSection.tsx
Normal file
105
app/src/components/JobSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ROLE_DESCRIPTIONS } from "@/lib/labels"
|
||||
import { CATEGORY_DESCRIPTIONS } from "@/lib/labels"
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
const categoryColors: Record<string, string> = {
|
||||
service: "bg-green-700 text-white",
|
||||
tool: "bg-blue-700 text-white",
|
||||
worker: "bg-blue-500 text-white",
|
||||
job: "bg-purple-700 text-white",
|
||||
tool: "bg-blue-700 text-white",
|
||||
frontend: "bg-yellow-600 text-black",
|
||||
remote: "bg-gray-600 text-gray-200",
|
||||
containerized: "bg-orange-600 text-black",
|
||||
component: "bg-gray-600 text-gray-200",
|
||||
}
|
||||
|
||||
export function RoleBadge({ role }: { role: string }) {
|
||||
@@ -16,9 +14,9 @@ export function RoleBadge({ role }: { role: string }) {
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block text-[0.65rem] font-semibold uppercase px-1.5 py-0.5 rounded",
|
||||
roleColors[role] ?? "bg-gray-600 text-gray-200",
|
||||
categoryColors[role] ?? "bg-gray-600 text-gray-200",
|
||||
)}
|
||||
title={ROLE_DESCRIPTIONS[role]}
|
||||
title={CATEGORY_DESCRIPTIONS[role]}
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
|
||||
11
app/src/components/SectionHeader.tsx
Normal file
11
app/src/components/SectionHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
24
app/src/components/ServiceSection.tsx
Normal file
24
app/src/components/ServiceSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
app/src/components/SortHeader.tsx
Normal file
48
app/src/components/SortHeader.tsx
Normal 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
|
||||
}
|
||||
120
app/src/components/ToolSection.tsx
Normal file
120
app/src/components/ToolSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -6,14 +6,28 @@ export const RUNNER_LABELS: Record<string, string> = {
|
||||
remote: "Remote",
|
||||
}
|
||||
|
||||
export const ROLE_DESCRIPTIONS: Record<string, string> = {
|
||||
service: "Exposes HTTP endpoints",
|
||||
export const CATEGORY_LABELS: Record<string, string> = {
|
||||
service: "Services",
|
||||
job: "Jobs",
|
||||
tool: "Tools",
|
||||
frontend: "Frontends",
|
||||
component: "Components",
|
||||
}
|
||||
|
||||
export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
|
||||
service: "Long-running daemon",
|
||||
job: "Scheduled task",
|
||||
tool: "CLI utility installed to PATH",
|
||||
worker: "Background process (no HTTP)",
|
||||
job: "Runs on a schedule",
|
||||
frontend: "Built static assets",
|
||||
remote: "Hosted externally",
|
||||
containerized: "Runs in a container",
|
||||
component: "Software component",
|
||||
}
|
||||
|
||||
export const SECTION_HEADERS: Record<string, { title: string; subtitle: string }> = {
|
||||
service: { title: "Services", subtitle: "Long-running daemons managed by systemd" },
|
||||
job: { title: "Jobs", subtitle: "Scheduled tasks with cron timers" },
|
||||
tool: { title: "Tools", subtitle: "CLI utilities installed to PATH" },
|
||||
frontend: { title: "Frontends", subtitle: "Built web applications" },
|
||||
component: { title: "Other", subtitle: "Software catalog entries" },
|
||||
}
|
||||
|
||||
export function runnerLabel(runner: string): string {
|
||||
|
||||
@@ -20,7 +20,7 @@ export function ComponentDetailPage() {
|
||||
const { mutate, isPending } = useServiceAction()
|
||||
const health = statusResp?.statuses.find((s) => s.id === name)
|
||||
const isDown = health?.status === "down"
|
||||
const isTool = component?.roles.includes("tool") ?? false
|
||||
const isTool = component?.category === "tool"
|
||||
const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "")
|
||||
const isGateway = name === "castle-gateway"
|
||||
const { data: caddyfile } = useCaddyfile(isGateway)
|
||||
@@ -28,10 +28,14 @@ export function ComponentDetailPage() {
|
||||
const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd)
|
||||
const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null)
|
||||
|
||||
const configSection = component?.category === "service" ? "services"
|
||||
: component?.category === "job" ? "jobs"
|
||||
: "components"
|
||||
|
||||
const handleSave = async (compName: string, config: Record<string, unknown>) => {
|
||||
setMessage(null)
|
||||
try {
|
||||
await apiClient.put(`/config/components/${compName}`, { config })
|
||||
await apiClient.put(`/config/${configSection}/${compName}`, { config })
|
||||
setMessage({ type: "ok", text: "Saved to castle.yaml" })
|
||||
refetch()
|
||||
qc.invalidateQueries({ queryKey: ["components"] })
|
||||
@@ -43,7 +47,7 @@ export function ComponentDetailPage() {
|
||||
|
||||
const handleDelete = async (compName: string) => {
|
||||
try {
|
||||
await apiClient.delete(`/config/components/${compName}`)
|
||||
await apiClient.delete(`/config/${configSection}/${compName}`)
|
||||
qc.invalidateQueries({ queryKey: ["components"] })
|
||||
navigate("/")
|
||||
} catch (e: unknown) {
|
||||
@@ -116,10 +120,11 @@ export function ComponentDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 mb-6">
|
||||
{component.roles.map((role) => (
|
||||
<RoleBadge key={role} role={role} />
|
||||
))}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<RoleBadge role={component.category} />
|
||||
{component.source && (
|
||||
<span className="text-sm text-[var(--muted)] font-mono">{component.source}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { ComponentTable } from "@/components/ComponentTable"
|
||||
import { useMemo } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks"
|
||||
import { ServiceSection } from "@/components/ServiceSection"
|
||||
import { JobSection } from "@/components/JobSection"
|
||||
import { ToolSection } from "@/components/ToolSection"
|
||||
import { SectionHeader } from "@/components/SectionHeader"
|
||||
|
||||
export function Dashboard() {
|
||||
useEventStream()
|
||||
@@ -7,6 +12,20 @@ export function Dashboard() {
|
||||
const { data: statusResp } = useStatus()
|
||||
const { data: gateway } = useGateway()
|
||||
|
||||
const { services, jobs, tools, frontends, other } = useMemo(() => {
|
||||
const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components }
|
||||
for (const c of components ?? []) {
|
||||
if (c.category === "service") s.services!.push(c)
|
||||
else if (c.category === "job") s.jobs!.push(c)
|
||||
else if (c.category === "tool") s.tools!.push(c)
|
||||
else if (c.category === "frontend") s.frontends!.push(c)
|
||||
else s.other!.push(c)
|
||||
}
|
||||
return { services: s.services!, jobs: s.jobs!, tools: s.tools!, frontends: s.frontends!, other: s.other! }
|
||||
}, [components])
|
||||
|
||||
const statuses = statusResp?.statuses ?? []
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<div className="mb-8">
|
||||
@@ -15,21 +34,79 @@ export function Dashboard() {
|
||||
Personal software platform
|
||||
{gateway && (
|
||||
<span className="ml-2 text-sm">
|
||||
· {gateway.component_count} components · port {gateway.port}
|
||||
· {services.length} services · {jobs.length} jobs
|
||||
· {tools.length} tools · port {gateway.port}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="text-[var(--muted)]">Loading components...</p>
|
||||
) : components ? (
|
||||
<ComponentTable
|
||||
components={components}
|
||||
statuses={statusResp?.statuses ?? []}
|
||||
/>
|
||||
<p className="text-[var(--muted)]">Loading...</p>
|
||||
) : (
|
||||
<p className="text-red-400">Failed to load components</p>
|
||||
<div className="space-y-10">
|
||||
{services.length > 0 && (
|
||||
<ServiceSection services={services} statuses={statuses} />
|
||||
)}
|
||||
{jobs.length > 0 && (
|
||||
<JobSection jobs={jobs} />
|
||||
)}
|
||||
{tools.length > 0 && (
|
||||
<ToolSection tools={tools} />
|
||||
)}
|
||||
{frontends.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader category="frontend" />
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{frontends.map((fe) => (
|
||||
<tr key={fe.id} className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
|
||||
<td className="px-3 py-2.5">
|
||||
<Link
|
||||
to={`/component/${fe.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{fe.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{fe.description ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{other.length > 0 && (
|
||||
<section>
|
||||
<SectionHeader category="component" />
|
||||
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<tbody>
|
||||
{other.map((c) => (
|
||||
<tr key={c.id} className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors">
|
||||
<td className="px-3 py-2.5">
|
||||
<Link
|
||||
to={`/component/${c.id}`}
|
||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||
>
|
||||
{c.id}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--muted)]">
|
||||
{c.description ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ export interface SystemdInfo {
|
||||
export interface ComponentSummary {
|
||||
id: string
|
||||
description: string | null
|
||||
roles: string[]
|
||||
category: string
|
||||
runner: string | null
|
||||
port: number | null
|
||||
health_path: string | null
|
||||
|
||||
@@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from castle_core.config import save_config
|
||||
from castle_core.manifest import ComponentManifest
|
||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
||||
|
||||
from castle_api.config import get_castle_root, get_config, get_registry
|
||||
from castle_api.stream import broadcast
|
||||
@@ -29,6 +29,8 @@ class ConfigSaveRequest(BaseModel):
|
||||
class ConfigSaveResponse(BaseModel):
|
||||
ok: bool
|
||||
component_count: int
|
||||
service_count: int
|
||||
job_count: int
|
||||
errors: list[str]
|
||||
|
||||
|
||||
@@ -42,6 +44,14 @@ class ComponentConfigRequest(BaseModel):
|
||||
config: dict
|
||||
|
||||
|
||||
class ServiceConfigRequest(BaseModel):
|
||||
config: dict
|
||||
|
||||
|
||||
class JobConfigRequest(BaseModel):
|
||||
config: dict
|
||||
|
||||
|
||||
def _require_repo() -> None:
|
||||
"""Raise 503 if repo is not available."""
|
||||
if get_castle_root() is None:
|
||||
@@ -76,22 +86,44 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
detail=f"Invalid YAML: {e}",
|
||||
)
|
||||
|
||||
if not isinstance(data, dict) or "components" not in data:
|
||||
if not isinstance(data, dict):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="YAML must have a 'components' key",
|
||||
detail="YAML must be a mapping",
|
||||
)
|
||||
|
||||
# Validate each component
|
||||
count = 0
|
||||
# Validate components
|
||||
comp_count = 0
|
||||
for name, comp_data in data.get("components", {}).items():
|
||||
try:
|
||||
comp_data_copy = dict(comp_data) if comp_data else {}
|
||||
comp_data_copy["id"] = name
|
||||
ComponentManifest.model_validate(comp_data_copy)
|
||||
count += 1
|
||||
ComponentSpec.model_validate(comp_data_copy)
|
||||
comp_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"{name}: {e}")
|
||||
errors.append(f"components.{name}: {e}")
|
||||
|
||||
# Validate services
|
||||
svc_count = 0
|
||||
for name, svc_data in data.get("services", {}).items():
|
||||
try:
|
||||
svc_data_copy = dict(svc_data) if svc_data else {}
|
||||
svc_data_copy["id"] = name
|
||||
ServiceSpec.model_validate(svc_data_copy)
|
||||
svc_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"services.{name}: {e}")
|
||||
|
||||
# Validate jobs
|
||||
job_count = 0
|
||||
for name, job_data in data.get("jobs", {}).items():
|
||||
try:
|
||||
job_data_copy = dict(job_data) if job_data else {}
|
||||
job_data_copy["id"] = name
|
||||
JobSpec.model_validate(job_data_copy)
|
||||
job_count += 1
|
||||
except Exception as e:
|
||||
errors.append(f"jobs.{name}: {e}")
|
||||
|
||||
if errors:
|
||||
raise HTTPException(
|
||||
@@ -105,7 +137,10 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse:
|
||||
shutil.copy2(config_path, backup_path)
|
||||
config_path.write_text(request.yaml_content)
|
||||
|
||||
return ConfigSaveResponse(ok=True, component_count=count, errors=[])
|
||||
return ConfigSaveResponse(
|
||||
ok=True, component_count=comp_count, service_count=svc_count,
|
||||
job_count=job_count, errors=[],
|
||||
)
|
||||
|
||||
|
||||
@router.put("/components/{name}")
|
||||
@@ -113,11 +148,10 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
|
||||
"""Update a single component's config in castle.yaml."""
|
||||
_require_repo()
|
||||
|
||||
# Validate
|
||||
try:
|
||||
comp_data = dict(request.config)
|
||||
comp_data["id"] = name
|
||||
ComponentManifest.model_validate(comp_data)
|
||||
ComponentSpec.model_validate(comp_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
@@ -125,7 +159,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict:
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
config.components[name] = ComponentManifest.model_validate(
|
||||
config.components[name] = ComponentSpec.model_validate(
|
||||
{**request.config, "id": name}
|
||||
)
|
||||
save_config(config)
|
||||
@@ -146,6 +180,80 @@ def delete_component(name: str) -> dict:
|
||||
return {"ok": True, "component": name, "action": "deleted"}
|
||||
|
||||
|
||||
@router.put("/services/{name}")
|
||||
def save_service(name: str, request: ServiceConfigRequest) -> dict:
|
||||
"""Update a single service's config in castle.yaml."""
|
||||
_require_repo()
|
||||
|
||||
try:
|
||||
svc_data = dict(request.config)
|
||||
svc_data["id"] = name
|
||||
ServiceSpec.model_validate(svc_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid service config: {e}",
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
config.services[name] = ServiceSpec.model_validate(
|
||||
{**request.config, "id": name}
|
||||
)
|
||||
save_config(config)
|
||||
return {"ok": True, "service": name}
|
||||
|
||||
|
||||
@router.delete("/services/{name}")
|
||||
def delete_service(name: str) -> dict:
|
||||
"""Remove a service from castle.yaml."""
|
||||
config = get_config()
|
||||
if name not in config.services:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Service '{name}' not found",
|
||||
)
|
||||
del config.services[name]
|
||||
save_config(config)
|
||||
return {"ok": True, "service": name, "action": "deleted"}
|
||||
|
||||
|
||||
@router.put("/jobs/{name}")
|
||||
def save_job(name: str, request: JobConfigRequest) -> dict:
|
||||
"""Update a single job's config in castle.yaml."""
|
||||
_require_repo()
|
||||
|
||||
try:
|
||||
job_data = dict(request.config)
|
||||
job_data["id"] = name
|
||||
JobSpec.model_validate(job_data)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid job config: {e}",
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
config.jobs[name] = JobSpec.model_validate(
|
||||
{**request.config, "id": name}
|
||||
)
|
||||
save_config(config)
|
||||
return {"ok": True, "job": name}
|
||||
|
||||
|
||||
@router.delete("/jobs/{name}")
|
||||
def delete_job(name: str) -> dict:
|
||||
"""Remove a job from castle.yaml."""
|
||||
config = get_config()
|
||||
if name not in config.jobs:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Job '{name}' not found",
|
||||
)
|
||||
del config.jobs[name]
|
||||
save_config(config)
|
||||
return {"ok": True, "job": name, "action": "deleted"}
|
||||
|
||||
|
||||
@router.post("/apply", response_model=ApplyResponse)
|
||||
async def apply_config() -> ApplyResponse:
|
||||
"""Apply config: restart managed services + regenerate and reload gateway."""
|
||||
|
||||
@@ -8,9 +8,7 @@ from collections.abc import AsyncGenerator
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
from castle_core.config import load_config
|
||||
|
||||
from castle_api.config import settings
|
||||
from castle_api.config import get_castle_root
|
||||
|
||||
router = APIRouter(prefix="/logs", tags=["logs"])
|
||||
|
||||
@@ -24,12 +22,25 @@ async def get_logs(
|
||||
follow: bool = Query(default=False, description="Stream new lines via SSE"),
|
||||
) -> StreamingResponse | dict:
|
||||
"""Get logs for a systemd-managed service."""
|
||||
config = load_config(settings.castle_root)
|
||||
if name not in config.managed:
|
||||
root = get_castle_root()
|
||||
if root:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
is_managed = (
|
||||
(name in config.services and config.services[name].manage is not None)
|
||||
or (name in config.jobs and config.jobs[name].manage is not None)
|
||||
)
|
||||
if not is_managed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"'{name}' is not a managed service",
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Castle root not available",
|
||||
)
|
||||
|
||||
unit = f"{UNIT_PREFIX}{name}.service"
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class ComponentSummary(BaseModel):
|
||||
|
||||
id: str
|
||||
description: str | None = None
|
||||
roles: list[str]
|
||||
category: str
|
||||
runner: str | None = None
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
||||
|
||||
from castle_api.config import get_castle_root, get_registry
|
||||
from castle_api.health import check_all_health
|
||||
@@ -39,13 +40,13 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
|
||||
|
||||
# Check if tool is installed on PATH
|
||||
installed: bool | None = None
|
||||
if "tool" in deployed.roles:
|
||||
if deployed.category == "tool":
|
||||
installed = shutil.which(name) is not None
|
||||
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
description=deployed.description,
|
||||
roles=deployed.roles,
|
||||
category=deployed.category,
|
||||
runner=deployed.runner,
|
||||
port=deployed.port,
|
||||
health_path=deployed.health_path,
|
||||
@@ -57,67 +58,116 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
|
||||
)
|
||||
|
||||
|
||||
def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a manifest (for non-deployed components)."""
|
||||
def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a ServiceSpec (non-deployed)."""
|
||||
port = None
|
||||
health_path = None
|
||||
proxy_path = None
|
||||
if manifest.expose and manifest.expose.http:
|
||||
port = manifest.expose.http.internal.port
|
||||
health_path = manifest.expose.http.health_path
|
||||
if manifest.proxy and manifest.proxy.caddy:
|
||||
proxy_path = manifest.proxy.caddy.path_prefix
|
||||
if svc.expose and svc.expose.http:
|
||||
port = svc.expose.http.internal.port
|
||||
health_path = svc.expose.http.health_path
|
||||
if svc.proxy and svc.proxy.caddy:
|
||||
proxy_path = svc.proxy.caddy.path_prefix
|
||||
|
||||
managed = bool(
|
||||
manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable
|
||||
)
|
||||
managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable)
|
||||
|
||||
systemd_info: SystemdInfo | None = None
|
||||
if managed:
|
||||
unit_name = f"castle-{name}.service"
|
||||
unit_path = str(Path("~/.config/systemd/user") / unit_name)
|
||||
has_timer = any(
|
||||
getattr(t, "type", None) == "schedule" for t in manifest.triggers
|
||||
)
|
||||
systemd_info = SystemdInfo(
|
||||
unit_name=unit_name,
|
||||
unit_path=unit_path,
|
||||
timer=has_timer,
|
||||
)
|
||||
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=False)
|
||||
|
||||
schedule = None
|
||||
for t in manifest.triggers:
|
||||
if t.type == "schedule":
|
||||
schedule = t.cron
|
||||
break
|
||||
description = svc.description
|
||||
source = None
|
||||
if svc.component and svc.component in config.components:
|
||||
comp = config.components[svc.component]
|
||||
if not description:
|
||||
description = comp.description
|
||||
source = comp.source
|
||||
|
||||
runner = manifest.run.runner if manifest.run else None
|
||||
if runner is None and manifest.tool and manifest.tool.source:
|
||||
source_dir = root / manifest.tool.source
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
runner = "python"
|
||||
elif source_dir.is_file():
|
||||
runner = "command"
|
||||
|
||||
installed: bool | None = None
|
||||
if manifest.install and manifest.install.path:
|
||||
alias = manifest.install.path.alias or name
|
||||
installed = shutil.which(alias) is not None
|
||||
runner = svc.run.runner
|
||||
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
description=manifest.description,
|
||||
roles=[r.value for r in manifest.roles],
|
||||
description=description,
|
||||
category="service",
|
||||
runner=runner,
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
proxy_path=proxy_path,
|
||||
managed=managed,
|
||||
systemd=systemd_info,
|
||||
version=manifest.tool.version if manifest.tool else None,
|
||||
source=manifest.tool.source if manifest.tool else None,
|
||||
system_dependencies=manifest.tool.system_dependencies if manifest.tool else [],
|
||||
schedule=schedule,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a JobSpec (non-deployed)."""
|
||||
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
|
||||
|
||||
systemd_info: SystemdInfo | None = None
|
||||
if managed:
|
||||
unit_name = f"castle-{name}.service"
|
||||
unit_path = str(Path("~/.config/systemd/user") / unit_name)
|
||||
systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=True)
|
||||
|
||||
description = job.description
|
||||
source = None
|
||||
if job.component and job.component in config.components:
|
||||
comp = config.components[job.component]
|
||||
if not description:
|
||||
description = comp.description
|
||||
source = comp.source
|
||||
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
description=description,
|
||||
category="job",
|
||||
runner=job.run.runner,
|
||||
managed=managed,
|
||||
systemd=systemd_info,
|
||||
schedule=job.schedule,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary:
|
||||
"""Build a ComponentSummary from a ComponentSpec (tools/frontends)."""
|
||||
# Determine category
|
||||
is_tool = bool((comp.install and comp.install.path) or comp.tool)
|
||||
is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands))
|
||||
|
||||
if is_tool:
|
||||
category = "tool"
|
||||
elif is_frontend:
|
||||
category = "frontend"
|
||||
else:
|
||||
category = "component"
|
||||
|
||||
source = comp.source
|
||||
|
||||
# Infer runner from source directory
|
||||
runner = None
|
||||
if source:
|
||||
source_dir = root / source
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
runner = "python"
|
||||
elif source_dir.is_file():
|
||||
runner = "command"
|
||||
|
||||
installed: bool | None = None
|
||||
if comp.install and comp.install.path:
|
||||
alias = comp.install.path.alias or name
|
||||
installed = shutil.which(alias) is not None
|
||||
|
||||
return ComponentSummary(
|
||||
id=name,
|
||||
description=comp.description,
|
||||
category=category,
|
||||
runner=runner,
|
||||
version=comp.tool.version if comp.tool else None,
|
||||
source=source,
|
||||
system_dependencies=comp.tool.system_dependencies if comp.tool else [],
|
||||
installed=installed,
|
||||
)
|
||||
|
||||
@@ -134,16 +184,49 @@ def list_components() -> list[ComponentSummary]:
|
||||
summaries.append(_summary_from_deployed(name, deployed))
|
||||
seen.add(name)
|
||||
|
||||
# Non-deployed components from castle.yaml (if repo available)
|
||||
# Non-deployed from castle.yaml (if repo available)
|
||||
root = get_castle_root()
|
||||
if root:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
for name, manifest in config.components.items():
|
||||
|
||||
# Services not in registry
|
||||
for name, svc in config.services.items():
|
||||
if name not in seen:
|
||||
summaries.append(_summary_from_manifest(name, manifest, root))
|
||||
summaries.append(_summary_from_service(name, svc, config))
|
||||
seen.add(name)
|
||||
|
||||
# Jobs not in registry
|
||||
for name, job in config.jobs.items():
|
||||
if name not in seen:
|
||||
summaries.append(_summary_from_job(name, job, config))
|
||||
seen.add(name)
|
||||
|
||||
# Backfill source from component refs for deployed items
|
||||
for s in summaries:
|
||||
if s.source is None and s.id in config.components:
|
||||
s.source = config.components[s.id].source
|
||||
elif s.source is None:
|
||||
# Check if a service/job references a component
|
||||
ref = None
|
||||
if s.id in config.services:
|
||||
ref = config.services[s.id].component
|
||||
elif s.id in config.jobs:
|
||||
ref = config.jobs[s.id].component
|
||||
if ref and ref in config.components:
|
||||
s.source = config.components[ref].source
|
||||
|
||||
# Components (tools/frontends) — always listed, even if a
|
||||
# service/job with the same name exists. A component is
|
||||
# "what software exists", services/jobs are "how it runs".
|
||||
for name, comp in config.components.items():
|
||||
summary = _summary_from_component(name, comp, root)
|
||||
# Skip if this exact category is already represented
|
||||
# (e.g. a deployed tool already in the list)
|
||||
if not any(s.id == name and s.category == summary.category for s in summaries):
|
||||
summaries.append(summary)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
@@ -158,6 +241,27 @@ def get_component(name: str) -> ComponentDetail:
|
||||
if name in registry.deployed:
|
||||
deployed = registry.deployed[name]
|
||||
summary = _summary_from_deployed(name, deployed)
|
||||
|
||||
# Backfill source from castle.yaml component ref
|
||||
root = get_castle_root()
|
||||
if root and summary.source is None:
|
||||
try:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
if name in config.components:
|
||||
summary.source = config.components[name].source
|
||||
else:
|
||||
ref = None
|
||||
if name in config.services:
|
||||
ref = config.services[name].component
|
||||
elif name in config.jobs:
|
||||
ref = config.jobs[name].component
|
||||
if ref and ref in config.components:
|
||||
summary.source = config.components[ref].source
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
raw = {
|
||||
"runner": deployed.runner,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
@@ -166,7 +270,7 @@ def get_component(name: str) -> ComponentDetail:
|
||||
"health_path": deployed.health_path,
|
||||
"proxy_path": deployed.proxy_path,
|
||||
"managed": deployed.managed,
|
||||
"roles": deployed.roles,
|
||||
"category": deployed.category,
|
||||
}
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
@@ -176,10 +280,23 @@ def get_component(name: str) -> ComponentDetail:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
summary = _summary_from_service(name, svc, config)
|
||||
raw = svc.model_dump(mode="json", exclude_none=True)
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
if name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
summary = _summary_from_job(name, job, config)
|
||||
raw = job.model_dump(mode="json", exclude_none=True)
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
if name in config.components:
|
||||
manifest = config.components[name]
|
||||
summary = _summary_from_manifest(name, manifest, root)
|
||||
raw = manifest.model_dump(mode="json", exclude_none=True)
|
||||
comp = config.components[name]
|
||||
summary = _summary_from_component(name, comp, root)
|
||||
raw = comp.model_dump(mode="json", exclude_none=True)
|
||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
||||
|
||||
raise HTTPException(
|
||||
|
||||
@@ -131,21 +131,29 @@ def get_unit(name: str) -> dict[str, str | None]:
|
||||
registry = get_registry()
|
||||
deployed = registry.deployed[name]
|
||||
|
||||
# Get systemd spec from manifest if repo available
|
||||
# Get systemd spec from config if repo available
|
||||
systemd_spec = None
|
||||
schedule = None
|
||||
description = None
|
||||
root = get_castle_root()
|
||||
manifest = None
|
||||
if root:
|
||||
from castle_core.config import load_config
|
||||
|
||||
config = load_config(root)
|
||||
if name in config.components:
|
||||
manifest = config.components[name]
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
systemd_spec = manifest.manage.systemd
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
if svc.manage and svc.manage.systemd:
|
||||
systemd_spec = svc.manage.systemd
|
||||
description = svc.description
|
||||
elif name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
if job.manage and job.manage.systemd:
|
||||
systemd_spec = job.manage.systemd
|
||||
schedule = job.schedule
|
||||
description = job.description
|
||||
|
||||
unit = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
timer = generate_timer(name, manifest) if manifest else None
|
||||
timer = generate_timer(name, schedule, description) if schedule else None
|
||||
return {"service": unit, "timer": timer}
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
|
||||
from castle_core.manifest import ComponentManifest
|
||||
from castle_core.manifest import ComponentSpec
|
||||
|
||||
from castle_api.config import get_config
|
||||
from castle_api.models import ToolDetail, ToolSummary
|
||||
@@ -15,20 +15,25 @@ from castle_api.models import ToolDetail, ToolSummary
|
||||
router = APIRouter(tags=["tools"])
|
||||
|
||||
|
||||
def _is_tool(comp: ComponentSpec) -> bool:
|
||||
"""Check if a component is a tool (has install.path or tool spec)."""
|
||||
return bool((comp.install and comp.install.path) or comp.tool)
|
||||
|
||||
|
||||
def _tool_summary(
|
||||
name: str, manifest: ComponentManifest, root: Path | None = None
|
||||
name: str, comp: ComponentSpec, root: Path | None = None
|
||||
) -> ToolSummary:
|
||||
"""Build a ToolSummary from a manifest that has a tool spec."""
|
||||
t = manifest.tool
|
||||
assert t is not None
|
||||
"""Build a ToolSummary from a ComponentSpec that is a tool."""
|
||||
t = comp.tool
|
||||
installed = bool(
|
||||
manifest.install and manifest.install.path and manifest.install.path.enable
|
||||
comp.install and comp.install.path and comp.install.path.enable
|
||||
)
|
||||
|
||||
# Infer runner from run block or source directory
|
||||
runner = manifest.run.runner if manifest.run else None
|
||||
if runner is None and t.source and root:
|
||||
source_dir = root / t.source
|
||||
# Infer runner from source directory
|
||||
runner = None
|
||||
source = comp.source
|
||||
if source and root:
|
||||
source_dir = root / source
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
runner = "python"
|
||||
elif source_dir.is_file():
|
||||
@@ -36,11 +41,11 @@ def _tool_summary(
|
||||
|
||||
return ToolSummary(
|
||||
id=name,
|
||||
description=manifest.description,
|
||||
source=t.source,
|
||||
version=t.version,
|
||||
description=comp.description,
|
||||
source=source,
|
||||
version=t.version if t else None,
|
||||
runner=runner,
|
||||
system_dependencies=t.system_dependencies,
|
||||
system_dependencies=t.system_dependencies if t else [],
|
||||
installed=installed,
|
||||
)
|
||||
|
||||
@@ -49,12 +54,12 @@ def _tool_summary(
|
||||
def list_tools() -> list[ToolSummary]:
|
||||
"""List all registered tools (requires repo access)."""
|
||||
config = get_config()
|
||||
tools = {k: v for k, v in config.components.items() if v.tool}
|
||||
tools = {k: v for k, v in config.components.items() if _is_tool(v)}
|
||||
|
||||
return sorted(
|
||||
[
|
||||
_tool_summary(name, manifest, config.root)
|
||||
for name, manifest in tools.items()
|
||||
_tool_summary(name, comp, config.root)
|
||||
for name, comp in tools.items()
|
||||
],
|
||||
key=lambda t: t.id,
|
||||
)
|
||||
@@ -71,14 +76,14 @@ def get_tool(name: str) -> ToolDetail:
|
||||
detail=f"Component '{name}' not found",
|
||||
)
|
||||
|
||||
manifest = config.components[name]
|
||||
if not manifest.tool:
|
||||
comp = config.components[name]
|
||||
if not _is_tool(comp):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"'{name}' is not a tool",
|
||||
)
|
||||
|
||||
summary = _tool_summary(name, manifest, config.root)
|
||||
summary = _tool_summary(name, comp, config.root)
|
||||
return ToolDetail(**summary.model_dump())
|
||||
|
||||
|
||||
@@ -91,16 +96,16 @@ async def install_tool(name: str) -> dict:
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
||||
)
|
||||
|
||||
manifest = config.components[name]
|
||||
if not manifest.tool or not manifest.tool.source:
|
||||
comp = config.components[name]
|
||||
if not comp.source:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"'{name}' has no tool source to install"
|
||||
status_code=400, detail=f"'{name}' has no source to install"
|
||||
)
|
||||
|
||||
source_dir = config.root / manifest.tool.source
|
||||
source_dir = config.root / comp.source
|
||||
if not (source_dir / "pyproject.toml").exists():
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}"
|
||||
status_code=400, detail=f"No pyproject.toml in {comp.source}"
|
||||
)
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
@@ -131,12 +136,12 @@ async def uninstall_tool(name: str) -> dict:
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found"
|
||||
)
|
||||
|
||||
manifest = config.components[name]
|
||||
if not manifest.tool or not manifest.tool.source:
|
||||
raise HTTPException(status_code=400, detail=f"'{name}' has no tool source")
|
||||
comp = config.components[name]
|
||||
if not comp.source:
|
||||
raise HTTPException(status_code=400, detail=f"'{name}' has no source")
|
||||
|
||||
# uv tool uninstall uses the package name from pyproject.toml
|
||||
source_dir = config.root / manifest.tool.source
|
||||
source_dir = config.root / comp.source
|
||||
pkg_name = source_dir.name
|
||||
pyproject = source_dir / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
|
||||
@@ -24,9 +24,26 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
config = {
|
||||
"gateway": {"port": 9000},
|
||||
"components": {
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"source": "test-tool",
|
||||
"install": {"path": {"alias": "test-tool"}},
|
||||
"tool": {
|
||||
"system_dependencies": ["pandoc"],
|
||||
},
|
||||
},
|
||||
"test-tool-2": {
|
||||
"description": "Another test tool",
|
||||
"source": "test-tool-2",
|
||||
"tool": {
|
||||
"version": "2.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"services": {
|
||||
"test-svc": {
|
||||
"component": "test-svc-comp",
|
||||
"description": "Test service",
|
||||
"source": "test-svc",
|
||||
"run": {
|
||||
"runner": "python",
|
||||
"tool": "test-svc",
|
||||
@@ -40,20 +57,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"proxy": {"caddy": {"path_prefix": "/test-svc"}},
|
||||
"manage": {"systemd": {}},
|
||||
},
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"install": {"path": {"alias": "test-tool"}},
|
||||
"tool": {
|
||||
"source": "test-tool",
|
||||
"system_dependencies": ["pandoc"],
|
||||
},
|
||||
"jobs": {
|
||||
"test-job": {
|
||||
"description": "Test job",
|
||||
"run": {
|
||||
"runner": "command",
|
||||
"argv": ["test-job"],
|
||||
},
|
||||
"test-tool-2": {
|
||||
"description": "Another test tool",
|
||||
"tool": {
|
||||
"source": "test-tool-2",
|
||||
"version": "2.0.0",
|
||||
},
|
||||
"schedule": "0 2 * * *",
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -80,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
|
||||
"TEST_SVC_DATA_DIR": "/data/castle/test-svc",
|
||||
},
|
||||
description="Test service",
|
||||
roles=["service"],
|
||||
category="service",
|
||||
port=19000,
|
||||
health_path="/health",
|
||||
proxy_path="/test-svc",
|
||||
|
||||
@@ -34,7 +34,7 @@ class TestComponents:
|
||||
assert svc["health_path"] == "/health"
|
||||
assert svc["proxy_path"] == "/test-svc"
|
||||
assert svc["managed"] is True
|
||||
assert "service" in svc["roles"]
|
||||
assert svc["category"] == "service"
|
||||
|
||||
def test_tool_has_no_port(self, client: TestClient) -> None:
|
||||
"""Tool component has no port."""
|
||||
@@ -42,7 +42,15 @@ class TestComponents:
|
||||
data = response.json()
|
||||
tool = next(c for c in data if c["id"] == "test-tool")
|
||||
assert tool["port"] is None
|
||||
assert "tool" in tool["roles"]
|
||||
assert tool["category"] == "tool"
|
||||
|
||||
def test_job_has_schedule(self, client: TestClient) -> None:
|
||||
"""Job component has schedule."""
|
||||
response = client.get("/components")
|
||||
data = response.json()
|
||||
job = next(c for c in data if c["id"] == "test-job")
|
||||
assert job["category"] == "job"
|
||||
assert job["schedule"] == "0 2 * * *"
|
||||
|
||||
|
||||
class TestComponentDetail:
|
||||
|
||||
224
castle.yaml
224
castle.yaml
@@ -1,138 +1,31 @@
|
||||
gateway:
|
||||
port: 9000
|
||||
|
||||
components:
|
||||
castle-gateway:
|
||||
description: Caddy reverse proxy gateway
|
||||
run:
|
||||
runner: command
|
||||
argv:
|
||||
- caddy
|
||||
- run
|
||||
- --config
|
||||
- /home/payne/.castle/generated/Caddyfile
|
||||
- --adapter
|
||||
- caddyfile
|
||||
manage:
|
||||
systemd:
|
||||
exec_reload: caddy reload --config /home/payne/.castle/generated/Caddyfile
|
||||
--adapter caddyfile
|
||||
expose:
|
||||
http:
|
||||
internal:
|
||||
port: 9000
|
||||
health_path: /
|
||||
central-context:
|
||||
description: Content storage API useful to get context into my data dir from anywhere
|
||||
on the LAN
|
||||
source: components/central-context
|
||||
run:
|
||||
runner: python
|
||||
tool: central-context
|
||||
manage:
|
||||
systemd: {}
|
||||
expose:
|
||||
http:
|
||||
internal:
|
||||
port: 9001
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy:
|
||||
path_prefix: /central-context
|
||||
notification-bridge:
|
||||
description: Desktop notification forwarder. This taps into your notifications
|
||||
system on the desktop and will forward all notifications the the central context
|
||||
server.
|
||||
source: components/notification-bridge
|
||||
run:
|
||||
runner: python
|
||||
tool: notification-bridge
|
||||
defaults:
|
||||
env:
|
||||
CENTRAL_CONTEXT_URL: http://localhost:9001
|
||||
BUCKET_NAME: notifications
|
||||
PORT: '9002'
|
||||
manage:
|
||||
systemd: {}
|
||||
expose:
|
||||
http:
|
||||
internal:
|
||||
port: 9002
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy:
|
||||
path_prefix: /notifications
|
||||
castle-api:
|
||||
description: Castle API
|
||||
source: castle-api
|
||||
run:
|
||||
runner: python
|
||||
tool: castle-api
|
||||
manage:
|
||||
systemd: {}
|
||||
expose:
|
||||
http:
|
||||
internal:
|
||||
port: 9020
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy:
|
||||
path_prefix: /api
|
||||
protonmail:
|
||||
description: ProtonMail email sync via Bridge
|
||||
source: components/protonmail
|
||||
run:
|
||||
runner: command
|
||||
argv:
|
||||
- protonmail
|
||||
- sync
|
||||
defaults:
|
||||
env:
|
||||
PROTONMAIL_USERNAME: paul@payne.io
|
||||
PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY}
|
||||
triggers:
|
||||
- type: schedule
|
||||
cron: '*/5 * * * *'
|
||||
timezone: America/Los_Angeles
|
||||
manage:
|
||||
systemd: {}
|
||||
install:
|
||||
path:
|
||||
alias: protonmail
|
||||
backup-collect:
|
||||
description: Collect files from various sources into backup directory
|
||||
source: components/backup-collect
|
||||
run:
|
||||
runner: command
|
||||
argv:
|
||||
- backup-collect
|
||||
defaults:
|
||||
env:
|
||||
DBACKUP: /data/backup
|
||||
triggers:
|
||||
- type: schedule
|
||||
cron: 0 2 * * *
|
||||
timezone: America/Los_Angeles
|
||||
manage:
|
||||
systemd: {}
|
||||
tool:
|
||||
system_dependencies:
|
||||
- rsync
|
||||
backup-data:
|
||||
description: Nightly restic backup of /data to /storage
|
||||
run:
|
||||
runner: command
|
||||
argv:
|
||||
- backup-data
|
||||
defaults:
|
||||
env:
|
||||
RESTIC_REPOSITORY: /storage/restic-data
|
||||
RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password
|
||||
triggers:
|
||||
- type: schedule
|
||||
cron: 30 3 * * *
|
||||
timezone: America/Los_Angeles
|
||||
manage:
|
||||
systemd: {}
|
||||
castle-app:
|
||||
description: Castle web app
|
||||
source: app
|
||||
@@ -249,3 +142,118 @@ components:
|
||||
install:
|
||||
path:
|
||||
alias: text-extractor
|
||||
|
||||
services:
|
||||
castle-gateway:
|
||||
description: Caddy reverse proxy gateway
|
||||
run:
|
||||
runner: command
|
||||
argv:
|
||||
- caddy
|
||||
- run
|
||||
- --config
|
||||
- /home/payne/.castle/generated/Caddyfile
|
||||
- --adapter
|
||||
- caddyfile
|
||||
manage:
|
||||
systemd:
|
||||
exec_reload: caddy reload --config /home/payne/.castle/generated/Caddyfile
|
||||
--adapter caddyfile
|
||||
expose:
|
||||
http:
|
||||
internal:
|
||||
port: 9000
|
||||
health_path: /
|
||||
central-context:
|
||||
component: central-context
|
||||
run:
|
||||
runner: python
|
||||
tool: central-context
|
||||
manage:
|
||||
systemd: {}
|
||||
expose:
|
||||
http:
|
||||
internal:
|
||||
port: 9001
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy:
|
||||
path_prefix: /central-context
|
||||
notification-bridge:
|
||||
component: notification-bridge
|
||||
run:
|
||||
runner: python
|
||||
tool: notification-bridge
|
||||
defaults:
|
||||
env:
|
||||
CENTRAL_CONTEXT_URL: http://localhost:9001
|
||||
BUCKET_NAME: notifications
|
||||
PORT: '9002'
|
||||
manage:
|
||||
systemd: {}
|
||||
expose:
|
||||
http:
|
||||
internal:
|
||||
port: 9002
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy:
|
||||
path_prefix: /notifications
|
||||
castle-api:
|
||||
component: castle-api
|
||||
run:
|
||||
runner: python
|
||||
tool: castle-api
|
||||
manage:
|
||||
systemd: {}
|
||||
expose:
|
||||
http:
|
||||
internal:
|
||||
port: 9020
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy:
|
||||
path_prefix: /api
|
||||
|
||||
jobs:
|
||||
protonmail-sync:
|
||||
component: protonmail
|
||||
description: Sync ProtonMail email
|
||||
run:
|
||||
runner: command
|
||||
argv:
|
||||
- protonmail
|
||||
- sync
|
||||
schedule: '*/5 * * * *'
|
||||
defaults:
|
||||
env:
|
||||
PROTONMAIL_USERNAME: paul@payne.io
|
||||
PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY}
|
||||
manage:
|
||||
systemd: {}
|
||||
backup-collect:
|
||||
component: backup-collect
|
||||
description: Collect files from various sources into backup directory
|
||||
run:
|
||||
runner: command
|
||||
argv:
|
||||
- backup-collect
|
||||
schedule: 0 2 * * *
|
||||
defaults:
|
||||
env:
|
||||
DBACKUP: /data/backup
|
||||
manage:
|
||||
systemd: {}
|
||||
backup-data:
|
||||
description: Nightly restic backup of /data to /storage
|
||||
run:
|
||||
runner: command
|
||||
argv:
|
||||
- backup-data
|
||||
schedule: 30 3 * * *
|
||||
defaults:
|
||||
env:
|
||||
RESTIC_REPOSITORY: /storage/restic-data
|
||||
RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password
|
||||
manage:
|
||||
systemd: {}
|
||||
|
||||
@@ -7,7 +7,7 @@ import argparse
|
||||
from castle_cli.config import load_config, save_config
|
||||
from castle_cli.manifest import (
|
||||
CaddySpec,
|
||||
ComponentManifest,
|
||||
ComponentSpec,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
@@ -16,6 +16,7 @@ from castle_cli.manifest import (
|
||||
PathInstallSpec,
|
||||
ProxySpec,
|
||||
RunPython,
|
||||
ServiceSpec,
|
||||
SystemdSpec,
|
||||
ToolSpec,
|
||||
)
|
||||
@@ -25,9 +26,9 @@ from castle_cli.templates.scaffold import scaffold_project
|
||||
def next_available_port(config: object) -> int:
|
||||
"""Find the next available port starting from 9001 (9000 is reserved for gateway)."""
|
||||
used_ports = set()
|
||||
for manifest in config.components.values():
|
||||
if manifest.expose and manifest.expose.http:
|
||||
used_ports.add(manifest.expose.http.internal.port)
|
||||
for svc in config.services.values():
|
||||
if svc.expose and svc.expose.http:
|
||||
used_ports.add(svc.expose.http.internal.port)
|
||||
# Also reserve gateway port
|
||||
used_ports.add(config.gateway.port)
|
||||
|
||||
@@ -43,8 +44,8 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
name = args.name
|
||||
proj_type = args.type
|
||||
|
||||
if name in config.components:
|
||||
print(f"Error: component '{name}' already exists in castle.yaml")
|
||||
if name in config.components or name in config.services or name in config.jobs:
|
||||
print(f"Error: '{name}' already exists in castle.yaml")
|
||||
return 1
|
||||
|
||||
components_dir = config.root / "components"
|
||||
@@ -72,16 +73,19 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
port=port,
|
||||
)
|
||||
|
||||
# Build manifest entry
|
||||
# Build entries
|
||||
if proj_type == "service":
|
||||
manifest = ComponentManifest(
|
||||
# Component for software identity
|
||||
config.components[name] = ComponentSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
source=f"components/{name}",
|
||||
run=RunPython(
|
||||
runner="python",
|
||||
tool=name,
|
||||
),
|
||||
)
|
||||
# Service for deployment
|
||||
config.services[name] = ServiceSpec(
|
||||
id=name,
|
||||
component=name,
|
||||
run=RunPython(runner="python", tool=name),
|
||||
expose=ExposeSpec(
|
||||
http=HttpExposeSpec(
|
||||
internal=HttpInternal(port=port),
|
||||
@@ -92,22 +96,21 @@ def run_create(args: argparse.Namespace) -> int:
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
elif proj_type == "tool":
|
||||
manifest = ComponentManifest(
|
||||
config.components[name] = ComponentSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
source=f"components/{name}",
|
||||
tool=ToolSpec(source=f"components/{name}/"),
|
||||
tool=ToolSpec(),
|
||||
install=InstallSpec(path=PathInstallSpec(alias=name)),
|
||||
)
|
||||
else:
|
||||
# library or other
|
||||
manifest = ComponentManifest(
|
||||
config.components[name] = ComponentSpec(
|
||||
id=name,
|
||||
description=args.description or f"A castle {proj_type}",
|
||||
source=f"components/{name}",
|
||||
)
|
||||
|
||||
config.components[name] = manifest
|
||||
save_config(config)
|
||||
|
||||
print(f"Created {proj_type} '{name}' at {project_dir}")
|
||||
|
||||
@@ -19,11 +19,10 @@ from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||
from castle_core.generators.systemd import (
|
||||
generate_timer,
|
||||
generate_unit_from_deployed,
|
||||
get_schedule_trigger,
|
||||
timer_name,
|
||||
unit_name,
|
||||
)
|
||||
from castle_core.manifest import ComponentManifest
|
||||
from castle_core.manifest import JobSpec, ServiceSpec
|
||||
from castle_core.registry import (
|
||||
REGISTRY_PATH,
|
||||
DeployedComponent,
|
||||
@@ -40,15 +39,7 @@ SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
|
||||
def run_deploy(args: argparse.Namespace) -> int:
|
||||
"""Deploy components from castle.yaml to ~/.castle/."""
|
||||
config = load_config()
|
||||
component_name = getattr(args, "component", None)
|
||||
|
||||
if component_name:
|
||||
if component_name not in config.components:
|
||||
print(f"Error: component '{component_name}' not found in castle.yaml")
|
||||
return 1
|
||||
names = [component_name]
|
||||
else:
|
||||
names = list(config.components.keys())
|
||||
target_name = getattr(args, "component", None)
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
@@ -57,7 +48,7 @@ def run_deploy(args: argparse.Namespace) -> int:
|
||||
|
||||
# Load existing registry to preserve components not being redeployed,
|
||||
# or start fresh if deploying all
|
||||
if component_name and REGISTRY_PATH.exists():
|
||||
if target_name and REGISTRY_PATH.exists():
|
||||
try:
|
||||
existing = load_registry()
|
||||
registry = NodeRegistry(node=node, deployed=dict(existing.deployed))
|
||||
@@ -67,14 +58,21 @@ def run_deploy(args: argparse.Namespace) -> int:
|
||||
registry = NodeRegistry(node=node)
|
||||
|
||||
deployed_count = 0
|
||||
for name in names:
|
||||
manifest = config.components[name]
|
||||
|
||||
# Only deploy components with a run spec
|
||||
if not manifest.run:
|
||||
# Deploy services
|
||||
for name, svc in config.services.items():
|
||||
if target_name and name != target_name:
|
||||
continue
|
||||
deployed = _build_deployed_service(config, name, svc)
|
||||
registry.deployed[name] = deployed
|
||||
deployed_count += 1
|
||||
_print_deployed(name, deployed)
|
||||
|
||||
deployed = _build_deployed(config, name, manifest)
|
||||
# Deploy jobs
|
||||
for name, job in config.jobs.items():
|
||||
if target_name and name != target_name:
|
||||
continue
|
||||
deployed = _build_deployed_job(config, name, job)
|
||||
registry.deployed[name] = deployed
|
||||
deployed_count += 1
|
||||
_print_deployed(name, deployed)
|
||||
@@ -108,69 +106,100 @@ def _env_prefix(name: str) -> str:
|
||||
return name.replace("-", "_").upper()
|
||||
|
||||
|
||||
def _build_deployed(
|
||||
config: CastleConfig, name: str, manifest: ComponentManifest
|
||||
) -> DeployedComponent:
|
||||
"""Build a DeployedComponent from a manifest spec."""
|
||||
run = manifest.run
|
||||
assert run is not None
|
||||
def _resolve_description(
|
||||
config: CastleConfig, spec: ServiceSpec | JobSpec
|
||||
) -> str | None:
|
||||
"""Get description, falling through to component if referenced."""
|
||||
if spec.description:
|
||||
return spec.description
|
||||
if spec.component and spec.component in config.components:
|
||||
return config.components[spec.component].description
|
||||
return None
|
||||
|
||||
# 1. Convention-based env vars
|
||||
|
||||
def _build_deployed_service(
|
||||
config: CastleConfig, name: str, svc: ServiceSpec
|
||||
) -> DeployedComponent:
|
||||
"""Build a DeployedComponent from a ServiceSpec."""
|
||||
run = svc.run
|
||||
prefix = _env_prefix(name)
|
||||
env: dict[str, str] = {}
|
||||
|
||||
# Data dir convention (for all managed components)
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
# Data dir convention (for managed services)
|
||||
managed = True # Services are always managed by default
|
||||
if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable:
|
||||
managed = False
|
||||
if managed:
|
||||
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
|
||||
|
||||
# Port convention (if exposed)
|
||||
if manifest.expose and manifest.expose.http:
|
||||
env[f"{prefix}_PORT"] = str(manifest.expose.http.internal.port)
|
||||
|
||||
# 2. Merge defaults.env (overrides conventions)
|
||||
if manifest.defaults and manifest.defaults.env:
|
||||
env.update(manifest.defaults.env)
|
||||
|
||||
# 3. Resolve secrets
|
||||
env = resolve_env_vars(env, manifest)
|
||||
|
||||
# 4. Build run_cmd
|
||||
run_cmd = _build_run_cmd(run, env)
|
||||
|
||||
# 5. Extract metadata
|
||||
port = None
|
||||
health_path = None
|
||||
if manifest.expose and manifest.expose.http:
|
||||
port = manifest.expose.http.internal.port
|
||||
health_path = manifest.expose.http.health_path
|
||||
if svc.expose and svc.expose.http:
|
||||
port = svc.expose.http.internal.port
|
||||
env[f"{prefix}_PORT"] = str(port)
|
||||
health_path = svc.expose.http.health_path
|
||||
|
||||
# Merge defaults.env (overrides conventions)
|
||||
if svc.defaults and svc.defaults.env:
|
||||
env.update(svc.defaults.env)
|
||||
|
||||
# Resolve secrets
|
||||
env = resolve_env_vars(env)
|
||||
|
||||
# Build run_cmd
|
||||
run_cmd = _build_run_cmd(run, env)
|
||||
|
||||
# Proxy path
|
||||
proxy_path = None
|
||||
if manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable:
|
||||
proxy_path = manifest.proxy.caddy.path_prefix or f"/{name}"
|
||||
|
||||
schedule = None
|
||||
sched_trigger = get_schedule_trigger(manifest)
|
||||
if sched_trigger:
|
||||
schedule = sched_trigger.cron
|
||||
|
||||
managed = bool(manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable)
|
||||
|
||||
roles = [r.value for r in manifest.roles]
|
||||
if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable:
|
||||
proxy_path = svc.proxy.caddy.path_prefix or f"/{name}"
|
||||
|
||||
return DeployedComponent(
|
||||
runner=run.runner,
|
||||
run_cmd=run_cmd,
|
||||
env=env,
|
||||
description=manifest.description,
|
||||
roles=roles,
|
||||
description=_resolve_description(config, svc),
|
||||
category="service",
|
||||
port=port,
|
||||
health_path=health_path,
|
||||
proxy_path=proxy_path,
|
||||
schedule=schedule,
|
||||
managed=managed,
|
||||
)
|
||||
|
||||
|
||||
def _build_deployed_job(
|
||||
config: CastleConfig, name: str, job: JobSpec
|
||||
) -> DeployedComponent:
|
||||
"""Build a DeployedComponent from a JobSpec."""
|
||||
run = job.run
|
||||
prefix = _env_prefix(name)
|
||||
env: dict[str, str] = {}
|
||||
|
||||
# Data dir convention
|
||||
env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name)
|
||||
|
||||
# Merge defaults.env (overrides conventions)
|
||||
if job.defaults and job.defaults.env:
|
||||
env.update(job.defaults.env)
|
||||
|
||||
# Resolve secrets
|
||||
env = resolve_env_vars(env)
|
||||
|
||||
# Build run_cmd
|
||||
run_cmd = _build_run_cmd(run, env)
|
||||
|
||||
return DeployedComponent(
|
||||
runner=run.runner,
|
||||
run_cmd=run_cmd,
|
||||
env=env,
|
||||
description=_resolve_description(config, job),
|
||||
category="job",
|
||||
schedule=job.schedule,
|
||||
managed=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
|
||||
"""Build a run command list from a RunSpec."""
|
||||
match run.runner:
|
||||
@@ -199,7 +228,6 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
|
||||
cmd.extend(["-p", f"{host_port}:{container_port}"])
|
||||
for vol in run.volumes:
|
||||
cmd.extend(["-v", vol])
|
||||
# Container env comes from both run.env (container-specific) and deployed env
|
||||
for key, val in run.env.items():
|
||||
cmd.extend(["-e", f"{key}={val}"])
|
||||
for key, val in env.items():
|
||||
@@ -238,13 +266,12 @@ def _copy_app_static(config: CastleConfig) -> None:
|
||||
if "castle-app" not in config.components:
|
||||
return
|
||||
|
||||
manifest = config.components["castle-app"]
|
||||
if not (manifest.build and manifest.build.outputs):
|
||||
comp = config.components["castle-app"]
|
||||
if not (comp.build and comp.build.outputs):
|
||||
return
|
||||
|
||||
# Find the source dist directory
|
||||
source_dir = manifest.source_dir or "app"
|
||||
for output in manifest.build.outputs:
|
||||
source_dir = comp.source_dir or "app"
|
||||
for output in comp.build.outputs:
|
||||
src = config.root / source_dir / output
|
||||
if src.exists():
|
||||
dest = STATIC_DIR / "castle-app"
|
||||
@@ -262,22 +289,29 @@ def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> Non
|
||||
if not deployed.managed:
|
||||
continue
|
||||
|
||||
# Get systemd spec from manifest (for restart policy, exec_reload, etc.)
|
||||
# Get systemd spec from config (services or jobs)
|
||||
systemd_spec = None
|
||||
if name in config.components:
|
||||
manifest = config.components[name]
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
systemd_spec = manifest.manage.systemd
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
if svc.manage and svc.manage.systemd:
|
||||
systemd_spec = svc.manage.systemd
|
||||
elif name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
if job.manage and job.manage.systemd:
|
||||
systemd_spec = job.manage.systemd
|
||||
|
||||
# Generate and write service unit
|
||||
svc_name = unit_name(name)
|
||||
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
(SYSTEMD_USER_DIR / svc_name).write_text(svc_content)
|
||||
|
||||
# Generate timer if scheduled
|
||||
if name in config.components:
|
||||
timer_content = generate_timer(name, config.components[name])
|
||||
if timer_content:
|
||||
# Generate timer for jobs
|
||||
if deployed.schedule:
|
||||
timer_content = generate_timer(
|
||||
name,
|
||||
schedule=deployed.schedule,
|
||||
description=deployed.description,
|
||||
)
|
||||
tmr_name = timer_name(name)
|
||||
(SYSTEMD_USER_DIR / tmr_name).write_text(timer_content)
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ def _gateway_start(config: CastleConfig) -> int:
|
||||
"""Generate config and enable the gateway service."""
|
||||
from castle_cli.commands.service import _service_enable
|
||||
|
||||
if GATEWAY_COMPONENT not in config.managed:
|
||||
print(f"Error: '{GATEWAY_COMPONENT}' not found in castle.yaml or not managed")
|
||||
if GATEWAY_COMPONENT not in config.services:
|
||||
print(f"Error: '{GATEWAY_COMPONENT}' not found in services section")
|
||||
return 1
|
||||
|
||||
print("Generating gateway configuration...")
|
||||
@@ -101,7 +101,6 @@ def _gateway_reload() -> int:
|
||||
if result.returncode == 0:
|
||||
print("Gateway reloaded.")
|
||||
else:
|
||||
# Fall back to restart if reload not supported
|
||||
print("Reload signal sent. Verifying...")
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", GATEWAY_UNIT],
|
||||
|
||||
@@ -29,105 +29,102 @@ def _load_deployed_component(name: str) -> object | None:
|
||||
|
||||
|
||||
def run_info(args: argparse.Namespace) -> int:
|
||||
"""Show detailed info for a component."""
|
||||
"""Show detailed info for a component, service, or job."""
|
||||
config = load_config()
|
||||
name = args.project
|
||||
|
||||
if name not in config.components:
|
||||
print(f"Error: component '{name}' not found in castle.yaml")
|
||||
# Look up in all sections
|
||||
component = config.components.get(name)
|
||||
service = config.services.get(name)
|
||||
job = config.jobs.get(name)
|
||||
|
||||
if not component and not service and not job:
|
||||
print(f"Error: '{name}' not found in castle.yaml")
|
||||
return 1
|
||||
|
||||
manifest = config.components[name]
|
||||
deployed = _load_deployed_component(name)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
data = manifest.model_dump(exclude_none=True)
|
||||
# Include CLAUDE.md content if it exists
|
||||
claude_md = _find_claude_md(config.root, manifest.source_dir or name)
|
||||
if claude_md:
|
||||
data["claude_md"] = claude_md
|
||||
if deployed:
|
||||
data["deployed"] = {
|
||||
"runner": deployed.runner,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"port": deployed.port,
|
||||
"managed": deployed.managed,
|
||||
}
|
||||
print(json.dumps(data, indent=2))
|
||||
return 0
|
||||
return _info_json(config, name, component, service, job, deployed)
|
||||
|
||||
# Human-readable output
|
||||
print(f"\n{BOLD}{name}{RESET}")
|
||||
print(f"{'─' * 40}")
|
||||
print(f" {BOLD}roles{RESET}: {', '.join(r.value for r in manifest.roles)}")
|
||||
if manifest.description:
|
||||
print(f" {BOLD}description{RESET}: {manifest.description}")
|
||||
|
||||
# Source
|
||||
if manifest.source:
|
||||
print(f" {BOLD}source{RESET}: {manifest.source}")
|
||||
# Determine category
|
||||
if service:
|
||||
print(f" {BOLD}category{RESET}: service")
|
||||
elif job:
|
||||
print(f" {BOLD}category{RESET}: job")
|
||||
elif component:
|
||||
if component.tool or (component.install and component.install.path):
|
||||
print(f" {BOLD}category{RESET}: tool")
|
||||
elif component.build:
|
||||
print(f" {BOLD}category{RESET}: frontend")
|
||||
else:
|
||||
print(f" {BOLD}category{RESET}: component")
|
||||
|
||||
# Component info
|
||||
if component:
|
||||
if component.description:
|
||||
print(f" {BOLD}description{RESET}: {component.description}")
|
||||
if component.source:
|
||||
print(f" {BOLD}source{RESET}: {component.source}")
|
||||
if component.install and component.install.path:
|
||||
pi = component.install.path
|
||||
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
|
||||
if component.tool:
|
||||
t = component.tool
|
||||
if t.system_dependencies:
|
||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
||||
if component.tags:
|
||||
print(f" {BOLD}tags{RESET}: {', '.join(component.tags)}")
|
||||
|
||||
# Service info
|
||||
spec = service or job
|
||||
if spec:
|
||||
desc = spec.description
|
||||
if not desc and spec.component and spec.component in config.components:
|
||||
desc = config.components[spec.component].description
|
||||
if desc and not (component and component.description == desc):
|
||||
print(f" {BOLD}description{RESET}: {desc}")
|
||||
if spec.component:
|
||||
print(f" {BOLD}component{RESET}: {spec.component}")
|
||||
|
||||
# Run spec
|
||||
if manifest.run:
|
||||
print(f" {BOLD}runner{RESET}: {manifest.run.runner}")
|
||||
if hasattr(manifest.run, "tool"):
|
||||
print(f" {BOLD}tool{RESET}: {manifest.run.tool}")
|
||||
elif hasattr(manifest.run, "argv"):
|
||||
print(f" {BOLD}argv{RESET}: {manifest.run.argv}")
|
||||
elif hasattr(manifest.run, "image"):
|
||||
print(f" {BOLD}image{RESET}: {manifest.run.image}")
|
||||
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
|
||||
if hasattr(spec.run, "tool"):
|
||||
print(f" {BOLD}tool{RESET}: {spec.run.tool}")
|
||||
elif hasattr(spec.run, "argv"):
|
||||
print(f" {BOLD}argv{RESET}: {spec.run.argv}")
|
||||
elif hasattr(spec.run, "image"):
|
||||
print(f" {BOLD}image{RESET}: {spec.run.image}")
|
||||
|
||||
# Defaults env
|
||||
if manifest.defaults and manifest.defaults.env:
|
||||
if spec.defaults and spec.defaults.env:
|
||||
print(f" {BOLD}defaults.env{RESET}:")
|
||||
for key, val in manifest.defaults.env.items():
|
||||
for key, val in spec.defaults.env.items():
|
||||
print(f" {key}: {val}")
|
||||
|
||||
# Expose
|
||||
if manifest.expose and manifest.expose.http:
|
||||
http = manifest.expose.http
|
||||
# Service-specific: expose, proxy, manage
|
||||
if service:
|
||||
if service.expose and service.expose.http:
|
||||
http = service.expose.http
|
||||
print(f" {BOLD}port{RESET}: {http.internal.port}")
|
||||
if http.health_path:
|
||||
print(f" {BOLD}health{RESET}: {http.health_path}")
|
||||
|
||||
# Proxy
|
||||
if manifest.proxy and manifest.proxy.caddy:
|
||||
caddy = manifest.proxy.caddy
|
||||
if service.proxy and service.proxy.caddy:
|
||||
caddy = service.proxy.caddy
|
||||
if caddy.path_prefix:
|
||||
print(f" {BOLD}path{RESET}: {caddy.path_prefix}")
|
||||
|
||||
# Manage
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
sd = manifest.manage.systemd
|
||||
if service.manage and service.manage.systemd:
|
||||
sd = service.manage.systemd
|
||||
print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}")
|
||||
|
||||
# Install
|
||||
if manifest.install and manifest.install.path:
|
||||
pi = manifest.install.path
|
||||
print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else ""))
|
||||
|
||||
# Tool
|
||||
if manifest.tool:
|
||||
t = manifest.tool
|
||||
if t.source:
|
||||
print(f" {BOLD}tool.source{RESET}: {t.source}")
|
||||
if t.system_dependencies:
|
||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
||||
|
||||
# Tags
|
||||
if manifest.tags:
|
||||
print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}")
|
||||
|
||||
# Capabilities
|
||||
if manifest.provides:
|
||||
print(f" {BOLD}provides{RESET}:")
|
||||
for cap in manifest.provides:
|
||||
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
|
||||
if manifest.consumes:
|
||||
print(f" {BOLD}consumes{RESET}:")
|
||||
for cap in manifest.consumes:
|
||||
print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else ""))
|
||||
# Job-specific
|
||||
if job:
|
||||
print(f" {BOLD}schedule{RESET}: {job.schedule}")
|
||||
print(f" {BOLD}timezone{RESET}: {job.timezone}")
|
||||
|
||||
# Deployed state from registry
|
||||
if deployed:
|
||||
@@ -142,10 +139,16 @@ def run_info(args: argparse.Namespace) -> int:
|
||||
for key, val in deployed.env.items():
|
||||
print(f" {key}={val}")
|
||||
else:
|
||||
print(f"\n {DIM}not deployed (run 'castle deploy {name}'){RESET}")
|
||||
print(f"\n {DIM}not deployed (run 'castle deploy'){RESET}")
|
||||
|
||||
# Show CLAUDE.md if it exists
|
||||
source_dir = manifest.source_dir or name
|
||||
source_dir = None
|
||||
if component and component.source_dir:
|
||||
source_dir = component.source_dir
|
||||
elif spec and spec.component and spec.component in config.components:
|
||||
source_dir = config.components[spec.component].source_dir
|
||||
|
||||
if source_dir:
|
||||
claude_md = _find_claude_md(config.root, source_dir)
|
||||
if claude_md:
|
||||
print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}")
|
||||
@@ -156,6 +159,46 @@ def run_info(args: argparse.Namespace) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def _info_json(
|
||||
config: object,
|
||||
name: str,
|
||||
component: object | None,
|
||||
service: object | None,
|
||||
job: object | None,
|
||||
deployed: object | None,
|
||||
) -> int:
|
||||
"""Output JSON info."""
|
||||
data: dict = {"name": name}
|
||||
|
||||
if component:
|
||||
data["component"] = component.model_dump(exclude_none=True, exclude={"id"})
|
||||
if service:
|
||||
data["service"] = service.model_dump(exclude_none=True, exclude={"id"})
|
||||
data["category"] = "service"
|
||||
if job:
|
||||
data["job"] = job.model_dump(exclude_none=True, exclude={"id"})
|
||||
data["category"] = "job"
|
||||
if not service and not job and component:
|
||||
if component.tool or (component.install and component.install.path):
|
||||
data["category"] = "tool"
|
||||
elif component.build:
|
||||
data["category"] = "frontend"
|
||||
else:
|
||||
data["category"] = "component"
|
||||
|
||||
if deployed:
|
||||
data["deployed"] = {
|
||||
"runner": deployed.runner,
|
||||
"run_cmd": deployed.run_cmd,
|
||||
"env": deployed.env,
|
||||
"port": deployed.port,
|
||||
"managed": deployed.managed,
|
||||
}
|
||||
|
||||
print(json.dumps(data, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def _find_claude_md(root: Path, source_dir: str) -> str | None:
|
||||
"""Read CLAUDE.md from project directory if it exists."""
|
||||
claude_path = root / source_dir / "CLAUDE.md"
|
||||
|
||||
@@ -7,7 +7,6 @@ import json
|
||||
import logging
|
||||
|
||||
from castle_cli.config import load_config
|
||||
from castle_cli.manifest import Role
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,15 +16,15 @@ RESET = "\033[0m"
|
||||
DIM = "\033[2m"
|
||||
GREEN = "\033[92m"
|
||||
RED = "\033[91m"
|
||||
CYAN = "\033[96m"
|
||||
MAGENTA = "\033[95m"
|
||||
YELLOW = "\033[93m"
|
||||
|
||||
ROLE_COLORS: dict[str, str] = {
|
||||
Role.SERVICE: "\033[92m", # green
|
||||
Role.TOOL: "\033[96m", # cyan
|
||||
Role.WORKER: "\033[94m", # blue
|
||||
Role.JOB: "\033[95m", # magenta
|
||||
Role.FRONTEND: "\033[93m", # yellow
|
||||
Role.REMOTE: "\033[90m", # dim
|
||||
Role.CONTAINERIZED: "\033[33m", # orange
|
||||
CATEGORY_COLORS: dict[str, str] = {
|
||||
"service": GREEN,
|
||||
"job": MAGENTA,
|
||||
"tool": CYAN,
|
||||
"frontend": YELLOW,
|
||||
}
|
||||
|
||||
|
||||
@@ -41,71 +40,132 @@ def _load_deployed() -> dict[str, object] | None:
|
||||
|
||||
|
||||
def run_list(args: argparse.Namespace) -> int:
|
||||
"""List all components."""
|
||||
"""List all components, services, and jobs."""
|
||||
config = load_config()
|
||||
deployed = _load_deployed()
|
||||
|
||||
components = config.components
|
||||
|
||||
filter_role = getattr(args, "role", None)
|
||||
if filter_role:
|
||||
components = {k: v for k, v in components.items() if filter_role in v.roles}
|
||||
filter_type = getattr(args, "type", None)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
output = []
|
||||
for name, manifest in components.items():
|
||||
entry: dict = {
|
||||
"name": name,
|
||||
"roles": [r.value for r in manifest.roles],
|
||||
"deployed": deployed is not None and name in deployed,
|
||||
}
|
||||
if manifest.description:
|
||||
entry["description"] = manifest.description
|
||||
if manifest.expose and manifest.expose.http:
|
||||
entry["port"] = manifest.expose.http.internal.port
|
||||
if deployed and name in deployed:
|
||||
dep = deployed[name]
|
||||
if dep.port is not None:
|
||||
entry["port"] = dep.port
|
||||
output.append(entry)
|
||||
print(json.dumps(output, indent=2))
|
||||
return 0
|
||||
return _list_json(config, deployed, filter_type)
|
||||
|
||||
if not components:
|
||||
print("No components found.")
|
||||
return 0
|
||||
any_output = False
|
||||
|
||||
# Group by primary role (first in sorted list)
|
||||
by_role: dict[str, list[tuple[str, object]]] = {}
|
||||
for name, manifest in components.items():
|
||||
primary_role = manifest.roles[0].value if manifest.roles else "other"
|
||||
by_role.setdefault(primary_role, []).append((name, manifest))
|
||||
|
||||
# Display order
|
||||
role_order = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"]
|
||||
for role_name in role_order:
|
||||
items = by_role.get(role_name, [])
|
||||
if not items:
|
||||
continue
|
||||
color = ROLE_COLORS.get(role_name, "")
|
||||
print(f"\n{BOLD}{color}{role_name}s{RESET}")
|
||||
# Services
|
||||
if not filter_type or filter_type == "service":
|
||||
if config.services:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["service"]
|
||||
print(f"\n{BOLD}{color}Services{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, manifest in items:
|
||||
for name, svc in config.services.items():
|
||||
port_str = ""
|
||||
if manifest.expose and manifest.expose.http:
|
||||
port_str = f" :{manifest.expose.http.internal.port}"
|
||||
if svc.expose and svc.expose.http:
|
||||
port_str = f" :{svc.expose.http.internal.port}"
|
||||
|
||||
# Show deployed status indicator
|
||||
if deployed is not None:
|
||||
status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}"
|
||||
else:
|
||||
status = f"{DIM}?{RESET}"
|
||||
|
||||
desc = f" {DIM}{manifest.description}{RESET}" if manifest.description else ""
|
||||
desc = f" {DIM}{svc.description}{RESET}" if svc.description else ""
|
||||
print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}")
|
||||
|
||||
# Jobs
|
||||
if not filter_type or filter_type == "job":
|
||||
if config.jobs:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["job"]
|
||||
print(f"\n{BOLD}{color}Jobs{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, job in config.jobs.items():
|
||||
if deployed is not None:
|
||||
status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}"
|
||||
else:
|
||||
status = f"{DIM}?{RESET}"
|
||||
|
||||
desc = f" {DIM}{job.description}{RESET}" if job.description else ""
|
||||
sched = f" {DIM}[{job.schedule}]{RESET}"
|
||||
print(f" {status} {BOLD}{name}{RESET}{sched}{desc}")
|
||||
|
||||
# Tools
|
||||
if not filter_type or filter_type == "tool":
|
||||
tools = config.tools
|
||||
if tools:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["tool"]
|
||||
print(f"\n{BOLD}{color}Tools{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, comp in tools.items():
|
||||
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
|
||||
print(f" {BOLD}{name}{RESET}{desc}")
|
||||
|
||||
# Frontends
|
||||
if not filter_type or filter_type == "frontend":
|
||||
frontends = config.frontends
|
||||
if frontends:
|
||||
any_output = True
|
||||
color = CATEGORY_COLORS["frontend"]
|
||||
print(f"\n{BOLD}{color}Frontends{RESET}")
|
||||
print(f"{color}{'─' * 40}{RESET}")
|
||||
for name, comp in frontends.items():
|
||||
desc = f" {DIM}{comp.description}{RESET}" if comp.description else ""
|
||||
print(f" {BOLD}{name}{RESET}{desc}")
|
||||
|
||||
if not any_output:
|
||||
print("No components found.")
|
||||
|
||||
if deployed is None:
|
||||
print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}")
|
||||
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def _list_json(
|
||||
config: object, deployed: dict | None, filter_type: str | None
|
||||
) -> int:
|
||||
"""Output JSON list of all entries."""
|
||||
output = []
|
||||
|
||||
if not filter_type or filter_type == "service":
|
||||
for name, svc in config.services.items():
|
||||
entry: dict = {
|
||||
"name": name,
|
||||
"category": "service",
|
||||
"deployed": deployed is not None and name in deployed,
|
||||
}
|
||||
if svc.description:
|
||||
entry["description"] = svc.description
|
||||
if svc.expose and svc.expose.http:
|
||||
entry["port"] = svc.expose.http.internal.port
|
||||
output.append(entry)
|
||||
|
||||
if not filter_type or filter_type == "job":
|
||||
for name, job in config.jobs.items():
|
||||
entry = {
|
||||
"name": name,
|
||||
"category": "job",
|
||||
"deployed": deployed is not None and name in deployed,
|
||||
"schedule": job.schedule,
|
||||
}
|
||||
if job.description:
|
||||
entry["description"] = job.description
|
||||
output.append(entry)
|
||||
|
||||
if not filter_type or filter_type == "tool":
|
||||
for name, comp in config.tools.items():
|
||||
entry = {"name": name, "category": "tool"}
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
output.append(entry)
|
||||
|
||||
if not filter_type or filter_type == "frontend":
|
||||
for name, comp in config.frontends.items():
|
||||
entry = {"name": name, "category": "frontend"}
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
output.append(entry)
|
||||
|
||||
print(json.dumps(output, indent=2))
|
||||
return 0
|
||||
|
||||
@@ -10,25 +10,22 @@ from castle_cli.config import load_config
|
||||
|
||||
|
||||
def run_logs(args: argparse.Namespace) -> int:
|
||||
"""View logs for a component."""
|
||||
"""View logs for a service or job."""
|
||||
config = load_config()
|
||||
name = args.name
|
||||
|
||||
if name not in config.components:
|
||||
print(f"Error: component '{name}' not found in castle.yaml")
|
||||
return 1
|
||||
|
||||
manifest = config.components[name]
|
||||
|
||||
# Container logs
|
||||
if manifest.run and manifest.run.runner == "container":
|
||||
# Check services
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
if svc.run.runner == "container":
|
||||
return _container_logs(name, args)
|
||||
|
||||
# Systemd logs (default for managed services)
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
return _systemd_logs(name, args)
|
||||
|
||||
print(f"Error: '{name}' has no log source (not systemd-managed or containerized)")
|
||||
# Check jobs
|
||||
if name in config.jobs:
|
||||
return _systemd_logs(name, args)
|
||||
|
||||
print(f"Error: '{name}' not found in services or jobs")
|
||||
return 1
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ from castle_core.generators.systemd import (
|
||||
SYSTEMD_USER_DIR,
|
||||
generate_timer,
|
||||
generate_unit_from_deployed,
|
||||
get_schedule_trigger,
|
||||
timer_name,
|
||||
unit_name,
|
||||
)
|
||||
@@ -21,6 +20,9 @@ from castle_cli.config import (
|
||||
load_config,
|
||||
)
|
||||
|
||||
# Re-export for use by other commands
|
||||
UNIT_PREFIX = "castle-"
|
||||
|
||||
|
||||
def _install_unit(uname: str, content: str) -> None:
|
||||
"""Write a systemd unit file."""
|
||||
@@ -76,7 +78,6 @@ def run_services(args: argparse.Namespace) -> int:
|
||||
|
||||
def _service_enable(config: CastleConfig, name: str) -> int:
|
||||
"""Enable and start a single service (or timer for scheduled jobs)."""
|
||||
# Require registry
|
||||
if not REGISTRY_PATH.exists():
|
||||
print("Error: no registry found. Run 'castle deploy' first.")
|
||||
return 1
|
||||
@@ -93,22 +94,29 @@ def _service_enable(config: CastleConfig, name: str) -> int:
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
# Get systemd spec from manifest for restart policy etc.
|
||||
# Get systemd spec from config (services or jobs)
|
||||
systemd_spec = None
|
||||
if name in config.components:
|
||||
manifest = config.components[name]
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
systemd_spec = manifest.manage.systemd
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
if svc.manage and svc.manage.systemd:
|
||||
systemd_spec = svc.manage.systemd
|
||||
elif name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
if job.manage and job.manage.systemd:
|
||||
systemd_spec = job.manage.systemd
|
||||
|
||||
# Generate and install the service unit from registry
|
||||
svc_unit = unit_name(name)
|
||||
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
_install_unit(svc_unit, svc_content)
|
||||
|
||||
# Check for timer (still uses manifest for schedule config)
|
||||
manifest = config.components.get(name)
|
||||
timer_content = generate_timer(name, manifest) if manifest else None
|
||||
if timer_content:
|
||||
# Check for timer (jobs have schedule)
|
||||
if deployed.schedule:
|
||||
timer_content = generate_timer(
|
||||
name,
|
||||
schedule=deployed.schedule,
|
||||
description=deployed.description,
|
||||
)
|
||||
tmr_unit = timer_name(name)
|
||||
_install_unit(tmr_unit, timer_content)
|
||||
|
||||
@@ -156,7 +164,6 @@ def _service_disable(name: str) -> int:
|
||||
|
||||
print(f"Disabling {name}...")
|
||||
|
||||
# Stop and disable timer if exists
|
||||
timer_path = SYSTEMD_USER_DIR / tmr_unit
|
||||
if timer_path.exists():
|
||||
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
|
||||
@@ -172,30 +179,11 @@ def _service_disable(name: str) -> int:
|
||||
|
||||
|
||||
def _service_status(config: CastleConfig) -> int:
|
||||
"""Show status of all managed services."""
|
||||
"""Show status of all managed services and jobs."""
|
||||
print("\nCastle Services")
|
||||
print("=" * 50)
|
||||
|
||||
for name, manifest in config.managed.items():
|
||||
is_scheduled = get_schedule_trigger(manifest) is not None
|
||||
|
||||
if is_scheduled:
|
||||
tmr_unit = timer_name(name)
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", tmr_unit],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
status = result.stdout.strip()
|
||||
if status in ("active", "waiting"):
|
||||
color = "\033[92m"
|
||||
elif status == "inactive":
|
||||
color = "\033[90m"
|
||||
else:
|
||||
color = "\033[91m"
|
||||
reset = "\033[0m"
|
||||
print(f" {color}{status:10s}{reset} {name} (timer)")
|
||||
else:
|
||||
for name, svc in config.services.items():
|
||||
svc_unit = unit_name(name)
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", svc_unit],
|
||||
@@ -212,36 +200,61 @@ def _service_status(config: CastleConfig) -> int:
|
||||
reset = "\033[0m"
|
||||
|
||||
port_str = ""
|
||||
if manifest.expose and manifest.expose.http:
|
||||
port_str = f":{manifest.expose.http.internal.port}"
|
||||
if svc.expose and svc.expose.http:
|
||||
port_str = f":{svc.expose.http.internal.port}"
|
||||
print(f" {color}{status:10s}{reset} {name}{port_str}")
|
||||
|
||||
if config.jobs:
|
||||
print(f"\n{'─' * 50}")
|
||||
print("Jobs")
|
||||
for name in config.jobs:
|
||||
tmr_unit = timer_name(name)
|
||||
result = subprocess.run(
|
||||
["systemctl", "--user", "is-active", tmr_unit],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
status = result.stdout.strip()
|
||||
if status in ("active", "waiting"):
|
||||
color = "\033[92m"
|
||||
elif status == "inactive":
|
||||
color = "\033[90m"
|
||||
else:
|
||||
color = "\033[91m"
|
||||
reset = "\033[0m"
|
||||
print(f" {color}{status:10s}{reset} {name} (timer)")
|
||||
|
||||
print()
|
||||
return 0
|
||||
|
||||
|
||||
def _service_dry_run(config: CastleConfig, name: str) -> int:
|
||||
"""Print the generated systemd unit(s) without installing."""
|
||||
# Try registry first, fall back to showing what deploy would generate
|
||||
if REGISTRY_PATH.exists():
|
||||
registry = load_registry()
|
||||
if name in registry.deployed:
|
||||
deployed = registry.deployed[name]
|
||||
systemd_spec = None
|
||||
if name in config.components:
|
||||
manifest = config.components[name]
|
||||
if manifest.manage and manifest.manage.systemd:
|
||||
systemd_spec = manifest.manage.systemd
|
||||
if name in config.services:
|
||||
svc = config.services[name]
|
||||
if svc.manage and svc.manage.systemd:
|
||||
systemd_spec = svc.manage.systemd
|
||||
elif name in config.jobs:
|
||||
job = config.jobs[name]
|
||||
if job.manage and job.manage.systemd:
|
||||
systemd_spec = job.manage.systemd
|
||||
|
||||
svc_unit = unit_name(name)
|
||||
svc_content = generate_unit_from_deployed(name, deployed, systemd_spec)
|
||||
print(f"# {svc_unit}")
|
||||
print(svc_content)
|
||||
|
||||
manifest = config.components.get(name)
|
||||
if manifest:
|
||||
timer_content = generate_timer(name, manifest)
|
||||
if timer_content:
|
||||
if deployed.schedule:
|
||||
timer_content = generate_timer(
|
||||
name,
|
||||
schedule=deployed.schedule,
|
||||
description=deployed.description,
|
||||
)
|
||||
print(f"# {timer_name(name)}")
|
||||
print(timer_content)
|
||||
return 0
|
||||
@@ -252,14 +265,12 @@ def _service_dry_run(config: CastleConfig, name: str) -> int:
|
||||
|
||||
def _services_start(config: CastleConfig) -> int:
|
||||
"""Start all managed services and gateway."""
|
||||
# Require registry
|
||||
if not REGISTRY_PATH.exists():
|
||||
print("Error: no registry found. Run 'castle deploy' first.")
|
||||
return 1
|
||||
|
||||
ensure_dirs()
|
||||
|
||||
# Generate Caddyfile from registry
|
||||
from castle_core.config import GENERATED_DIR
|
||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||
|
||||
@@ -268,7 +279,13 @@ def _services_start(config: CastleConfig) -> int:
|
||||
caddyfile_path.write_text(generate_caddyfile_from_registry(registry))
|
||||
print(f"Generated {caddyfile_path}")
|
||||
|
||||
for name in config.managed:
|
||||
for name in config.services:
|
||||
if name not in registry.deployed:
|
||||
print(f" {name}: skipped (not in registry, run 'castle deploy')")
|
||||
continue
|
||||
_service_enable(config, name)
|
||||
|
||||
for name in config.jobs:
|
||||
if name not in registry.deployed:
|
||||
print(f" {name}: skipped (not in registry, run 'castle deploy')")
|
||||
continue
|
||||
@@ -279,14 +296,17 @@ def _services_start(config: CastleConfig) -> int:
|
||||
|
||||
|
||||
def _services_stop(config: CastleConfig) -> int:
|
||||
"""Stop all managed services and gateway."""
|
||||
for name, manifest in config.managed.items():
|
||||
is_scheduled = get_schedule_trigger(manifest) is not None
|
||||
if is_scheduled:
|
||||
"""Stop all managed services and jobs."""
|
||||
for name in config.jobs:
|
||||
tmr_unit = timer_name(name)
|
||||
subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False)
|
||||
svc_unit = unit_name(name)
|
||||
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
|
||||
print(f" {name}: stopped")
|
||||
|
||||
for name in config.services:
|
||||
svc_unit = unit_name(name)
|
||||
subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False)
|
||||
print(f" {name}: stopped")
|
||||
|
||||
return 0
|
||||
|
||||
@@ -8,26 +8,6 @@ import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from castle_cli.config import ensure_dirs, load_config
|
||||
from castle_cli.manifest import ComponentManifest
|
||||
|
||||
|
||||
def _sync_cmd(manifest: ComponentManifest) -> list[str] | None:
|
||||
"""Derive the sync command from the manifest's runner."""
|
||||
run = manifest.run
|
||||
if run is None:
|
||||
# No runner — check for build commands (frontends)
|
||||
if manifest.build and manifest.build.commands:
|
||||
# Frontends declare build commands; infer from source dir at call site
|
||||
return None
|
||||
return None
|
||||
|
||||
match run.runner:
|
||||
case "python":
|
||||
return ["uv", "sync"]
|
||||
case "node":
|
||||
return [run.package_manager, "install"]
|
||||
case _:
|
||||
return None
|
||||
|
||||
|
||||
def run_sync(args: argparse.Namespace) -> int:
|
||||
@@ -44,11 +24,12 @@ def run_sync(args: argparse.Namespace) -> int:
|
||||
if result.returncode != 0:
|
||||
print("Warning: git submodule update failed (may not be a git repo)")
|
||||
|
||||
# Sync dependencies in each project
|
||||
# Sync dependencies in each component's source directory
|
||||
all_ok = True
|
||||
synced_dirs: set[Path] = set()
|
||||
for name, manifest in config.components.items():
|
||||
source_dir = manifest.source_dir
|
||||
|
||||
for name, comp in config.components.items():
|
||||
source_dir = comp.source_dir
|
||||
if not source_dir:
|
||||
continue
|
||||
project_dir = config.root / source_dir
|
||||
@@ -56,13 +37,15 @@ def run_sync(args: argparse.Namespace) -> int:
|
||||
if project_dir in synced_dirs or not project_dir.is_dir():
|
||||
continue
|
||||
|
||||
cmd = _sync_cmd(manifest)
|
||||
if cmd is None:
|
||||
# No runner — check if it's a frontend with a package.json
|
||||
if manifest.build and (project_dir / "package.json").exists():
|
||||
# Determine sync command based on project type
|
||||
cmd = None
|
||||
if (project_dir / "pyproject.toml").exists():
|
||||
cmd = ["uv", "sync"]
|
||||
elif (project_dir / "package.json").exists():
|
||||
pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm"
|
||||
cmd = [pm, "install"]
|
||||
else:
|
||||
|
||||
if cmd is None:
|
||||
continue
|
||||
|
||||
label = cmd[0]
|
||||
@@ -75,28 +58,33 @@ def run_sync(args: argparse.Namespace) -> int:
|
||||
print(" OK")
|
||||
synced_dirs.add(project_dir)
|
||||
|
||||
# Install components as uv tools or symlinks
|
||||
# Install components and python-runner services as uv tools
|
||||
uv_path = shutil.which("uv") or "uv"
|
||||
installed_dirs: set[Path] = set()
|
||||
|
||||
for name, manifest in config.components.items():
|
||||
# Install if: has install.path, or is a python runner service
|
||||
if not (
|
||||
(manifest.install and manifest.install.path)
|
||||
or (manifest.run and manifest.run.runner == "python")
|
||||
):
|
||||
# Install components with install.path
|
||||
for name, comp in config.components.items():
|
||||
if not (comp.install and comp.install.path):
|
||||
continue
|
||||
|
||||
source = manifest.source_dir
|
||||
source = comp.source_dir
|
||||
if not source:
|
||||
continue
|
||||
_try_install(config.root / source, name, comp, uv_path, installed_dirs)
|
||||
|
||||
# Install python-runner services
|
||||
for name, svc in config.services.items():
|
||||
if svc.run.runner != "python":
|
||||
continue
|
||||
# Find source from component reference
|
||||
source = None
|
||||
if svc.component and svc.component in config.components:
|
||||
source = config.components[svc.component].source_dir
|
||||
if not source:
|
||||
continue
|
||||
source_dir = config.root / source
|
||||
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
# Python package — uv tool install
|
||||
if source_dir in installed_dirs:
|
||||
continue
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
print(f"\nInstalling {name}...")
|
||||
result = subprocess.run(
|
||||
[uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
|
||||
@@ -113,21 +101,53 @@ def run_sync(args: argparse.Namespace) -> int:
|
||||
print(f" {name}: OK")
|
||||
installed_dirs.add(source_dir)
|
||||
|
||||
elif source_dir.is_file():
|
||||
# Script file — symlink to ~/.local/bin/
|
||||
alias = name
|
||||
if manifest.install and manifest.install.path and manifest.install.path.alias:
|
||||
alias = manifest.install.path.alias
|
||||
if not shutil.which(alias):
|
||||
link = Path.home() / ".local" / "bin" / alias
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not link.exists():
|
||||
link.symlink_to(source_dir)
|
||||
print(f"\n Linked {alias} → {source_dir}")
|
||||
|
||||
if all_ok:
|
||||
print("\nAll projects synced.")
|
||||
else:
|
||||
print("\nSync completed with warnings.")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
def _try_install(
|
||||
source_dir: Path,
|
||||
name: str,
|
||||
comp: object,
|
||||
uv_path: str,
|
||||
installed_dirs: set[Path],
|
||||
) -> bool:
|
||||
"""Try to install a component. Returns True if installed."""
|
||||
if source_dir in installed_dirs:
|
||||
return False
|
||||
|
||||
if (source_dir / "pyproject.toml").exists():
|
||||
print(f"\nInstalling {name}...")
|
||||
result = subprocess.run(
|
||||
[uv_path, "tool", "install", "--editable", str(source_dir), "--force"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
if "already installed" in result.stderr.lower():
|
||||
print(f" {name}: already installed")
|
||||
else:
|
||||
print(f" Warning: {result.stderr.strip()}")
|
||||
return False
|
||||
else:
|
||||
print(f" {name}: OK")
|
||||
installed_dirs.add(source_dir)
|
||||
return True
|
||||
|
||||
elif source_dir.is_file():
|
||||
alias = name
|
||||
if comp.install and comp.install.path and comp.install.path.alias:
|
||||
alias = comp.install.path.alias
|
||||
if not shutil.which(alias):
|
||||
link = Path.home() / ".local" / "bin" / alias
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not link.exists():
|
||||
link.symlink_to(source_dir)
|
||||
print(f"\n Linked {alias} → {source_dir}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -67,8 +67,8 @@ def _tool_info(name: str) -> int:
|
||||
if manifest.description:
|
||||
print(f" {manifest.description}")
|
||||
print(f" {BOLD}version{RESET}: {t.version}")
|
||||
if t.source:
|
||||
print(f" {BOLD}source{RESET}: {t.source}")
|
||||
if manifest.source:
|
||||
print(f" {BOLD}source{RESET}: {manifest.source}")
|
||||
if t.system_dependencies:
|
||||
print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}")
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
# castle list
|
||||
list_parser = subparsers.add_parser("list", help="List all components")
|
||||
list_parser.add_argument(
|
||||
"--role",
|
||||
choices=["service", "tool", "worker", "job", "frontend", "remote", "containerized"],
|
||||
help="Filter by role",
|
||||
"--type",
|
||||
choices=["service", "job", "tool", "frontend"],
|
||||
help="Filter by type",
|
||||
)
|
||||
list_parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
BuildSpec,
|
||||
CaddySpec,
|
||||
Capability,
|
||||
ComponentManifest,
|
||||
ComponentSpec,
|
||||
DefaultsSpec,
|
||||
EnvMap,
|
||||
ExposeSpec,
|
||||
@@ -13,12 +13,12 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
HttpInternal,
|
||||
HttpPublic,
|
||||
InstallSpec,
|
||||
JobSpec,
|
||||
ManageSpec,
|
||||
PathInstallSpec,
|
||||
ProxySpec,
|
||||
ReadinessHttpGet,
|
||||
RestartPolicy,
|
||||
Role,
|
||||
RunBase,
|
||||
RunCommand,
|
||||
RunContainer,
|
||||
@@ -26,12 +26,8 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ
|
||||
RunPython,
|
||||
RunRemote,
|
||||
RunSpec,
|
||||
ServiceSpec,
|
||||
SystemdSpec,
|
||||
TLSMode,
|
||||
ToolSpec,
|
||||
TriggerEvent,
|
||||
TriggerManual,
|
||||
TriggerRequest,
|
||||
TriggerSchedule,
|
||||
TriggerSpec,
|
||||
)
|
||||
|
||||
@@ -16,9 +16,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
config = {
|
||||
"gateway": {"port": 18000},
|
||||
"components": {
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"install": {
|
||||
"path": {"alias": "test-tool"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"services": {
|
||||
"test-svc": {
|
||||
"component": "test-svc-comp",
|
||||
"description": "Test service",
|
||||
"source": "test-svc",
|
||||
"run": {
|
||||
"runner": "python",
|
||||
"tool": "test-svc",
|
||||
@@ -39,11 +47,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"systemd": {},
|
||||
},
|
||||
},
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"install": {
|
||||
"path": {"alias": "test-tool"},
|
||||
},
|
||||
"jobs": {
|
||||
"test-job": {
|
||||
"description": "Test job",
|
||||
"run": {
|
||||
"runner": "command",
|
||||
"argv": ["test-job"],
|
||||
},
|
||||
"schedule": "0 2 * * *",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from castle_cli.config import load_config
|
||||
from castle_cli.manifest import Role
|
||||
|
||||
|
||||
class TestCreateCommand:
|
||||
@@ -42,11 +41,12 @@ class TestCreateCommand:
|
||||
assert (project_dir / "tests" / "test_health.py").exists()
|
||||
assert (project_dir / "CLAUDE.md").exists()
|
||||
|
||||
# Verify registered as ComponentManifest
|
||||
# Verify registered as ComponentSpec + ServiceSpec
|
||||
assert "my-api" in config.components
|
||||
manifest = config.components["my-api"]
|
||||
assert Role.SERVICE in manifest.roles
|
||||
assert manifest.expose.http.internal.port == 9050
|
||||
assert "my-api" in config.services
|
||||
svc = config.services["my-api"]
|
||||
assert svc.expose.http.internal.port == 9050
|
||||
assert svc.component == "my-api"
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_create_tool(self, castle_root: Path) -> None:
|
||||
@@ -60,17 +60,18 @@ class TestCreateCommand:
|
||||
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
args = Namespace(name="my-tool", type="tool", description="My tool", port=None)
|
||||
args = Namespace(name="my-tool2", type="tool", description="My tool", port=None)
|
||||
result = run_create(args)
|
||||
|
||||
assert result == 0
|
||||
project_dir = castle_root / "components" / "my-tool"
|
||||
project_dir = castle_root / "components" / "my-tool2"
|
||||
assert project_dir.exists()
|
||||
assert (project_dir / "src" / "my_tool" / "main.py").exists()
|
||||
assert (project_dir / "src" / "my_tool2" / "main.py").exists()
|
||||
assert (project_dir / "CLAUDE.md").exists()
|
||||
assert "my-tool" in config.components
|
||||
manifest = config.components["my-tool"]
|
||||
assert Role.TOOL in manifest.roles
|
||||
assert "my-tool2" in config.components
|
||||
comp = config.components["my-tool2"]
|
||||
assert comp.tool is not None
|
||||
assert comp.install is not None
|
||||
|
||||
def test_create_library(self, castle_root: Path) -> None:
|
||||
"""Create a new library project."""
|
||||
@@ -101,6 +102,7 @@ class TestCreateCommand:
|
||||
|
||||
from castle_cli.commands.create import run_create
|
||||
|
||||
# test-svc exists in the services section
|
||||
args = Namespace(
|
||||
name="test-svc",
|
||||
type="service",
|
||||
@@ -130,8 +132,8 @@ class TestCreateCommand:
|
||||
)
|
||||
run_create(args)
|
||||
|
||||
manifest = config.components["auto-port-svc"]
|
||||
port = manifest.expose.http.internal.port
|
||||
svc = config.services["auto-port-svc"]
|
||||
port = svc.expose.http.internal.port
|
||||
# Port 18000 is gateway, 19000 is test-svc, so next should be 9001+
|
||||
assert port is not None
|
||||
assert port not in (18000, 19000)
|
||||
|
||||
@@ -59,8 +59,8 @@ class TestInfoCommand:
|
||||
output = capsys.readouterr().out
|
||||
assert "not found" in output
|
||||
|
||||
def test_info_json(self, castle_root: Path, capsys) -> None:
|
||||
"""--json produces valid JSON with manifest fields."""
|
||||
def test_info_json_service(self, castle_root: Path, capsys) -> None:
|
||||
"""--json produces valid JSON with service fields."""
|
||||
from castle_cli.config import load_config
|
||||
|
||||
with patch("castle_cli.commands.info.load_config") as mock_load:
|
||||
@@ -73,48 +73,8 @@ class TestInfoCommand:
|
||||
assert result == 0
|
||||
output = capsys.readouterr().out
|
||||
data = json.loads(output)
|
||||
assert data["id"] == "test-svc"
|
||||
assert "service" in data["roles"]
|
||||
assert data["expose"]["http"]["internal"]["port"] == 19000
|
||||
|
||||
def test_info_shows_claude_md(self, castle_root: Path, capsys) -> None:
|
||||
"""Info shows CLAUDE.md content when present."""
|
||||
project_dir = castle_root / "test-svc"
|
||||
project_dir.mkdir(exist_ok=True)
|
||||
(project_dir / "CLAUDE.md").write_text("# Test Service\n\nSome docs here.")
|
||||
|
||||
from castle_cli.config import load_config
|
||||
|
||||
with patch("castle_cli.commands.info.load_config") as mock_load:
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
|
||||
from castle_cli.commands.info import run_info
|
||||
|
||||
result = run_info(Namespace(project="test-svc", json=False))
|
||||
|
||||
assert result == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "Some docs here" in output
|
||||
|
||||
def test_info_json_includes_claude_md(self, castle_root: Path, capsys) -> None:
|
||||
"""--json includes claude_md field when CLAUDE.md exists."""
|
||||
project_dir = castle_root / "test-svc"
|
||||
project_dir.mkdir(exist_ok=True)
|
||||
(project_dir / "CLAUDE.md").write_text("# Docs\n")
|
||||
|
||||
from castle_cli.config import load_config
|
||||
|
||||
with patch("castle_cli.commands.info.load_config") as mock_load:
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
|
||||
from castle_cli.commands.info import run_info
|
||||
|
||||
result = run_info(Namespace(project="test-svc", json=True))
|
||||
|
||||
assert result == 0
|
||||
data = json.loads(capsys.readouterr().out)
|
||||
assert "claude_md" in data
|
||||
assert "# Docs" in data["claude_md"]
|
||||
assert data["category"] == "service"
|
||||
assert data["service"]["expose"]["http"]["internal"]["port"] == 19000
|
||||
|
||||
def test_info_shows_env(self, castle_root: Path, capsys) -> None:
|
||||
"""Info displays environment variables."""
|
||||
@@ -131,8 +91,8 @@ class TestInfoCommand:
|
||||
output = capsys.readouterr().out
|
||||
assert "TEST_SVC_DATA_DIR" in output
|
||||
|
||||
def test_info_shows_roles(self, castle_root: Path, capsys) -> None:
|
||||
"""Info displays derived roles."""
|
||||
def test_info_shows_category(self, castle_root: Path, capsys) -> None:
|
||||
"""Info displays category instead of roles."""
|
||||
from castle_cli.config import load_config
|
||||
|
||||
with patch("castle_cli.commands.info.load_config") as mock_load:
|
||||
@@ -144,5 +104,5 @@ class TestInfoCommand:
|
||||
|
||||
assert result == 0
|
||||
output = capsys.readouterr().out
|
||||
assert "roles" in output
|
||||
assert "category" in output
|
||||
assert "service" in output
|
||||
|
||||
@@ -19,7 +19,7 @@ class TestListCommand:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(role=None, json=False)
|
||||
args = Namespace(type=None, json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -27,27 +27,13 @@ class TestListCommand:
|
||||
assert "test-svc" in captured.out
|
||||
assert "test-tool" in captured.out
|
||||
|
||||
def test_list_filter_role(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered by role."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(role="tool", json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr() # type: ignore[attr-defined]
|
||||
assert "test-tool" in captured.out
|
||||
assert "test-svc" not in captured.out
|
||||
|
||||
def test_list_filter_service(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered to services."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(role="service", json=False)
|
||||
args = Namespace(type="service", json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -55,13 +41,41 @@ class TestListCommand:
|
||||
assert "test-svc" in captured.out
|
||||
assert "test-tool" not in captured.out
|
||||
|
||||
def test_list_filter_tool(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered to tools."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(type="tool", json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr() # type: ignore[attr-defined]
|
||||
assert "test-tool" in captured.out
|
||||
assert "test-svc" not in captured.out
|
||||
|
||||
def test_list_filter_job(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List filtered to jobs."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(type="job", json=False)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
captured = capsys.readouterr() # type: ignore[attr-defined]
|
||||
assert "test-job" in captured.out
|
||||
assert "test-svc" not in captured.out
|
||||
|
||||
def test_list_json(self, castle_root: Path, capsys: object) -> None:
|
||||
"""List output as JSON."""
|
||||
with patch("castle_cli.commands.list_cmd.load_config") as mock_load:
|
||||
from castle_cli.config import load_config
|
||||
|
||||
mock_load.return_value = load_config(castle_root)
|
||||
args = Namespace(role=None, json=True)
|
||||
args = Namespace(type=None, json=True)
|
||||
result = run_list(args)
|
||||
|
||||
assert result == 0
|
||||
@@ -71,5 +85,4 @@ class TestListCommand:
|
||||
assert "test-svc" in names
|
||||
assert "test-tool" in names
|
||||
svc = next(p for p in data if p["name"] == "test-svc")
|
||||
assert "roles" in svc
|
||||
assert "service" in svc["roles"]
|
||||
assert svc["category"] == "service"
|
||||
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from castle_core.manifest import ComponentManifest, Role
|
||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
||||
|
||||
|
||||
def find_castle_root() -> Path:
|
||||
@@ -47,36 +47,30 @@ class CastleConfig:
|
||||
|
||||
root: Path
|
||||
gateway: GatewayConfig
|
||||
components: dict[str, ComponentManifest]
|
||||
components: dict[str, ComponentSpec]
|
||||
services: dict[str, ServiceSpec]
|
||||
jobs: dict[str, JobSpec]
|
||||
|
||||
@property
|
||||
def services(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components with the SERVICE role."""
|
||||
return {k: v for k, v in self.components.items() if Role.SERVICE in v.roles}
|
||||
|
||||
@property
|
||||
def tools(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components with the TOOL role."""
|
||||
return {k: v for k, v in self.components.items() if Role.TOOL in v.roles}
|
||||
|
||||
@property
|
||||
def workers(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components with the WORKER role."""
|
||||
return {k: v for k, v in self.components.items() if Role.WORKER in v.roles}
|
||||
|
||||
@property
|
||||
def managed(self) -> dict[str, ComponentManifest]:
|
||||
"""Return components managed by systemd."""
|
||||
def tools(self) -> dict[str, ComponentSpec]:
|
||||
"""Return components that are tools (have install.path or tool spec)."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in self.components.items()
|
||||
if v.manage and v.manage.systemd and v.manage.systemd.enable
|
||||
if (v.install and v.install.path) or v.tool
|
||||
}
|
||||
|
||||
@property
|
||||
def frontends(self) -> dict[str, ComponentSpec]:
|
||||
"""Return components that are frontends (have build outputs)."""
|
||||
return {
|
||||
k: v
|
||||
for k, v in self.components.items()
|
||||
if v.build and (v.build.outputs or v.build.commands)
|
||||
}
|
||||
|
||||
|
||||
def resolve_env_vars(
|
||||
env: dict[str, str], manifest: ComponentManifest
|
||||
) -> dict[str, str]:
|
||||
def resolve_env_vars(env: dict[str, str]) -> dict[str, str]:
|
||||
"""Resolve ${secret:NAME} references in env values."""
|
||||
resolved = {}
|
||||
for key, value in env.items():
|
||||
@@ -100,11 +94,25 @@ def _read_secret(name: str) -> str:
|
||||
return f"<MISSING_SECRET:{name}>"
|
||||
|
||||
|
||||
def _parse_component(name: str, data: dict) -> ComponentManifest:
|
||||
"""Parse a components: entry directly into a ComponentManifest."""
|
||||
def _parse_component(name: str, data: dict) -> ComponentSpec:
|
||||
"""Parse a components: entry into a ComponentSpec."""
|
||||
data_copy = dict(data)
|
||||
data_copy["id"] = name
|
||||
return ComponentManifest.model_validate(data_copy)
|
||||
return ComponentSpec.model_validate(data_copy)
|
||||
|
||||
|
||||
def _parse_service(name: str, data: dict) -> ServiceSpec:
|
||||
"""Parse a services: entry into a ServiceSpec."""
|
||||
data_copy = dict(data)
|
||||
data_copy["id"] = name
|
||||
return ServiceSpec.model_validate(data_copy)
|
||||
|
||||
|
||||
def _parse_job(name: str, data: dict) -> JobSpec:
|
||||
"""Parse a jobs: entry into a JobSpec."""
|
||||
data_copy = dict(data)
|
||||
data_copy["id"] = name
|
||||
return JobSpec.model_validate(data_copy)
|
||||
|
||||
|
||||
def load_config(root: Path | None = None) -> CastleConfig:
|
||||
@@ -122,11 +130,25 @@ def load_config(root: Path | None = None) -> CastleConfig:
|
||||
gateway_data = data.get("gateway", {})
|
||||
gateway = GatewayConfig(port=gateway_data.get("port", 9000))
|
||||
|
||||
components: dict[str, ComponentManifest] = {}
|
||||
components: dict[str, ComponentSpec] = {}
|
||||
for name, comp_data in data.get("components", {}).items():
|
||||
components[name] = _parse_component(name, comp_data)
|
||||
|
||||
return CastleConfig(root=root, gateway=gateway, components=components)
|
||||
services: dict[str, ServiceSpec] = {}
|
||||
for name, svc_data in data.get("services", {}).items():
|
||||
services[name] = _parse_service(name, svc_data)
|
||||
|
||||
jobs: dict[str, JobSpec] = {}
|
||||
for name, job_data in data.get("jobs", {}).items():
|
||||
jobs[name] = _parse_job(name, job_data)
|
||||
|
||||
return CastleConfig(
|
||||
root=root,
|
||||
gateway=gateway,
|
||||
components=components,
|
||||
services=services,
|
||||
jobs=jobs,
|
||||
)
|
||||
|
||||
|
||||
def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object:
|
||||
@@ -165,11 +187,12 @@ _STRUCTURAL_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict:
|
||||
"""Serialize a manifest to a YAML-friendly dict, preserving structural presence."""
|
||||
full = manifest.model_dump(mode="json", exclude_none=True, exclude={"id", "roles"})
|
||||
minimal = manifest.model_dump(
|
||||
mode="json", exclude_none=True, exclude={"id", "roles"}, exclude_defaults=True
|
||||
def _spec_to_yaml_dict(spec: ComponentSpec | ServiceSpec | JobSpec) -> dict:
|
||||
"""Serialize a spec to a YAML-friendly dict, preserving structural presence."""
|
||||
exclude_fields = {"id"}
|
||||
full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields)
|
||||
minimal = spec.model_dump(
|
||||
mode="json", exclude_none=True, exclude=exclude_fields, exclude_defaults=True
|
||||
)
|
||||
|
||||
def merge(full_val: object, min_val: object | None, key: str = "") -> object:
|
||||
@@ -203,10 +226,22 @@ def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict:
|
||||
|
||||
def save_config(config: CastleConfig) -> None:
|
||||
"""Save castle configuration to castle.yaml."""
|
||||
data: dict = {"gateway": {"port": config.gateway.port}, "components": {}}
|
||||
data: dict = {"gateway": {"port": config.gateway.port}}
|
||||
|
||||
for name, manifest in config.components.items():
|
||||
data["components"][name] = _manifest_to_yaml_dict(manifest)
|
||||
if config.components:
|
||||
data["components"] = {}
|
||||
for name, spec in config.components.items():
|
||||
data["components"][name] = _spec_to_yaml_dict(spec)
|
||||
|
||||
if config.services:
|
||||
data["services"] = {}
|
||||
for name, spec in config.services.items():
|
||||
data["services"][name] = _spec_to_yaml_dict(spec)
|
||||
|
||||
if config.jobs:
|
||||
data["jobs"] = {}
|
||||
for name, spec in config.jobs.items():
|
||||
data["jobs"][name] = _spec_to_yaml_dict(spec)
|
||||
|
||||
config_path = config.root / "castle.yaml"
|
||||
with open(config_path, "w") as f:
|
||||
|
||||
@@ -8,7 +8,6 @@ from castle_core.generators.systemd import (
|
||||
cron_to_oncalendar,
|
||||
generate_timer,
|
||||
generate_unit_from_deployed,
|
||||
get_schedule_trigger,
|
||||
timer_name,
|
||||
unit_name,
|
||||
)
|
||||
@@ -19,7 +18,6 @@ __all__ = [
|
||||
"generate_caddyfile_from_registry",
|
||||
"generate_timer",
|
||||
"generate_unit_from_deployed",
|
||||
"get_schedule_trigger",
|
||||
"timer_name",
|
||||
"unit_name",
|
||||
]
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from castle_core.manifest import ComponentManifest, RestartPolicy, SystemdSpec
|
||||
from castle_core.manifest import RestartPolicy, SystemdSpec
|
||||
from castle_core.registry import DeployedComponent
|
||||
|
||||
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
|
||||
@@ -22,14 +22,6 @@ def timer_name(service_name: str) -> str:
|
||||
return f"{UNIT_PREFIX}{service_name}.timer"
|
||||
|
||||
|
||||
def get_schedule_trigger(manifest: ComponentManifest) -> object | None:
|
||||
"""Return the schedule trigger if one exists, else None."""
|
||||
for t in manifest.triggers:
|
||||
if getattr(t, "type", None) == "schedule":
|
||||
return t
|
||||
return None
|
||||
|
||||
|
||||
def cron_to_oncalendar(cron: str) -> str:
|
||||
"""Best-effort conversion of cron expression to systemd OnCalendar.
|
||||
|
||||
@@ -142,17 +134,17 @@ WantedBy={wanted_by}
|
||||
return unit
|
||||
|
||||
|
||||
def generate_timer(name: str, manifest: ComponentManifest) -> str | None:
|
||||
"""Generate a systemd timer unit if the component has a schedule trigger."""
|
||||
trigger = get_schedule_trigger(manifest)
|
||||
if trigger is None:
|
||||
return None
|
||||
|
||||
description = manifest.description or name
|
||||
def generate_timer(
|
||||
name: str,
|
||||
schedule: str,
|
||||
description: str | None = None,
|
||||
) -> str:
|
||||
"""Generate a systemd timer unit from a cron schedule string."""
|
||||
description = description or name
|
||||
|
||||
# Try to convert cron to OnCalendar, fall back to OnUnitActiveSec
|
||||
on_calendar = cron_to_oncalendar(trigger.cron)
|
||||
interval_sec = cron_to_interval_sec(trigger.cron)
|
||||
on_calendar = cron_to_oncalendar(schedule)
|
||||
interval_sec = cron_to_interval_sec(schedule)
|
||||
|
||||
timer_lines = ""
|
||||
if on_calendar:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""Castle component manifest — Pydantic models for the component registry."""
|
||||
"""Castle manifest models — component specs, service specs, job specs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Annotated, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
EnvMap = dict[str, str]
|
||||
|
||||
@@ -22,16 +22,6 @@ class TLSMode(str, Enum):
|
||||
LETSENCRYPT = "letsencrypt"
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
TOOL = "tool"
|
||||
SERVICE = "service"
|
||||
WORKER = "worker"
|
||||
FRONTEND = "frontend"
|
||||
JOB = "job"
|
||||
REMOTE = "remote"
|
||||
CONTAINERIZED = "containerized"
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Run specs (discriminated union)
|
||||
# ---------------------
|
||||
@@ -82,36 +72,6 @@ RunSpec = Annotated[
|
||||
]
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Triggers
|
||||
# ---------------------
|
||||
|
||||
|
||||
class TriggerManual(BaseModel):
|
||||
type: Literal["manual"] = "manual"
|
||||
|
||||
|
||||
class TriggerSchedule(BaseModel):
|
||||
type: Literal["schedule"] = "schedule"
|
||||
cron: str
|
||||
timezone: str = "America/Los_Angeles"
|
||||
|
||||
|
||||
class TriggerEvent(BaseModel):
|
||||
type: Literal["event"] = "event"
|
||||
source: str
|
||||
topic: str | None = None
|
||||
queue: str | None = None
|
||||
|
||||
|
||||
class TriggerRequest(BaseModel):
|
||||
type: Literal["request"] = "request"
|
||||
protocol: Literal["http", "https", "grpc"] = "http"
|
||||
|
||||
|
||||
TriggerSpec = Union[TriggerManual, TriggerSchedule, TriggerEvent, TriggerRequest]
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Systemd management
|
||||
# ---------------------
|
||||
@@ -166,7 +126,6 @@ class ToolSpec(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
version: str = "1.0.0"
|
||||
source: str | None = None
|
||||
system_dependencies: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -238,90 +197,77 @@ class DefaultsSpec(BaseModel):
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Component manifest
|
||||
# Component spec — software identity
|
||||
# ---------------------
|
||||
|
||||
|
||||
class ComponentManifest(BaseModel):
|
||||
class ComponentSpec(BaseModel):
|
||||
"""Software catalog entry — what exists."""
|
||||
|
||||
id: str = ""
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
source: str | None = None
|
||||
|
||||
run: RunSpec | None = None
|
||||
|
||||
triggers: list[TriggerSpec] = Field(default_factory=list)
|
||||
|
||||
manage: ManageSpec | None = None
|
||||
install: InstallSpec | None = None
|
||||
tool: ToolSpec | None = None
|
||||
expose: ExposeSpec | None = None
|
||||
proxy: ProxySpec | None = None
|
||||
build: BuildSpec | None = None
|
||||
|
||||
defaults: DefaultsSpec | None = None
|
||||
|
||||
provides: list[Capability] = Field(default_factory=list)
|
||||
consumes: list[Capability] = Field(default_factory=list)
|
||||
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def roles(self) -> list[Role]:
|
||||
roles: set[Role] = set()
|
||||
|
||||
if self.run:
|
||||
if self.run.runner == "remote":
|
||||
roles.add(Role.REMOTE)
|
||||
if self.run.runner == "container":
|
||||
roles.add(Role.CONTAINERIZED)
|
||||
|
||||
if self.install and self.install.path and self.install.path.enable:
|
||||
roles.add(Role.TOOL)
|
||||
|
||||
if self.tool:
|
||||
roles.add(Role.TOOL)
|
||||
|
||||
if self.expose and self.expose.http:
|
||||
roles.add(Role.SERVICE)
|
||||
|
||||
if (
|
||||
self.manage
|
||||
and self.manage.systemd
|
||||
and self.manage.systemd.enable
|
||||
and not (self.expose and self.expose.http)
|
||||
):
|
||||
roles.add(Role.WORKER)
|
||||
|
||||
if self.build and (self.build.outputs or self.build.commands):
|
||||
roles.add(Role.FRONTEND)
|
||||
|
||||
if any(getattr(t, "type", None) == "schedule" for t in self.triggers):
|
||||
roles.add(Role.JOB)
|
||||
|
||||
if not roles:
|
||||
roles.add(Role.TOOL)
|
||||
|
||||
return sorted(roles, key=lambda r: r.value)
|
||||
|
||||
@property
|
||||
def source_dir(self) -> str | None:
|
||||
"""Best-effort relative directory for this component's source.
|
||||
|
||||
Resolution order: source → tool.source (strip trailing /).
|
||||
Returns None if no directory can be determined.
|
||||
"""
|
||||
"""Relative directory for this component's source, or None."""
|
||||
if self.source:
|
||||
return self.source.rstrip("/")
|
||||
if self.tool and self.tool.source:
|
||||
return self.tool.source.rstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Service spec — long-running daemon
|
||||
# ---------------------
|
||||
|
||||
|
||||
class ServiceSpec(BaseModel):
|
||||
"""Long-running daemon deployment config."""
|
||||
|
||||
id: str = ""
|
||||
component: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
run: RunSpec
|
||||
|
||||
expose: ExposeSpec | None = None
|
||||
proxy: ProxySpec | None = None
|
||||
manage: ManageSpec | None = None
|
||||
defaults: DefaultsSpec | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _basic_consistency(self) -> ComponentManifest:
|
||||
def _validate_consistency(self) -> ServiceSpec:
|
||||
if self.manage and self.manage.systemd and self.manage.systemd.enable:
|
||||
if self.run and self.run.runner == "remote":
|
||||
if self.run.runner == "remote":
|
||||
raise ValueError("manage.systemd cannot be enabled for runner=remote.")
|
||||
return self
|
||||
|
||||
|
||||
# ---------------------
|
||||
# Job spec — scheduled task
|
||||
# ---------------------
|
||||
|
||||
|
||||
class JobSpec(BaseModel):
|
||||
"""Scheduled task that runs periodically and exits."""
|
||||
|
||||
id: str = ""
|
||||
component: str | None = None
|
||||
description: str | None = None
|
||||
|
||||
run: RunSpec
|
||||
schedule: str
|
||||
timezone: str = "America/Los_Angeles"
|
||||
|
||||
manage: ManageSpec | None = None
|
||||
defaults: DefaultsSpec | None = None
|
||||
|
||||
@@ -35,7 +35,7 @@ class DeployedComponent:
|
||||
run_cmd: list[str]
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
description: str | None = None
|
||||
roles: list[str] = field(default_factory=list)
|
||||
category: str = "service"
|
||||
port: int | None = None
|
||||
health_path: str | None = None
|
||||
proxy_path: str | None = None
|
||||
@@ -82,7 +82,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||
run_cmd=comp_data.get("run_cmd", []),
|
||||
env=comp_data.get("env", {}),
|
||||
description=comp_data.get("description"),
|
||||
roles=comp_data.get("roles", []),
|
||||
category=comp_data.get("category", "service"),
|
||||
port=comp_data.get("port"),
|
||||
health_path=comp_data.get("health_path"),
|
||||
proxy_path=comp_data.get("proxy_path"),
|
||||
@@ -120,8 +120,7 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None:
|
||||
entry["env"] = comp.env
|
||||
if comp.description:
|
||||
entry["description"] = comp.description
|
||||
if comp.roles:
|
||||
entry["roles"] = comp.roles
|
||||
entry["category"] = comp.category
|
||||
if comp.port is not None:
|
||||
entry["port"] = comp.port
|
||||
if comp.health_path:
|
||||
|
||||
@@ -16,9 +16,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
config = {
|
||||
"gateway": {"port": 18000},
|
||||
"components": {
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"install": {
|
||||
"path": {"alias": "test-tool"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"services": {
|
||||
"test-svc": {
|
||||
"component": "test-svc-comp",
|
||||
"description": "Test service",
|
||||
"source": "test-svc",
|
||||
"run": {
|
||||
"runner": "python",
|
||||
"tool": "test-svc",
|
||||
@@ -39,11 +47,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
"systemd": {},
|
||||
},
|
||||
},
|
||||
"test-tool": {
|
||||
"description": "Test tool",
|
||||
"install": {
|
||||
"path": {"alias": "test-tool"},
|
||||
},
|
||||
"jobs": {
|
||||
"test-job": {
|
||||
"description": "Test job",
|
||||
"run": {
|
||||
"runner": "command",
|
||||
"argv": ["test-job"],
|
||||
},
|
||||
"schedule": "0 2 * * *",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -11,82 +11,64 @@ from castle_core.config import (
|
||||
resolve_env_vars,
|
||||
save_config,
|
||||
)
|
||||
from castle_core.manifest import ComponentManifest, Role
|
||||
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
"""Tests for loading castle.yaml."""
|
||||
|
||||
def test_load_basic(self, castle_root: Path) -> None:
|
||||
"""Load a castle.yaml."""
|
||||
"""Load a castle.yaml with three sections."""
|
||||
config = load_config(castle_root)
|
||||
assert isinstance(config, CastleConfig)
|
||||
assert config.gateway.port == 18000
|
||||
assert "test-svc" in config.components
|
||||
assert "test-tool" in config.components
|
||||
assert "test-svc" in config.services
|
||||
assert "test-job" in config.jobs
|
||||
|
||||
def test_load_produces_manifests(self, castle_root: Path) -> None:
|
||||
"""Components are ComponentManifest objects."""
|
||||
def test_load_produces_typed_specs(self, castle_root: Path) -> None:
|
||||
"""Each section produces the correct spec type."""
|
||||
config = load_config(castle_root)
|
||||
assert isinstance(config.components["test-svc"], ComponentManifest)
|
||||
assert isinstance(config.components["test-tool"], ComponentManifest)
|
||||
|
||||
def test_service_roles(self, castle_root: Path) -> None:
|
||||
"""Service with expose.http gets SERVICE role."""
|
||||
config = load_config(castle_root)
|
||||
svc = config.components["test-svc"]
|
||||
assert Role.SERVICE in svc.roles
|
||||
|
||||
def test_tool_roles(self, castle_root: Path) -> None:
|
||||
"""Tool with install.path gets TOOL role."""
|
||||
config = load_config(castle_root)
|
||||
tool = config.components["test-tool"]
|
||||
assert Role.TOOL in tool.roles
|
||||
assert isinstance(config.components["test-tool"], ComponentSpec)
|
||||
assert isinstance(config.services["test-svc"], ServiceSpec)
|
||||
assert isinstance(config.jobs["test-job"], JobSpec)
|
||||
|
||||
def test_service_expose(self, castle_root: Path) -> None:
|
||||
"""Service has correct expose spec."""
|
||||
config = load_config(castle_root)
|
||||
svc = config.components["test-svc"]
|
||||
svc = config.services["test-svc"]
|
||||
assert svc.expose.http.internal.port == 19000
|
||||
assert svc.expose.http.health_path == "/health"
|
||||
|
||||
def test_service_proxy(self, castle_root: Path) -> None:
|
||||
"""Service has correct proxy spec."""
|
||||
config = load_config(castle_root)
|
||||
svc = config.components["test-svc"]
|
||||
svc = config.services["test-svc"]
|
||||
assert svc.proxy.caddy.path_prefix == "/test-svc"
|
||||
|
||||
def test_service_run_spec(self, castle_root: Path) -> None:
|
||||
"""Service has correct RunSpec."""
|
||||
config = load_config(castle_root)
|
||||
svc = config.components["test-svc"]
|
||||
svc = config.services["test-svc"]
|
||||
assert svc.run.runner == "python"
|
||||
assert svc.run.tool == "test-svc"
|
||||
assert svc.source == "test-svc"
|
||||
|
||||
def test_tool_no_run(self, castle_root: Path) -> None:
|
||||
"""Tool without run block has no run spec."""
|
||||
def test_service_component_ref(self, castle_root: Path) -> None:
|
||||
"""Service references a component."""
|
||||
config = load_config(castle_root)
|
||||
tool = config.components["test-tool"]
|
||||
assert tool.run is None
|
||||
svc = config.services["test-svc"]
|
||||
assert svc.component == "test-svc-comp"
|
||||
|
||||
def test_services_property(self, castle_root: Path) -> None:
|
||||
"""Services property filters to SERVICE role."""
|
||||
def test_job_schedule(self, castle_root: Path) -> None:
|
||||
"""Job has correct schedule."""
|
||||
config = load_config(castle_root)
|
||||
assert "test-svc" in config.services
|
||||
assert "test-tool" not in config.services
|
||||
job = config.jobs["test-job"]
|
||||
assert job.schedule == "0 2 * * *"
|
||||
|
||||
def test_tools_property(self, castle_root: Path) -> None:
|
||||
"""Tools property filters to TOOL role."""
|
||||
"""Tools property filters to components with install.path or tool."""
|
||||
config = load_config(castle_root)
|
||||
assert "test-tool" in config.tools
|
||||
assert "test-svc" not in config.tools
|
||||
|
||||
def test_managed_property(self, castle_root: Path) -> None:
|
||||
"""Managed property returns systemd-managed components."""
|
||||
config = load_config(castle_root)
|
||||
assert "test-svc" in config.managed
|
||||
assert "test-tool" not in config.managed
|
||||
|
||||
def test_missing_config_raises(self, tmp_path: Path) -> None:
|
||||
"""Missing castle.yaml raises FileNotFoundError."""
|
||||
@@ -105,11 +87,13 @@ class TestSaveConfig:
|
||||
|
||||
assert config2.gateway.port == config.gateway.port
|
||||
assert set(config2.components.keys()) == set(config.components.keys())
|
||||
assert set(config2.services.keys()) == set(config.services.keys())
|
||||
assert set(config2.jobs.keys()) == set(config.jobs.keys())
|
||||
|
||||
def test_save_adds_component(self, castle_root: Path) -> None:
|
||||
"""Adding a component and saving persists it."""
|
||||
config = load_config(castle_root)
|
||||
config.components["new-lib"] = ComponentManifest(
|
||||
config.components["new-lib"] = ComponentSpec(
|
||||
id="new-lib", description="A new library"
|
||||
)
|
||||
save_config(config)
|
||||
@@ -123,7 +107,9 @@ class TestSaveConfig:
|
||||
config = load_config(castle_root)
|
||||
save_config(config)
|
||||
config2 = load_config(castle_root)
|
||||
assert "test-svc" in config2.managed
|
||||
svc = config2.services["test-svc"]
|
||||
assert svc.manage is not None
|
||||
assert svc.manage.systemd is not None
|
||||
|
||||
|
||||
class TestResolveEnvVars:
|
||||
@@ -131,16 +117,14 @@ class TestResolveEnvVars:
|
||||
|
||||
def test_no_vars(self) -> None:
|
||||
"""Plain values pass through unchanged."""
|
||||
manifest = ComponentManifest(id="test")
|
||||
env = {"MY_VAR": "plain_value"}
|
||||
resolved = resolve_env_vars(env, manifest)
|
||||
resolved = resolve_env_vars(env)
|
||||
assert resolved["MY_VAR"] == "plain_value"
|
||||
|
||||
def test_unrecognized_vars_preserved(self) -> None:
|
||||
"""Non-secret ${} references pass through unchanged."""
|
||||
manifest = ComponentManifest(id="test")
|
||||
env = {"MY_VAR": "${unknown_var}"}
|
||||
resolved = resolve_env_vars(env, manifest)
|
||||
resolved = resolve_env_vars(env)
|
||||
assert resolved["MY_VAR"] == "${unknown_var}"
|
||||
|
||||
def test_resolve_secret(
|
||||
@@ -152,9 +136,8 @@ class TestResolveEnvVars:
|
||||
(secrets_dir / "API_KEY").write_text("my-secret-key\n")
|
||||
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
|
||||
|
||||
manifest = ComponentManifest(id="test")
|
||||
env = {"API_KEY": "${secret:API_KEY}"}
|
||||
resolved = resolve_env_vars(env, manifest)
|
||||
resolved = resolve_env_vars(env)
|
||||
assert resolved["API_KEY"] == "my-secret-key"
|
||||
|
||||
def test_resolve_missing_secret(
|
||||
@@ -165,7 +148,6 @@ class TestResolveEnvVars:
|
||||
secrets_dir.mkdir()
|
||||
monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir)
|
||||
|
||||
manifest = ComponentManifest(id="test")
|
||||
env = {"API_KEY": "${secret:NONEXISTENT}"}
|
||||
resolved = resolve_env_vars(env, manifest)
|
||||
resolved = resolve_env_vars(env)
|
||||
assert resolved["API_KEY"] == "<MISSING_SECRET:NONEXISTENT>"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for castle manifest — role derivation, validation."""
|
||||
"""Tests for castle manifest models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,171 +6,183 @@ import pytest
|
||||
from castle_core.manifest import (
|
||||
BuildSpec,
|
||||
CaddySpec,
|
||||
ComponentManifest,
|
||||
ComponentSpec,
|
||||
ExposeSpec,
|
||||
HttpExposeSpec,
|
||||
HttpInternal,
|
||||
InstallSpec,
|
||||
JobSpec,
|
||||
ManageSpec,
|
||||
PathInstallSpec,
|
||||
ProxySpec,
|
||||
Role,
|
||||
RunCommand,
|
||||
RunContainer,
|
||||
RunPython,
|
||||
RunRemote,
|
||||
ServiceSpec,
|
||||
SystemdSpec,
|
||||
ToolSpec,
|
||||
TriggerSchedule,
|
||||
)
|
||||
|
||||
|
||||
class TestRoleDerivation:
|
||||
"""Tests for computed role derivation."""
|
||||
class TestComponentSpec:
|
||||
"""Tests for component (software catalog) model."""
|
||||
|
||||
def test_service_from_expose_http(self) -> None:
|
||||
"""Component with expose.http gets SERVICE role."""
|
||||
m = ComponentManifest(
|
||||
id="svc",
|
||||
run=RunPython(runner="python", tool="svc"),
|
||||
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
|
||||
def test_minimal(self) -> None:
|
||||
"""Minimal component just needs an id."""
|
||||
c = ComponentSpec(id="bare")
|
||||
assert c.description is None
|
||||
assert c.source is None
|
||||
assert c.install is None
|
||||
assert c.tool is None
|
||||
assert c.build is None
|
||||
|
||||
def test_tool_component(self) -> None:
|
||||
"""Component with tool and install specs."""
|
||||
c = ComponentSpec(
|
||||
id="my-tool",
|
||||
description="A tool",
|
||||
source="my-tool/",
|
||||
tool=ToolSpec(),
|
||||
install=InstallSpec(path=PathInstallSpec(alias="my-tool")),
|
||||
)
|
||||
assert Role.SERVICE in m.roles
|
||||
assert c.source == "my-tool/"
|
||||
assert c.install.path.alias == "my-tool"
|
||||
|
||||
def test_tool_from_install_path(self) -> None:
|
||||
"""Component with install.path gets TOOL role."""
|
||||
m = ComponentManifest(
|
||||
id="mytool",
|
||||
install=InstallSpec(path=PathInstallSpec(alias="mytool")),
|
||||
)
|
||||
assert Role.TOOL in m.roles
|
||||
|
||||
def test_worker_from_systemd_without_http(self) -> None:
|
||||
"""Component managed by systemd but no HTTP gets WORKER role."""
|
||||
m = ComponentManifest(
|
||||
id="worker",
|
||||
run=RunCommand(runner="command", argv=["worker-bin"]),
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
assert Role.WORKER in m.roles
|
||||
assert Role.SERVICE not in m.roles
|
||||
|
||||
def test_container_role(self) -> None:
|
||||
"""Container runner gets CONTAINERIZED role."""
|
||||
m = ComponentManifest(
|
||||
id="container",
|
||||
run=RunContainer(runner="container", image="redis:7"),
|
||||
)
|
||||
assert Role.CONTAINERIZED in m.roles
|
||||
|
||||
def test_remote_role(self) -> None:
|
||||
"""Remote runner gets REMOTE role."""
|
||||
m = ComponentManifest(
|
||||
id="remote",
|
||||
run=RunRemote(runner="remote", base_url="http://example.com"),
|
||||
)
|
||||
assert Role.REMOTE in m.roles
|
||||
|
||||
def test_job_from_schedule_trigger(self) -> None:
|
||||
"""Component with schedule trigger gets JOB role."""
|
||||
m = ComponentManifest(
|
||||
id="job",
|
||||
run=RunCommand(runner="command", argv=["backup"]),
|
||||
triggers=[TriggerSchedule(cron="0 * * * *")],
|
||||
)
|
||||
assert Role.JOB in m.roles
|
||||
|
||||
def test_frontend_from_build(self) -> None:
|
||||
"""Component with build outputs gets FRONTEND role."""
|
||||
m = ComponentManifest(
|
||||
id="frontend",
|
||||
run=RunCommand(runner="command", argv=["serve"]),
|
||||
def test_frontend_component(self) -> None:
|
||||
"""Component with build spec."""
|
||||
c = ComponentSpec(
|
||||
id="my-app",
|
||||
build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]),
|
||||
)
|
||||
assert Role.FRONTEND in m.roles
|
||||
assert c.build.outputs == ["dist/"]
|
||||
|
||||
def test_tool_from_tool_spec(self) -> None:
|
||||
"""Component with tool spec gets TOOL role."""
|
||||
m = ComponentManifest(
|
||||
id="docx2md",
|
||||
tool=ToolSpec(source="docx2md/"),
|
||||
)
|
||||
assert Role.TOOL in m.roles
|
||||
def test_source_dir_from_source(self) -> None:
|
||||
"""source_dir uses source field."""
|
||||
c = ComponentSpec(id="x", source="components/x/")
|
||||
assert c.source_dir == "components/x"
|
||||
|
||||
def test_tool_spec_without_install(self) -> None:
|
||||
"""Tool spec alone is enough for TOOL role, no install.path needed."""
|
||||
m = ComponentManifest(
|
||||
id="my-tool",
|
||||
tool=ToolSpec(),
|
||||
)
|
||||
assert Role.TOOL in m.roles
|
||||
def test_source_dir_none(self) -> None:
|
||||
"""source_dir returns None when no source available."""
|
||||
c = ComponentSpec(id="x")
|
||||
assert c.source_dir is None
|
||||
|
||||
def test_fallback_to_tool(self) -> None:
|
||||
"""Component with no indicators defaults to TOOL."""
|
||||
m = ComponentManifest(id="bare")
|
||||
assert m.roles == [Role.TOOL]
|
||||
|
||||
def test_multiple_roles(self) -> None:
|
||||
"""Component can have multiple roles."""
|
||||
m = ComponentManifest(
|
||||
id="multi",
|
||||
run=RunPython(runner="python", tool="multi"),
|
||||
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
|
||||
install=InstallSpec(path=PathInstallSpec(alias="multi")),
|
||||
)
|
||||
assert Role.SERVICE in m.roles
|
||||
assert Role.TOOL in m.roles
|
||||
class TestServiceSpec:
|
||||
"""Tests for service (long-running daemon) model."""
|
||||
|
||||
def test_systemd_with_http_is_service_not_worker(self) -> None:
|
||||
"""Systemd + HTTP = SERVICE, not WORKER."""
|
||||
m = ComponentManifest(
|
||||
def test_basic_service(self) -> None:
|
||||
"""Service with run and expose."""
|
||||
s = ServiceSpec(
|
||||
id="svc",
|
||||
run=RunPython(runner="python", tool="svc"),
|
||||
expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))),
|
||||
)
|
||||
assert s.run.runner == "python"
|
||||
assert s.expose.http.internal.port == 8000
|
||||
|
||||
def test_service_with_component_ref(self) -> None:
|
||||
"""Service can reference a component."""
|
||||
s = ServiceSpec(
|
||||
id="svc",
|
||||
component="my-component",
|
||||
run=RunPython(runner="python", tool="svc"),
|
||||
)
|
||||
assert s.component == "my-component"
|
||||
|
||||
def test_service_with_proxy(self) -> None:
|
||||
"""Service with proxy spec."""
|
||||
s = ServiceSpec(
|
||||
id="svc",
|
||||
run=RunPython(runner="python", tool="svc"),
|
||||
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
|
||||
)
|
||||
assert s.proxy.caddy.path_prefix == "/svc"
|
||||
|
||||
def test_service_with_manage(self) -> None:
|
||||
"""Service with systemd management."""
|
||||
s = ServiceSpec(
|
||||
id="svc",
|
||||
run=RunCommand(runner="command", argv=["bin"]),
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
assert Role.SERVICE in m.roles
|
||||
assert Role.WORKER not in m.roles
|
||||
|
||||
|
||||
class TestConsistencyValidation:
|
||||
"""Tests for model validation."""
|
||||
assert s.manage.systemd.enable is True
|
||||
|
||||
def test_remote_with_systemd_raises(self) -> None:
|
||||
"""Remote runner + systemd management is invalid."""
|
||||
with pytest.raises(
|
||||
ValueError, match="manage.systemd cannot be enabled for runner=remote"
|
||||
):
|
||||
ComponentManifest(
|
||||
ServiceSpec(
|
||||
id="bad",
|
||||
run=RunRemote(runner="remote", base_url="http://example.com"),
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
|
||||
def test_no_run_is_valid(self) -> None:
|
||||
"""Component with no run spec is valid (registration-only)."""
|
||||
m = ComponentManifest(id="reg-only", description="Just registered")
|
||||
assert m.run is None
|
||||
assert m.roles == [Role.TOOL]
|
||||
def test_no_run_is_invalid(self) -> None:
|
||||
"""Service requires a run spec."""
|
||||
with pytest.raises(Exception):
|
||||
ServiceSpec(id="bad")
|
||||
|
||||
|
||||
class TestJobSpec:
|
||||
"""Tests for job (scheduled task) model."""
|
||||
|
||||
def test_basic_job(self) -> None:
|
||||
"""Job with run and schedule."""
|
||||
j = JobSpec(
|
||||
id="my-job",
|
||||
run=RunCommand(runner="command", argv=["backup"]),
|
||||
schedule="0 2 * * *",
|
||||
)
|
||||
assert j.schedule == "0 2 * * *"
|
||||
assert j.timezone == "America/Los_Angeles"
|
||||
|
||||
def test_job_with_component_ref(self) -> None:
|
||||
"""Job can reference a component."""
|
||||
j = JobSpec(
|
||||
id="sync",
|
||||
component="protonmail",
|
||||
run=RunCommand(runner="command", argv=["protonmail", "sync"]),
|
||||
schedule="*/5 * * * *",
|
||||
)
|
||||
assert j.component == "protonmail"
|
||||
|
||||
def test_job_requires_schedule(self) -> None:
|
||||
"""Job without schedule is invalid."""
|
||||
with pytest.raises(Exception):
|
||||
JobSpec(
|
||||
id="bad",
|
||||
run=RunCommand(runner="command", argv=["x"]),
|
||||
)
|
||||
|
||||
def test_job_custom_timezone(self) -> None:
|
||||
"""Job with custom timezone."""
|
||||
j = JobSpec(
|
||||
id="job",
|
||||
run=RunCommand(runner="command", argv=["x"]),
|
||||
schedule="0 0 * * *",
|
||||
timezone="UTC",
|
||||
)
|
||||
assert j.timezone == "UTC"
|
||||
|
||||
|
||||
class TestModelSerialization:
|
||||
"""Tests for model_dump behavior."""
|
||||
|
||||
def test_dump_excludes_none(self) -> None:
|
||||
def test_dump_component_excludes_none(self) -> None:
|
||||
"""model_dump with exclude_none drops None fields."""
|
||||
m = ComponentManifest(id="test", description="Test")
|
||||
data = m.model_dump(exclude_none=True, exclude={"id", "roles"})
|
||||
c = ComponentSpec(id="test", description="Test")
|
||||
data = c.model_dump(exclude_none=True, exclude={"id"})
|
||||
assert "description" in data
|
||||
assert "run" not in data
|
||||
assert "manage" not in data
|
||||
assert "install" not in data
|
||||
assert "tool" not in data
|
||||
|
||||
def test_dump_service(self) -> None:
|
||||
"""Full service manifest serializes correctly."""
|
||||
m = ComponentManifest(
|
||||
"""Full service serializes correctly."""
|
||||
s = ServiceSpec(
|
||||
id="svc",
|
||||
description="A service",
|
||||
run=RunPython(runner="python", tool="svc", cwd="svc"),
|
||||
run=RunPython(runner="python", tool="svc"),
|
||||
expose=ExposeSpec(
|
||||
http=HttpExposeSpec(
|
||||
internal=HttpInternal(port=9001), health_path="/health"
|
||||
@@ -179,7 +191,7 @@ class TestModelSerialization:
|
||||
proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")),
|
||||
manage=ManageSpec(systemd=SystemdSpec()),
|
||||
)
|
||||
data = m.model_dump(exclude_none=True, exclude={"id", "roles"})
|
||||
data = s.model_dump(exclude_none=True, exclude={"id"})
|
||||
assert data["run"]["runner"] == "python"
|
||||
assert data["expose"]["http"]["internal"]["port"] == 9001
|
||||
assert data["proxy"]["caddy"]["path_prefix"] == "/svc"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from castle_core.generators.systemd import (
|
||||
generate_timer,
|
||||
generate_unit_from_deployed,
|
||||
unit_name,
|
||||
)
|
||||
@@ -115,3 +116,25 @@ class TestUnitFromDeployed:
|
||||
)
|
||||
unit = generate_unit_from_deployed("my-svc", deployed)
|
||||
assert "/data/repos/" not in unit
|
||||
|
||||
|
||||
class TestGenerateTimer:
|
||||
"""Tests for timer generation from schedule strings."""
|
||||
|
||||
def test_daily_timer(self) -> None:
|
||||
"""Daily cron produces OnCalendar timer."""
|
||||
timer = generate_timer("my-job", schedule="0 2 * * *", description="Nightly")
|
||||
assert "Description=Castle timer: Nightly" in timer
|
||||
assert "OnCalendar=*-*-* 02:00:00" in timer
|
||||
assert "WantedBy=timers.target" in timer
|
||||
|
||||
def test_interval_timer(self) -> None:
|
||||
"""*/N minute cron produces OnUnitActiveSec timer."""
|
||||
timer = generate_timer("sync", schedule="*/5 * * * *")
|
||||
assert "OnUnitActiveSec=300s" in timer
|
||||
assert "OnBootSec=60" in timer
|
||||
|
||||
def test_fallback_description(self) -> None:
|
||||
"""Timer uses name when no description given."""
|
||||
timer = generate_timer("my-job", schedule="0 0 * * *")
|
||||
assert "Description=Castle timer: my-job" in timer
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
# Component Registry
|
||||
|
||||
How castle tracks, configures, and manages components. This is the central
|
||||
reference for `castle.yaml` structure and the manifest architecture.
|
||||
reference for `castle.yaml` structure and the registry architecture.
|
||||
|
||||
## castle.yaml
|
||||
|
||||
The single source of truth for all components. Lives at the repo root.
|
||||
Three top-level sections:
|
||||
|
||||
```yaml
|
||||
gateway:
|
||||
port: 9000
|
||||
|
||||
components:
|
||||
my-service:
|
||||
my-tool:
|
||||
description: Does something useful
|
||||
source: components/my-tool
|
||||
install:
|
||||
path: { alias: my-tool }
|
||||
tool:
|
||||
system_dependencies: [pandoc]
|
||||
|
||||
services:
|
||||
my-service:
|
||||
component: my-service
|
||||
run:
|
||||
runner: python
|
||||
tool: my-service
|
||||
@@ -25,16 +35,84 @@ components:
|
||||
caddy: { path_prefix: /my-service }
|
||||
manage:
|
||||
systemd: {}
|
||||
|
||||
jobs:
|
||||
my-job:
|
||||
component: my-tool
|
||||
run:
|
||||
runner: command
|
||||
argv: [my-tool, sync]
|
||||
schedule: "0 2 * * *"
|
||||
manage:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
## Manifest blocks
|
||||
### Section semantics
|
||||
|
||||
Each component declares **what it does** through these optional blocks:
|
||||
| Section | Purpose | Category |
|
||||
|---------|---------|----------|
|
||||
| `components:` | Software catalog — what exists | tool, frontend, component |
|
||||
| `services:` | Long-running daemons — how they run | service |
|
||||
| `jobs:` | Scheduled tasks — when they run | job |
|
||||
|
||||
### `run` — How to start it
|
||||
Services and jobs can reference a component via `component:` for description
|
||||
fallthrough and source code linking. They can also exist independently
|
||||
(e.g., `castle-gateway` runs Caddy — not our software).
|
||||
|
||||
Discriminated union on `runner`. The runner encodes both the language/toolchain
|
||||
(used by `castle sync`) and the deployment resolution (used by `castle deploy`):
|
||||
## Component blocks
|
||||
|
||||
Components define **what software exists** — identity, source, tools, builds.
|
||||
|
||||
### `source` — Where the source lives
|
||||
|
||||
```yaml
|
||||
source: components/my-tool
|
||||
```
|
||||
|
||||
Relative path from repo root to the project directory.
|
||||
|
||||
### `install` — How to install it
|
||||
|
||||
```yaml
|
||||
install:
|
||||
path:
|
||||
alias: my-tool # Command name in PATH
|
||||
```
|
||||
|
||||
Creates a shim so the tool is available system-wide after
|
||||
`uv tool install --editable .`.
|
||||
|
||||
### `tool` — Tool metadata
|
||||
|
||||
```yaml
|
||||
tool:
|
||||
version: "1.0.0"
|
||||
system_dependencies: [pandoc, poppler-utils]
|
||||
```
|
||||
|
||||
This block provides metadata for `castle tool list` and the dashboard.
|
||||
It's separate from `install` (which handles PATH registration). The source
|
||||
directory is set via the top-level `source` field on the component, not here.
|
||||
|
||||
### `build` — How to build it
|
||||
|
||||
```yaml
|
||||
build:
|
||||
commands:
|
||||
- ["pnpm", "build"]
|
||||
outputs:
|
||||
- dist/
|
||||
```
|
||||
|
||||
Components with build outputs are categorized as **frontends** in the UI.
|
||||
|
||||
## Service blocks
|
||||
|
||||
Services define **how long-running daemons are deployed**.
|
||||
|
||||
### `run` — How to start it (required)
|
||||
|
||||
Discriminated union on `runner`:
|
||||
|
||||
| Runner | Sync | Deploy | Key fields |
|
||||
|--------|------|--------|------------|
|
||||
@@ -44,38 +122,22 @@ Discriminated union on `runner`. The runner encodes both the language/toolchain
|
||||
| `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` |
|
||||
| `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` |
|
||||
|
||||
**Services** use `python`:
|
||||
```yaml
|
||||
run:
|
||||
runner: python
|
||||
tool: my-service # name in [project.scripts]
|
||||
```
|
||||
|
||||
**Tools invoked by castle** (jobs, scheduled tasks) use `command`:
|
||||
```yaml
|
||||
run:
|
||||
runner: command
|
||||
argv: ["protonmail", "sync"]
|
||||
cwd: protonmail
|
||||
env:
|
||||
PROTONMAIL_USERNAME: user@example.com
|
||||
```
|
||||
|
||||
**Standalone tools** that users invoke directly often have no `run` block at
|
||||
all — castle just installs them to PATH.
|
||||
|
||||
### `expose` — What it exposes
|
||||
|
||||
```yaml
|
||||
expose:
|
||||
http:
|
||||
internal:
|
||||
port: 9001 # Required for services
|
||||
port: 9001 # Required for HTTP services
|
||||
health_path: /health # Used by health polling
|
||||
```
|
||||
|
||||
Having `expose.http` gives the component the **service** role.
|
||||
|
||||
### `proxy` — How to proxy it
|
||||
|
||||
```yaml
|
||||
@@ -95,7 +157,7 @@ manage:
|
||||
```
|
||||
|
||||
Enables `castle service enable/disable` and `castle logs`. An empty `{}`
|
||||
uses defaults (enable=true, restart=on-failure, restart_sec=5).
|
||||
uses defaults (enable=true, restart=on-failure, restart_sec=2).
|
||||
|
||||
Full options:
|
||||
```yaml
|
||||
@@ -107,91 +169,39 @@ manage:
|
||||
no_new_privileges: true
|
||||
after: [network.target, castle-other.service]
|
||||
wanted_by: [default.target]
|
||||
exec_reload: "caddy reload ..."
|
||||
```
|
||||
|
||||
### `install` — How to install it
|
||||
### `defaults` — Default environment
|
||||
|
||||
```yaml
|
||||
install:
|
||||
path:
|
||||
alias: my-tool # Command name in PATH
|
||||
```
|
||||
|
||||
Creates a shim so the tool is available system-wide after
|
||||
`uv tool install --editable .`.
|
||||
|
||||
### `tool` — Tool metadata
|
||||
|
||||
```yaml
|
||||
tool:
|
||||
source: components/my-tool/ # Source directory
|
||||
version: "1.0.0"
|
||||
system_dependencies: [pandoc, poppler-utils]
|
||||
```
|
||||
|
||||
This block provides metadata for `castle tool list` and the dashboard.
|
||||
It's separate from `install` (which handles PATH registration) and `run`
|
||||
(which handles execution).
|
||||
|
||||
The install method (uv tool install vs symlink) is inferred from the source
|
||||
directory: if `pyproject.toml` exists, it's a Python package; if the source
|
||||
is a file, it's symlinked.
|
||||
|
||||
### `build` — How to build it
|
||||
|
||||
```yaml
|
||||
build:
|
||||
commands:
|
||||
- ["pnpm", "build"]
|
||||
outputs:
|
||||
- dist/
|
||||
```
|
||||
|
||||
Having build outputs gives the component the **frontend** role.
|
||||
|
||||
### `triggers` — What triggers it
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- type: schedule
|
||||
cron: "*/5 * * * *"
|
||||
timezone: America/Los_Angeles # default
|
||||
```
|
||||
|
||||
Having a schedule trigger gives the component the **job** role.
|
||||
Castle generates a systemd .timer file alongside the .service unit.
|
||||
|
||||
Other trigger types: `manual`, `event` (source + topic), `request` (protocol).
|
||||
|
||||
### `env` with secrets
|
||||
|
||||
Environment variables can reference secrets stored in `~/.castle/secrets/`:
|
||||
|
||||
```yaml
|
||||
run:
|
||||
defaults:
|
||||
env:
|
||||
CENTRAL_CONTEXT_URL: http://localhost:9001
|
||||
API_KEY: ${secret:MY_API_KEY}
|
||||
```
|
||||
|
||||
Castle resolves `${secret:NAME}` by reading `~/.castle/secrets/NAME`.
|
||||
Never store secrets in castle.yaml or project directories.
|
||||
|
||||
## Role derivation
|
||||
## Job blocks
|
||||
|
||||
Roles are **computed** from manifest declarations, never set manually:
|
||||
Jobs define **how scheduled tasks run**. Same blocks as services plus
|
||||
`schedule` and `timezone`.
|
||||
|
||||
| Role | Derived when |
|
||||
|------|-------------|
|
||||
| **service** | Has `expose.http` |
|
||||
| **tool** | Has `install.path` or has `tool` spec (fallback) |
|
||||
| **worker** | Has `manage.systemd` but no `expose.http` |
|
||||
| **job** | Has trigger with `type: schedule` |
|
||||
| **frontend** | Has `build` with outputs or commands |
|
||||
| **containerized** | Runner is `container` |
|
||||
| **remote** | Runner is `remote` |
|
||||
### `schedule` — Cron expression (required)
|
||||
|
||||
A component can have multiple roles. For example, `protonmail` is both a
|
||||
**tool** (installed to PATH) and a **job** (runs on a cron schedule).
|
||||
```yaml
|
||||
schedule: "*/5 * * * *"
|
||||
timezone: America/Los_Angeles # default
|
||||
```
|
||||
|
||||
Castle generates a systemd `.timer` file alongside the `.service` unit.
|
||||
|
||||
### Other blocks
|
||||
|
||||
Jobs also support `run` (required), `manage`, and `defaults` — same
|
||||
semantics as services.
|
||||
|
||||
## Registering a new component
|
||||
|
||||
@@ -207,17 +217,38 @@ castle create my-tool --type tool --description "Does something"
|
||||
|
||||
### Manually
|
||||
|
||||
Add an entry to the `components:` section of `castle.yaml`:
|
||||
Add entries to the appropriate sections of `castle.yaml`:
|
||||
|
||||
```yaml
|
||||
# Tool — only needs a component entry
|
||||
components:
|
||||
my-tool:
|
||||
description: Does something useful
|
||||
tool:
|
||||
source: components/my-tool/
|
||||
source: components/my-tool
|
||||
install:
|
||||
path:
|
||||
alias: my-tool
|
||||
|
||||
# Service — needs both component and service entries
|
||||
components:
|
||||
my-service:
|
||||
description: Does something useful
|
||||
source: components/my-service
|
||||
|
||||
services:
|
||||
my-service:
|
||||
component: my-service
|
||||
run:
|
||||
runner: python
|
||||
tool: my-service
|
||||
expose:
|
||||
http:
|
||||
internal: { port: 9001 }
|
||||
health_path: /health
|
||||
proxy:
|
||||
caddy: { path_prefix: /my-service }
|
||||
manage:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
## Lifecycle
|
||||
@@ -254,19 +285,16 @@ uv tool install --editable components/my-tool/ # 4. Install to PATH
|
||||
|
||||
### Job lifecycle
|
||||
|
||||
Jobs are tools or services with a schedule trigger. They need both `run`
|
||||
(so castle knows how to execute them) and `manage.systemd` (so systemd
|
||||
handles the timer):
|
||||
Jobs are defined in the `jobs:` section with a `run` spec and `schedule`:
|
||||
|
||||
```yaml
|
||||
my-job:
|
||||
jobs:
|
||||
my-job:
|
||||
description: Runs nightly
|
||||
run:
|
||||
runner: command
|
||||
argv: ["my-job"]
|
||||
triggers:
|
||||
- type: schedule
|
||||
cron: "0 2 * * *"
|
||||
schedule: "0 2 * * *"
|
||||
manage:
|
||||
systemd: {}
|
||||
```
|
||||
@@ -289,14 +317,16 @@ and a `.timer` file.
|
||||
|
||||
The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes:
|
||||
|
||||
- `ComponentManifest` — top-level model, has `roles` computed property
|
||||
- `ComponentSpec` — software catalog entry (source, install, tool, build)
|
||||
- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults)
|
||||
- `JobSpec` — scheduled task (run, schedule, manage, defaults)
|
||||
- `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote)
|
||||
- `TriggerSpec` — union (TriggerSchedule, TriggerManual, TriggerEvent, TriggerRequest)
|
||||
- `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec`
|
||||
- `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal`
|
||||
|
||||
Config loading: `core/src/castle_core/config.py` — `load_config()` parses
|
||||
castle.yaml into `CastleConfig` with typed `components` dict.
|
||||
castle.yaml into `CastleConfig` with typed `components`, `services`, and
|
||||
`jobs` dicts.
|
||||
|
||||
Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer
|
||||
generation (`systemd.py`) and Caddyfile generation (`caddyfile.py`).
|
||||
|
||||
@@ -20,9 +20,9 @@ is self-sufficient. The mesh is optional.
|
||||
Castle service is just a well-behaved Unix daemon that happens to
|
||||
be registered in a manifest.
|
||||
|
||||
3. **Declare capabilities, derive roles.** Components say what they
|
||||
do (expose HTTP, run on a schedule, install to PATH). Castle infers
|
||||
what they are (service, job, tool, frontend). No role labels.
|
||||
3. **Section is category.** Components, services, and jobs live in
|
||||
separate sections of `castle.yaml`. The section determines the
|
||||
category — no role derivation needed.
|
||||
|
||||
4. **Language-agnostic above the build line.** Below the build line,
|
||||
every language is different (uv, pnpm, cargo, go). Above it,
|
||||
@@ -149,13 +149,17 @@ answers: "is it working?"
|
||||
|
||||
These map to two files:
|
||||
|
||||
**`castle.yaml`** (in the repo, version-controlled) — Component specs:
|
||||
**`castle.yaml`** (in the repo, version-controlled) — Three sections:
|
||||
|
||||
```yaml
|
||||
components:
|
||||
central-context:
|
||||
description: Content storage API
|
||||
source: components/central-context
|
||||
|
||||
services:
|
||||
central-context:
|
||||
component: central-context
|
||||
run:
|
||||
runner: python
|
||||
tool: central-context
|
||||
@@ -168,12 +172,29 @@ components:
|
||||
path_prefix: /central-context
|
||||
manage:
|
||||
systemd: {}
|
||||
|
||||
jobs:
|
||||
backup-collect:
|
||||
component: backup-collect
|
||||
run:
|
||||
runner: command
|
||||
argv: [backup-collect]
|
||||
schedule: "0 2 * * *"
|
||||
manage:
|
||||
systemd: {}
|
||||
```
|
||||
|
||||
The spec says what the component *is* and what it *needs* — a port, a
|
||||
data directory, HTTP exposure. Convention-based env vars (`<PREFIX>_PORT`,
|
||||
`<PREFIX>_DATA_DIR`) are generated automatically during deploy. Only
|
||||
non-convention values need `defaults.env`.
|
||||
Components define *what software exists* (identity, source, install, tools).
|
||||
Services define *how daemons run* (run config, expose, proxy, systemd).
|
||||
Jobs define *how scheduled tasks run* (run config, cron schedule, systemd).
|
||||
|
||||
Services and jobs can reference a component via `component:` for description
|
||||
fallthrough and source code linking. They can also exist independently
|
||||
(e.g., `castle-gateway` runs Caddy — not our software).
|
||||
|
||||
Convention-based env vars (`<PREFIX>_PORT`, `<PREFIX>_DATA_DIR`) are
|
||||
generated automatically during deploy. Only non-convention values need
|
||||
`defaults.env`.
|
||||
|
||||
**`~/.castle/registry.yaml`** (per-node, not in the repo) — Node config:
|
||||
|
||||
@@ -189,7 +210,7 @@ deployed:
|
||||
env:
|
||||
CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context
|
||||
CENTRAL_CONTEXT_PORT: "9001"
|
||||
roles: [service]
|
||||
category: service
|
||||
port: 9001
|
||||
health_path: /health
|
||||
proxy_path: /central-context
|
||||
|
||||
@@ -317,28 +317,27 @@ uv run ruff format . # Format
|
||||
components:
|
||||
my-tool:
|
||||
description: Does something useful
|
||||
tool:
|
||||
source: components/my-tool/
|
||||
source: components/my-tool
|
||||
install:
|
||||
path:
|
||||
alias: my-tool
|
||||
```
|
||||
|
||||
Tools with system dependencies declare them in the manifest:
|
||||
Tools with system dependencies declare them in the component:
|
||||
|
||||
```yaml
|
||||
components:
|
||||
pdf2md:
|
||||
description: Convert PDF files to Markdown
|
||||
tool:
|
||||
source: components/pdf2md/
|
||||
system_dependencies: [pandoc, poppler-utils]
|
||||
source: components/pdf2md
|
||||
install:
|
||||
path:
|
||||
alias: pdf2md
|
||||
tool:
|
||||
system_dependencies: [pandoc, poppler-utils]
|
||||
```
|
||||
|
||||
Tools with `install.path` get the **tool** role. They don't need `expose`,
|
||||
`proxy`, or `manage` blocks unless castle also runs them (e.g., scheduled jobs).
|
||||
Tools live in the `components:` section. If a tool also runs on a schedule,
|
||||
add a separate entry in the `jobs:` section referencing the component.
|
||||
|
||||
See @docs/component-registry.md for the full manifest reference.
|
||||
See @docs/component-registry.md for the full registry reference.
|
||||
|
||||
@@ -101,7 +101,12 @@ Castle passes config via env vars in castle.yaml:
|
||||
```yaml
|
||||
components:
|
||||
my-service:
|
||||
source: my-service
|
||||
description: Does something useful
|
||||
source: components/my-service
|
||||
|
||||
services:
|
||||
my-service:
|
||||
component: my-service
|
||||
run:
|
||||
runner: python
|
||||
tool: my-service
|
||||
|
||||
@@ -107,8 +107,8 @@ The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files.
|
||||
|
||||
## Registering as a castle component
|
||||
|
||||
A frontend component has a `build` spec (produces static output) and optionally
|
||||
a `proxy` spec (Caddy serves the built files). No `run` block needed if Caddy
|
||||
A frontend component has a `build` spec (produces static output). Register it
|
||||
in the `components:` section of `castle.yaml`. No `run` block needed if Caddy
|
||||
handles serving directly from the build output.
|
||||
|
||||
```yaml
|
||||
@@ -116,45 +116,33 @@ handles serving directly from the build output.
|
||||
components:
|
||||
my-frontend:
|
||||
description: Web dashboard
|
||||
source: my-frontend
|
||||
build:
|
||||
commands:
|
||||
- ["pnpm", "build"]
|
||||
outputs:
|
||||
- dist/
|
||||
proxy:
|
||||
caddy:
|
||||
path_prefix: /app
|
||||
```
|
||||
|
||||
For development with Vite's dev server, add a `run` block:
|
||||
For production, Caddy serves the static files — add a service entry with a
|
||||
proxy spec:
|
||||
|
||||
```yaml
|
||||
components:
|
||||
services:
|
||||
my-frontend:
|
||||
description: Web dashboard
|
||||
component: my-frontend
|
||||
run:
|
||||
runner: node
|
||||
script: dev
|
||||
package_manager: pnpm
|
||||
cwd: my-frontend
|
||||
build:
|
||||
commands:
|
||||
- ["pnpm", "build"]
|
||||
outputs:
|
||||
- dist/
|
||||
expose:
|
||||
http:
|
||||
internal: { port: 5173 }
|
||||
proxy:
|
||||
caddy:
|
||||
path_prefix: /app
|
||||
caddy: { path_prefix: /app }
|
||||
```
|
||||
|
||||
This gives the component both the `frontend` role (from `build`) and the
|
||||
`service` role (from `expose.http`) during development.
|
||||
|
||||
See @docs/component-registry.md for the full manifest reference and role
|
||||
derivation rules.
|
||||
See @docs/component-registry.md for the full registry reference.
|
||||
|
||||
## Serving with Caddy
|
||||
|
||||
|
||||
Reference in New Issue
Block a user