Skip to main content

somatize_coordinator/
server.rs

1//! HTTP/WebSocket server for the Coordinator.
2//!
3//! Endpoints:
4//! - `GET  /health` — liveness check
5//! - `GET  /workers` — list active workers with capabilities
6//! - `GET  /summary` — cluster summary (total CPUs, GPUs, RAM)
7//! - `POST /register` — worker self-registration (JSON body)
8//! - `POST /submit` — client submits a SerializedPlan for execution
9//! - `POST /heartbeat` — worker heartbeat with load metrics
10//!
11//! All mutating endpoints require `?token=sk-xxx` when a token is configured.
12
13use crate::registry::WorkerRegistry;
14use axum::Router;
15use axum::extract::{Json, Query, State};
16use axum::http::{HeaderMap, StatusCode};
17use axum::response::IntoResponse;
18use axum::routing::{get, post};
19use serde::{Deserialize, Serialize};
20use somatize_worker::protocol::{Capabilities, LoadMetrics};
21use std::sync::Arc;
22
23/// Shared coordinator server state.
24struct CoordinatorState {
25    registry: WorkerRegistry,
26    token: Option<String>,
27}
28
29/// Query params for token authentication.
30#[derive(Deserialize, Default)]
31struct AuthParams {
32    token: Option<String>,
33}
34
35/// Build the coordinator router.
36///
37/// Also starts the reaper: without it a worker that dies leaves its entry
38/// in the registry forever, because nothing ever called `prune_stale`.
39pub fn coordinator_router(registry: WorkerRegistry, token: Option<String>) -> Router {
40    let state = Arc::new(CoordinatorState { registry, token });
41
42    let reaping = state.registry.clone();
43    tokio::spawn(async move {
44        let mut tick = tokio::time::interval(std::time::Duration::from_secs(10));
45        loop {
46            tick.tick().await;
47            for id in reaping.prune_stale() {
48                tracing::warn!("worker {id} stopped sending heartbeats; dropped");
49            }
50        }
51    });
52
53    Router::new()
54        .route("/health", get(health))
55        .route("/workers", get(list_workers))
56        .route("/summary", get(summary))
57        .route("/register", post(register_worker))
58        .route("/heartbeat", post(heartbeat))
59        .route("/submit", post(submit_plan))
60        .route("/complete", post(complete_plan))
61        .with_state(state)
62}
63
64/// Start the coordinator server.
65pub async fn serve_coordinator(
66    registry: WorkerRegistry,
67    addr: &str,
68    token: Option<String>,
69) -> Result<(), Box<dyn std::error::Error>> {
70    let listener = tokio::net::TcpListener::bind(addr).await?;
71    tracing::info!("Coordinator listening on {addr}");
72    if token.is_some() {
73        tracing::info!("Authentication enabled");
74    }
75    axum::serve(listener, coordinator_router(registry, token)).await?;
76    Ok(())
77}
78
79/// Validate the token if one is configured.
80///
81/// `Authorization: Bearer <token>` is the supported form. `?token=` is
82/// still accepted so existing workers keep working, but it is deprecated:
83/// a query string ends up in access logs, proxy logs and browser history,
84/// which is not where a credential belongs.
85///
86/// The comparison is constant-time. A byte-by-byte `==` leaks the length
87/// of the matching prefix through timing, which is enough to recover a
88/// token one character at a time.
89fn check_auth(
90    state: &CoordinatorState,
91    headers: &HeaderMap,
92    params: &AuthParams,
93) -> Result<(), StatusCode> {
94    let Some(expected) = &state.token else {
95        return Ok(());
96    };
97
98    let from_header = headers
99        .get(axum::http::header::AUTHORIZATION)
100        .and_then(|v| v.to_str().ok())
101        .and_then(|v| v.strip_prefix("Bearer "))
102        .map(str::to_string);
103
104    if from_header.is_none() && params.token.is_some() {
105        tracing::warn!("a client authenticated with ?token=; use `Authorization: Bearer` instead");
106    }
107
108    match from_header.or_else(|| params.token.clone()) {
109        Some(provided) if constant_time_eq(provided.as_bytes(), expected.as_bytes()) => Ok(()),
110        _ => Err(StatusCode::UNAUTHORIZED),
111    }
112}
113
114/// Compare without leaking where two secrets first differ.
115fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
116    if a.len() != b.len() {
117        return false;
118    }
119    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
120}
121
122// ── Handlers ──
123
124async fn health() -> &'static str {
125    "ok"
126}
127
128async fn list_workers(State(state): State<Arc<CoordinatorState>>) -> impl IntoResponse {
129    let workers = state.registry.active_workers();
130    axum::Json(workers)
131}
132
133async fn summary(State(state): State<Arc<CoordinatorState>>) -> impl IntoResponse {
134    state.registry.summary()
135}
136
137/// Worker registration request body.
138#[derive(Deserialize)]
139struct RegisterRequest {
140    worker_id: String,
141    address: String,
142    capabilities: Capabilities,
143}
144
145/// Worker registration response.
146#[derive(Serialize)]
147struct RegisterResponse {
148    status: String,
149    worker_id: String,
150}
151
152async fn register_worker(
153    Query(params): Query<AuthParams>,
154    State(state): State<Arc<CoordinatorState>>,
155    headers: HeaderMap,
156    Json(req): Json<RegisterRequest>,
157) -> Result<impl IntoResponse, StatusCode> {
158    check_auth(&state, &headers, &params)?;
159
160    tracing::info!(
161        "Worker registered: {} at {} ({})",
162        req.worker_id,
163        req.address,
164        req.capabilities.summary()
165    );
166
167    state
168        .registry
169        .register(&req.worker_id, &req.address, req.capabilities);
170
171    Ok(axum::Json(RegisterResponse {
172        status: "registered".into(),
173        worker_id: req.worker_id,
174    }))
175}
176
177/// Heartbeat request body.
178#[derive(Deserialize)]
179struct HeartbeatRequest {
180    worker_id: String,
181    load: LoadMetrics,
182}
183
184async fn heartbeat(
185    Query(params): Query<AuthParams>,
186    State(state): State<Arc<CoordinatorState>>,
187    headers: HeaderMap,
188    Json(req): Json<HeartbeatRequest>,
189) -> Result<impl IntoResponse, StatusCode> {
190    check_auth(&state, &headers, &params)?;
191    // An unknown worker is told to register rather than silently ignored:
192    // it may have been reaped while it was busy, and it has no other way
193    // to find out.
194    if state.registry.get(&req.worker_id).is_none() {
195        return Err(StatusCode::NOT_FOUND);
196    }
197    state.registry.heartbeat(&req.worker_id, req.load);
198    Ok(StatusCode::OK)
199}
200
201/// A request to place a plan on a worker.
202///
203/// It used to carry the whole `SerializedPlan` — cloudpickled filters and
204/// inline input included — and then throw it away, returning only an
205/// address. That is a large upload parsed for nothing, and it made the
206/// endpoint look like it executed the plan when it does not.
207///
208/// The coordinator places work; the client then talks to the worker
209/// directly, which is what keeps tensor-sized payloads off this hop. All
210/// it needs to do that is the plan's id, to hold the lease under.
211#[derive(Deserialize)]
212struct SubmitRequest {
213    plan_id: String,
214    /// Required tags for worker selection.
215    #[serde(default)]
216    required_tags: Vec<String>,
217    /// Max concurrent plans per worker (for capacity check).
218    #[serde(default = "default_max_concurrent")]
219    max_concurrent: usize,
220}
221
222fn default_max_concurrent() -> usize {
223    4
224}
225
226/// Plan submission response.
227#[derive(Serialize)]
228struct SubmitResponse {
229    status: String,
230    worker_id: Option<String>,
231    worker_address: Option<String>,
232    error: Option<String>,
233}
234
235async fn submit_plan(
236    Query(params): Query<AuthParams>,
237    State(state): State<Arc<CoordinatorState>>,
238    headers: HeaderMap,
239    Json(req): Json<SubmitRequest>,
240) -> Result<impl IntoResponse, StatusCode> {
241    check_auth(&state, &headers, &params)?;
242
243    // Find a suitable worker
244    let candidates = state
245        .registry
246        .find_workers(&req.required_tags, req.max_concurrent);
247
248    if candidates.is_empty() {
249        return Ok(axum::Json(SubmitResponse {
250            status: "no_workers".into(),
251            worker_id: None,
252            worker_address: None,
253            error: Some("No workers available matching requirements".into()),
254        }));
255    }
256
257    // Pick the least loaded worker
258    let best = candidates
259        .iter()
260        .min_by_key(|w| w.active_plans.len())
261        .unwrap();
262
263    tracing::info!(
264        "Placing plan {} on worker {} ({})",
265        req.plan_id,
266        best.id,
267        best.address
268    );
269
270    // Hold the lease before answering. Without it the next caller sees the
271    // same worker as equally idle and every plan lands on one machine.
272    state.registry.claim(&best.id, &req.plan_id);
273
274    Ok(axum::Json(SubmitResponse {
275        status: "routed".into(),
276        worker_id: Some(best.id.clone()),
277        worker_address: Some(best.address.clone()),
278        error: None,
279    }))
280}
281
282/// Release a placement, whether the plan finished or failed.
283#[derive(Deserialize)]
284struct CompleteRequest {
285    worker_id: String,
286    plan_id: String,
287}
288
289async fn complete_plan(
290    Query(params): Query<AuthParams>,
291    State(state): State<Arc<CoordinatorState>>,
292    headers: HeaderMap,
293    Json(req): Json<CompleteRequest>,
294) -> Result<impl IntoResponse, StatusCode> {
295    check_auth(&state, &headers, &params)?;
296    if !state.registry.release(&req.worker_id, &req.plan_id) {
297        return Err(StatusCode::NOT_FOUND);
298    }
299    Ok(StatusCode::OK)
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305    use crate::registry::WorkerStatus;
306    use axum::body::Body;
307    use axum::http::Request;
308    use tower::ServiceExt;
309
310    fn test_caps() -> Capabilities {
311        Capabilities {
312            cpu_cores: 4,
313            ram_bytes: 8_000_000_000,
314            gpus: vec![],
315            python_envs: vec![],
316            tags: vec!["cpu".into()],
317        }
318    }
319
320    #[tokio::test]
321    async fn health_endpoint() {
322        let registry = WorkerRegistry::new();
323        let app = coordinator_router(registry, None);
324
325        let resp = app
326            .oneshot(Request::get("/health").body(Body::empty()).unwrap())
327            .await
328            .unwrap();
329
330        assert_eq!(resp.status(), StatusCode::OK);
331    }
332
333    #[tokio::test]
334    async fn register_and_list() {
335        let registry = WorkerRegistry::new();
336        let app = coordinator_router(registry.clone(), None);
337
338        // Register a worker
339        let body = serde_json::json!({
340            "worker_id": "w1",
341            "address": "ws://host1:8080",
342            "capabilities": test_caps()
343        });
344
345        let resp = app
346            .clone()
347            .oneshot(
348                Request::post("/register")
349                    .header("content-type", "application/json")
350                    .body(Body::from(serde_json::to_string(&body).unwrap()))
351                    .unwrap(),
352            )
353            .await
354            .unwrap();
355        assert_eq!(resp.status(), StatusCode::OK);
356
357        // List workers
358        let resp = app
359            .oneshot(Request::get("/workers").body(Body::empty()).unwrap())
360            .await
361            .unwrap();
362        assert_eq!(resp.status(), StatusCode::OK);
363
364        let body = axum::body::to_bytes(resp.into_body(), 10_000)
365            .await
366            .unwrap();
367        let workers: Vec<WorkerStatus> = serde_json::from_slice(&body).unwrap();
368        assert_eq!(workers.len(), 1);
369        assert_eq!(workers[0].id, "w1");
370    }
371
372    #[tokio::test]
373    async fn auth_rejects_without_token() {
374        let registry = WorkerRegistry::new();
375        let app = coordinator_router(registry, Some("sk-secret".into()));
376
377        let body = serde_json::json!({
378            "worker_id": "w1",
379            "address": "ws://host:8080",
380            "capabilities": test_caps()
381        });
382
383        // Without token → 401
384        let resp = app
385            .clone()
386            .oneshot(
387                Request::post("/register")
388                    .header("content-type", "application/json")
389                    .body(Body::from(serde_json::to_string(&body).unwrap()))
390                    .unwrap(),
391            )
392            .await
393            .unwrap();
394        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
395
396        // With token → 200
397        let resp = app
398            .oneshot(
399                Request::post("/register?token=sk-secret")
400                    .header("content-type", "application/json")
401                    .body(Body::from(serde_json::to_string(&body).unwrap()))
402                    .unwrap(),
403            )
404            .await
405            .unwrap();
406        assert_eq!(resp.status(), StatusCode::OK);
407    }
408
409    #[tokio::test]
410    async fn summary_endpoint() {
411        let registry = WorkerRegistry::new();
412        registry.register("w1", "ws://h1:8080", test_caps());
413
414        let app = coordinator_router(registry, None);
415        let resp = app
416            .oneshot(Request::get("/summary").body(Body::empty()).unwrap())
417            .await
418            .unwrap();
419        assert_eq!(resp.status(), StatusCode::OK);
420
421        let body = axum::body::to_bytes(resp.into_body(), 10_000)
422            .await
423            .unwrap();
424        let text = String::from_utf8_lossy(&body);
425        assert!(text.contains("1 workers"));
426    }
427}