Skip to main content

faucet_cli/serve/
config.rs

1//! `ServeConfig` — the validated, runtime-ready server configuration built from
2//! `ServeArgs`. The no-auth gate lives here so an unauthenticated server can
3//! never start silently.
4
5use crate::cli::ServeArgs;
6use crate::error::{CliError, CliResult};
7use crate::serve::cluster::ClusterConfig;
8use std::net::SocketAddr;
9use std::path::PathBuf;
10use std::time::Duration;
11
12/// How `/v1/*` requests are authenticated.
13#[derive(Clone)]
14pub enum AuthMode {
15    /// Require `Authorization: Bearer <token>`.
16    Token(String),
17    /// Authentication explicitly disabled via `--no-auth`.
18    None,
19}
20
21// Hand-written so `{:?}` of an `AuthMode` (or the `ServeConfig` that embeds one)
22// never prints the bearer token in clear. The token is also registered for
23// redaction in `ServeConfig::from_args`, but masking here closes the Debug path
24// directly.
25impl std::fmt::Debug for AuthMode {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            AuthMode::Token(_) => f.debug_tuple("Token").field(&"***").finish(),
29            AuthMode::None => f.write_str("None"),
30        }
31    }
32}
33
34/// Run-history storage backend selection (parsed from `--history`).
35#[derive(Debug, Clone)]
36pub enum HistoryBackendSpec {
37    /// In-process `DashMap`; lost on restart (default).
38    Memory,
39    /// Postgres connection URL (stored verbatim, e.g. `postgres://host/db`).
40    Postgres(String),
41    /// SQLite connection URL, stored verbatim including the `sqlite:` scheme
42    /// (e.g. `sqlite:runs.db`, `sqlite::memory:`) so the backend can hand it
43    /// straight to `sqlx` without re-deriving the form.
44    Sqlite(String),
45}
46
47/// Validated server configuration.
48#[derive(Debug, Clone)]
49pub struct ServeConfig {
50    pub listen: SocketAddr,
51    pub auth: AuthMode,
52    pub max_concurrent_runs: usize,
53    pub max_queued_runs: usize,
54    pub default_config_path: Option<PathBuf>,
55    pub history: HistoryBackendSpec,
56    pub cors_origins: Vec<String>,
57    pub body_limit_bytes: usize,
58    pub shutdown_grace: Duration,
59    pub retain_terminal_runs: Duration,
60    pub idempotency_retention: Duration,
61    /// Run-ownership lease TTL for multi-instance orphan fencing (#146 H7).
62    pub lease_ttl: Duration,
63    pub probe_timeout: Duration,
64    pub env_file: Option<PathBuf>,
65    pub no_env_file: bool,
66    /// Tracing filter directive for serve's own subscriber. Set from the
67    /// clap-resolved `--log-level` / `FAUCET_LOG`; defaults to `"info"`.
68    pub log_level: String,
69    /// Whether to serve the embedded web console. Built only when the `serve-ui`
70    /// feature is on; this gates serving at runtime (`--no-ui`).
71    #[cfg_attr(not(feature = "serve-ui"), allow(dead_code))]
72    pub ui_enabled: bool,
73    /// Clustered-execution settings (`--cluster*`). Disabled by default.
74    pub cluster: ClusterConfig,
75    /// Path to a `--triggers` file. `None` = no event-driven triggers. The file
76    /// is loaded + validated at startup (gated on the `triggers` feature).
77    pub triggers_path: Option<PathBuf>,
78}
79
80fn default_max_concurrent() -> usize {
81    std::thread::available_parallelism()
82        .map(|n| n.get())
83        .unwrap_or(4)
84        .clamp(1, 16)
85}
86
87impl ServeConfig {
88    /// Build + validate a `ServeConfig` from parsed CLI args. Enforces the
89    /// no-auth gate: a server with neither a token nor `--no-auth` refuses to start.
90    pub fn from_args(args: ServeArgs) -> CliResult<Self> {
91        let auth = match (args.auth_token, args.no_auth) {
92            (Some(t), _) if t.is_empty() => {
93                return Err(CliError::Serve(
94                    "--auth-token / FAUCET_SERVE_AUTH_TOKEN must not be empty \
95                     (use --no-auth to explicitly disable authentication)"
96                        .into(),
97                ));
98            }
99            (Some(t), _) => {
100                // Register so the RedactingWriter scrubs the token from any
101                // tracing/log/error output for the lifetime of the process.
102                crate::secrets::registry::register(&t);
103                AuthMode::Token(t)
104            }
105            (None, true) => AuthMode::None,
106            (None, false) => {
107                return Err(CliError::Serve(
108                    "refusing to start without authentication: pass --auth-token \
109                     (or FAUCET_SERVE_AUTH_TOKEN), or --no-auth to explicitly disable it"
110                        .into(),
111                ));
112            }
113        };
114
115        let listen: SocketAddr = args
116            .listen
117            .parse()
118            .map_err(|e| CliError::Serve(format!("invalid --listen '{}': {e}", args.listen)))?;
119
120        let history = match args.history {
121            None => HistoryBackendSpec::Memory,
122            Some(u) if u.starts_with("postgres://") || u.starts_with("postgresql://") => {
123                HistoryBackendSpec::Postgres(u)
124            }
125            Some(u) if u.starts_with("sqlite:") => HistoryBackendSpec::Sqlite(u),
126            Some(u) => {
127                return Err(CliError::Serve(format!(
128                    "unrecognised --history '{u}': use a postgres:// URL or sqlite:<path>"
129                )));
130            }
131        };
132
133        if args.lease_ttl_secs == 0 {
134            return Err(CliError::Serve(
135                "--lease-ttl-secs must be > 0 (it gates multi-instance orphan recovery; \
136                 0 would immediately expire every run's lease and let a maintenance tick \
137                 fail in-flight runs)"
138                    .into(),
139            ));
140        }
141
142        // A zero drain window makes graceful shutdown `timeout(Duration::ZERO,
143        // wait_drained())` time out instantly and cancel in-flight runs —
144        // defeating the drain. Reject it (mirroring the lease-ttl gate).
145        // `--idempotency-retention-secs == 0` is NOT rejected: it is an explicit
146        // "dedup disabled" sentinel (every prior claim is immediately expired).
147        if args.shutdown_grace_secs == 0 {
148            return Err(CliError::Serve(
149                "--shutdown-grace-secs must be > 0 (0 makes graceful shutdown cancel \
150                 in-flight runs immediately instead of draining them; use a small \
151                 value like 1)"
152                    .into(),
153            ));
154        }
155
156        let cluster = if args.cluster {
157            if matches!(history, HistoryBackendSpec::Memory) {
158                return Err(CliError::Serve(
159                    "--cluster requires a persistent --history backend \
160                     (postgres://… or sqlite:…); the in-memory store is single-process"
161                        .into(),
162                ));
163            }
164            if args.cluster_poll_secs == 0 {
165                return Err(CliError::Serve(
166                    "--cluster-poll-secs must be > 0 (0 would spin the claim loop \
167                     with no back-off, saturating the history DB)"
168                        .into(),
169                ));
170            }
171            if args.cluster_max_attempts == 0 {
172                return Err(CliError::Serve(
173                    "--cluster-max-attempts must be > 0 (0 would never re-run \
174                     orphaned runs, defeating failover)"
175                        .into(),
176                ));
177            }
178            ClusterConfig {
179                enabled: true,
180                poll: Duration::from_secs(args.cluster_poll_secs),
181                max_attempts: args.cluster_max_attempts,
182            }
183        } else {
184            ClusterConfig::disabled()
185        };
186
187        let max_concurrent_runs = args
188            .max_concurrent_runs
189            .unwrap_or_else(default_max_concurrent)
190            .max(1);
191        let max_queued_runs = args
192            .max_queued_runs
193            .unwrap_or_else(|| max_concurrent_runs.saturating_mul(8))
194            .max(1);
195
196        Ok(Self {
197            listen,
198            auth,
199            max_concurrent_runs,
200            max_queued_runs,
201            default_config_path: args.default_config,
202            history,
203            cors_origins: args.cors_origin,
204            body_limit_bytes: args.body_limit_bytes,
205            shutdown_grace: Duration::from_secs(args.shutdown_grace_secs),
206            retain_terminal_runs: Duration::from_secs(args.retain_terminal_runs_secs),
207            idempotency_retention: Duration::from_secs(args.idempotency_retention_secs),
208            lease_ttl: Duration::from_secs(args.lease_ttl_secs),
209            probe_timeout: Duration::from_secs(args.probe_timeout_secs),
210            env_file: args.env_file,
211            no_env_file: args.no_env_file,
212            log_level: "info".to_string(),
213            ui_enabled: !args.no_ui,
214            cluster,
215            triggers_path: args.triggers,
216        })
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223    use std::net::SocketAddr;
224
225    fn base_args() -> crate::cli::ServeArgs {
226        crate::cli::ServeArgs {
227            listen: "127.0.0.1:8080".into(),
228            auth_token: None,
229            no_auth: false,
230            max_concurrent_runs: None,
231            max_queued_runs: None,
232            default_config: None,
233            history: None,
234            cors_origin: vec![],
235            body_limit_bytes: 1_048_576,
236            shutdown_grace_secs: 60,
237            retain_terminal_runs_secs: 604_800,
238            idempotency_retention_secs: 86_400,
239            lease_ttl_secs: 30,
240            probe_timeout_secs: 10,
241            env_file: None,
242            no_env_file: false,
243            no_ui: false,
244            cluster: false,
245            cluster_poll_secs: 2,
246            cluster_max_attempts: 3,
247            triggers: None,
248        }
249    }
250
251    #[test]
252    fn no_auth_gate_rejects_silent_unauthenticated_start() {
253        let err = ServeConfig::from_args(base_args()).unwrap_err();
254        assert!(err.to_string().contains("--no-auth"), "{err}");
255    }
256
257    #[test]
258    fn explicit_no_auth_is_allowed() {
259        let mut a = base_args();
260        a.no_auth = true;
261        let cfg = ServeConfig::from_args(a).unwrap();
262        assert!(matches!(cfg.auth, AuthMode::None));
263    }
264
265    #[test]
266    fn token_sets_token_auth() {
267        let mut a = base_args();
268        a.auth_token = Some("hunter2".into());
269        let cfg = ServeConfig::from_args(a).unwrap();
270        assert!(matches!(cfg.auth, AuthMode::Token(t) if t == "hunter2"));
271    }
272
273    #[test]
274    fn auth_mode_debug_masks_token() {
275        // ServeConfig derives Debug and embeds AuthMode; a {:?} must not print
276        // the bearer token in clear.
277        let s = format!("{:?}", AuthMode::Token("supersecrettoken".into()));
278        assert!(
279            !s.contains("supersecrettoken"),
280            "serve token leaked via Debug: {s}"
281        );
282        assert!(s.contains("***"), "token not masked: {s}");
283    }
284
285    #[test]
286    fn token_is_registered_for_redaction() {
287        let mut a = base_args();
288        a.auth_token = Some("uniqueserveauthsecret987".into());
289        let _cfg = ServeConfig::from_args(a).unwrap();
290        // The token must be registered so the RedactingWriter scrubs it from logs.
291        let scrubbed =
292            crate::secrets::registry::redact("hdr=uniqueserveauthsecret987 end").into_owned();
293        assert!(
294            !scrubbed.contains("uniqueserveauthsecret987"),
295            "serve token not registered for redaction: {scrubbed}"
296        );
297    }
298
299    #[test]
300    fn listen_parses_to_socket_addr() {
301        let mut a = base_args();
302        a.no_auth = true;
303        a.listen = "0.0.0.0:9999".into();
304        let cfg = ServeConfig::from_args(a).unwrap();
305        assert_eq!(cfg.listen, "0.0.0.0:9999".parse::<SocketAddr>().unwrap());
306    }
307
308    #[test]
309    fn history_url_selects_backend() {
310        let mut a = base_args();
311        a.no_auth = true;
312        a.history = Some("postgres://localhost/db".into());
313        assert!(matches!(
314            ServeConfig::from_args(a.clone()).unwrap().history,
315            HistoryBackendSpec::Postgres(_)
316        ));
317        // The `postgresql://` alias maps to the same backend.
318        a.history = Some("postgresql://localhost/db".into());
319        assert!(matches!(
320            ServeConfig::from_args(a.clone()).unwrap().history,
321            HistoryBackendSpec::Postgres(_)
322        ));
323        // SQLite is stored verbatim, scheme included (for direct sqlx use).
324        a.history = Some("sqlite:runs.db".into());
325        match ServeConfig::from_args(a).unwrap().history {
326            HistoryBackendSpec::Sqlite(url) => assert_eq!(url, "sqlite:runs.db"),
327            other => panic!("expected Sqlite, got {other:?}"),
328        }
329    }
330
331    #[test]
332    fn empty_token_is_rejected() {
333        let mut a = base_args();
334        a.auth_token = Some(String::new());
335        let err = ServeConfig::from_args(a).unwrap_err();
336        assert!(err.to_string().contains("must not be empty"), "{err}");
337    }
338
339    #[test]
340    fn invalid_listen_returns_error() {
341        let mut a = base_args();
342        a.no_auth = true;
343        a.listen = "not-a-socket".into();
344        let err = ServeConfig::from_args(a).unwrap_err();
345        assert!(err.to_string().contains("invalid --listen"), "{err}");
346    }
347
348    #[test]
349    fn unrecognised_history_scheme_returns_error() {
350        let mut a = base_args();
351        a.no_auth = true;
352        a.history = Some("mysql://localhost/db".into());
353        let err = ServeConfig::from_args(a).unwrap_err();
354        assert!(err.to_string().contains("unrecognised --history"), "{err}");
355    }
356
357    #[test]
358    fn zero_lease_ttl_is_rejected() {
359        let mut a = base_args();
360        a.no_auth = true;
361        a.lease_ttl_secs = 0;
362        let err = ServeConfig::from_args(a).unwrap_err();
363        assert!(err.to_string().contains("--lease-ttl-secs"), "{err}");
364    }
365
366    #[test]
367    fn lease_ttl_maps_to_duration() {
368        let mut a = base_args();
369        a.no_auth = true;
370        a.lease_ttl_secs = 45;
371        let cfg = ServeConfig::from_args(a).unwrap();
372        assert_eq!(cfg.lease_ttl, Duration::from_secs(45));
373    }
374
375    #[test]
376    fn zero_shutdown_grace_is_rejected() {
377        // A zero drain window makes graceful shutdown `timeout(Duration::ZERO,
378        // wait_drained())` cancel in-flight runs instantly — defeating the
379        // drain. Reject it the same way the lease-ttl gate does.
380        let mut a = base_args();
381        a.no_auth = true;
382        a.shutdown_grace_secs = 0;
383        let err = ServeConfig::from_args(a).unwrap_err();
384        assert!(err.to_string().contains("--shutdown-grace-secs"), "{err}");
385    }
386
387    #[test]
388    fn nonzero_shutdown_grace_is_accepted() {
389        let mut a = base_args();
390        a.no_auth = true;
391        a.shutdown_grace_secs = 5;
392        let cfg = ServeConfig::from_args(a).unwrap();
393        assert_eq!(cfg.shutdown_grace, Duration::from_secs(5));
394    }
395
396    #[test]
397    fn zero_idempotency_retention_is_accepted_as_dedup_disabled_sentinel() {
398        // `idempotency_retention_secs == 0` is an explicit "dedup disabled"
399        // sentinel (every prior claim is immediately expired), NOT an error.
400        let mut a = base_args();
401        a.no_auth = true;
402        a.idempotency_retention_secs = 0;
403        let cfg = ServeConfig::from_args(a).unwrap();
404        assert_eq!(cfg.idempotency_retention, Duration::ZERO);
405    }
406
407    #[test]
408    fn cluster_requires_persistent_history() {
409        let mut a = base_args();
410        a.no_auth = true;
411        a.cluster = true; // history defaults to memory
412        let err = ServeConfig::from_args(a).unwrap_err();
413        assert!(err.to_string().contains("--cluster requires"), "{err}");
414    }
415
416    #[test]
417    fn cluster_with_sqlite_is_enabled() {
418        let mut a = base_args();
419        a.no_auth = true;
420        a.cluster = true;
421        a.history = Some("sqlite:runs.db".into());
422        let cfg = ServeConfig::from_args(a).unwrap();
423        assert!(cfg.cluster.enabled);
424        assert_eq!(cfg.cluster.max_attempts, 3);
425    }
426
427    #[test]
428    fn cluster_disabled_by_default() {
429        let mut a = base_args();
430        a.no_auth = true;
431        assert!(!ServeConfig::from_args(a).unwrap().cluster.enabled);
432    }
433}