1use 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
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 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
62async 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
76fn 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
87pub(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; 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
116fn lease_interval(lease_ttl: Duration) -> Duration {
119 (lease_ttl / 3).max(Duration::from_secs(1))
120}
121
122pub(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; 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
160pub async fn serve(config: ServeConfig) -> CliResult<()> {
163 let (prom, log_hub) = crate::serve::observability::install(&config.log_level);
164
165 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 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 let lease_period = lease_interval(config.lease_ttl);
232 let leases = tokio::spawn(lease_loop(state.history(), lease_period, shutdown.clone()));
233
234 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 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 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
264async 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 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 assert_eq!(
320 purge_interval(Duration::from_secs(604_800), Duration::from_secs(86_400)),
321 Duration::from_secs(3600)
322 );
323 assert_eq!(
325 purge_interval(Duration::from_secs(604_800), Duration::from_secs(120)),
326 Duration::from_secs(60)
327 );
328 assert_eq!(
330 purge_interval(Duration::from_secs(1), Duration::from_secs(1)),
331 Duration::from_secs(60)
332 );
333 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 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, Duration::from_millis(10), shutdown.clone(),
365 ));
366
367 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}