1use crate::error::{CliError, CliResult};
4use crate::serve::config::ServeConfig;
5use crate::serve::handlers::{doctor, health, logs, runs, schemas};
6use crate::serve::history::RunHistory;
7use crate::serve::state::ServerState;
8use crate::serve::{auth, metrics};
9use axum::Router;
10use axum::routing::{get, post};
11use serde_json::Value;
12use std::sync::Arc;
13use std::time::Duration;
14use tokio_util::sync::CancellationToken;
15use tower_http::cors::{AllowOrigin, CorsLayer};
16use tower_http::limit::RequestBodyLimitLayer;
17
18pub fn build_router(state: ServerState, config: &ServeConfig) -> Router {
20 let public = Router::new()
21 .route("/healthz", get(health::healthz))
22 .route("/readyz", get(health::readyz))
23 .route("/metrics", get(health::metrics));
24
25 #[cfg_attr(not(feature = "triggers"), allow(unused_mut))]
28 let mut api = Router::new()
29 .route("/v1/runs", post(runs::submit_run).get(runs::list_runs))
30 .route("/v1/runs/{id}", get(runs::get_run).delete(runs::delete_run))
31 .route("/v1/runs/{id}/cancel", post(runs::cancel_run))
32 .route("/v1/runs/{id}/logs", get(logs::stream_logs))
33 .route("/v1/schemas", get(schemas::list_schemas))
34 .route("/v1/schemas/{kind}/{name}", get(schemas::get_schema))
35 .route("/v1/doctor", post(doctor::doctor));
36 #[cfg(feature = "triggers")]
37 {
38 api = api.route(
39 "/v1/triggers/{name}",
40 post(crate::serve::triggers::webhook::handle)
41 .put(crate::serve::triggers::webhook::handle),
42 );
43 }
44 let api = api.route_layer(axum::middleware::from_fn_with_state(
45 state.clone(),
46 auth::require_auth,
47 ));
48
49 let cors = if config.cors_origins.is_empty() {
50 CorsLayer::new()
51 } else {
52 let origins: Vec<axum::http::HeaderValue> = config
53 .cors_origins
54 .iter()
55 .filter_map(|o| match o.parse() {
56 Ok(v) => Some(v),
57 Err(e) => {
58 tracing::warn!(origin = %o, error = %e, "ignoring invalid --cors-origin");
59 None
60 }
61 })
62 .collect();
63 CorsLayer::new().allow_origin(AllowOrigin::list(origins))
64 };
65
66 #[cfg_attr(not(feature = "serve-ui"), allow(unused_mut))]
67 let mut router = public.merge(api);
68
69 #[cfg(feature = "serve-ui")]
70 if config.ui_enabled {
71 use crate::serve::ui_assets;
72 router = router
73 .route("/", axum::routing::get(ui_assets::index))
74 .route("/assets/{*path}", axum::routing::get(ui_assets::asset))
75 .fallback(ui_assets::spa_fallback);
76 }
77
78 router
79 .layer(RequestBodyLimitLayer::new(config.body_limit_bytes))
80 .layer(axum::middleware::from_fn(metrics::track_metrics))
81 .layer(cors)
82 .with_state(state)
83}
84
85async fn load_default_base(config: &ServeConfig) -> CliResult<Option<Value>> {
88 match &config.default_config_path {
89 None => Ok(None),
90 Some(path) => {
91 let profile = std::env::var("FAUCET_PROFILE").ok();
93 let cfg =
94 crate::config::PipelineConfig::from_path_async(path, profile.as_deref()).await?;
95 Ok(Some(serde_json::to_value(&cfg).map_err(|e| {
96 CliError::Serve(format!("serializing --default-config: {e}"))
97 })?))
98 }
99 }
100}
101
102fn purge_interval(retain_terminal: Duration, idem_retention: Duration) -> Duration {
109 (retain_terminal.min(idem_retention) / 4)
110 .clamp(Duration::from_secs(60), Duration::from_secs(3600))
111}
112
113pub(crate) async fn maintenance_loop(
120 history: Arc<dyn RunHistory>,
121 retain: Duration,
122 period: Duration,
123 shutdown: CancellationToken,
124) {
125 let mut tick = tokio::time::interval(period);
126 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
127 tick.tick().await; loop {
129 tokio::select! {
130 _ = shutdown.cancelled() => break,
131 _ = tick.tick() => match history.purge_expired(retain).await {
132 Ok(n) if n > 0 => {
133 tracing::info!(purged = n, "purged expired run records / idempotency claims")
134 }
135 Ok(_) => {}
136 Err(e) => tracing::warn!(error = %e, "history purge_expired failed"),
137 },
138 }
139 }
140}
141
142fn lease_interval(lease_ttl: Duration) -> Duration {
145 (lease_ttl / 3).max(Duration::from_secs(1))
146}
147
148pub(crate) async fn lease_loop(state: ServerState, period: Duration, shutdown: CancellationToken) {
164 let cluster = state.cluster().clone();
165 let member_ttl = period.saturating_mul(3);
168 let mut tick = tokio::time::interval(period);
169 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
170 tick.tick().await; loop {
172 tokio::select! {
173 _ = shutdown.cancelled() => break,
174 _ = tick.tick() => {
175 if let Err(e) = state.history().renew_leases().await {
176 tracing::warn!(error = %e, "lease heartbeat (renew_leases) failed");
177 }
178 if cluster.enabled() {
179 let beat = crate::serve::history::InstanceHeartbeat {
181 started_at: cluster.started_at(),
182 listen: Some(cluster.listen().to_string()),
183 max_concurrent: cluster.max_concurrent(),
184 in_flight: state.registry().in_flight() as u32,
185 };
186 if let Err(e) = state.history().heartbeat_instance(&beat).await {
187 tracing::warn!(error = %e, "cluster: heartbeat_instance failed");
188 }
189 match state.history().live_instances(member_ttl).await {
190 Ok(members) => {
191 cluster.set_members(members.len());
192 crate::serve::metrics::set_cluster_instances(members.len());
193 }
194 Err(e) => tracing::warn!(error = %e, "cluster: live_instances failed"),
195 }
196 match state.history().reclaim_orphans(cluster.max_attempts()).await {
198 Ok(r) if r.requeued > 0 || r.failed > 0 => {
199 crate::serve::metrics::record_runs_reclaimed(r.requeued, r.failed);
200 tracing::warn!(
201 requeued = r.requeued, failed = r.failed,
202 "cluster: reclaimed orphaned runs from an expired-lease instance"
203 );
204 }
205 Ok(_) => {}
206 Err(e) => tracing::warn!(error = %e, "cluster: reclaim_orphans failed"),
207 }
208 if let Err(e) = state.history().renew_shard_leases().await {
212 tracing::warn!(error = %e, "cluster: renew_shard_leases failed");
213 }
214 match state.history().reclaim_shards(cluster.max_attempts()).await {
215 Ok(r) if r.requeued > 0 || r.failed > 0 => {
216 crate::serve::metrics::record_shards_reclaimed(r.requeued, r.failed);
217 tracing::warn!(
218 requeued = r.requeued, failed = r.failed,
219 "cluster: reclaimed orphaned shards from an expired-lease instance"
220 );
221 }
222 Ok(_) => {}
223 Err(e) => tracing::warn!(error = %e, "cluster: reclaim_shards failed"),
224 }
225 match state.history().finalize_completed_sharded_parents().await {
232 Ok(n) if n > 0 => tracing::info!(
233 finalized = n,
234 "cluster: finalized completed sharded parent run(s) via sweep"
235 ),
236 Ok(_) => {}
237 Err(e) => {
238 tracing::warn!(error = %e, "cluster: finalize_completed_sharded_parents failed")
239 }
240 }
241 } else {
242 match state.history().recover_orphans().await {
244 Ok(n) if n > 0 => tracing::warn!(
245 recovered = n,
246 "recovered orphaned runs from an expired-lease instance"
247 ),
248 Ok(_) => {}
249 Err(e) => tracing::warn!(error = %e, "orphan recovery failed"),
250 }
251 }
252 }
253 }
254 }
255}
256
257pub async fn serve(config: ServeConfig) -> CliResult<()> {
260 let (prom, log_hub) = crate::serve::observability::install(&config.log_level);
261 crate::serve::metrics::set_cluster_enabled(config.cluster.enabled);
262
263 let instance_id = uuid::Uuid::new_v4().to_string();
267 tracing::info!(
268 instance_id = %instance_id,
269 lease_ttl_secs = config.lease_ttl.as_secs(),
270 "faucet serve instance id"
271 );
272
273 let history = crate::serve::history::connect(
274 &config.history,
275 config.idempotency_retention,
276 config.lease_ttl,
277 &instance_id,
278 )
279 .await?;
280 if config.cluster.enabled {
281 let report = history
284 .reclaim_orphans(config.cluster.max_attempts)
285 .await
286 .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
287 if report.requeued > 0 || report.failed > 0 {
288 tracing::warn!(
289 requeued = report.requeued,
290 failed = report.failed,
291 "startup reclaim of orphaned runs from an expired-lease instance"
292 );
293 }
294 } else {
295 let recovered = history
296 .recover_orphans()
297 .await
298 .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
299 if recovered > 0 {
300 tracing::warn!(
301 recovered,
302 "marked orphaned non-terminal runs (expired owner lease) as failed"
303 );
304 }
305 }
306 let default_base = load_default_base(&config).await?;
307
308 #[cfg(feature = "triggers")]
311 let triggers = match &config.triggers_path {
312 Some(path) => {
313 crate::serve::triggers::metrics::describe();
316 Some(crate::serve::triggers::load_triggers(path).await?)
317 }
318 None => None,
319 };
320 #[cfg(feature = "triggers")]
321 let triggers_handle = match &triggers {
322 Some(c) => crate::serve::triggers::health::TriggersHandle::from_compiled(&c.triggers),
323 None => crate::serve::triggers::health::TriggersHandle::empty(),
324 };
325 #[cfg(not(feature = "triggers"))]
327 if config.triggers_path.is_some() {
328 return Err(CliError::Serve(
329 "--triggers requires a build with the `triggers` feature".into(),
330 ));
331 }
332
333 let shutdown = CancellationToken::new();
334 let state = ServerState::new(
335 &config,
336 prom,
337 shutdown.clone(),
338 history,
339 log_hub,
340 default_base,
341 #[cfg(feature = "triggers")]
342 triggers_handle,
343 );
344 let app = build_router(state.clone(), &config);
345
346 let listener = tokio::net::TcpListener::bind(config.listen)
347 .await
348 .map_err(|e| CliError::Serve(format!("failed to bind {}: {e}", config.listen)))?;
349 let local = listener
350 .local_addr()
351 .map_err(|e| CliError::Serve(e.to_string()))?;
352 tracing::info!(listen = %local, "faucet serve listening");
353
354 let purge_period = purge_interval(config.retain_terminal_runs, config.idempotency_retention);
357 tracing::info!(
358 interval_secs = purge_period.as_secs(),
359 retain_secs = config.retain_terminal_runs.as_secs(),
360 "history maintenance task started"
361 );
362 let maintenance = tokio::spawn(maintenance_loop(
363 state.history(),
364 config.retain_terminal_runs,
365 purge_period,
366 shutdown.clone(),
367 ));
368
369 let lease_period = lease_interval(config.lease_ttl);
373 let leases = tokio::spawn(lease_loop(state.clone(), lease_period, shutdown.clone()));
374
375 let claim = if config.cluster.enabled {
377 tracing::info!(
378 poll_secs = config.cluster.poll.as_secs(),
379 max_attempts = config.cluster.max_attempts,
380 "cluster mode enabled; starting claim loop"
381 );
382 Some(tokio::spawn(crate::serve::cluster::claim_loop(
383 state.clone(),
384 shutdown.clone(),
385 )))
386 } else {
387 None
388 };
389
390 #[cfg(feature = "triggers")]
394 let trigger_handles = match &triggers {
395 Some(c) => {
396 tracing::info!(count = c.triggers.len(), "spawning trigger watchers");
397 crate::serve::triggers::spawn_watchers(state.clone(), c, shutdown.clone())
398 }
399 None => Vec::new(),
400 };
401
402 axum::serve(listener, app)
405 .with_graceful_shutdown(async move {
406 wait_for_signal().await;
407 tracing::info!("shutdown signal received; draining in-flight runs");
408 })
409 .await
410 .map_err(|e| CliError::Serve(format!("server error: {e}")))?;
411
412 if let Some(claim) = claim {
417 claim.abort();
418 }
419
420 let drained =
422 tokio::time::timeout(config.shutdown_grace, state.registry().wait_drained()).await;
423 if drained.is_err() {
424 let remaining = state.registry().in_flight();
425 tracing::warn!(remaining, "grace window expired; cancelling in-flight runs");
426 shutdown.cancel();
427 let _ = tokio::time::timeout(
429 std::time::Duration::from_secs(5),
430 state.registry().wait_drained(),
431 )
432 .await;
433 }
434 maintenance.abort();
435 leases.abort();
436 #[cfg(feature = "triggers")]
437 for h in trigger_handles {
438 h.abort();
439 }
440 faucet_core::shutdown_otel();
443 tracing::info!("faucet serve stopped");
444 Ok(())
445}
446
447async fn wait_for_signal() {
449 #[cfg(unix)]
450 {
451 use tokio::signal::unix::{SignalKind, signal};
452 let mut term = match signal(SignalKind::terminate()) {
453 Ok(s) => s,
454 Err(_) => {
455 let _ = tokio::signal::ctrl_c().await;
456 return;
457 }
458 };
459 tokio::select! {
460 _ = tokio::signal::ctrl_c() => {}
461 _ = term.recv() => {}
462 }
463 }
464 #[cfg(not(unix))]
465 {
466 let _ = tokio::signal::ctrl_c().await;
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use crate::serve::history::memory::MemoryHistory;
474 use crate::serve::history::{RunRecord, RunStatus};
475 use chrono::Utc;
476 use std::collections::BTreeMap;
477
478 #[test]
479 fn lease_interval_is_third_of_ttl_floored_at_one_sec() {
480 assert_eq!(
481 lease_interval(Duration::from_secs(30)),
482 Duration::from_secs(10)
483 );
484 assert_eq!(
485 lease_interval(Duration::from_secs(90)),
486 Duration::from_secs(30)
487 );
488 assert_eq!(
490 lease_interval(Duration::from_secs(1)),
491 Duration::from_secs(1)
492 );
493 assert_eq!(
494 lease_interval(Duration::from_secs(2)),
495 Duration::from_secs(1)
496 );
497 }
498
499 #[test]
500 fn purge_interval_is_quarter_of_shorter_window_clamped() {
501 assert_eq!(
503 purge_interval(Duration::from_secs(604_800), Duration::from_secs(86_400)),
504 Duration::from_secs(3600)
505 );
506 assert_eq!(
508 purge_interval(Duration::from_secs(604_800), Duration::from_secs(120)),
509 Duration::from_secs(60)
510 );
511 assert_eq!(
513 purge_interval(Duration::from_secs(1), Duration::from_secs(1)),
514 Duration::from_secs(60)
515 );
516 assert_eq!(
518 purge_interval(Duration::from_secs(2400), Duration::from_secs(2400)),
519 Duration::from_secs(600)
520 );
521 }
522
523 #[tokio::test]
524 async fn maintenance_loop_purges_expired_terminal_runs() {
525 let history: Arc<dyn RunHistory> = Arc::new(MemoryHistory::new(Duration::from_secs(60)));
526
527 let mut old = RunRecord::queued(
530 "old".into(),
531 None,
532 BTreeMap::new(),
533 None,
534 Utc::now() - chrono::Duration::seconds(10),
535 );
536 old.status = RunStatus::Completed;
537 old.finished_at = Some(Utc::now() - chrono::Duration::seconds(10));
538 history.upsert(&old).await.unwrap();
539 let live = RunRecord::queued("live".into(), None, BTreeMap::new(), None, Utc::now());
540 history.upsert(&live).await.unwrap();
541
542 let shutdown = CancellationToken::new();
543 let handle = tokio::spawn(maintenance_loop(
544 history.clone(),
545 Duration::ZERO, Duration::from_millis(10), shutdown.clone(),
548 ));
549
550 tokio::time::sleep(Duration::from_millis(80)).await;
552 shutdown.cancel();
553 let _ = handle.await;
554
555 assert!(
556 history.get("old").await.unwrap().is_none(),
557 "expired terminal run should have been purged by the maintenance loop"
558 );
559 assert!(
560 history.get("live").await.unwrap().is_some(),
561 "non-terminal run must be kept"
562 );
563 }
564}