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::{health, logs, runs};
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    let api = Router::new()
28        .route("/v1/runs", post(runs::submit_run).get(runs::list_runs))
29        .route("/v1/runs/{id}", get(runs::get_run).delete(runs::delete_run))
30        .route("/v1/runs/{id}/cancel", post(runs::cancel_run))
31        .route("/v1/runs/{id}/logs", get(logs::stream_logs))
32        .route_layer(axum::middleware::from_fn_with_state(
33            state.clone(),
34            auth::require_auth,
35        ));
36
37    let cors = if config.cors_origins.is_empty() {
38        CorsLayer::new()
39    } else {
40        let origins: Vec<axum::http::HeaderValue> = config
41            .cors_origins
42            .iter()
43            .filter_map(|o| match o.parse() {
44                Ok(v) => Some(v),
45                Err(e) => {
46                    tracing::warn!(origin = %o, error = %e, "ignoring invalid --cors-origin");
47                    None
48                }
49            })
50            .collect();
51        CorsLayer::new().allow_origin(AllowOrigin::list(origins))
52    };
53
54    public
55        .merge(api)
56        .layer(RequestBodyLimitLayer::new(config.body_limit_bytes))
57        .layer(axum::middleware::from_fn(metrics::track_metrics))
58        .layer(cors)
59        .with_state(state)
60}
61
62/// Load the optional `--default-config` once at startup, fully resolved, as a
63/// merge base `Value`.
64async fn load_default_base(config: &ServeConfig) -> CliResult<Option<Value>> {
65    match &config.default_config_path {
66        None => Ok(None),
67        Some(path) => {
68            let cfg = crate::config::PipelineConfig::from_path_async(path).await?;
69            Ok(Some(serde_json::to_value(&cfg).map_err(|e| {
70                CliError::Serve(format!("serializing --default-config: {e}"))
71            })?))
72        }
73    }
74}
75
76/// How often the background maintenance task purges expired history.
77///
78/// A quarter of the shorter of the two retention windows, clamped to
79/// `[60s, 1h]`: frequent enough to bound store growth (and to honour a short
80/// `idempotency_retention`) without churning on the multi-day default
81/// `retain_terminal_runs`.
82fn purge_interval(retain_terminal: Duration, idem_retention: Duration) -> Duration {
83    (retain_terminal.min(idem_retention) / 4)
84        .clamp(Duration::from_secs(60), Duration::from_secs(3600))
85}
86
87/// Background history-maintenance loop: every `period`, drop terminal run
88/// records older than `retain` and expired idempotency claims, until `shutdown`
89/// fires. Without this, the history store (in-memory `DashMap`s or the SQL
90/// `faucet_serve_runs` / `faucet_serve_idem` tables) grows without bound for the
91/// life of the process and the `--retain-terminal-runs-secs` /
92/// `--idempotency-retention-secs` knobs are inert (audit #146 C4).
93pub(crate) async fn maintenance_loop(
94    history: Arc<dyn RunHistory>,
95    retain: Duration,
96    period: Duration,
97    shutdown: CancellationToken,
98) {
99    let mut tick = tokio::time::interval(period);
100    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
101    tick.tick().await; // consume the immediate first tick so we don't purge at t=0
102    loop {
103        tokio::select! {
104            _ = shutdown.cancelled() => break,
105            _ = tick.tick() => match history.purge_expired(retain).await {
106                Ok(n) if n > 0 => {
107                    tracing::info!(purged = n, "purged expired run records / idempotency claims")
108                }
109                Ok(_) => {}
110                Err(e) => tracing::warn!(error = %e, "history purge_expired failed"),
111            },
112        }
113    }
114}
115
116/// The lease heartbeat / orphan-recovery cadence: one third of the lease TTL
117/// (so a run sees ≥2 renewals before its lease could expire), floored at 1s.
118fn lease_interval(lease_ttl: Duration) -> Duration {
119    (lease_ttl / 3).max(Duration::from_secs(1))
120}
121
122/// Background lease-maintenance loop (#146 H7). Every `period`:
123///
124/// 1. **Heartbeat** — renew this instance's own non-terminal runs' leases, so a
125///    peer never reclaims a run we are still executing.
126/// 2. **Recover** — fail any non-terminal run whose owning instance's lease has
127///    expired (a crashed/gone peer), so a survivor eventually cleans up orphans
128///    rather than waiting for the next process restart.
129///
130/// Renew runs *before* recover so this instance's leases are fresh when the
131/// expiry scan runs. For the in-memory backend both calls are no-ops.
132pub(crate) async fn lease_loop(
133    history: Arc<dyn RunHistory>,
134    period: Duration,
135    shutdown: CancellationToken,
136) {
137    let mut tick = tokio::time::interval(period);
138    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
139    tick.tick().await; // consume the immediate first tick
140    loop {
141        tokio::select! {
142            _ = shutdown.cancelled() => break,
143            _ = tick.tick() => {
144                if let Err(e) = history.renew_leases().await {
145                    tracing::warn!(error = %e, "lease heartbeat (renew_leases) failed");
146                }
147                match history.recover_orphans().await {
148                    Ok(n) if n > 0 => tracing::warn!(
149                        recovered = n,
150                        "recovered orphaned runs from an expired-lease instance"
151                    ),
152                    Ok(_) => {}
153                    Err(e) => tracing::warn!(error = %e, "orphan recovery failed"),
154                }
155            }
156        }
157    }
158}
159
160/// Boot the server: install observability, build state + router, bind, serve
161/// until SIGTERM/SIGINT, then drain in-flight runs up to the grace window.
162pub async fn serve(config: ServeConfig) -> CliResult<()> {
163    let (prom, log_hub) = crate::serve::observability::install(&config.log_level);
164
165    // This process's identity for run-ownership leases (#146 H7). A fresh id per
166    // process, so a restarted instance recovers its prior incarnation's runs only
167    // once their lease expires — never another live instance's heartbeated runs.
168    let instance_id = uuid::Uuid::new_v4().to_string();
169    tracing::info!(
170        instance_id = %instance_id,
171        lease_ttl_secs = config.lease_ttl.as_secs(),
172        "faucet serve instance id"
173    );
174
175    let history = crate::serve::history::connect(
176        &config.history,
177        config.idempotency_retention,
178        config.lease_ttl,
179        &instance_id,
180    )
181    .await?;
182    let recovered = history
183        .recover_orphans()
184        .await
185        .map_err(|e| CliError::Serve(format!("history recovery: {e}")))?;
186    if recovered > 0 {
187        tracing::warn!(
188            recovered,
189            "marked orphaned non-terminal runs (expired owner lease) as failed"
190        );
191    }
192    let default_base = load_default_base(&config).await?;
193
194    let shutdown = CancellationToken::new();
195    let state = ServerState::new(
196        &config,
197        prom,
198        shutdown.clone(),
199        history,
200        log_hub,
201        default_base,
202    );
203    let app = build_router(state.clone(), &config);
204
205    let listener = tokio::net::TcpListener::bind(config.listen)
206        .await
207        .map_err(|e| CliError::Serve(format!("failed to bind {}: {e}", config.listen)))?;
208    let local = listener
209        .local_addr()
210        .map_err(|e| CliError::Serve(e.to_string()))?;
211    tracing::info!(listen = %local, "faucet serve listening");
212
213    // Background history maintenance: bounds run-record / idempotency-claim
214    // growth and makes the retention knobs effective (audit #146 C4).
215    let purge_period = purge_interval(config.retain_terminal_runs, config.idempotency_retention);
216    tracing::info!(
217        interval_secs = purge_period.as_secs(),
218        retain_secs = config.retain_terminal_runs.as_secs(),
219        "history maintenance task started"
220    );
221    let maintenance = tokio::spawn(maintenance_loop(
222        state.history(),
223        config.retain_terminal_runs,
224        purge_period,
225        shutdown.clone(),
226    ));
227
228    // Lease heartbeat + cross-instance orphan recovery (#146 H7). Renews this
229    // instance's run leases and reclaims runs whose owning instance's lease has
230    // expired. A no-op for the in-memory backend.
231    let lease_period = lease_interval(config.lease_ttl);
232    let leases = tokio::spawn(lease_loop(state.history(), lease_period, shutdown.clone()));
233
234    // The HTTP graceful-shutdown future resolves on signal and stops accepting
235    // new connections / drains in-flight HTTP — it does NOT cancel run tasks.
236    axum::serve(listener, app)
237        .with_graceful_shutdown(async move {
238            wait_for_signal().await;
239            tracing::info!("shutdown signal received; draining in-flight runs");
240        })
241        .await
242        .map_err(|e| CliError::Serve(format!("server error: {e}")))?;
243
244    // Now drain run tasks: wait up to the grace window, then cancel the rest.
245    let drained =
246        tokio::time::timeout(config.shutdown_grace, state.registry().wait_drained()).await;
247    if drained.is_err() {
248        let remaining = state.registry().in_flight();
249        tracing::warn!(remaining, "grace window expired; cancelling in-flight runs");
250        shutdown.cancel();
251        // Give cancelled tasks a brief moment to write their terminal status.
252        let _ = tokio::time::timeout(
253            std::time::Duration::from_secs(5),
254            state.registry().wait_drained(),
255        )
256        .await;
257    }
258    maintenance.abort();
259    leases.abort();
260    tracing::info!("faucet serve stopped");
261    Ok(())
262}
263
264/// Resolve on SIGTERM (Unix) or Ctrl-C (any platform).
265async fn wait_for_signal() {
266    #[cfg(unix)]
267    {
268        use tokio::signal::unix::{SignalKind, signal};
269        let mut term = match signal(SignalKind::terminate()) {
270            Ok(s) => s,
271            Err(_) => {
272                let _ = tokio::signal::ctrl_c().await;
273                return;
274            }
275        };
276        tokio::select! {
277            _ = tokio::signal::ctrl_c() => {}
278            _ = term.recv() => {}
279        }
280    }
281    #[cfg(not(unix))]
282    {
283        let _ = tokio::signal::ctrl_c().await;
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use crate::serve::history::memory::MemoryHistory;
291    use crate::serve::history::{RunRecord, RunStatus};
292    use chrono::Utc;
293    use std::collections::BTreeMap;
294
295    #[test]
296    fn lease_interval_is_third_of_ttl_floored_at_one_sec() {
297        assert_eq!(
298            lease_interval(Duration::from_secs(30)),
299            Duration::from_secs(10)
300        );
301        assert_eq!(
302            lease_interval(Duration::from_secs(90)),
303            Duration::from_secs(30)
304        );
305        // Floor: a tiny TTL still heartbeats at least once per second.
306        assert_eq!(
307            lease_interval(Duration::from_secs(1)),
308            Duration::from_secs(1)
309        );
310        assert_eq!(
311            lease_interval(Duration::from_secs(2)),
312            Duration::from_secs(1)
313        );
314    }
315
316    #[test]
317    fn purge_interval_is_quarter_of_shorter_window_clamped() {
318        // Defaults (retain 7d, idem 1d) → min 1d, /4 = 6h → clamped to the 1h cap.
319        assert_eq!(
320            purge_interval(Duration::from_secs(604_800), Duration::from_secs(86_400)),
321            Duration::from_secs(3600)
322        );
323        // A short idempotency window drives a faster cadence (but never below 60s).
324        assert_eq!(
325            purge_interval(Duration::from_secs(604_800), Duration::from_secs(120)),
326            Duration::from_secs(60)
327        );
328        // Both tiny → the 60s floor.
329        assert_eq!(
330            purge_interval(Duration::from_secs(1), Duration::from_secs(1)),
331            Duration::from_secs(60)
332        );
333        // A 40-minute window lands inside the range: 2400/4 = 600s.
334        assert_eq!(
335            purge_interval(Duration::from_secs(2400), Duration::from_secs(2400)),
336            Duration::from_secs(600)
337        );
338    }
339
340    #[tokio::test]
341    async fn maintenance_loop_purges_expired_terminal_runs() {
342        let history: Arc<dyn RunHistory> = Arc::new(MemoryHistory::new(Duration::from_secs(60)));
343
344        // An old terminal record (eligible for purge with retain=0) and a
345        // non-terminal one (must be kept).
346        let mut old = RunRecord::queued(
347            "old".into(),
348            None,
349            BTreeMap::new(),
350            None,
351            Utc::now() - chrono::Duration::seconds(10),
352        );
353        old.status = RunStatus::Completed;
354        old.finished_at = Some(Utc::now() - chrono::Duration::seconds(10));
355        history.upsert(&old).await.unwrap();
356        let live = RunRecord::queued("live".into(), None, BTreeMap::new(), None, Utc::now());
357        history.upsert(&live).await.unwrap();
358
359        let shutdown = CancellationToken::new();
360        let handle = tokio::spawn(maintenance_loop(
361            history.clone(),
362            Duration::ZERO,            // retain=0 → every terminal record is expired
363            Duration::from_millis(10), // fast tick for the test
364            shutdown.clone(),
365        ));
366
367        // Allow several ticks (the first is consumed at t=0).
368        tokio::time::sleep(Duration::from_millis(80)).await;
369        shutdown.cancel();
370        let _ = handle.await;
371
372        assert!(
373            history.get("old").await.unwrap().is_none(),
374            "expired terminal run should have been purged by the maintenance loop"
375        );
376        assert!(
377            history.get("live").await.unwrap().is_some(),
378            "non-terminal run must be kept"
379        );
380    }
381}