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 } else {
209 match state.history().recover_orphans().await {
211 Ok(n) if n > 0 => tracing::warn!(
212 recovered = n,
213 "recovered orphaned runs from an expired-lease instance"
214 ),
215 Ok(_) => {}
216 Err(e) => tracing::warn!(error = %e, "orphan recovery failed"),
217 }
218 }
219 }
220 }
221 }
222}
223
224pub async fn serve(config: ServeConfig) -> CliResult<()> {
227 let (prom, log_hub) = crate::serve::observability::install(&config.log_level);
228 crate::serve::metrics::set_cluster_enabled(config.cluster.enabled);
229
230 let instance_id = uuid::Uuid::new_v4().to_string();
234 tracing::info!(
235 instance_id = %instance_id,
236 lease_ttl_secs = config.lease_ttl.as_secs(),
237 "faucet serve instance id"
238 );
239
240 let history = crate::serve::history::connect(
241 &config.history,
242 config.idempotency_retention,
243 config.lease_ttl,
244 &instance_id,
245 )
246 .await?;
247 if config.cluster.enabled {
248 let report = history
251 .reclaim_orphans(config.cluster.max_attempts)
252 .await
253 .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
254 if report.requeued > 0 || report.failed > 0 {
255 tracing::warn!(
256 requeued = report.requeued,
257 failed = report.failed,
258 "startup reclaim of orphaned runs from an expired-lease instance"
259 );
260 }
261 } else {
262 let recovered = history
263 .recover_orphans()
264 .await
265 .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
266 if recovered > 0 {
267 tracing::warn!(
268 recovered,
269 "marked orphaned non-terminal runs (expired owner lease) as failed"
270 );
271 }
272 }
273 let default_base = load_default_base(&config).await?;
274
275 #[cfg(feature = "triggers")]
278 let triggers = match &config.triggers_path {
279 Some(path) => {
280 crate::serve::triggers::metrics::describe();
283 Some(crate::serve::triggers::load_triggers(path).await?)
284 }
285 None => None,
286 };
287 #[cfg(feature = "triggers")]
288 let triggers_handle = match &triggers {
289 Some(c) => crate::serve::triggers::health::TriggersHandle::from_compiled(&c.triggers),
290 None => crate::serve::triggers::health::TriggersHandle::empty(),
291 };
292 #[cfg(not(feature = "triggers"))]
294 if config.triggers_path.is_some() {
295 return Err(CliError::Serve(
296 "--triggers requires a build with the `triggers` feature".into(),
297 ));
298 }
299
300 let shutdown = CancellationToken::new();
301 let state = ServerState::new(
302 &config,
303 prom,
304 shutdown.clone(),
305 history,
306 log_hub,
307 default_base,
308 #[cfg(feature = "triggers")]
309 triggers_handle,
310 );
311 let app = build_router(state.clone(), &config);
312
313 let listener = tokio::net::TcpListener::bind(config.listen)
314 .await
315 .map_err(|e| CliError::Serve(format!("failed to bind {}: {e}", config.listen)))?;
316 let local = listener
317 .local_addr()
318 .map_err(|e| CliError::Serve(e.to_string()))?;
319 tracing::info!(listen = %local, "faucet serve listening");
320
321 let purge_period = purge_interval(config.retain_terminal_runs, config.idempotency_retention);
324 tracing::info!(
325 interval_secs = purge_period.as_secs(),
326 retain_secs = config.retain_terminal_runs.as_secs(),
327 "history maintenance task started"
328 );
329 let maintenance = tokio::spawn(maintenance_loop(
330 state.history(),
331 config.retain_terminal_runs,
332 purge_period,
333 shutdown.clone(),
334 ));
335
336 let lease_period = lease_interval(config.lease_ttl);
340 let leases = tokio::spawn(lease_loop(state.clone(), lease_period, shutdown.clone()));
341
342 let claim = if config.cluster.enabled {
344 tracing::info!(
345 poll_secs = config.cluster.poll.as_secs(),
346 max_attempts = config.cluster.max_attempts,
347 "cluster mode enabled; starting claim loop"
348 );
349 Some(tokio::spawn(crate::serve::cluster::claim_loop(
350 state.clone(),
351 shutdown.clone(),
352 )))
353 } else {
354 None
355 };
356
357 #[cfg(feature = "triggers")]
361 let trigger_handles = match &triggers {
362 Some(c) => {
363 tracing::info!(count = c.triggers.len(), "spawning trigger watchers");
364 crate::serve::triggers::spawn_watchers(state.clone(), c, shutdown.clone())
365 }
366 None => Vec::new(),
367 };
368
369 axum::serve(listener, app)
372 .with_graceful_shutdown(async move {
373 wait_for_signal().await;
374 tracing::info!("shutdown signal received; draining in-flight runs");
375 })
376 .await
377 .map_err(|e| CliError::Serve(format!("server error: {e}")))?;
378
379 if let Some(claim) = claim {
384 claim.abort();
385 }
386
387 let drained =
389 tokio::time::timeout(config.shutdown_grace, state.registry().wait_drained()).await;
390 if drained.is_err() {
391 let remaining = state.registry().in_flight();
392 tracing::warn!(remaining, "grace window expired; cancelling in-flight runs");
393 shutdown.cancel();
394 let _ = tokio::time::timeout(
396 std::time::Duration::from_secs(5),
397 state.registry().wait_drained(),
398 )
399 .await;
400 }
401 maintenance.abort();
402 leases.abort();
403 #[cfg(feature = "triggers")]
404 for h in trigger_handles {
405 h.abort();
406 }
407 tracing::info!("faucet serve stopped");
408 Ok(())
409}
410
411async fn wait_for_signal() {
413 #[cfg(unix)]
414 {
415 use tokio::signal::unix::{SignalKind, signal};
416 let mut term = match signal(SignalKind::terminate()) {
417 Ok(s) => s,
418 Err(_) => {
419 let _ = tokio::signal::ctrl_c().await;
420 return;
421 }
422 };
423 tokio::select! {
424 _ = tokio::signal::ctrl_c() => {}
425 _ = term.recv() => {}
426 }
427 }
428 #[cfg(not(unix))]
429 {
430 let _ = tokio::signal::ctrl_c().await;
431 }
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437 use crate::serve::history::memory::MemoryHistory;
438 use crate::serve::history::{RunRecord, RunStatus};
439 use chrono::Utc;
440 use std::collections::BTreeMap;
441
442 #[test]
443 fn lease_interval_is_third_of_ttl_floored_at_one_sec() {
444 assert_eq!(
445 lease_interval(Duration::from_secs(30)),
446 Duration::from_secs(10)
447 );
448 assert_eq!(
449 lease_interval(Duration::from_secs(90)),
450 Duration::from_secs(30)
451 );
452 assert_eq!(
454 lease_interval(Duration::from_secs(1)),
455 Duration::from_secs(1)
456 );
457 assert_eq!(
458 lease_interval(Duration::from_secs(2)),
459 Duration::from_secs(1)
460 );
461 }
462
463 #[test]
464 fn purge_interval_is_quarter_of_shorter_window_clamped() {
465 assert_eq!(
467 purge_interval(Duration::from_secs(604_800), Duration::from_secs(86_400)),
468 Duration::from_secs(3600)
469 );
470 assert_eq!(
472 purge_interval(Duration::from_secs(604_800), Duration::from_secs(120)),
473 Duration::from_secs(60)
474 );
475 assert_eq!(
477 purge_interval(Duration::from_secs(1), Duration::from_secs(1)),
478 Duration::from_secs(60)
479 );
480 assert_eq!(
482 purge_interval(Duration::from_secs(2400), Duration::from_secs(2400)),
483 Duration::from_secs(600)
484 );
485 }
486
487 #[tokio::test]
488 async fn maintenance_loop_purges_expired_terminal_runs() {
489 let history: Arc<dyn RunHistory> = Arc::new(MemoryHistory::new(Duration::from_secs(60)));
490
491 let mut old = RunRecord::queued(
494 "old".into(),
495 None,
496 BTreeMap::new(),
497 None,
498 Utc::now() - chrono::Duration::seconds(10),
499 );
500 old.status = RunStatus::Completed;
501 old.finished_at = Some(Utc::now() - chrono::Duration::seconds(10));
502 history.upsert(&old).await.unwrap();
503 let live = RunRecord::queued("live".into(), None, BTreeMap::new(), None, Utc::now());
504 history.upsert(&live).await.unwrap();
505
506 let shutdown = CancellationToken::new();
507 let handle = tokio::spawn(maintenance_loop(
508 history.clone(),
509 Duration::ZERO, Duration::from_millis(10), shutdown.clone(),
512 ));
513
514 tokio::time::sleep(Duration::from_millis(80)).await;
516 shutdown.cancel();
517 let _ = handle.await;
518
519 assert!(
520 history.get("old").await.unwrap().is_none(),
521 "expired terminal run should have been purged by the maintenance loop"
522 );
523 assert!(
524 history.get("live").await.unwrap().is_some(),
525 "non-terminal run must be kept"
526 );
527 }
528}