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