1use 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
23struct CoordinatorState {
25 registry: WorkerRegistry,
26 token: Option<String>,
27}
28
29#[derive(Deserialize, Default)]
31struct AuthParams {
32 token: Option<String>,
33}
34
35pub 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
64pub 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
79fn 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
114fn 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
122async 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#[derive(Deserialize)]
139struct RegisterRequest {
140 worker_id: String,
141 address: String,
142 capabilities: Capabilities,
143}
144
145#[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, ¶ms)?;
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#[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, ¶ms)?;
191 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#[derive(Deserialize)]
212struct SubmitRequest {
213 plan_id: String,
214 #[serde(default)]
216 required_tags: Vec<String>,
217 #[serde(default = "default_max_concurrent")]
219 max_concurrent: usize,
220}
221
222fn default_max_concurrent() -> usize {
223 4
224}
225
226#[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, ¶ms)?;
242
243 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 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 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#[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, ¶ms)?;
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 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 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 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 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}