refactor: Align vocabulary — component → program / deployment
Canonical terms (see docs/registry.md glossary): - program: any catalog entry (tool/daemon/frontend) — the software - service / job: how a program is deployed (systemd .service / .timer) - deployment: umbrella for the unified service+job+program view Changes: - core: ServiceSpec/JobSpec component: → program: (component accepted as back-compat validation_alias); DeployedComponent → Deployment. - api: ComponentSummary/Detail → DeploymentSummary/Detail; GET /components → /deployments; GatewayRoute.component → program; GatewayInfo .component_count → deployment_count; ServiceActionResponse/action keys component → program. - app: matching type renames, /components → /deployments hook, route /component/:name → /deployment/:name, GatewayRoute.program. - docs: component-registry.md → registry.md (+ canonical glossary); CLAUDE.md endpoint list refreshed (drop removed /tools, add typed program/service/job + config routes). Tests: core 92, cli 24, api 52 green; app build clean; ruff clean.
This commit is contained in:
29
CLAUDE.md
29
CLAUDE.md
@@ -16,7 +16,7 @@ Castle is a personal software platform — a monorepo of independent projects
|
|||||||
Each program has a **stack** (development toolchain: python-fastapi,
|
Each program has a **stack** (development toolchain: python-fastapi,
|
||||||
python-cli, react-vite) and a **behavior** (runtime role: daemon, tool,
|
python-cli, react-vite) and a **behavior** (runtime role: daemon, tool,
|
||||||
frontend). Scheduling, systemd management, and proxying are orthogonal
|
frontend). Scheduling, systemd management, and proxying are orthogonal
|
||||||
operations. Services and jobs reference a program via `component:` for
|
operations. Services and jobs reference a program via `program:` for
|
||||||
description fallthrough.
|
description fallthrough.
|
||||||
|
|
||||||
**Key principle:** Regular projects must never depend on castle. They accept standard
|
**Key principle:** Regular projects must never depend on castle. They accept standard
|
||||||
@@ -41,7 +41,7 @@ verb commands and scaffold, but a program stands on its own via its declared
|
|||||||
|
|
||||||
Stack guides (for writing *new* code, AI-facing):
|
Stack guides (for writing *new* code, AI-facing):
|
||||||
|
|
||||||
- @docs/component-registry.md — Registry architecture, castle.yaml structure, lifecycle
|
- @docs/registry.md — Registry architecture, castle.yaml structure, lifecycle
|
||||||
- @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing)
|
- @docs/stacks/python-fastapi.md — FastAPI service patterns (config, routes, models, testing)
|
||||||
- @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
|
- @docs/stacks/python-cli.md — CLI tool patterns (argparse, stdin/stdout, piping, testing)
|
||||||
- @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns
|
- @docs/stacks/react-vite.md — React/Vite/TypeScript frontend patterns
|
||||||
@@ -123,11 +123,22 @@ Core:
|
|||||||
- `GET /health` — Health check
|
- `GET /health` — Health check
|
||||||
- `GET /stream` — SSE stream (health, service-action, mesh events)
|
- `GET /stream` — SSE stream (health, service-action, mesh events)
|
||||||
|
|
||||||
Components:
|
Deployments (the unified view of services + jobs + programs):
|
||||||
- `GET /components` — List all (add `?include_remote=true` for cross-node)
|
- `GET /deployments` — List all (add `?include_remote=true` for cross-node)
|
||||||
- `GET /components/{name}` — Component detail
|
- `GET /deployments/{name}` — Deployment detail
|
||||||
- `GET /status` — Live health for all services
|
- `GET /status` — Live health for all services
|
||||||
|
|
||||||
|
Programs / Services / Jobs (typed views + editing):
|
||||||
|
- `GET /programs`, `GET /programs/{name}` — Program catalog (`?behavior=tool` to filter)
|
||||||
|
- `POST /programs/{name}/{action}` — Run a program verb (install/uninstall/build/…)
|
||||||
|
- `PUT|DELETE /programs/{name}` — Edit or remove a program entry
|
||||||
|
- `GET /services`, `GET /services/{name}`, `PUT|DELETE /services/{name}`
|
||||||
|
- `GET /jobs`, `GET /jobs/{name}`, `PUT|DELETE /jobs/{name}`
|
||||||
|
|
||||||
|
Config:
|
||||||
|
- `GET /` — Full registry; `PUT /` — Save registry
|
||||||
|
- `POST /apply` — Apply registry changes; `POST /deploy` — Deploy to runtime
|
||||||
|
|
||||||
Gateway:
|
Gateway:
|
||||||
- `GET /gateway` — Gateway info with route table and hostname
|
- `GET /gateway` — Gateway info with route table and hostname
|
||||||
- `GET /gateway/caddyfile` — Generated Caddyfile content
|
- `GET /gateway/caddyfile` — Generated Caddyfile content
|
||||||
@@ -138,16 +149,10 @@ Mesh:
|
|||||||
- `GET /nodes` — All known nodes (local + discovered remote)
|
- `GET /nodes` — All known nodes (local + discovered remote)
|
||||||
- `GET /nodes/{hostname}` — Node detail with deployed components
|
- `GET /nodes/{hostname}` — Node detail with deployed components
|
||||||
|
|
||||||
Services:
|
Service actions:
|
||||||
- `POST /services/{name}/{action}` — start/stop/restart
|
- `POST /services/{name}/{action}` — start/stop/restart
|
||||||
- `GET /services/{name}/unit` — Systemd unit content
|
- `GET /services/{name}/unit` — Systemd unit content
|
||||||
|
|
||||||
Tools:
|
|
||||||
- `GET /tools` — List all tools
|
|
||||||
- `GET /tools/{name}` — Tool detail
|
|
||||||
- `POST /tools/{name}/install` — Install tool to PATH
|
|
||||||
- `POST /tools/{name}/uninstall` — Uninstall tool
|
|
||||||
|
|
||||||
## Mesh Coordination (opt-in)
|
## Mesh Coordination (opt-in)
|
||||||
|
|
||||||
Multi-node discovery is disabled by default. Enable via env vars:
|
Multi-node discovery is disabled by default. Enable via env vars:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ A personal software platform. Castle manages independent services, tools, and fr
|
|||||||
|
|
||||||
Historically, applications have been developed by third parties, distributed through app stores, and installed on user devices.
|
Historically, applications have been developed by third parties, distributed through app stores, and installed on user devices.
|
||||||
|
|
||||||
With the advent of AI-assisted software development, users can write the software they need directly, eliminating the need for packaging and distribution. This makes many classes of software simpler, too. Oftentimes all a user needs are simple scripts or configurations of existing tools. But no matter how simple your script or application, it needed to be tailored and packaged for specific distribution channels. Castle provides a unified environment for developing, managing, deploying, and advertising these simple applications.
|
With the advent of AI-assisted software development, users can write the software they need directly, eliminating the need for packaging and distribution. This makes many classes of software simpler. Oftentimes all a user needs are simple scripts or configurations of existing tools. But no matter how simple your script or application, it needed to be tailored and packaged for specific distribution channels. Castle provides a unified environment for developing, managing, deploying, and advertising these simple applications.
|
||||||
|
|
||||||
Castle _stacks_ are pre-configured development environments that provide a starting point for building applications. They include everything needed to get started, from the programming language and framework to the necessary dependencies and tools. This is a design intended for coding assistants to generate castle programs with a level of consistency and to ensure that they are properly configured and ready to use with Castle. If your coding assistant knows about Castle, it can help you create and manage your custom applications more efficiently.
|
Castle _stacks_ are pre-configured development environments that provide a starting point for building applications. They include everything needed to get started, from the programming language and framework to the necessary dependencies and tools. This is a design intended for coding assistants to generate castle programs with a level of consistency and to ensure that they are properly configured and ready to use with Castle. If your coding assistant knows about Castle, it can help you create and manage your custom applications more efficiently.
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react"
|
import { ChevronDown, ChevronRight } from "lucide-react"
|
||||||
import type { ComponentDetail } from "@/types"
|
import type { DeploymentDetail } from "@/types"
|
||||||
import { ComponentFields } from "./ComponentFields"
|
import { ComponentFields } from "./ComponentFields"
|
||||||
import { BehaviorBadge } from "./BehaviorBadge"
|
import { BehaviorBadge } from "./BehaviorBadge"
|
||||||
import { StackBadge } from "./StackBadge"
|
import { StackBadge } from "./StackBadge"
|
||||||
|
|
||||||
interface ComponentEditorProps {
|
interface ComponentEditorProps {
|
||||||
component: ComponentDetail
|
component: DeploymentDetail
|
||||||
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
onSave: (name: string, config: Record<string, unknown>) => Promise<void>
|
||||||
onDelete: (name: string) => Promise<void>
|
onDelete: (name: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{gateway.routes.map((route) => {
|
{gateway.routes.map((route) => {
|
||||||
const health = statusMap.get(route.component)
|
const health = statusMap.get(route.program)
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={route.path}
|
key={route.path}
|
||||||
@@ -84,10 +84,10 @@ export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2">
|
<td className="px-4 py-2">
|
||||||
<Link
|
<Link
|
||||||
to={`/component/${route.component}`}
|
to={`/deployment/${route.program}`}
|
||||||
className="hover:text-[var(--primary)] transition-colors"
|
className="hover:text-[var(--primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{route.component}
|
{route.program}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 font-mono text-[var(--muted)]">
|
<td className="px-4 py-2 font-mono text-[var(--muted)]">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Link } from "react-router-dom"
|
|||||||
import { ArrowLeft, Check, Loader2, Zap } from "lucide-react"
|
import { ArrowLeft, Check, Loader2, Zap } from "lucide-react"
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||||
import { apiClient } from "@/services/api/client"
|
import { apiClient } from "@/services/api/client"
|
||||||
import type { ComponentDetail } from "@/types"
|
import type { DeploymentDetail } from "@/types"
|
||||||
import { AddComponent } from "@/components/AddComponent"
|
import { AddComponent } from "@/components/AddComponent"
|
||||||
import { ComponentEditor } from "@/components/ComponentEditor"
|
import { ComponentEditor } from "@/components/ComponentEditor"
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ export function ConfigEditorPage() {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const list = await apiClient.get<{ id: string }[]>("/components")
|
const list = await apiClient.get<{ id: string }[]>("/components")
|
||||||
const details = await Promise.all(
|
const details = await Promise.all(
|
||||||
list.map((c) => apiClient.get<ComponentDetail>(`/components/${c.id}`))
|
list.map((c) => apiClient.get<DeploymentDetail>(`/components/${c.id}`))
|
||||||
)
|
)
|
||||||
return details
|
return details
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function NodeDetailPage() {
|
|||||||
>
|
>
|
||||||
<td className="px-3 py-2.5">
|
<td className="px-3 py-2.5">
|
||||||
<Link
|
<Link
|
||||||
to={`/component/${comp.id}`}
|
to={`/deployment/${comp.id}`}
|
||||||
className="font-medium hover:text-[var(--primary)] transition-colors"
|
className="font-medium hover:text-[var(--primary)] transition-colors"
|
||||||
>
|
>
|
||||||
{comp.id}
|
{comp.id}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const router = createBrowserRouter([
|
|||||||
element: <ProgramDetailPage />,
|
element: <ProgramDetailPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/component/:name",
|
path: "/deployment/:name",
|
||||||
element: <ProgramRedirect />,
|
element: <ProgramRedirect />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect } from "react"
|
|||||||
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"
|
||||||
import { apiClient } from "./client"
|
import { apiClient } from "./client"
|
||||||
import type {
|
import type {
|
||||||
ComponentDetail,
|
DeploymentDetail,
|
||||||
ServiceSummary,
|
ServiceSummary,
|
||||||
ServiceDetail,
|
ServiceDetail,
|
||||||
JobSummary,
|
JobSummary,
|
||||||
@@ -21,8 +21,8 @@ import type {
|
|||||||
// Legacy compat hook — used by ConfigEditorPage and ProgramRedirect
|
// Legacy compat hook — used by ConfigEditorPage and ProgramRedirect
|
||||||
export function useComponent(name: string) {
|
export function useComponent(name: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["components", name],
|
queryKey: ["deployments", name],
|
||||||
queryFn: () => apiClient.get<ComponentDetail>(`/components/${name}`),
|
queryFn: () => apiClient.get<DeploymentDetail>(`/deployments/${name}`),
|
||||||
enabled: !!name,
|
enabled: !!name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ export function useServiceAction() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Network error from self-restart killing the connection — expected
|
// Network error from self-restart killing the connection — expected
|
||||||
if (err instanceof TypeError) {
|
if (err instanceof TypeError) {
|
||||||
return { component: name, action, status: "accepted" } as ServiceActionResponse
|
return { program: name, action, status: "accepted" } as ServiceActionResponse
|
||||||
}
|
}
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
@@ -144,7 +144,7 @@ export function useProgramAction() {
|
|||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ name, action }: { name: string; action: string }) =>
|
mutationFn: ({ name, action }: { name: string; action: string }) =>
|
||||||
apiClient.post<{ component: string; action: string; status: string; output: string }>(
|
apiClient.post<{ program: string; action: string; status: string; output: string }>(
|
||||||
`/programs/${name}/${action}`,
|
`/programs/${name}/${action}`,
|
||||||
),
|
),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ export interface ProgramDetail extends ProgramSummary {
|
|||||||
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
|
export type AnyDetail = ServiceDetail | JobDetail | ProgramDetail
|
||||||
|
|
||||||
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
|
// Legacy unified type — kept for NodeDetail.deployed and compat endpoint
|
||||||
export interface ComponentSummary {
|
export interface DeploymentSummary {
|
||||||
id: string
|
id: string
|
||||||
category: "program" | "service" | "job" | null
|
category: "program" | "service" | "job" | null
|
||||||
description: string | null
|
description: string | null
|
||||||
@@ -88,7 +88,7 @@ export interface ComponentSummary {
|
|||||||
node: string | null
|
node: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ComponentDetail extends ComponentSummary {
|
export interface DeploymentDetail extends DeploymentSummary {
|
||||||
manifest: Record<string, unknown>
|
manifest: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,21 +105,21 @@ export interface StatusResponse {
|
|||||||
export interface GatewayRoute {
|
export interface GatewayRoute {
|
||||||
path: string
|
path: string
|
||||||
target_port: number
|
target_port: number
|
||||||
component: string
|
program: string
|
||||||
node: string
|
node: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GatewayInfo {
|
export interface GatewayInfo {
|
||||||
port: number
|
port: number
|
||||||
hostname: string
|
hostname: string
|
||||||
component_count: number
|
deployment_count: number
|
||||||
service_count: number
|
service_count: number
|
||||||
managed_count: number
|
managed_count: number
|
||||||
routes: GatewayRoute[]
|
routes: GatewayRoute[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServiceActionResponse {
|
export interface ServiceActionResponse {
|
||||||
component: string
|
program: string
|
||||||
action: string
|
action: string
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ export interface SSEHealthEvent {
|
|||||||
|
|
||||||
export interface SSEServiceActionEvent {
|
export interface SSEServiceActionEvent {
|
||||||
action: string
|
action: string
|
||||||
component: string
|
program: string
|
||||||
status: string
|
status: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +147,7 @@ export interface NodeSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NodeDetail extends NodeSummary {
|
export interface NodeDetail extends NodeSummary {
|
||||||
deployed: ComponentSummary[]
|
deployed: DeploymentSummary[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MeshStatus {
|
export interface MeshStatus {
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ async def get_logs(
|
|||||||
)
|
)
|
||||||
stdout, _ = await proc.communicate()
|
stdout, _ = await proc.communicate()
|
||||||
lines = (stdout or b"").decode().splitlines()
|
lines = (stdout or b"").decode().splitlines()
|
||||||
return {"component": name, "lines": lines}
|
return {"name": name, "lines": lines}
|
||||||
|
|
||||||
|
|
||||||
async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:
|
async def _follow_logs(unit: str, n: int) -> AsyncGenerator[str, None]:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class SystemdInfo(BaseModel):
|
|||||||
timer: bool = False
|
timer: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ComponentSummary(BaseModel):
|
class DeploymentSummary(BaseModel):
|
||||||
"""Summary of a single component."""
|
"""Summary of a single component."""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
@@ -37,7 +37,7 @@ class ComponentSummary(BaseModel):
|
|||||||
node: str | None = None
|
node: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ComponentDetail(ComponentSummary):
|
class DeploymentDetail(DeploymentSummary):
|
||||||
"""Full detail for a single component, including raw manifest."""
|
"""Full detail for a single component, including raw manifest."""
|
||||||
|
|
||||||
manifest: dict
|
manifest: dict
|
||||||
@@ -130,7 +130,7 @@ class GatewayRoute(BaseModel):
|
|||||||
|
|
||||||
path: str
|
path: str
|
||||||
target_port: int
|
target_port: int
|
||||||
component: str
|
program: str
|
||||||
node: str
|
node: str
|
||||||
|
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ class GatewayInfo(BaseModel):
|
|||||||
|
|
||||||
port: int
|
port: int
|
||||||
hostname: str
|
hostname: str
|
||||||
component_count: int
|
deployment_count: int
|
||||||
service_count: int
|
service_count: int
|
||||||
managed_count: int
|
managed_count: int
|
||||||
routes: list[GatewayRoute] = []
|
routes: list[GatewayRoute] = []
|
||||||
@@ -161,7 +161,7 @@ class NodeSummary(BaseModel):
|
|||||||
class NodeDetail(NodeSummary):
|
class NodeDetail(NodeSummary):
|
||||||
"""Full detail for a node, including its deployed components."""
|
"""Full detail for a node, including its deployed components."""
|
||||||
|
|
||||||
deployed: list[ComponentSummary] = []
|
deployed: list[DeploymentSummary] = []
|
||||||
|
|
||||||
|
|
||||||
class MeshStatus(BaseModel):
|
class MeshStatus(BaseModel):
|
||||||
@@ -179,6 +179,6 @@ class MeshStatus(BaseModel):
|
|||||||
class ServiceActionResponse(BaseModel):
|
class ServiceActionResponse(BaseModel):
|
||||||
"""Response from a service management action."""
|
"""Response from a service management action."""
|
||||||
|
|
||||||
component: str
|
program: str
|
||||||
action: str
|
action: str
|
||||||
status: str
|
status: str
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import logging
|
|||||||
import paho.mqtt.client as mqtt
|
import paho.mqtt.client as mqtt
|
||||||
|
|
||||||
from castle_core.registry import (
|
from castle_core.registry import (
|
||||||
DeployedComponent,
|
Deployment,
|
||||||
NodeConfig,
|
NodeConfig,
|
||||||
NodeRegistry,
|
NodeRegistry,
|
||||||
)
|
)
|
||||||
@@ -74,9 +74,9 @@ def _json_to_registry(payload: str) -> NodeRegistry:
|
|||||||
castle_root=node_data.get("castle_root"),
|
castle_root=node_data.get("castle_root"),
|
||||||
gateway_port=node_data.get("gateway_port", 9000),
|
gateway_port=node_data.get("gateway_port", 9000),
|
||||||
)
|
)
|
||||||
deployed: dict[str, DeployedComponent] = {}
|
deployed: dict[str, Deployment] = {}
|
||||||
for name, comp_data in data.get("deployed", {}).items():
|
for name, comp_data in data.get("deployed", {}).items():
|
||||||
deployed[name] = DeployedComponent(
|
deployed[name] = Deployment(
|
||||||
runner=comp_data.get("runner", "command"),
|
runner=comp_data.get("runner", "command"),
|
||||||
run_cmd=comp_data.get("run_cmd", []),
|
run_cmd=comp_data.get("run_cmd", []),
|
||||||
env=comp_data.get("env", {}),
|
env=comp_data.get("env", {}),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, HTTPException, Request, status
|
|||||||
|
|
||||||
from castle_api.config import get_registry, settings
|
from castle_api.config import get_registry, settings
|
||||||
from castle_api.mesh import mesh_state
|
from castle_api.mesh import mesh_state
|
||||||
from castle_api.models import ComponentSummary, MeshStatus, NodeDetail, NodeSummary
|
from castle_api.models import DeploymentSummary, MeshStatus, NodeDetail, NodeSummary
|
||||||
|
|
||||||
router = APIRouter(tags=["nodes"])
|
router = APIRouter(tags=["nodes"])
|
||||||
|
|
||||||
@@ -39,12 +39,12 @@ def _remote_node_summary(hostname: str, remote: object) -> NodeSummary:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSummary]:
|
def _deployed_to_summaries(registry: object, hostname: str) -> list[DeploymentSummary]:
|
||||||
"""Convert deployed components from a registry into ComponentSummary list."""
|
"""Convert deployed components from a registry into DeploymentSummary list."""
|
||||||
summaries = []
|
summaries = []
|
||||||
for name, d in registry.deployed.items():
|
for name, d in registry.deployed.items():
|
||||||
summaries.append(
|
summaries.append(
|
||||||
ComponentSummary(
|
DeploymentSummary(
|
||||||
id=name,
|
id=name,
|
||||||
category="job" if d.schedule else "service",
|
category="job" if d.schedule else "service",
|
||||||
description=d.description,
|
description=d.description,
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ async def program_action(name: str, action: str) -> dict:
|
|||||||
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
|
raise HTTPException(status_code=500, detail=result.output or f"{action} failed")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"component": result.component,
|
"program": result.program,
|
||||||
"action": result.action,
|
"action": result.action,
|
||||||
"status": result.status,
|
"status": result.status,
|
||||||
"output": result.output,
|
"output": result.output,
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ from castle_api.config import get_castle_root, get_registry
|
|||||||
from castle_api.mesh import mesh_state
|
from castle_api.mesh import mesh_state
|
||||||
from castle_api.health import check_all_health
|
from castle_api.health import check_all_health
|
||||||
from castle_api.models import (
|
from castle_api.models import (
|
||||||
ComponentDetail,
|
DeploymentDetail,
|
||||||
ComponentSummary,
|
DeploymentSummary,
|
||||||
GatewayInfo,
|
GatewayInfo,
|
||||||
GatewayRoute,
|
GatewayRoute,
|
||||||
JobDetail,
|
JobDetail,
|
||||||
@@ -47,8 +47,8 @@ def _declared_commands_dict(comp: ProgramSpec) -> dict[str, list[list[str]]] | N
|
|||||||
return out or None
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
|
def _summary_from_deployed(name: str, deployed: object) -> DeploymentSummary:
|
||||||
"""Build a ComponentSummary from a DeployedComponent."""
|
"""Build a DeploymentSummary from a Deployment."""
|
||||||
managed = deployed.managed
|
managed = deployed.managed
|
||||||
|
|
||||||
systemd_info: SystemdInfo | None = None
|
systemd_info: SystemdInfo | None = None
|
||||||
@@ -69,7 +69,7 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
|
|||||||
|
|
||||||
category = "job" if deployed.schedule else "service"
|
category = "job" if deployed.schedule else "service"
|
||||||
|
|
||||||
return ComponentSummary(
|
return DeploymentSummary(
|
||||||
id=name,
|
id=name,
|
||||||
category=category,
|
category=category,
|
||||||
description=deployed.description,
|
description=deployed.description,
|
||||||
@@ -88,8 +88,8 @@ def _summary_from_deployed(name: str, deployed: object) -> ComponentSummary:
|
|||||||
|
|
||||||
def _summary_from_service(
|
def _summary_from_service(
|
||||||
name: str, svc: ServiceSpec, config: object
|
name: str, svc: ServiceSpec, config: object
|
||||||
) -> ComponentSummary:
|
) -> DeploymentSummary:
|
||||||
"""Build a ComponentSummary from a ServiceSpec (non-deployed)."""
|
"""Build a DeploymentSummary from a ServiceSpec (non-deployed)."""
|
||||||
port = None
|
port = None
|
||||||
health_path = None
|
health_path = None
|
||||||
proxy_path = None
|
proxy_path = None
|
||||||
@@ -112,8 +112,8 @@ def _summary_from_service(
|
|||||||
description = svc.description
|
description = svc.description
|
||||||
source = None
|
source = None
|
||||||
stack = None
|
stack = None
|
||||||
if svc.component and svc.component in config.programs:
|
if svc.program and svc.program in config.programs:
|
||||||
comp = config.programs[svc.component]
|
comp = config.programs[svc.program]
|
||||||
if not description:
|
if not description:
|
||||||
description = comp.description
|
description = comp.description
|
||||||
source = comp.source
|
source = comp.source
|
||||||
@@ -121,7 +121,7 @@ def _summary_from_service(
|
|||||||
|
|
||||||
runner = svc.run.runner
|
runner = svc.run.runner
|
||||||
|
|
||||||
return ComponentSummary(
|
return DeploymentSummary(
|
||||||
id=name,
|
id=name,
|
||||||
category="service",
|
category="service",
|
||||||
description=description,
|
description=description,
|
||||||
@@ -137,8 +137,8 @@ def _summary_from_service(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSummary:
|
def _summary_from_job(name: str, job: JobSpec, config: object) -> DeploymentSummary:
|
||||||
"""Build a ComponentSummary from a JobSpec (non-deployed)."""
|
"""Build a DeploymentSummary from a JobSpec (non-deployed)."""
|
||||||
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
|
managed = bool(job.manage and job.manage.systemd and job.manage.systemd.enable)
|
||||||
|
|
||||||
systemd_info: SystemdInfo | None = None
|
systemd_info: SystemdInfo | None = None
|
||||||
@@ -150,14 +150,14 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
|
|||||||
description = job.description
|
description = job.description
|
||||||
source = None
|
source = None
|
||||||
stack = None
|
stack = None
|
||||||
if job.component and job.component in config.programs:
|
if job.program and job.program in config.programs:
|
||||||
comp = config.programs[job.component]
|
comp = config.programs[job.program]
|
||||||
if not description:
|
if not description:
|
||||||
description = comp.description
|
description = comp.description
|
||||||
source = comp.source
|
source = comp.source
|
||||||
stack = comp.stack
|
stack = comp.stack
|
||||||
|
|
||||||
return ComponentSummary(
|
return DeploymentSummary(
|
||||||
id=name,
|
id=name,
|
||||||
category="job",
|
category="job",
|
||||||
description=description,
|
description=description,
|
||||||
@@ -171,8 +171,8 @@ def _summary_from_job(name: str, job: JobSpec, config: object) -> ComponentSumma
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> ComponentSummary:
|
def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> DeploymentSummary:
|
||||||
"""Build a ComponentSummary from a ProgramSpec (tools/frontends)."""
|
"""Build a DeploymentSummary from a ProgramSpec (tools/frontends)."""
|
||||||
source = comp.source
|
source = comp.source
|
||||||
|
|
||||||
# Infer runner from source directory
|
# Infer runner from source directory
|
||||||
@@ -188,7 +188,7 @@ def _summary_from_program(name: str, comp: ProgramSpec, root: Path) -> Component
|
|||||||
if comp.source and (comp.stack or comp.commands):
|
if comp.source and (comp.stack or comp.commands):
|
||||||
installed = shutil.which(name) is not None
|
installed = shutil.which(name) is not None
|
||||||
|
|
||||||
return ComponentSummary(
|
return DeploymentSummary(
|
||||||
id=name,
|
id=name,
|
||||||
category="program",
|
category="program",
|
||||||
description=comp.description,
|
description=comp.description,
|
||||||
@@ -222,16 +222,16 @@ def _backfill_source(name: str, config: object) -> str | None:
|
|||||||
return config.programs[name].source
|
return config.programs[name].source
|
||||||
ref = None
|
ref = None
|
||||||
if name in config.services:
|
if name in config.services:
|
||||||
ref = config.services[name].component
|
ref = config.services[name].program
|
||||||
elif name in config.jobs:
|
elif name in config.jobs:
|
||||||
ref = config.jobs[name].component
|
ref = config.jobs[name].program
|
||||||
if ref and ref in config.programs:
|
if ref and ref in config.programs:
|
||||||
return config.programs[ref].source
|
return config.programs[ref].source
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
def _service_from_deployed(name: str, deployed: object) -> ServiceSummary:
|
||||||
"""Build a ServiceSummary from a DeployedComponent."""
|
"""Build a ServiceSummary from a Deployment."""
|
||||||
systemd_info = _make_systemd_info(name) if deployed.managed else None
|
systemd_info = _make_systemd_info(name) if deployed.managed else None
|
||||||
return ServiceSummary(
|
return ServiceSummary(
|
||||||
id=name,
|
id=name,
|
||||||
@@ -263,8 +263,8 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
|
|||||||
description = svc.description
|
description = svc.description
|
||||||
source = None
|
source = None
|
||||||
stack = None
|
stack = None
|
||||||
if svc.component and svc.component in config.programs:
|
if svc.program and svc.program in config.programs:
|
||||||
comp = config.programs[svc.component]
|
comp = config.programs[svc.program]
|
||||||
if not description:
|
if not description:
|
||||||
description = comp.description
|
description = comp.description
|
||||||
source = comp.source
|
source = comp.source
|
||||||
@@ -285,7 +285,7 @@ def _service_from_spec(name: str, svc: ServiceSpec, config: object) -> ServiceSu
|
|||||||
|
|
||||||
|
|
||||||
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
def _job_from_deployed(name: str, deployed: object) -> JobSummary:
|
||||||
"""Build a JobSummary from a DeployedComponent."""
|
"""Build a JobSummary from a Deployment."""
|
||||||
systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None
|
systemd_info = _make_systemd_info(name, timer=True) if deployed.managed else None
|
||||||
return JobSummary(
|
return JobSummary(
|
||||||
id=name,
|
id=name,
|
||||||
@@ -306,8 +306,8 @@ def _job_from_spec(name: str, job: JobSpec, config: object) -> JobSummary:
|
|||||||
description = job.description
|
description = job.description
|
||||||
source = None
|
source = None
|
||||||
stack = None
|
stack = None
|
||||||
if job.component and job.component in config.programs:
|
if job.program and job.program in config.programs:
|
||||||
comp = config.programs[job.component]
|
comp = config.programs[job.program]
|
||||||
if not description:
|
if not description:
|
||||||
description = comp.description
|
description = comp.description
|
||||||
source = comp.source
|
source = comp.source
|
||||||
@@ -636,15 +636,15 @@ def get_program(name: str) -> ProgramDetail:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/components", response_model=list[ComponentSummary])
|
@router.get("/deployments", response_model=list[DeploymentSummary])
|
||||||
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
def list_components(include_remote: bool = False) -> list[DeploymentSummary]:
|
||||||
"""List all components — deployed from registry, non-deployed from castle.yaml.
|
"""List all components — deployed from registry, non-deployed from castle.yaml.
|
||||||
|
|
||||||
Pass ?include_remote=true to include components from remote mesh nodes.
|
Pass ?include_remote=true to include components from remote mesh nodes.
|
||||||
"""
|
"""
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
local_hostname = registry.node.hostname
|
local_hostname = registry.node.hostname
|
||||||
summaries: list[ComponentSummary] = []
|
summaries: list[DeploymentSummary] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
|
|
||||||
# Deployed components from registry
|
# Deployed components from registry
|
||||||
@@ -686,9 +686,9 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
|||||||
# Check if a service/job references a program
|
# Check if a service/job references a program
|
||||||
ref = None
|
ref = None
|
||||||
if s.id in config.services:
|
if s.id in config.services:
|
||||||
ref = config.services[s.id].component
|
ref = config.services[s.id].program
|
||||||
elif s.id in config.jobs:
|
elif s.id in config.jobs:
|
||||||
ref = config.jobs[s.id].component
|
ref = config.jobs[s.id].program
|
||||||
if ref and ref in config.programs:
|
if ref and ref in config.programs:
|
||||||
s.source = config.programs[ref].source
|
s.source = config.programs[ref].source
|
||||||
|
|
||||||
@@ -708,7 +708,7 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
|||||||
for name, d in remote.registry.deployed.items():
|
for name, d in remote.registry.deployed.items():
|
||||||
if name not in seen:
|
if name not in seen:
|
||||||
summaries.append(
|
summaries.append(
|
||||||
ComponentSummary(
|
DeploymentSummary(
|
||||||
id=name,
|
id=name,
|
||||||
category="job" if d.schedule else "service",
|
category="job" if d.schedule else "service",
|
||||||
description=d.description,
|
description=d.description,
|
||||||
@@ -728,8 +728,8 @@ def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
|||||||
return summaries
|
return summaries
|
||||||
|
|
||||||
|
|
||||||
@router.get("/components/{name}", response_model=ComponentDetail)
|
@router.get("/deployments/{name}", response_model=DeploymentDetail)
|
||||||
def get_component(name: str) -> ComponentDetail:
|
def get_component(name: str) -> DeploymentDetail:
|
||||||
"""Get detailed info for a single component."""
|
"""Get detailed info for a single component."""
|
||||||
registry = get_registry()
|
registry = get_registry()
|
||||||
|
|
||||||
@@ -749,9 +749,9 @@ def get_component(name: str) -> ComponentDetail:
|
|||||||
else:
|
else:
|
||||||
ref = None
|
ref = None
|
||||||
if name in config.services:
|
if name in config.services:
|
||||||
ref = config.services[name].component
|
ref = config.services[name].program
|
||||||
elif name in config.jobs:
|
elif name in config.jobs:
|
||||||
ref = config.jobs[name].component
|
ref = config.jobs[name].program
|
||||||
if ref and ref in config.programs:
|
if ref and ref in config.programs:
|
||||||
summary.source = config.programs[ref].source
|
summary.source = config.programs[ref].source
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -768,7 +768,7 @@ def get_component(name: str) -> ComponentDetail:
|
|||||||
"behavior": deployed.behavior,
|
"behavior": deployed.behavior,
|
||||||
"stack": deployed.stack,
|
"stack": deployed.stack,
|
||||||
}
|
}
|
||||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
return DeploymentDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
# Fall back to castle.yaml
|
# Fall back to castle.yaml
|
||||||
root = get_castle_root()
|
root = get_castle_root()
|
||||||
@@ -781,23 +781,23 @@ def get_component(name: str) -> ComponentDetail:
|
|||||||
svc = config.services[name]
|
svc = config.services[name]
|
||||||
summary = _summary_from_service(name, svc, config)
|
summary = _summary_from_service(name, svc, config)
|
||||||
raw = svc.model_dump(mode="json", exclude_none=True)
|
raw = svc.model_dump(mode="json", exclude_none=True)
|
||||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
return DeploymentDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
if name in config.jobs:
|
if name in config.jobs:
|
||||||
job = config.jobs[name]
|
job = config.jobs[name]
|
||||||
summary = _summary_from_job(name, job, config)
|
summary = _summary_from_job(name, job, config)
|
||||||
raw = job.model_dump(mode="json", exclude_none=True)
|
raw = job.model_dump(mode="json", exclude_none=True)
|
||||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
return DeploymentDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
if name in config.programs:
|
if name in config.programs:
|
||||||
comp = config.programs[name]
|
comp = config.programs[name]
|
||||||
summary = _summary_from_program(name, comp, root)
|
summary = _summary_from_program(name, comp, root)
|
||||||
raw = comp.model_dump(mode="json", exclude_none=True)
|
raw = comp.model_dump(mode="json", exclude_none=True)
|
||||||
return ComponentDetail(**summary.model_dump(), manifest=raw)
|
return DeploymentDetail(**summary.model_dump(), manifest=raw)
|
||||||
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Component '{name}' not found",
|
detail=f"'{name}' not found",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -822,7 +822,7 @@ def get_gateway() -> GatewayInfo:
|
|||||||
GatewayRoute(
|
GatewayRoute(
|
||||||
path=d.proxy_path,
|
path=d.proxy_path,
|
||||||
target_port=d.port,
|
target_port=d.port,
|
||||||
component=name,
|
program=name,
|
||||||
node=registry.node.hostname,
|
node=registry.node.hostname,
|
||||||
)
|
)
|
||||||
for name, d in registry.deployed.items()
|
for name, d in registry.deployed.items()
|
||||||
@@ -838,7 +838,7 @@ def get_gateway() -> GatewayInfo:
|
|||||||
GatewayRoute(
|
GatewayRoute(
|
||||||
path=d.proxy_path,
|
path=d.proxy_path,
|
||||||
target_port=d.port,
|
target_port=d.port,
|
||||||
component=name,
|
program=name,
|
||||||
node=hostname,
|
node=hostname,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -848,7 +848,7 @@ def get_gateway() -> GatewayInfo:
|
|||||||
return GatewayInfo(
|
return GatewayInfo(
|
||||||
port=registry.node.gateway_port,
|
port=registry.node.gateway_port,
|
||||||
hostname=registry.node.hostname,
|
hostname=registry.node.hostname,
|
||||||
component_count=deployed_count,
|
deployment_count=deployed_count,
|
||||||
service_count=service_count,
|
service_count=service_count,
|
||||||
managed_count=managed_count,
|
managed_count=managed_count,
|
||||||
routes=routes,
|
routes=routes,
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ async def _do_action(name: str, action: str) -> JSONResponse:
|
|||||||
asyncio.create_task(_deferred_systemctl(action, unit))
|
asyncio.create_task(_deferred_systemctl(action, unit))
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=202,
|
status_code=202,
|
||||||
content={"component": name, "action": action, "status": "accepted"},
|
content={"program": name, "action": action, "status": "accepted"},
|
||||||
)
|
)
|
||||||
|
|
||||||
ok, output = await _systemctl(action, unit)
|
ok, output = await _systemctl(action, unit)
|
||||||
@@ -120,7 +120,7 @@ async def _do_action(name: str, action: str) -> JSONResponse:
|
|||||||
await _broadcast_health_with_override(name, unit_status)
|
await _broadcast_health_with_override(name, unit_status)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={"component": name, "action": action, "status": unit_status},
|
content={"program": name, "action": action, "status": unit_status},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from fastapi.testclient import TestClient
|
|||||||
import castle_api.config as api_config
|
import castle_api.config as api_config
|
||||||
from castle_api.main import app
|
from castle_api.main import app
|
||||||
from castle_core.registry import (
|
from castle_core.registry import (
|
||||||
DeployedComponent,
|
Deployment,
|
||||||
NodeConfig,
|
NodeConfig,
|
||||||
NodeRegistry,
|
NodeRegistry,
|
||||||
save_registry,
|
save_registry,
|
||||||
@@ -50,7 +50,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"test-svc": {
|
"test-svc": {
|
||||||
"component": "test-svc-comp",
|
"program": "test-svc-comp",
|
||||||
"description": "Test service",
|
"description": "Test service",
|
||||||
"run": {
|
"run": {
|
||||||
"runner": "python",
|
"runner": "python",
|
||||||
@@ -92,7 +92,7 @@ def registry_path(tmp_path: Path, castle_root: Path) -> Generator[Path, None, No
|
|||||||
gateway_port=9000,
|
gateway_port=9000,
|
||||||
),
|
),
|
||||||
deployed={
|
deployed={
|
||||||
"test-svc": DeployedComponent(
|
"test-svc": Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["uv", "run", "test-svc"],
|
run_cmd=["uv", "run", "test-svc"],
|
||||||
env={
|
env={
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ class TestComponents:
|
|||||||
|
|
||||||
def test_list_components(self, client: TestClient) -> None:
|
def test_list_components(self, client: TestClient) -> None:
|
||||||
"""Returns all registered components."""
|
"""Returns all registered components."""
|
||||||
response = client.get("/components")
|
response = client.get("/deployments")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
names = [c["id"] for c in data]
|
names = [c["id"] for c in data]
|
||||||
@@ -27,7 +27,7 @@ class TestComponents:
|
|||||||
|
|
||||||
def test_service_has_port(self, client: TestClient) -> None:
|
def test_service_has_port(self, client: TestClient) -> None:
|
||||||
"""Service component includes port info."""
|
"""Service component includes port info."""
|
||||||
response = client.get("/components")
|
response = client.get("/deployments")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
svc = next(c for c in data if c["id"] == "test-svc")
|
svc = next(c for c in data if c["id"] == "test-svc")
|
||||||
assert svc["port"] == 19000
|
assert svc["port"] == 19000
|
||||||
@@ -38,7 +38,7 @@ class TestComponents:
|
|||||||
|
|
||||||
def test_tool_has_no_port(self, client: TestClient) -> None:
|
def test_tool_has_no_port(self, client: TestClient) -> None:
|
||||||
"""Tool component has no port."""
|
"""Tool component has no port."""
|
||||||
response = client.get("/components")
|
response = client.get("/deployments")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
tool = next(c for c in data if c["id"] == "test-tool")
|
tool = next(c for c in data if c["id"] == "test-tool")
|
||||||
assert tool["port"] is None
|
assert tool["port"] is None
|
||||||
@@ -46,19 +46,19 @@ class TestComponents:
|
|||||||
|
|
||||||
def test_job_has_schedule(self, client: TestClient) -> None:
|
def test_job_has_schedule(self, client: TestClient) -> None:
|
||||||
"""Job component has schedule."""
|
"""Job component has schedule."""
|
||||||
response = client.get("/components")
|
response = client.get("/deployments")
|
||||||
data = response.json()
|
data = response.json()
|
||||||
job = next(c for c in data if c["id"] == "test-job")
|
job = next(c for c in data if c["id"] == "test-job")
|
||||||
assert job["behavior"] == "tool"
|
assert job["behavior"] == "tool"
|
||||||
assert job["schedule"] == "0 2 * * *"
|
assert job["schedule"] == "0 2 * * *"
|
||||||
|
|
||||||
|
|
||||||
class TestComponentDetail:
|
class TestDeploymentDetail:
|
||||||
"""Component detail endpoint tests."""
|
"""Component detail endpoint tests."""
|
||||||
|
|
||||||
def test_get_component(self, client: TestClient) -> None:
|
def test_get_component(self, client: TestClient) -> None:
|
||||||
"""Returns detailed info for a component."""
|
"""Returns detailed info for a component."""
|
||||||
response = client.get("/components/test-svc")
|
response = client.get("/deployments/test-svc")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["id"] == "test-svc"
|
assert data["id"] == "test-svc"
|
||||||
@@ -67,7 +67,7 @@ class TestComponentDetail:
|
|||||||
|
|
||||||
def test_not_found(self, client: TestClient) -> None:
|
def test_not_found(self, client: TestClient) -> None:
|
||||||
"""Returns 404 for unknown component."""
|
"""Returns 404 for unknown component."""
|
||||||
response = client.get("/components/nonexistent")
|
response = client.get("/deployments/nonexistent")
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
@@ -245,7 +245,7 @@ class TestGateway:
|
|||||||
assert data["port"] == 9000
|
assert data["port"] == 9000
|
||||||
assert data["hostname"] == "test-node"
|
assert data["hostname"] == "test-node"
|
||||||
# Registry has 1 deployed component (test-svc)
|
# Registry has 1 deployed component (test-svc)
|
||||||
assert data["component_count"] == 1
|
assert data["deployment_count"] == 1
|
||||||
assert data["service_count"] == 1
|
assert data["service_count"] == 1
|
||||||
assert data["managed_count"] == 1
|
assert data["managed_count"] == 1
|
||||||
|
|
||||||
@@ -258,7 +258,7 @@ class TestGateway:
|
|||||||
route = routes[0]
|
route = routes[0]
|
||||||
assert route["path"] == "/test-svc"
|
assert route["path"] == "/test-svc"
|
||||||
assert route["target_port"] == 19000
|
assert route["target_port"] == 19000
|
||||||
assert route["component"] == "test-svc"
|
assert route["program"] == "test-svc"
|
||||||
assert route["node"] == "test-node"
|
assert route["node"] == "test-node"
|
||||||
|
|
||||||
def test_gateway_routes_sorted(self, client: TestClient) -> None:
|
def test_gateway_routes_sorted(self, client: TestClient) -> None:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
|
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||||
|
|
||||||
from castle_api.mesh import STALE_TTL_SECONDS, MeshStateManager, RemoteNode
|
from castle_api.mesh import STALE_TTL_SECONDS, MeshStateManager, RemoteNode
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ class TestMeshStateManager:
|
|||||||
mgr.update_node("devbox", _make_registry("devbox"))
|
mgr.update_node("devbox", _make_registry("devbox"))
|
||||||
new_reg = _make_registry(
|
new_reg = _make_registry(
|
||||||
"devbox",
|
"devbox",
|
||||||
{"svc": DeployedComponent(runner="python", run_cmd=["svc"])},
|
{"svc": Deployment(runner="python", run_cmd=["svc"])},
|
||||||
)
|
)
|
||||||
mgr.update_node("devbox", new_reg)
|
mgr.update_node("devbox", new_reg)
|
||||||
node = mgr.get_node("devbox")
|
node = mgr.get_node("devbox")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
|
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||||
|
|
||||||
from castle_api.mqtt_client import _json_to_registry, _registry_to_json
|
from castle_api.mqtt_client import _json_to_registry, _registry_to_json
|
||||||
|
|
||||||
@@ -11,7 +11,7 @@ def _make_registry() -> NodeRegistry:
|
|||||||
return NodeRegistry(
|
return NodeRegistry(
|
||||||
node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000),
|
node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000),
|
||||||
deployed={
|
deployed={
|
||||||
"my-svc": DeployedComponent(
|
"my-svc": Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["uv", "run", "my-svc"],
|
run_cmd=["uv", "run", "my-svc"],
|
||||||
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
|
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
|
||||||
@@ -23,7 +23,7 @@ def _make_registry() -> NodeRegistry:
|
|||||||
proxy_path="/my-svc",
|
proxy_path="/my-svc",
|
||||||
managed=True,
|
managed=True,
|
||||||
),
|
),
|
||||||
"my-job": DeployedComponent(
|
"my-job": Deployment(
|
||||||
runner="command",
|
runner="command",
|
||||||
run_cmd=["my-job"],
|
run_cmd=["my-job"],
|
||||||
behavior="tool",
|
behavior="tool",
|
||||||
@@ -75,7 +75,7 @@ class TestRegistrySerialization:
|
|||||||
reg = NodeRegistry(
|
reg = NodeRegistry(
|
||||||
node=NodeConfig(hostname="minimal"),
|
node=NodeConfig(hostname="minimal"),
|
||||||
deployed={
|
deployed={
|
||||||
"bare": DeployedComponent(runner="command", run_cmd=["bare"]),
|
"bare": Deployment(runner="command", run_cmd=["bare"]),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
restored = _json_to_registry(_registry_to_json(reg))
|
restored = _json_to_registry(_registry_to_json(reg))
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
|
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||||
|
|
||||||
from castle_api.mesh import MeshStateManager
|
from castle_api.mesh import MeshStateManager
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ class TestNodesList:
|
|||||||
remote_reg = NodeRegistry(
|
remote_reg = NodeRegistry(
|
||||||
node=NodeConfig(hostname="devbox", gateway_port=9000),
|
node=NodeConfig(hostname="devbox", gateway_port=9000),
|
||||||
deployed={
|
deployed={
|
||||||
"remote-svc": DeployedComponent(
|
"remote-svc": Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["svc"],
|
run_cmd=["svc"],
|
||||||
port=9050,
|
port=9050,
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def run_create(args: argparse.Namespace) -> int:
|
|||||||
if behavior == "daemon":
|
if behavior == "daemon":
|
||||||
config.services[name] = ServiceSpec(
|
config.services[name] = ServiceSpec(
|
||||||
id=name,
|
id=name,
|
||||||
component=name,
|
program=name,
|
||||||
run=RunPython(runner="python", program=name),
|
run=RunPython(runner="python", program=name),
|
||||||
expose=ExposeSpec(
|
expose=ExposeSpec(
|
||||||
http=HttpExposeSpec(
|
http=HttpExposeSpec(
|
||||||
|
|||||||
@@ -66,10 +66,10 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
stack = None
|
stack = None
|
||||||
if program and program.stack:
|
if program and program.stack:
|
||||||
stack = program.stack
|
stack = program.stack
|
||||||
elif service and service.component and service.component in config.programs:
|
elif service and service.program and service.program in config.programs:
|
||||||
stack = config.programs[service.component].stack
|
stack = config.programs[service.program].stack
|
||||||
elif job and job.component and job.component in config.programs:
|
elif job and job.program and job.program in config.programs:
|
||||||
stack = config.programs[job.component].stack
|
stack = config.programs[job.program].stack
|
||||||
if stack:
|
if stack:
|
||||||
print(f" {BOLD}stack{RESET}: {stack}")
|
print(f" {BOLD}stack{RESET}: {stack}")
|
||||||
|
|
||||||
@@ -88,12 +88,12 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
spec = service or job
|
spec = service or job
|
||||||
if spec:
|
if spec:
|
||||||
desc = spec.description
|
desc = spec.description
|
||||||
if not desc and spec.component and spec.component in config.programs:
|
if not desc and spec.program and spec.program in config.programs:
|
||||||
desc = config.programs[spec.component].description
|
desc = config.programs[spec.program].description
|
||||||
if desc and not (program and program.description == desc):
|
if desc and not (program and program.description == desc):
|
||||||
print(f" {BOLD}description{RESET}: {desc}")
|
print(f" {BOLD}description{RESET}: {desc}")
|
||||||
if spec.component:
|
if spec.program:
|
||||||
print(f" {BOLD}program{RESET}: {spec.component}")
|
print(f" {BOLD}program{RESET}: {spec.program}")
|
||||||
|
|
||||||
# Run spec
|
# Run spec
|
||||||
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
|
print(f" {BOLD}runner{RESET}: {spec.run.runner}")
|
||||||
@@ -149,8 +149,8 @@ def run_info(args: argparse.Namespace) -> int:
|
|||||||
source_dir = None
|
source_dir = None
|
||||||
if program and program.source_dir:
|
if program and program.source_dir:
|
||||||
source_dir = program.source_dir
|
source_dir = program.source_dir
|
||||||
elif spec and spec.component and spec.component in config.programs:
|
elif spec and spec.program and spec.program in config.programs:
|
||||||
source_dir = config.programs[spec.component].source_dir
|
source_dir = config.programs[spec.program].source_dir
|
||||||
|
|
||||||
if source_dir:
|
if source_dir:
|
||||||
claude_md = _find_claude_md(config.root, source_dir)
|
claude_md = _find_claude_md(config.root, source_dir)
|
||||||
@@ -191,10 +191,10 @@ def _info_json(
|
|||||||
stack = None
|
stack = None
|
||||||
if program and program.stack:
|
if program and program.stack:
|
||||||
stack = program.stack
|
stack = program.stack
|
||||||
elif service and service.component and service.component in config.programs:
|
elif service and service.program and service.program in config.programs:
|
||||||
stack = config.programs[service.component].stack
|
stack = config.programs[service.program].stack
|
||||||
elif job and job.component and job.component in config.programs:
|
elif job and job.program and job.program in config.programs:
|
||||||
stack = config.programs[job.component].stack
|
stack = config.programs[job.program].stack
|
||||||
if stack:
|
if stack:
|
||||||
data["stack"] = stack
|
data["stack"] = stack
|
||||||
|
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ def _resolve_stack(config: object, name: str) -> str | None:
|
|||||||
# Check services for program ref
|
# Check services for program ref
|
||||||
if name in config.services:
|
if name in config.services:
|
||||||
svc = config.services[name]
|
svc = config.services[name]
|
||||||
comp_name = svc.component
|
comp_name = svc.program
|
||||||
if comp_name and comp_name in config.programs:
|
if comp_name and comp_name in config.programs:
|
||||||
return config.programs[comp_name].stack
|
return config.programs[comp_name].stack
|
||||||
# Check jobs for program ref
|
# Check jobs for program ref
|
||||||
if name in config.jobs:
|
if name in config.jobs:
|
||||||
job = config.jobs[name]
|
job = config.jobs[name]
|
||||||
comp_name = job.component
|
comp_name = job.program
|
||||||
if comp_name and comp_name in config.programs:
|
if comp_name and comp_name in config.programs:
|
||||||
return config.programs[comp_name].stack
|
return config.programs[comp_name].stack
|
||||||
# Direct program
|
# Direct program
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from castle_core.config import ( # noqa: F401 — explicit re-exports for type
|
|||||||
)
|
)
|
||||||
from castle_core.registry import ( # noqa: F401
|
from castle_core.registry import ( # noqa: F401
|
||||||
REGISTRY_PATH,
|
REGISTRY_PATH,
|
||||||
DeployedComponent,
|
Deployment,
|
||||||
NodeConfig,
|
NodeConfig,
|
||||||
NodeRegistry,
|
NodeRegistry,
|
||||||
load_registry,
|
load_registry,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"test-svc": {
|
"test-svc": {
|
||||||
"component": "test-svc-comp",
|
"program": "test-svc-comp",
|
||||||
"description": "Test service",
|
"description": "Test service",
|
||||||
"run": {
|
"run": {
|
||||||
"runner": "python",
|
"runner": "python",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class TestCreateCommand:
|
|||||||
assert "my-api" in config.services
|
assert "my-api" in config.services
|
||||||
svc = config.services["my-api"]
|
svc = config.services["my-api"]
|
||||||
assert svc.expose.http.internal.port == 9050
|
assert svc.expose.http.internal.port == 9050
|
||||||
assert svc.component == "my-api"
|
assert svc.program == "my-api"
|
||||||
mock_save.assert_called_once()
|
mock_save.assert_called_once()
|
||||||
|
|
||||||
def test_create_tool(self, castle_root: Path, tmp_path: Path) -> None:
|
def test_create_tool(self, castle_root: Path, tmp_path: Path) -> None:
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ def _expand_units(
|
|||||||
|
|
||||||
services[name] = ServiceSpec(
|
services[name] = ServiceSpec(
|
||||||
id=name,
|
id=name,
|
||||||
component=name,
|
program=name,
|
||||||
run=run_spec,
|
run=run_spec,
|
||||||
expose=ExposeSpec(
|
expose=ExposeSpec(
|
||||||
http=HttpExposeSpec(
|
http=HttpExposeSpec(
|
||||||
@@ -272,7 +272,7 @@ def _expand_units(
|
|||||||
assert unit.argv is not None # guaranteed by validator
|
assert unit.argv is not None # guaranteed by validator
|
||||||
jobs[name] = JobSpec(
|
jobs[name] = JobSpec(
|
||||||
id=name,
|
id=name,
|
||||||
component=name,
|
program=name,
|
||||||
description=unit.description,
|
description=unit.description,
|
||||||
run=RunCommand(runner="command", argv=list(unit.argv)),
|
run=RunCommand(runner="command", argv=list(unit.argv)),
|
||||||
schedule=unit.schedule,
|
schedule=unit.schedule,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Deploy logic — bridge castle.yaml spec to runtime (~/.castle/).
|
"""Deploy logic — bridge castle.yaml spec to runtime (~/.castle/).
|
||||||
|
|
||||||
This module contains the core deploy logic shared by the CLI and API.
|
This module contains the core deploy logic shared by the CLI and API.
|
||||||
It reads castle.yaml, resolves services/jobs into DeployedComponents,
|
It reads castle.yaml, resolves services/jobs into Deployments,
|
||||||
writes the registry, generates systemd units and the Caddyfile, and
|
writes the registry, generates systemd units and the Caddyfile, and
|
||||||
copies frontend build outputs.
|
copies frontend build outputs.
|
||||||
"""
|
"""
|
||||||
@@ -31,7 +31,7 @@ from castle_core.generators.systemd import (
|
|||||||
from castle_core.manifest import JobSpec, ServiceSpec
|
from castle_core.manifest import JobSpec, ServiceSpec
|
||||||
from castle_core.registry import (
|
from castle_core.registry import (
|
||||||
REGISTRY_PATH,
|
REGISTRY_PATH,
|
||||||
DeployedComponent,
|
Deployment,
|
||||||
NodeConfig,
|
NodeConfig,
|
||||||
NodeRegistry,
|
NodeRegistry,
|
||||||
load_registry,
|
load_registry,
|
||||||
@@ -139,21 +139,21 @@ def _resolve_description(config: CastleConfig, spec: ServiceSpec | JobSpec) -> s
|
|||||||
"""Get description, falling through to program if referenced."""
|
"""Get description, falling through to program if referenced."""
|
||||||
if spec.description:
|
if spec.description:
|
||||||
return spec.description
|
return spec.description
|
||||||
if spec.component and spec.component in config.programs:
|
if spec.program and spec.program in config.programs:
|
||||||
return config.programs[spec.component].description
|
return config.programs[spec.program].description
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _build_deployed_service(
|
def _build_deployed_service(
|
||||||
config: CastleConfig, name: str, svc: ServiceSpec, messages: list[str]
|
config: CastleConfig, name: str, svc: ServiceSpec, messages: list[str]
|
||||||
) -> DeployedComponent:
|
) -> Deployment:
|
||||||
"""Build a DeployedComponent from a ServiceSpec."""
|
"""Build a Deployment from a ServiceSpec."""
|
||||||
run = svc.run
|
run = svc.run
|
||||||
# Env prefix and data dir are keyed by the program (component) the service
|
# Env prefix and data dir are keyed by the program (component) the service
|
||||||
# runs, not the service name — that's the prefix the program's settings read
|
# runs, not the service name — that's the prefix the program's settings read
|
||||||
# (e.g. job `protonmail-sync` runs program `protonmail` → PROTONMAIL_DATA_DIR).
|
# (e.g. job `protonmail-sync` runs program `protonmail` → PROTONMAIL_DATA_DIR).
|
||||||
# Falls back to the service name when no component is referenced.
|
# Falls back to the service name when no component is referenced.
|
||||||
config_key = svc.component or name
|
config_key = svc.program or name
|
||||||
prefix = _env_prefix(config_key)
|
prefix = _env_prefix(config_key)
|
||||||
env: dict[str, str] = {}
|
env: dict[str, str] = {}
|
||||||
|
|
||||||
@@ -182,7 +182,7 @@ def _build_deployed_service(
|
|||||||
env = resolve_env_vars(env)
|
env = resolve_env_vars(env)
|
||||||
|
|
||||||
# Ensure python tool is installed before resolving binary
|
# Ensure python tool is installed before resolving binary
|
||||||
_ensure_python_tool(config, svc.component, messages)
|
_ensure_python_tool(config, svc.program, messages)
|
||||||
|
|
||||||
# Build run_cmd
|
# Build run_cmd
|
||||||
run_cmd = _build_run_cmd(name, run, env, messages)
|
run_cmd = _build_run_cmd(name, run, env, messages)
|
||||||
@@ -194,13 +194,13 @@ def _build_deployed_service(
|
|||||||
|
|
||||||
# Resolve stack from referenced program
|
# Resolve stack from referenced program
|
||||||
stack = None
|
stack = None
|
||||||
if svc.component and svc.component in config.programs:
|
if svc.program and svc.program in config.programs:
|
||||||
stack = config.programs[svc.component].stack
|
stack = config.programs[svc.program].stack
|
||||||
|
|
||||||
# Remote services proxy to an external base_url
|
# Remote services proxy to an external base_url
|
||||||
base_url = getattr(run, "base_url", None)
|
base_url = getattr(run, "base_url", None)
|
||||||
|
|
||||||
return DeployedComponent(
|
return Deployment(
|
||||||
runner=run.runner,
|
runner=run.runner,
|
||||||
run_cmd=run_cmd,
|
run_cmd=run_cmd,
|
||||||
env=env,
|
env=env,
|
||||||
@@ -217,12 +217,12 @@ def _build_deployed_service(
|
|||||||
|
|
||||||
def _build_deployed_job(
|
def _build_deployed_job(
|
||||||
config: CastleConfig, name: str, job: JobSpec, messages: list[str]
|
config: CastleConfig, name: str, job: JobSpec, messages: list[str]
|
||||||
) -> DeployedComponent:
|
) -> Deployment:
|
||||||
"""Build a DeployedComponent from a JobSpec."""
|
"""Build a Deployment from a JobSpec."""
|
||||||
run = job.run
|
run = job.run
|
||||||
# Keyed by the program (component) the job runs, not the job name — see
|
# Keyed by the program (component) the job runs, not the job name — see
|
||||||
# _build_deployed_service for rationale. Falls back to the job name.
|
# _build_deployed_service for rationale. Falls back to the job name.
|
||||||
config_key = job.component or name
|
config_key = job.program or name
|
||||||
prefix = _env_prefix(config_key)
|
prefix = _env_prefix(config_key)
|
||||||
env: dict[str, str] = {}
|
env: dict[str, str] = {}
|
||||||
|
|
||||||
@@ -232,14 +232,14 @@ def _build_deployed_job(
|
|||||||
env.update(job.defaults.env)
|
env.update(job.defaults.env)
|
||||||
|
|
||||||
env = resolve_env_vars(env)
|
env = resolve_env_vars(env)
|
||||||
_ensure_python_tool(config, job.component, messages)
|
_ensure_python_tool(config, job.program, messages)
|
||||||
run_cmd = _build_run_cmd(name, run, env, messages)
|
run_cmd = _build_run_cmd(name, run, env, messages)
|
||||||
|
|
||||||
stack = None
|
stack = None
|
||||||
if job.component and job.component in config.programs:
|
if job.program and job.program in config.programs:
|
||||||
stack = config.programs[job.component].stack
|
stack = config.programs[job.program].stack
|
||||||
|
|
||||||
return DeployedComponent(
|
return Deployment(
|
||||||
runner=run.runner,
|
runner=run.runner,
|
||||||
run_cmd=run_cmd,
|
run_cmd=run_cmd,
|
||||||
env=env,
|
env=env,
|
||||||
@@ -273,33 +273,33 @@ def _python_tool_needs_install(program: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _ensure_python_tool(
|
def _ensure_python_tool(
|
||||||
config: CastleConfig, component: str | None, messages: list[str]
|
config: CastleConfig, program: str | None, messages: list[str]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Ensure a Python program's editable install is current."""
|
"""Ensure a Python program's editable install is current."""
|
||||||
if not component or component not in config.programs:
|
if not program or program not in config.programs:
|
||||||
return
|
return
|
||||||
comp = config.programs[component]
|
comp = config.programs[program]
|
||||||
if not comp.source or not comp.stack or not comp.stack.startswith("python"):
|
if not comp.source or not comp.stack or not comp.stack.startswith("python"):
|
||||||
return
|
return
|
||||||
source_dir = Path(comp.source)
|
source_dir = Path(comp.source)
|
||||||
if not source_dir.is_dir():
|
if not source_dir.is_dir():
|
||||||
messages.append(f"Warning: source not found: {source_dir}")
|
messages.append(f"Warning: source not found: {source_dir}")
|
||||||
return
|
return
|
||||||
if not _python_tool_needs_install(component):
|
if not _python_tool_needs_install(program):
|
||||||
return
|
return
|
||||||
pkg_spec = str(source_dir)
|
pkg_spec = str(source_dir)
|
||||||
if comp.install_extras:
|
if comp.install_extras:
|
||||||
pkg_spec += "[" + ",".join(comp.install_extras) + "]"
|
pkg_spec += "[" + ",".join(comp.install_extras) + "]"
|
||||||
messages.append(f"Installing {component} from {source_dir}...")
|
messages.append(f"Installing {program} from {source_dir}...")
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["uv", "tool", "install", "--editable", pkg_spec, "--force"],
|
["uv", "tool", "install", "--editable", pkg_spec, "--force"],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
messages.append(f"Error: {component} install failed:\n{result.stdout}{result.stderr}")
|
messages.append(f"Error: {program} install failed:\n{result.stdout}{result.stderr}")
|
||||||
else:
|
else:
|
||||||
messages.append(f"Installed {component}")
|
messages.append(f"Installed {program}")
|
||||||
|
|
||||||
|
|
||||||
def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[str]) -> list[str]:
|
def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[str]) -> list[str]:
|
||||||
@@ -354,7 +354,7 @@ def _build_run_cmd(name: str, run: object, env: dict[str, str], messages: list[s
|
|||||||
raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr]
|
raise ValueError(f"Unsupported runner: {run.runner}") # type: ignore[union-attr]
|
||||||
|
|
||||||
|
|
||||||
def _format_deployed(name: str, deployed: DeployedComponent) -> str:
|
def _format_deployed(name: str, deployed: Deployment) -> str:
|
||||||
"""Format deployment summary for a component."""
|
"""Format deployment summary for a component."""
|
||||||
parts = [name]
|
parts = [name]
|
||||||
if deployed.port:
|
if deployed.port:
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import shutil
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from castle_core.manifest import RestartPolicy, SystemdSpec
|
from castle_core.manifest import RestartPolicy, SystemdSpec
|
||||||
from castle_core.registry import DeployedComponent
|
from castle_core.registry import Deployment
|
||||||
|
|
||||||
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
|
SYSTEMD_USER_DIR = Path.home() / ".config" / "systemd" / "user"
|
||||||
UNIT_PREFIX = "castle-"
|
UNIT_PREFIX = "castle-"
|
||||||
@@ -74,7 +74,7 @@ def cron_to_interval_sec(cron: str) -> int | None:
|
|||||||
|
|
||||||
def generate_unit_from_deployed(
|
def generate_unit_from_deployed(
|
||||||
name: str,
|
name: str,
|
||||||
deployed: DeployedComponent,
|
deployed: Deployment,
|
||||||
systemd_spec: SystemdSpec | None = None,
|
systemd_spec: SystemdSpec | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a systemd unit from a deployed component (registry-based).
|
"""Generate a systemd unit from a deployed component (registry-based).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Annotated, Literal, Union
|
from typing import Annotated, Literal, Union
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, model_validator
|
||||||
|
|
||||||
EnvMap = dict[str, str]
|
EnvMap = dict[str, str]
|
||||||
|
|
||||||
@@ -247,8 +247,13 @@ class ProgramSpec(BaseModel):
|
|||||||
class ServiceSpec(BaseModel):
|
class ServiceSpec(BaseModel):
|
||||||
"""Long-running daemon deployment config."""
|
"""Long-running daemon deployment config."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
id: str = ""
|
id: str = ""
|
||||||
component: str | None = None
|
# The program this service deploys. (`component` accepted as a legacy alias.)
|
||||||
|
program: str | None = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("program", "component")
|
||||||
|
)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
run: RunSpec
|
run: RunSpec
|
||||||
@@ -274,8 +279,13 @@ class ServiceSpec(BaseModel):
|
|||||||
class JobSpec(BaseModel):
|
class JobSpec(BaseModel):
|
||||||
"""Scheduled task that runs periodically and exits."""
|
"""Scheduled task that runs periodically and exits."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
id: str = ""
|
id: str = ""
|
||||||
component: str | None = None
|
# The program this job runs. (`component` accepted as a legacy alias.)
|
||||||
|
program: str | None = Field(
|
||||||
|
default=None, validation_alias=AliasChoices("program", "component")
|
||||||
|
)
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
|
||||||
run: RunSpec
|
run: RunSpec
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class NodeConfig:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DeployedComponent:
|
class Deployment:
|
||||||
"""A component deployed on this node with resolved runtime config."""
|
"""A component deployed on this node with resolved runtime config."""
|
||||||
|
|
||||||
runner: str
|
runner: str
|
||||||
@@ -50,7 +50,7 @@ class NodeRegistry:
|
|||||||
"""What's deployed on this node."""
|
"""What's deployed on this node."""
|
||||||
|
|
||||||
node: NodeConfig
|
node: NodeConfig
|
||||||
deployed: dict[str, DeployedComponent] = field(default_factory=dict)
|
deployed: dict[str, Deployment] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
def load_registry(path: Path | None = None) -> NodeRegistry:
|
def load_registry(path: Path | None = None) -> NodeRegistry:
|
||||||
@@ -77,7 +77,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
|||||||
gateway_port=node_data.get("gateway_port", 9000),
|
gateway_port=node_data.get("gateway_port", 9000),
|
||||||
)
|
)
|
||||||
|
|
||||||
deployed: dict[str, DeployedComponent] = {}
|
deployed: dict[str, Deployment] = {}
|
||||||
for name, comp_data in data.get("deployed", {}).items():
|
for name, comp_data in data.get("deployed", {}).items():
|
||||||
# Support both old "category" and new "behavior" keys for migration
|
# Support both old "category" and new "behavior" keys for migration
|
||||||
behavior = comp_data.get("behavior")
|
behavior = comp_data.get("behavior")
|
||||||
@@ -92,7 +92,7 @@ def load_registry(path: Path | None = None) -> NodeRegistry:
|
|||||||
if category == "frontend"
|
if category == "frontend"
|
||||||
else category
|
else category
|
||||||
)
|
)
|
||||||
deployed[name] = DeployedComponent(
|
deployed[name] = Deployment(
|
||||||
runner=comp_data.get("runner", "command"),
|
runner=comp_data.get("runner", "command"),
|
||||||
run_cmd=comp_data.get("run_cmd", []),
|
run_cmd=comp_data.get("run_cmd", []),
|
||||||
env=comp_data.get("env", {}),
|
env=comp_data.get("env", {}),
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ _EXTRA_PATH_DIRS = [
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ActionResult:
|
class ActionResult:
|
||||||
"""Result of a stack lifecycle action."""
|
"""Result of a program lifecycle action."""
|
||||||
|
|
||||||
component: str
|
program: str
|
||||||
action: str
|
action: str
|
||||||
status: str # "ok" | "error"
|
status: str # "ok" | "error"
|
||||||
output: str = ""
|
output: str = ""
|
||||||
@@ -90,12 +90,12 @@ class StackHandler:
|
|||||||
result = await action_fn(name, comp, root)
|
result = await action_fn(name, comp, root)
|
||||||
if result.status != "ok":
|
if result.status != "ok":
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name,
|
program=name,
|
||||||
action="check",
|
action="check",
|
||||||
status="error",
|
status="error",
|
||||||
output=f"{action_name} failed:\n{result.output}",
|
output=f"{action_name} failed:\n{result.output}",
|
||||||
)
|
)
|
||||||
return ActionResult(component=name, action="check", status="ok")
|
return ActionResult(program=name, action="check", status="ok")
|
||||||
|
|
||||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
@@ -111,33 +111,33 @@ class PythonHandler(StackHandler):
|
|||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["uv", "sync"], src)
|
rc, output = await _run(["uv", "sync"], src)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="build", status="ok" if rc == 0 else "error", output=output
|
program=name, action="build", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
if not (src / "tests").exists():
|
if not (src / "tests").exists():
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="test", status="ok",
|
program=name, action="test", status="ok",
|
||||||
output="No tests directory found, skipping.",
|
output="No tests directory found, skipping.",
|
||||||
)
|
)
|
||||||
rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src)
|
rc, output = await _run(["uv", "run", "pytest", "tests/", "-v"], src)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="test", status="ok" if rc == 0 else "error", output=output
|
program=name, action="test", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["uv", "run", "ruff", "check", "."], src)
|
rc, output = await _run(["uv", "run", "ruff", "check", "."], src)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
program=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["uv", "run", "pyright"], src)
|
rc, output = await _run(["uv", "run", "pyright"], src)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
|
program=name, action="type-check", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
@@ -149,7 +149,7 @@ class PythonHandler(StackHandler):
|
|||||||
["uv", "tool", "install", "--editable", pkg_spec, "--force"], src
|
["uv", "tool", "install", "--editable", pkg_spec, "--force"], src
|
||||||
)
|
)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="install", status="ok" if rc == 0 else "error", output=output
|
program=name, action="install", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def uninstall(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
@@ -162,7 +162,7 @@ class PythonHandler(StackHandler):
|
|||||||
pkg_name = data.get("project", {}).get("name", pkg_name)
|
pkg_name = data.get("project", {}).get("name", pkg_name)
|
||||||
rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src)
|
rc, output = await _run(["uv", "tool", "uninstall", pkg_name], src)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="uninstall", status="ok" if rc == 0 else "error", output=output
|
program=name, action="uninstall", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -173,28 +173,28 @@ class ReactViteHandler(StackHandler):
|
|||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["pnpm", "build"], src)
|
rc, output = await _run(["pnpm", "build"], src)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="build", status="ok" if rc == 0 else "error", output=output
|
program=name, action="build", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def test(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["pnpm", "test"], src)
|
rc, output = await _run(["pnpm", "test"], src)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="test", status="ok" if rc == 0 else "error", output=output
|
program=name, action="test", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def lint(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["pnpm", "lint"], src)
|
rc, output = await _run(["pnpm", "lint"], src)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
program=name, action="lint", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def type_check(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
rc, output = await _run(["pnpm", "type-check"], src)
|
rc, output = await _run(["pnpm", "type-check"], src)
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="type-check", status="ok" if rc == 0 else "error", output=output
|
program=name, action="type-check", status="ok" if rc == 0 else "error", output=output
|
||||||
)
|
)
|
||||||
|
|
||||||
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def install(self, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
@@ -203,18 +203,18 @@ class ReactViteHandler(StackHandler):
|
|||||||
result = await self.build(name, comp, root)
|
result = await self.build(name, comp, root)
|
||||||
if result.status != "ok":
|
if result.status != "ok":
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="install", status="error",
|
program=name, action="install", status="error",
|
||||||
output=f"Build failed:\n{result.output}",
|
output=f"Build failed:\n{result.output}",
|
||||||
)
|
)
|
||||||
outputs = comp.build.outputs if comp.build else []
|
outputs = comp.build.outputs if comp.build else []
|
||||||
if not outputs:
|
if not outputs:
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="install", status="error",
|
program=name, action="install", status="error",
|
||||||
output="No build outputs configured.",
|
output="No build outputs configured.",
|
||||||
)
|
)
|
||||||
dist = _source_dir(comp, root) / outputs[0]
|
dist = _source_dir(comp, root) / outputs[0]
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="install", status="ok",
|
program=name, action="install", status="ok",
|
||||||
output=f"Built; served in place from {dist}",
|
output=f"Built; served in place from {dist}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ class ReactViteHandler(StackHandler):
|
|||||||
Deactivating one means dropping its gateway route — handled by removing the
|
Deactivating one means dropping its gateway route — handled by removing the
|
||||||
program from the registry, not by deleting build output."""
|
program from the registry, not by deleting build output."""
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="uninstall", status="ok",
|
program=name, action="uninstall", status="ok",
|
||||||
output=f"{name}: served in place; nothing to uninstall.",
|
output=f"{name}: served in place; nothing to uninstall.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -287,8 +287,8 @@ async def _run_declared(
|
|||||||
rc, output = await _run(argv, src)
|
rc, output = await _run(argv, src)
|
||||||
outputs.append(output)
|
outputs.append(output)
|
||||||
if rc != 0:
|
if rc != 0:
|
||||||
return ActionResult(component=name, action=verb, status="error", output="".join(outputs))
|
return ActionResult(program=name, action=verb, status="error", output="".join(outputs))
|
||||||
return ActionResult(component=name, action=verb, status="ok", output="".join(outputs))
|
return ActionResult(program=name, action=verb, status="ok", output="".join(outputs))
|
||||||
|
|
||||||
|
|
||||||
async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> ActionResult:
|
||||||
@@ -303,17 +303,17 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
|
|||||||
subs = [s for s in ("lint", "type-check", "test") if is_available(comp, s)]
|
subs = [s for s in ("lint", "type-check", "test") if is_available(comp, s)]
|
||||||
if not subs:
|
if not subs:
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="check", status="error",
|
program=name, action="check", status="error",
|
||||||
output="No checkable verbs available.",
|
output="No checkable verbs available.",
|
||||||
)
|
)
|
||||||
for sub in subs:
|
for sub in subs:
|
||||||
result = await run_action(sub, name, comp, root)
|
result = await run_action(sub, name, comp, root)
|
||||||
if result.status != "ok":
|
if result.status != "ok":
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action="check", status="error",
|
program=name, action="check", status="error",
|
||||||
output=f"{sub} failed:\n{result.output}",
|
output=f"{sub} failed:\n{result.output}",
|
||||||
)
|
)
|
||||||
return ActionResult(component=name, action="check", status="ok")
|
return ActionResult(program=name, action="check", status="ok")
|
||||||
|
|
||||||
# 1. Declared command overrides the stack default.
|
# 1. Declared command overrides the stack default.
|
||||||
declared = _declared_commands(comp, verb)
|
declared = _declared_commands(comp, verb)
|
||||||
@@ -321,7 +321,7 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
|
|||||||
try:
|
try:
|
||||||
src = _source_dir(comp, root)
|
src = _source_dir(comp, root)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return ActionResult(component=name, action=verb, status="error", output="No source directory")
|
return ActionResult(program=name, action=verb, status="error", output="No source directory")
|
||||||
return await _run_declared(name, verb, declared, src)
|
return await _run_declared(name, verb, declared, src)
|
||||||
|
|
||||||
# 2. Stack default.
|
# 2. Stack default.
|
||||||
@@ -333,7 +333,7 @@ async def run_action(verb: str, name: str, comp: ProgramSpec, root: Path) -> Act
|
|||||||
|
|
||||||
# 3. Unavailable.
|
# 3. Unavailable.
|
||||||
return ActionResult(
|
return ActionResult(
|
||||||
component=name, action=verb, status="error",
|
program=name, action=verb, status="error",
|
||||||
output=f"Verb '{verb}' is not available for '{name}' "
|
output=f"Verb '{verb}' is not available for '{name}' "
|
||||||
f"(no declared command and no stack handler provides it).",
|
f"(no declared command and no stack handler provides it).",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def castle_root(tmp_path: Path) -> Generator[Path, None, None]:
|
|||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"test-svc": {
|
"test-svc": {
|
||||||
"component": "test-svc-comp",
|
"program": "test-svc-comp",
|
||||||
"description": "Test service",
|
"description": "Test service",
|
||||||
"run": {
|
"run": {
|
||||||
"runner": "python",
|
"runner": "python",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
from castle_core.generators.caddyfile import generate_caddyfile_from_registry
|
||||||
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
|
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
@@ -22,7 +22,7 @@ def _isolate_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _make_registry(
|
def _make_registry(
|
||||||
deployed: dict[str, DeployedComponent] | None = None,
|
deployed: dict[str, Deployment] | None = None,
|
||||||
gateway_port: int = 9000,
|
gateway_port: int = 9000,
|
||||||
) -> NodeRegistry:
|
) -> NodeRegistry:
|
||||||
"""Create a test registry."""
|
"""Create a test registry."""
|
||||||
@@ -45,7 +45,7 @@ class TestCaddyfileFromRegistry:
|
|||||||
"""Caddyfile has reverse proxy routes for deployed services."""
|
"""Caddyfile has reverse proxy routes for deployed services."""
|
||||||
registry = _make_registry(
|
registry = _make_registry(
|
||||||
deployed={
|
deployed={
|
||||||
"test-svc": DeployedComponent(
|
"test-svc": Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["uv", "run", "test-svc"],
|
run_cmd=["uv", "run", "test-svc"],
|
||||||
port=19000,
|
port=19000,
|
||||||
@@ -61,7 +61,7 @@ class TestCaddyfileFromRegistry:
|
|||||||
"""Components without proxy_path are not in Caddyfile."""
|
"""Components without proxy_path are not in Caddyfile."""
|
||||||
registry = _make_registry(
|
registry = _make_registry(
|
||||||
deployed={
|
deployed={
|
||||||
"test-tool": DeployedComponent(
|
"test-tool": Deployment(
|
||||||
runner="command",
|
runner="command",
|
||||||
run_cmd=["test-tool"],
|
run_cmd=["test-tool"],
|
||||||
),
|
),
|
||||||
@@ -81,7 +81,7 @@ class TestCaddyfileFromRegistry:
|
|||||||
"""Service proxy routes appear before the dashboard catch-all."""
|
"""Service proxy routes appear before the dashboard catch-all."""
|
||||||
registry = _make_registry(
|
registry = _make_registry(
|
||||||
deployed={
|
deployed={
|
||||||
"test-svc": DeployedComponent(
|
"test-svc": Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["uv", "run", "test-svc"],
|
run_cmd=["uv", "run", "test-svc"],
|
||||||
port=19000,
|
port=19000,
|
||||||
@@ -98,13 +98,13 @@ class TestCaddyfileFromRegistry:
|
|||||||
"""Multiple services get separate proxy routes."""
|
"""Multiple services get separate proxy routes."""
|
||||||
registry = _make_registry(
|
registry = _make_registry(
|
||||||
deployed={
|
deployed={
|
||||||
"svc-a": DeployedComponent(
|
"svc-a": Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["uv", "run", "svc-a"],
|
run_cmd=["uv", "run", "svc-a"],
|
||||||
port=9001,
|
port=9001,
|
||||||
proxy_path="/svc-a",
|
proxy_path="/svc-a",
|
||||||
),
|
),
|
||||||
"svc-b": DeployedComponent(
|
"svc-b": Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["uv", "run", "svc-b"],
|
run_cmd=["uv", "run", "svc-b"],
|
||||||
port=9002,
|
port=9002,
|
||||||
@@ -126,14 +126,14 @@ class TestCaddyfileRemoteRegistries:
|
|||||||
"""Remote services get reverse_proxy entries to their hostname."""
|
"""Remote services get reverse_proxy entries to their hostname."""
|
||||||
local = _make_registry(
|
local = _make_registry(
|
||||||
deployed={
|
deployed={
|
||||||
"local-svc": DeployedComponent(
|
"local-svc": Deployment(
|
||||||
runner="python", run_cmd=["svc"], port=9001, proxy_path="/local"
|
runner="python", run_cmd=["svc"], port=9001, proxy_path="/local"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
remote = _make_registry(
|
remote = _make_registry(
|
||||||
deployed={
|
deployed={
|
||||||
"remote-svc": DeployedComponent(
|
"remote-svc": Deployment(
|
||||||
runner="python", run_cmd=["svc"], port=9050, proxy_path="/remote"
|
runner="python", run_cmd=["svc"], port=9050, proxy_path="/remote"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -148,14 +148,14 @@ class TestCaddyfileRemoteRegistries:
|
|||||||
"""If local and remote use the same path, local wins."""
|
"""If local and remote use the same path, local wins."""
|
||||||
local = _make_registry(
|
local = _make_registry(
|
||||||
deployed={
|
deployed={
|
||||||
"svc": DeployedComponent(
|
"svc": Deployment(
|
||||||
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
|
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
remote = _make_registry(
|
remote = _make_registry(
|
||||||
deployed={
|
deployed={
|
||||||
"svc": DeployedComponent(
|
"svc": Deployment(
|
||||||
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
|
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -169,7 +169,7 @@ class TestCaddyfileRemoteRegistries:
|
|||||||
"""No remote routes when remote_registries is None."""
|
"""No remote routes when remote_registries is None."""
|
||||||
local = _make_registry(
|
local = _make_registry(
|
||||||
deployed={
|
deployed={
|
||||||
"svc": DeployedComponent(
|
"svc": Deployment(
|
||||||
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
|
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class TestLoadConfig:
|
|||||||
"""Service references a component."""
|
"""Service references a component."""
|
||||||
config = load_config(castle_root)
|
config = load_config(castle_root)
|
||||||
svc = config.services["test-svc"]
|
svc = config.services["test-svc"]
|
||||||
assert svc.component == "test-svc-comp"
|
assert svc.program == "test-svc-comp"
|
||||||
|
|
||||||
def test_job_schedule(self, castle_root: Path) -> None:
|
def test_job_schedule(self, castle_root: Path) -> None:
|
||||||
"""Job has correct schedule."""
|
"""Job has correct schedule."""
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
from castle_core import deploy as deploy_mod
|
from castle_core import deploy as deploy_mod
|
||||||
from castle_core.deploy import _desired_unit_files, _prune_orphans
|
from castle_core.deploy import _desired_unit_files, _prune_orphans
|
||||||
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
|
from castle_core.registry import Deployment, NodeConfig, NodeRegistry
|
||||||
|
|
||||||
|
|
||||||
def _svc(managed: bool = True, schedule: str | None = None) -> DeployedComponent:
|
def _svc(managed: bool = True, schedule: str | None = None) -> Deployment:
|
||||||
return DeployedComponent(
|
return Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["x"],
|
run_cmd=["x"],
|
||||||
env={},
|
env={},
|
||||||
@@ -20,7 +20,7 @@ def _svc(managed: bool = True, schedule: str | None = None) -> DeployedComponent
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _registry(**deployed: DeployedComponent) -> NodeRegistry:
|
def _registry(**deployed: Deployment) -> NodeRegistry:
|
||||||
return NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000), deployed=dict(deployed))
|
return NodeRegistry(node=NodeConfig(castle_root="/x", gateway_port=9000), deployed=dict(deployed))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -82,10 +82,10 @@ class TestServiceSpec:
|
|||||||
"""Service can reference a component."""
|
"""Service can reference a component."""
|
||||||
s = ServiceSpec(
|
s = ServiceSpec(
|
||||||
id="svc",
|
id="svc",
|
||||||
component="my-component",
|
program="my-component",
|
||||||
run=RunPython(runner="python", program="svc"),
|
run=RunPython(runner="python", program="svc"),
|
||||||
)
|
)
|
||||||
assert s.component == "my-component"
|
assert s.program == "my-component"
|
||||||
|
|
||||||
def test_service_with_proxy(self) -> None:
|
def test_service_with_proxy(self) -> None:
|
||||||
"""Service with proxy spec."""
|
"""Service with proxy spec."""
|
||||||
@@ -139,11 +139,11 @@ class TestJobSpec:
|
|||||||
"""Job can reference a component."""
|
"""Job can reference a component."""
|
||||||
j = JobSpec(
|
j = JobSpec(
|
||||||
id="sync",
|
id="sync",
|
||||||
component="protonmail",
|
program="protonmail",
|
||||||
run=RunCommand(runner="command", argv=["protonmail", "sync"]),
|
run=RunCommand(runner="command", argv=["protonmail", "sync"]),
|
||||||
schedule="*/5 * * * *",
|
schedule="*/5 * * * *",
|
||||||
)
|
)
|
||||||
assert j.component == "protonmail"
|
assert j.program == "protonmail"
|
||||||
|
|
||||||
def test_job_requires_schedule(self) -> None:
|
def test_job_requires_schedule(self) -> None:
|
||||||
"""Job without schedule is invalid."""
|
"""Job without schedule is invalid."""
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ from castle_core.generators.systemd import (
|
|||||||
generate_unit_from_deployed,
|
generate_unit_from_deployed,
|
||||||
unit_name,
|
unit_name,
|
||||||
)
|
)
|
||||||
from castle_core.registry import DeployedComponent
|
from castle_core.registry import Deployment
|
||||||
|
|
||||||
|
|
||||||
class TestUnitName:
|
class TestUnitName:
|
||||||
@@ -24,7 +24,7 @@ class TestUnitFromDeployed:
|
|||||||
|
|
||||||
def test_contains_description(self) -> None:
|
def test_contains_description(self) -> None:
|
||||||
"""Unit file has service description."""
|
"""Unit file has service description."""
|
||||||
deployed = DeployedComponent(
|
deployed = Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||||
env={"TEST_SVC_PORT": "19000"},
|
env={"TEST_SVC_PORT": "19000"},
|
||||||
@@ -35,7 +35,7 @@ class TestUnitFromDeployed:
|
|||||||
|
|
||||||
def test_no_working_directory(self) -> None:
|
def test_no_working_directory(self) -> None:
|
||||||
"""Unit file has no WorkingDirectory (source/runtime separation)."""
|
"""Unit file has no WorkingDirectory (source/runtime separation)."""
|
||||||
deployed = DeployedComponent(
|
deployed = Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||||
env={},
|
env={},
|
||||||
@@ -46,7 +46,7 @@ class TestUnitFromDeployed:
|
|||||||
|
|
||||||
def test_contains_environment(self) -> None:
|
def test_contains_environment(self) -> None:
|
||||||
"""Unit file has environment variables from deployed config."""
|
"""Unit file has environment variables from deployed config."""
|
||||||
deployed = DeployedComponent(
|
deployed = Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||||
env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"},
|
env={"TEST_SVC_DATA_DIR": "/home/user/.castle/data/test-svc"},
|
||||||
@@ -57,7 +57,7 @@ class TestUnitFromDeployed:
|
|||||||
|
|
||||||
def test_contains_restart_policy(self) -> None:
|
def test_contains_restart_policy(self) -> None:
|
||||||
"""Unit file has restart configuration."""
|
"""Unit file has restart configuration."""
|
||||||
deployed = DeployedComponent(
|
deployed = Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||||
env={},
|
env={},
|
||||||
@@ -68,7 +68,7 @@ class TestUnitFromDeployed:
|
|||||||
|
|
||||||
def test_exec_start_from_run_cmd(self) -> None:
|
def test_exec_start_from_run_cmd(self) -> None:
|
||||||
"""Unit file ExecStart uses resolved run_cmd."""
|
"""Unit file ExecStart uses resolved run_cmd."""
|
||||||
deployed = DeployedComponent(
|
deployed = Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
run_cmd=["/home/user/.local/bin/uv", "run", "test-svc"],
|
||||||
env={},
|
env={},
|
||||||
@@ -79,7 +79,7 @@ class TestUnitFromDeployed:
|
|||||||
|
|
||||||
def test_basic_service(self) -> None:
|
def test_basic_service(self) -> None:
|
||||||
"""Generate a unit from a deployed component."""
|
"""Generate a unit from a deployed component."""
|
||||||
deployed = DeployedComponent(
|
deployed = Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
|
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
|
||||||
env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/home/user/.castle/data/my-svc"},
|
env={"MY_SVC_PORT": "9001", "MY_SVC_DATA_DIR": "/home/user/.castle/data/my-svc"},
|
||||||
@@ -95,7 +95,7 @@ class TestUnitFromDeployed:
|
|||||||
|
|
||||||
def test_scheduled_job(self) -> None:
|
def test_scheduled_job(self) -> None:
|
||||||
"""Scheduled component generates oneshot unit."""
|
"""Scheduled component generates oneshot unit."""
|
||||||
deployed = DeployedComponent(
|
deployed = Deployment(
|
||||||
runner="command",
|
runner="command",
|
||||||
run_cmd=["/home/user/.local/bin/my-job"],
|
run_cmd=["/home/user/.local/bin/my-job"],
|
||||||
env={},
|
env={},
|
||||||
@@ -108,7 +108,7 @@ class TestUnitFromDeployed:
|
|||||||
|
|
||||||
def test_no_repo_paths(self) -> None:
|
def test_no_repo_paths(self) -> None:
|
||||||
"""Generated units must not reference repo paths."""
|
"""Generated units must not reference repo paths."""
|
||||||
deployed = DeployedComponent(
|
deployed = Deployment(
|
||||||
runner="python",
|
runner="python",
|
||||||
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
|
run_cmd=["/home/user/.local/bin/uv", "run", "my-svc"],
|
||||||
env={"DATA_DIR": "/home/user/.castle/data/my-svc"},
|
env={"DATA_DIR": "/home/user/.castle/data/my-svc"},
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class TestUnitExpansion:
|
|||||||
assert svc.expose.http.health_path == "/health"
|
assert svc.expose.http.health_path == "/health"
|
||||||
assert svc.proxy.caddy.path_prefix == "/my-svc"
|
assert svc.proxy.caddy.path_prefix == "/my-svc"
|
||||||
assert svc.manage.systemd is not None
|
assert svc.manage.systemd is not None
|
||||||
assert svc.component == "my-svc"
|
assert svc.program == "my-svc"
|
||||||
|
|
||||||
def test_tool_creates_program_only(self, units_root: Path) -> None:
|
def test_tool_creates_program_only(self, units_root: Path) -> None:
|
||||||
config = load_config(units_root)
|
config = load_config(units_root)
|
||||||
@@ -108,7 +108,7 @@ class TestUnitExpansion:
|
|||||||
assert job.run.runner == "command"
|
assert job.run.runner == "command"
|
||||||
assert job.run.argv == ["my-job", "run"]
|
assert job.run.argv == ["my-job", "run"]
|
||||||
assert job.defaults.env["DATA_DIR"] == "/tmp/data"
|
assert job.defaults.env["DATA_DIR"] == "/tmp/data"
|
||||||
assert job.component == "my-job"
|
assert job.program == "my-job"
|
||||||
|
|
||||||
def test_service_without_path_prefix(self, tmp_path: Path) -> None:
|
def test_service_without_path_prefix(self, tmp_path: Path) -> None:
|
||||||
config_data = {
|
config_data = {
|
||||||
|
|||||||
@@ -4,6 +4,33 @@ How castle tracks, configures, and manages programs, services, and jobs.
|
|||||||
This is the central reference for `castle.yaml` structure and the registry
|
This is the central reference for `castle.yaml` structure and the registry
|
||||||
architecture.
|
architecture.
|
||||||
|
|
||||||
|
## Vocabulary (canonical)
|
||||||
|
|
||||||
|
Use these terms consistently across code, CLI, API, and docs.
|
||||||
|
|
||||||
|
- **program** — any project castle manages, regardless of what it does. The
|
||||||
|
software catalog (`programs:`). Every program has a **behavior** and an
|
||||||
|
optional **stack**. *("component" was the old name for program — don't use it.)*
|
||||||
|
- **behavior** — what a program *is*: `tool` (a CLI you invoke), `daemon` (a
|
||||||
|
long-running server), `frontend` (a web UI). A property of the program,
|
||||||
|
independent of whether/how it's deployed.
|
||||||
|
- **stack** — a creation-time toolchain + scaffold template (`python-cli`,
|
||||||
|
`python-fastapi`, `react-vite`). Optional; seeds a program's default dev
|
||||||
|
commands but isn't required at runtime.
|
||||||
|
- **service** — a program deployed as a long-running systemd `.service`
|
||||||
|
(`services:`).
|
||||||
|
- **job** — a program deployed as a scheduled systemd `.timer` (+ oneshot)
|
||||||
|
(`jobs:`).
|
||||||
|
- **deployment** — the umbrella for "a service or a job" (a program materialized
|
||||||
|
into the runtime). The registry's deployed entries are deployments.
|
||||||
|
|
||||||
|
**Two orthogonal axes.** *behavior* (tool/daemon/frontend) is **what** a program
|
||||||
|
is; *service/job* is **how/when** it's deployed. They're independent: a program
|
||||||
|
may have neither (a tool you just install), a **service** (always-on), or a
|
||||||
|
**job** (scheduled). A `daemon`-behavior program is usually deployed as a
|
||||||
|
service; a `tool`-behavior program may back a job or just be installed for
|
||||||
|
manual use.
|
||||||
|
|
||||||
## castle.yaml
|
## castle.yaml
|
||||||
|
|
||||||
The single source of truth. Lives at `~/.castle/castle.yaml`. Three top-level
|
The single source of truth. Lives at `~/.castle/castle.yaml`. Three top-level
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
> A stack is a template + conventions, not a runtime requirement. `castle create
|
> A stack is a template + conventions, not a runtime requirement. `castle create
|
||||||
> --stack python-cli` scaffolds from it and seeds the program's default dev-verb
|
> --stack python-cli` scaffolds from it and seeds the program's default dev-verb
|
||||||
> commands. An existing CLI adopted with `castle add` doesn't need this stack — it
|
> commands. An existing CLI adopted with `castle add` doesn't need this stack — it
|
||||||
> declares its own `commands:`. See @docs/component-registry.md for `commands:`,
|
> declares its own `commands:`. See @docs/registry.md for `commands:`,
|
||||||
> `stack:` (optional), and `repo:`.
|
> `stack:` (optional), and `repo:`.
|
||||||
|
|
||||||
How to build CLI tools following Unix philosophy.
|
How to build CLI tools following Unix philosophy.
|
||||||
@@ -344,4 +344,4 @@ programs:
|
|||||||
Tools live in the `programs:` section. If a tool also runs on a schedule,
|
Tools live in the `programs:` section. If a tool also runs on a schedule,
|
||||||
add a separate entry in the `jobs:` section referencing the component.
|
add a separate entry in the `jobs:` section referencing the component.
|
||||||
|
|
||||||
See @docs/component-registry.md for the full registry reference.
|
See @docs/registry.md for the full registry reference.
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
> A stack is a template + conventions, not a runtime requirement. `castle create
|
> A stack is a template + conventions, not a runtime requirement. `castle create
|
||||||
> --stack python-fastapi` scaffolds from it and seeds the program's default
|
> --stack python-fastapi` scaffolds from it and seeds the program's default
|
||||||
> dev-verb commands. An existing service adopted with `castle add` doesn't need
|
> dev-verb commands. An existing service adopted with `castle add` doesn't need
|
||||||
> this stack — it declares its own `commands:`. See @docs/component-registry.md
|
> this stack — it declares its own `commands:`. See @docs/registry.md
|
||||||
> for `commands:`, `stack:` (optional), and `repo:`.
|
> for `commands:`, `stack:` (optional), and `repo:`.
|
||||||
|
|
||||||
How to build Python web APIs as castle service components. Based on the
|
How to build Python web APIs as castle service components. Based on the
|
||||||
@@ -448,5 +448,5 @@ uv run ruff format . # Format
|
|||||||
castle create my-service --stack python-fastapi --description "Does something useful"
|
castle create my-service --stack python-fastapi --description "Does something useful"
|
||||||
```
|
```
|
||||||
|
|
||||||
See @docs/component-registry.md for manifest fields, castle.yaml structure,
|
See @docs/registry.md for manifest fields, castle.yaml structure,
|
||||||
and the full service lifecycle (enable, logs, gateway reload).
|
and the full service lifecycle (enable, logs, gateway reload).
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
> A stack is a template + conventions, not a runtime requirement. `castle create
|
> A stack is a template + conventions, not a runtime requirement. `castle create
|
||||||
> --stack react-vite` scaffolds from it and seeds the program's default dev-verb
|
> --stack react-vite` scaffolds from it and seeds the program's default dev-verb
|
||||||
> commands. An existing frontend adopted with `castle add` doesn't need this
|
> commands. An existing frontend adopted with `castle add` doesn't need this
|
||||||
> stack — it declares its own `commands:`. See @docs/component-registry.md for
|
> stack — it declares its own `commands:`. See @docs/registry.md for
|
||||||
> `commands:`, `stack:` (optional), and `repo:`.
|
> `commands:`, `stack:` (optional), and `repo:`.
|
||||||
|
|
||||||
How to build, serve, and manage web frontends as castle components.
|
How to build, serve, and manage web frontends as castle components.
|
||||||
@@ -152,7 +152,7 @@ services:
|
|||||||
caddy: { path_prefix: /app }
|
caddy: { path_prefix: /app }
|
||||||
```
|
```
|
||||||
|
|
||||||
See @docs/component-registry.md for the full registry reference.
|
See @docs/registry.md for the full registry reference.
|
||||||
|
|
||||||
## Serving with Caddy
|
## Serving with Caddy
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user