1use std::env;
40use std::fmt;
41use std::path::PathBuf;
42
43use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
44use tracing::warn;
45
46#[derive(Debug, Clone)]
62pub struct ServerConfig {
63 pub database_url: Option<String>,
65 pub jwt_secret: String,
67 pub worker_token: String,
69 pub port: u16,
71 pub allowed_origins: Option<String>,
73 pub dashboard_dir: Option<PathBuf>,
75 pub webhook_url: Option<String>,
77 pub artifacts_dir: Option<PathBuf>,
85 pub artifact_max_bytes: u64,
89 pub purge_max_age_days: u32,
93 pub purge_max_runs_per_workflow: u32,
97 pub purge_dry_run: bool,
101 pub purge_interval_secs: u64,
105 pub is_production: bool,
107 pub rate_limit_auth: Option<u32>,
110 pub rate_limit_general: Option<u32>,
113}
114
115#[derive(Debug, Clone)]
129pub struct ConfigError {
130 pub errors: Vec<String>,
132}
133
134impl ConfigError {
135 pub fn new(errors: Vec<String>) -> Self {
137 Self { errors }
138 }
139}
140
141impl fmt::Display for ConfigError {
142 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143 writeln!(f, "configuration errors:")?;
144 for error in &self.errors {
145 writeln!(f, " - {error}")?;
146 }
147 Ok(())
148 }
149}
150
151impl std::error::Error for ConfigError {}
152
153const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
154const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
155
156fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
160 match env::var(name).ok() {
161 Some(raw) => match raw.parse::<u32>() {
162 Ok(0) => None,
163 Ok(v) => Some(v),
164 Err(_) => {
165 errors.push(format!(
166 "{name} must be a valid u32 (0 to disable), got: {raw}"
167 ));
168 Some(default)
169 }
170 },
171 None => Some(default),
172 }
173}
174
175impl ServerConfig {
176 pub fn from_env() -> Result<Self, ConfigError> {
200 let is_production = env::var("IRONFLOW_ENV")
201 .map(|v| v.eq_ignore_ascii_case("production"))
202 .unwrap_or(false);
203
204 let mut errors = Vec::new();
205
206 let database_url = env::var("DATABASE_URL").ok();
207 if is_production && database_url.is_none() {
208 errors.push("DATABASE_URL is required in production".to_string());
209 }
210
211 let jwt_secret_env = env::var("JWT_SECRET").ok();
212 let jwt_secret = match jwt_secret_env {
213 Some(val) => val,
214 None if is_production => {
215 errors.push("JWT_SECRET is required in production".to_string());
216 String::new()
217 }
218 None => {
219 warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
220 DEV_JWT_SECRET.to_string()
221 }
222 };
223
224 let worker_token_env = env::var("WORKER_TOKEN").ok();
225 let worker_token = match worker_token_env {
226 Some(val) => val,
227 None if is_production => {
228 errors.push("WORKER_TOKEN is required in production".to_string());
229 String::new()
230 }
231 None => {
232 warn!(
233 "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
234 );
235 DEV_WORKER_TOKEN.to_string()
236 }
237 };
238
239 let port = match env::var("PORT").ok() {
240 Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
241 errors.push(format!("PORT must be a valid u16, got: {raw}"));
242 0
243 }),
244 None => 3000,
245 };
246
247 let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
248 let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
249 let webhook_url = env::var("WEBHOOK_URL").ok();
250
251 let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
252 let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
253
254 let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
255 let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
256 Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
257 errors.push(format!(
258 "ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
259 ));
260 DEFAULT_MAX_ARTIFACT_BYTES
261 }),
262 None => DEFAULT_MAX_ARTIFACT_BYTES,
263 };
264
265 let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
266 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
267 errors.push(format!(
268 "PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
269 ));
270 90
271 }),
272 None => 90,
273 };
274 let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
275 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
276 errors.push(format!(
277 "PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
278 ));
279 1000
280 }),
281 None => 1000,
282 };
283 let purge_dry_run = env::var("PURGE_DRY_RUN")
284 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
285 .unwrap_or(false);
286 let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
287 Some(raw) => {
288 let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
289 errors.push(format!(
290 "PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
291 ));
292 86400
293 });
294 if parsed < 60 {
295 errors.push(format!(
296 "PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
297 ));
298 }
299 parsed
300 }
301 None => 86400,
302 };
303
304 if !errors.is_empty() {
305 return Err(ConfigError::new(errors));
306 }
307
308 Ok(Self {
309 database_url,
310 jwt_secret,
311 worker_token,
312 port,
313 allowed_origins,
314 dashboard_dir,
315 webhook_url,
316 is_production,
317 rate_limit_auth,
318 rate_limit_general,
319 artifacts_dir,
320 artifact_max_bytes,
321 purge_max_age_days,
322 purge_max_runs_per_workflow,
323 purge_dry_run,
324 purge_interval_secs,
325 })
326 }
327}
328
329#[cfg(test)]
330mod tests {
331 use std::sync::Mutex;
332
333 use super::*;
334
335 static ENV_LOCK: Mutex<()> = Mutex::new(());
337
338 unsafe fn clear_env() {
342 unsafe {
343 env::remove_var("IRONFLOW_ENV");
344 env::remove_var("DATABASE_URL");
345 env::remove_var("JWT_SECRET");
346 env::remove_var("WORKER_TOKEN");
347 env::remove_var("PORT");
348 env::remove_var("ALLOWED_ORIGINS");
349 env::remove_var("DASHBOARD_DIR");
350 env::remove_var("WEBHOOK_URL");
351 env::remove_var("RATE_LIMIT_AUTH");
352 env::remove_var("RATE_LIMIT_GENERAL");
353 env::remove_var("PURGE_MAX_AGE_DAYS");
354 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
355 env::remove_var("PURGE_DRY_RUN");
356 env::remove_var("PURGE_INTERVAL_SECS");
357 }
358 }
359
360 #[test]
361 fn config_error_display_lists_all_errors() {
362 let err = ConfigError::new(vec![
363 "JWT_SECRET is required".to_string(),
364 "DATABASE_URL is required".to_string(),
365 ]);
366 let msg = err.to_string();
367 assert!(msg.contains("JWT_SECRET"));
368 assert!(msg.contains("DATABASE_URL"));
369 assert!(msg.contains("configuration errors:"));
370 }
371
372 #[test]
373 fn config_error_is_std_error() {
374 let err = ConfigError::new(vec!["test".to_string()]);
375 let _: &dyn std::error::Error = &err;
376 }
377
378 #[test]
379 fn default_dev_config_succeeds() {
380 let _guard = ENV_LOCK.lock().unwrap();
381 unsafe { clear_env() };
382
383 let config = ServerConfig::from_env().expect("dev config should succeed");
384 assert!(!config.is_production);
385 assert_eq!(config.port, 3000);
386 assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
387 assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
388 }
389
390 #[test]
391 fn production_without_secrets_fails() {
392 let _guard = ENV_LOCK.lock().unwrap();
393 unsafe {
394 clear_env();
395 env::set_var("IRONFLOW_ENV", "production");
396 }
397
398 let result = ServerConfig::from_env();
399 assert!(result.is_err());
400 let err = result.unwrap_err();
401 assert!(err.errors.len() >= 3);
402 assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
403 assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
404 assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
405
406 unsafe { env::remove_var("IRONFLOW_ENV") };
407 }
408
409 #[test]
410 fn invalid_port_returns_error() {
411 let _guard = ENV_LOCK.lock().unwrap();
412 unsafe {
413 clear_env();
414 env::set_var("PORT", "not-a-number");
415 }
416
417 let result = ServerConfig::from_env();
418 assert!(result.is_err());
419 let err = result.unwrap_err();
420 assert!(err.errors.iter().any(|e| e.contains("PORT")));
421
422 unsafe { env::remove_var("PORT") };
423 }
424
425 #[test]
426 fn default_rate_limits() {
427 let _guard = ENV_LOCK.lock().unwrap();
428 unsafe { clear_env() };
429
430 let config = ServerConfig::from_env().unwrap();
431 assert_eq!(config.rate_limit_auth, Some(10));
432 assert_eq!(config.rate_limit_general, Some(60));
433 }
434
435 #[test]
436 fn custom_rate_limits() {
437 let _guard = ENV_LOCK.lock().unwrap();
438 unsafe {
439 clear_env();
440 env::set_var("RATE_LIMIT_AUTH", "20");
441 env::set_var("RATE_LIMIT_GENERAL", "120");
442 }
443
444 let config = ServerConfig::from_env().unwrap();
445 assert_eq!(config.rate_limit_auth, Some(20));
446 assert_eq!(config.rate_limit_general, Some(120));
447
448 unsafe {
449 env::remove_var("RATE_LIMIT_AUTH");
450 env::remove_var("RATE_LIMIT_GENERAL");
451 }
452 }
453
454 #[test]
455 fn zero_rate_limit_disables() {
456 let _guard = ENV_LOCK.lock().unwrap();
457 unsafe {
458 clear_env();
459 env::set_var("RATE_LIMIT_AUTH", "0");
460 env::set_var("RATE_LIMIT_GENERAL", "0");
461 }
462
463 let config = ServerConfig::from_env().unwrap();
464 assert!(config.rate_limit_auth.is_none());
465 assert!(config.rate_limit_general.is_none());
466
467 unsafe {
468 env::remove_var("RATE_LIMIT_AUTH");
469 env::remove_var("RATE_LIMIT_GENERAL");
470 }
471 }
472
473 #[test]
474 fn invalid_rate_limit_returns_error() {
475 let _guard = ENV_LOCK.lock().unwrap();
476 unsafe {
477 clear_env();
478 env::set_var("RATE_LIMIT_AUTH", "not-a-number");
479 }
480
481 let result = ServerConfig::from_env();
482 assert!(result.is_err());
483 let err = result.unwrap_err();
484 assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
485
486 unsafe { env::remove_var("RATE_LIMIT_AUTH") };
487 }
488
489 #[test]
490 fn default_purge_config() {
491 let _guard = ENV_LOCK.lock().unwrap();
492 unsafe { clear_env() };
493
494 let config = ServerConfig::from_env().unwrap();
495 assert_eq!(config.purge_max_age_days, 90);
496 assert_eq!(config.purge_max_runs_per_workflow, 1000);
497 assert!(!config.purge_dry_run);
498 assert_eq!(config.purge_interval_secs, 86400);
499 }
500
501 #[test]
502 fn custom_purge_config() {
503 let _guard = ENV_LOCK.lock().unwrap();
504 unsafe {
505 clear_env();
506 env::set_var("PURGE_MAX_AGE_DAYS", "30");
507 env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
508 env::set_var("PURGE_DRY_RUN", "true");
509 env::set_var("PURGE_INTERVAL_SECS", "3600");
510 }
511
512 let config = ServerConfig::from_env().unwrap();
513 assert_eq!(config.purge_max_age_days, 30);
514 assert_eq!(config.purge_max_runs_per_workflow, 500);
515 assert!(config.purge_dry_run);
516 assert_eq!(config.purge_interval_secs, 3600);
517
518 unsafe {
519 env::remove_var("PURGE_MAX_AGE_DAYS");
520 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
521 env::remove_var("PURGE_DRY_RUN");
522 env::remove_var("PURGE_INTERVAL_SECS");
523 }
524 }
525
526 #[test]
527 fn invalid_purge_max_age_days_returns_error() {
528 let _guard = ENV_LOCK.lock().unwrap();
529 unsafe {
530 clear_env();
531 env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
532 }
533
534 let result = ServerConfig::from_env();
535 assert!(result.is_err());
536 let err = result.unwrap_err();
537 assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
538
539 unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
540 }
541}