diff --git a/CLAUDE.md b/CLAUDE.md index 9a17031..428b655 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,19 +6,25 @@ with code in this repository. ## Overview Castle is a personal software platform — a monorepo of independent projects -(services, tools, libraries) managed by the `castle` CLI. Components declare -**what they do** (expose HTTP, manage via systemd, install to PATH) and roles -are **derived**, not labeled. +(services, tools, libraries) managed by the `castle` CLI. The registry +(`castle.yaml`) has three top-level sections: + +- **`components:`** — Software catalog (source, install, tool metadata, build) +- **`services:`** — Long-running daemons (run, expose, proxy, systemd) +- **`jobs:`** — Scheduled tasks (run, cron schedule, systemd timer) + +The section determines the category — no role derivation. Services and jobs +can reference a component via `component:` for description fallthrough. **Key principle:** Regular projects must never depend on castle. They accept standard -configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway, -event bus) know about castle internals. +configuration (data dir, port, URLs) via env vars. Only castle-components (CLI, gateway) +know about castle internals. ## Creating Components When creating a new service, tool, or frontend, follow the detailed guides: -- @docs/component-registry.md — Manifest architecture, castle.yaml structure, role derivation, lifecycle +- @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle - @docs/web-apis.md — FastAPI service patterns (config, routes, models, testing) - @docs/python-tools.md — CLI tool patterns (argparse, stdin/stdout, piping, testing) - @docs/web-frontends.md — React/Vite/TypeScript frontend patterns @@ -39,7 +45,7 @@ cd components/my-tool && uv sync ``` The `castle create` command scaffolds the project under `components/`, -generates a CLAUDE.md, and registers it in `castle.yaml` as a `ComponentManifest`. +generates a CLAUDE.md, and registers it in `castle.yaml`. ## Castle CLI @@ -47,8 +53,8 @@ The CLI lives in `cli/` and is installed via `uv tool install --editable cli/`. ```bash castle list # List all components -castle list --role service # Filter by derived role -castle info # Show manifest details (--json for machine-readable) +castle list --type service # Filter by category +castle info # Show details (--json for machine-readable) castle create --type service # Scaffold new project castle test [project] # Run tests (one or all) castle lint [project] # Run linter (one or all) @@ -93,8 +99,8 @@ Services also support: `uv run ` to start. ## Key Files -- `castle.yaml` — Component registry (single source of truth) -- `core/src/castle_core/manifest.py` — Pydantic models (ComponentManifest, RunSpec, etc.) +- `castle.yaml` — Component registry (three sections: components, services, jobs) +- `core/src/castle_core/manifest.py` — Pydantic models (ComponentSpec, ServiceSpec, JobSpec, RunSpec) - `core/src/castle_core/config.py` — Config loader (castle.yaml → CastleConfig) - `core/src/castle_core/generators/` — Systemd unit and Caddyfile generation - `cli/src/castle_cli/templates/scaffold.py` — Project scaffolding templates diff --git a/app/src/components/AddComponent.tsx b/app/src/components/AddComponent.tsx index 41d7897..5b44c13 100644 --- a/app/src/components/AddComponent.tsx +++ b/app/src/components/AddComponent.tsx @@ -27,15 +27,13 @@ const TEMPLATES: Record> = { path: { alias: "" }, }, }, - worker: { + job: { run: { runner: "command", argv: [""], cwd: "", }, - manage: { - systemd: {}, - }, + schedule: "0 2 * * *", }, empty: {}, } @@ -145,7 +143,7 @@ export function AddComponent({ onAdd, existingNames }: AddComponentProps) { > - + diff --git a/app/src/components/ComponentCard.tsx b/app/src/components/ComponentCard.tsx index fe10887..292cf67 100644 --- a/app/src/components/ComponentCard.tsx +++ b/app/src/components/ComponentCard.tsx @@ -38,9 +38,7 @@ export function ComponentCard({ component, health }: ComponentCardProps) {
- {component.roles.map((role) => ( - - ))} +
{component.description && ( diff --git a/app/src/components/ComponentEditor.tsx b/app/src/components/ComponentEditor.tsx index 05c37c4..99ec3b6 100644 --- a/app/src/components/ComponentEditor.tsx +++ b/app/src/components/ComponentEditor.tsx @@ -25,9 +25,7 @@ export function ComponentEditor({ component, onSave, onDelete }: ComponentEditor {component.description}
- {component.roles.map((r) => ( - - ))} +
diff --git a/app/src/components/ComponentFields.tsx b/app/src/components/ComponentFields.tsx index 0b392ae..59980fd 100644 --- a/app/src/components/ComponentFields.tsx +++ b/app/src/components/ComponentFields.tsx @@ -63,7 +63,7 @@ export function ComponentFields({ component, onSave, onDelete }: ComponentFields try { const config: Record = { ...m } delete config.id - delete config.roles + delete config.category config.description = description || undefined // Merge plain env + secret references back together diff --git a/app/src/components/ComponentGrid.tsx b/app/src/components/ComponentGrid.tsx index 20e1a9f..61b4860 100644 --- a/app/src/components/ComponentGrid.tsx +++ b/app/src/components/ComponentGrid.tsx @@ -1,16 +1,8 @@ import type { ComponentSummary, HealthStatus } from "@/types" +import { CATEGORY_LABELS } from "@/lib/labels" import { ComponentCard } from "./ComponentCard" -const ROLE_ORDER = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"] -const ROLE_LABELS: Record = { - service: "Services", - tool: "Tools", - worker: "Workers", - job: "Jobs", - frontend: "Frontends", - remote: "Remote", - containerized: "Containers", -} +const CATEGORY_ORDER = ["service", "job", "tool", "frontend", "component"] interface ComponentGridProps { components: ComponentSummary[] @@ -20,24 +12,24 @@ interface ComponentGridProps { export function ComponentGrid({ components, statuses }: ComponentGridProps) { const statusMap = new Map(statuses.map((s) => [s.id, s])) - // Group by primary role + // Group by category const groups = new Map() for (const comp of components) { - const primary = comp.roles[0] ?? "tool" - const list = groups.get(primary) ?? [] + const cat = comp.category + const list = groups.get(cat) ?? [] list.push(comp) - groups.set(primary, list) + groups.set(cat, list) } return (
- {ROLE_ORDER.map((role) => { - const items = groups.get(role) + {CATEGORY_ORDER.map((cat) => { + const items = groups.get(cat) if (!items?.length) return null return ( -
+

- {ROLE_LABELS[role] ?? role} + {CATEGORY_LABELS[cat] ?? cat}

{items.map((comp) => ( diff --git a/app/src/components/ComponentTable.tsx b/app/src/components/ComponentTable.tsx index de740db..1915adb 100644 --- a/app/src/components/ComponentTable.tsx +++ b/app/src/components/ComponentTable.tsx @@ -12,7 +12,7 @@ interface ComponentTableProps { statuses: HealthStatus[] } -type SortKey = "id" | "role" | "runner" | "schedule" | "port" | "status" +type SortKey = "id" | "category" | "runner" | "schedule" | "port" | "status" type SortDir = "asc" | "desc" function statusRank(s: HealthStatus | undefined, installed: boolean | null): number { @@ -30,13 +30,13 @@ function statusRank(s: HealthStatus | undefined, installed: boolean | null): num export function ComponentTable({ components, statuses }: ComponentTableProps) { const statusMap = useMemo(() => new Map(statuses.map((s) => [s.id, s])), [statuses]) - const allRoles = useMemo(() => { + const allCategories = useMemo(() => { const set = new Set() - for (const c of components) for (const r of c.roles) set.add(r) + for (const c of components) set.add(c.category) return Array.from(set).sort() }, [components]) - const [roleFilter, setRoleFilter] = useState(null) + const [categoryFilter, setCategoryFilter] = useState(null) const [search, setSearch] = useState("") const [sortKey, setSortKey] = useState("id") const [sortDir, setSortDir] = useState("asc") @@ -52,8 +52,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { const filtered = useMemo(() => { let list = components - if (roleFilter) { - list = list.filter((c) => c.roles.includes(roleFilter)) + if (categoryFilter) { + list = list.filter((c) => c.category === categoryFilter) } if (search) { const q = search.toLowerCase() @@ -64,7 +64,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { ) } return list - }, [components, roleFilter, search]) + }, [components, categoryFilter, search]) const sorted = useMemo(() => { const dir = sortDir === "asc" ? 1 : -1 @@ -72,8 +72,8 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { switch (sortKey) { case "id": return dir * a.id.localeCompare(b.id) - case "role": - return dir * (a.roles[0] ?? "").localeCompare(b.roles[0] ?? "") + case "category": + return dir * a.category.localeCompare(b.category) case "runner": return dir * (a.runner ?? "").localeCompare(b.runner ?? "") case "schedule": @@ -100,26 +100,26 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { />
- {allRoles.map((role) => ( + {allCategories.map((cat) => ( ))}
@@ -131,7 +131,7 @@ export function ComponentTable({ components, statuses }: ComponentTableProps) { - + @@ -233,11 +233,7 @@ function ComponentRow({ )} -
- {component.roles.map((role) => ( - - ))} -
+ {component.runner ? runnerLabel(component.runner) : "—"} diff --git a/app/src/components/JobSection.tsx b/app/src/components/JobSection.tsx new file mode 100644 index 0000000..0113faf --- /dev/null +++ b/app/src/components/JobSection.tsx @@ -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("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 ( +
+ +
+ + + + + + + + + + + {sorted.map((job) => ( + + ))} + +
ScheduleActions
+
+
+ ) +} + +function JobRow({ job }: { job: ComponentSummary }) { + const { mutate, isPending } = useServiceAction() + const hasTimer = job.systemd?.timer ?? false + + return ( + + + + {job.id} + + {job.description && ( +

+ {job.description} +

+ )} + + + {job.schedule ?? "—"} + + + {hasTimer ? ( + + + active + + ) : ( + + )} + + + {job.managed && ( + + )} + + + ) +} diff --git a/app/src/components/RoleBadge.tsx b/app/src/components/RoleBadge.tsx index 63c5f7e..d18b22d 100644 --- a/app/src/components/RoleBadge.tsx +++ b/app/src/components/RoleBadge.tsx @@ -1,14 +1,12 @@ import { cn } from "@/lib/utils" -import { ROLE_DESCRIPTIONS } from "@/lib/labels" +import { CATEGORY_DESCRIPTIONS } from "@/lib/labels" -const roleColors: Record = { +const categoryColors: Record = { service: "bg-green-700 text-white", - tool: "bg-blue-700 text-white", - worker: "bg-blue-500 text-white", job: "bg-purple-700 text-white", + tool: "bg-blue-700 text-white", frontend: "bg-yellow-600 text-black", - remote: "bg-gray-600 text-gray-200", - containerized: "bg-orange-600 text-black", + component: "bg-gray-600 text-gray-200", } export function RoleBadge({ role }: { role: string }) { @@ -16,9 +14,9 @@ export function RoleBadge({ role }: { role: string }) { {role} diff --git a/app/src/components/SectionHeader.tsx b/app/src/components/SectionHeader.tsx new file mode 100644 index 0000000..8058c5e --- /dev/null +++ b/app/src/components/SectionHeader.tsx @@ -0,0 +1,11 @@ +import { SECTION_HEADERS } from "@/lib/labels" + +export function SectionHeader({ category }: { category: string }) { + const info = SECTION_HEADERS[category] + return ( +
+

{info?.title ?? category}

+

{info?.subtitle}

+
+ ) +} diff --git a/app/src/components/ServiceSection.tsx b/app/src/components/ServiceSection.tsx new file mode 100644 index 0000000..e5aa1ce --- /dev/null +++ b/app/src/components/ServiceSection.tsx @@ -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 ( +
+ +
+ {services.map((svc) => ( + + ))} +
+
+ ) +} diff --git a/app/src/components/SortHeader.tsx b/app/src/components/SortHeader.tsx new file mode 100644 index 0000000..abebcc9 --- /dev/null +++ b/app/src/components/SortHeader.tsx @@ -0,0 +1,48 @@ +import { useState } from "react" +import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react" + +export type SortDir = "asc" | "desc" + +export function SortHeader({ + 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 ( + + + + ) +} + +export function useSort(defaultKey: K, defaultDir: SortDir = "asc") { + const [sortKey, setSortKey] = useState(defaultKey) + const [sortDir, setSortDir] = useState(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 +} diff --git a/app/src/components/ToolSection.tsx b/app/src/components/ToolSection.tsx new file mode 100644 index 0000000..830a4f5 --- /dev/null +++ b/app/src/components/ToolSection.tsx @@ -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("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 ( +
+ +
+ + + + + + + + + + + {sorted.map((tool) => ( + + ))} + +
DescriptionActions
+
+
+ ) +} + +function ToolRow({ tool }: { tool: ComponentSummary }) { + const { mutate, isPending } = useToolAction() + + return ( + + + + {tool.id} + + + + {tool.description ?? "—"} + + + {tool.installed !== null ? ( + tool.installed ? ( + + + installed + + ) : ( + + + not installed + + ) + ) : ( + + )} + + + {tool.installed !== null && ( + tool.installed ? ( + + ) : ( + + ) + )} + + + ) +} diff --git a/app/src/lib/labels.ts b/app/src/lib/labels.ts index 12db52e..97944c7 100644 --- a/app/src/lib/labels.ts +++ b/app/src/lib/labels.ts @@ -6,14 +6,28 @@ export const RUNNER_LABELS: Record = { remote: "Remote", } -export const ROLE_DESCRIPTIONS: Record = { - service: "Exposes HTTP endpoints", +export const CATEGORY_LABELS: Record = { + service: "Services", + job: "Jobs", + tool: "Tools", + frontend: "Frontends", + component: "Components", +} + +export const CATEGORY_DESCRIPTIONS: Record = { + service: "Long-running daemon", + job: "Scheduled task", tool: "CLI utility installed to PATH", - worker: "Background process (no HTTP)", - job: "Runs on a schedule", frontend: "Built static assets", - remote: "Hosted externally", - containerized: "Runs in a container", + component: "Software component", +} + +export const SECTION_HEADERS: Record = { + 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 { diff --git a/app/src/pages/ComponentDetail.tsx b/app/src/pages/ComponentDetail.tsx index 6f75594..676df1d 100644 --- a/app/src/pages/ComponentDetail.tsx +++ b/app/src/pages/ComponentDetail.tsx @@ -20,7 +20,7 @@ export function ComponentDetailPage() { const { mutate, isPending } = useServiceAction() const health = statusResp?.statuses.find((s) => s.id === name) const isDown = health?.status === "down" - const isTool = component?.roles.includes("tool") ?? false + const isTool = component?.category === "tool" const { data: toolDetail } = useToolDetail(isTool ? (name ?? "") : "") const isGateway = name === "castle-gateway" const { data: caddyfile } = useCaddyfile(isGateway) @@ -28,10 +28,14 @@ export function ComponentDetailPage() { const { data: unitData } = useSystemdUnit(name ?? "", showUnit && !!component?.systemd) const [message, setMessage] = useState<{ type: "ok" | "error"; text: string } | null>(null) + const configSection = component?.category === "service" ? "services" + : component?.category === "job" ? "jobs" + : "components" + const handleSave = async (compName: string, config: Record) => { setMessage(null) try { - await apiClient.put(`/config/components/${compName}`, { config }) + await apiClient.put(`/config/${configSection}/${compName}`, { config }) setMessage({ type: "ok", text: "Saved to castle.yaml" }) refetch() qc.invalidateQueries({ queryKey: ["components"] }) @@ -43,7 +47,7 @@ export function ComponentDetailPage() { const handleDelete = async (compName: string) => { try { - await apiClient.delete(`/config/components/${compName}`) + await apiClient.delete(`/config/${configSection}/${compName}`) qc.invalidateQueries({ queryKey: ["components"] }) navigate("/") } catch (e: unknown) { @@ -116,10 +120,11 @@ export function ComponentDetailPage() {
-
- {component.roles.map((role) => ( - - ))} +
+ + {component.source && ( + {component.source} + )}
{message && ( diff --git a/app/src/pages/Dashboard.tsx b/app/src/pages/Dashboard.tsx index 018641b..d3a7dcc 100644 --- a/app/src/pages/Dashboard.tsx +++ b/app/src/pages/Dashboard.tsx @@ -1,5 +1,10 @@ -import { ComponentTable } from "@/components/ComponentTable" +import { useMemo } from "react" +import { Link } from "react-router-dom" import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks" +import { ServiceSection } from "@/components/ServiceSection" +import { JobSection } from "@/components/JobSection" +import { ToolSection } from "@/components/ToolSection" +import { SectionHeader } from "@/components/SectionHeader" export function Dashboard() { useEventStream() @@ -7,6 +12,20 @@ export function Dashboard() { const { data: statusResp } = useStatus() const { data: gateway } = useGateway() + const { services, jobs, tools, frontends, other } = useMemo(() => { + const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components } + for (const c of components ?? []) { + if (c.category === "service") s.services!.push(c) + else if (c.category === "job") s.jobs!.push(c) + else if (c.category === "tool") s.tools!.push(c) + else if (c.category === "frontend") s.frontends!.push(c) + else s.other!.push(c) + } + return { services: s.services!, jobs: s.jobs!, tools: s.tools!, frontends: s.frontends!, other: s.other! } + }, [components]) + + const statuses = statusResp?.statuses ?? [] + return (
@@ -15,21 +34,79 @@ export function Dashboard() { Personal software platform {gateway && ( - · {gateway.component_count} components · port {gateway.port} + · {services.length} services · {jobs.length} jobs + · {tools.length} tools · port {gateway.port} )}

{isLoading ? ( -

Loading components...

- ) : components ? ( - +

Loading...

) : ( -

Failed to load components

+
+ {services.length > 0 && ( + + )} + {jobs.length > 0 && ( + + )} + {tools.length > 0 && ( + + )} + {frontends.length > 0 && ( +
+ +
+ + + {frontends.map((fe) => ( + + + + + ))} + +
+ + {fe.id} + + + {fe.description ?? "—"} +
+
+
+ )} + {other.length > 0 && ( +
+ +
+ + + {other.map((c) => ( + + + + + ))} + +
+ + {c.id} + + + {c.description ?? "—"} +
+
+
+ )} +
)}
) diff --git a/app/src/types/index.ts b/app/src/types/index.ts index b82bc43..2a6e132 100644 --- a/app/src/types/index.ts +++ b/app/src/types/index.ts @@ -7,7 +7,7 @@ export interface SystemdInfo { export interface ComponentSummary { id: string description: string | null - roles: string[] + category: string runner: string | null port: number | null health_path: string | null diff --git a/castle-api/src/castle_api/config_editor.py b/castle-api/src/castle_api/config_editor.py index 15d1d2c..a9ffd8f 100644 --- a/castle-api/src/castle_api/config_editor.py +++ b/castle-api/src/castle_api/config_editor.py @@ -10,7 +10,7 @@ from fastapi import APIRouter, HTTPException, status from pydantic import BaseModel from castle_core.config import save_config -from castle_core.manifest import ComponentManifest +from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec from castle_api.config import get_castle_root, get_config, get_registry from castle_api.stream import broadcast @@ -29,6 +29,8 @@ class ConfigSaveRequest(BaseModel): class ConfigSaveResponse(BaseModel): ok: bool component_count: int + service_count: int + job_count: int errors: list[str] @@ -42,6 +44,14 @@ class ComponentConfigRequest(BaseModel): config: dict +class ServiceConfigRequest(BaseModel): + config: dict + + +class JobConfigRequest(BaseModel): + config: dict + + def _require_repo() -> None: """Raise 503 if repo is not available.""" if get_castle_root() is None: @@ -76,22 +86,44 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse: detail=f"Invalid YAML: {e}", ) - if not isinstance(data, dict) or "components" not in data: + if not isinstance(data, dict): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="YAML must have a 'components' key", + detail="YAML must be a mapping", ) - # Validate each component - count = 0 + # Validate components + comp_count = 0 for name, comp_data in data.get("components", {}).items(): try: comp_data_copy = dict(comp_data) if comp_data else {} comp_data_copy["id"] = name - ComponentManifest.model_validate(comp_data_copy) - count += 1 + ComponentSpec.model_validate(comp_data_copy) + comp_count += 1 except Exception as e: - errors.append(f"{name}: {e}") + errors.append(f"components.{name}: {e}") + + # Validate services + svc_count = 0 + for name, svc_data in data.get("services", {}).items(): + try: + svc_data_copy = dict(svc_data) if svc_data else {} + svc_data_copy["id"] = name + ServiceSpec.model_validate(svc_data_copy) + svc_count += 1 + except Exception as e: + errors.append(f"services.{name}: {e}") + + # Validate jobs + job_count = 0 + for name, job_data in data.get("jobs", {}).items(): + try: + job_data_copy = dict(job_data) if job_data else {} + job_data_copy["id"] = name + JobSpec.model_validate(job_data_copy) + job_count += 1 + except Exception as e: + errors.append(f"jobs.{name}: {e}") if errors: raise HTTPException( @@ -105,7 +137,10 @@ def save_yaml(request: ConfigSaveRequest) -> ConfigSaveResponse: shutil.copy2(config_path, backup_path) config_path.write_text(request.yaml_content) - return ConfigSaveResponse(ok=True, component_count=count, errors=[]) + return ConfigSaveResponse( + ok=True, component_count=comp_count, service_count=svc_count, + job_count=job_count, errors=[], + ) @router.put("/components/{name}") @@ -113,11 +148,10 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict: """Update a single component's config in castle.yaml.""" _require_repo() - # Validate try: comp_data = dict(request.config) comp_data["id"] = name - ComponentManifest.model_validate(comp_data) + ComponentSpec.model_validate(comp_data) except Exception as e: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, @@ -125,7 +159,7 @@ def save_component(name: str, request: ComponentConfigRequest) -> dict: ) config = get_config() - config.components[name] = ComponentManifest.model_validate( + config.components[name] = ComponentSpec.model_validate( {**request.config, "id": name} ) save_config(config) @@ -146,6 +180,80 @@ def delete_component(name: str) -> dict: return {"ok": True, "component": name, "action": "deleted"} +@router.put("/services/{name}") +def save_service(name: str, request: ServiceConfigRequest) -> dict: + """Update a single service's config in castle.yaml.""" + _require_repo() + + try: + svc_data = dict(request.config) + svc_data["id"] = name + ServiceSpec.model_validate(svc_data) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid service config: {e}", + ) + + config = get_config() + config.services[name] = ServiceSpec.model_validate( + {**request.config, "id": name} + ) + save_config(config) + return {"ok": True, "service": name} + + +@router.delete("/services/{name}") +def delete_service(name: str) -> dict: + """Remove a service from castle.yaml.""" + config = get_config() + if name not in config.services: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Service '{name}' not found", + ) + del config.services[name] + save_config(config) + return {"ok": True, "service": name, "action": "deleted"} + + +@router.put("/jobs/{name}") +def save_job(name: str, request: JobConfigRequest) -> dict: + """Update a single job's config in castle.yaml.""" + _require_repo() + + try: + job_data = dict(request.config) + job_data["id"] = name + JobSpec.model_validate(job_data) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid job config: {e}", + ) + + config = get_config() + config.jobs[name] = JobSpec.model_validate( + {**request.config, "id": name} + ) + save_config(config) + return {"ok": True, "job": name} + + +@router.delete("/jobs/{name}") +def delete_job(name: str) -> dict: + """Remove a job from castle.yaml.""" + config = get_config() + if name not in config.jobs: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Job '{name}' not found", + ) + del config.jobs[name] + save_config(config) + return {"ok": True, "job": name, "action": "deleted"} + + @router.post("/apply", response_model=ApplyResponse) async def apply_config() -> ApplyResponse: """Apply config: restart managed services + regenerate and reload gateway.""" diff --git a/castle-api/src/castle_api/logs.py b/castle-api/src/castle_api/logs.py index 6ea743a..5286526 100644 --- a/castle-api/src/castle_api/logs.py +++ b/castle-api/src/castle_api/logs.py @@ -8,9 +8,7 @@ from collections.abc import AsyncGenerator from fastapi import APIRouter, HTTPException, Query, status from starlette.responses import StreamingResponse -from castle_core.config import load_config - -from castle_api.config import settings +from castle_api.config import get_castle_root router = APIRouter(prefix="/logs", tags=["logs"]) @@ -24,11 +22,24 @@ async def get_logs( follow: bool = Query(default=False, description="Stream new lines via SSE"), ) -> StreamingResponse | dict: """Get logs for a systemd-managed service.""" - config = load_config(settings.castle_root) - if name not in config.managed: + root = get_castle_root() + if root: + from castle_core.config import load_config + + config = load_config(root) + is_managed = ( + (name in config.services and config.services[name].manage is not None) + or (name in config.jobs and config.jobs[name].manage is not None) + ) + if not is_managed: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"'{name}' is not a managed service", + ) + else: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail=f"'{name}' is not a managed service", + detail="Castle root not available", ) unit = f"{UNIT_PREFIX}{name}.service" diff --git a/castle-api/src/castle_api/models.py b/castle-api/src/castle_api/models.py index 688914d..66fbd9a 100644 --- a/castle-api/src/castle_api/models.py +++ b/castle-api/src/castle_api/models.py @@ -16,7 +16,7 @@ class ComponentSummary(BaseModel): id: str description: str | None = None - roles: list[str] + category: str runner: str | None = None port: int | None = None health_path: str | None = None diff --git a/castle-api/src/castle_api/routes.py b/castle-api/src/castle_api/routes.py index fc39b54..e10e7da 100644 --- a/castle-api/src/castle_api/routes.py +++ b/castle-api/src/castle_api/routes.py @@ -8,6 +8,7 @@ from pathlib import Path from fastapi import APIRouter, HTTPException, status from castle_core.generators.caddyfile import generate_caddyfile_from_registry +from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec from castle_api.config import get_castle_root, get_registry from castle_api.health import check_all_health @@ -39,13 +40,13 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary: # Check if tool is installed on PATH installed: bool | None = None - if "tool" in deployed.roles: + if deployed.category == "tool": installed = shutil.which(name) is not None return ComponentSummary( id=name, description=deployed.description, - roles=deployed.roles, + category=deployed.category, runner=deployed.runner, port=deployed.port, health_path=deployed.health_path, @@ -57,67 +58,116 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary: ) -def _summary_from_manifest(name: str, manifest: object, root: Path) -> ComponentSummary: - """Build a ComponentSummary from a manifest (for non-deployed components).""" +def _summary_from_service(name: str, svc: ServiceSpec, config: object) -> ComponentSummary: + """Build a ComponentSummary from a ServiceSpec (non-deployed).""" port = None health_path = None proxy_path = None - if manifest.expose and manifest.expose.http: - port = manifest.expose.http.internal.port - health_path = manifest.expose.http.health_path - if manifest.proxy and manifest.proxy.caddy: - proxy_path = manifest.proxy.caddy.path_prefix + if svc.expose and svc.expose.http: + port = svc.expose.http.internal.port + health_path = svc.expose.http.health_path + if svc.proxy and svc.proxy.caddy: + proxy_path = svc.proxy.caddy.path_prefix - managed = bool( - manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable - ) + managed = bool(svc.manage and svc.manage.systemd and svc.manage.systemd.enable) systemd_info: SystemdInfo | None = None if managed: unit_name = f"castle-{name}.service" unit_path = str(Path("~/.config/systemd/user") / unit_name) - has_timer = any( - getattr(t, "type", None) == "schedule" for t in manifest.triggers - ) - systemd_info = SystemdInfo( - unit_name=unit_name, - unit_path=unit_path, - timer=has_timer, - ) + systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=False) - schedule = None - for t in manifest.triggers: - if t.type == "schedule": - schedule = t.cron - break + description = svc.description + source = None + if svc.component and svc.component in config.components: + comp = config.components[svc.component] + if not description: + description = comp.description + source = comp.source - runner = manifest.run.runner if manifest.run else None - if runner is None and manifest.tool and manifest.tool.source: - source_dir = root / manifest.tool.source - if (source_dir / "pyproject.toml").exists(): - runner = "python" - elif source_dir.is_file(): - runner = "command" - - installed: bool | None = None - if manifest.install and manifest.install.path: - alias = manifest.install.path.alias or name - installed = shutil.which(alias) is not None + runner = svc.run.runner return ComponentSummary( id=name, - description=manifest.description, - roles=[r.value for r in manifest.roles], + description=description, + category="service", runner=runner, port=port, health_path=health_path, proxy_path=proxy_path, managed=managed, systemd=systemd_info, - version=manifest.tool.version if manifest.tool else None, - source=manifest.tool.source if manifest.tool else None, - system_dependencies=manifest.tool.system_dependencies if manifest.tool else [], - schedule=schedule, + source=source, + ) + + +def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSummary: + """Build a ComponentSummary from a JobSpec (non-deployed).""" + managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable) + + systemd_info: SystemdInfo | None = None + if managed: + unit_name = f"castle-{name}.service" + unit_path = str(Path("~/.config/systemd/user") / unit_name) + systemd_info = SystemdInfo(unit_name=unit_name, unit_path=unit_path, timer=True) + + description = job.description + source = None + if job.component and job.component in config.components: + comp = config.components[job.component] + if not description: + description = comp.description + source = comp.source + + return ComponentSummary( + id=name, + description=description, + category="job", + runner=job.run.runner, + managed=managed, + systemd=systemd_info, + schedule=job.schedule, + source=source, + ) + + +def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> ComponentSummary: + """Build a ComponentSummary from a ComponentSpec (tools/frontends).""" + # Determine category + is_tool = bool((comp.install and comp.install.path) or comp.tool) + is_frontend = bool(comp.build and (comp.build.outputs or comp.build.commands)) + + if is_tool: + category = "tool" + elif is_frontend: + category = "frontend" + else: + category = "component" + + source = comp.source + + # Infer runner from source directory + runner = None + if source: + source_dir = root / source + if (source_dir / "pyproject.toml").exists(): + runner = "python" + elif source_dir.is_file(): + runner = "command" + + installed: bool | None = None + if comp.install and comp.install.path: + alias = comp.install.path.alias or name + installed = shutil.which(alias) is not None + + return ComponentSummary( + id=name, + description=comp.description, + category=category, + runner=runner, + version=comp.tool.version if comp.tool else None, + source=source, + system_dependencies=comp.tool.system_dependencies if comp.tool else [], installed=installed, ) @@ -134,16 +184,49 @@ def list_components() -> list[ComponentSummary]: summaries.append(_summary_from_deployed(name, deployed)) seen.add(name) - # Non-deployed components from castle.yaml (if repo available) + # Non-deployed from castle.yaml (if repo available) root = get_castle_root() if root: try: from castle_core.config import load_config config = load_config(root) - for name, manifest in config.components.items(): + + # Services not in registry + for name, svc in config.services.items(): if name not in seen: - summaries.append(_summary_from_manifest(name, manifest, root)) + summaries.append(_summary_from_service(name, svc, config)) + seen.add(name) + + # Jobs not in registry + for name, job in config.jobs.items(): + if name not in seen: + summaries.append(_summary_from_job(name, job, config)) + seen.add(name) + + # Backfill source from component refs for deployed items + for s in summaries: + if s.source is None and s.id in config.components: + s.source = config.components[s.id].source + elif s.source is None: + # Check if a service/job references a component + ref = None + if s.id in config.services: + ref = config.services[s.id].component + elif s.id in config.jobs: + ref = config.jobs[s.id].component + if ref and ref in config.components: + s.source = config.components[ref].source + + # Components (tools/frontends) — always listed, even if a + # service/job with the same name exists. A component is + # "what software exists", services/jobs are "how it runs". + for name, comp in config.components.items(): + summary = _summary_from_component(name, comp, root) + # Skip if this exact category is already represented + # (e.g. a deployed tool already in the list) + if not any(s.id == name and s.category == summary.category for s in summaries): + summaries.append(summary) except FileNotFoundError: pass @@ -158,6 +241,27 @@ def get_component(name: str) -> ComponentDetail: if name in registry.deployed: deployed = registry.deployed[name] summary = _summary_from_deployed(name, deployed) + + # Backfill source from castle.yaml component ref + root = get_castle_root() + if root and summary.source is None: + try: + from castle_core.config import load_config + + config = load_config(root) + if name in config.components: + summary.source = config.components[name].source + else: + ref = None + if name in config.services: + ref = config.services[name].component + elif name in config.jobs: + ref = config.jobs[name].component + if ref and ref in config.components: + summary.source = config.components[ref].source + except FileNotFoundError: + pass + raw = { "runner": deployed.runner, "run_cmd": deployed.run_cmd, @@ -166,7 +270,7 @@ def get_component(name: str) -> ComponentDetail: "health_path": deployed.health_path, "proxy_path": deployed.proxy_path, "managed": deployed.managed, - "roles": deployed.roles, + "category": deployed.category, } return ComponentDetail(**summary.model_dump(), manifest=raw) @@ -176,10 +280,23 @@ def get_component(name: str) -> ComponentDetail: from castle_core.config import load_config config = load_config(root) + + if name in config.services: + svc = config.services[name] + summary = _summary_from_service(name, svc, config) + raw = svc.model_dump(mode="json", exclude_none=True) + return ComponentDetail(**summary.model_dump(), manifest=raw) + + if name in config.jobs: + job = config.jobs[name] + summary = _summary_from_job(name, job, config) + raw = job.model_dump(mode="json", exclude_none=True) + return ComponentDetail(**summary.model_dump(), manifest=raw) + if name in config.components: - manifest = config.components[name] - summary = _summary_from_manifest(name, manifest, root) - raw = manifest.model_dump(mode="json", exclude_none=True) + comp = config.components[name] + summary = _summary_from_component(name, comp, root) + raw = comp.model_dump(mode="json", exclude_none=True) return ComponentDetail(**summary.model_dump(), manifest=raw) raise HTTPException( diff --git a/castle-api/src/castle_api/services.py b/castle-api/src/castle_api/services.py index 93dc892..72c3312 100644 --- a/castle-api/src/castle_api/services.py +++ b/castle-api/src/castle_api/services.py @@ -131,21 +131,29 @@ def get_unit(name: str) -> dict[str, str | None]: registry = get_registry() deployed = registry.deployed[name] - # Get systemd spec from manifest if repo available + # Get systemd spec from config if repo available systemd_spec = None + schedule = None + description = None root = get_castle_root() - manifest = None if root: from castle_core.config import load_config config = load_config(root) - if name in config.components: - manifest = config.components[name] - if manifest.manage and manifest.manage.systemd: - systemd_spec = manifest.manage.systemd + if name in config.services: + svc = config.services[name] + if svc.manage and svc.manage.systemd: + systemd_spec = svc.manage.systemd + description = svc.description + elif name in config.jobs: + job = config.jobs[name] + if job.manage and job.manage.systemd: + systemd_spec = job.manage.systemd + schedule = job.schedule + description = job.description unit = generate_unit_from_deployed(name, deployed, systemd_spec) - timer = generate_timer(name, manifest) if manifest else None + timer = generate_timer(name, schedule, description) if schedule else None return {"service": unit, "timer": timer} diff --git a/castle-api/src/castle_api/tools.py b/castle-api/src/castle_api/tools.py index 0b27800..54bf150 100644 --- a/castle-api/src/castle_api/tools.py +++ b/castle-api/src/castle_api/tools.py @@ -7,7 +7,7 @@ from pathlib import Path from fastapi import APIRouter, HTTPException, status -from castle_core.manifest import ComponentManifest +from castle_core.manifest import ComponentSpec from castle_api.config import get_config from castle_api.models import ToolDetail, ToolSummary @@ -15,20 +15,25 @@ from castle_api.models import ToolDetail, ToolSummary router = APIRouter(tags=["tools"]) +def _is_tool(comp: ComponentSpec) -> bool: + """Check if a component is a tool (has install.path or tool spec).""" + return bool((comp.install and comp.install.path) or comp.tool) + + def _tool_summary( - name: str, manifest: ComponentManifest, root: Path | None = None + name: str, comp: ComponentSpec, root: Path | None = None ) -> ToolSummary: - """Build a ToolSummary from a manifest that has a tool spec.""" - t = manifest.tool - assert t is not None + """Build a ToolSummary from a ComponentSpec that is a tool.""" + t = comp.tool installed = bool( - manifest.install and manifest.install.path and manifest.install.path.enable + comp.install and comp.install.path and comp.install.path.enable ) - # Infer runner from run block or source directory - runner = manifest.run.runner if manifest.run else None - if runner is None and t.source and root: - source_dir = root / t.source + # Infer runner from source directory + runner = None + source = comp.source + if source and root: + source_dir = root / source if (source_dir / "pyproject.toml").exists(): runner = "python" elif source_dir.is_file(): @@ -36,11 +41,11 @@ def _tool_summary( return ToolSummary( id=name, - description=manifest.description, - source=t.source, - version=t.version, + description=comp.description, + source=source, + version=t.version if t else None, runner=runner, - system_dependencies=t.system_dependencies, + system_dependencies=t.system_dependencies if t else [], installed=installed, ) @@ -49,12 +54,12 @@ def _tool_summary( def list_tools() -> list[ToolSummary]: """List all registered tools (requires repo access).""" config = get_config() - tools = {k: v for k, v in config.components.items() if v.tool} + tools = {k: v for k, v in config.components.items() if _is_tool(v)} return sorted( [ - _tool_summary(name, manifest, config.root) - for name, manifest in tools.items() + _tool_summary(name, comp, config.root) + for name, comp in tools.items() ], key=lambda t: t.id, ) @@ -71,14 +76,14 @@ def get_tool(name: str) -> ToolDetail: detail=f"Component '{name}' not found", ) - manifest = config.components[name] - if not manifest.tool: + comp = config.components[name] + if not _is_tool(comp): raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' is not a tool", ) - summary = _tool_summary(name, manifest, config.root) + summary = _tool_summary(name, comp, config.root) return ToolDetail(**summary.model_dump()) @@ -91,16 +96,16 @@ async def install_tool(name: str) -> dict: status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found" ) - manifest = config.components[name] - if not manifest.tool or not manifest.tool.source: + comp = config.components[name] + if not comp.source: raise HTTPException( - status_code=400, detail=f"'{name}' has no tool source to install" + status_code=400, detail=f"'{name}' has no source to install" ) - source_dir = config.root / manifest.tool.source + source_dir = config.root / comp.source if not (source_dir / "pyproject.toml").exists(): raise HTTPException( - status_code=400, detail=f"No pyproject.toml in {manifest.tool.source}" + status_code=400, detail=f"No pyproject.toml in {comp.source}" ) proc = await asyncio.create_subprocess_exec( @@ -131,12 +136,12 @@ async def uninstall_tool(name: str) -> dict: status_code=status.HTTP_404_NOT_FOUND, detail=f"'{name}' not found" ) - manifest = config.components[name] - if not manifest.tool or not manifest.tool.source: - raise HTTPException(status_code=400, detail=f"'{name}' has no tool source") + comp = config.components[name] + if not comp.source: + raise HTTPException(status_code=400, detail=f"'{name}' has no source") # uv tool uninstall uses the package name from pyproject.toml - source_dir = config.root / manifest.tool.source + source_dir = config.root / comp.source pkg_name = source_dir.name pyproject = source_dir / "pyproject.toml" if pyproject.exists(): diff --git a/castle-api/tests/conftest.py b/castle-api/tests/conftest.py index 734e6c2..1586185 100644 --- a/castle-api/tests/conftest.py +++ b/castle-api/tests/conftest.py @@ -24,9 +24,26 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: config = { "gateway": {"port": 9000}, "components": { + "test-tool": { + "description": "Test tool", + "source": "test-tool", + "install": {"path": {"alias": "test-tool"}}, + "tool": { + "system_dependencies": ["pandoc"], + }, + }, + "test-tool-2": { + "description": "Another test tool", + "source": "test-tool-2", + "tool": { + "version": "2.0.0", + }, + }, + }, + "services": { "test-svc": { + "component": "test-svc-comp", "description": "Test service", - "source": "test-svc", "run": { "runner": "python", "tool": "test-svc", @@ -40,20 +57,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "proxy": {"caddy": {"path_prefix": "/test-svc"}}, "manage": {"systemd": {}}, }, - "test-tool": { - "description": "Test tool", - "install": {"path": {"alias": "test-tool"}}, - "tool": { - "source": "test-tool", - "system_dependencies": ["pandoc"], - }, - }, - "test-tool-2": { - "description": "Another test tool", - "tool": { - "source": "test-tool-2", - "version": "2.0.0", + }, + "jobs": { + "test-job": { + "description": "Test job", + "run": { + "runner": "command", + "argv": ["test-job"], }, + "schedule": "0 2 * * *", }, }, } @@ -80,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No "TEST_SVC_DATA_DIR": "/data/castle/test-svc", }, description="Test service", - roles=["service"], + category="service", port=19000, health_path="/health", proxy_path="/test-svc", diff --git a/castle-api/tests/test_health.py b/castle-api/tests/test_health.py index 6f090d6..02fb303 100644 --- a/castle-api/tests/test_health.py +++ b/castle-api/tests/test_health.py @@ -34,7 +34,7 @@ class TestComponents: assert svc["health_path"] == "/health" assert svc["proxy_path"] == "/test-svc" assert svc["managed"] is True - assert "service" in svc["roles"] + assert svc["category"] == "service" def test_tool_has_no_port(self, client: TestClient) -> None: """Tool component has no port.""" @@ -42,7 +42,15 @@ class TestComponents: data = response.json() tool = next(c for c in data if c["id"] == "test-tool") assert tool["port"] is None - assert "tool" in tool["roles"] + assert tool["category"] == "tool" + + def test_job_has_schedule(self, client: TestClient) -> None: + """Job component has schedule.""" + response = client.get("/components") + data = response.json() + job = next(c for c in data if c["id"] == "test-job") + assert job["category"] == "job" + assert job["schedule"] == "0 2 * * *" class TestComponentDetail: diff --git a/castle.yaml b/castle.yaml index 8274a1a..fe6c4c0 100644 --- a/castle.yaml +++ b/castle.yaml @@ -1,138 +1,31 @@ gateway: port: 9000 + components: - castle-gateway: - description: Caddy reverse proxy gateway - run: - runner: command - argv: - - caddy - - run - - --config - - /home/payne/.castle/generated/Caddyfile - - --adapter - - caddyfile - manage: - systemd: - exec_reload: caddy reload --config /home/payne/.castle/generated/Caddyfile - --adapter caddyfile - expose: - http: - internal: - port: 9000 - health_path: / central-context: description: Content storage API useful to get context into my data dir from anywhere on the LAN source: components/central-context - run: - runner: python - tool: central-context - manage: - systemd: {} - expose: - http: - internal: - port: 9001 - health_path: /health - proxy: - caddy: - path_prefix: /central-context notification-bridge: description: Desktop notification forwarder. This taps into your notifications system on the desktop and will forward all notifications the the central context server. source: components/notification-bridge - run: - runner: python - tool: notification-bridge - defaults: - env: - CENTRAL_CONTEXT_URL: http://localhost:9001 - BUCKET_NAME: notifications - PORT: '9002' - manage: - systemd: {} - expose: - http: - internal: - port: 9002 - health_path: /health - proxy: - caddy: - path_prefix: /notifications castle-api: description: Castle API source: castle-api - run: - runner: python - tool: castle-api - manage: - systemd: {} - expose: - http: - internal: - port: 9020 - health_path: /health - proxy: - caddy: - path_prefix: /api protonmail: description: ProtonMail email sync via Bridge source: components/protonmail - run: - runner: command - argv: - - protonmail - - sync - defaults: - env: - PROTONMAIL_USERNAME: paul@payne.io - PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY} - triggers: - - type: schedule - cron: '*/5 * * * *' - timezone: America/Los_Angeles - manage: - systemd: {} install: path: alias: protonmail backup-collect: description: Collect files from various sources into backup directory source: components/backup-collect - run: - runner: command - argv: - - backup-collect - defaults: - env: - DBACKUP: /data/backup - triggers: - - type: schedule - cron: 0 2 * * * - timezone: America/Los_Angeles - manage: - systemd: {} tool: system_dependencies: - rsync - backup-data: - description: Nightly restic backup of /data to /storage - run: - runner: command - argv: - - backup-data - defaults: - env: - RESTIC_REPOSITORY: /storage/restic-data - RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password - triggers: - - type: schedule - cron: 30 3 * * * - timezone: America/Los_Angeles - manage: - systemd: {} castle-app: description: Castle web app source: app @@ -249,3 +142,118 @@ components: install: path: alias: text-extractor + +services: + castle-gateway: + description: Caddy reverse proxy gateway + run: + runner: command + argv: + - caddy + - run + - --config + - /home/payne/.castle/generated/Caddyfile + - --adapter + - caddyfile + manage: + systemd: + exec_reload: caddy reload --config /home/payne/.castle/generated/Caddyfile + --adapter caddyfile + expose: + http: + internal: + port: 9000 + health_path: / + central-context: + component: central-context + run: + runner: python + tool: central-context + manage: + systemd: {} + expose: + http: + internal: + port: 9001 + health_path: /health + proxy: + caddy: + path_prefix: /central-context + notification-bridge: + component: notification-bridge + run: + runner: python + tool: notification-bridge + defaults: + env: + CENTRAL_CONTEXT_URL: http://localhost:9001 + BUCKET_NAME: notifications + PORT: '9002' + manage: + systemd: {} + expose: + http: + internal: + port: 9002 + health_path: /health + proxy: + caddy: + path_prefix: /notifications + castle-api: + component: castle-api + run: + runner: python + tool: castle-api + manage: + systemd: {} + expose: + http: + internal: + port: 9020 + health_path: /health + proxy: + caddy: + path_prefix: /api + +jobs: + protonmail-sync: + component: protonmail + description: Sync ProtonMail email + run: + runner: command + argv: + - protonmail + - sync + schedule: '*/5 * * * *' + defaults: + env: + PROTONMAIL_USERNAME: paul@payne.io + PROTONMAIL_API_KEY: ${secret:PROTONMAIL_API_KEY} + manage: + systemd: {} + backup-collect: + component: backup-collect + description: Collect files from various sources into backup directory + run: + runner: command + argv: + - backup-collect + schedule: 0 2 * * * + defaults: + env: + DBACKUP: /data/backup + manage: + systemd: {} + backup-data: + description: Nightly restic backup of /data to /storage + run: + runner: command + argv: + - backup-data + schedule: 30 3 * * * + defaults: + env: + RESTIC_REPOSITORY: /storage/restic-data + RESTIC_PASSWORD_FILE: /home/payne/.config/restic-password + manage: + systemd: {} diff --git a/cli/src/castle_cli/commands/create.py b/cli/src/castle_cli/commands/create.py index 5c02e72..548f16c 100644 --- a/cli/src/castle_cli/commands/create.py +++ b/cli/src/castle_cli/commands/create.py @@ -7,7 +7,7 @@ import argparse from castle_cli.config import load_config, save_config from castle_cli.manifest import ( CaddySpec, - ComponentManifest, + ComponentSpec, ExposeSpec, HttpExposeSpec, HttpInternal, @@ -16,6 +16,7 @@ from castle_cli.manifest import ( PathInstallSpec, ProxySpec, RunPython, + ServiceSpec, SystemdSpec, ToolSpec, ) @@ -25,9 +26,9 @@ from castle_cli.templates.scaffold import scaffold_project def next_available_port(config: object) -> int: """Find the next available port starting from 9001 (9000 is reserved for gateway).""" used_ports = set() - for manifest in config.components.values(): - if manifest.expose and manifest.expose.http: - used_ports.add(manifest.expose.http.internal.port) + for svc in config.services.values(): + if svc.expose and svc.expose.http: + used_ports.add(svc.expose.http.internal.port) # Also reserve gateway port used_ports.add(config.gateway.port) @@ -43,8 +44,8 @@ def run_create(args: argparse.Namespace) -> int: name = args.name proj_type = args.type - if name in config.components: - print(f"Error: component '{name}' already exists in castle.yaml") + if name in config.components or name in config.services or name in config.jobs: + print(f"Error: '{name}' already exists in castle.yaml") return 1 components_dir = config.root / "components" @@ -72,16 +73,19 @@ def run_create(args: argparse.Namespace) -> int: port=port, ) - # Build manifest entry + # Build entries if proj_type == "service": - manifest = ComponentManifest( + # Component for software identity + config.components[name] = ComponentSpec( id=name, description=args.description or f"A castle {proj_type}", source=f"components/{name}", - run=RunPython( - runner="python", - tool=name, - ), + ) + # Service for deployment + config.services[name] = ServiceSpec( + id=name, + component=name, + run=RunPython(runner="python", tool=name), expose=ExposeSpec( http=HttpExposeSpec( internal=HttpInternal(port=port), @@ -92,22 +96,21 @@ def run_create(args: argparse.Namespace) -> int: manage=ManageSpec(systemd=SystemdSpec()), ) elif proj_type == "tool": - manifest = ComponentManifest( + config.components[name] = ComponentSpec( id=name, description=args.description or f"A castle {proj_type}", source=f"components/{name}", - tool=ToolSpec(source=f"components/{name}/"), + tool=ToolSpec(), install=InstallSpec(path=PathInstallSpec(alias=name)), ) else: # library or other - manifest = ComponentManifest( + config.components[name] = ComponentSpec( id=name, description=args.description or f"A castle {proj_type}", source=f"components/{name}", ) - config.components[name] = manifest save_config(config) print(f"Created {proj_type} '{name}' at {project_dir}") diff --git a/cli/src/castle_cli/commands/deploy.py b/cli/src/castle_cli/commands/deploy.py index 09e8727..65baf56 100644 --- a/cli/src/castle_cli/commands/deploy.py +++ b/cli/src/castle_cli/commands/deploy.py @@ -19,11 +19,10 @@ from castle_core.generators.caddyfile import generate_caddyfile_from_registry from castle_core.generators.systemd import ( generate_timer, generate_unit_from_deployed, - get_schedule_trigger, timer_name, unit_name, ) -from castle_core.manifest import ComponentManifest +from castle_core.manifest import JobSpec, ServiceSpec from castle_core.registry import ( REGISTRY_PATH, DeployedComponent, @@ -40,15 +39,7 @@ SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" def run_deploy(args: argparse.Namespace) -> int: """Deploy components from castle.yaml to ~/.castle/.""" config = load_config() - component_name = getattr(args, "component", None) - - if component_name: - if component_name not in config.components: - print(f"Error: component '{component_name}' not found in castle.yaml") - return 1 - names = [component_name] - else: - names = list(config.components.keys()) + target_name = getattr(args, "component", None) ensure_dirs() @@ -57,7 +48,7 @@ def run_deploy(args: argparse.Namespace) -> int: # Load existing registry to preserve components not being redeployed, # or start fresh if deploying all - if component_name and REGISTRY_PATH.exists(): + if target_name and REGISTRY_PATH.exists(): try: existing = load_registry() registry = NodeRegistry(node=node, deployed=dict(existing.deployed)) @@ -67,14 +58,21 @@ def run_deploy(args: argparse.Namespace) -> int: registry = NodeRegistry(node=node) deployed_count = 0 - for name in names: - manifest = config.components[name] - # Only deploy components with a run spec - if not manifest.run: + # Deploy services + for name, svc in config.services.items(): + if target_name and name != target_name: continue + deployed = _build_deployed_service(config, name, svc) + registry.deployed[name] = deployed + deployed_count += 1 + _print_deployed(name, deployed) - deployed = _build_deployed(config, name, manifest) + # Deploy jobs + for name, job in config.jobs.items(): + if target_name and name != target_name: + continue + deployed = _build_deployed_job(config, name, job) registry.deployed[name] = deployed deployed_count += 1 _print_deployed(name, deployed) @@ -108,69 +106,100 @@ def _env_prefix(name: str) -> str: return name.replace("-", "_").upper() -def _build_deployed( - config: CastleConfig, name: str, manifest: ComponentManifest -) -> DeployedComponent: - """Build a DeployedComponent from a manifest spec.""" - run = manifest.run - assert run is not None +def _resolve_description( + config: CastleConfig, spec: ServiceSpec | JobSpec +) -> str | None: + """Get description, falling through to component if referenced.""" + if spec.description: + return spec.description + if spec.component and spec.component in config.components: + return config.components[spec.component].description + return None - # 1. Convention-based env vars + +def _build_deployed_service( + config: CastleConfig, name: str, svc: ServiceSpec +) -> DeployedComponent: + """Build a DeployedComponent from a ServiceSpec.""" + run = svc.run prefix = _env_prefix(name) env: dict[str, str] = {} - # Data dir convention (for all managed components) - if manifest.manage and manifest.manage.systemd: + # Data dir convention (for managed services) + managed = True # Services are always managed by default + if svc.manage and svc.manage.systemd and not svc.manage.systemd.enable: + managed = False + if managed: env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name) # Port convention (if exposed) - if manifest.expose and manifest.expose.http: - env[f"{prefix}_PORT"] = str(manifest.expose.http.internal.port) - - # 2. Merge defaults.env (overrides conventions) - if manifest.defaults and manifest.defaults.env: - env.update(manifest.defaults.env) - - # 3. Resolve secrets - env = resolve_env_vars(env, manifest) - - # 4. Build run_cmd - run_cmd = _build_run_cmd(run, env) - - # 5. Extract metadata port = None health_path = None - if manifest.expose and manifest.expose.http: - port = manifest.expose.http.internal.port - health_path = manifest.expose.http.health_path + if svc.expose and svc.expose.http: + port = svc.expose.http.internal.port + env[f"{prefix}_PORT"] = str(port) + health_path = svc.expose.http.health_path + # Merge defaults.env (overrides conventions) + if svc.defaults and svc.defaults.env: + env.update(svc.defaults.env) + + # Resolve secrets + env = resolve_env_vars(env) + + # Build run_cmd + run_cmd = _build_run_cmd(run, env) + + # Proxy path proxy_path = None - if manifest.proxy and manifest.proxy.caddy and manifest.proxy.caddy.enable: - proxy_path = manifest.proxy.caddy.path_prefix or f"/{name}" - - schedule = None - sched_trigger = get_schedule_trigger(manifest) - if sched_trigger: - schedule = sched_trigger.cron - - managed = bool(manifest.manage and manifest.manage.systemd and manifest.manage.systemd.enable) - - roles = [r.value for r in manifest.roles] + if svc.proxy and svc.proxy.caddy and svc.proxy.caddy.enable: + proxy_path = svc.proxy.caddy.path_prefix or f"/{name}" return DeployedComponent( runner=run.runner, run_cmd=run_cmd, env=env, - description=manifest.description, - roles=roles, + description=_resolve_description(config, svc), + category="service", port=port, health_path=health_path, proxy_path=proxy_path, - schedule=schedule, managed=managed, ) +def _build_deployed_job( + config: CastleConfig, name: str, job: JobSpec +) -> DeployedComponent: + """Build a DeployedComponent from a JobSpec.""" + run = job.run + prefix = _env_prefix(name) + env: dict[str, str] = {} + + # Data dir convention + env[f"{prefix}_DATA_DIR"] = str(DATA_ROOT / name) + + # Merge defaults.env (overrides conventions) + if job.defaults and job.defaults.env: + env.update(job.defaults.env) + + # Resolve secrets + env = resolve_env_vars(env) + + # Build run_cmd + run_cmd = _build_run_cmd(run, env) + + return DeployedComponent( + runner=run.runner, + run_cmd=run_cmd, + env=env, + description=_resolve_description(config, job), + category="job", + schedule=job.schedule, + managed=True, + ) + + def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]: """Build a run command list from a RunSpec.""" match run.runner: @@ -199,7 +228,6 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]: cmd.extend(["-p", f"{host_port}:{container_port}"]) for vol in run.volumes: cmd.extend(["-v", vol]) - # Container env comes from both run.env (container-specific) and deployed env for key, val in run.env.items(): cmd.extend(["-e", f"{key}={val}"]) for key, val in env.items(): @@ -238,13 +266,12 @@ def _copy_app_static(config: CastleConfig) -> None: if "castle-app" not in config.components: return - manifest = config.components["castle-app"] - if not (manifest.build and manifest.build.outputs): + comp = config.components["castle-app"] + if not (comp.build and comp.build.outputs): return - # Find the source dist directory - source_dir = manifest.source_dir or "app" - for output in manifest.build.outputs: + source_dir = comp.source_dir or "app" + for output in comp.build.outputs: src = config.root / source_dir / output if src.exists(): dest = STATIC_DIR / "castle-app" @@ -262,23 +289,30 @@ def _generate_systemd_units(config: CastleConfig, registry: NodeRegistry) -> Non if not deployed.managed: continue - # Get systemd spec from manifest (for restart policy, exec_reload, etc.) + # Get systemd spec from config (services or jobs) systemd_spec = None - if name in config.components: - manifest = config.components[name] - if manifest.manage and manifest.manage.systemd: - systemd_spec = manifest.manage.systemd + if name in config.services: + svc = config.services[name] + if svc.manage and svc.manage.systemd: + systemd_spec = svc.manage.systemd + elif name in config.jobs: + job = config.jobs[name] + if job.manage and job.manage.systemd: + systemd_spec = job.manage.systemd # Generate and write service unit svc_name = unit_name(name) svc_content = generate_unit_from_deployed(name, deployed, systemd_spec) (SYSTEMD_USER_DIR / svc_name).write_text(svc_content) - # Generate timer if scheduled - if name in config.components: - timer_content = generate_timer(name, config.components[name]) - if timer_content: - tmr_name = timer_name(name) - (SYSTEMD_USER_DIR / tmr_name).write_text(timer_content) + # Generate timer for jobs + if deployed.schedule: + timer_content = generate_timer( + name, + schedule=deployed.schedule, + description=deployed.description, + ) + tmr_name = timer_name(name) + (SYSTEMD_USER_DIR / tmr_name).write_text(timer_content) print(f"Systemd units written: {SYSTEMD_USER_DIR}") diff --git a/cli/src/castle_cli/commands/gateway.py b/cli/src/castle_cli/commands/gateway.py index 3515774..dfcbdfc 100644 --- a/cli/src/castle_cli/commands/gateway.py +++ b/cli/src/castle_cli/commands/gateway.py @@ -69,8 +69,8 @@ def _gateway_start(config: CastleConfig) -> int: """Generate config and enable the gateway service.""" from castle_cli.commands.service import _service_enable - if GATEWAY_COMPONENT not in config.managed: - print(f"Error: '{GATEWAY_COMPONENT}' not found in castle.yaml or not managed") + if GATEWAY_COMPONENT not in config.services: + print(f"Error: '{GATEWAY_COMPONENT}' not found in services section") return 1 print("Generating gateway configuration...") @@ -101,7 +101,6 @@ def _gateway_reload() -> int: if result.returncode == 0: print("Gateway reloaded.") else: - # Fall back to restart if reload not supported print("Reload signal sent. Verifying...") result = subprocess.run( ["systemctl", "--user", "is-active", GATEWAY_UNIT], diff --git a/cli/src/castle_cli/commands/info.py b/cli/src/castle_cli/commands/info.py index aa23509..81db226 100644 --- a/cli/src/castle_cli/commands/info.py +++ b/cli/src/castle_cli/commands/info.py @@ -29,105 +29,102 @@ def _load_deployed_component(name: str) -> object | None: def run_info(args: argparse.Namespace) -> int: - """Show detailed info for a component.""" + """Show detailed info for a component, service, or job.""" config = load_config() name = args.project - if name not in config.components: - print(f"Error: component '{name}' not found in castle.yaml") + # Look up in all sections + component = config.components.get(name) + service = config.services.get(name) + job = config.jobs.get(name) + + if not component and not service and not job: + print(f"Error: '{name}' not found in castle.yaml") return 1 - manifest = config.components[name] deployed = _load_deployed_component(name) if getattr(args, "json", False): - data = manifest.model_dump(exclude_none=True) - # Include CLAUDE.md content if it exists - claude_md = _find_claude_md(config.root, manifest.source_dir or name) - if claude_md: - data["claude_md"] = claude_md - if deployed: - data["deployed"] = { - "runner": deployed.runner, - "run_cmd": deployed.run_cmd, - "env": deployed.env, - "port": deployed.port, - "managed": deployed.managed, - } - print(json.dumps(data, indent=2)) - return 0 + return _info_json(config, name, component, service, job, deployed) # Human-readable output print(f"\n{BOLD}{name}{RESET}") print(f"{'─' * 40}") - print(f" {BOLD}roles{RESET}: {', '.join(r.value for r in manifest.roles)}") - if manifest.description: - print(f" {BOLD}description{RESET}: {manifest.description}") - # Source - if manifest.source: - print(f" {BOLD}source{RESET}: {manifest.source}") + # Determine category + if service: + print(f" {BOLD}category{RESET}: service") + elif job: + print(f" {BOLD}category{RESET}: job") + elif component: + if component.tool or (component.install and component.install.path): + print(f" {BOLD}category{RESET}: tool") + elif component.build: + print(f" {BOLD}category{RESET}: frontend") + else: + print(f" {BOLD}category{RESET}: component") - # Run spec - if manifest.run: - print(f" {BOLD}runner{RESET}: {manifest.run.runner}") - if hasattr(manifest.run, "tool"): - print(f" {BOLD}tool{RESET}: {manifest.run.tool}") - elif hasattr(manifest.run, "argv"): - print(f" {BOLD}argv{RESET}: {manifest.run.argv}") - elif hasattr(manifest.run, "image"): - print(f" {BOLD}image{RESET}: {manifest.run.image}") + # 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)}") - # Defaults env - if manifest.defaults and manifest.defaults.env: - print(f" {BOLD}defaults.env{RESET}:") - for key, val in manifest.defaults.env.items(): - print(f" {key}: {val}") + # 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}") - # Expose - if manifest.expose and manifest.expose.http: - http = manifest.expose.http - print(f" {BOLD}port{RESET}: {http.internal.port}") - if http.health_path: - print(f" {BOLD}health{RESET}: {http.health_path}") + # Run spec + print(f" {BOLD}runner{RESET}: {spec.run.runner}") + if hasattr(spec.run, "tool"): + print(f" {BOLD}tool{RESET}: {spec.run.tool}") + elif hasattr(spec.run, "argv"): + print(f" {BOLD}argv{RESET}: {spec.run.argv}") + elif hasattr(spec.run, "image"): + print(f" {BOLD}image{RESET}: {spec.run.image}") - # Proxy - if manifest.proxy and manifest.proxy.caddy: - caddy = manifest.proxy.caddy - if caddy.path_prefix: - print(f" {BOLD}path{RESET}: {caddy.path_prefix}") + # Defaults env + if spec.defaults and spec.defaults.env: + print(f" {BOLD}defaults.env{RESET}:") + for key, val in spec.defaults.env.items(): + print(f" {key}: {val}") - # Manage - if manifest.manage and manifest.manage.systemd: - sd = manifest.manage.systemd - print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}") + # Service-specific: expose, proxy, manage + if service: + if service.expose and service.expose.http: + http = service.expose.http + print(f" {BOLD}port{RESET}: {http.internal.port}") + if http.health_path: + print(f" {BOLD}health{RESET}: {http.health_path}") + if service.proxy and service.proxy.caddy: + caddy = service.proxy.caddy + if caddy.path_prefix: + print(f" {BOLD}path{RESET}: {caddy.path_prefix}") + if service.manage and service.manage.systemd: + sd = service.manage.systemd + print(f" {BOLD}systemd{RESET}: enabled={sd.enable}, restart={sd.restart.value}") - # Install - if manifest.install and manifest.install.path: - pi = manifest.install.path - print(f" {BOLD}install{RESET}: path" + (f" (alias: {pi.alias})" if pi.alias else "")) - - # Tool - if manifest.tool: - t = manifest.tool - if t.source: - print(f" {BOLD}tool.source{RESET}: {t.source}") - if t.system_dependencies: - print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}") - - # Tags - if manifest.tags: - print(f" {BOLD}tags{RESET}: {', '.join(manifest.tags)}") - - # Capabilities - if manifest.provides: - print(f" {BOLD}provides{RESET}:") - for cap in manifest.provides: - print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else "")) - if manifest.consumes: - print(f" {BOLD}consumes{RESET}:") - for cap in manifest.consumes: - print(f" - {cap.type}" + (f" ({cap.name})" if cap.name else "")) + # Job-specific + if job: + print(f" {BOLD}schedule{RESET}: {job.schedule}") + print(f" {BOLD}timezone{RESET}: {job.timezone}") # Deployed state from registry if deployed: @@ -142,20 +139,66 @@ def run_info(args: argparse.Namespace) -> int: for key, val in deployed.env.items(): print(f" {key}={val}") else: - print(f"\n {DIM}not deployed (run 'castle deploy {name}'){RESET}") + print(f"\n {DIM}not deployed (run 'castle deploy'){RESET}") # Show CLAUDE.md if it exists - source_dir = manifest.source_dir or name - claude_md = _find_claude_md(config.root, source_dir) - if claude_md: - print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}") - print(f"{CYAN}{'─' * 40}{RESET}") - print(f"{DIM}{claude_md}{RESET}") + source_dir = None + if component and component.source_dir: + source_dir = component.source_dir + elif spec and spec.component and spec.component in config.components: + source_dir = config.components[spec.component].source_dir + + if source_dir: + claude_md = _find_claude_md(config.root, source_dir) + if claude_md: + print(f"\n{BOLD}{CYAN}CLAUDE.md{RESET}") + print(f"{CYAN}{'─' * 40}{RESET}") + print(f"{DIM}{claude_md}{RESET}") print() return 0 +def _info_json( + config: object, + name: str, + component: object | None, + service: object | None, + job: object | None, + deployed: object | None, +) -> int: + """Output JSON info.""" + data: dict = {"name": name} + + if component: + data["component"] = component.model_dump(exclude_none=True, exclude={"id"}) + if service: + data["service"] = service.model_dump(exclude_none=True, exclude={"id"}) + data["category"] = "service" + if job: + data["job"] = job.model_dump(exclude_none=True, exclude={"id"}) + data["category"] = "job" + if not service and not job and component: + if component.tool or (component.install and component.install.path): + data["category"] = "tool" + elif component.build: + data["category"] = "frontend" + else: + data["category"] = "component" + + if deployed: + data["deployed"] = { + "runner": deployed.runner, + "run_cmd": deployed.run_cmd, + "env": deployed.env, + "port": deployed.port, + "managed": deployed.managed, + } + + print(json.dumps(data, indent=2)) + return 0 + + def _find_claude_md(root: Path, source_dir: str) -> str | None: """Read CLAUDE.md from project directory if it exists.""" claude_path = root / source_dir / "CLAUDE.md" diff --git a/cli/src/castle_cli/commands/list_cmd.py b/cli/src/castle_cli/commands/list_cmd.py index 904733b..ecc3268 100644 --- a/cli/src/castle_cli/commands/list_cmd.py +++ b/cli/src/castle_cli/commands/list_cmd.py @@ -7,7 +7,6 @@ import json import logging from castle_cli.config import load_config -from castle_cli.manifest import Role log = logging.getLogger(__name__) @@ -17,15 +16,15 @@ RESET = "\033[0m" DIM = "\033[2m" GREEN = "\033[92m" RED = "\033[91m" +CYAN = "\033[96m" +MAGENTA = "\033[95m" +YELLOW = "\033[93m" -ROLE_COLORS: dict[str, str] = { - Role.SERVICE: "\033[92m", # green - Role.TOOL: "\033[96m", # cyan - Role.WORKER: "\033[94m", # blue - Role.JOB: "\033[95m", # magenta - Role.FRONTEND: "\033[93m", # yellow - Role.REMOTE: "\033[90m", # dim - Role.CONTAINERIZED: "\033[33m", # orange +CATEGORY_COLORS: dict[str, str] = { + "service": GREEN, + "job": MAGENTA, + "tool": CYAN, + "frontend": YELLOW, } @@ -41,71 +40,132 @@ def _load_deployed() -> dict[str, object] | None: def run_list(args: argparse.Namespace) -> int: - """List all components.""" + """List all components, services, and jobs.""" config = load_config() deployed = _load_deployed() - components = config.components - - filter_role = getattr(args, "role", None) - if filter_role: - components = {k: v for k, v in components.items() if filter_role in v.roles} + filter_type = getattr(args, "type", None) if getattr(args, "json", False): - output = [] - for name, manifest in components.items(): - entry: dict = { - "name": name, - "roles": [r.value for r in manifest.roles], - "deployed": deployed is not None and name in deployed, - } - if manifest.description: - entry["description"] = manifest.description - if manifest.expose and manifest.expose.http: - entry["port"] = manifest.expose.http.internal.port - if deployed and name in deployed: - dep = deployed[name] - if dep.port is not None: - entry["port"] = dep.port - output.append(entry) - print(json.dumps(output, indent=2)) - return 0 + return _list_json(config, deployed, filter_type) - if not components: + any_output = False + + # Services + if not filter_type or filter_type == "service": + if config.services: + any_output = True + color = CATEGORY_COLORS["service"] + print(f"\n{BOLD}{color}Services{RESET}") + print(f"{color}{'─' * 40}{RESET}") + for name, svc in config.services.items(): + port_str = "" + if svc.expose and svc.expose.http: + port_str = f" :{svc.expose.http.internal.port}" + + 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}{svc.description}{RESET}" if svc.description else "" + print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}") + + # Jobs + if not filter_type or filter_type == "job": + if config.jobs: + any_output = True + color = CATEGORY_COLORS["job"] + print(f"\n{BOLD}{color}Jobs{RESET}") + print(f"{color}{'─' * 40}{RESET}") + for name, job in config.jobs.items(): + if deployed is not None: + status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}" + else: + status = f"{DIM}?{RESET}" + + desc = f" {DIM}{job.description}{RESET}" if job.description else "" + sched = f" {DIM}[{job.schedule}]{RESET}" + print(f" {status} {BOLD}{name}{RESET}{sched}{desc}") + + # Tools + if not filter_type or filter_type == "tool": + tools = config.tools + if tools: + any_output = True + color = CATEGORY_COLORS["tool"] + print(f"\n{BOLD}{color}Tools{RESET}") + print(f"{color}{'─' * 40}{RESET}") + for name, comp in tools.items(): + desc = f" {DIM}{comp.description}{RESET}" if comp.description else "" + print(f" {BOLD}{name}{RESET}{desc}") + + # Frontends + if not filter_type or filter_type == "frontend": + frontends = config.frontends + if frontends: + any_output = True + color = CATEGORY_COLORS["frontend"] + print(f"\n{BOLD}{color}Frontends{RESET}") + print(f"{color}{'─' * 40}{RESET}") + for name, comp in frontends.items(): + desc = f" {DIM}{comp.description}{RESET}" if comp.description else "" + print(f" {BOLD}{name}{RESET}{desc}") + + if not any_output: print("No components found.") - return 0 - - # Group by primary role (first in sorted list) - by_role: dict[str, list[tuple[str, object]]] = {} - for name, manifest in components.items(): - primary_role = manifest.roles[0].value if manifest.roles else "other" - by_role.setdefault(primary_role, []).append((name, manifest)) - - # Display order - role_order = ["service", "tool", "worker", "job", "frontend", "remote", "containerized"] - for role_name in role_order: - items = by_role.get(role_name, []) - if not items: - continue - color = ROLE_COLORS.get(role_name, "") - print(f"\n{BOLD}{color}{role_name}s{RESET}") - print(f"{color}{'─' * 40}{RESET}") - for name, manifest in items: - port_str = "" - if manifest.expose and manifest.expose.http: - port_str = f" :{manifest.expose.http.internal.port}" - - # Show deployed status indicator - if deployed is not None: - status = f"{GREEN}●{RESET}" if name in deployed else f"{RED}○{RESET}" - else: - status = f"{DIM}?{RESET}" - - desc = f" {DIM}{manifest.description}{RESET}" if manifest.description else "" - print(f" {status} {BOLD}{name}{RESET}{port_str}{desc}") if deployed is None: print(f"\n{DIM}(no registry — run 'castle deploy' to generate){RESET}") print() return 0 + + +def _list_json( + config: object, deployed: dict | None, filter_type: str | None +) -> int: + """Output JSON list of all entries.""" + output = [] + + if not filter_type or filter_type == "service": + for name, svc in config.services.items(): + entry: dict = { + "name": name, + "category": "service", + "deployed": deployed is not None and name in deployed, + } + if svc.description: + entry["description"] = svc.description + if svc.expose and svc.expose.http: + entry["port"] = svc.expose.http.internal.port + output.append(entry) + + if not filter_type or filter_type == "job": + for name, job in config.jobs.items(): + entry = { + "name": name, + "category": "job", + "deployed": deployed is not None and name in deployed, + "schedule": job.schedule, + } + if job.description: + entry["description"] = job.description + output.append(entry) + + if not filter_type or filter_type == "tool": + for name, comp in config.tools.items(): + entry = {"name": name, "category": "tool"} + if comp.description: + entry["description"] = comp.description + output.append(entry) + + if not filter_type or filter_type == "frontend": + for name, comp in config.frontends.items(): + entry = {"name": name, "category": "frontend"} + if comp.description: + entry["description"] = comp.description + output.append(entry) + + print(json.dumps(output, indent=2)) + return 0 diff --git a/cli/src/castle_cli/commands/logs.py b/cli/src/castle_cli/commands/logs.py index 89d14a1..95076cb 100644 --- a/cli/src/castle_cli/commands/logs.py +++ b/cli/src/castle_cli/commands/logs.py @@ -10,25 +10,22 @@ from castle_cli.config import load_config def run_logs(args: argparse.Namespace) -> int: - """View logs for a component.""" + """View logs for a service or job.""" config = load_config() name = args.name - if name not in config.components: - print(f"Error: component '{name}' not found in castle.yaml") - return 1 - - manifest = config.components[name] - - # Container logs - if manifest.run and manifest.run.runner == "container": - return _container_logs(name, args) - - # Systemd logs (default for managed services) - if manifest.manage and manifest.manage.systemd: + # Check services + if name in config.services: + svc = config.services[name] + if svc.run.runner == "container": + return _container_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 diff --git a/cli/src/castle_cli/commands/service.py b/cli/src/castle_cli/commands/service.py index d83d842..f5971de 100644 --- a/cli/src/castle_cli/commands/service.py +++ b/cli/src/castle_cli/commands/service.py @@ -9,7 +9,6 @@ from castle_core.generators.systemd import ( SYSTEMD_USER_DIR, generate_timer, generate_unit_from_deployed, - get_schedule_trigger, timer_name, unit_name, ) @@ -21,6 +20,9 @@ from castle_cli.config import ( load_config, ) +# Re-export for use by other commands +UNIT_PREFIX = "castle-" + def _install_unit(uname: str, content: str) -> None: """Write a systemd unit file.""" @@ -76,7 +78,6 @@ def run_services(args: argparse.Namespace) -> int: def _service_enable(config: CastleConfig, name: str) -> int: """Enable and start a single service (or timer for scheduled jobs).""" - # Require registry if not REGISTRY_PATH.exists(): print("Error: no registry found. Run 'castle deploy' first.") return 1 @@ -93,22 +94,29 @@ def _service_enable(config: CastleConfig, name: str) -> int: ensure_dirs() - # Get systemd spec from manifest for restart policy etc. + # Get systemd spec from config (services or jobs) systemd_spec = None - if name in config.components: - manifest = config.components[name] - if manifest.manage and manifest.manage.systemd: - systemd_spec = manifest.manage.systemd + if name in config.services: + svc = config.services[name] + if svc.manage and svc.manage.systemd: + systemd_spec = svc.manage.systemd + elif name in config.jobs: + job = config.jobs[name] + if job.manage and job.manage.systemd: + systemd_spec = job.manage.systemd # Generate and install the service unit from registry svc_unit = unit_name(name) svc_content = generate_unit_from_deployed(name, deployed, systemd_spec) _install_unit(svc_unit, svc_content) - # Check for timer (still uses manifest for schedule config) - manifest = config.components.get(name) - timer_content = generate_timer(name, manifest) if manifest else None - if timer_content: + # Check for timer (jobs have schedule) + if deployed.schedule: + timer_content = generate_timer( + name, + schedule=deployed.schedule, + description=deployed.description, + ) tmr_unit = timer_name(name) _install_unit(tmr_unit, timer_content) @@ -156,7 +164,6 @@ def _service_disable(name: str) -> int: print(f"Disabling {name}...") - # Stop and disable timer if exists timer_path = SYSTEMD_USER_DIR / tmr_unit if timer_path.exists(): subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False) @@ -172,14 +179,35 @@ def _service_disable(name: str) -> int: def _service_status(config: CastleConfig) -> int: - """Show status of all managed services.""" + """Show status of all managed services and jobs.""" print("\nCastle Services") print("=" * 50) - for name, manifest in config.managed.items(): - is_scheduled = get_schedule_trigger(manifest) is not None + for name, svc in config.services.items(): + svc_unit = unit_name(name) + result = subprocess.run( + ["systemctl", "--user", "is-active", svc_unit], + capture_output=True, + text=True, + ) + status = result.stdout.strip() + if status == "active": + color = "\033[92m" + elif status == "inactive": + color = "\033[90m" + else: + color = "\033[91m" + reset = "\033[0m" - if is_scheduled: + port_str = "" + if svc.expose and svc.expose.http: + port_str = f":{svc.expose.http.internal.port}" + print(f" {color}{status:10s}{reset} {name}{port_str}") + + if config.jobs: + print(f"\n{'─' * 50}") + print("Jobs") + for name in config.jobs: tmr_unit = timer_name(name) result = subprocess.run( ["systemctl", "--user", "is-active", tmr_unit], @@ -195,26 +223,6 @@ def _service_status(config: CastleConfig) -> int: color = "\033[91m" reset = "\033[0m" print(f" {color}{status:10s}{reset} {name} (timer)") - else: - svc_unit = unit_name(name) - result = subprocess.run( - ["systemctl", "--user", "is-active", svc_unit], - capture_output=True, - text=True, - ) - status = result.stdout.strip() - if status == "active": - color = "\033[92m" - elif status == "inactive": - color = "\033[90m" - else: - color = "\033[91m" - reset = "\033[0m" - - port_str = "" - if manifest.expose and manifest.expose.http: - port_str = f":{manifest.expose.http.internal.port}" - print(f" {color}{status:10s}{reset} {name}{port_str}") print() return 0 @@ -222,28 +230,33 @@ def _service_status(config: CastleConfig) -> int: def _service_dry_run(config: CastleConfig, name: str) -> int: """Print the generated systemd unit(s) without installing.""" - # Try registry first, fall back to showing what deploy would generate if REGISTRY_PATH.exists(): registry = load_registry() if name in registry.deployed: deployed = registry.deployed[name] systemd_spec = None - if name in config.components: - manifest = config.components[name] - if manifest.manage and manifest.manage.systemd: - systemd_spec = manifest.manage.systemd + if name in config.services: + svc = config.services[name] + if svc.manage and svc.manage.systemd: + systemd_spec = svc.manage.systemd + elif name in config.jobs: + job = config.jobs[name] + if job.manage and job.manage.systemd: + systemd_spec = job.manage.systemd svc_unit = unit_name(name) svc_content = generate_unit_from_deployed(name, deployed, systemd_spec) print(f"# {svc_unit}") print(svc_content) - manifest = config.components.get(name) - if manifest: - timer_content = generate_timer(name, manifest) - if timer_content: - print(f"# {timer_name(name)}") - print(timer_content) + if deployed.schedule: + timer_content = generate_timer( + name, + schedule=deployed.schedule, + description=deployed.description, + ) + print(f"# {timer_name(name)}") + print(timer_content) return 0 print(f"Error: '{name}' not found in registry. Run 'castle deploy' first.") @@ -252,14 +265,12 @@ def _service_dry_run(config: CastleConfig, name: str) -> int: def _services_start(config: CastleConfig) -> int: """Start all managed services and gateway.""" - # Require registry if not REGISTRY_PATH.exists(): print("Error: no registry found. Run 'castle deploy' first.") return 1 ensure_dirs() - # Generate Caddyfile from registry from castle_core.config import GENERATED_DIR from castle_core.generators.caddyfile import generate_caddyfile_from_registry @@ -268,7 +279,13 @@ def _services_start(config: CastleConfig) -> int: caddyfile_path.write_text(generate_caddyfile_from_registry(registry)) print(f"Generated {caddyfile_path}") - for name in config.managed: + for name in config.services: + if name not in registry.deployed: + print(f" {name}: skipped (not in registry, run 'castle deploy')") + continue + _service_enable(config, name) + + for name in config.jobs: if name not in registry.deployed: print(f" {name}: skipped (not in registry, run 'castle deploy')") continue @@ -279,12 +296,15 @@ def _services_start(config: CastleConfig) -> int: def _services_stop(config: CastleConfig) -> int: - """Stop all managed services and gateway.""" - for name, manifest in config.managed.items(): - is_scheduled = get_schedule_trigger(manifest) is not None - if is_scheduled: - tmr_unit = timer_name(name) - subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False) + """Stop all managed services and jobs.""" + for name in config.jobs: + tmr_unit = timer_name(name) + subprocess.run(["systemctl", "--user", "stop", tmr_unit], check=False) + svc_unit = unit_name(name) + subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False) + print(f" {name}: stopped") + + for name in config.services: svc_unit = unit_name(name) subprocess.run(["systemctl", "--user", "stop", svc_unit], check=False) print(f" {name}: stopped") diff --git a/cli/src/castle_cli/commands/sync.py b/cli/src/castle_cli/commands/sync.py index 59c64df..463b371 100644 --- a/cli/src/castle_cli/commands/sync.py +++ b/cli/src/castle_cli/commands/sync.py @@ -8,26 +8,6 @@ import subprocess from pathlib import Path from castle_cli.config import ensure_dirs, load_config -from castle_cli.manifest import ComponentManifest - - -def _sync_cmd(manifest: ComponentManifest) -> list[str] | None: - """Derive the sync command from the manifest's runner.""" - run = manifest.run - if run is None: - # No runner — check for build commands (frontends) - if manifest.build and manifest.build.commands: - # Frontends declare build commands; infer from source dir at call site - return None - return None - - match run.runner: - case "python": - return ["uv", "sync"] - case "node": - return [run.package_manager, "install"] - case _: - return None def run_sync(args: argparse.Namespace) -> int: @@ -44,11 +24,12 @@ def run_sync(args: argparse.Namespace) -> int: if result.returncode != 0: print("Warning: git submodule update failed (may not be a git repo)") - # Sync dependencies in each project + # Sync dependencies in each component's source directory all_ok = True synced_dirs: set[Path] = set() - for name, manifest in config.components.items(): - source_dir = manifest.source_dir + + for name, comp in config.components.items(): + source_dir = comp.source_dir if not source_dir: continue project_dir = config.root / source_dir @@ -56,14 +37,16 @@ def run_sync(args: argparse.Namespace) -> int: if project_dir in synced_dirs or not project_dir.is_dir(): continue - cmd = _sync_cmd(manifest) + # Determine sync command based on project type + cmd = None + if (project_dir / "pyproject.toml").exists(): + cmd = ["uv", "sync"] + elif (project_dir / "package.json").exists(): + pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm" + cmd = [pm, "install"] + if cmd is None: - # No runner — check if it's a frontend with a package.json - if manifest.build and (project_dir / "package.json").exists(): - pm = "pnpm" if (project_dir / "pnpm-lock.yaml").exists() else "npm" - cmd = [pm, "install"] - else: - continue + continue label = cmd[0] print(f"\nSyncing {name} ({label})...") @@ -75,28 +58,33 @@ def run_sync(args: argparse.Namespace) -> int: print(" OK") synced_dirs.add(project_dir) - # Install components as uv tools or symlinks + # Install components and python-runner services as uv tools uv_path = shutil.which("uv") or "uv" installed_dirs: set[Path] = set() - for name, manifest in config.components.items(): - # Install if: has install.path, or is a python runner service - if not ( - (manifest.install and manifest.install.path) - or (manifest.run and manifest.run.runner == "python") - ): + # Install components with install.path + for name, comp in config.components.items(): + if not (comp.install and comp.install.path): continue - - source = manifest.source_dir + source = comp.source_dir if not source: continue + _try_install(config.root / source, name, comp, uv_path, installed_dirs) + # Install python-runner services + for name, svc in config.services.items(): + if svc.run.runner != "python": + continue + # Find source from component reference + source = None + if svc.component and svc.component in config.components: + source = config.components[svc.component].source_dir + if not source: + continue source_dir = config.root / source - + if source_dir in installed_dirs: + continue if (source_dir / "pyproject.toml").exists(): - # Python package — uv tool install - if source_dir in installed_dirs: - continue print(f"\nInstalling {name}...") result = subprocess.run( [uv_path, "tool", "install", "--editable", str(source_dir), "--force"], @@ -113,21 +101,53 @@ def run_sync(args: argparse.Namespace) -> int: print(f" {name}: OK") installed_dirs.add(source_dir) - elif source_dir.is_file(): - # Script file — symlink to ~/.local/bin/ - alias = name - if manifest.install and manifest.install.path and manifest.install.path.alias: - alias = manifest.install.path.alias - if not shutil.which(alias): - link = Path.home() / ".local" / "bin" / alias - link.parent.mkdir(parents=True, exist_ok=True) - if not link.exists(): - link.symlink_to(source_dir) - print(f"\n Linked {alias} → {source_dir}") - if all_ok: print("\nAll projects synced.") else: print("\nSync completed with warnings.") return 0 + + +def _try_install( + source_dir: Path, + name: str, + comp: object, + uv_path: str, + installed_dirs: set[Path], +) -> bool: + """Try to install a component. Returns True if installed.""" + if source_dir in installed_dirs: + return False + + if (source_dir / "pyproject.toml").exists(): + print(f"\nInstalling {name}...") + result = subprocess.run( + [uv_path, "tool", "install", "--editable", str(source_dir), "--force"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + if "already installed" in result.stderr.lower(): + print(f" {name}: already installed") + else: + print(f" Warning: {result.stderr.strip()}") + return False + else: + print(f" {name}: OK") + installed_dirs.add(source_dir) + return True + + elif source_dir.is_file(): + alias = name + if comp.install and comp.install.path and comp.install.path.alias: + alias = comp.install.path.alias + if not shutil.which(alias): + link = Path.home() / ".local" / "bin" / alias + link.parent.mkdir(parents=True, exist_ok=True) + if not link.exists(): + link.symlink_to(source_dir) + print(f"\n Linked {alias} → {source_dir}") + return True + + return False diff --git a/cli/src/castle_cli/commands/tool.py b/cli/src/castle_cli/commands/tool.py index 6bd8c21..d736c21 100644 --- a/cli/src/castle_cli/commands/tool.py +++ b/cli/src/castle_cli/commands/tool.py @@ -67,8 +67,8 @@ def _tool_info(name: str) -> int: if manifest.description: print(f" {manifest.description}") print(f" {BOLD}version{RESET}: {t.version}") - if t.source: - print(f" {BOLD}source{RESET}: {t.source}") + if manifest.source: + print(f" {BOLD}source{RESET}: {manifest.source}") if t.system_dependencies: print(f" {BOLD}requires{RESET}: {', '.join(t.system_dependencies)}") diff --git a/cli/src/castle_cli/main.py b/cli/src/castle_cli/main.py index 83cf567..b07c9a6 100644 --- a/cli/src/castle_cli/main.py +++ b/cli/src/castle_cli/main.py @@ -21,9 +21,9 @@ def build_parser() -> argparse.ArgumentParser: # castle list list_parser = subparsers.add_parser("list", help="List all components") list_parser.add_argument( - "--role", - choices=["service", "tool", "worker", "job", "frontend", "remote", "containerized"], - help="Filter by role", + "--type", + choices=["service", "job", "tool", "frontend"], + help="Filter by type", ) list_parser.add_argument("--json", action="store_true", help="Output as JSON") diff --git a/cli/src/castle_cli/manifest.py b/cli/src/castle_cli/manifest.py index fa0a4c1..2685939 100644 --- a/cli/src/castle_cli/manifest.py +++ b/cli/src/castle_cli/manifest.py @@ -5,7 +5,7 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ BuildSpec, CaddySpec, Capability, - ComponentManifest, + ComponentSpec, DefaultsSpec, EnvMap, ExposeSpec, @@ -13,12 +13,12 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ HttpInternal, HttpPublic, InstallSpec, + JobSpec, ManageSpec, PathInstallSpec, ProxySpec, ReadinessHttpGet, RestartPolicy, - Role, RunBase, RunCommand, RunContainer, @@ -26,12 +26,8 @@ from castle_core.manifest import ( # noqa: F401 — explicit re-exports for typ RunPython, RunRemote, RunSpec, + ServiceSpec, SystemdSpec, TLSMode, ToolSpec, - TriggerEvent, - TriggerManual, - TriggerRequest, - TriggerSchedule, - TriggerSpec, ) diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py index 681629e..772b72e 100644 --- a/cli/tests/conftest.py +++ b/cli/tests/conftest.py @@ -16,9 +16,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: config = { "gateway": {"port": 18000}, "components": { + "test-tool": { + "description": "Test tool", + "install": { + "path": {"alias": "test-tool"}, + }, + }, + }, + "services": { "test-svc": { + "component": "test-svc-comp", "description": "Test service", - "source": "test-svc", "run": { "runner": "python", "tool": "test-svc", @@ -39,11 +47,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "systemd": {}, }, }, - "test-tool": { - "description": "Test tool", - "install": { - "path": {"alias": "test-tool"}, + }, + "jobs": { + "test-job": { + "description": "Test job", + "run": { + "runner": "command", + "argv": ["test-job"], }, + "schedule": "0 2 * * *", }, }, } diff --git a/cli/tests/test_create.py b/cli/tests/test_create.py index d91b26c..e6b1155 100644 --- a/cli/tests/test_create.py +++ b/cli/tests/test_create.py @@ -7,7 +7,6 @@ from pathlib import Path from unittest.mock import patch from castle_cli.config import load_config -from castle_cli.manifest import Role class TestCreateCommand: @@ -42,11 +41,12 @@ class TestCreateCommand: assert (project_dir / "tests" / "test_health.py").exists() assert (project_dir / "CLAUDE.md").exists() - # Verify registered as ComponentManifest + # Verify registered as ComponentSpec + ServiceSpec assert "my-api" in config.components - manifest = config.components["my-api"] - assert Role.SERVICE in manifest.roles - assert manifest.expose.http.internal.port == 9050 + assert "my-api" in config.services + svc = config.services["my-api"] + assert svc.expose.http.internal.port == 9050 + assert svc.component == "my-api" mock_save.assert_called_once() def test_create_tool(self, castle_root: Path) -> None: @@ -60,17 +60,18 @@ class TestCreateCommand: from castle_cli.commands.create import run_create - args = Namespace(name="my-tool", type="tool", description="My tool", port=None) + args = Namespace(name="my-tool2", type="tool", description="My tool", port=None) result = run_create(args) assert result == 0 - project_dir = castle_root / "components" / "my-tool" + project_dir = castle_root / "components" / "my-tool2" assert project_dir.exists() - assert (project_dir / "src" / "my_tool" / "main.py").exists() + assert (project_dir / "src" / "my_tool2" / "main.py").exists() assert (project_dir / "CLAUDE.md").exists() - assert "my-tool" in config.components - manifest = config.components["my-tool"] - assert Role.TOOL in manifest.roles + assert "my-tool2" in config.components + comp = config.components["my-tool2"] + assert comp.tool is not None + assert comp.install is not None def test_create_library(self, castle_root: Path) -> None: """Create a new library project.""" @@ -101,6 +102,7 @@ class TestCreateCommand: from castle_cli.commands.create import run_create + # test-svc exists in the services section args = Namespace( name="test-svc", type="service", @@ -130,8 +132,8 @@ class TestCreateCommand: ) run_create(args) - manifest = config.components["auto-port-svc"] - port = manifest.expose.http.internal.port + svc = config.services["auto-port-svc"] + port = svc.expose.http.internal.port # Port 18000 is gateway, 19000 is test-svc, so next should be 9001+ assert port is not None assert port not in (18000, 19000) diff --git a/cli/tests/test_info.py b/cli/tests/test_info.py index 4558bbe..8a6f299 100644 --- a/cli/tests/test_info.py +++ b/cli/tests/test_info.py @@ -59,8 +59,8 @@ class TestInfoCommand: output = capsys.readouterr().out assert "not found" in output - def test_info_json(self, castle_root: Path, capsys) -> None: - """--json produces valid JSON with manifest fields.""" + def test_info_json_service(self, castle_root: Path, capsys) -> None: + """--json produces valid JSON with service fields.""" from castle_cli.config import load_config with patch("castle_cli.commands.info.load_config") as mock_load: @@ -73,48 +73,8 @@ class TestInfoCommand: assert result == 0 output = capsys.readouterr().out data = json.loads(output) - assert data["id"] == "test-svc" - assert "service" in data["roles"] - assert data["expose"]["http"]["internal"]["port"] == 19000 - - def test_info_shows_claude_md(self, castle_root: Path, capsys) -> None: - """Info shows CLAUDE.md content when present.""" - project_dir = castle_root / "test-svc" - project_dir.mkdir(exist_ok=True) - (project_dir / "CLAUDE.md").write_text("# Test Service\n\nSome docs here.") - - from castle_cli.config import load_config - - with patch("castle_cli.commands.info.load_config") as mock_load: - mock_load.return_value = load_config(castle_root) - - from castle_cli.commands.info import run_info - - result = run_info(Namespace(project="test-svc", json=False)) - - assert result == 0 - output = capsys.readouterr().out - assert "Some docs here" in output - - def test_info_json_includes_claude_md(self, castle_root: Path, capsys) -> None: - """--json includes claude_md field when CLAUDE.md exists.""" - project_dir = castle_root / "test-svc" - project_dir.mkdir(exist_ok=True) - (project_dir / "CLAUDE.md").write_text("# Docs\n") - - from castle_cli.config import load_config - - with patch("castle_cli.commands.info.load_config") as mock_load: - mock_load.return_value = load_config(castle_root) - - from castle_cli.commands.info import run_info - - result = run_info(Namespace(project="test-svc", json=True)) - - assert result == 0 - data = json.loads(capsys.readouterr().out) - assert "claude_md" in data - assert "# Docs" in data["claude_md"] + assert data["category"] == "service" + assert data["service"]["expose"]["http"]["internal"]["port"] == 19000 def test_info_shows_env(self, castle_root: Path, capsys) -> None: """Info displays environment variables.""" @@ -131,8 +91,8 @@ class TestInfoCommand: output = capsys.readouterr().out assert "TEST_SVC_DATA_DIR" in output - def test_info_shows_roles(self, castle_root: Path, capsys) -> None: - """Info displays derived roles.""" + def test_info_shows_category(self, castle_root: Path, capsys) -> None: + """Info displays category instead of roles.""" from castle_cli.config import load_config with patch("castle_cli.commands.info.load_config") as mock_load: @@ -144,5 +104,5 @@ class TestInfoCommand: assert result == 0 output = capsys.readouterr().out - assert "roles" in output + assert "category" in output assert "service" in output diff --git a/cli/tests/test_list.py b/cli/tests/test_list.py index 70bf2c6..c337661 100644 --- a/cli/tests/test_list.py +++ b/cli/tests/test_list.py @@ -19,7 +19,7 @@ class TestListCommand: from castle_cli.config import load_config mock_load.return_value = load_config(castle_root) - args = Namespace(role=None, json=False) + args = Namespace(type=None, json=False) result = run_list(args) assert result == 0 @@ -27,27 +27,13 @@ class TestListCommand: assert "test-svc" in captured.out assert "test-tool" in captured.out - def test_list_filter_role(self, castle_root: Path, capsys: object) -> None: - """List filtered by role.""" - with patch("castle_cli.commands.list_cmd.load_config") as mock_load: - from castle_cli.config import load_config - - mock_load.return_value = load_config(castle_root) - args = Namespace(role="tool", json=False) - result = run_list(args) - - assert result == 0 - captured = capsys.readouterr() # type: ignore[attr-defined] - assert "test-tool" in captured.out - assert "test-svc" not in captured.out - def test_list_filter_service(self, castle_root: Path, capsys: object) -> None: """List filtered to services.""" with patch("castle_cli.commands.list_cmd.load_config") as mock_load: from castle_cli.config import load_config mock_load.return_value = load_config(castle_root) - args = Namespace(role="service", json=False) + args = Namespace(type="service", json=False) result = run_list(args) assert result == 0 @@ -55,13 +41,41 @@ class TestListCommand: assert "test-svc" in captured.out assert "test-tool" not in captured.out + def test_list_filter_tool(self, castle_root: Path, capsys: object) -> None: + """List filtered to tools.""" + with patch("castle_cli.commands.list_cmd.load_config") as mock_load: + from castle_cli.config import load_config + + mock_load.return_value = load_config(castle_root) + args = Namespace(type="tool", json=False) + result = run_list(args) + + assert result == 0 + captured = capsys.readouterr() # type: ignore[attr-defined] + assert "test-tool" in captured.out + assert "test-svc" not in captured.out + + def test_list_filter_job(self, castle_root: Path, capsys: object) -> None: + """List filtered to jobs.""" + with patch("castle_cli.commands.list_cmd.load_config") as mock_load: + from castle_cli.config import load_config + + mock_load.return_value = load_config(castle_root) + args = Namespace(type="job", json=False) + result = run_list(args) + + assert result == 0 + captured = capsys.readouterr() # type: ignore[attr-defined] + assert "test-job" in captured.out + assert "test-svc" not in captured.out + def test_list_json(self, castle_root: Path, capsys: object) -> None: """List output as JSON.""" with patch("castle_cli.commands.list_cmd.load_config") as mock_load: from castle_cli.config import load_config mock_load.return_value = load_config(castle_root) - args = Namespace(role=None, json=True) + args = Namespace(type=None, json=True) result = run_list(args) assert result == 0 @@ -71,5 +85,4 @@ class TestListCommand: assert "test-svc" in names assert "test-tool" in names svc = next(p for p in data if p["name"] == "test-svc") - assert "roles" in svc - assert "service" in svc["roles"] + assert svc["category"] == "service" diff --git a/core/src/castle_core/config.py b/core/src/castle_core/config.py index 1fe75e4..4d0c772 100644 --- a/core/src/castle_core/config.py +++ b/core/src/castle_core/config.py @@ -9,7 +9,7 @@ from pathlib import Path import yaml -from castle_core.manifest import ComponentManifest, Role +from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec def find_castle_root() -> Path: @@ -47,36 +47,30 @@ class CastleConfig: root: Path gateway: GatewayConfig - components: dict[str, ComponentManifest] + components: dict[str, ComponentSpec] + services: dict[str, ServiceSpec] + jobs: dict[str, JobSpec] @property - def services(self) -> dict[str, ComponentManifest]: - """Return components with the SERVICE role.""" - return {k: v for k, v in self.components.items() if Role.SERVICE in v.roles} - - @property - def tools(self) -> dict[str, ComponentManifest]: - """Return components with the TOOL role.""" - return {k: v for k, v in self.components.items() if Role.TOOL in v.roles} - - @property - def workers(self) -> dict[str, ComponentManifest]: - """Return components with the WORKER role.""" - return {k: v for k, v in self.components.items() if Role.WORKER in v.roles} - - @property - def managed(self) -> dict[str, ComponentManifest]: - """Return components managed by systemd.""" + def tools(self) -> dict[str, ComponentSpec]: + """Return components that are tools (have install.path or tool spec).""" return { k: v for k, v in self.components.items() - if v.manage and v.manage.systemd and v.manage.systemd.enable + if (v.install and v.install.path) or v.tool + } + + @property + def frontends(self) -> dict[str, ComponentSpec]: + """Return components that are frontends (have build outputs).""" + return { + k: v + for k, v in self.components.items() + if v.build and (v.build.outputs or v.build.commands) } -def resolve_env_vars( - env: dict[str, str], manifest: ComponentManifest -) -> dict[str, str]: +def resolve_env_vars(env: dict[str, str]) -> dict[str, str]: """Resolve ${secret:NAME} references in env values.""" resolved = {} for key, value in env.items(): @@ -100,11 +94,25 @@ def _read_secret(name: str) -> str: return f"" -def _parse_component(name: str, data: dict) -> ComponentManifest: - """Parse a components: entry directly into a ComponentManifest.""" +def _parse_component(name: str, data: dict) -> ComponentSpec: + """Parse a components: entry into a ComponentSpec.""" data_copy = dict(data) data_copy["id"] = name - return ComponentManifest.model_validate(data_copy) + return ComponentSpec.model_validate(data_copy) + + +def _parse_service(name: str, data: dict) -> ServiceSpec: + """Parse a services: entry into a ServiceSpec.""" + data_copy = dict(data) + data_copy["id"] = name + return ServiceSpec.model_validate(data_copy) + + +def _parse_job(name: str, data: dict) -> JobSpec: + """Parse a jobs: entry into a JobSpec.""" + data_copy = dict(data) + data_copy["id"] = name + return JobSpec.model_validate(data_copy) def load_config(root: Path | None = None) -> CastleConfig: @@ -122,11 +130,25 @@ def load_config(root: Path | None = None) -> CastleConfig: gateway_data = data.get("gateway", {}) gateway = GatewayConfig(port=gateway_data.get("port", 9000)) - components: dict[str, ComponentManifest] = {} + components: dict[str, ComponentSpec] = {} for name, comp_data in data.get("components", {}).items(): components[name] = _parse_component(name, comp_data) - return CastleConfig(root=root, gateway=gateway, components=components) + services: dict[str, ServiceSpec] = {} + for name, svc_data in data.get("services", {}).items(): + services[name] = _parse_service(name, svc_data) + + jobs: dict[str, JobSpec] = {} + for name, job_data in data.get("jobs", {}).items(): + jobs[name] = _parse_job(name, job_data) + + return CastleConfig( + root=root, + gateway=gateway, + components=components, + services=services, + jobs=jobs, + ) def _clean_for_yaml(data: object, preserve_keys: set[str] | None = None) -> object: @@ -165,11 +187,12 @@ _STRUCTURAL_KEYS = { } -def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict: - """Serialize a manifest to a YAML-friendly dict, preserving structural presence.""" - full = manifest.model_dump(mode="json", exclude_none=True, exclude={"id", "roles"}) - minimal = manifest.model_dump( - mode="json", exclude_none=True, exclude={"id", "roles"}, exclude_defaults=True +def _spec_to_yaml_dict(spec: ComponentSpec | ServiceSpec | JobSpec) -> dict: + """Serialize a spec to a YAML-friendly dict, preserving structural presence.""" + exclude_fields = {"id"} + full = spec.model_dump(mode="json", exclude_none=True, exclude=exclude_fields) + minimal = spec.model_dump( + mode="json", exclude_none=True, exclude=exclude_fields, exclude_defaults=True ) def merge(full_val: object, min_val: object | None, key: str = "") -> object: @@ -203,10 +226,22 @@ def _manifest_to_yaml_dict(manifest: ComponentManifest) -> dict: def save_config(config: CastleConfig) -> None: """Save castle configuration to castle.yaml.""" - data: dict = {"gateway": {"port": config.gateway.port}, "components": {}} + data: dict = {"gateway": {"port": config.gateway.port}} - for name, manifest in config.components.items(): - data["components"][name] = _manifest_to_yaml_dict(manifest) + if config.components: + data["components"] = {} + for name, spec in config.components.items(): + data["components"][name] = _spec_to_yaml_dict(spec) + + if config.services: + data["services"] = {} + for name, spec in config.services.items(): + data["services"][name] = _spec_to_yaml_dict(spec) + + if config.jobs: + data["jobs"] = {} + for name, spec in config.jobs.items(): + data["jobs"][name] = _spec_to_yaml_dict(spec) config_path = config.root / "castle.yaml" with open(config_path, "w") as f: diff --git a/core/src/castle_core/generators/__init__.py b/core/src/castle_core/generators/__init__.py index 50fee9f..de3e496 100644 --- a/core/src/castle_core/generators/__init__.py +++ b/core/src/castle_core/generators/__init__.py @@ -8,7 +8,6 @@ from castle_core.generators.systemd import ( cron_to_oncalendar, generate_timer, generate_unit_from_deployed, - get_schedule_trigger, timer_name, unit_name, ) @@ -19,7 +18,6 @@ __all__ = [ "generate_caddyfile_from_registry", "generate_timer", "generate_unit_from_deployed", - "get_schedule_trigger", "timer_name", "unit_name", ] diff --git a/core/src/castle_core/generators/systemd.py b/core/src/castle_core/generators/systemd.py index d81013c..ff47461 100644 --- a/core/src/castle_core/generators/systemd.py +++ b/core/src/castle_core/generators/systemd.py @@ -5,7 +5,7 @@ from __future__ import annotations import shutil from pathlib import Path -from castle_core.manifest import ComponentManifest, RestartPolicy, SystemdSpec +from castle_core.manifest import RestartPolicy, SystemdSpec from castle_core.registry import DeployedComponent SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user" @@ -22,14 +22,6 @@ def timer_name(service_name: str) -> str: return f"{UNIT_PREFIX}{service_name}.timer" -def get_schedule_trigger(manifest: ComponentManifest) -> object | None: - """Return the schedule trigger if one exists, else None.""" - for t in manifest.triggers: - if getattr(t, "type", None) == "schedule": - return t - return None - - def cron_to_oncalendar(cron: str) -> str: """Best-effort conversion of cron expression to systemd OnCalendar. @@ -142,17 +134,17 @@ WantedBy={wanted_by} return unit -def generate_timer(name: str, manifest: ComponentManifest) -> str | None: - """Generate a systemd timer unit if the component has a schedule trigger.""" - trigger = get_schedule_trigger(manifest) - if trigger is None: - return None - - description = manifest.description or name +def generate_timer( + name: str, + schedule: str, + description: str | None = None, +) -> str: + """Generate a systemd timer unit from a cron schedule string.""" + description = description or name # Try to convert cron to OnCalendar, fall back to OnUnitActiveSec - on_calendar = cron_to_oncalendar(trigger.cron) - interval_sec = cron_to_interval_sec(trigger.cron) + on_calendar = cron_to_oncalendar(schedule) + interval_sec = cron_to_interval_sec(schedule) timer_lines = "" if on_calendar: diff --git a/core/src/castle_core/manifest.py b/core/src/castle_core/manifest.py index 28156e6..8e1f4a0 100644 --- a/core/src/castle_core/manifest.py +++ b/core/src/castle_core/manifest.py @@ -1,11 +1,11 @@ -"""Castle component manifest — Pydantic models for the component registry.""" +"""Castle manifest models — component specs, service specs, job specs.""" from __future__ import annotations from enum import Enum from typing import Annotated, Literal, Union -from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator EnvMap = dict[str, str] @@ -22,16 +22,6 @@ class TLSMode(str, Enum): LETSENCRYPT = "letsencrypt" -class Role(str, Enum): - TOOL = "tool" - SERVICE = "service" - WORKER = "worker" - FRONTEND = "frontend" - JOB = "job" - REMOTE = "remote" - CONTAINERIZED = "containerized" - - # --------------------- # Run specs (discriminated union) # --------------------- @@ -82,36 +72,6 @@ RunSpec = Annotated[ ] -# --------------------- -# Triggers -# --------------------- - - -class TriggerManual(BaseModel): - type: Literal["manual"] = "manual" - - -class TriggerSchedule(BaseModel): - type: Literal["schedule"] = "schedule" - cron: str - timezone: str = "America/Los_Angeles" - - -class TriggerEvent(BaseModel): - type: Literal["event"] = "event" - source: str - topic: str | None = None - queue: str | None = None - - -class TriggerRequest(BaseModel): - type: Literal["request"] = "request" - protocol: Literal["http", "https", "grpc"] = "http" - - -TriggerSpec = Union[TriggerManual, TriggerSchedule, TriggerEvent, TriggerRequest] - - # --------------------- # Systemd management # --------------------- @@ -166,7 +126,6 @@ class ToolSpec(BaseModel): model_config = ConfigDict(extra="ignore") version: str = "1.0.0" - source: str | None = None system_dependencies: list[str] = Field(default_factory=list) @@ -238,90 +197,77 @@ class DefaultsSpec(BaseModel): # --------------------- -# Component manifest +# Component spec — software identity # --------------------- -class ComponentManifest(BaseModel): +class ComponentSpec(BaseModel): + """Software catalog entry — what exists.""" + id: str = "" - name: str | None = None description: str | None = None source: str | None = None - run: RunSpec | None = None - - triggers: list[TriggerSpec] = Field(default_factory=list) - - manage: ManageSpec | None = None install: InstallSpec | None = None tool: ToolSpec | None = None - expose: ExposeSpec | None = None - proxy: ProxySpec | None = None build: BuildSpec | None = None - defaults: DefaultsSpec | None = None - provides: list[Capability] = Field(default_factory=list) consumes: list[Capability] = Field(default_factory=list) tags: list[str] = Field(default_factory=list) - @computed_field # type: ignore[prop-decorator] - @property - def roles(self) -> list[Role]: - roles: set[Role] = set() - - if self.run: - if self.run.runner == "remote": - roles.add(Role.REMOTE) - if self.run.runner == "container": - roles.add(Role.CONTAINERIZED) - - if self.install and self.install.path and self.install.path.enable: - roles.add(Role.TOOL) - - if self.tool: - roles.add(Role.TOOL) - - if self.expose and self.expose.http: - roles.add(Role.SERVICE) - - if ( - self.manage - and self.manage.systemd - and self.manage.systemd.enable - and not (self.expose and self.expose.http) - ): - roles.add(Role.WORKER) - - if self.build and (self.build.outputs or self.build.commands): - roles.add(Role.FRONTEND) - - if any(getattr(t, "type", None) == "schedule" for t in self.triggers): - roles.add(Role.JOB) - - if not roles: - roles.add(Role.TOOL) - - return sorted(roles, key=lambda r: r.value) - @property def source_dir(self) -> str | None: - """Best-effort relative directory for this component's source. - - Resolution order: source → tool.source (strip trailing /). - Returns None if no directory can be determined. - """ + """Relative directory for this component's source, or None.""" if self.source: return self.source.rstrip("/") - if self.tool and self.tool.source: - return self.tool.source.rstrip("/") return None + +# --------------------- +# Service spec — long-running daemon +# --------------------- + + +class ServiceSpec(BaseModel): + """Long-running daemon deployment config.""" + + id: str = "" + component: str | None = None + description: str | None = None + + run: RunSpec + + expose: ExposeSpec | None = None + proxy: ProxySpec | None = None + manage: ManageSpec | None = None + defaults: DefaultsSpec | None = None + @model_validator(mode="after") - def _basic_consistency(self) -> ComponentManifest: + def _validate_consistency(self) -> ServiceSpec: if self.manage and self.manage.systemd and self.manage.systemd.enable: - if self.run and self.run.runner == "remote": + if self.run.runner == "remote": raise ValueError("manage.systemd cannot be enabled for runner=remote.") return self + + +# --------------------- +# Job spec — scheduled task +# --------------------- + + +class JobSpec(BaseModel): + """Scheduled task that runs periodically and exits.""" + + id: str = "" + component: str | None = None + description: str | None = None + + run: RunSpec + schedule: str + timezone: str = "America/Los_Angeles" + + manage: ManageSpec | None = None + defaults: DefaultsSpec | None = None diff --git a/core/src/castle_core/registry.py b/core/src/castle_core/registry.py index 9101c7d..b2074dd 100644 --- a/core/src/castle_core/registry.py +++ b/core/src/castle_core/registry.py @@ -35,7 +35,7 @@ class DeployedComponent: run_cmd: list[str] env: dict[str, str] = field(default_factory=dict) description: str | None = None - roles: list[str] = field(default_factory=list) + category: str = "service" port: int | None = None health_path: str | None = None proxy_path: str | None = None @@ -82,7 +82,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry: run_cmd=comp_data.get("run_cmd", []), env=comp_data.get("env", {}), description=comp_data.get("description"), - roles=comp_data.get("roles", []), + category=comp_data.get("category", "service"), port=comp_data.get("port"), health_path=comp_data.get("health_path"), proxy_path=comp_data.get("proxy_path"), @@ -120,8 +120,7 @@ def save_registry(registry: NodeRegistry, path: Path | None = None) -> None: entry["env"] = comp.env if comp.description: entry["description"] = comp.description - if comp.roles: - entry["roles"] = comp.roles + entry["category"] = comp.category if comp.port is not None: entry["port"] = comp.port if comp.health_path: diff --git a/core/tests/conftest.py b/core/tests/conftest.py index 58e6c13..0f6191e 100644 --- a/core/tests/conftest.py +++ b/core/tests/conftest.py @@ -16,9 +16,17 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: config = { "gateway": {"port": 18000}, "components": { + "test-tool": { + "description": "Test tool", + "install": { + "path": {"alias": "test-tool"}, + }, + }, + }, + "services": { "test-svc": { + "component": "test-svc-comp", "description": "Test service", - "source": "test-svc", "run": { "runner": "python", "tool": "test-svc", @@ -39,11 +47,15 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]: "systemd": {}, }, }, - "test-tool": { - "description": "Test tool", - "install": { - "path": {"alias": "test-tool"}, + }, + "jobs": { + "test-job": { + "description": "Test job", + "run": { + "runner": "command", + "argv": ["test-job"], }, + "schedule": "0 2 * * *", }, }, } diff --git a/core/tests/test_config.py b/core/tests/test_config.py index f7d00ff..3aaf78c 100644 --- a/core/tests/test_config.py +++ b/core/tests/test_config.py @@ -11,82 +11,64 @@ from castle_core.config import ( resolve_env_vars, save_config, ) -from castle_core.manifest import ComponentManifest, Role +from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec class TestLoadConfig: """Tests for loading castle.yaml.""" def test_load_basic(self, castle_root: Path) -> None: - """Load a castle.yaml.""" + """Load a castle.yaml with three sections.""" config = load_config(castle_root) assert isinstance(config, CastleConfig) assert config.gateway.port == 18000 - assert "test-svc" in config.components assert "test-tool" in config.components + assert "test-svc" in config.services + assert "test-job" in config.jobs - def test_load_produces_manifests(self, castle_root: Path) -> None: - """Components are ComponentManifest objects.""" + def test_load_produces_typed_specs(self, castle_root: Path) -> None: + """Each section produces the correct spec type.""" config = load_config(castle_root) - assert isinstance(config.components["test-svc"], ComponentManifest) - assert isinstance(config.components["test-tool"], ComponentManifest) - - def test_service_roles(self, castle_root: Path) -> None: - """Service with expose.http gets SERVICE role.""" - config = load_config(castle_root) - svc = config.components["test-svc"] - assert Role.SERVICE in svc.roles - - def test_tool_roles(self, castle_root: Path) -> None: - """Tool with install.path gets TOOL role.""" - config = load_config(castle_root) - tool = config.components["test-tool"] - assert Role.TOOL in tool.roles + assert isinstance(config.components["test-tool"], ComponentSpec) + assert isinstance(config.services["test-svc"], ServiceSpec) + assert isinstance(config.jobs["test-job"], JobSpec) def test_service_expose(self, castle_root: Path) -> None: """Service has correct expose spec.""" config = load_config(castle_root) - svc = config.components["test-svc"] + svc = config.services["test-svc"] assert svc.expose.http.internal.port == 19000 assert svc.expose.http.health_path == "/health" def test_service_proxy(self, castle_root: Path) -> None: """Service has correct proxy spec.""" config = load_config(castle_root) - svc = config.components["test-svc"] + svc = config.services["test-svc"] assert svc.proxy.caddy.path_prefix == "/test-svc" def test_service_run_spec(self, castle_root: Path) -> None: """Service has correct RunSpec.""" config = load_config(castle_root) - svc = config.components["test-svc"] + svc = config.services["test-svc"] assert svc.run.runner == "python" assert svc.run.tool == "test-svc" - assert svc.source == "test-svc" - def test_tool_no_run(self, castle_root: Path) -> None: - """Tool without run block has no run spec.""" + def test_service_component_ref(self, castle_root: Path) -> None: + """Service references a component.""" config = load_config(castle_root) - tool = config.components["test-tool"] - assert tool.run is None + svc = config.services["test-svc"] + assert svc.component == "test-svc-comp" - def test_services_property(self, castle_root: Path) -> None: - """Services property filters to SERVICE role.""" + def test_job_schedule(self, castle_root: Path) -> None: + """Job has correct schedule.""" config = load_config(castle_root) - assert "test-svc" in config.services - assert "test-tool" not in config.services + job = config.jobs["test-job"] + assert job.schedule == "0 2 * * *" def test_tools_property(self, castle_root: Path) -> None: - """Tools property filters to TOOL role.""" + """Tools property filters to components with install.path or tool.""" config = load_config(castle_root) assert "test-tool" in config.tools - assert "test-svc" not in config.tools - - def test_managed_property(self, castle_root: Path) -> None: - """Managed property returns systemd-managed components.""" - config = load_config(castle_root) - assert "test-svc" in config.managed - assert "test-tool" not in config.managed def test_missing_config_raises(self, tmp_path: Path) -> None: """Missing castle.yaml raises FileNotFoundError.""" @@ -105,11 +87,13 @@ class TestSaveConfig: assert config2.gateway.port == config.gateway.port assert set(config2.components.keys()) == set(config.components.keys()) + assert set(config2.services.keys()) == set(config.services.keys()) + assert set(config2.jobs.keys()) == set(config.jobs.keys()) def test_save_adds_component(self, castle_root: Path) -> None: """Adding a component and saving persists it.""" config = load_config(castle_root) - config.components["new-lib"] = ComponentManifest( + config.components["new-lib"] = ComponentSpec( id="new-lib", description="A new library" ) save_config(config) @@ -123,7 +107,9 @@ class TestSaveConfig: config = load_config(castle_root) save_config(config) config2 = load_config(castle_root) - assert "test-svc" in config2.managed + svc = config2.services["test-svc"] + assert svc.manage is not None + assert svc.manage.systemd is not None class TestResolveEnvVars: @@ -131,16 +117,14 @@ class TestResolveEnvVars: def test_no_vars(self) -> None: """Plain values pass through unchanged.""" - manifest = ComponentManifest(id="test") env = {"MY_VAR": "plain_value"} - resolved = resolve_env_vars(env, manifest) + resolved = resolve_env_vars(env) assert resolved["MY_VAR"] == "plain_value" def test_unrecognized_vars_preserved(self) -> None: """Non-secret ${} references pass through unchanged.""" - manifest = ComponentManifest(id="test") env = {"MY_VAR": "${unknown_var}"} - resolved = resolve_env_vars(env, manifest) + resolved = resolve_env_vars(env) assert resolved["MY_VAR"] == "${unknown_var}" def test_resolve_secret( @@ -152,9 +136,8 @@ class TestResolveEnvVars: (secrets_dir / "API_KEY").write_text("my-secret-key\n") monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir) - manifest = ComponentManifest(id="test") env = {"API_KEY": "${secret:API_KEY}"} - resolved = resolve_env_vars(env, manifest) + resolved = resolve_env_vars(env) assert resolved["API_KEY"] == "my-secret-key" def test_resolve_missing_secret( @@ -165,7 +148,6 @@ class TestResolveEnvVars: secrets_dir.mkdir() monkeypatch.setattr("castle_core.config.SECRETS_DIR", secrets_dir) - manifest = ComponentManifest(id="test") env = {"API_KEY": "${secret:NONEXISTENT}"} - resolved = resolve_env_vars(env, manifest) + resolved = resolve_env_vars(env) assert resolved["API_KEY"] == "" diff --git a/core/tests/test_manifest.py b/core/tests/test_manifest.py index 27fa0ed..bf636b0 100644 --- a/core/tests/test_manifest.py +++ b/core/tests/test_manifest.py @@ -1,4 +1,4 @@ -"""Tests for castle manifest — role derivation, validation.""" +"""Tests for castle manifest models.""" from __future__ import annotations @@ -6,171 +6,183 @@ import pytest from castle_core.manifest import ( BuildSpec, CaddySpec, - ComponentManifest, + ComponentSpec, ExposeSpec, HttpExposeSpec, HttpInternal, InstallSpec, + JobSpec, ManageSpec, PathInstallSpec, ProxySpec, - Role, RunCommand, - RunContainer, RunPython, RunRemote, + ServiceSpec, SystemdSpec, ToolSpec, - TriggerSchedule, ) -class TestRoleDerivation: - """Tests for computed role derivation.""" +class TestComponentSpec: + """Tests for component (software catalog) model.""" - def test_service_from_expose_http(self) -> None: - """Component with expose.http gets SERVICE role.""" - m = ComponentManifest( - id="svc", - run=RunPython(runner="python", tool="svc"), - expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))), + def test_minimal(self) -> None: + """Minimal component just needs an id.""" + c = ComponentSpec(id="bare") + assert c.description is None + assert c.source is None + assert c.install is None + assert c.tool is None + assert c.build is None + + def test_tool_component(self) -> None: + """Component with tool and install specs.""" + c = ComponentSpec( + id="my-tool", + description="A tool", + source="my-tool/", + tool=ToolSpec(), + install=InstallSpec(path=PathInstallSpec(alias="my-tool")), ) - assert Role.SERVICE in m.roles + assert c.source == "my-tool/" + assert c.install.path.alias == "my-tool" - def test_tool_from_install_path(self) -> None: - """Component with install.path gets TOOL role.""" - m = ComponentManifest( - id="mytool", - install=InstallSpec(path=PathInstallSpec(alias="mytool")), - ) - assert Role.TOOL in m.roles - - def test_worker_from_systemd_without_http(self) -> None: - """Component managed by systemd but no HTTP gets WORKER role.""" - m = ComponentManifest( - id="worker", - run=RunCommand(runner="command", argv=["worker-bin"]), - manage=ManageSpec(systemd=SystemdSpec()), - ) - assert Role.WORKER in m.roles - assert Role.SERVICE not in m.roles - - def test_container_role(self) -> None: - """Container runner gets CONTAINERIZED role.""" - m = ComponentManifest( - id="container", - run=RunContainer(runner="container", image="redis:7"), - ) - assert Role.CONTAINERIZED in m.roles - - def test_remote_role(self) -> None: - """Remote runner gets REMOTE role.""" - m = ComponentManifest( - id="remote", - run=RunRemote(runner="remote", base_url="http://example.com"), - ) - assert Role.REMOTE in m.roles - - def test_job_from_schedule_trigger(self) -> None: - """Component with schedule trigger gets JOB role.""" - m = ComponentManifest( - id="job", - run=RunCommand(runner="command", argv=["backup"]), - triggers=[TriggerSchedule(cron="0 * * * *")], - ) - assert Role.JOB in m.roles - - def test_frontend_from_build(self) -> None: - """Component with build outputs gets FRONTEND role.""" - m = ComponentManifest( - id="frontend", - run=RunCommand(runner="command", argv=["serve"]), + def test_frontend_component(self) -> None: + """Component with build spec.""" + c = ComponentSpec( + id="my-app", build=BuildSpec(commands=[["pnpm", "build"]], outputs=["dist/"]), ) - assert Role.FRONTEND in m.roles + assert c.build.outputs == ["dist/"] - def test_tool_from_tool_spec(self) -> None: - """Component with tool spec gets TOOL role.""" - m = ComponentManifest( - id="docx2md", - tool=ToolSpec(source="docx2md/"), - ) - assert Role.TOOL in m.roles + def test_source_dir_from_source(self) -> None: + """source_dir uses source field.""" + c = ComponentSpec(id="x", source="components/x/") + assert c.source_dir == "components/x" - def test_tool_spec_without_install(self) -> None: - """Tool spec alone is enough for TOOL role, no install.path needed.""" - m = ComponentManifest( - id="my-tool", - tool=ToolSpec(), - ) - assert Role.TOOL in m.roles + def test_source_dir_none(self) -> None: + """source_dir returns None when no source available.""" + c = ComponentSpec(id="x") + assert c.source_dir is None - def test_fallback_to_tool(self) -> None: - """Component with no indicators defaults to TOOL.""" - m = ComponentManifest(id="bare") - assert m.roles == [Role.TOOL] - def test_multiple_roles(self) -> None: - """Component can have multiple roles.""" - m = ComponentManifest( - id="multi", - run=RunPython(runner="python", tool="multi"), - expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))), - install=InstallSpec(path=PathInstallSpec(alias="multi")), - ) - assert Role.SERVICE in m.roles - assert Role.TOOL in m.roles +class TestServiceSpec: + """Tests for service (long-running daemon) model.""" - def test_systemd_with_http_is_service_not_worker(self) -> None: - """Systemd + HTTP = SERVICE, not WORKER.""" - m = ComponentManifest( + def test_basic_service(self) -> None: + """Service with run and expose.""" + s = ServiceSpec( id="svc", run=RunPython(runner="python", tool="svc"), expose=ExposeSpec(http=HttpExposeSpec(internal=HttpInternal(port=8000))), + ) + assert s.run.runner == "python" + assert s.expose.http.internal.port == 8000 + + def test_service_with_component_ref(self) -> None: + """Service can reference a component.""" + s = ServiceSpec( + id="svc", + component="my-component", + run=RunPython(runner="python", tool="svc"), + ) + assert s.component == "my-component" + + def test_service_with_proxy(self) -> None: + """Service with proxy spec.""" + s = ServiceSpec( + id="svc", + run=RunPython(runner="python", tool="svc"), + proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")), + ) + assert s.proxy.caddy.path_prefix == "/svc" + + def test_service_with_manage(self) -> None: + """Service with systemd management.""" + s = ServiceSpec( + id="svc", + run=RunCommand(runner="command", argv=["bin"]), manage=ManageSpec(systemd=SystemdSpec()), ) - assert Role.SERVICE in m.roles - assert Role.WORKER not in m.roles - - -class TestConsistencyValidation: - """Tests for model validation.""" + assert s.manage.systemd.enable is True def test_remote_with_systemd_raises(self) -> None: """Remote runner + systemd management is invalid.""" with pytest.raises( ValueError, match="manage.systemd cannot be enabled for runner=remote" ): - ComponentManifest( + ServiceSpec( id="bad", run=RunRemote(runner="remote", base_url="http://example.com"), manage=ManageSpec(systemd=SystemdSpec()), ) - def test_no_run_is_valid(self) -> None: - """Component with no run spec is valid (registration-only).""" - m = ComponentManifest(id="reg-only", description="Just registered") - assert m.run is None - assert m.roles == [Role.TOOL] + def test_no_run_is_invalid(self) -> None: + """Service requires a run spec.""" + with pytest.raises(Exception): + ServiceSpec(id="bad") + + +class TestJobSpec: + """Tests for job (scheduled task) model.""" + + def test_basic_job(self) -> None: + """Job with run and schedule.""" + j = JobSpec( + id="my-job", + run=RunCommand(runner="command", argv=["backup"]), + schedule="0 2 * * *", + ) + assert j.schedule == "0 2 * * *" + assert j.timezone == "America/Los_Angeles" + + def test_job_with_component_ref(self) -> None: + """Job can reference a component.""" + j = JobSpec( + id="sync", + component="protonmail", + run=RunCommand(runner="command", argv=["protonmail", "sync"]), + schedule="*/5 * * * *", + ) + assert j.component == "protonmail" + + def test_job_requires_schedule(self) -> None: + """Job without schedule is invalid.""" + with pytest.raises(Exception): + JobSpec( + id="bad", + run=RunCommand(runner="command", argv=["x"]), + ) + + def test_job_custom_timezone(self) -> None: + """Job with custom timezone.""" + j = JobSpec( + id="job", + run=RunCommand(runner="command", argv=["x"]), + schedule="0 0 * * *", + timezone="UTC", + ) + assert j.timezone == "UTC" class TestModelSerialization: """Tests for model_dump behavior.""" - def test_dump_excludes_none(self) -> None: + def test_dump_component_excludes_none(self) -> None: """model_dump with exclude_none drops None fields.""" - m = ComponentManifest(id="test", description="Test") - data = m.model_dump(exclude_none=True, exclude={"id", "roles"}) + c = ComponentSpec(id="test", description="Test") + data = c.model_dump(exclude_none=True, exclude={"id"}) assert "description" in data - assert "run" not in data - assert "manage" not in data + assert "install" not in data + assert "tool" not in data def test_dump_service(self) -> None: - """Full service manifest serializes correctly.""" - m = ComponentManifest( + """Full service serializes correctly.""" + s = ServiceSpec( id="svc", description="A service", - run=RunPython(runner="python", tool="svc", cwd="svc"), + run=RunPython(runner="python", tool="svc"), expose=ExposeSpec( http=HttpExposeSpec( internal=HttpInternal(port=9001), health_path="/health" @@ -179,7 +191,7 @@ class TestModelSerialization: proxy=ProxySpec(caddy=CaddySpec(path_prefix="/svc")), manage=ManageSpec(systemd=SystemdSpec()), ) - data = m.model_dump(exclude_none=True, exclude={"id", "roles"}) + data = s.model_dump(exclude_none=True, exclude={"id"}) assert data["run"]["runner"] == "python" assert data["expose"]["http"]["internal"]["port"] == 9001 assert data["proxy"]["caddy"]["path_prefix"] == "/svc" diff --git a/core/tests/test_systemd.py b/core/tests/test_systemd.py index e4aaac4..18233f9 100644 --- a/core/tests/test_systemd.py +++ b/core/tests/test_systemd.py @@ -3,6 +3,7 @@ from __future__ import annotations from castle_core.generators.systemd import ( + generate_timer, generate_unit_from_deployed, unit_name, ) @@ -115,3 +116,25 @@ class TestUnitFromDeployed: ) unit = generate_unit_from_deployed("my-svc", deployed) assert "/data/repos/" not in unit + + +class TestGenerateTimer: + """Tests for timer generation from schedule strings.""" + + def test_daily_timer(self) -> None: + """Daily cron produces OnCalendar timer.""" + timer = generate_timer("my-job", schedule="0 2 * * *", description="Nightly") + assert "Description=Castle timer: Nightly" in timer + assert "OnCalendar=*-*-* 02:00:00" in timer + assert "WantedBy=timers.target" in timer + + def test_interval_timer(self) -> None: + """*/N minute cron produces OnUnitActiveSec timer.""" + timer = generate_timer("sync", schedule="*/5 * * * *") + assert "OnUnitActiveSec=300s" in timer + assert "OnBootSec=60" in timer + + def test_fallback_description(self) -> None: + """Timer uses name when no description given.""" + timer = generate_timer("my-job", schedule="0 0 * * *") + assert "Description=Castle timer: my-job" in timer diff --git a/docs/component-registry.md b/docs/component-registry.md index f1632f7..0ab5739 100644 --- a/docs/component-registry.md +++ b/docs/component-registry.md @@ -1,19 +1,29 @@ # Component Registry How castle tracks, configures, and manages components. This is the central -reference for `castle.yaml` structure and the manifest architecture. +reference for `castle.yaml` structure and the registry architecture. ## castle.yaml The single source of truth for all components. Lives at the repo root. +Three top-level sections: ```yaml gateway: port: 9000 components: - my-service: + my-tool: description: Does something useful + source: components/my-tool + install: + path: { alias: my-tool } + tool: + system_dependencies: [pandoc] + +services: + my-service: + component: my-service run: runner: python tool: my-service @@ -25,16 +35,84 @@ components: caddy: { path_prefix: /my-service } manage: systemd: {} + +jobs: + my-job: + component: my-tool + run: + runner: command + argv: [my-tool, sync] + schedule: "0 2 * * *" + manage: + systemd: {} ``` -## Manifest blocks +### Section semantics -Each component declares **what it does** through these optional blocks: +| Section | Purpose | Category | +|---------|---------|----------| +| `components:` | Software catalog — what exists | tool, frontend, component | +| `services:` | Long-running daemons — how they run | service | +| `jobs:` | Scheduled tasks — when they run | job | -### `run` — How to start it +Services and jobs can reference a component via `component:` for description +fallthrough and source code linking. They can also exist independently +(e.g., `castle-gateway` runs Caddy — not our software). -Discriminated union on `runner`. The runner encodes both the language/toolchain -(used by `castle sync`) and the deployment resolution (used by `castle deploy`): +## Component blocks + +Components define **what software exists** — identity, source, tools, builds. + +### `source` — Where the source lives + +```yaml +source: components/my-tool +``` + +Relative path from repo root to the project directory. + +### `install` — How to install it + +```yaml +install: + path: + alias: my-tool # Command name in PATH +``` + +Creates a shim so the tool is available system-wide after +`uv tool install --editable .`. + +### `tool` — Tool metadata + +```yaml +tool: + version: "1.0.0" + system_dependencies: [pandoc, poppler-utils] +``` + +This block provides metadata for `castle tool list` and the dashboard. +It's separate from `install` (which handles PATH registration). The source +directory is set via the top-level `source` field on the component, not here. + +### `build` — How to build it + +```yaml +build: + commands: + - ["pnpm", "build"] + outputs: + - dist/ +``` + +Components with build outputs are categorized as **frontends** in the UI. + +## Service blocks + +Services define **how long-running daemons are deployed**. + +### `run` — How to start it (required) + +Discriminated union on `runner`: | Runner | Sync | Deploy | Key fields | |--------|------|--------|------------| @@ -44,38 +122,22 @@ Discriminated union on `runner`. The runner encodes both the language/toolchain | `node` | `package_manager install` | `package_manager run script` | `script`, `package_manager` | | `remote` | *(none)* | *(none — no local process)* | `base_url`, `health_url` | -**Services** use `python`: ```yaml run: runner: python tool: my-service # name in [project.scripts] ``` -**Tools invoked by castle** (jobs, scheduled tasks) use `command`: -```yaml -run: - runner: command - argv: ["protonmail", "sync"] - cwd: protonmail - env: - PROTONMAIL_USERNAME: user@example.com -``` - -**Standalone tools** that users invoke directly often have no `run` block at -all — castle just installs them to PATH. - ### `expose` — What it exposes ```yaml expose: http: internal: - port: 9001 # Required for services + port: 9001 # Required for HTTP services health_path: /health # Used by health polling ``` -Having `expose.http` gives the component the **service** role. - ### `proxy` — How to proxy it ```yaml @@ -95,7 +157,7 @@ manage: ``` Enables `castle service enable/disable` and `castle logs`. An empty `{}` -uses defaults (enable=true, restart=on-failure, restart_sec=5). +uses defaults (enable=true, restart=on-failure, restart_sec=2). Full options: ```yaml @@ -107,91 +169,39 @@ manage: no_new_privileges: true after: [network.target, castle-other.service] wanted_by: [default.target] + exec_reload: "caddy reload ..." ``` -### `install` — How to install it +### `defaults` — Default environment ```yaml -install: - path: - alias: my-tool # Command name in PATH -``` - -Creates a shim so the tool is available system-wide after -`uv tool install --editable .`. - -### `tool` — Tool metadata - -```yaml -tool: - source: components/my-tool/ # Source directory - version: "1.0.0" - system_dependencies: [pandoc, poppler-utils] -``` - -This block provides metadata for `castle tool list` and the dashboard. -It's separate from `install` (which handles PATH registration) and `run` -(which handles execution). - -The install method (uv tool install vs symlink) is inferred from the source -directory: if `pyproject.toml` exists, it's a Python package; if the source -is a file, it's symlinked. - -### `build` — How to build it - -```yaml -build: - commands: - - ["pnpm", "build"] - outputs: - - dist/ -``` - -Having build outputs gives the component the **frontend** role. - -### `triggers` — What triggers it - -```yaml -triggers: - - type: schedule - cron: "*/5 * * * *" - timezone: America/Los_Angeles # default -``` - -Having a schedule trigger gives the component the **job** role. -Castle generates a systemd .timer file alongside the .service unit. - -Other trigger types: `manual`, `event` (source + topic), `request` (protocol). - -### `env` with secrets - -Environment variables can reference secrets stored in `~/.castle/secrets/`: - -```yaml -run: +defaults: env: + CENTRAL_CONTEXT_URL: http://localhost:9001 API_KEY: ${secret:MY_API_KEY} ``` Castle resolves `${secret:NAME}` by reading `~/.castle/secrets/NAME`. Never store secrets in castle.yaml or project directories. -## Role derivation +## Job blocks -Roles are **computed** from manifest declarations, never set manually: +Jobs define **how scheduled tasks run**. Same blocks as services plus +`schedule` and `timezone`. -| Role | Derived when | -|------|-------------| -| **service** | Has `expose.http` | -| **tool** | Has `install.path` or has `tool` spec (fallback) | -| **worker** | Has `manage.systemd` but no `expose.http` | -| **job** | Has trigger with `type: schedule` | -| **frontend** | Has `build` with outputs or commands | -| **containerized** | Runner is `container` | -| **remote** | Runner is `remote` | +### `schedule` — Cron expression (required) -A component can have multiple roles. For example, `protonmail` is both a -**tool** (installed to PATH) and a **job** (runs on a cron schedule). +```yaml +schedule: "*/5 * * * *" +timezone: America/Los_Angeles # default +``` + +Castle generates a systemd `.timer` file alongside the `.service` unit. + +### Other blocks + +Jobs also support `run` (required), `manage`, and `defaults` — same +semantics as services. ## Registering a new component @@ -207,17 +217,38 @@ castle create my-tool --type tool --description "Does something" ### Manually -Add an entry to the `components:` section of `castle.yaml`: +Add entries to the appropriate sections of `castle.yaml`: ```yaml +# Tool — only needs a component entry components: my-tool: description: Does something useful - tool: - source: components/my-tool/ + source: components/my-tool install: path: alias: my-tool + +# Service — needs both component and service entries +components: + my-service: + description: Does something useful + source: components/my-service + +services: + my-service: + component: my-service + run: + runner: python + tool: my-service + expose: + http: + internal: { port: 9001 } + health_path: /health + proxy: + caddy: { path_prefix: /my-service } + manage: + systemd: {} ``` ## Lifecycle @@ -254,21 +285,18 @@ uv tool install --editable components/my-tool/ # 4. Install to PATH ### Job lifecycle -Jobs are tools or services with a schedule trigger. They need both `run` -(so castle knows how to execute them) and `manage.systemd` (so systemd -handles the timer): +Jobs are defined in the `jobs:` section with a `run` spec and `schedule`: ```yaml -my-job: - description: Runs nightly - run: - runner: command - argv: ["my-job"] - triggers: - - type: schedule - cron: "0 2 * * *" - manage: - systemd: {} +jobs: + my-job: + description: Runs nightly + run: + runner: command + argv: ["my-job"] + schedule: "0 2 * * *" + manage: + systemd: {} ``` `castle service enable my-job` generates both a `.service` (Type=oneshot) @@ -289,14 +317,16 @@ and a `.timer` file. The Pydantic models live in `core/src/castle_core/manifest.py`. Key classes: -- `ComponentManifest` — top-level model, has `roles` computed property +- `ComponentSpec` — software catalog entry (source, install, tool, build) +- `ServiceSpec` — long-running daemon (run, expose, proxy, manage, defaults) +- `JobSpec` — scheduled task (run, schedule, manage, defaults) - `RunSpec` — discriminated union (RunPython, RunCommand, RunContainer, RunNode, RunRemote) -- `TriggerSpec` — union (TriggerSchedule, TriggerManual, TriggerEvent, TriggerRequest) - `ExposeSpec`, `ProxySpec`, `ManageSpec`, `InstallSpec`, `ToolSpec`, `BuildSpec` - `CaddySpec`, `SystemdSpec`, `HttpExposeSpec`, `HttpInternal` Config loading: `core/src/castle_core/config.py` — `load_config()` parses -castle.yaml into `CastleConfig` with typed `components` dict. +castle.yaml into `CastleConfig` with typed `components`, `services`, and +`jobs` dicts. Infrastructure generators: `core/src/castle_core/generators/` — systemd unit/timer generation (`systemd.py`) and Caddyfile generation (`caddyfile.py`). diff --git a/docs/design.md b/docs/design.md index 9b5db54..2d17808 100644 --- a/docs/design.md +++ b/docs/design.md @@ -20,9 +20,9 @@ is self-sufficient. The mesh is optional. Castle service is just a well-behaved Unix daemon that happens to be registered in a manifest. -3. **Declare capabilities, derive roles.** Components say what they - do (expose HTTP, run on a schedule, install to PATH). Castle infers - what they are (service, job, tool, frontend). No role labels. +3. **Section is category.** Components, services, and jobs live in + separate sections of `castle.yaml`. The section determines the + category — no role derivation needed. 4. **Language-agnostic above the build line.** Below the build line, every language is different (uv, pnpm, cargo, go). Above it, @@ -149,13 +149,17 @@ answers: "is it working?" These map to two files: -**`castle.yaml`** (in the repo, version-controlled) — Component specs: +**`castle.yaml`** (in the repo, version-controlled) — Three sections: ```yaml components: central-context: description: Content storage API source: components/central-context + +services: + central-context: + component: central-context run: runner: python tool: central-context @@ -168,12 +172,29 @@ components: path_prefix: /central-context manage: systemd: {} + +jobs: + backup-collect: + component: backup-collect + run: + runner: command + argv: [backup-collect] + schedule: "0 2 * * *" + manage: + systemd: {} ``` -The spec says what the component *is* and what it *needs* — a port, a -data directory, HTTP exposure. Convention-based env vars (`_PORT`, -`_DATA_DIR`) are generated automatically during deploy. Only -non-convention values need `defaults.env`. +Components define *what software exists* (identity, source, install, tools). +Services define *how daemons run* (run config, expose, proxy, systemd). +Jobs define *how scheduled tasks run* (run config, cron schedule, systemd). + +Services and jobs can reference a component via `component:` for description +fallthrough and source code linking. They can also exist independently +(e.g., `castle-gateway` runs Caddy — not our software). + +Convention-based env vars (`_PORT`, `_DATA_DIR`) are +generated automatically during deploy. Only non-convention values need +`defaults.env`. **`~/.castle/registry.yaml`** (per-node, not in the repo) — Node config: @@ -189,7 +210,7 @@ deployed: env: CENTRAL_CONTEXT_DATA_DIR: /data/castle/central-context CENTRAL_CONTEXT_PORT: "9001" - roles: [service] + category: service port: 9001 health_path: /health proxy_path: /central-context diff --git a/docs/python-tools.md b/docs/python-tools.md index 243a551..554ac7d 100644 --- a/docs/python-tools.md +++ b/docs/python-tools.md @@ -317,28 +317,27 @@ uv run ruff format . # Format components: my-tool: description: Does something useful - tool: - source: components/my-tool/ + source: components/my-tool install: path: alias: my-tool ``` -Tools with system dependencies declare them in the manifest: +Tools with system dependencies declare them in the component: ```yaml components: pdf2md: description: Convert PDF files to Markdown - tool: - source: components/pdf2md/ - system_dependencies: [pandoc, poppler-utils] + source: components/pdf2md install: path: alias: pdf2md + tool: + system_dependencies: [pandoc, poppler-utils] ``` -Tools with `install.path` get the **tool** role. They don't need `expose`, -`proxy`, or `manage` blocks unless castle also runs them (e.g., scheduled jobs). +Tools live in the `components:` section. If a tool also runs on a schedule, +add a separate entry in the `jobs:` section referencing the component. -See @docs/component-registry.md for the full manifest reference. +See @docs/component-registry.md for the full registry reference. diff --git a/docs/web-apis.md b/docs/web-apis.md index 1ecd0b8..e1f1d01 100644 --- a/docs/web-apis.md +++ b/docs/web-apis.md @@ -101,7 +101,12 @@ Castle passes config via env vars in castle.yaml: ```yaml components: my-service: - source: my-service + description: Does something useful + source: components/my-service + +services: + my-service: + component: my-service run: runner: python tool: my-service diff --git a/docs/web-frontends.md b/docs/web-frontends.md index f9490bc..d675e89 100644 --- a/docs/web-frontends.md +++ b/docs/web-frontends.md @@ -107,8 +107,8 @@ The `build` output is a static SPA in `dist/` — just HTML, JS, and CSS files. ## Registering as a castle component -A frontend component has a `build` spec (produces static output) and optionally -a `proxy` spec (Caddy serves the built files). No `run` block needed if Caddy +A frontend component has a `build` spec (produces static output). Register it +in the `components:` section of `castle.yaml`. No `run` block needed if Caddy handles serving directly from the build output. ```yaml @@ -116,45 +116,33 @@ handles serving directly from the build output. components: my-frontend: description: Web dashboard + source: my-frontend build: commands: - ["pnpm", "build"] outputs: - dist/ - proxy: - caddy: - path_prefix: /app ``` -For development with Vite's dev server, add a `run` block: +For production, Caddy serves the static files — add a service entry with a +proxy spec: ```yaml -components: +services: my-frontend: - description: Web dashboard + component: my-frontend run: runner: node script: dev package_manager: pnpm - cwd: my-frontend - build: - commands: - - ["pnpm", "build"] - outputs: - - dist/ expose: http: internal: { port: 5173 } proxy: - caddy: - path_prefix: /app + caddy: { path_prefix: /app } ``` -This gives the component both the `frontend` role (from `build`) and the -`service` role (from `expose.http`) during development. - -See @docs/component-registry.md for the full manifest reference and role -derivation rules. +See @docs/component-registry.md for the full registry reference. ## Serving with Caddy