feat: Implement multi-node support with MQTT and mDNS for service discovery and coordination

This commit is contained in:
2026-02-23 02:30:12 -08:00
parent eeaa5045d0
commit 3343e955fd
29 changed files with 1878 additions and 35 deletions

View File

@@ -77,6 +77,50 @@ castle services start|stop # Start/stop everything
- **Data**: Service data lives in `/data/castle/<service-name>/`, passed via env var. - **Data**: Service data lives in `/data/castle/<service-name>/`, passed via env var.
- **Secrets**: `~/.castle/secrets/` — never in project directories. - **Secrets**: `~/.castle/secrets/` — never in project directories.
## API Endpoints (castle-api, port 9020)
Core:
- `GET /health` — Health check
- `GET /stream` — SSE stream (health, service-action, mesh events)
Components:
- `GET /components` — List all (add `?include_remote=true` for cross-node)
- `GET /components/{name}` — Component detail
- `GET /status` — Live health for all services
Gateway:
- `GET /gateway` — Gateway info with route table and hostname
- `GET /gateway/caddyfile` — Generated Caddyfile content
- `POST /gateway/reload` — Regenerate Caddyfile and reload Caddy
Nodes (mesh):
- `GET /nodes` — All known nodes (local + discovered remote)
- `GET /nodes/{hostname}` — Node detail with deployed components
Services:
- `POST /services/{name}/{action}` — start/stop/restart
- `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)
Multi-node discovery is disabled by default. Enable via env vars:
```bash
CASTLE_API_MQTT_ENABLED=true # Connect to MQTT broker
CASTLE_API_MQTT_HOST=localhost # Broker address
CASTLE_API_MQTT_PORT=1883 # Broker port
CASTLE_API_MDNS_ENABLED=true # Advertise/discover via mDNS
```
Key modules: `castle_api.mesh` (MeshStateManager), `castle_api.mqtt_client`
(paho-mqtt wrapper), `castle_api.mdns` (python-zeroconf wrapper).
## Per-Project Commands ## Per-Project Commands
All projects use **uv**. Commands run from each project's directory: All projects use **uv**. Commands run from each project's directory:

21
TODO.md
View File

@@ -3,8 +3,19 @@
- Remove devbox-connect. Instead, make it easy to copy an ssh tunnel command to expose all ports of all services to a remote box: `ssh -L 9000:localhost:9000 payne@dev.payne.io, etc. - Remove devbox-connect. Instead, make it easy to copy an ssh tunnel command to expose all ports of all services to a remote box: `ssh -L 9000:localhost:9000 payne@dev.payne.io, etc.
- Add a scripts dir? - Add a scripts dir?
- tools: use std-in/std-out unix philosophy - Component (software units) types:
- daemons or web-services: expose ports - python/golang/rust/bash tool: use std-in/std-out unix philosophy
- workers: just keep doing work in a background--usually watching the filesystem or a queue - python/golang/rust-daemon: Whatever
- jobs: scheduled tools or daemon requests - python web-service: RESTful, fastapi, SSE, etc.
- frontend: files served up by caddy - react-frontend
- python API
- Dependencies
- docker/podman (mqtt)
- all component system dependencies
- uv
- golang/rust/java/.net/whatever
- Add amplifier to make a component builder
- What's this about? "I need to add the MQTT env vars to the systemd unit. Let me add them via a drop-in override so the deploy command doesn't overwrite them."

View File

@@ -0,0 +1,136 @@
import { useMemo, useState } from "react"
import { Link } from "react-router-dom"
import { Globe, RefreshCw, FileText } from "lucide-react"
import type { GatewayInfo, HealthStatus } from "@/types"
import { useGatewayReload, useCaddyfile } from "@/services/api/hooks"
import { HealthBadge } from "./HealthBadge"
interface GatewayPanelProps {
gateway: GatewayInfo
statuses: HealthStatus[]
}
export function GatewayPanel({ gateway, statuses }: GatewayPanelProps) {
const statusMap = new Map(statuses.map((s) => [s.id, s]))
const { mutate: reload, isPending: reloading } = useGatewayReload()
const [showCaddyfile, setShowCaddyfile] = useState(false)
const { data: caddyfileData } = useCaddyfile(showCaddyfile)
const multiNode = useMemo(() => {
const nodes = new Set(gateway.routes.map((r) => r.node))
return nodes.size > 1
}, [gateway.routes])
return (
<section className="border border-[var(--border)] rounded-lg overflow-hidden bg-[var(--card)]">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border)]">
<div className="flex items-center gap-2">
<Globe size={16} className="text-[var(--primary)]" />
<h2 className="font-semibold">Gateway</h2>
<span className="text-sm text-[var(--muted)]">
{gateway.hostname} &middot; port {gateway.port} &middot; {gateway.routes.length} route{gateway.routes.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={() => reload()}
disabled={reloading}
className="flex items-center gap-1 text-xs px-2.5 py-1 rounded bg-[var(--border)] hover:bg-[var(--border)]/80 text-[var(--muted)] hover:text-[var(--foreground)] transition-colors disabled:opacity-40"
title="Regenerate Caddyfile and reload Caddy"
>
<RefreshCw size={12} className={reloading ? "animate-spin" : ""} />
Reload
</button>
<button
onClick={() => setShowCaddyfile((v) => !v)}
className={`flex items-center gap-1 text-xs px-2.5 py-1 rounded transition-colors ${
showCaddyfile
? "bg-[var(--primary)] text-white"
: "bg-[var(--border)] text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
title="View generated Caddyfile"
>
<FileText size={12} />
Caddyfile
</button>
</div>
</div>
{/* Route table */}
{gateway.routes.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-4 py-2 font-medium text-[var(--muted)]">Path</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Component</th>
<th className="px-4 py-2 font-medium text-[var(--muted)]">Port</th>
{multiNode && (
<th className="px-4 py-2 font-medium text-[var(--muted)]">Node</th>
)}
<th className="px-4 py-2 font-medium text-[var(--muted)]">Health</th>
</tr>
</thead>
<tbody>
{gateway.routes.map((route) => {
const health = statusMap.get(route.component)
return (
<tr
key={route.path}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-black/20 transition-colors"
>
<td className="px-4 py-2 font-mono text-[var(--primary)]">
{route.path}
</td>
<td className="px-4 py-2">
<Link
to={`/component/${route.component}`}
className="hover:text-[var(--primary)] transition-colors"
>
{route.component}
</Link>
</td>
<td className="px-4 py-2 font-mono text-[var(--muted)]">
{route.target_port}
</td>
{multiNode && (
<td className="px-4 py-2">
<Link
to={`/node/${route.node}`}
className="text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
>
{route.node}
</Link>
</td>
)}
<td className="px-4 py-2">
{health ? (
<HealthBadge status={health.status} latency={health.latency_ms} />
) : (
<HealthBadge status="unknown" />
)}
</td>
</tr>
)
})}
</tbody>
</table>
)}
{gateway.routes.length === 0 && (
<p className="px-4 py-6 text-center text-[var(--muted)] text-sm">
No proxy routes configured.
</p>
)}
{/* Caddyfile viewer */}
{showCaddyfile && caddyfileData && (
<div className="border-t border-[var(--border)]">
<pre className="px-4 py-3 text-xs font-mono text-[var(--muted)] overflow-x-auto max-h-64 overflow-y-auto">
{caddyfileData.content}
</pre>
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,67 @@
import { Link } from "react-router-dom"
import { Network, Wifi, WifiOff } from "lucide-react"
import { cn } from "@/lib/utils"
import type { MeshStatus } from "@/types"
interface MeshPanelProps {
mesh: MeshStatus
}
export function MeshPanel({ mesh }: MeshPanelProps) {
if (!mesh.enabled) return null
return (
<section className="border border-[var(--border)] rounded-lg overflow-hidden bg-[var(--card)]">
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-2">
<Network size={16} className="text-[var(--primary)]" />
<h2 className="font-semibold text-sm">Mesh</h2>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full",
mesh.mqtt_connected
? "bg-green-800/50 text-green-300"
: "bg-red-800/50 text-red-300",
)}
>
{mesh.mqtt_connected ? (
<Wifi size={10} />
) : (
<WifiOff size={10} />
)}
{mesh.mqtt_connected ? "connected" : "disconnected"}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-[var(--muted)]">
{mesh.mqtt_broker_host && (
<span className="font-mono">
mqtt://{mesh.mqtt_broker_host}:{mesh.mqtt_broker_port}
</span>
)}
{mesh.mdns_enabled && (
<span>mDNS active</span>
)}
<span>
{mesh.peer_count} peer{mesh.peer_count !== 1 ? "s" : ""}
</span>
</div>
</div>
{mesh.peers.length > 0 && (
<div className="border-t border-[var(--border)] px-4 py-2 flex items-center gap-2">
<span className="text-xs text-[var(--muted)]">Peers:</span>
{mesh.peers.map((peer) => (
<Link
key={peer}
to={`/node/${peer}`}
className="text-xs font-mono px-2 py-0.5 rounded bg-[var(--border)] hover:text-[var(--foreground)] transition-colors"
>
{peer}
</Link>
))}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,41 @@
import { Link } from "react-router-dom"
import { cn } from "@/lib/utils"
import type { NodeSummary } from "@/types"
interface NodeBarProps {
nodes: NodeSummary[]
}
export function NodeBar({ nodes }: NodeBarProps) {
// Only show when there are remote nodes
if (nodes.length <= 1) return null
return (
<div className="flex items-center gap-2 mb-6">
{nodes.map((node) => (
<Link
key={node.hostname}
to={`/node/${node.hostname}`}
className={cn(
"flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-md border transition-colors",
node.is_local
? "border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--foreground)]"
: "border-[var(--border)] bg-[var(--card)] text-[var(--muted)] hover:text-[var(--foreground)] hover:border-[var(--foreground)]/20",
node.is_stale && "opacity-50",
)}
>
<span
className={cn(
"w-2 h-2 rounded-full",
node.online && !node.is_stale ? "bg-green-400" : "bg-zinc-500",
)}
/>
<span className="font-medium">{node.hostname}</span>
{!node.is_local && node.deployed_count > 0 && (
<span className="text-xs text-[var(--muted)]">({node.deployed_count})</span>
)}
</Link>
))}
</div>
)
}

View File

@@ -1,6 +1,9 @@
import { useMemo } from "react" import { useMemo } from "react"
import { Link } from "react-router-dom" import { Link } from "react-router-dom"
import { useComponents, useStatus, useGateway, useEventStream } from "@/services/api/hooks" import { useComponents, useStatus, useGateway, useNodes, useMeshStatus, useEventStream } from "@/services/api/hooks"
import { GatewayPanel } from "@/components/GatewayPanel"
import { MeshPanel } from "@/components/MeshPanel"
import { NodeBar } from "@/components/NodeBar"
import { ServiceSection } from "@/components/ServiceSection" import { ServiceSection } from "@/components/ServiceSection"
import { JobSection } from "@/components/JobSection" import { JobSection } from "@/components/JobSection"
import { ToolSection } from "@/components/ToolSection" import { ToolSection } from "@/components/ToolSection"
@@ -11,6 +14,8 @@ export function Dashboard() {
const { data: components, isLoading } = useComponents() const { data: components, isLoading } = useComponents()
const { data: statusResp } = useStatus() const { data: statusResp } = useStatus()
const { data: gateway } = useGateway() const { data: gateway } = useGateway()
const { data: nodes } = useNodes()
const { data: mesh } = useMeshStatus()
const { services, jobs, tools, frontends, other } = useMemo(() => { const { services, jobs, tools, frontends, other } = useMemo(() => {
const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components } const s = { services: [] as typeof components, jobs: [] as typeof components, tools: [] as typeof components, frontends: [] as typeof components, other: [] as typeof components }
@@ -30,17 +35,23 @@ export function Dashboard() {
<div className="max-w-6xl mx-auto px-6 py-8"> <div className="max-w-6xl mx-auto px-6 py-8">
<div className="mb-8"> <div className="mb-8">
<h1 className="text-3xl font-bold">Castle</h1> <h1 className="text-3xl font-bold">Castle</h1>
<p className="text-[var(--muted)] mt-1"> <p className="text-[var(--muted)] mt-1">Personal software platform</p>
Personal software platform
{gateway && (
<span className="ml-2 text-sm">
&middot; {services.length} services &middot; {jobs.length} jobs
&middot; {tools.length} tools &middot; port {gateway.port}
</span>
)}
</p>
</div> </div>
{nodes && <NodeBar nodes={nodes} />}
{gateway && (
<div className="mb-6">
<GatewayPanel gateway={gateway} statuses={statuses} />
</div>
)}
{mesh && (
<div className="mb-10">
<MeshPanel mesh={mesh} />
</div>
)}
{isLoading ? ( {isLoading ? (
<p className="text-[var(--muted)]">Loading...</p> <p className="text-[var(--muted)]">Loading...</p>
) : ( ) : (

View File

@@ -0,0 +1,106 @@
import { useParams, Link } from "react-router-dom"
import { ArrowLeft, Server } from "lucide-react"
import { useNode } from "@/services/api/hooks"
import { RoleBadge } from "@/components/RoleBadge"
import { cn } from "@/lib/utils"
export function NodeDetailPage() {
const { hostname } = useParams<{ hostname: string }>()
const { data: node, isLoading, error } = useNode(hostname ?? "")
if (isLoading) {
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<p className="text-[var(--muted)]">Loading...</p>
</div>
)
}
if (error || !node) {
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<Link to="/" className="text-[var(--muted)] hover:text-[var(--foreground)] flex items-center gap-1 mb-4">
<ArrowLeft size={14} /> Back
</Link>
<p className="text-red-400">Node "{hostname}" not found.</p>
</div>
)
}
return (
<div className="max-w-4xl mx-auto px-6 py-8">
<Link to="/" className="text-[var(--muted)] hover:text-[var(--foreground)] flex items-center gap-1 mb-4">
<ArrowLeft size={14} /> Back
</Link>
<div className="mb-6">
<div className="flex items-center gap-3">
<Server size={20} className="text-[var(--primary)]" />
<h1 className="text-2xl font-bold">{node.hostname}</h1>
<span
className={cn(
"inline-flex items-center gap-1.5 text-xs px-2 py-0.5 rounded-full",
node.online ? "bg-green-800/50 text-green-300" : "bg-zinc-700/50 text-zinc-400",
)}
>
<span className={cn("w-1.5 h-1.5 rounded-full", node.online ? "bg-green-400" : "bg-zinc-500")} />
{node.online ? "online" : "offline"}
</span>
{node.is_local && (
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-800/50 text-blue-300">local</span>
)}
</div>
<p className="text-sm text-[var(--muted)] mt-1">
Gateway port {node.gateway_port} &middot; {node.deployed_count} deployed &middot; {node.service_count} services
</p>
</div>
{/* Deployed components table */}
{node.deployed.length > 0 ? (
<div className="border border-[var(--border)] rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-[var(--card)] border-b border-[var(--border)] text-left">
<th className="px-3 py-2 font-medium text-[var(--muted)]">Component</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Category</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Runner</th>
<th className="px-3 py-2 font-medium text-[var(--muted)]">Port</th>
</tr>
</thead>
<tbody>
{node.deployed.map((comp) => (
<tr
key={comp.id}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--card)]/50 transition-colors"
>
<td className="px-3 py-2.5">
<Link
to={`/component/${comp.id}`}
className="font-medium hover:text-[var(--primary)] transition-colors"
>
{comp.id}
</Link>
{comp.description && (
<p className="text-xs text-[var(--muted)] mt-0.5 truncate max-w-xs">{comp.description}</p>
)}
</td>
<td className="px-3 py-2.5">
<RoleBadge role={comp.category} />
</td>
<td className="px-3 py-2.5 text-[var(--muted)]">
{comp.runner ?? "—"}
</td>
<td className="px-3 py-2.5 font-mono text-[var(--muted)]">
{comp.port ?? "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-[var(--muted)]">No deployed components on this node.</p>
)}
</div>
)
}

View File

@@ -1,6 +1,7 @@
import { createBrowserRouter } from "react-router-dom" import { createBrowserRouter } from "react-router-dom"
import { Dashboard } from "@/pages/Dashboard" import { Dashboard } from "@/pages/Dashboard"
import { ComponentDetailPage } from "@/pages/ComponentDetail" import { ComponentDetailPage } from "@/pages/ComponentDetail"
import { NodeDetailPage } from "@/pages/NodeDetail"
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
@@ -11,4 +12,8 @@ export const router = createBrowserRouter([
path: "/component/:name", path: "/component/:name",
element: <ComponentDetailPage />, element: <ComponentDetailPage />,
}, },
{
path: "/node/:hostname",
element: <NodeDetailPage />,
},
]) ])

View File

@@ -8,6 +8,9 @@ import type {
GatewayInfo, GatewayInfo,
ServiceActionResponse, ServiceActionResponse,
SSEHealthEvent, SSEHealthEvent,
MeshStatus,
NodeSummary,
NodeDetail,
ToolSummary, ToolSummary,
ToolDetail, ToolDetail,
} from "@/types" } from "@/types"
@@ -107,6 +110,39 @@ export function useToolAction() {
}) })
} }
export function useGatewayReload() {
const qc = useQueryClient()
return useMutation({
mutationFn: () => apiClient.post<{ status: string }>("/gateway/reload"),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["gateway"] })
},
})
}
export function useNodes() {
return useQuery({
queryKey: ["nodes"],
queryFn: () => apiClient.get<NodeSummary[]>("/nodes"),
})
}
export function useNode(hostname: string) {
return useQuery({
queryKey: ["nodes", hostname],
queryFn: () => apiClient.get<NodeDetail>(`/nodes/${hostname}`),
enabled: !!hostname,
})
}
export function useMeshStatus() {
return useQuery({
queryKey: ["mesh"],
queryFn: () => apiClient.get<MeshStatus>("/mesh/status"),
refetchInterval: 30_000,
})
}
export function useTools() { export function useTools() {
return useQuery({ return useQuery({
queryKey: ["tools"], queryKey: ["tools"],
@@ -140,6 +176,13 @@ export function useEventStream() {
qc.invalidateQueries({ queryKey: ["components"] }) qc.invalidateQueries({ queryKey: ["components"] })
}) })
es.addEventListener("mesh", () => {
// A remote node updated or went offline — refresh mesh, nodes, and gateway
qc.invalidateQueries({ queryKey: ["mesh"] })
qc.invalidateQueries({ queryKey: ["nodes"] })
qc.invalidateQueries({ queryKey: ["gateway"] })
})
es.onerror = () => { es.onerror = () => {
// EventSource auto-reconnects; nothing to do // EventSource auto-reconnects; nothing to do
} }

View File

@@ -19,6 +19,7 @@ export interface ComponentSummary {
system_dependencies: string[] system_dependencies: string[]
schedule: string | null schedule: string | null
installed: boolean | null installed: boolean | null
node: string | null
} }
export interface ComponentDetail extends ComponentSummary { export interface ComponentDetail extends ComponentSummary {
@@ -35,11 +36,20 @@ export interface StatusResponse {
statuses: HealthStatus[] statuses: HealthStatus[]
} }
export interface GatewayRoute {
path: string
target_port: number
component: string
node: string
}
export interface GatewayInfo { export interface GatewayInfo {
port: number port: number
hostname: string
component_count: number component_count: number
service_count: number service_count: number
managed_count: number managed_count: number
routes: GatewayRoute[]
} }
export interface ServiceActionResponse { export interface ServiceActionResponse {
@@ -59,6 +69,31 @@ export interface SSEServiceActionEvent {
status: string status: string
} }
export interface NodeSummary {
hostname: string
gateway_port: number
deployed_count: number
service_count: number
is_local: boolean
online: boolean
is_stale: boolean
last_seen: number | null
}
export interface NodeDetail extends NodeSummary {
deployed: ComponentSummary[]
}
export interface MeshStatus {
enabled: boolean
mqtt_connected: boolean
mqtt_broker_host: string | null
mqtt_broker_port: number | null
mdns_enabled: boolean
peer_count: number
peers: string[]
}
export interface ToolSummary { export interface ToolSummary {
id: string id: string
description: string | null description: string | null

View File

@@ -9,6 +9,8 @@ dependencies = [
"pydantic-settings>=2.0.0", "pydantic-settings>=2.0.0",
"httpx>=0.27.0", "httpx>=0.27.0",
"castle-core", "castle-core",
"paho-mqtt>=2.0.0",
"zeroconf>=0.131.0",
] ]
[tool.uv.sources] [tool.uv.sources]

View File

@@ -14,6 +14,12 @@ class Settings(BaseSettings):
host: str = "0.0.0.0" host: str = "0.0.0.0"
port: int = 9020 port: int = 9020
# Mesh coordination (all off by default — single-node works without them)
mqtt_enabled: bool = False
mqtt_host: str = "localhost"
mqtt_port: int = 1883
mdns_enabled: bool = False
model_config = { model_config = {
"env_prefix": "CASTLE_API_", "env_prefix": "CASTLE_API_",
"env_file": ".env", "env_file": ".env",

View File

@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import logging
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -11,7 +12,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
from castle_api.config import settings from castle_api.config import get_registry, settings
from castle_api.config_editor import router as config_router from castle_api.config_editor import router as config_router
from castle_api.logs import router as logs_router from castle_api.logs import router as logs_router
from castle_api.routes import router as dashboard_router from castle_api.routes import router as dashboard_router
@@ -23,8 +24,11 @@ from castle_api.stream import (
subscribe, subscribe,
unsubscribe, unsubscribe,
) )
from castle_api.nodes import router as nodes_router
from castle_api.tools import router as tools_router from castle_api.tools import router as tools_router
logger = logging.getLogger(__name__)
# Set by _watch_shutdown when uvicorn begins its shutdown sequence. # Set by _watch_shutdown when uvicorn begins its shutdown sequence.
_shutting_down = False _shutting_down = False
@@ -46,10 +50,53 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
poll_task = asyncio.create_task(health_poll_loop()) poll_task = asyncio.create_task(health_poll_loop())
# --- Mesh coordination (opt-in) ---
mqtt_client = None
mdns_service = None
if settings.mqtt_enabled:
try:
from castle_api.mqtt_client import CastleMQTTClient
registry = get_registry()
mqtt_client = CastleMQTTClient(
local_hostname=registry.node.hostname,
local_registry=registry,
broker_host=settings.mqtt_host,
broker_port=settings.mqtt_port,
)
await mqtt_client.start()
app.state.mqtt_client = mqtt_client
except Exception:
logger.exception("Failed to start MQTT client")
mqtt_client = None
if settings.mdns_enabled:
try:
from castle_api.mdns import CastleMDNS
registry = get_registry()
mdns_service = CastleMDNS(
hostname=registry.node.hostname,
gateway_port=registry.node.gateway_port,
api_port=settings.port,
)
mdns_service.start()
app.state.mdns = mdns_service
except Exception:
logger.exception("Failed to start mDNS")
mdns_service = None
yield yield
_shutting_down = True _shutting_down = True
poll_task.cancel() poll_task.cancel()
if mqtt_client:
await mqtt_client.stop()
if mdns_service:
mdns_service.stop()
close_all_subscribers() close_all_subscribers()
@@ -70,6 +117,7 @@ app.add_middleware(
app.include_router(config_router) app.include_router(config_router)
app.include_router(dashboard_router) app.include_router(dashboard_router)
app.include_router(logs_router) app.include_router(logs_router)
app.include_router(nodes_router)
app.include_router(secrets_router) app.include_router(secrets_router)
app.include_router(services_router) app.include_router(services_router)
app.include_router(tools_router) app.include_router(tools_router)

View File

@@ -0,0 +1,130 @@
"""mDNS discovery for castle mesh — zero-config LAN peer and broker discovery.
Advertises this node as _castle._tcp and browses for:
- _castle._tcp — peer castle nodes
- _mqtt._tcp — MQTT broker address
"""
from __future__ import annotations
import logging
import socket
from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf
logger = logging.getLogger(__name__)
CASTLE_SERVICE_TYPE = "_castle._tcp.local."
MQTT_SERVICE_TYPE = "_mqtt._tcp.local."
class CastleMDNS:
"""Advertise this castle node and discover peers + MQTT broker via mDNS."""
def __init__(
self,
hostname: str,
gateway_port: int,
api_port: int,
) -> None:
self._hostname = hostname
self._gateway_port = gateway_port
self._api_port = api_port
self._zeroconf: Zeroconf | None = None
self._browsers: list[ServiceBrowser] = []
self._service_info: ServiceInfo | None = None
# Discovered state
self.peers: dict[str, dict] = {} # hostname -> {gateway_port, api_port, addresses}
self.mqtt_broker: dict | None = None # {host, port} or None
def _on_service_state_change(
self,
zeroconf: Zeroconf,
service_type: str,
name: str,
state_change: ServiceStateChange,
) -> None:
"""Handle discovered/removed services."""
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
info = zeroconf.get_service_info(service_type, name)
if info is None:
return
if service_type == CASTLE_SERVICE_TYPE:
self._handle_castle_peer(info)
elif service_type == MQTT_SERVICE_TYPE:
self._handle_mqtt_broker(info)
elif state_change == ServiceStateChange.Removed:
if service_type == CASTLE_SERVICE_TYPE:
# Extract hostname from service name (format: "hostname._castle._tcp.local.")
peer_hostname = name.replace(f".{CASTLE_SERVICE_TYPE}", "")
if peer_hostname != self._hostname and peer_hostname in self.peers:
del self.peers[peer_hostname]
logger.info("mDNS: peer %s removed", peer_hostname)
def _handle_castle_peer(self, info: ServiceInfo) -> None:
"""Process a discovered castle peer."""
props = {
k.decode() if isinstance(k, bytes) else k: v.decode() if isinstance(v, bytes) else v
for k, v in info.properties.items()
}
peer_hostname = props.get("hostname", "")
if not peer_hostname or peer_hostname == self._hostname:
return
addresses = [socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4]
self.peers[peer_hostname] = {
"gateway_port": int(props.get("gateway_port", 9000)),
"api_port": int(props.get("api_port", 9020)),
"addresses": addresses,
}
logger.info("mDNS: discovered peer %s at %s", peer_hostname, addresses)
def _handle_mqtt_broker(self, info: ServiceInfo) -> None:
"""Process a discovered MQTT broker."""
addresses = [socket.inet_ntoa(addr) for addr in info.addresses if len(addr) == 4]
if addresses:
self.mqtt_broker = {
"host": addresses[0],
"port": info.port,
}
logger.info("mDNS: discovered MQTT broker at %s:%d", addresses[0], info.port)
def start(self) -> None:
"""Start advertising and browsing."""
self._zeroconf = Zeroconf()
# Advertise ourselves
self._service_info = ServiceInfo(
CASTLE_SERVICE_TYPE,
f"{self._hostname}.{CASTLE_SERVICE_TYPE}",
port=self._gateway_port,
properties={
"hostname": self._hostname,
"gateway_port": str(self._gateway_port),
"api_port": str(self._api_port),
},
)
self._zeroconf.register_service(self._service_info)
logger.info("mDNS: advertising %s on port %d", self._hostname, self._gateway_port)
# Browse for peers and MQTT broker
self._browsers.append(
ServiceBrowser(self._zeroconf, CASTLE_SERVICE_TYPE, handlers=[self._on_service_state_change])
)
self._browsers.append(
ServiceBrowser(self._zeroconf, MQTT_SERVICE_TYPE, handlers=[self._on_service_state_change])
)
def stop(self) -> None:
"""Stop advertising and close."""
if self._zeroconf:
if self._service_info:
self._zeroconf.unregister_service(self._service_info)
self._zeroconf.close()
self._zeroconf = None
self._browsers.clear()
logger.info("mDNS: stopped")

View File

@@ -0,0 +1,76 @@
"""Mesh state manager — aggregates remote node registries."""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from castle_core.registry import NodeRegistry
logger = logging.getLogger(__name__)
# Remote registries older than this are considered stale.
STALE_TTL_SECONDS = 300 # 5 minutes
@dataclass
class RemoteNode:
"""A remote node's registry and metadata."""
registry: NodeRegistry
last_seen: float = field(default_factory=time.time)
online: bool = True
@property
def is_stale(self) -> bool:
return (time.time() - self.last_seen) > STALE_TTL_SECONDS
class MeshStateManager:
"""Singleton holding remote node state discovered via MQTT.
Thread-safe for reads from the FastAPI request handlers.
Mutations happen only from the MQTT callback task.
"""
def __init__(self) -> None:
self._nodes: dict[str, RemoteNode] = {}
def update_node(self, hostname: str, registry: NodeRegistry) -> None:
"""Add or update a remote node's registry."""
self._nodes[hostname] = RemoteNode(registry=registry)
logger.info("Mesh: updated node %s (%d deployed)", hostname, len(registry.deployed))
def set_offline(self, hostname: str) -> None:
"""Mark a node as offline (LWT received)."""
if hostname in self._nodes:
self._nodes[hostname].online = False
logger.info("Mesh: node %s went offline", hostname)
def remove_node(self, hostname: str) -> None:
"""Remove a node entirely."""
if self._nodes.pop(hostname, None):
logger.info("Mesh: removed node %s", hostname)
def get_node(self, hostname: str) -> RemoteNode | None:
"""Get a specific remote node."""
return self._nodes.get(hostname)
def all_nodes(self, *, include_stale: bool = False) -> dict[str, RemoteNode]:
"""Return all remote nodes, optionally filtering out stale ones."""
if include_stale:
return dict(self._nodes)
return {h: n for h, n in self._nodes.items() if not n.is_stale}
def prune_stale(self) -> list[str]:
"""Remove nodes that have gone stale. Returns list of pruned hostnames."""
pruned = [h for h, n in self._nodes.items() if n.is_stale]
for h in pruned:
del self._nodes[h]
logger.info("Mesh: pruned stale node %s", h)
return pruned
# Module-level singleton — imported by MQTT client and API routes.
mesh_state = MeshStateManager()

View File

@@ -28,6 +28,7 @@ class ComponentSummary(BaseModel):
system_dependencies: list[str] = [] system_dependencies: list[str] = []
schedule: str | None = None schedule: str | None = None
installed: bool | None = None installed: bool | None = None
node: str | None = None
class ComponentDetail(ComponentSummary): class ComponentDetail(ComponentSummary):
@@ -50,13 +51,55 @@ class StatusResponse(BaseModel):
statuses: list[HealthStatus] statuses: list[HealthStatus]
class GatewayRoute(BaseModel):
"""A single route in the gateway's reverse proxy table."""
path: str
target_port: int
component: str
node: str
class GatewayInfo(BaseModel): class GatewayInfo(BaseModel):
"""Gateway configuration summary.""" """Gateway configuration summary."""
port: int port: int
hostname: str
component_count: int component_count: int
service_count: int service_count: int
managed_count: int managed_count: int
routes: list[GatewayRoute] = []
class NodeSummary(BaseModel):
"""Summary of a discovered node in the mesh."""
hostname: str
gateway_port: int
deployed_count: int
service_count: int
is_local: bool = False
online: bool = True
is_stale: bool = False
last_seen: float | None = None
class NodeDetail(NodeSummary):
"""Full detail for a node, including its deployed components."""
deployed: list[ComponentSummary] = []
class MeshStatus(BaseModel):
"""Current state of the mesh coordination layer."""
enabled: bool = False
mqtt_connected: bool = False
mqtt_broker_host: str | None = None
mqtt_broker_port: int | None = None
mdns_enabled: bool = False
peer_count: int = 0
peers: list[str] = []
class ServiceActionResponse(BaseModel): class ServiceActionResponse(BaseModel):

View File

@@ -0,0 +1,255 @@
"""MQTT client for inter-node mesh coordination.
Topics:
castle/{hostname}/registry — retained JSON NodeRegistry, published on connect
castle/{hostname}/status — "online" (retained) / "offline" (LWT)
On incoming messages from other hostnames, updates MeshStateManager.
"""
from __future__ import annotations
import asyncio
import json
import logging
import paho.mqtt.client as mqtt
from castle_core.registry import (
DeployedComponent,
NodeConfig,
NodeRegistry,
)
from castle_api.mesh import mesh_state
from castle_api.stream import broadcast
logger = logging.getLogger(__name__)
def _registry_to_json(registry: NodeRegistry) -> str:
"""Serialize a NodeRegistry to JSON for MQTT publishing.
Only includes fields needed for mesh routing — env vars, run_cmd,
and castle_root are excluded to avoid leaking secrets.
"""
data: dict = {
"node": {
"hostname": registry.node.hostname,
"gateway_port": registry.node.gateway_port,
},
"deployed": {},
}
for name, comp in registry.deployed.items():
entry: dict = {
"runner": comp.runner,
"category": comp.category,
}
if comp.description:
entry["description"] = comp.description
if comp.port is not None:
entry["port"] = comp.port
if comp.health_path:
entry["health_path"] = comp.health_path
if comp.proxy_path:
entry["proxy_path"] = comp.proxy_path
if comp.schedule:
entry["schedule"] = comp.schedule
if comp.managed:
entry["managed"] = comp.managed
data["deployed"][name] = entry
return json.dumps(data)
def _json_to_registry(payload: str) -> NodeRegistry:
"""Deserialize a NodeRegistry from MQTT JSON payload."""
data = json.loads(payload)
node_data = data.get("node", {})
node = NodeConfig(
hostname=node_data.get("hostname", ""),
castle_root=node_data.get("castle_root"),
gateway_port=node_data.get("gateway_port", 9000),
)
deployed: dict[str, DeployedComponent] = {}
for name, comp_data in data.get("deployed", {}).items():
deployed[name] = DeployedComponent(
runner=comp_data.get("runner", "command"),
run_cmd=comp_data.get("run_cmd", []),
env=comp_data.get("env", {}),
description=comp_data.get("description"),
category=comp_data.get("category", "service"),
port=comp_data.get("port"),
health_path=comp_data.get("health_path"),
proxy_path=comp_data.get("proxy_path"),
schedule=comp_data.get("schedule"),
managed=comp_data.get("managed", False),
)
return NodeRegistry(node=node, deployed=deployed)
class CastleMQTTClient:
"""Async wrapper around paho-mqtt for castle mesh coordination."""
def __init__(
self,
local_hostname: str,
local_registry: NodeRegistry,
broker_host: str = "localhost",
broker_port: int = 1883,
loop: asyncio.AbstractEventLoop | None = None,
) -> None:
self._local_hostname = local_hostname
self._local_registry = local_registry
self._broker_host = broker_host
self._broker_port = broker_port
self._loop = loop or asyncio.get_event_loop()
self._client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=f"castle-{local_hostname}",
clean_session=True,
)
# LWT: if we disconnect unexpectedly, broker publishes "offline"
self._client.will_set(
f"castle/{local_hostname}/status",
payload="offline",
qos=1,
retain=True,
)
self._connected = False
self._client.on_connect = self._on_connect
self._client.on_disconnect = self._on_disconnect
self._client.on_message = self._on_message
@property
def connected(self) -> bool:
return self._connected
@property
def broker_host(self) -> str:
return self._broker_host
@property
def broker_port(self) -> int:
return self._broker_port
def _on_disconnect(
self,
client: mqtt.Client,
userdata: object,
flags: mqtt.DisconnectFlags,
rc: mqtt.ReasonCode,
properties: mqtt.Properties | None = None,
) -> None:
self._connected = False
logger.info("Disconnected from MQTT broker (rc=%s)", rc)
def _on_connect(
self,
client: mqtt.Client,
userdata: object,
flags: mqtt.ConnectFlags,
rc: mqtt.ReasonCode,
properties: mqtt.Properties | None = None,
) -> None:
"""Called when connected to broker — publish our state and subscribe."""
if rc.is_failure:
logger.error("MQTT connect failed: %s", rc)
self._connected = False
return
self._connected = True
logger.info("Connected to MQTT broker at %s:%d", self._broker_host, self._broker_port)
# Publish our status as online (retained)
client.publish(
f"castle/{self._local_hostname}/status",
payload="online",
qos=1,
retain=True,
)
# Publish our registry (retained)
self.publish_registry(self._local_registry)
# Subscribe to all castle nodes
client.subscribe("castle/+/registry", qos=1)
client.subscribe("castle/+/status", qos=1)
def _on_message(
self,
client: mqtt.Client,
userdata: object,
msg: mqtt.MQTTMessage,
) -> None:
"""Process incoming MQTT messages from other nodes."""
try:
parts = msg.topic.split("/")
if len(parts) != 3 or parts[0] != "castle":
return
hostname = parts[1]
msg_type = parts[2]
# Ignore our own messages
if hostname == self._local_hostname:
return
payload = msg.payload.decode()
if msg_type == "registry":
registry = _json_to_registry(payload)
mesh_state.update_node(hostname, registry)
# Notify SSE clients about mesh change
asyncio.run_coroutine_threadsafe(
broadcast("mesh", {"event": "node_updated", "hostname": hostname}),
self._loop,
)
elif msg_type == "status":
if payload == "offline":
mesh_state.set_offline(hostname)
asyncio.run_coroutine_threadsafe(
broadcast("mesh", {"event": "node_offline", "hostname": hostname}),
self._loop,
)
except Exception:
logger.exception("Error processing MQTT message on %s", msg.topic)
def publish_registry(self, registry: NodeRegistry) -> None:
"""Publish (or re-publish) our local registry."""
self._local_registry = registry
self._client.publish(
f"castle/{self._local_hostname}/registry",
payload=_registry_to_json(registry),
qos=1,
retain=True,
)
async def start(self) -> None:
"""Connect to the broker and start the network loop."""
self._client.connect_async(self._broker_host, self._broker_port)
self._client.loop_start()
logger.info(
"MQTT client starting (broker=%s:%d)",
self._broker_host,
self._broker_port,
)
async def stop(self) -> None:
"""Disconnect and stop the network loop."""
# Publish offline status before disconnecting
self._client.publish(
f"castle/{self._local_hostname}/status",
payload="offline",
qos=1,
retain=True,
)
self._client.loop_stop()
self._client.disconnect()
logger.info("MQTT client stopped")

View File

@@ -0,0 +1,115 @@
"""Nodes router — discover and inspect mesh nodes."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Request, status
from castle_api.config import get_registry, settings
from castle_api.mesh import mesh_state
from castle_api.models import ComponentSummary, MeshStatus, NodeDetail, NodeSummary
router = APIRouter(tags=["nodes"])
def _local_node_summary(registry: object) -> NodeSummary:
"""Build a NodeSummary for the local node from the registry."""
return NodeSummary(
hostname=registry.node.hostname,
gateway_port=registry.node.gateway_port,
deployed_count=len(registry.deployed),
service_count=sum(1 for d in registry.deployed.values() if d.port is not None),
is_local=True,
online=True,
is_stale=False,
)
def _remote_node_summary(hostname: str, remote: object) -> NodeSummary:
"""Build a NodeSummary from a RemoteNode."""
reg = remote.registry
return NodeSummary(
hostname=hostname,
gateway_port=reg.node.gateway_port,
deployed_count=len(reg.deployed),
service_count=sum(1 for d in reg.deployed.values() if d.port is not None),
is_local=False,
online=remote.online,
is_stale=remote.is_stale,
last_seen=remote.last_seen,
)
def _deployed_to_summaries(registry: object, hostname: str) -> list[ComponentSummary]:
"""Convert deployed components from a registry into ComponentSummary list."""
summaries = []
for name, d in registry.deployed.items():
summaries.append(
ComponentSummary(
id=name,
description=d.description,
category=d.category,
runner=d.runner,
port=d.port,
health_path=d.health_path,
proxy_path=d.proxy_path,
managed=d.managed,
schedule=d.schedule,
node=hostname,
)
)
return summaries
@router.get("/mesh/status", response_model=MeshStatus)
def get_mesh_status(request: Request) -> MeshStatus:
"""Get the current state of the mesh coordination layer."""
mqtt_client = getattr(request.app.state, "mqtt_client", None)
mdns = getattr(request.app.state, "mdns", None)
peers = list(mesh_state.all_nodes(include_stale=True).keys())
return MeshStatus(
enabled=settings.mqtt_enabled,
mqtt_connected=mqtt_client.connected if mqtt_client else False,
mqtt_broker_host=mqtt_client.broker_host if mqtt_client else None,
mqtt_broker_port=mqtt_client.broker_port if mqtt_client else None,
mdns_enabled=settings.mdns_enabled,
peer_count=len(peers),
peers=peers,
)
@router.get("/nodes", response_model=list[NodeSummary])
def list_nodes() -> list[NodeSummary]:
"""List all known nodes (local + discovered remote)."""
registry = get_registry()
nodes = [_local_node_summary(registry)]
for hostname, remote in mesh_state.all_nodes(include_stale=True).items():
nodes.append(_remote_node_summary(hostname, remote))
return nodes
@router.get("/nodes/{hostname}", response_model=NodeDetail)
def get_node(hostname: str) -> NodeDetail:
"""Get detailed info for a specific node."""
registry = get_registry()
# Local node
if hostname == registry.node.hostname:
summary = _local_node_summary(registry)
deployed = _deployed_to_summaries(registry, hostname)
return NodeDetail(**summary.model_dump(), deployed=deployed)
# Remote node
remote = mesh_state.get_node(hostname)
if remote is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Node '{hostname}' not found",
)
summary = _remote_node_summary(hostname, remote)
deployed = _deployed_to_summaries(remote.registry, hostname)
return NodeDetail(**summary.model_dump(), deployed=deployed)

View File

@@ -2,20 +2,24 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import shutil import shutil
from pathlib import Path from pathlib import Path
from fastapi import APIRouter, HTTPException, status from fastapi import APIRouter, HTTPException, status
from castle_core.config import GENERATED_DIR
from castle_core.generators.caddyfile import generate_caddyfile_from_registry from castle_core.generators.caddyfile import generate_caddyfile_from_registry
from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec from castle_core.manifest import ComponentSpec, JobSpec, ServiceSpec
from castle_api.config import get_castle_root, get_registry from castle_api.config import get_castle_root, get_registry
from castle_api.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, ComponentDetail,
ComponentSummary, ComponentSummary,
GatewayInfo, GatewayInfo,
GatewayRoute,
StatusResponse, StatusResponse,
SystemdInfo, SystemdInfo,
) )
@@ -173,15 +177,21 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
@router.get("/components", response_model=list[ComponentSummary]) @router.get("/components", response_model=list[ComponentSummary])
def list_components() -> list[ComponentSummary]: def list_components(include_remote: bool = False) -> list[ComponentSummary]:
"""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.
"""
registry = get_registry() registry = get_registry()
local_hostname = registry.node.hostname
summaries: list[ComponentSummary] = [] summaries: list[ComponentSummary] = []
seen: set[str] = set() seen: set[str] = set()
# Deployed components from registry # Deployed components from registry
for name, deployed in registry.deployed.items(): for name, deployed in registry.deployed.items():
summaries.append(_summary_from_deployed(name, deployed)) s = _summary_from_deployed(name, deployed)
s.node = local_hostname
summaries.append(s)
seen.add(name) seen.add(name)
# Non-deployed from castle.yaml (if repo available) # Non-deployed from castle.yaml (if repo available)
@@ -195,13 +205,17 @@ def list_components() -> list[ComponentSummary]:
# Services not in registry # Services not in registry
for name, svc in config.services.items(): for name, svc in config.services.items():
if name not in seen: if name not in seen:
summaries.append(_summary_from_service(name, svc, config)) s = _summary_from_service(name, svc, config)
s.node = local_hostname
summaries.append(s)
seen.add(name) seen.add(name)
# Jobs not in registry # Jobs not in registry
for name, job in config.jobs.items(): for name, job in config.jobs.items():
if name not in seen: if name not in seen:
summaries.append(_summary_from_job(name, job, config)) s = _summary_from_job(name, job, config)
s.node = local_hostname
summaries.append(s)
seen.add(name) seen.add(name)
# Backfill source from component refs for deployed items # Backfill source from component refs for deployed items
@@ -223,6 +237,7 @@ def list_components() -> list[ComponentSummary]:
# "what software exists", services/jobs are "how it runs". # "what software exists", services/jobs are "how it runs".
for name, comp in config.components.items(): for name, comp in config.components.items():
summary = _summary_from_component(name, comp, root) summary = _summary_from_component(name, comp, root)
summary.node = local_hostname
# Skip if this exact category is already represented # Skip if this exact category is already represented
# (e.g. a deployed tool already in the list) # (e.g. a deployed tool already in the list)
if not any(s.id == name and s.category == summary.category for s in summaries): if not any(s.id == name and s.category == summary.category for s in summaries):
@@ -230,6 +245,27 @@ def list_components() -> list[ComponentSummary]:
except FileNotFoundError: except FileNotFoundError:
pass pass
# Remote components from mesh (local wins on name conflicts)
if include_remote:
for hostname, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if name not in seen:
summaries.append(
ComponentSummary(
id=name,
description=d.description,
category=d.category,
runner=d.runner,
port=d.port,
health_path=d.health_path,
proxy_path=d.proxy_path,
managed=d.managed,
schedule=d.schedule,
node=hostname,
)
)
seen.add(name)
return summaries return summaries
@@ -320,11 +356,42 @@ def get_gateway() -> GatewayInfo:
deployed_count = len(registry.deployed) deployed_count = len(registry.deployed)
service_count = sum(1 for d in registry.deployed.values() if d.port is not None) service_count = sum(1 for d in registry.deployed.values() if d.port is not None)
managed_count = sum(1 for d in registry.deployed.values() if d.managed) managed_count = sum(1 for d in registry.deployed.values() if d.managed)
# Local routes
routes: list[GatewayRoute] = [
GatewayRoute(
path=d.proxy_path,
target_port=d.port,
component=name,
node=registry.node.hostname,
)
for name, d in registry.deployed.items()
if d.proxy_path and d.port
]
# Remote routes from mesh (local paths take precedence)
local_paths = {r.path for r in routes}
for hostname, remote in mesh_state.all_nodes().items():
for name, d in remote.registry.deployed.items():
if d.proxy_path and d.port and d.proxy_path not in local_paths:
routes.append(
GatewayRoute(
path=d.proxy_path,
target_port=d.port,
component=name,
node=hostname,
)
)
routes.sort(key=lambda r: r.path)
return GatewayInfo( return GatewayInfo(
port=registry.node.gateway_port, port=registry.node.gateway_port,
hostname=registry.node.hostname,
component_count=deployed_count, component_count=deployed_count,
service_count=service_count, service_count=service_count,
managed_count=managed_count, managed_count=managed_count,
routes=routes,
) )
@@ -333,3 +400,32 @@ def get_caddyfile() -> dict[str, str]:
"""Return the generated Caddyfile content.""" """Return the generated Caddyfile content."""
registry = get_registry() registry = get_registry()
return {"content": generate_caddyfile_from_registry(registry)} return {"content": generate_caddyfile_from_registry(registry)}
@router.post("/gateway/reload")
async def reload_gateway() -> dict[str, str]:
"""Regenerate Caddyfile and reload Caddy."""
registry = get_registry()
GENERATED_DIR.mkdir(parents=True, exist_ok=True)
caddyfile_path = GENERATED_DIR / "Caddyfile"
# Include remote registries for cross-node routing
remote_regs = {h: n.registry for h, n in mesh_state.all_nodes().items()}
caddyfile_path.write_text(
generate_caddyfile_from_registry(registry, remote_registries=remote_regs or None)
)
proc = await asyncio.create_subprocess_exec(
"systemctl", "--user", "reload", "castle-castle-gateway.service",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
_, stderr = await proc.communicate()
if proc.returncode != 0:
raise HTTPException(
status_code=500,
detail=f"Reload failed: {(stderr or b'').decode().strip()}",
)
return {"status": "ok"}

View File

@@ -80,7 +80,27 @@ class TestGateway:
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert data["port"] == 9000 assert data["port"] == 9000
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["component_count"] == 1
assert data["service_count"] == 1 assert data["service_count"] == 1
assert data["managed_count"] == 1 assert data["managed_count"] == 1
def test_gateway_routes(self, client: TestClient) -> None:
"""Returns proxy routes from deployed components."""
response = client.get("/gateway")
data = response.json()
routes = data["routes"]
assert len(routes) == 1
route = routes[0]
assert route["path"] == "/test-svc"
assert route["target_port"] == 19000
assert route["component"] == "test-svc"
assert route["node"] == "test-node"
def test_gateway_routes_sorted(self, client: TestClient) -> None:
"""Routes are sorted by path."""
response = client.get("/gateway")
data = response.json()
paths = [r["path"] for r in data["routes"]]
assert paths == sorted(paths)

View File

@@ -0,0 +1,104 @@
"""Tests for MeshStateManager."""
import time
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_api.mesh import STALE_TTL_SECONDS, MeshStateManager, RemoteNode
def _make_registry(hostname: str, deployed: dict | None = None) -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(hostname=hostname, gateway_port=9000),
deployed=deployed or {},
)
class TestRemoteNode:
"""RemoteNode staleness tracking."""
def test_fresh_node_not_stale(self) -> None:
node = RemoteNode(registry=_make_registry("a"))
assert not node.is_stale
def test_old_node_is_stale(self) -> None:
node = RemoteNode(
registry=_make_registry("a"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
assert node.is_stale
class TestMeshStateManager:
"""MeshStateManager add/remove/stale operations."""
def test_update_and_get(self) -> None:
mgr = MeshStateManager()
reg = _make_registry("devbox")
mgr.update_node("devbox", reg)
node = mgr.get_node("devbox")
assert node is not None
assert node.registry.node.hostname == "devbox"
assert node.online is True
def test_set_offline(self) -> None:
mgr = MeshStateManager()
mgr.update_node("devbox", _make_registry("devbox"))
mgr.set_offline("devbox")
node = mgr.get_node("devbox")
assert node is not None
assert node.online is False
def test_remove_node(self) -> None:
mgr = MeshStateManager()
mgr.update_node("devbox", _make_registry("devbox"))
mgr.remove_node("devbox")
assert mgr.get_node("devbox") is None
def test_remove_nonexistent_is_safe(self) -> None:
mgr = MeshStateManager()
mgr.remove_node("nope") # should not raise
def test_all_nodes_excludes_stale(self) -> None:
mgr = MeshStateManager()
mgr.update_node("fresh", _make_registry("fresh"))
mgr._nodes["stale"] = RemoteNode(
registry=_make_registry("stale"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
result = mgr.all_nodes()
assert "fresh" in result
assert "stale" not in result
def test_all_nodes_includes_stale_when_requested(self) -> None:
mgr = MeshStateManager()
mgr._nodes["stale"] = RemoteNode(
registry=_make_registry("stale"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
result = mgr.all_nodes(include_stale=True)
assert "stale" in result
def test_prune_stale(self) -> None:
mgr = MeshStateManager()
mgr.update_node("fresh", _make_registry("fresh"))
mgr._nodes["stale"] = RemoteNode(
registry=_make_registry("stale"),
last_seen=time.time() - STALE_TTL_SECONDS - 1,
)
pruned = mgr.prune_stale()
assert pruned == ["stale"]
assert mgr.get_node("stale") is None
assert mgr.get_node("fresh") is not None
def test_update_replaces_existing(self) -> None:
mgr = MeshStateManager()
mgr.update_node("devbox", _make_registry("devbox"))
new_reg = _make_registry(
"devbox",
{"svc": DeployedComponent(runner="python", run_cmd=["svc"])},
)
mgr.update_node("devbox", new_reg)
node = mgr.get_node("devbox")
assert node is not None
assert "svc" in node.registry.deployed

View File

@@ -0,0 +1,96 @@
"""Tests for MQTT client serialization logic."""
import json
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_api.mqtt_client import _json_to_registry, _registry_to_json
def _make_registry() -> NodeRegistry:
return NodeRegistry(
node=NodeConfig(hostname="tower", castle_root="/data/repos/castle", gateway_port=9000),
deployed={
"my-svc": DeployedComponent(
runner="python",
run_cmd=["uv", "run", "my-svc"],
env={"PORT": "9001", "SECRET_KEY": "super-secret"},
description="My service",
category="service",
port=9001,
health_path="/health",
proxy_path="/my-svc",
managed=True,
),
"my-job": DeployedComponent(
runner="command",
run_cmd=["my-job"],
category="job",
schedule="0 2 * * *",
),
},
)
class TestRegistrySerialization:
"""Round-trip serialization of NodeRegistry to/from JSON."""
def test_round_trip(self) -> None:
original = _make_registry()
json_str = _registry_to_json(original)
restored = _json_to_registry(json_str)
assert restored.node.hostname == "tower"
assert restored.node.gateway_port == 9000
def test_deployed_components_preserved(self) -> None:
original = _make_registry()
restored = _json_to_registry(_registry_to_json(original))
assert "my-svc" in restored.deployed
svc = restored.deployed["my-svc"]
assert svc.runner == "python"
assert svc.port == 9001
assert svc.health_path == "/health"
assert svc.proxy_path == "/my-svc"
assert svc.managed is True
def test_job_fields_preserved(self) -> None:
original = _make_registry()
restored = _json_to_registry(_registry_to_json(original))
assert "my-job" in restored.deployed
job = restored.deployed["my-job"]
assert job.runner == "command"
assert job.schedule == "0 2 * * *"
assert job.category == "job"
def test_optional_fields_omitted(self) -> None:
"""Fields like port, health_path are None when not set."""
reg = NodeRegistry(
node=NodeConfig(hostname="minimal"),
deployed={
"bare": DeployedComponent(runner="command", run_cmd=["bare"]),
},
)
restored = _json_to_registry(_registry_to_json(reg))
bare = restored.deployed["bare"]
assert bare.port is None
assert bare.health_path is None
assert bare.proxy_path is None
assert bare.schedule is None
assert bare.managed is False
def test_no_secrets_in_payload(self) -> None:
"""env vars, run_cmd, and castle_root must not appear in MQTT payload."""
original = _make_registry()
json_str = _registry_to_json(original)
data = json.loads(json_str)
# No castle_root in node
assert "castle_root" not in data["node"]
# No env or run_cmd in any component
for name, comp in data["deployed"].items():
assert "env" not in comp, f"{name} has env in MQTT payload"
assert "run_cmd" not in comp, f"{name} has run_cmd in MQTT payload"

View File

@@ -0,0 +1,91 @@
"""Tests for nodes endpoints."""
from pathlib import Path
from fastapi.testclient import TestClient
from castle_core.registry import DeployedComponent, NodeConfig, NodeRegistry
from castle_api.mesh import MeshStateManager
class TestNodesList:
"""GET /nodes endpoint tests."""
def test_returns_local_node(self, client: TestClient) -> None:
"""Always returns the local node."""
response = client.get("/nodes")
assert response.status_code == 200
data = response.json()
assert len(data) >= 1
local = data[0]
assert local["hostname"] == "test-node"
assert local["is_local"] is True
assert local["online"] is True
def test_local_node_counts(self, client: TestClient) -> None:
"""Local node has correct deployment counts."""
response = client.get("/nodes")
data = response.json()
local = data[0]
assert local["deployed_count"] == 1 # test-svc
assert local["service_count"] == 1
def test_includes_remote_nodes(self, client: TestClient, registry_path: Path) -> None:
"""Remote nodes from mesh state are included."""
import castle_api.mesh as mesh_mod
original = mesh_mod.mesh_state
try:
mgr = MeshStateManager()
remote_reg = NodeRegistry(
node=NodeConfig(hostname="devbox", gateway_port=9000),
deployed={
"remote-svc": DeployedComponent(
runner="python",
run_cmd=["svc"],
port=9050,
category="service",
),
},
)
mgr.update_node("devbox", remote_reg)
mesh_mod.mesh_state = mgr
# Also patch the reference in the nodes module
import castle_api.nodes as nodes_mod
nodes_mod.mesh_state = mgr
response = client.get("/nodes")
data = response.json()
hostnames = [n["hostname"] for n in data]
assert "devbox" in hostnames
devbox = next(n for n in data if n["hostname"] == "devbox")
assert devbox["is_local"] is False
assert devbox["deployed_count"] == 1
finally:
mesh_mod.mesh_state = original
import castle_api.nodes as nodes_mod2
nodes_mod2.mesh_state = original
class TestNodeDetail:
"""GET /nodes/{hostname} endpoint tests."""
def test_local_node_detail(self, client: TestClient) -> None:
"""Returns local node detail with deployed components."""
response = client.get("/nodes/test-node")
assert response.status_code == 200
data = response.json()
assert data["hostname"] == "test-node"
assert data["is_local"] is True
assert len(data["deployed"]) == 1
assert data["deployed"][0]["id"] == "test-svc"
assert data["deployed"][0]["node"] == "test-node"
def test_unknown_node_returns_404(self, client: TestClient) -> None:
"""Returns 404 for unknown hostname."""
response = client.get("/nodes/nonexistent")
assert response.status_code == 404

View File

@@ -215,6 +215,23 @@ services:
caddy: caddy:
path_prefix: /api path_prefix: /api
castle-mqtt:
description: MQTT broker for inter-node mesh coordination
run:
runner: container
image: eclipse-mosquitto:2
ports:
1883: 1883
volumes:
- /data/castle/castle-mqtt/config:/mosquitto/config
- /data/castle/castle-mqtt/data:/mosquitto/data
manage:
systemd: {}
expose:
http:
internal:
port: 1883
jobs: jobs:
protonmail-sync: protonmail-sync:
component: protonmail component: protonmail

View File

@@ -221,7 +221,7 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
cmd[0] = resolved cmd[0] = resolved
return cmd return cmd
case "container": case "container":
runtime = shutil.which("podman") or shutil.which("docker") or "podman" runtime = shutil.which("docker") or shutil.which("podman") or "docker"
image_name = run.image.split("/")[-1].split(":")[0] image_name = run.image.split("/")[-1].split(":")[0]
cmd = [runtime, "run", "--rm", f"--name=castle-{image_name}"] cmd = [runtime, "run", "--rm", f"--name=castle-{image_name}"]
for container_port, host_port in run.ports.items(): for container_port, host_port in run.ports.items():

View File

@@ -6,23 +6,49 @@ from castle_core.config import GENERATED_DIR, STATIC_DIR
from castle_core.registry import NodeRegistry from castle_core.registry import NodeRegistry
def generate_caddyfile_from_registry(registry: NodeRegistry) -> str: def generate_caddyfile_from_registry(
registry: NodeRegistry,
remote_registries: dict[str, NodeRegistry] | None = None,
) -> str:
"""Generate Caddyfile from the node registry. """Generate Caddyfile from the node registry.
Static files served from ~/.castle/static/castle-app/. Static files served from ~/.castle/static/castle-app/.
No repo-relative paths. No repo-relative paths.
If remote_registries is provided, cross-node routes are added for
remote services whose proxy_path doesn't conflict with local ones.
""" """
lines = [f":{registry.node.gateway_port} {{"] lines = [f":{registry.node.gateway_port} {{"]
# Track local proxy paths for precedence
local_paths: set[str] = set()
for name, deployed in registry.deployed.items(): for name, deployed in registry.deployed.items():
if not deployed.proxy_path or not deployed.port: if not deployed.proxy_path or not deployed.port:
continue continue
local_paths.add(deployed.proxy_path)
lines.append(f" handle_path {deployed.proxy_path}/* {{") lines.append(f" handle_path {deployed.proxy_path}/* {{")
lines.append(f" reverse_proxy localhost:{deployed.port}") lines.append(f" reverse_proxy localhost:{deployed.port}")
lines.append(" }") lines.append(" }")
lines.append("") lines.append("")
# Remote routes (cross-node) — local paths take precedence
if remote_registries:
for hostname, remote_reg in remote_registries.items():
for name, deployed in remote_reg.deployed.items():
if not deployed.proxy_path or not deployed.port:
continue
if deployed.proxy_path in local_paths:
continue
local_paths.add(deployed.proxy_path)
lines.append(f" # {name} on {hostname}")
lines.append(f" handle_path {deployed.proxy_path}/* {{")
lines.append(f" reverse_proxy {hostname}:{deployed.port}")
lines.append(" }")
lines.append("")
# SPA from static dir # SPA from static dir
static_app = STATIC_DIR / "castle-app" static_app = STATIC_DIR / "castle-app"
if (static_app / "index.html").exists(): if (static_app / "index.html").exists():

View File

@@ -113,3 +113,64 @@ class TestCaddyfileFromRegistry:
assert "reverse_proxy localhost:9001" in caddyfile assert "reverse_proxy localhost:9001" in caddyfile
assert "handle_path /svc-b/*" in caddyfile assert "handle_path /svc-b/*" in caddyfile
assert "reverse_proxy localhost:9002" in caddyfile assert "reverse_proxy localhost:9002" in caddyfile
class TestCaddyfileRemoteRegistries:
"""Tests for cross-node routing in Caddyfile."""
def test_remote_routes_included(self) -> None:
"""Remote services get reverse_proxy entries to their hostname."""
local = _make_registry(
deployed={
"local-svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/local"
),
}
)
remote = _make_registry(
deployed={
"remote-svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9050, proxy_path="/remote"
),
}
)
remote.node.hostname = "devbox"
caddyfile = generate_caddyfile_from_registry(local, remote_registries={"devbox": remote})
assert "reverse_proxy localhost:9001" in caddyfile
assert "reverse_proxy devbox:9050" in caddyfile
assert "handle_path /remote/*" in caddyfile
def test_local_takes_precedence(self) -> None:
"""If local and remote use the same path, local wins."""
local = _make_registry(
deployed={
"svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
remote = _make_registry(
deployed={
"svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
remote.node.hostname = "devbox"
caddyfile = generate_caddyfile_from_registry(local, remote_registries={"devbox": remote})
assert "reverse_proxy localhost:9001" in caddyfile
assert "devbox" not in caddyfile
def test_no_remote_when_none(self) -> None:
"""No remote routes when remote_registries is None."""
local = _make_registry(
deployed={
"svc": DeployedComponent(
runner="python", run_cmd=["svc"], port=9001, proxy_path="/api"
),
}
)
caddyfile = generate_caddyfile_from_registry(local, remote_registries=None)
assert "reverse_proxy localhost:9001" in caddyfile
# Only one reverse_proxy line
assert caddyfile.count("reverse_proxy") == 1

View File

@@ -243,26 +243,45 @@ Three interfaces expose the registry:
### Coordination Layer ### Coordination Layer
*Partially built. This section describes the target architecture.*
Coordination handles discovery and communication — both between Coordination handles discovery and communication — both between
components on a single node and across multiple Castle nodes. components on a single node and across multiple Castle nodes.
**Intra-node coordination** (current): **Intra-node coordination:**
- Components find each other through the gateway (path-based routing) - Components find each other through the gateway (path-based routing)
or direct port access via env vars. or direct port access via env vars.
- The registry (CLI/API) provides discoverability. - The registry (CLI/API) provides discoverability.
- No service mesh or message broker required for basic operation. - No service mesh or message broker required for basic operation.
**Inter-node coordination** (future): **Inter-node coordination:**
- Each Castle node runs the API, which exposes its component registry. - Each Castle node runs the API, which exposes its component registry.
- Nodes discover each other via MQTT retained messages or mDNS/DNS-SD - Nodes discover each other via MQTT retained messages and mDNS/DNS-SD
(Avahi) for LAN environments. (python-zeroconf) for LAN environments.
- The gateway on each node can proxy to services on other nodes, - The gateway on each node can proxy to services on other nodes,
preserving path-based routing. Components don't know which node preserving path-based routing. Components don't know which node
they're talking to. they're talking to.
- MQTT provides pub/sub messaging for events, status, and coordination - MQTT provides pub/sub messaging for events, status, and coordination
across nodes. across nodes.
- All mesh features are opt-in: `CASTLE_API_MQTT_ENABLED=true` and
`CASTLE_API_MDNS_ENABLED=true`. Single-node works without them.
**MQTT topics:**
- `castle/{hostname}/registry` — retained JSON, full NodeRegistry.
Published on connect and after `castle deploy`.
- `castle/{hostname}/status``"online"` (retained) / `"offline"` (LWT).
LWT ensures nodes are marked offline if they disconnect unexpectedly.
**MeshStateManager** (`castle_api.mesh`) holds remote NodeRegistry
instances in memory, indexed by hostname. 5-minute staleness TTL.
Updated by the MQTT client on incoming messages. Read by API endpoints
to serve cross-node data.
**mDNS** (`castle_api.mdns`) advertises `_castle._tcp` and browses
for peers and `_mqtt._tcp` broker. Uses python-zeroconf. Properties
include hostname, gateway_port, api_port.
**Caddyfile generation** supports `remote_registries` — cross-node
routes are added with `reverse_proxy {hostname}:{port}` entries.
Local paths always take precedence.
**Why MQTT over custom gossip:** **Why MQTT over custom gossip:**
- Standard protocol, every language has a client library. - Standard protocol, every language has a client library.
@@ -272,12 +291,74 @@ components on a single node and across multiple Castle nodes.
- Mosquitto is a single binary, simple to run as a Castle component. - Mosquitto is a single binary, simple to run as a Castle component.
**Why mDNS/DNS-SD as a complement:** **Why mDNS/DNS-SD as a complement:**
- Zero-config LAN discovery via Avahi (already on most Linux systems). - Zero-config LAN discovery via python-zeroconf.
- Each node advertises `_castle._tcp` — standard tooling works - Each node advertises `_castle._tcp` — standard tooling works
(`avahi-browse`). (`avahi-browse`, `dns-sd`).
- Good for bootstrapping: find the MQTT broker without hardcoding - Good for bootstrapping: find the MQTT broker without hardcoding
its address. its address.
### Dashboard
The web dashboard (`castle-app`) is a React SPA served by Caddy from
`~/.castle/static/castle-app/`. It talks to `castle-api` via the
gateway proxy at `/api`.
**Layout:**
```
Castle
Personal software platform
[tower] [devbox (3)] ← NodeBar (hidden in single-node)
┌─────────────────────────────────────────────────────────┐
│ Gateway · tower · port 9000 · 4 routes [Reload] [Caddyfile] │
│ │
│ Path Component Port Node Health│
│ /api castle-api 9020 tower ● up │
│ /central-context central-context 9001 tower ● up │
│ /notifications notification-bridge 9002 tower ● up │
│ /devbox-api devbox-api 9020 devbox ● up │
└─────────────────────────────────────────────────────────┘
Services · Long-running daemons managed by systemd
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ castle-api │ │ central-ctx │ │ notif-bridge │
│ ● up 5ms │ │ ● up 12ms │ │ ● up 8ms │
│ :9020 │ │ :9001 │ │ :9002 │
└──────────────┘ └──────────────┘ └──────────────┘
Jobs · Scheduled tasks with cron timers
Name Schedule Timer
protonmail-sync */5 * * * * active
backup-collect 0 2 * * * active
Tools · CLI utilities installed to PATH
Name Status
pdf2md ● installed
gpt ● installed
```
**Key components:**
- **GatewayPanel** — Route table with live health badges, reload button,
collapsible Caddyfile viewer. Node column appears when multi-node.
- **NodeBar** — Horizontal list of discovered nodes. Hidden in single-node
mode. Each node links to `/node/{hostname}`.
- **ServiceSection** — Service cards in a responsive grid.
- **JobSection** — Sortable table of scheduled tasks.
- **ToolSection** — Sortable table of CLI tools with install/uninstall.
**Real-time updates:**
- SSE stream at `/stream` pushes `health`, `service-action`, and `mesh`
events. React Query caches are updated or invalidated on each event.
- Health polling runs every 10s server-side; SSE delivers updates to all
connected dashboard clients.
**Multi-node behavior:**
- NodeBar appears when `GET /nodes` returns >1 node.
- GatewayPanel shows a "Node" column when routes span multiple nodes.
- `/node/{hostname}` page shows a specific node's deployed components.
## Component Contract ## Component Contract
Every Castle component, regardless of language, must satisfy a minimal Every Castle component, regardless of language, must satisfy a minimal
@@ -439,14 +520,27 @@ What exists today:
- **Tools** — ~15 CLI utilities (pdf2md, docx2md, search, gpt, etc.) - **Tools** — ~15 CLI utilities (pdf2md, docx2md, search, gpt, etc.)
- **Manifest** — `castle.yaml` with typed Pydantic models - **Manifest** — `castle.yaml` with typed Pydantic models
- **Mesh infrastructure** — MQTT client (paho-mqtt), mDNS discovery
(python-zeroconf), MeshStateManager, all wired into API lifespan.
Opt-in via `CASTLE_API_MQTT_ENABLED` / `CASTLE_API_MDNS_ENABLED`.
- **Node API** — `GET /nodes`, `GET /nodes/{hostname}` endpoints.
`GET /components?include_remote=true` for cross-node component listing.
- **Gateway panel** — Dedicated UI showing route table, health per route,
reload button, Caddyfile viewer. Cross-node routes shown when multi-node.
- **Node-aware UI** — NodeBar (hidden single-node), node detail page,
mesh SSE events for live node discovery updates.
- **Cross-node routing** — Caddyfile generator accepts remote registries,
generates `reverse_proxy {hostname}:{port}` entries.
What doesn't exist yet: What doesn't exist yet:
- **Multi-language support** — Rust and Go components (the abstractions - **Multi-language support** — Rust and Go components (the abstractions
support them via `command` runner, but no examples exist yet) support them via `command` runner, but no examples exist yet)
- **Inter-node coordination** — MQTT broker, node discovery, cross-node
routing
- **Build automation** — Castle records build specs but doesn't - **Build automation** — Castle records build specs but doesn't
orchestrate builds (each project builds independently) orchestrate builds (each project builds independently)
- **Mosquitto deployment** — Registered in castle.yaml as `castle-mqtt`
container service, but not yet deployed (needs `castle deploy` +
container runtime)
## Technology Map ## Technology Map
@@ -466,7 +560,7 @@ What doesn't exist yet:
| Testing | pytest (Python), Vitest (TS) | Active | | Testing | pytest (Python), Vitest (TS) | Active |
| Secrets | `~/.castle/secrets/` file-based | Active | | Secrets | `~/.castle/secrets/` file-based | Active |
| Data storage | Filesystem (`/data/castle/`) | Active | | Data storage | Filesystem (`/data/castle/`) | Active |
| Messaging | MQTT (Mosquitto) | Planned | | Messaging | MQTT (paho-mqtt client, Mosquitto broker) | Active (opt-in) |
| Node discovery | mDNS/Avahi + MQTT | Planned | | Node discovery | mDNS (python-zeroconf) + MQTT | Active (opt-in) |
| Rust packaging | cargo | Planned | | Rust packaging | cargo | Planned |
| Go packaging | go build | Planned | | Go packaging | go build | Planned |

64
uv.lock generated
View File

@@ -48,8 +48,10 @@ dependencies = [
{ name = "castle-core" }, { name = "castle-core" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "httpx" }, { name = "httpx" },
{ name = "paho-mqtt" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "zeroconf" },
] ]
[package.dev-dependencies] [package.dev-dependencies]
@@ -65,8 +67,10 @@ requires-dist = [
{ name = "castle-core", editable = "core" }, { name = "castle-core", editable = "core" },
{ name = "fastapi", specifier = ">=0.115.0" }, { name = "fastapi", specifier = ">=0.115.0" },
{ name = "httpx", specifier = ">=0.27.0" }, { name = "httpx", specifier = ">=0.27.0" },
{ name = "paho-mqtt", specifier = ">=2.0.0" },
{ name = "pydantic-settings", specifier = ">=2.0.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" },
{ name = "uvicorn", specifier = ">=0.34.0" }, { name = "uvicorn", specifier = ">=0.34.0" },
{ name = "zeroconf", specifier = ">=0.131.0" },
] ]
[package.metadata.requires-dev] [package.metadata.requires-dev]
@@ -234,6 +238,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
] ]
[[package]]
name = "ifaddr"
version = "0.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" },
]
[[package]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" version = "2.3.0"
@@ -325,6 +338,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
] ]
[[package]]
name = "paho-mqtt"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -581,3 +603,45 @@ sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
] ]
[[package]]
name = "zeroconf"
version = "0.148.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ifaddr" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/46/10db987799629d01930176ae523f70879b63577060d63e05ebf9214aba4b/zeroconf-0.148.0.tar.gz", hash = "sha256:03fcca123df3652e23d945112d683d2f605f313637611b7d4adf31056f681702", size = 164447, upload-time = "2025-10-05T00:21:19.199Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/09/394a24a633645063557c5144c9abb694699df76155dcab5e1e3078dd1323/zeroconf-0.148.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6ad889929bdc3953530546a4a2486d8c07f5a18d4ef494a98446bf17414897a7", size = 1714465, upload-time = "2025-10-05T01:08:28.692Z" },
{ url = "https://files.pythonhosted.org/packages/3d/db/f57c4bfcceb67fe474705cbadba3f8f7a88bdc95892e74ba6d85e24d28c3/zeroconf-0.148.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:29fb10be743650eb40863f1a1ee868df1869357a0c2ab75140ee3d7079540c1e", size = 1683877, upload-time = "2025-10-05T01:08:30.42Z" },
{ url = "https://files.pythonhosted.org/packages/54/6c/b3e2d39c40802a8cc9415357acdb76ff01bc29e25ffaa811771b6fffc428/zeroconf-0.148.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f2995e74969c577461060539164c47e1ba674470585cb0f954ebeb77f032f3c2", size = 2122874, upload-time = "2025-10-05T01:08:32.11Z" },
{ url = "https://files.pythonhosted.org/packages/66/eb/0ac2bf51d58d47cfa854628036a7ad95544a1802bc890f3d69649dc35e46/zeroconf-0.148.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5be50346efdc20823f9d68d8757612767d11ceb8da7637d46080977b87912551", size = 1922164, upload-time = "2025-10-05T01:08:33.78Z" },
{ url = "https://files.pythonhosted.org/packages/59/ff/c7372507c7e25ad3499fe08d4678deb1ed41c57f78ff5df43bd2d4d98cfc/zeroconf-0.148.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc88fd01b5552ffb4d5bc551d027ac28a1852c03ceab754d02bd0d5f04c54e85", size = 2214119, upload-time = "2025-10-05T01:08:35.478Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c7/57f0889f47923b4fa4364b62b7b3ffc347f6bad09a25ce4e578b8991a86d/zeroconf-0.148.0-cp313-cp313-manylinux_2_36_x86_64.whl", hash = "sha256:5af260c74187751c0df6a40f38d6fd17cb8658a734b0e1148a86084b71c1977c", size = 2137609, upload-time = "2025-10-05T00:21:15.953Z" },
{ url = "https://files.pythonhosted.org/packages/3b/33/9cb5558695c1377941dbb10a5591f88a787f9e1fba130642693d5c80663b/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b6078c73a76d49ba969ca2bb7067e4d58ebd2b79a5f956e45c4c989b11d36e03", size = 2154314, upload-time = "2025-10-05T01:08:37.523Z" },
{ url = "https://files.pythonhosted.org/packages/38/06/cf4e17a86922b4561d85d36f50f1adada1328723e882d95aa42baefa5479/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3e686bf741158f4253d5e0aa6a8f9d34b3140bf5826c0aca9b906273b9c77a5f", size = 2004973, upload-time = "2025-10-05T01:08:39.825Z" },
{ url = "https://files.pythonhosted.org/packages/a4/61/937a405783317639cd11e7bfab3879669896297b6ca2edfb0d2d9c8dbb30/zeroconf-0.148.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:52d6ac06efe05a1e46089cfde066985782824f64b64c6982e8678e70b4b49453", size = 2237775, upload-time = "2025-10-05T01:08:41.535Z" },
{ url = "https://files.pythonhosted.org/packages/03/43/a1751c4b63e108a2318c2266e5afdd9d62292250aa8b1a8ed1674090885c/zeroconf-0.148.0-cp313-cp313-win32.whl", hash = "sha256:b9ba58e2bbb0cff020b54330916eaeb8ee8f4b0dde852e84f670f4ca3a0dd059", size = 1291073, upload-time = "2025-10-05T01:08:43.757Z" },
{ url = "https://files.pythonhosted.org/packages/5e/69/5f4f9eb14506e2afd2d423472e566d5455334d0c8740b933914d642bdbb5/zeroconf-0.148.0-cp313-cp313-win_amd64.whl", hash = "sha256:ee3fcc2edcc04635cf673c400abac2f0c22c9786490fbfb971e0a860a872bf26", size = 1528568, upload-time = "2025-10-05T01:08:45.505Z" },
{ url = "https://files.pythonhosted.org/packages/a5/46/ac86e3a3ff355058cd0818b01a3a97ca3f2abc0a034f1edb8eea27cea65c/zeroconf-0.148.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:2158d8bfefcdb90237937df65b2235870ccef04644497e4e29d3ab5a4b3199b6", size = 1714870, upload-time = "2025-10-05T01:08:47.624Z" },
{ url = "https://files.pythonhosted.org/packages/de/02/c5e8cd8dfda0ca16c7309c8d12c09a3114e5b50054bce3c93da65db8b8e4/zeroconf-0.148.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:695f6663bf8df30fe1826a2c4d5acd8213d9cbd9111f59d375bf1ad635790e98", size = 1697756, upload-time = "2025-10-05T01:08:49.472Z" },
{ url = "https://files.pythonhosted.org/packages/63/04/a66c1011d05d7bb8ae6a847d41ac818271a942390f3d8c83c776389ca094/zeroconf-0.148.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa65a24ec055be0a1cba2b986ac3e1c5d97a40abe164991aabc6a6416cc9df02", size = 2146784, upload-time = "2025-10-05T01:08:51.766Z" },
{ url = "https://files.pythonhosted.org/packages/7c/d4/2239d87c3f60f886bd2dd299e9c63b811efd58b8b6fc659d8fd0900db3bc/zeroconf-0.148.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79890df4ff696a5cdc4a59152957be568bea1423ed13632fc09e2a196c6721d5", size = 1899394, upload-time = "2025-10-05T01:08:53.457Z" },
{ url = "https://files.pythonhosted.org/packages/fb/60/534a4b576a8f9f5edff648ac9a5417323bef3086a77397f2f2058125a3c8/zeroconf-0.148.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c0ca6e8e063eb5a385469bb8d8dec12381368031cb3a82c446225511863ede3", size = 2221319, upload-time = "2025-10-05T01:08:55.271Z" },
{ url = "https://files.pythonhosted.org/packages/b5/8c/1c8e9b7d604910830243ceb533d796dae98ed0c72902624a642487edfd61/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ece6f030cc7a771199760963c11ce4e77ed95011eedffb1ca5186247abfec24a", size = 2178586, upload-time = "2025-10-05T01:08:56.966Z" },
{ url = "https://files.pythonhosted.org/packages/16/55/178c4b95840dc687d45e413a74d2236a25395ab036f4813628271306ab9d/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:c3f860ad0003a8999736fa2ae4c2051dd3c2e5df1bc1eaea2f872f5fcbd1f1c1", size = 1972371, upload-time = "2025-10-05T01:08:59.103Z" },
{ url = "https://files.pythonhosted.org/packages/fb/86/b599421fe634d9f3a2799f69e6e7db9f13f77d326331fa2bb5982e936665/zeroconf-0.148.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ab8e687255cf54ebeae7ede6a8be0566aec752c570e16dbea84b3f9b149ba829", size = 2244286, upload-time = "2025-10-05T01:09:01.029Z" },
{ url = "https://files.pythonhosted.org/packages/3e/cb/a30c42057be5da6bb4cbe1ab53bc3a7d9a29cd59caae097d3072a9375c14/zeroconf-0.148.0-cp314-cp314-win32.whl", hash = "sha256:6b1a6ddba3328d741798c895cecff21481863eb945c3e5d30a679461f4435684", size = 1321693, upload-time = "2025-10-05T01:09:02.715Z" },
{ url = "https://files.pythonhosted.org/packages/2c/38/06873cdf769130af463ef5acadbaf4a50826a7274374bc3b9a4ec5d32678/zeroconf-0.148.0-cp314-cp314-win_amd64.whl", hash = "sha256:2588f1ca889f57cdc09b3da0e51175f1b6153ce0f060bf5eb2a8804c5953b135", size = 1563980, upload-time = "2025-10-05T01:09:04.857Z" },
{ url = "https://files.pythonhosted.org/packages/36/fb/53d749793689279bc9657d818615176577233ad556d62f76f719e86ead1d/zeroconf-0.148.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:40fe100381365c983a89e4b219a7ececcc2a789ac179cd26d4a6bbe00ae3e8fe", size = 3418152, upload-time = "2025-10-05T01:09:06.71Z" },
{ url = "https://files.pythonhosted.org/packages/b9/19/5eb647f7277378cbfdb6943dc8e60c3b17cdd1556f5082ccfdd6813e1ce8/zeroconf-0.148.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b9c7bcae8af8e27593bad76ee0f0c21d43c6a2324cd1e34d06e6e08cb3fd922", size = 3389671, upload-time = "2025-10-05T01:09:08.903Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/3134aa54d30a9ae2e2473212eab586fe1779f845bf241e68729eca63d2ab/zeroconf-0.148.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cf8ba75dacd58558769afb5da24d83da4fdc2a5c43a52f619aaa107fa55d3fdc", size = 4123125, upload-time = "2025-10-05T01:09:11.064Z" },
{ url = "https://files.pythonhosted.org/packages/12/23/4a0284254ebce373ff1aee7240932a0599ecf47e3c711f93242a861aa382/zeroconf-0.148.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:75f9a8212c541a4447c064433862fd4b23d75d47413912a28204d2f9c4929a59", size = 3651426, upload-time = "2025-10-05T01:09:13.725Z" },
{ url = "https://files.pythonhosted.org/packages/76/9a/7b79ef986b5467bb8f17b9a9e6eea887b0b56ecafc00515c81d118e681b4/zeroconf-0.148.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be64c0eb48efa1972c13f7f17a7ac0ed7932ebb9672e57f55b17536412146206", size = 4263151, upload-time = "2025-10-05T01:09:15.732Z" },
{ url = "https://files.pythonhosted.org/packages/dd/0a/caa6d05548ca7cf28a0b8aa20a9dbb0f8176172f28799e53ea11f78692a3/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac1d4ee1d5bac71c27aea6d1dc1e1485423a1631a81be1ea65fb45ac280ade96", size = 4191717, upload-time = "2025-10-05T01:09:18.071Z" },
{ url = "https://files.pythonhosted.org/packages/46/f6/dbafa3b0f2d7a09315ed3ad588d36de79776ce49e00ec945c6195cad3f18/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8da9bdb39ead9d5971136046146cd5e11413cb979c011e19f717b098788b5c37", size = 3793490, upload-time = "2025-10-05T01:09:20.045Z" },
{ url = "https://files.pythonhosted.org/packages/c4/05/f8b88937659075116c122355bdd9ce52376cc46e2269d91d7d4f10c9a658/zeroconf-0.148.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f6e3dd22732df47a126aefb5ca4b267e828b47098a945d4468d38c72843dd6df", size = 4311455, upload-time = "2025-10-05T01:09:22.042Z" },
{ url = "https://files.pythonhosted.org/packages/58/c0/359bdb3b435d9c573aec1f877f8a63d5e81145deb6c160de89647b237363/zeroconf-0.148.0-cp314-cp314t-win32.whl", hash = "sha256:cdc8083f0b5efa908ab6c8e41687bcb75fd3d23f49ee0f34cbc58422437a456f", size = 2755961, upload-time = "2025-10-05T01:09:24.041Z" },
{ url = "https://files.pythonhosted.org/packages/d8/ab/7b487afd5d1fd053c5a018565be734ac6d5e554bce938c7cc126154adcfc/zeroconf-0.148.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f72c1f77a89638e87f243a63979f0fd921ce391f83e18e17ec88f9f453717701", size = 3309977, upload-time = "2025-10-05T01:09:26.039Z" },
]