Skip to main content

faucet_cli/serve/
server.rs

1//! axum router assembly and the bind / graceful-shutdown serve loop.
2
3use 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
18/// Build the full router: unauthenticated probes + the bearer-guarded `/v1` API.
19pub 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    // `/v1` routes guarded by the bearer middleware via `route_layer` (only runs
26    // for matched routes; OPTIONS preflight is allowed through inside the layer).
27    #[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
85/// Load the optional `--default-config` once at startup, fully resolved, as a
86/// merge base `Value`.
87async fn load_default_base(config: &ServeConfig) -> CliResult<Option<Value>> {
88    match &config.default_config_path {
89        None => Ok(None),
90        Some(path) => {
91            // `serve` has no --profile flag; honour FAUCET_PROFILE from the environment directly.
92            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
102/// How often the background maintenance task purges expired history.
103///
104/// A quarter of the shorter of the two retention windows, clamped to
105/// `[60s, 1h]`: frequent enough to bound store growth (and to honour a short
106/// `idempotency_retention`) without churning on the multi-day default
107/// `retain_terminal_runs`.
108fn 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
113/// Background history-maintenance loop: every `period`, drop terminal run
114/// records older than `retain` and expired idempotency claims, until `shutdown`
115/// fires. Without this, the history store (in-memory `DashMap`s or the SQL
116/// `faucet_serve_runs` / `faucet_serve_idem` tables) grows without bound for the
117/// life of the process and the `--retain-terminal-runs-secs` /
118/// `--idempotency-retention-secs` knobs are inert (audit #146 C4).
119pub(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; // consume the immediate first tick so we don't purge at t=0
128    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
142/// The lease heartbeat / orphan-recovery cadence: one third of the lease TTL
143/// (so a run sees ≥2 renewals before its lease could expire), floored at 1s.
144fn lease_interval(lease_ttl: Duration) -> Duration {
145    (lease_ttl / 3).max(Duration::from_secs(1))
146}
147
148/// Background lease-maintenance loop (#146 H7). Every `period`:
149///
150/// 1. **Heartbeat** — renew this instance's own non-terminal runs' leases, so a
151///    peer never reclaims a run we are still executing.
152/// 2. **Recover** — fail any non-terminal run whose owning instance's lease has
153///    expired (a crashed/gone peer), so a survivor eventually cleans up orphans
154///    rather than waiting for the next process restart.
155///
156/// Renew runs *before* recover so this instance's leases are fresh when the
157/// expiry scan runs. For the in-memory backend both calls are no-ops.
158///
159/// In cluster mode (#197) the recover step is replaced by: a membership
160/// heartbeat, a live-member refresh (`member_ttl = period * 3`), and a
161/// failover `reclaim_orphans` that re-queues an expired-lease peer's runs
162/// (capped at `max_attempts`) rather than failing them outright.
163pub(crate) async fn lease_loop(state: ServerState, period: Duration, shutdown: CancellationToken) {
164    let cluster = state.cluster().clone();
165    // Member-liveness window ≈ the real lease TTL (period == lease_ttl/3), so a
166    // member must miss ~3 heartbeats before peers treat it as gone.
167    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; // consume the immediate first tick
171    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                    // Membership heartbeat.
180                    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                    // Failover reclaim (re-run orphans).
197                    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                    // Single-instance: mark orphans failed (today's behavior).
210                    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
224/// Boot the server: install observability, build state + router, bind, serve
225/// until SIGTERM/SIGINT, then drain in-flight runs up to the grace window.
226pub 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    // This process's identity for run-ownership leases (#146 H7). A fresh id per
231    // process, so a restarted instance recovers its prior incarnation's runs only
232    // once their lease expires — never another live instance's heartbeated runs.
233    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        // Cluster mode: a restarting instance re-queues its prior incarnation's
249        // in-flight runs (capped) rather than failing them.
250        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    // Event-driven triggers (#196): load + validate the file (fail-fast), then
276    // build the shared handle (webhook table + health rows).
277    #[cfg(feature = "triggers")]
278    let triggers = match &config.triggers_path {
279        Some(path) => {
280            // Register HELP text for the trigger metric family once at startup so
281            // the series carry descriptions in `/metrics` (mirrors schedule).
282            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    // A `--triggers` path in a build without the feature is a clear error.
293    #[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    // Background history maintenance: bounds run-record / idempotency-claim
322    // growth and makes the retention knobs effective (audit #146 C4).
323    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    // Lease heartbeat + cross-instance orphan recovery (#146 H7). Renews this
337    // instance's run leases and reclaims runs whose owning instance's lease has
338    // expired. A no-op for the in-memory backend.
339    let lease_period = lease_interval(config.lease_ttl);
340    let leases = tokio::spawn(lease_loop(state.clone(), lease_period, shutdown.clone()));
341
342    // Cluster claim loop: pulls Pending runs from the shared DB (cluster only).
343    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    // Event-driven trigger watchers (#196): spawn one supervised task per enabled
358    // polling trigger (object_arrival / queue_depth). Webhook triggers are
359    // handled by the router — no watcher task needed for them.
360    #[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    // The HTTP graceful-shutdown future resolves on signal and stops accepting
370    // new connections / drains in-flight HTTP — it does NOT cancel run tasks.
371    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    // Stop pulling NEW work the moment we begin draining: abort the claim loop so
380    // a shutting-down instance drains its in-flight runs rather than claiming more.
381    // The lease loop keeps heartbeating in-flight runs during the drain so peers
382    // don't reclaim them mid-shutdown.
383    if let Some(claim) = claim {
384        claim.abort();
385    }
386
387    // Now drain run tasks: wait up to the grace window, then cancel the rest.
388    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        // Give cancelled tasks a brief moment to write their terminal status.
395        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
411/// Resolve on SIGTERM (Unix) or Ctrl-C (any platform).
412async 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        // Floor: a tiny TTL still heartbeats at least once per second.
453        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        // Defaults (retain 7d, idem 1d) → min 1d, /4 = 6h → clamped to the 1h cap.
466        assert_eq!(
467            purge_interval(Duration::from_secs(604_800), Duration::from_secs(86_400)),
468            Duration::from_secs(3600)
469        );
470        // A short idempotency window drives a faster cadence (but never below 60s).
471        assert_eq!(
472            purge_interval(Duration::from_secs(604_800), Duration::from_secs(120)),
473            Duration::from_secs(60)
474        );
475        // Both tiny → the 60s floor.
476        assert_eq!(
477            purge_interval(Duration::from_secs(1), Duration::from_secs(1)),
478            Duration::from_secs(60)
479        );
480        // A 40-minute window lands inside the range: 2400/4 = 600s.
481        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        // An old terminal record (eligible for purge with retain=0) and a
492        // non-terminal one (must be kept).
493        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,            // retain=0 → every terminal record is expired
510            Duration::from_millis(10), // fast tick for the test
511            shutdown.clone(),
512        ));
513
514        // Allow several ticks (the first is consumed at t=0).
515        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}