1use 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#[derive(Clone)]
14pub enum AuthMode {
15 Token(String),
17 None,
19}
20
21impl 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#[derive(Debug, Clone)]
36pub enum HistoryBackendSpec {
37 Memory,
39 Postgres(String),
41 Sqlite(String),
45}
46
47#[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 pub lease_ttl: Duration,
63 pub probe_timeout: Duration,
64 pub env_file: Option<PathBuf>,
65 pub no_env_file: bool,
66 pub log_level: String,
69 #[cfg_attr(not(feature = "serve-ui"), allow(dead_code))]
72 pub ui_enabled: bool,
73 pub cluster: ClusterConfig,
75 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 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 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 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 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 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 a.history = Some("postgresql://localhost/db".into());
319 assert!(matches!(
320 ServeConfig::from_args(a.clone()).unwrap().history,
321 HistoryBackendSpec::Postgres(_)
322 ));
323 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 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 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; 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}