feat: Implement multi-node support with MQTT and mDNS for service discovery and coordination
This commit is contained in:
44
CLAUDE.md
44
CLAUDE.md
@@ -77,6 +77,50 @@ castle services start|stop # Start/stop everything
|
||||
- **Data**: Service data lives in `/data/castle/<service-name>/`, passed via env var.
|
||||
- **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
|
||||
|
||||
All projects use **uv**. Commands run from each project's directory:
|
||||
|
||||
21
TODO.md
21
TODO.md
@@ -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.
|
||||
- Add a scripts dir?
|
||||
|
||||
- tools: use std-in/std-out unix philosophy
|
||||
- daemons or web-services: expose ports
|
||||
- workers: just keep doing work in a background--usually watching the filesystem or a queue
|
||||
- jobs: scheduled tools or daemon requests
|
||||
- frontend: files served up by caddy
|
||||
- Component (software units) types:
|
||||
- python/golang/rust/bash tool: use std-in/std-out unix philosophy
|
||||
- python/golang/rust-daemon: Whatever
|
||||
- python web-service: RESTful, fastapi, SSE, etc.
|
||||
- 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."
|
||||
|
||||
136
app/src/components/GatewayPanel.tsx
Normal file
136
app/src/components/GatewayPanel.tsx
Normal 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} · port {gateway.port} · {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>
|
||||
)
|
||||
}
|
||||
67
app/src/components/MeshPanel.tsx
Normal file
67
app/src/components/MeshPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
app/src/components/NodeBar.tsx
Normal file
41
app/src/components/NodeBar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useMemo } from "react"
|
||||
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 { JobSection } from "@/components/JobSection"
|
||||
import { ToolSection } from "@/components/ToolSection"
|
||||
@@ -11,6 +14,8 @@ export function Dashboard() {
|
||||
const { data: components, isLoading } = useComponents()
|
||||
const { data: statusResp } = useStatus()
|
||||
const { data: gateway } = useGateway()
|
||||
const { data: nodes } = useNodes()
|
||||
const { data: mesh } = useMeshStatus()
|
||||
|
||||
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 }
|
||||
@@ -30,17 +35,23 @@ export function Dashboard() {
|
||||
<div className="max-w-6xl mx-auto px-6 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold">Castle</h1>
|
||||
<p className="text-[var(--muted)] mt-1">
|
||||
Personal software platform
|
||||
{gateway && (
|
||||
<span className="ml-2 text-sm">
|
||||
· {services.length} services · {jobs.length} jobs
|
||||
· {tools.length} tools · port {gateway.port}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[var(--muted)] mt-1">Personal software platform</p>
|
||||
</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 ? (
|
||||
<p className="text-[var(--muted)]">Loading...</p>
|
||||
) : (
|
||||
|
||||
106
app/src/pages/NodeDetail.tsx
Normal file
106
app/src/pages/NodeDetail.tsx
Normal 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} · {node.deployed_count} deployed · {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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createBrowserRouter } from "react-router-dom"
|
||||
import { Dashboard } from "@/pages/Dashboard"
|
||||
import { ComponentDetailPage } from "@/pages/ComponentDetail"
|
||||
import { NodeDetailPage } from "@/pages/NodeDetail"
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -11,4 +12,8 @@ export const router = createBrowserRouter([
|
||||
path: "/component/:name",
|
||||
element: <ComponentDetailPage />,
|
||||
},
|
||||
{
|
||||
path: "/node/:hostname",
|
||||
element: <NodeDetailPage />,
|
||||
},
|
||||
])
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
GatewayInfo,
|
||||
ServiceActionResponse,
|
||||
SSEHealthEvent,
|
||||
MeshStatus,
|
||||
NodeSummary,
|
||||
NodeDetail,
|
||||
ToolSummary,
|
||||
ToolDetail,
|
||||
} 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() {
|
||||
return useQuery({
|
||||
queryKey: ["tools"],
|
||||
@@ -140,6 +176,13 @@ export function useEventStream() {
|
||||
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 = () => {
|
||||
// EventSource auto-reconnects; nothing to do
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface ComponentSummary {
|
||||
system_dependencies: string[]
|
||||
schedule: string | null
|
||||
installed: boolean | null
|
||||
node: string | null
|
||||
}
|
||||
|
||||
export interface ComponentDetail extends ComponentSummary {
|
||||
@@ -35,11 +36,20 @@ export interface StatusResponse {
|
||||
statuses: HealthStatus[]
|
||||
}
|
||||
|
||||
export interface GatewayRoute {
|
||||
path: string
|
||||
target_port: number
|
||||
component: string
|
||||
node: string
|
||||
}
|
||||
|
||||
export interface GatewayInfo {
|
||||
port: number
|
||||
hostname: string
|
||||
component_count: number
|
||||
service_count: number
|
||||
managed_count: number
|
||||
routes: GatewayRoute[]
|
||||
}
|
||||
|
||||
export interface ServiceActionResponse {
|
||||
@@ -59,6 +69,31 @@ export interface SSEServiceActionEvent {
|
||||
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 {
|
||||
id: string
|
||||
description: string | null
|
||||
|
||||
@@ -9,6 +9,8 @@ dependencies = [
|
||||
"pydantic-settings>=2.0.0",
|
||||
"httpx>=0.27.0",
|
||||
"castle-core",
|
||||
"paho-mqtt>=2.0.0",
|
||||
"zeroconf>=0.131.0",
|
||||
]
|
||||
|
||||
[tool.uv.sources]
|
||||
|
||||
@@ -14,6 +14,12 @@ class Settings(BaseSettings):
|
||||
host: str = "0.0.0.0"
|
||||
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 = {
|
||||
"env_prefix": "CASTLE_API_",
|
||||
"env_file": ".env",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@@ -11,7 +12,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
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.logs import router as logs_router
|
||||
from castle_api.routes import router as dashboard_router
|
||||
@@ -23,8 +24,11 @@ from castle_api.stream import (
|
||||
subscribe,
|
||||
unsubscribe,
|
||||
)
|
||||
from castle_api.nodes import router as nodes_router
|
||||
from castle_api.tools import router as tools_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Set by _watch_shutdown when uvicorn begins its shutdown sequence.
|
||||
_shutting_down = False
|
||||
|
||||
@@ -46,10 +50,53 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
|
||||
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
|
||||
|
||||
_shutting_down = True
|
||||
poll_task.cancel()
|
||||
|
||||
if mqtt_client:
|
||||
await mqtt_client.stop()
|
||||
if mdns_service:
|
||||
mdns_service.stop()
|
||||
|
||||
close_all_subscribers()
|
||||
|
||||
|
||||
@@ -70,6 +117,7 @@ app.add_middleware(
|
||||
app.include_router(config_router)
|
||||
app.include_router(dashboard_router)
|
||||
app.include_router(logs_router)
|
||||
app.include_router(nodes_router)
|
||||
app.include_router(secrets_router)
|
||||
app.include_router(services_router)
|
||||
app.include_router(tools_router)
|
||||
|
||||
130
castle-api/src/castle_api/mdns.py
Normal file
130
castle-api/src/castle_api/mdns.py
Normal 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")
|
||||
76
castle-api/src/castle_api/mesh.py
Normal file
76
castle-api/src/castle_api/mesh.py
Normal 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()
|
||||
@@ -28,6 +28,7 @@ class ComponentSummary(BaseModel):
|
||||
system_dependencies: list[str] = []
|
||||
schedule: str | None = None
|
||||
installed: bool | None = None
|
||||
node: str | None = None
|
||||
|
||||
|
||||
class ComponentDetail(ComponentSummary):
|
||||
@@ -50,13 +51,55 @@ class StatusResponse(BaseModel):
|
||||
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):
|
||||
"""Gateway configuration summary."""
|
||||
|
||||
port: int
|
||||
hostname: str
|
||||
component_count: int
|
||||
service_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):
|
||||
|
||||
255
castle-api/src/castle_api/mqtt_client.py
Normal file
255
castle-api/src/castle_api/mqtt_client.py
Normal 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")
|
||||
115
castle-api/src/castle_api/nodes.py
Normal file
115
castle-api/src/castle_api/nodes.py
Normal 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)
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
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.manifest import ComponentSpec, JobSpec, ServiceSpec
|
||||
|
||||
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.models import (
|
||||
ComponentDetail,
|
||||
ComponentSummary,
|
||||
GatewayInfo,
|
||||
GatewayRoute,
|
||||
StatusResponse,
|
||||
SystemdInfo,
|
||||
)
|
||||
@@ -173,15 +177,21 @@ def _summary_from_component(name: str, comp: ComponentSpec, root: Path) -> Compo
|
||||
|
||||
|
||||
@router.get("/components", response_model=list[ComponentSummary])
|
||||
def list_components() -> list[ComponentSummary]:
|
||||
"""List all components — deployed from registry, non-deployed from castle.yaml."""
|
||||
def list_components(include_remote: bool = False) -> list[ComponentSummary]:
|
||||
"""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()
|
||||
local_hostname = registry.node.hostname
|
||||
summaries: list[ComponentSummary] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
# Deployed components from registry
|
||||
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)
|
||||
|
||||
# Non-deployed from castle.yaml (if repo available)
|
||||
@@ -195,13 +205,17 @@ def list_components() -> list[ComponentSummary]:
|
||||
# Services not in registry
|
||||
for name, svc in config.services.items():
|
||||
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)
|
||||
|
||||
# Jobs not in registry
|
||||
for name, job in config.jobs.items():
|
||||
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)
|
||||
|
||||
# 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".
|
||||
for name, comp in config.components.items():
|
||||
summary = _summary_from_component(name, comp, root)
|
||||
summary.node = local_hostname
|
||||
# Skip if this exact category is already represented
|
||||
# (e.g. a deployed tool already in the list)
|
||||
if not any(s.id == name and s.category == summary.category for s in summaries):
|
||||
@@ -230,6 +245,27 @@ def list_components() -> list[ComponentSummary]:
|
||||
except FileNotFoundError:
|
||||
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
|
||||
|
||||
|
||||
@@ -320,11 +356,42 @@ def get_gateway() -> GatewayInfo:
|
||||
deployed_count = len(registry.deployed)
|
||||
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)
|
||||
|
||||
# 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(
|
||||
port=registry.node.gateway_port,
|
||||
hostname=registry.node.hostname,
|
||||
component_count=deployed_count,
|
||||
service_count=service_count,
|
||||
managed_count=managed_count,
|
||||
routes=routes,
|
||||
)
|
||||
|
||||
|
||||
@@ -333,3 +400,32 @@ def get_caddyfile() -> dict[str, str]:
|
||||
"""Return the generated Caddyfile content."""
|
||||
registry = get_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"}
|
||||
|
||||
@@ -80,7 +80,27 @@ class TestGateway:
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["port"] == 9000
|
||||
assert data["hostname"] == "test-node"
|
||||
# Registry has 1 deployed component (test-svc)
|
||||
assert data["component_count"] == 1
|
||||
assert data["service_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)
|
||||
|
||||
104
castle-api/tests/test_mesh.py
Normal file
104
castle-api/tests/test_mesh.py
Normal 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
|
||||
96
castle-api/tests/test_mqtt.py
Normal file
96
castle-api/tests/test_mqtt.py
Normal 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"
|
||||
91
castle-api/tests/test_nodes.py
Normal file
91
castle-api/tests/test_nodes.py
Normal 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
|
||||
17
castle.yaml
17
castle.yaml
@@ -215,6 +215,23 @@ services:
|
||||
caddy:
|
||||
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:
|
||||
protonmail-sync:
|
||||
component: protonmail
|
||||
|
||||
@@ -221,7 +221,7 @@ def _build_run_cmd(run: object, env: dict[str, str]) -> list[str]:
|
||||
cmd[0] = resolved
|
||||
return cmd
|
||||
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]
|
||||
cmd = [runtime, "run", "--rm", f"--name=castle-{image_name}"]
|
||||
for container_port, host_port in run.ports.items():
|
||||
|
||||
@@ -6,23 +6,49 @@ from castle_core.config import GENERATED_DIR, STATIC_DIR
|
||||
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.
|
||||
|
||||
Static files served from ~/.castle/static/castle-app/.
|
||||
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} {{"]
|
||||
|
||||
# Track local proxy paths for precedence
|
||||
local_paths: set[str] = set()
|
||||
|
||||
for name, deployed in registry.deployed.items():
|
||||
if not deployed.proxy_path or not deployed.port:
|
||||
continue
|
||||
|
||||
local_paths.add(deployed.proxy_path)
|
||||
lines.append(f" handle_path {deployed.proxy_path}/* {{")
|
||||
lines.append(f" reverse_proxy localhost:{deployed.port}")
|
||||
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
|
||||
static_app = STATIC_DIR / "castle-app"
|
||||
if (static_app / "index.html").exists():
|
||||
|
||||
@@ -113,3 +113,64 @@ class TestCaddyfileFromRegistry:
|
||||
assert "reverse_proxy localhost:9001" in caddyfile
|
||||
assert "handle_path /svc-b/*" 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
|
||||
|
||||
118
docs/design.md
118
docs/design.md
@@ -243,26 +243,45 @@ Three interfaces expose the registry:
|
||||
|
||||
### Coordination Layer
|
||||
|
||||
*Partially built. This section describes the target architecture.*
|
||||
|
||||
Coordination handles discovery and communication — both between
|
||||
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)
|
||||
or direct port access via env vars.
|
||||
- The registry (CLI/API) provides discoverability.
|
||||
- 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.
|
||||
- Nodes discover each other via MQTT retained messages or mDNS/DNS-SD
|
||||
(Avahi) for LAN environments.
|
||||
- Nodes discover each other via MQTT retained messages and mDNS/DNS-SD
|
||||
(python-zeroconf) for LAN environments.
|
||||
- The gateway on each node can proxy to services on other nodes,
|
||||
preserving path-based routing. Components don't know which node
|
||||
they're talking to.
|
||||
- MQTT provides pub/sub messaging for events, status, and coordination
|
||||
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:**
|
||||
- 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.
|
||||
|
||||
**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
|
||||
(`avahi-browse`).
|
||||
(`avahi-browse`, `dns-sd`).
|
||||
- Good for bootstrapping: find the MQTT broker without hardcoding
|
||||
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
|
||||
|
||||
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.)
|
||||
- **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:
|
||||
|
||||
- **Multi-language support** — Rust and Go components (the abstractions
|
||||
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
|
||||
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
|
||||
|
||||
@@ -466,7 +560,7 @@ What doesn't exist yet:
|
||||
| Testing | pytest (Python), Vitest (TS) | Active |
|
||||
| Secrets | `~/.castle/secrets/` file-based | Active |
|
||||
| Data storage | Filesystem (`/data/castle/`) | Active |
|
||||
| Messaging | MQTT (Mosquitto) | Planned |
|
||||
| Node discovery | mDNS/Avahi + MQTT | Planned |
|
||||
| Messaging | MQTT (paho-mqtt client, Mosquitto broker) | Active (opt-in) |
|
||||
| Node discovery | mDNS (python-zeroconf) + MQTT | Active (opt-in) |
|
||||
| Rust packaging | cargo | Planned |
|
||||
| Go packaging | go build | Planned |
|
||||
|
||||
64
uv.lock
generated
64
uv.lock
generated
@@ -48,8 +48,10 @@ dependencies = [
|
||||
{ name = "castle-core" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "zeroconf" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -65,8 +67,10 @@ requires-dist = [
|
||||
{ name = "castle-core", editable = "core" },
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.27.0" },
|
||||
{ name = "paho-mqtt", specifier = ">=2.0.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.34.0" },
|
||||
{ name = "zeroconf", specifier = ">=0.131.0" },
|
||||
]
|
||||
|
||||
[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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "iniconfig"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
@@ -581,3 +603,45 @@ sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user