1use std::env;
48use std::fmt;
49use std::path::PathBuf;
50
51use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
52use tracing::warn;
53
54#[derive(Debug, Clone)]
70pub struct ServerConfig {
71 pub database_url: Option<String>,
73 pub jwt_secret: String,
75 pub worker_token: String,
77 pub port: u16,
79 pub allowed_origins: Option<String>,
81 pub dashboard_dir: Option<PathBuf>,
83 pub webhook_url: Option<String>,
85 pub artifacts_dir: Option<PathBuf>,
93 pub artifact_max_bytes: u64,
97 pub purge_max_age_days: u32,
101 pub purge_max_runs_per_workflow: u32,
105 pub purge_dry_run: bool,
109 pub purge_interval_secs: u64,
113 pub is_production: bool,
115 pub rate_limit_auth: Option<u32>,
118 pub rate_limit_general: Option<u32>,
121 pub artifact_backend: String,
123 pub artifact_s3_bucket: Option<String>,
125 pub artifact_s3_region: String,
127 pub artifact_s3_endpoint: Option<String>,
129 pub artifact_s3_prefix: Option<String>,
131 pub artifact_gc_interval_secs: u64,
133 pub artifact_gc_grace_days: u32,
135 pub artifact_gc_dry_run: bool,
137}
138
139#[derive(Debug, Clone)]
153pub struct ConfigError {
154 pub errors: Vec<String>,
156}
157
158impl ConfigError {
159 pub fn new(errors: Vec<String>) -> Self {
161 Self { errors }
162 }
163}
164
165impl fmt::Display for ConfigError {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 writeln!(f, "configuration errors:")?;
168 for error in &self.errors {
169 writeln!(f, " - {error}")?;
170 }
171 Ok(())
172 }
173}
174
175impl std::error::Error for ConfigError {}
176
177const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
178const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
179
180fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
184 match env::var(name).ok() {
185 Some(raw) => match raw.parse::<u32>() {
186 Ok(0) => None,
187 Ok(v) => Some(v),
188 Err(_) => {
189 errors.push(format!(
190 "{name} must be a valid u32 (0 to disable), got: {raw}"
191 ));
192 Some(default)
193 }
194 },
195 None => Some(default),
196 }
197}
198
199impl ServerConfig {
200 pub fn from_env() -> Result<Self, ConfigError> {
224 let is_production = env::var("IRONFLOW_ENV")
225 .map(|v| v.eq_ignore_ascii_case("production"))
226 .unwrap_or(false);
227
228 let mut errors = Vec::new();
229
230 let database_url = env::var("DATABASE_URL").ok();
231 if is_production && database_url.is_none() {
232 errors.push("DATABASE_URL is required in production".to_string());
233 }
234
235 let jwt_secret_env = env::var("JWT_SECRET").ok();
236 let jwt_secret = match jwt_secret_env {
237 Some(val) => val,
238 None if is_production => {
239 errors.push("JWT_SECRET is required in production".to_string());
240 String::new()
241 }
242 None => {
243 warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
244 DEV_JWT_SECRET.to_string()
245 }
246 };
247
248 let worker_token_env = env::var("WORKER_TOKEN").ok();
249 let worker_token = match worker_token_env {
250 Some(val) => val,
251 None if is_production => {
252 errors.push("WORKER_TOKEN is required in production".to_string());
253 String::new()
254 }
255 None => {
256 warn!(
257 "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
258 );
259 DEV_WORKER_TOKEN.to_string()
260 }
261 };
262
263 let port = match env::var("PORT").ok() {
264 Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
265 errors.push(format!("PORT must be a valid u16, got: {raw}"));
266 0
267 }),
268 None => 3000,
269 };
270
271 let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
272 let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
273 let webhook_url = env::var("WEBHOOK_URL").ok();
274
275 let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
276 let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
277
278 let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
279 let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
280 Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
281 errors.push(format!(
282 "ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
283 ));
284 DEFAULT_MAX_ARTIFACT_BYTES
285 }),
286 None => DEFAULT_MAX_ARTIFACT_BYTES,
287 };
288
289 let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
290 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
291 errors.push(format!(
292 "PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
293 ));
294 90
295 }),
296 None => 90,
297 };
298 let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
299 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
300 errors.push(format!(
301 "PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
302 ));
303 1000
304 }),
305 None => 1000,
306 };
307 let purge_dry_run = env::var("PURGE_DRY_RUN")
308 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
309 .unwrap_or(false);
310 let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
311 Some(raw) => {
312 let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
313 errors.push(format!(
314 "PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
315 ));
316 86400
317 });
318 if parsed < 60 {
319 errors.push(format!(
320 "PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
321 ));
322 }
323 parsed
324 }
325 None => 86400,
326 };
327
328 let artifact_backend = env::var("ARTIFACT_BACKEND")
329 .unwrap_or_else(|_| "local".to_string())
330 .to_lowercase();
331 let artifact_s3_bucket = env::var("ARTIFACT_S3_BUCKET").ok();
332 let artifact_s3_region =
333 env::var("ARTIFACT_S3_REGION").unwrap_or_else(|_| "eu-west-1".to_string());
334 let artifact_s3_endpoint = env::var("ARTIFACT_S3_ENDPOINT").ok();
335 let artifact_s3_prefix = env::var("ARTIFACT_S3_PREFIX").ok();
336
337 if artifact_backend == "s3" && artifact_s3_bucket.is_none() {
338 errors.push("ARTIFACT_S3_BUCKET is required when ARTIFACT_BACKEND=s3".to_string());
339 }
340 if artifact_backend != "local" && artifact_backend != "s3" {
341 errors.push(format!(
342 "ARTIFACT_BACKEND must be 'local' or 's3', got: {artifact_backend}"
343 ));
344 }
345
346 let artifact_gc_interval_secs = match env::var("ARTIFACT_GC_INTERVAL_SECS").ok() {
347 Some(raw) => {
348 let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
349 errors.push(format!(
350 "ARTIFACT_GC_INTERVAL_SECS must be a valid u64, got: {raw}"
351 ));
352 86400
353 });
354 if parsed < 60 {
355 errors.push(format!(
356 "ARTIFACT_GC_INTERVAL_SECS must be at least 60, got: {parsed}"
357 ));
358 }
359 parsed
360 }
361 None => 86400,
362 };
363 let artifact_gc_grace_days = match env::var("ARTIFACT_GC_GRACE_DAYS").ok() {
364 Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
365 errors.push(format!(
366 "ARTIFACT_GC_GRACE_DAYS must be a valid u32, got: {raw}"
367 ));
368 7
369 }),
370 None => 7,
371 };
372 let artifact_gc_dry_run = env::var("ARTIFACT_GC_DRY_RUN")
373 .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
374 .unwrap_or(false);
375
376 if !errors.is_empty() {
377 return Err(ConfigError::new(errors));
378 }
379
380 Ok(Self {
381 database_url,
382 jwt_secret,
383 worker_token,
384 port,
385 allowed_origins,
386 dashboard_dir,
387 webhook_url,
388 is_production,
389 rate_limit_auth,
390 rate_limit_general,
391 artifacts_dir,
392 artifact_max_bytes,
393 purge_max_age_days,
394 purge_max_runs_per_workflow,
395 purge_dry_run,
396 purge_interval_secs,
397 artifact_backend,
398 artifact_s3_bucket,
399 artifact_s3_region,
400 artifact_s3_endpoint,
401 artifact_s3_prefix,
402 artifact_gc_interval_secs,
403 artifact_gc_grace_days,
404 artifact_gc_dry_run,
405 })
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use std::sync::Mutex;
412
413 use super::*;
414
415 static ENV_LOCK: Mutex<()> = Mutex::new(());
417
418 unsafe fn clear_env() {
422 unsafe {
423 env::remove_var("IRONFLOW_ENV");
424 env::remove_var("DATABASE_URL");
425 env::remove_var("JWT_SECRET");
426 env::remove_var("WORKER_TOKEN");
427 env::remove_var("PORT");
428 env::remove_var("ALLOWED_ORIGINS");
429 env::remove_var("DASHBOARD_DIR");
430 env::remove_var("WEBHOOK_URL");
431 env::remove_var("RATE_LIMIT_AUTH");
432 env::remove_var("RATE_LIMIT_GENERAL");
433 env::remove_var("PURGE_MAX_AGE_DAYS");
434 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
435 env::remove_var("PURGE_DRY_RUN");
436 env::remove_var("PURGE_INTERVAL_SECS");
437 env::remove_var("ARTIFACT_BACKEND");
438 env::remove_var("ARTIFACT_S3_BUCKET");
439 env::remove_var("ARTIFACT_S3_REGION");
440 env::remove_var("ARTIFACT_S3_ENDPOINT");
441 env::remove_var("ARTIFACT_S3_PREFIX");
442 env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
443 env::remove_var("ARTIFACT_GC_GRACE_DAYS");
444 env::remove_var("ARTIFACT_GC_DRY_RUN");
445 }
446 }
447
448 #[test]
449 fn config_error_display_lists_all_errors() {
450 let err = ConfigError::new(vec![
451 "JWT_SECRET is required".to_string(),
452 "DATABASE_URL is required".to_string(),
453 ]);
454 let msg = err.to_string();
455 assert!(msg.contains("JWT_SECRET"));
456 assert!(msg.contains("DATABASE_URL"));
457 assert!(msg.contains("configuration errors:"));
458 }
459
460 #[test]
461 fn config_error_is_std_error() {
462 let err = ConfigError::new(vec!["test".to_string()]);
463 let _: &dyn std::error::Error = &err;
464 }
465
466 #[test]
467 fn default_dev_config_succeeds() {
468 let _guard = ENV_LOCK.lock().unwrap();
469 unsafe { clear_env() };
470
471 let config = ServerConfig::from_env().expect("dev config should succeed");
472 assert!(!config.is_production);
473 assert_eq!(config.port, 3000);
474 assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
475 assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
476 }
477
478 #[test]
479 fn production_without_secrets_fails() {
480 let _guard = ENV_LOCK.lock().unwrap();
481 unsafe {
482 clear_env();
483 env::set_var("IRONFLOW_ENV", "production");
484 }
485
486 let result = ServerConfig::from_env();
487 assert!(result.is_err());
488 let err = result.unwrap_err();
489 assert!(err.errors.len() >= 3);
490 assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
491 assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
492 assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
493
494 unsafe { env::remove_var("IRONFLOW_ENV") };
495 }
496
497 #[test]
498 fn invalid_port_returns_error() {
499 let _guard = ENV_LOCK.lock().unwrap();
500 unsafe {
501 clear_env();
502 env::set_var("PORT", "not-a-number");
503 }
504
505 let result = ServerConfig::from_env();
506 assert!(result.is_err());
507 let err = result.unwrap_err();
508 assert!(err.errors.iter().any(|e| e.contains("PORT")));
509
510 unsafe { env::remove_var("PORT") };
511 }
512
513 #[test]
514 fn default_rate_limits() {
515 let _guard = ENV_LOCK.lock().unwrap();
516 unsafe { clear_env() };
517
518 let config = ServerConfig::from_env().unwrap();
519 assert_eq!(config.rate_limit_auth, Some(10));
520 assert_eq!(config.rate_limit_general, Some(60));
521 }
522
523 #[test]
524 fn custom_rate_limits() {
525 let _guard = ENV_LOCK.lock().unwrap();
526 unsafe {
527 clear_env();
528 env::set_var("RATE_LIMIT_AUTH", "20");
529 env::set_var("RATE_LIMIT_GENERAL", "120");
530 }
531
532 let config = ServerConfig::from_env().unwrap();
533 assert_eq!(config.rate_limit_auth, Some(20));
534 assert_eq!(config.rate_limit_general, Some(120));
535
536 unsafe {
537 env::remove_var("RATE_LIMIT_AUTH");
538 env::remove_var("RATE_LIMIT_GENERAL");
539 }
540 }
541
542 #[test]
543 fn zero_rate_limit_disables() {
544 let _guard = ENV_LOCK.lock().unwrap();
545 unsafe {
546 clear_env();
547 env::set_var("RATE_LIMIT_AUTH", "0");
548 env::set_var("RATE_LIMIT_GENERAL", "0");
549 }
550
551 let config = ServerConfig::from_env().unwrap();
552 assert!(config.rate_limit_auth.is_none());
553 assert!(config.rate_limit_general.is_none());
554
555 unsafe {
556 env::remove_var("RATE_LIMIT_AUTH");
557 env::remove_var("RATE_LIMIT_GENERAL");
558 }
559 }
560
561 #[test]
562 fn invalid_rate_limit_returns_error() {
563 let _guard = ENV_LOCK.lock().unwrap();
564 unsafe {
565 clear_env();
566 env::set_var("RATE_LIMIT_AUTH", "not-a-number");
567 }
568
569 let result = ServerConfig::from_env();
570 assert!(result.is_err());
571 let err = result.unwrap_err();
572 assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
573
574 unsafe { env::remove_var("RATE_LIMIT_AUTH") };
575 }
576
577 #[test]
578 fn default_purge_config() {
579 let _guard = ENV_LOCK.lock().unwrap();
580 unsafe { clear_env() };
581
582 let config = ServerConfig::from_env().unwrap();
583 assert_eq!(config.purge_max_age_days, 90);
584 assert_eq!(config.purge_max_runs_per_workflow, 1000);
585 assert!(!config.purge_dry_run);
586 assert_eq!(config.purge_interval_secs, 86400);
587 }
588
589 #[test]
590 fn custom_purge_config() {
591 let _guard = ENV_LOCK.lock().unwrap();
592 unsafe {
593 clear_env();
594 env::set_var("PURGE_MAX_AGE_DAYS", "30");
595 env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
596 env::set_var("PURGE_DRY_RUN", "true");
597 env::set_var("PURGE_INTERVAL_SECS", "3600");
598 }
599
600 let config = ServerConfig::from_env().unwrap();
601 assert_eq!(config.purge_max_age_days, 30);
602 assert_eq!(config.purge_max_runs_per_workflow, 500);
603 assert!(config.purge_dry_run);
604 assert_eq!(config.purge_interval_secs, 3600);
605
606 unsafe {
607 env::remove_var("PURGE_MAX_AGE_DAYS");
608 env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
609 env::remove_var("PURGE_DRY_RUN");
610 env::remove_var("PURGE_INTERVAL_SECS");
611 }
612 }
613
614 #[test]
615 fn invalid_purge_max_age_days_returns_error() {
616 let _guard = ENV_LOCK.lock().unwrap();
617 unsafe {
618 clear_env();
619 env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
620 }
621
622 let result = ServerConfig::from_env();
623 assert!(result.is_err());
624 let err = result.unwrap_err();
625 assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
626
627 unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
628 }
629}