1use std::path::{Path, PathBuf};
257
258use serde::Deserialize;
259use thiserror::Error;
260
261use std::sync::OnceLock;
263
264static MACRO_MANIFEST_DIR: OnceLock<String> = OnceLock::new();
265static MACRO_IS_DEBUG: OnceLock<bool> = OnceLock::new();
266
267#[doc(hidden)]
268pub fn __set_macro_context(manifest_dir: String, is_debug: bool) {
269 let _ = MACRO_MANIFEST_DIR.set(manifest_dir);
270 let _ = MACRO_IS_DEBUG.set(is_debug);
271}
272
273pub trait Env {
279 fn var(&self, key: &str) -> Result<String, std::env::VarError>;
294}
295
296#[derive(Clone, Default)]
298pub struct OsEnv;
299
300impl Env for OsEnv {
301 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
302 if key == "AUTUMN_MANIFEST_DIR" {
303 if let Ok(override_val) = std::env::var(key) {
307 return Ok(override_val);
308 }
309 if let Some(dir) = MACRO_MANIFEST_DIR.get() {
310 return Ok(dir.clone());
311 }
312 } else if key == "AUTUMN_IS_DEBUG"
313 && let Some(is_debug) = MACRO_IS_DEBUG.get()
314 {
315 return Ok(if *is_debug {
316 "1".to_string()
317 } else {
318 "0".to_string()
319 });
320 }
321 std::env::var(key)
322 }
323}
324
325#[derive(Clone, Default)]
327pub struct MockEnv {
328 vars: std::collections::HashMap<String, String>,
329}
330
331impl MockEnv {
332 #[must_use]
334 pub fn new() -> Self {
335 Self {
336 vars: std::collections::HashMap::new(),
337 }
338 }
339
340 #[must_use]
342 pub fn with(mut self, key: &str, value: &str) -> Self {
343 self.vars.insert(key.to_owned(), value.to_owned());
344 self
345 }
346
347 #[must_use]
349 pub fn without(mut self, key: &str) -> Self {
350 self.vars.remove(key);
351 self
352 }
353}
354
355impl Env for MockEnv {
356 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
357 self.vars
358 .get(key)
359 .cloned()
360 .ok_or(std::env::VarError::NotPresent)
361 }
362}
363
364fn find_config_file_named(filename: &str, env: &dyn Env) -> PathBuf {
366 if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
367 let candidate = PathBuf::from(manifest_dir).join(filename);
368 if candidate.exists() {
369 return candidate;
370 }
371 }
372 PathBuf::from(filename)
373}
374
375fn load_raw_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
378 match std::fs::read_to_string(path) {
379 Ok(contents) => {
380 let table = toml::from_str::<toml::Table>(&contents)?;
381 Ok(Some(toml::Value::Table(table)))
382 }
383 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
384 Err(e) => Err(ConfigError::Io(e)),
385 }
386}
387
388pub(crate) fn resolve_profile(env: &dyn Env) -> String {
396 let selected_profile_input = resolve_profile_input(env);
397 normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned())
398}
399
400fn resolve_profile_input(env: &dyn Env) -> String {
408 if let Ok(profile) = env.var("AUTUMN_ENV") {
410 let trimmed = profile.trim();
411 if !trimmed.is_empty() {
412 return trimmed.to_owned();
413 }
414 }
415
416 if let Ok(profile) = env.var("AUTUMN_PROFILE") {
418 let trimmed = profile.trim();
419 if !trimmed.is_empty() {
420 return trimmed.to_owned();
421 }
422 }
423
424 let args: Vec<String> = std::env::args().collect();
426 for (i, arg) in args.iter().enumerate() {
427 if arg == "--profile"
428 && let Some(profile) = args.get(i + 1)
429 {
430 let trimmed = profile.trim();
431 if !trimmed.is_empty() {
432 return trimmed.to_owned();
433 }
434 }
435 if let Some(profile) = arg.strip_prefix("--profile=") {
436 let trimmed = profile.trim();
437 if !trimmed.is_empty() {
438 return trimmed.to_owned();
439 }
440 }
441 }
442
443 if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
445 return "prod".to_owned();
446 }
447 "dev".to_owned()
448}
449
450#[must_use]
462pub fn normalize_profile_name(profile: &str) -> Option<String> {
463 let trimmed = profile.trim();
464 if trimmed.is_empty() {
465 return None;
466 }
467
468 if trimmed.eq_ignore_ascii_case("production") {
469 return Some("prod".to_owned());
470 }
471 if trimmed.eq_ignore_ascii_case("development") {
472 return Some("dev".to_owned());
473 }
474 if trimmed.eq_ignore_ascii_case("prod") {
475 return Some("prod".to_owned());
476 }
477 if trimmed.eq_ignore_ascii_case("dev") {
478 return Some("dev".to_owned());
479 }
480
481 Some(trimmed.to_owned())
483}
484
485#[must_use]
503pub(crate) fn profile_is_dev(profile: Option<&str>) -> bool {
504 matches!(profile, Some("dev" | "development"))
505}
506
507fn profile_lookup_names(profile: &str) -> Vec<&str> {
512 match profile {
513 "prod" => vec!["production", "prod"],
514 "dev" => vec!["development", "dev"],
515 other => vec![other],
516 }
517}
518
519#[must_use]
528pub fn profile_override_file_lookup_names(
529 profile: &str,
530 selected_profile_input: &str,
531) -> Vec<String> {
532 match profile {
533 "prod" if selected_profile_input.eq_ignore_ascii_case("production") => {
534 vec!["production".to_owned(), "prod".to_owned()]
535 }
536 "prod" => vec!["prod".to_owned(), "production".to_owned()],
537 "dev" if selected_profile_input.eq_ignore_ascii_case("development") => {
538 vec!["development".to_owned(), "dev".to_owned()]
539 }
540 "dev" => vec!["dev".to_owned(), "development".to_owned()],
541 other => vec![other.to_owned()],
542 }
543}
544
545fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
547 base.get("profile")
548 .and_then(toml::Value::as_table)
549 .and_then(|profiles| profiles.get(profile))
550 .and_then(toml::Value::as_table)
551 .map(|table| toml::Value::Table(table.clone()))
552}
553
554#[must_use]
560pub fn profile_shutdown_defaults(profile: &str) -> (u64, u64) {
561 let name = normalize_profile_name(profile).unwrap_or_else(|| profile.trim().to_owned());
562 let defaults = profile_defaults_as_toml(&name);
563 let read = |key: &str, fallback: u64| {
564 defaults
565 .get("server")
566 .and_then(|server| server.get(key))
567 .and_then(toml::Value::as_integer)
568 .and_then(|value| u64::try_from(value).ok())
569 .unwrap_or(fallback)
570 };
571 (
572 read("prestop_grace_secs", default_prestop_grace()),
573 read("shutdown_timeout_secs", default_shutdown_timeout()),
574 )
575}
576
577fn profile_defaults_as_toml(profile: &str) -> toml::Value {
583 match profile {
584 "dev" => toml::from_str(
585 r#"
586[log]
587level = "debug"
588format = "Pretty"
589
590[telemetry]
591environment = "development"
592
593[server]
594host = "127.0.0.1"
595shutdown_timeout_secs = 1
596# Zero-out the prestop grace in dev: there is no load balancer to
597# deregister, so the 5-second default would add unnecessary latency
598# on every Ctrl-C.
599prestop_grace_secs = 0
600
601[health]
602detailed = true
603
604[actuator]
605sensitive = true
606
607[cors]
608allowed_origins = ["*"]
609
610# Dev: enable the local-disk blob store rooted at
611# `target/blobs/` automatically when the `storage` feature
612# is on. `prod` deliberately leaves `backend = "disabled"`
613# so the operator has to opt into either `local` (with
614# `allow_local_in_production = true`) or `s3`.
615[storage]
616backend = "local"
617
618[security.trusted_proxies]
619# Dev: trust X-Forwarded-* from loopback only so local reverse
620# proxies (nginx, caddy, etc. on 127.0.0.1/::1) work out of the box.
621trust_forwarded_headers = true
622ranges = ["127.0.0.0/8", "::1/128"]
623# Dev: CSRF disabled (default), HSTS off (default)
624"#,
625 )
626 .expect("valid dev toml"),
627 "prod" => {
628 #[cfg_attr(
629 feature = "sqlite",
630 allow(unused_mut, reason = "only the Postgres build adds to it")
631 )]
632 let mut prod: toml::Value = toml::from_str(
633 r#"
634[log]
635level = "info"
636format = "Json"
637
638[telemetry]
639environment = "production"
640
641[server]
642host = "0.0.0.0"
643# Request timeout + 5 s margin (issue #3058).
644shutdown_timeout_secs = 35
645# Prod: a misspelled key fails the boot (#3057).
646strict_config = true
647
648[server.timeouts]
649request_timeout_ms = 30_000
650
651# Prod: bound slow and idle clients (issue #3065).
652[server.http]
653header_read_timeout_ms = 10_000
654keep_alive_timeout_ms = 75_000
655max_header_bytes = 65_536
656http2_max_concurrent_streams = 100
657max_connections = 10_000
658
659[realtime]
660max_message_bytes = 1_048_576
661ping_interval_ms = 30_000
662idle_timeout_ms = 120_000
663
664[health]
665detailed = false
666
667# Prod: strict security -- HSTS on, CSRF enabled, secure cookies
668[security.headers]
669strict_transport_security = true
670
671[security.csrf]
672enabled = true
673
674[session]
675secure = true
676"#,
677 )
678 .expect("valid prod toml");
679 #[cfg(not(feature = "sqlite"))]
682 deep_merge(
683 &mut prod,
684 toml::from_str(
685 r#"
686[database]
687statement_timeout = "30s"
688idle_in_transaction_timeout = "60s"
689"#,
690 )
691 .expect("valid prod database toml"),
692 );
693 prod
694 }
695 _ => toml::Value::Table(toml::map::Map::new()), }
697}
698
699#[cfg(feature = "mail")]
700fn has_mail_transport_source(merged: &toml::Value, env: &dyn Env) -> bool {
701 merged
702 .get("mail")
703 .and_then(toml::Value::as_table)
704 .is_some_and(|mail| mail.contains_key("transport"))
705 || env
706 .var("AUTUMN_MAIL__TRANSPORT")
707 .ok()
708 .as_deref()
709 .is_some_and(|value| crate::mail::Transport::from_env_value(value).is_some())
710}
711
712const MAX_MERGE_DEPTH: usize = 16;
714
715fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
718 deep_merge_with_depth(base, overlay, 0);
719}
720
721fn deep_merge_with_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
722 if depth > MAX_MERGE_DEPTH {
723 eprintln!(
724 "Warning: Configuration merge exceeded max depth ({MAX_MERGE_DEPTH}), ignoring deeper values."
725 );
726 return;
727 }
728
729 let toml::Value::Table(overlay_table) = overlay else {
730 return;
731 };
732 let Some(base_table) = base.as_table_mut() else {
733 return;
734 };
735
736 for (key, overlay_val) in overlay_table {
737 let is_recursive_merge =
738 overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
739
740 if is_recursive_merge {
741 if let Some(base_val) = base_table.get_mut(&key) {
742 deep_merge_with_depth(base_val, overlay_val, depth + 1);
743 }
744 } else {
745 base_table.insert(key, overlay_val);
746 }
747 }
748}
749
750fn suggest_profile(profile: &str) -> Option<&'static str> {
754 let known = ["dev", "prod"];
755 let mut suggestions: Vec<(&str, usize)> = known
756 .iter()
757 .map(|k| (*k, levenshtein(profile, k)))
758 .filter(|(_, d)| *d <= 2)
759 .collect();
760 suggestions.sort_by_key(|(_, d)| *d);
761 suggestions.first().map(|(name, _)| *name)
762}
763
764fn warn_profile_typo(profile: &str) {
766 if let Some(suggestion) = suggest_profile(profile) {
767 eprintln!(
768 "Warning: profile \"{profile}\" has no config file (autumn-{profile}.toml) \
769 and no smart defaults. Did you mean \"{suggestion}\"?"
770 );
771 }
772}
773
774fn should_warn_missing_profile_file(profile: &str, has_inline_profile_section: bool) -> bool {
775 profile != "dev" && profile != "prod" && !has_inline_profile_section
776}
777
778#[must_use]
784pub fn levenshtein(a: &str, b: &str) -> usize {
785 let n = b.chars().count();
786 let mut prev: Vec<usize> = (0..=n).collect();
787 for (i, a_ch) in a.chars().enumerate() {
788 let mut prev_diag = prev[0];
789 prev[0] = i + 1;
790 for (j, b_ch) in b.chars().enumerate() {
791 let old_prev = prev[j + 1];
792 let cost = usize::from(a_ch != b_ch);
793 prev[j + 1] = (prev[j + 1] + 1).min(prev[j] + 1).min(prev_diag + cost);
794 prev_diag = old_prev;
795 }
796 }
797 prev[n]
798}
799
800#[derive(Debug, Clone, Copy, PartialEq, Eq)]
820pub struct DeprecatedKey {
821 pub path: &'static str,
823 pub replacement: Option<&'static str>,
825 pub since: &'static str,
827 pub remove_in: &'static str,
829}
830
831pub static DEPRECATED_CONFIG_KEYS: &[DeprecatedKey] = &[
837 DeprecatedKey {
838 path: "security.rate_limit.trusted_proxies",
839 replacement: Some("security.trusted_proxies.ranges"),
840 since: "0.5.0",
841 remove_in: "1.0.0",
842 },
843 DeprecatedKey {
844 path: "security.rate_limit.trust_forwarded_headers",
845 replacement: Some("security.trusted_proxies.trust_forwarded_headers"),
846 since: "0.5.0",
847 remove_in: "1.0.0",
848 },
849];
850
851#[must_use]
853pub fn deprecated_config_keys() -> &'static [DeprecatedKey] {
854 DEPRECATED_CONFIG_KEYS
855}
856
857#[must_use]
868pub fn deprecated_env_var_name(path: &str) -> String {
869 format!("AUTUMN_{}", path.to_uppercase().replace('.', "__"))
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq)]
874pub enum DeprecationSource {
875 Toml,
876 Env,
877 Both,
878}
879
880#[derive(Debug, Clone, PartialEq, Eq)]
882pub struct DeprecationFinding {
883 pub path: String,
884 pub replacement: Option<String>,
885 pub since: String,
886 pub remove_in: String,
887 pub source: DeprecationSource,
888}
889
890fn toml_path_present(table: &toml::Table, path: &str) -> bool {
894 let mut current_table = table;
895 let mut segments = path.split('.').peekable();
896
897 while let Some(segment) = segments.next() {
898 if segments.peek().is_none() {
899 return current_table.contains_key(segment);
900 }
901 match current_table.get(segment) {
902 Some(toml::Value::Table(next)) => current_table = next,
903 _ => return false,
904 }
905 }
906 false
907}
908
909#[must_use]
915pub fn detect_deprecated_keys(
916 merged: &toml::Table,
917 env: &dyn Env,
918 registry: &[DeprecatedKey],
919) -> Vec<DeprecationFinding> {
920 let mut findings = Vec::new();
921 for entry in registry {
922 let in_toml = toml_path_present(merged, entry.path);
923 let env_name = deprecated_env_var_name(entry.path);
924 let in_env = env.var(&env_name).is_ok();
925
926 let source = match (in_toml, in_env) {
927 (false, false) => continue,
928 (true, false) => DeprecationSource::Toml,
929 (false, true) => DeprecationSource::Env,
930 (true, true) => DeprecationSource::Both,
931 };
932
933 findings.push(DeprecationFinding {
934 path: entry.path.to_owned(),
935 replacement: entry.replacement.map(str::to_owned),
936 since: entry.since.to_owned(),
937 remove_in: entry.remove_in.to_owned(),
938 source,
939 });
940 }
941 findings
942}
943
944#[must_use]
952pub fn detect_deprecated_keys_for(
953 profile: &str,
954 file_table: &toml::Table,
955 env: &dyn Env,
956 registry: &[DeprecatedKey],
957) -> Vec<DeprecationFinding> {
958 let mut merged = profile_defaults_as_toml(profile);
959 deep_merge(&mut merged, toml::Value::Table(file_table.clone()));
960 let empty_table = toml::Table::new();
961 let merged_table = merged.as_table().unwrap_or(&empty_table);
962 detect_deprecated_keys(merged_table, env, registry)
963}
964
965#[derive(Debug, Error)]
981#[non_exhaustive]
982pub enum ConfigError {
983 #[error("failed to read autumn.toml: {0}")]
985 Io(#[from] std::io::Error),
986
987 #[error("invalid autumn.toml: {0}")]
989 Parse(#[from] toml::de::Error),
990
991 #[error("configuration error: {0}")]
994 Validation(String),
995
996 #[error("credentials error: {0}")]
998 Credentials(String),
999
1000 #[error("dotenv error: {0}")]
1002 Dotenv(String),
1003}
1004
1005#[derive(Debug, Clone, Default, Deserialize)]
1041pub struct BackupConfig {
1042 #[serde(default)]
1050 pub offsite: Option<Box<OffsiteBackupConfig>>,
1051}
1052
1053#[derive(Debug, Clone, Default, Deserialize)]
1060pub struct OffsiteBackupConfig {
1061 #[serde(default)]
1064 pub s3: OffsiteS3Config,
1065
1066 #[serde(default)]
1069 pub prefix: Option<String>,
1070
1071 #[serde(default)]
1075 pub keep: Option<usize>,
1076
1077 #[serde(default)]
1080 pub auto_upload: bool,
1081
1082 #[serde(default)]
1086 pub allow_shared_bucket: bool,
1087}
1088
1089#[derive(Debug, Clone, Default, Deserialize)]
1096pub struct OffsiteS3Config {
1097 #[serde(default)]
1099 pub bucket: Option<String>,
1100
1101 #[serde(default)]
1104 pub region: Option<String>,
1105
1106 #[serde(default)]
1109 pub endpoint: Option<String>,
1110
1111 #[serde(default)]
1113 pub access_key_id_env: Option<String>,
1114
1115 #[serde(default)]
1117 pub secret_access_key_env: Option<String>,
1118
1119 #[serde(default)]
1121 pub force_path_style: bool,
1122}
1123
1124#[derive(Debug, Clone, Deserialize)]
1150pub struct ReplicationConfig {
1151 #[serde(default)]
1154 pub enabled: bool,
1155
1156 #[serde(default = "default_rpo_secs")]
1160 pub rpo_secs: u64,
1161
1162 #[serde(default)]
1165 pub sync_interval_secs: Option<u64>,
1166
1167 #[serde(default = "default_snapshot_interval_secs")]
1170 pub snapshot_interval_secs: u64,
1171
1172 #[serde(default = "default_max_wal_bytes")]
1175 pub max_wal_bytes: u64,
1176
1177 #[serde(default = "default_retention_hours")]
1180 pub retention_hours: u64,
1181
1182 #[serde(default = "default_verify_interval_secs")]
1185 pub verify_interval_secs: u64,
1186
1187 #[serde(default)]
1190 pub prefix: Option<String>,
1191
1192 #[serde(default)]
1197 pub allow_shared_bucket: bool,
1198
1199 #[serde(default)]
1202 pub s3: Option<ReplicationS3Config>,
1203
1204 #[serde(default)]
1208 pub path: Option<String>,
1209}
1210
1211impl Default for ReplicationConfig {
1212 fn default() -> Self {
1213 Self {
1214 enabled: false,
1215 rpo_secs: default_rpo_secs(),
1216 sync_interval_secs: None,
1217 snapshot_interval_secs: default_snapshot_interval_secs(),
1218 max_wal_bytes: default_max_wal_bytes(),
1219 retention_hours: default_retention_hours(),
1220 verify_interval_secs: default_verify_interval_secs(),
1221 prefix: None,
1222 allow_shared_bucket: false,
1223 s3: None,
1224 path: None,
1225 }
1226 }
1227}
1228
1229impl ReplicationConfig {
1230 #[must_use]
1233 pub fn sync_interval(&self) -> std::time::Duration {
1234 let secs = self
1235 .sync_interval_secs
1236 .unwrap_or_else(|| self.rpo_secs.div_euclid(2))
1237 .max(1);
1238 std::time::Duration::from_secs(secs)
1239 }
1240
1241 #[must_use]
1243 pub fn rpo(&self) -> std::time::Duration {
1244 std::time::Duration::from_secs(self.rpo_secs.max(1))
1245 }
1246
1247 #[must_use]
1249 pub fn verify_interval(&self) -> Option<std::time::Duration> {
1250 (self.verify_interval_secs > 0)
1251 .then(|| std::time::Duration::from_secs(self.verify_interval_secs))
1252 }
1253
1254 #[must_use]
1257 pub fn validation_errors(&self) -> Vec<String> {
1258 let mut errors = Vec::new();
1259 if !self.enabled {
1260 return errors;
1261 }
1262 match (&self.s3, &self.path) {
1263 (Some(_), Some(_)) => errors.push(
1264 "[replication] configures both `s3` and `path`; pick exactly one destination"
1265 .to_owned(),
1266 ),
1267 (None, None) => errors.push(
1268 "[replication] enabled = true but no destination is configured; add a \
1269 [replication.s3] section (bucket/region/endpoint plus *_env credential \
1270 indirection) or set `path` to a directory"
1271 .to_owned(),
1272 ),
1273 (Some(s3), None) => {
1274 if s3.bucket.as_deref().unwrap_or("").trim().is_empty() {
1275 errors.push("[replication.s3] bucket is unset".to_owned());
1276 }
1277 if s3
1278 .access_key_id_env
1279 .as_deref()
1280 .unwrap_or("")
1281 .trim()
1282 .is_empty()
1283 || s3
1284 .secret_access_key_env
1285 .as_deref()
1286 .unwrap_or("")
1287 .trim()
1288 .is_empty()
1289 {
1290 errors.push(
1291 "[replication.s3] needs access_key_id_env and secret_access_key_env \
1292 (the NAMES of the environment variables the credentials are read \
1293 from — never the credentials themselves)"
1294 .to_owned(),
1295 );
1296 }
1297 }
1298 (None, Some(path)) => {
1299 if path.trim().is_empty() {
1300 errors.push("[replication] path is empty".to_owned());
1301 }
1302 }
1303 }
1304 if self.rpo_secs == 0 {
1305 errors.push("[replication] rpo_secs must be at least 1".to_owned());
1306 }
1307 if let Some(interval) = self.sync_interval_secs
1314 && self.rpo_secs > 0
1315 && interval > self.rpo_secs
1316 {
1317 errors.push(format!(
1318 "[replication] sync_interval_secs ({interval}) must not exceed rpo_secs \
1319 ({}): shipping less often than the objective cannot meet it",
1320 self.rpo_secs
1321 ));
1322 }
1323 if self.retention_hours == 0 {
1324 errors.push("[replication] retention_hours must be at least 1".to_owned());
1325 }
1326 if self.max_wal_bytes < 32 {
1327 errors.push(
1328 "[replication] max_wal_bytes must be at least 32 (the size of a WAL header)"
1329 .to_owned(),
1330 );
1331 }
1332 errors
1333 }
1334}
1335
1336#[derive(Debug, Clone, Default, Deserialize)]
1341pub struct ReplicationS3Config {
1342 #[serde(default)]
1344 pub bucket: Option<String>,
1345 #[serde(default)]
1347 pub region: Option<String>,
1348 #[serde(default)]
1350 pub endpoint: Option<String>,
1351 #[serde(default)]
1353 pub access_key_id_env: Option<String>,
1354 #[serde(default)]
1356 pub secret_access_key_env: Option<String>,
1357 #[serde(default)]
1359 pub force_path_style: bool,
1360}
1361
1362const fn default_rpo_secs() -> u64 {
1364 10
1365}
1366
1367const fn default_snapshot_interval_secs() -> u64 {
1369 3600
1370}
1371
1372const fn default_max_wal_bytes() -> u64 {
1374 16 * 1024 * 1024
1375}
1376
1377const fn default_retention_hours() -> u64 {
1379 168
1380}
1381
1382const fn default_verify_interval_secs() -> u64 {
1384 21_600
1385}
1386
1387#[derive(Debug, Clone, Default, Deserialize)]
1388pub struct AutumnConfig {
1389 #[serde(skip)]
1392 pub profile: Option<String>,
1393
1394 #[serde(default)]
1396 pub server: ServerConfig,
1397
1398 #[serde(default)]
1426 pub deploy: Option<DeployConfig>,
1427
1428 #[serde(default)]
1436 pub slo: Vec<crate::slo::SloConfig>,
1437
1438 #[cfg(feature = "reporting")]
1456 #[serde(default)]
1457 pub failure_capture: FailureCaptureConfig,
1458
1459 #[serde(default)]
1477 pub cluster: ClusterConfig,
1478
1479 #[serde(default)]
1497 pub shadow: crate::shadow::ShadowConfig,
1498
1499 #[serde(default)]
1519 pub metrics: MetricsConfig,
1520
1521 #[serde(default)]
1530 pub fault_injection: crate::fault_injection::FaultInjectionConfig,
1531
1532 #[serde(default)]
1534 pub database: DatabaseConfig,
1535
1536 #[serde(default)]
1538 pub log: LogConfig,
1539
1540 #[serde(default)]
1542 pub telemetry: TelemetryConfig,
1543
1544 #[serde(default)]
1546 pub health: HealthConfig,
1547
1548 #[serde(default)]
1550 pub actuator: ActuatorConfig,
1551
1552 #[serde(default)]
1554 pub cors: CorsConfig,
1555
1556 #[serde(default)]
1558 pub session: crate::session::SessionConfig,
1559
1560 #[serde(default)]
1562 pub cache: CacheConfig,
1563
1564 #[serde(default)]
1566 pub tenancy: TenancyConfig,
1567
1568 #[serde(default)]
1575 pub push: crate::push::PushConfig,
1576
1577 #[serde(default)]
1579 pub idempotency: IdempotencyConfig,
1580
1581 #[serde(default)]
1583 pub channels: ChannelConfig,
1584
1585 #[serde(default)]
1587 pub realtime: RealtimeConfig,
1588
1589 #[serde(default)]
1591 pub jobs: JobConfig,
1592
1593 #[serde(default)]
1595 pub outbox: OutboxConfig,
1596
1597 #[serde(default)]
1599 pub scheduler: SchedulerConfig,
1600
1601 #[serde(default)]
1606 pub role: ProcessRole,
1607
1608 #[serde(default)]
1610 pub auth: crate::auth::AuthConfig,
1611
1612 #[serde(default)]
1614 pub security: crate::security::config::SecurityConfig,
1615
1616 #[cfg(feature = "i18n")]
1620 #[serde(default)]
1621 pub i18n: crate::i18n::I18nConfig,
1622
1623 #[serde(default)]
1635 pub time_zone: crate::time_zone::TimeZoneConfig,
1636 #[cfg(feature = "storage")]
1639 #[serde(default)]
1640 pub storage: crate::storage::StorageConfig,
1641
1642 #[serde(default)]
1651 pub backup: BackupConfig,
1652 #[serde(default)]
1658 pub replication: Option<Box<ReplicationConfig>>,
1659 #[cfg(feature = "mail")]
1661 #[serde(default)]
1662 pub mail: crate::mail::MailConfig,
1663 #[serde(default, rename = "openapi")]
1669 pub openapi_runtime: OpenApiRuntimeConfig,
1670
1671 #[serde(skip)]
1676 pub credentials: crate::credentials::CredentialsStore,
1677
1678 #[cfg(feature = "http-client")]
1682 #[serde(default, rename = "http")]
1683 pub http: HttpConfig,
1684
1685 #[serde(default)]
1690 pub dev: DevConfig,
1691
1692 #[cfg(feature = "maud")]
1698 #[serde(default)]
1699 pub stories: crate::stories::StoriesConfig,
1700
1701 #[cfg(feature = "reporting")]
1707 #[serde(default)]
1708 pub reporting: ReportingConfig,
1709
1710 #[serde(default)]
1719 pub compression: CompressionConfig,
1720
1721 #[serde(default)]
1737 pub bot_protection: crate::security::captcha::BotProtectionConfig,
1738
1739 #[serde(default)]
1741 pub resilience: ResilienceConfig,
1742
1743 #[serde(default)]
1758 pub seo: SeoConfig,
1759
1760 #[serde(default)]
1764 pub aeo: crate::aeo::AeoConfig,
1765
1766 #[serde(default)]
1772 pub observability: ObservabilityConfig,
1773
1774 #[serde(default)]
1777 pub cost: CostConfig,
1778
1779 #[serde(default)]
1794 pub alerts: Box<crate::alerts::AlertConfig>,
1795
1796 #[serde(default)]
1805 pub retention: RetentionConfig,
1806}
1807
1808#[derive(Debug, Clone, Default, Deserialize)]
1828pub struct DeployTlsConfig {
1829 #[serde(default)]
1832 pub enabled: bool,
1833
1834 #[serde(default)]
1838 pub host: Option<String>,
1839}
1840
1841#[derive(Debug, Clone, Deserialize)]
1861pub struct DeployBakeConfig {
1862 #[serde(default)]
1865 pub duration_secs: u64,
1866 #[serde(default = "default_deploy_bake_interval_secs")]
1868 pub interval_secs: u64,
1869 #[serde(default = "default_deploy_bake_min_requests")]
1872 pub min_requests: u64,
1873 #[serde(default)]
1875 pub max_error_rate: Option<f64>,
1876 #[serde(default)]
1878 pub max_p99_ms: Option<u64>,
1879}
1880
1881const fn default_deploy_bake_interval_secs() -> u64 {
1882 10
1883}
1884
1885const fn default_deploy_bake_min_requests() -> u64 {
1886 20
1887}
1888
1889impl Default for DeployBakeConfig {
1890 fn default() -> Self {
1891 Self {
1892 duration_secs: 0,
1893 interval_secs: default_deploy_bake_interval_secs(),
1894 min_requests: default_deploy_bake_min_requests(),
1895 max_error_rate: None,
1896 max_p99_ms: None,
1897 }
1898 }
1899}
1900
1901#[derive(Debug, Clone, Deserialize)]
1928pub struct DeployConfig {
1929 #[serde(default)]
1935 pub host: Option<String>,
1936
1937 #[serde(default)]
1954 pub hosts: Vec<String>,
1955
1956 #[serde(default = "default_deploy_user")]
1958 pub user: String,
1959
1960 #[serde(default = "default_deploy_ssh_port")]
1962 pub ssh_port: u16,
1963
1964 #[serde(default)]
1968 pub app_name: Option<String>,
1969
1970 #[serde(default)]
1973 pub app_dir: Option<String>,
1974
1975 #[serde(default)]
1977 pub service_name: Option<String>,
1978
1979 #[serde(default = "default_deploy_readiness_timeout_secs")]
1982 pub readiness_timeout_secs: u64,
1983
1984 #[serde(default = "default_deploy_keep_releases")]
1986 pub keep_releases: u32,
1987
1988 #[serde(default = "default_deploy_profile")]
1993 pub profile: String,
1994
1995 #[serde(default)]
1999 pub tls: DeployTlsConfig,
2000
2001 #[serde(default = "default_deploy_install_proxy")]
2016 pub install_proxy: bool,
2017
2018 #[serde(default)]
2021 pub bake: DeployBakeConfig,
2022}
2023
2024const fn default_deploy_install_proxy() -> bool {
2026 true
2027}
2028
2029impl Default for DeployConfig {
2030 fn default() -> Self {
2031 Self {
2032 host: None,
2033 hosts: Vec::new(),
2036 user: default_deploy_user(),
2037 ssh_port: default_deploy_ssh_port(),
2038 app_name: None,
2039 app_dir: None,
2040 service_name: None,
2041 readiness_timeout_secs: default_deploy_readiness_timeout_secs(),
2042 keep_releases: default_deploy_keep_releases(),
2043 profile: default_deploy_profile(),
2044 tls: DeployTlsConfig::default(),
2045 install_proxy: default_deploy_install_proxy(),
2046 bake: DeployBakeConfig::default(),
2047 }
2048 }
2049}
2050
2051impl DeployConfig {
2052 pub fn validate(&self) -> Result<(), String> {
2073 let host = self
2074 .host
2075 .as_deref()
2076 .map(str::trim)
2077 .filter(|host| !host.is_empty());
2078
2079 if host.is_some() && !self.hosts.is_empty() {
2082 return Err(
2083 "[deploy] host and [deploy] hosts are mutually exclusive: keep the \
2084 single-server `[deploy] host = \"<address>\"` or the fleet list \
2085 `[deploy] hosts = [\"<address>\", …]` in autumn.toml, not both (#1621)"
2086 .to_owned(),
2087 );
2088 }
2089
2090 for (index, entry) in self.hosts.iter().enumerate() {
2092 if entry.trim().is_empty() {
2093 return Err(format!(
2094 "[deploy] hosts entry {index} is blank: every fleet entry must be an \
2095 SSH-reachable hostname or IP (#1621)"
2096 ));
2097 }
2098 }
2099
2100 let mut seen: Vec<&str> = Vec::with_capacity(self.hosts.len());
2105 for entry in &self.hosts {
2106 let trimmed = entry.trim();
2107 if seen.contains(&trimmed) {
2108 return Err(format!(
2109 "[deploy] hosts lists `{trimmed}` more than once: each fleet host must \
2110 appear exactly once (#1621)"
2111 ));
2112 }
2113 seen.push(trimmed);
2114 }
2115
2116 if host.is_none() && self.hosts.is_empty() {
2118 return Err(
2119 "[deploy] requires a target host: set `[deploy] host = \"<address>\"` in \
2120 autumn.toml to the SSH-reachable hostname or IP of your server, or \
2121 `[deploy] hosts = [\"<address>\", …]` for a fleet (#1621)"
2122 .to_owned(),
2123 );
2124 }
2125
2126 Ok(())
2127 }
2128}
2129
2130#[derive(Debug, Clone, Deserialize)]
2146#[non_exhaustive]
2147pub struct CostConfig {
2148 #[serde(default)]
2151 pub enabled: bool,
2152 #[serde(default)]
2155 pub defer_threshold: Option<f64>,
2156 #[serde(default = "default_cost_defer_recheck_secs")]
2159 pub defer_recheck_secs: u64,
2160 #[serde(default = "default_cost_max_tenants")]
2163 pub max_tenants: usize,
2164 #[serde(default = "default_cost_signal_refresh_secs")]
2167 pub signal_refresh_secs: u64,
2168}
2169
2170impl CostConfig {
2171 pub fn validate(&self) -> Result<(), String> {
2177 if self.defer_recheck_secs == 0 {
2178 return Err("cost.defer_recheck_secs must be at least 1".to_owned());
2179 }
2180 if self.signal_refresh_secs == 0 {
2181 return Err("cost.signal_refresh_secs must be at least 1".to_owned());
2182 }
2183 if let Some(threshold) = self.defer_threshold
2184 && !(threshold.is_finite() && threshold >= 0.0)
2185 {
2186 return Err(format!(
2187 "cost.defer_threshold must be a finite number >= 0, got {threshold}"
2188 ));
2189 }
2190 Ok(())
2191 }
2192}
2193
2194impl Default for CostConfig {
2195 fn default() -> Self {
2196 Self {
2197 enabled: false,
2198 defer_threshold: None,
2199 defer_recheck_secs: default_cost_defer_recheck_secs(),
2200 max_tenants: default_cost_max_tenants(),
2201 signal_refresh_secs: default_cost_signal_refresh_secs(),
2202 }
2203 }
2204}
2205
2206const fn default_cost_defer_recheck_secs() -> u64 {
2207 30
2208}
2209
2210const fn default_cost_max_tenants() -> usize {
2211 1000
2212}
2213
2214const fn default_cost_signal_refresh_secs() -> u64 {
2215 5
2216}
2217
2218#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
2250pub struct ObservabilityConfig {
2251 #[serde(default)]
2257 pub server_timing: Option<bool>,
2258}
2259
2260pub(crate) fn server_timing_enabled(cfg: &AutumnConfig) -> bool {
2269 if let Some(explicit) = cfg.observability.server_timing {
2270 return explicit;
2271 }
2272 matches!(cfg.profile.as_deref(), Some("dev" | "development"))
2273}
2274
2275#[derive(Debug, Clone, Default, Deserialize)]
2287pub struct SeoConfig {
2288 pub base_url: Option<String>,
2293
2294 #[serde(default)]
2296 pub robots: RobotsConfig,
2297}
2298
2299#[derive(Debug, Clone, Default, Deserialize)]
2304pub struct RobotsConfig {
2305 pub allow_all: Option<bool>,
2310
2311 #[serde(default)]
2315 pub additional_rules: Vec<String>,
2316
2317 pub sitemap_url: Option<String>,
2321}
2322
2323#[cfg(feature = "reporting")]
2337#[derive(Debug, Clone, Deserialize)]
2338pub struct ReportingConfig {
2339 #[serde(default = "default_reporting_enabled")]
2345 pub enabled: bool,
2346 #[serde(default = "default_reporting_sample_rate")]
2351 pub sample_rate: f64,
2352}
2353
2354#[cfg(feature = "reporting")]
2355impl Default for ReportingConfig {
2356 fn default() -> Self {
2357 Self {
2358 enabled: default_reporting_enabled(),
2359 sample_rate: default_reporting_sample_rate(),
2360 }
2361 }
2362}
2363
2364#[cfg(feature = "reporting")]
2365const fn default_reporting_enabled() -> bool {
2366 true
2367}
2368
2369#[cfg(feature = "reporting")]
2370const fn default_reporting_sample_rate() -> f64 {
2371 1.0
2372}
2373
2374#[cfg(feature = "reporting")]
2394#[derive(Debug, Clone, Deserialize)]
2395pub struct FailureCaptureConfig {
2396 #[serde(default = "default_failure_capture_enabled")]
2402 pub enabled: bool,
2403
2404 #[serde(default = "default_failure_capture_dir")]
2407 pub dir: String,
2408
2409 #[serde(default = "default_failure_capture_max_body_bytes")]
2414 pub max_body_bytes: usize,
2415
2416 #[serde(default = "default_failure_capture_max_capsule_bytes")]
2421 pub max_capsule_bytes: usize,
2422
2423 #[serde(default = "default_failure_capture_max_capsules")]
2426 pub max_capsules: usize,
2427}
2428
2429#[cfg(feature = "reporting")]
2430impl Default for FailureCaptureConfig {
2431 fn default() -> Self {
2432 Self {
2433 enabled: default_failure_capture_enabled(),
2434 dir: default_failure_capture_dir(),
2435 max_body_bytes: default_failure_capture_max_body_bytes(),
2436 max_capsule_bytes: default_failure_capture_max_capsule_bytes(),
2437 max_capsules: default_failure_capture_max_capsules(),
2438 }
2439 }
2440}
2441
2442#[cfg(feature = "reporting")]
2443const fn default_failure_capture_enabled() -> bool {
2444 false
2445}
2446
2447#[cfg(feature = "reporting")]
2448fn default_failure_capture_dir() -> String {
2449 "tmp/autumn-capsules".to_owned()
2450}
2451
2452#[cfg(feature = "reporting")]
2453const fn default_failure_capture_max_body_bytes() -> usize {
2454 65_536
2455}
2456
2457#[cfg(feature = "reporting")]
2458const fn default_failure_capture_max_capsule_bytes() -> usize {
2459 1_048_576
2460}
2461
2462#[cfg(feature = "reporting")]
2463const fn default_failure_capture_max_capsules() -> usize {
2464 50
2465}
2466
2467#[derive(Debug, Clone, Deserialize)]
2480pub struct DevConfig {
2481 #[serde(default = "default_inspector_path")]
2486 pub inspector_path: String,
2487
2488 #[serde(default = "default_inspector_capacity")]
2493 pub inspector_capacity: usize,
2494
2495 #[serde(default = "default_inspector_n_plus_one_threshold")]
2500 pub inspector_n_plus_one_threshold: usize,
2501}
2502
2503impl Default for DevConfig {
2504 fn default() -> Self {
2505 Self {
2506 inspector_path: default_inspector_path(),
2507 inspector_capacity: default_inspector_capacity(),
2508 inspector_n_plus_one_threshold: default_inspector_n_plus_one_threshold(),
2509 }
2510 }
2511}
2512
2513fn default_inspector_path() -> String {
2514 "/_autumn/inspect".to_owned()
2515}
2516
2517const fn default_inspector_capacity() -> usize {
2518 100
2519}
2520
2521const fn default_inspector_n_plus_one_threshold() -> usize {
2522 crate::inspector::DEFAULT_N_PLUS_ONE_THRESHOLD
2523}
2524
2525#[cfg(feature = "http-client")]
2527#[derive(Debug, Clone, Default, Deserialize)]
2528pub struct HttpConfig {
2529 #[serde(default)]
2531 pub client: HttpClientConfig,
2532}
2533
2534#[cfg(feature = "http-client")]
2548#[derive(Debug, Clone, Deserialize)]
2549pub struct HttpClientConfig {
2550 #[serde(default = "default_http_timeout_secs")]
2552 pub timeout_secs: u64,
2553
2554 #[serde(default = "default_http_max_retries")]
2557 pub max_retries: u32,
2558
2559 #[serde(default = "default_http_max_retry_after_secs")]
2563 pub max_retry_after_secs: u64,
2564
2565 #[serde(default = "default_http_max_backoff_ms")]
2568 pub max_backoff_ms: u64,
2569
2570 #[serde(default)]
2577 pub base_urls: std::collections::HashMap<String, String>,
2578
2579 #[serde(default = "default_http_send_deadline_header")]
2583 pub send_deadline_header: bool,
2584
2585 #[serde(default)]
2588 pub retry_budget: RetryBudgetConfig,
2589
2590 #[serde(default)]
2593 pub adaptive_throttle: AdaptiveThrottleConfig,
2594}
2595
2596#[cfg(feature = "http-client")]
2612#[derive(Debug, Clone, PartialEq, Deserialize)]
2613pub struct RetryBudgetConfig {
2614 #[serde(default = "default_retry_budget_enabled")]
2616 pub enabled: bool,
2617 #[serde(default = "default_retry_budget_capacity")]
2619 pub capacity: u32,
2620 #[serde(default = "default_retry_budget_transient_cost")]
2623 pub transient_cost: u32,
2624 #[serde(default = "default_retry_budget_throttling_cost")]
2626 pub throttling_cost: u32,
2627 #[serde(default = "default_retry_budget_retry_ratio")]
2630 pub retry_ratio: f64,
2631}
2632
2633#[cfg(feature = "http-client")]
2634const fn default_http_send_deadline_header() -> bool {
2635 true
2636}
2637
2638#[cfg(feature = "http-client")]
2639const fn default_retry_budget_enabled() -> bool {
2640 true
2641}
2642
2643#[cfg(feature = "http-client")]
2644const fn default_retry_budget_capacity() -> u32 {
2645 500
2646}
2647
2648#[cfg(feature = "http-client")]
2649const fn default_retry_budget_transient_cost() -> u32 {
2650 14
2651}
2652
2653#[cfg(feature = "http-client")]
2654const fn default_retry_budget_throttling_cost() -> u32 {
2655 5
2656}
2657
2658#[cfg(feature = "http-client")]
2659const fn default_retry_budget_retry_ratio() -> f64 {
2660 0.1
2661}
2662
2663#[cfg(feature = "http-client")]
2664impl RetryBudgetConfig {
2665 pub fn validate(&self) -> Result<(), ConfigError> {
2673 if !self.enabled {
2674 return Ok(());
2675 }
2676 if !(0.0..=1.0).contains(&self.retry_ratio) {
2677 return Err(ConfigError::Validation(format!(
2678 "http.client.retry_budget.retry_ratio must be in 0.0..=1.0, got {}",
2679 self.retry_ratio
2680 )));
2681 }
2682 if self.transient_cost == 0 || self.throttling_cost == 0 {
2683 return Err(ConfigError::Validation(
2684 "http.client.retry_budget costs must be 1 or more; set enabled = false \
2685 to turn the budget off"
2686 .to_owned(),
2687 ));
2688 }
2689 Ok(())
2690 }
2691}
2692
2693#[cfg(feature = "http-client")]
2694impl Default for RetryBudgetConfig {
2695 fn default() -> Self {
2696 Self {
2697 enabled: default_retry_budget_enabled(),
2698 capacity: default_retry_budget_capacity(),
2699 transient_cost: default_retry_budget_transient_cost(),
2700 throttling_cost: default_retry_budget_throttling_cost(),
2701 retry_ratio: default_retry_budget_retry_ratio(),
2702 }
2703 }
2704}
2705
2706#[cfg(feature = "http-client")]
2720#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
2721#[non_exhaustive]
2722pub struct AdaptiveThrottleConfig {
2723 #[serde(default)]
2725 pub enabled: bool,
2726 #[serde(default = "default_throttle_k")]
2728 pub k: f64,
2729 #[serde(default = "default_throttle_window_secs")]
2731 pub window_secs: u64,
2732}
2733
2734#[cfg(feature = "http-client")]
2735const fn default_throttle_k() -> f64 {
2736 2.0
2737}
2738
2739#[cfg(feature = "http-client")]
2740const fn default_throttle_window_secs() -> u64 {
2741 120
2742}
2743
2744#[cfg(feature = "http-client")]
2745impl Default for AdaptiveThrottleConfig {
2746 fn default() -> Self {
2747 Self {
2748 enabled: false,
2749 k: default_throttle_k(),
2750 window_secs: default_throttle_window_secs(),
2751 }
2752 }
2753}
2754
2755#[cfg(feature = "http-client")]
2756impl AdaptiveThrottleConfig {
2757 pub fn validate(&self) -> Result<(), ConfigError> {
2764 if !(self.k >= 1.0 && self.k.is_finite()) {
2765 return Err(ConfigError::Validation(format!(
2766 "http.client.adaptive_throttle.k = {} must be a number >= 1.0: a smaller k \
2767 rejects calls to a healthy host",
2768 self.k
2769 )));
2770 }
2771 if self.window_secs == 0 {
2772 return Err(ConfigError::Validation(
2773 "http.client.adaptive_throttle.window_secs must be at least 1".to_owned(),
2774 ));
2775 }
2776 Ok(())
2777 }
2778}
2779
2780#[cfg(feature = "http-client")]
2781const fn default_http_timeout_secs() -> u64 {
2782 30
2783}
2784
2785#[cfg(feature = "http-client")]
2786const fn default_http_max_retries() -> u32 {
2787 3
2788}
2789
2790#[cfg(feature = "http-client")]
2791const fn default_http_max_retry_after_secs() -> u64 {
2792 10
2793}
2794
2795#[cfg(feature = "http-client")]
2796const fn default_http_max_backoff_ms() -> u64 {
2797 crate::backoff::DEFAULT_HTTP_MAX_BACKOFF_MS
2798}
2799
2800#[cfg(feature = "http-client")]
2801impl Default for HttpClientConfig {
2802 fn default() -> Self {
2803 Self {
2804 timeout_secs: default_http_timeout_secs(),
2805 max_retries: default_http_max_retries(),
2806 max_retry_after_secs: default_http_max_retry_after_secs(),
2807 max_backoff_ms: default_http_max_backoff_ms(),
2808 base_urls: std::collections::HashMap::new(),
2809 send_deadline_header: default_http_send_deadline_header(),
2810 retry_budget: RetryBudgetConfig::default(),
2811 adaptive_throttle: AdaptiveThrottleConfig::default(),
2812 }
2813 }
2814}
2815
2816impl axum::extract::FromRequestParts<crate::AppState> for AutumnConfig {
2817 type Rejection = crate::AutumnError;
2818
2819 async fn from_request_parts(
2820 _parts: &mut http::request::Parts,
2821 state: &crate::AppState,
2822 ) -> Result<Self, Self::Rejection> {
2823 state
2824 .extension::<Self>()
2825 .as_deref()
2826 .cloned()
2827 .ok_or_else(|| crate::AutumnError::service_unavailable_msg("Config is not available"))
2828 }
2829}
2830
2831#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2833#[serde(rename_all = "snake_case")]
2834pub enum ChannelBackend {
2835 #[serde(alias = "local", alias = "memory")]
2837 #[default]
2838 InProcess,
2839 Redis,
2841}
2842
2843impl ChannelBackend {
2844 #[must_use]
2846 pub fn from_env_value(value: &str) -> Option<Self> {
2847 match value.trim().to_ascii_lowercase().as_str() {
2848 "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
2849 "redis" => Some(Self::Redis),
2850 _ => None,
2851 }
2852 }
2853}
2854
2855#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
2870pub struct RealtimeConfig {
2871 #[serde(default)]
2874 pub max_connections: Option<usize>,
2875
2876 #[serde(default)]
2879 pub max_message_bytes: Option<usize>,
2880
2881 #[serde(default)]
2883 pub ping_interval_ms: Option<u64>,
2884
2885 #[serde(default)]
2888 pub idle_timeout_ms: Option<u64>,
2889}
2890
2891impl RealtimeConfig {
2892 pub fn validate(&self) -> Result<(), ConfigError> {
2898 if self.max_message_bytes == Some(0) {
2899 return Err(ConfigError::Validation(
2900 "realtime.max_message_bytes must be greater than zero".to_owned(),
2901 ));
2902 }
2903 Ok(())
2904 }
2905}
2906
2907#[derive(Debug, Clone, Deserialize)]
2909pub struct ChannelConfig {
2910 #[serde(default)]
2912 pub backend: ChannelBackend,
2913 #[serde(default = "default_channel_capacity")]
2915 pub capacity: usize,
2916 #[serde(default = "default_channel_replay_buffer")]
2922 pub replay_buffer: usize,
2923 #[serde(default)]
2925 pub redis: ChannelRedisConfig,
2926}
2927
2928impl Default for ChannelConfig {
2929 fn default() -> Self {
2930 Self {
2931 backend: ChannelBackend::default(),
2932 capacity: default_channel_capacity(),
2933 replay_buffer: default_channel_replay_buffer(),
2934 redis: ChannelRedisConfig::default(),
2935 }
2936 }
2937}
2938
2939#[derive(Debug, Clone, Deserialize)]
2941pub struct ChannelRedisConfig {
2942 #[serde(default)]
2944 pub url: Option<String>,
2945 #[serde(default = "default_channels_redis_prefix")]
2947 pub key_prefix: String,
2948}
2949
2950impl Default for ChannelRedisConfig {
2951 fn default() -> Self {
2952 Self {
2953 url: None,
2954 key_prefix: default_channels_redis_prefix(),
2955 }
2956 }
2957}
2958
2959const fn default_channel_capacity() -> usize {
2960 32
2961}
2962
2963const fn default_channel_replay_buffer() -> usize {
2964 256
2965}
2966
2967fn default_channels_redis_prefix() -> String {
2968 "autumn:channels".to_owned()
2969}
2970
2971#[derive(Debug, Clone, Deserialize)]
2997pub struct ClusterConfig {
2998 #[serde(default)]
3001 pub enabled: bool,
3002
3003 #[serde(default)]
3009 pub secret: Option<secrecy::SecretString>,
3010
3011 #[serde(default = "default_cluster_name")]
3014 pub cluster_name: String,
3015
3016 #[serde(default = "default_cluster_bind_addr")]
3019 pub bind_addr: String,
3020
3021 #[serde(default)]
3024 pub advertise_addr: Option<String>,
3025
3026 #[serde(default)]
3028 pub seed_peers: Vec<String>,
3029
3030 #[serde(default)]
3032 pub node_id: Option<String>,
3033
3034 #[serde(default = "default_cluster_push_interval_ms")]
3037 pub push_interval_ms: u64,
3038
3039 #[serde(default = "default_cluster_suspicion_timeout_ms")]
3042 pub suspicion_timeout_ms: u64,
3043}
3044
3045fn default_cluster_name() -> String {
3046 "autumn".to_owned()
3047}
3048
3049fn default_cluster_bind_addr() -> String {
3050 "127.0.0.1:0".to_owned()
3051}
3052
3053const fn default_cluster_push_interval_ms() -> u64 {
3054 500
3055}
3056
3057const fn default_cluster_suspicion_timeout_ms() -> u64 {
3058 2_500
3059}
3060
3061pub(crate) const MIN_CLUSTER_SECRET_LEN: usize = 16;
3063
3064pub(crate) const MIN_CLUSTER_PUSH_INTERVAL_MS: u64 = 10;
3066
3067pub(crate) const MIN_CLUSTER_SUSPICION_MULTIPLE: u64 = 3;
3069
3070pub(crate) const MAX_CLUSTER_IDENT_LEN: usize = 64;
3075
3076pub(crate) const CLUSTER_CELL_KEY_SEPARATOR: char = '#';
3080
3081fn validate_cluster_ident(field: &str, value: &str) -> Result<(), ConfigError> {
3086 if value.trim().is_empty() {
3087 return Err(ConfigError::Validation(format!(
3088 "{field} must not be empty: it identifies this node (or its cluster) in every frame"
3089 )));
3090 }
3091 let len = value.len();
3092 if len > MAX_CLUSTER_IDENT_LEN {
3093 return Err(ConfigError::Validation(format!(
3094 "{field} must be at most {MAX_CLUSTER_IDENT_LEN} bytes, got {len} ({value:?}); it \
3095 travels in every cluster frame and is covered by the MAC"
3096 )));
3097 }
3098 if value.contains(CLUSTER_CELL_KEY_SEPARATOR) {
3099 return Err(ConfigError::Validation(format!(
3100 "{field} must not contain {CLUSTER_CELL_KEY_SEPARATOR:?} ({value:?}): it separates \
3101 the node id from the incarnation in counter cell keys, and an id containing it \
3102 would make two different cells collide"
3103 )));
3104 }
3105 Ok(())
3106}
3107
3108fn parse_cluster_addr(field: &str, value: &str) -> Result<std::net::SocketAddr, ConfigError> {
3111 value.parse::<std::net::SocketAddr>().map_err(|error| {
3112 ConfigError::Validation(format!(
3113 "{field} must be a socket address of the form host:port with an IP literal \
3114 (hostnames are not resolved), got {value:?}: {error}"
3115 ))
3116 })
3117}
3118
3119fn reject_ephemeral_cluster_port(
3126 field: &str,
3127 value: &str,
3128 addr: std::net::SocketAddr,
3129) -> Result<(), ConfigError> {
3130 if addr.port() == 0 {
3131 return Err(ConfigError::Validation(format!(
3132 "{field} is {value:?}, which no peer can dial: port 0 means \"any free port\" and is \
3133 only meaningful on cluster.bind_addr, where the node advertises the port it was \
3134 actually given (see docs/guide/clustering.md)"
3135 )));
3136 }
3137 Ok(())
3138}
3139
3140impl Default for ClusterConfig {
3141 fn default() -> Self {
3142 Self {
3143 enabled: false,
3144 secret: None,
3145 cluster_name: default_cluster_name(),
3146 bind_addr: default_cluster_bind_addr(),
3147 advertise_addr: None,
3148 seed_peers: Vec::new(),
3149 node_id: None,
3150 push_interval_ms: default_cluster_push_interval_ms(),
3151 suspicion_timeout_ms: default_cluster_suspicion_timeout_ms(),
3152 }
3153 }
3154}
3155
3156impl ClusterConfig {
3157 pub fn validate(&self) -> Result<(), ConfigError> {
3183 if self.enabled {
3186 self.validate_secret()?;
3187 }
3188
3189 if self.push_interval_ms < MIN_CLUSTER_PUSH_INTERVAL_MS {
3190 return Err(ConfigError::Validation(format!(
3191 "cluster.push_interval_ms must be at least {MIN_CLUSTER_PUSH_INTERVAL_MS}ms, \
3192 got {}",
3193 self.push_interval_ms
3194 )));
3195 }
3196 let min_suspicion = self
3199 .push_interval_ms
3200 .saturating_mul(MIN_CLUSTER_SUSPICION_MULTIPLE);
3201 if self.suspicion_timeout_ms < min_suspicion {
3202 return Err(ConfigError::Validation(format!(
3203 "cluster.suspicion_timeout_ms ({}) must be at least \
3204 {MIN_CLUSTER_SUSPICION_MULTIPLE}x cluster.push_interval_ms ({}), i.e. at least \
3205 {min_suspicion}ms: below that ratio one delayed push evicts a healthy peer and \
3206 the view flaps",
3207 self.suspicion_timeout_ms, self.push_interval_ms
3208 )));
3209 }
3210
3211 validate_cluster_ident("cluster.cluster_name", &self.cluster_name)?;
3212 if let Some(node_id) = self.node_id.as_deref() {
3213 validate_cluster_ident("cluster.node_id", node_id)?;
3214 }
3215
3216 let bind_addr = parse_cluster_addr("cluster.bind_addr", &self.bind_addr)?;
3219 let advertise_addr = match self.advertise_addr.as_deref() {
3220 Some(addr) => {
3221 let parsed = parse_cluster_addr("cluster.advertise_addr", addr)?;
3222 reject_ephemeral_cluster_port("cluster.advertise_addr", addr, parsed)?;
3223 parsed
3224 }
3225 None => bind_addr,
3226 };
3227 for (index, peer) in self.seed_peers.iter().enumerate() {
3228 let field = format!("cluster.seed_peers[{index}]");
3229 let parsed = parse_cluster_addr(&field, peer)?;
3230 reject_ephemeral_cluster_port(&field, peer, parsed)?;
3231 }
3232
3233 if self.enabled && advertise_addr.ip().is_unspecified() {
3236 let source = if self.advertise_addr.is_some() {
3237 "cluster.advertise_addr"
3238 } else {
3239 "cluster.bind_addr"
3240 };
3241 return Err(ConfigError::Validation(format!(
3242 "{source} advertises {advertise_addr}, which no peer can dial: binding a \
3243 wildcard address is fine, but it requires an explicit, non-wildcard \
3244 cluster.advertise_addr (see docs/guide/clustering.md)"
3245 )));
3246 }
3247
3248 Ok(())
3249 }
3250
3251 fn validate_secret(&self) -> Result<(), ConfigError> {
3253 let Some(secret) = self.secret.as_ref() else {
3254 return Err(ConfigError::Validation(
3255 "cluster.secret is required when cluster.enabled = true: the cluster transport \
3256 is authenticated (HMAC-SHA256) and has no unauthenticated mode — set it with \
3257 AUTUMN_CLUSTER__SECRET"
3258 .to_owned(),
3259 ));
3260 };
3261 let len = secrecy::ExposeSecret::expose_secret(secret).len();
3262 if len < MIN_CLUSTER_SECRET_LEN {
3263 return Err(ConfigError::Validation(format!(
3264 "cluster.secret must be at least {MIN_CLUSTER_SECRET_LEN} bytes, got {len}: a \
3265 short shared key is a guessable one, and every member signs every frame with it"
3266 )));
3267 }
3268 Ok(())
3269 }
3270}
3271
3272#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
3276#[serde(rename_all = "lowercase")]
3277#[non_exhaustive]
3278pub enum CacheBackend {
3279 #[default]
3281 Memory,
3282 Redis,
3284}
3285
3286impl CacheBackend {
3287 pub(crate) fn from_env_value(value: &str) -> Option<Self> {
3288 match value.trim().to_ascii_lowercase().as_str() {
3289 "memory" => Some(Self::Memory),
3290 "redis" => Some(Self::Redis),
3291 _ => None,
3292 }
3293 }
3294}
3295
3296#[derive(Debug, Clone, Default, serde::Deserialize)]
3311pub struct CacheConfig {
3312 #[serde(default)]
3314 pub backend: CacheBackend,
3315
3316 #[serde(default)]
3318 pub redis: CacheRedisConfig,
3319}
3320
3321impl CacheConfig {
3322 #[must_use]
3324 pub fn is_memory(&self) -> bool {
3325 self.backend == CacheBackend::Memory
3326 }
3327
3328 #[must_use]
3330 pub fn is_redis(&self) -> bool {
3331 self.backend == CacheBackend::Redis
3332 }
3333}
3334
3335#[derive(Debug, Clone, serde::Deserialize)]
3337pub struct CacheRedisConfig {
3338 #[serde(default)]
3340 pub url: Option<String>,
3341
3342 #[serde(default = "default_cache_redis_key_prefix")]
3344 pub key_prefix: String,
3345}
3346
3347impl Default for CacheRedisConfig {
3348 fn default() -> Self {
3349 Self {
3350 url: None,
3351 key_prefix: default_cache_redis_key_prefix(),
3352 }
3353 }
3354}
3355
3356fn default_cache_redis_key_prefix() -> String {
3357 "autumn:cache".to_owned()
3358}
3359
3360#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
3362#[serde(rename_all = "snake_case")]
3363pub enum SchedulerBackend {
3364 #[serde(alias = "local", alias = "memory")]
3366 #[default]
3367 InProcess,
3368 Postgres,
3373 #[serde(alias = "single_host")]
3379 Sqlite,
3380}
3381
3382impl SchedulerBackend {
3383 #[must_use]
3385 pub fn from_env_value(value: &str) -> Option<Self> {
3386 match value.trim().to_ascii_lowercase().as_str() {
3387 "in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
3388 "postgres" | "postgresql" => Some(Self::Postgres),
3389 "sqlite" | "single_host" | "single-host" => Some(Self::Sqlite),
3390 _ => None,
3391 }
3392 }
3393
3394 #[must_use]
3404 pub const fn is_fleet_distributed(self) -> bool {
3405 matches!(self, Self::Postgres | Self::Sqlite)
3406 }
3407}
3408
3409#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
3426#[serde(rename_all = "snake_case")]
3427pub enum ProcessRole {
3428 #[serde(
3431 alias = "all",
3432 alias = "combined",
3433 alias = "web_and_worker",
3434 alias = "server_and_worker"
3435 )]
3436 #[default]
3437 Combined,
3438 #[serde(alias = "server", alias = "http")]
3440 Web,
3441 #[serde(alias = "jobs", alias = "worker_only")]
3443 Worker,
3444}
3445
3446impl ProcessRole {
3447 #[must_use]
3453 pub fn from_env_value(value: &str) -> Option<Self> {
3454 match value.trim().to_ascii_lowercase().as_str() {
3455 "combined" | "all" | "web_and_worker" | "server_and_worker" => Some(Self::Combined),
3456 "web" | "server" | "http" => Some(Self::Web),
3457 "worker" | "jobs" | "worker_only" => Some(Self::Worker),
3458 _ => None,
3459 }
3460 }
3461
3462 #[must_use]
3464 pub const fn as_str(self) -> &'static str {
3465 match self {
3466 Self::Combined => "combined",
3467 Self::Web => "web",
3468 Self::Worker => "worker",
3469 }
3470 }
3471
3472 #[must_use]
3475 pub const fn serves_http(self) -> bool {
3476 matches!(self, Self::Combined | Self::Web)
3477 }
3478
3479 #[must_use]
3482 pub const fn runs_workers(self) -> bool {
3483 matches!(self, Self::Combined | Self::Worker)
3484 }
3485}
3486
3487#[must_use]
3513pub fn split_role_requires_durable_backend(role: ProcessRole, jobs_backend: &str) -> bool {
3514 role != ProcessRole::Combined && !matches!(jobs_backend, "postgres" | "redis" | "sqlite")
3515}
3516
3517#[must_use]
3529pub fn is_in_memory_sqlite_target(url: &str) -> bool {
3530 if url.starts_with("file:") {
3531 return url == "file::memory:"
3532 || url.starts_with("file::memory:?")
3533 || url.contains("mode=memory");
3534 }
3535 let target = url
3536 .strip_prefix("sqlite://")
3537 .or_else(|| url.strip_prefix("sqlite:"))
3538 .unwrap_or(url);
3539 target.is_empty()
3543 || target == ":memory:"
3544 || target == "file::memory:"
3545 || target.starts_with("file::memory:?")
3546 || target.contains("mode=memory")
3547}
3548
3549#[must_use]
3562pub fn split_role_requires_file_backed_sqlite(
3563 role: ProcessRole,
3564 jobs_backend: &str,
3565 database_url: Option<&str>,
3566) -> bool {
3567 role != ProcessRole::Combined
3568 && jobs_backend == "sqlite"
3569 && database_url.is_some_and(is_in_memory_sqlite_target)
3570}
3571
3572#[derive(Debug, Clone, Deserialize)]
3610pub struct RetentionConfig {
3611 #[serde(default = "default_retention_sweep_interval")]
3616 pub sweep_interval: String,
3617
3618 #[serde(default)]
3629 pub job_history: Option<String>,
3630
3631 #[serde(default)]
3635 pub commit_hooks: Option<String>,
3636
3637 #[serde(default)]
3645 pub job_tracking: Option<String>,
3646
3647 #[serde(default)]
3654 pub idempotency: Option<String>,
3655
3656 #[serde(default)]
3659 pub experiment_assignments: Option<String>,
3660
3661 #[serde(default)]
3667 pub webhook_replay: Option<String>,
3668
3669 #[serde(default)]
3673 pub sessions: Option<String>,
3674
3675 #[serde(default)]
3682 pub audit_archives: Option<String>,
3683
3684 #[serde(default)]
3693 pub custom_domains: Option<String>,
3694}
3695
3696impl Default for RetentionConfig {
3697 fn default() -> Self {
3698 Self {
3699 sweep_interval: default_retention_sweep_interval(),
3700 job_history: None,
3701 commit_hooks: None,
3702 job_tracking: None,
3703 idempotency: None,
3704 experiment_assignments: None,
3705 webhook_replay: None,
3706 sessions: None,
3707 audit_archives: None,
3708 custom_domains: None,
3709 }
3710 }
3711}
3712
3713fn default_retention_sweep_interval() -> String {
3714 "1h".to_owned()
3715}
3716
3717impl RetentionConfig {
3718 #[must_use]
3725 pub fn windows(&self) -> [(&'static str, Option<&str>); 9] {
3726 [
3727 ("job_history", self.job_history.as_deref()),
3728 ("commit_hooks", self.commit_hooks.as_deref()),
3729 ("job_tracking", self.job_tracking.as_deref()),
3730 ("idempotency", self.idempotency.as_deref()),
3731 (
3732 "experiment_assignments",
3733 self.experiment_assignments.as_deref(),
3734 ),
3735 ("webhook_replay", self.webhook_replay.as_deref()),
3736 ("sessions", self.sessions.as_deref()),
3737 ("audit_archives", self.audit_archives.as_deref()),
3738 ("custom_domains", self.custom_domains.as_deref()),
3739 ]
3740 }
3741
3742 #[must_use]
3749 pub fn any_window_configured(&self) -> bool {
3750 self.windows().iter().any(|(_, window)| window.is_some())
3751 }
3752
3753 #[must_use]
3759 pub fn window(&self, key: &str) -> Option<std::time::Duration> {
3760 self.windows()
3761 .into_iter()
3762 .find(|(name, _)| *name == key)
3763 .and_then(|(_, window)| window)
3764 .and_then(crate::task::parse_duration)
3765 }
3766
3767 #[must_use]
3770 pub fn sweep_interval_duration(&self) -> std::time::Duration {
3771 crate::task::parse_duration(&self.sweep_interval)
3772 .unwrap_or(std::time::Duration::from_secs(3_600))
3773 }
3774
3775 pub fn validate(&self) -> Result<(), ConfigError> {
3784 for (key, window) in self.windows() {
3785 let Some(raw) = window else { continue };
3786 let Some(parsed) = crate::task::parse_duration(raw) else {
3791 return Err(ConfigError::Validation(format!(
3792 "retention.{key} = {raw:?} is not a valid non-zero duration: use a \
3793 string like \"90d\", \"12h\", or \"1h 30m\". A zero window would \
3794 purge the dataset as soon as it is written; remove the key entirely \
3795 to keep today's behavior (see docs/guide/data-retention.md)"
3796 )));
3797 };
3798 if parsed.as_secs() > u64::from(u32::MAX) {
3805 return Err(ConfigError::Validation(format!(
3806 "retention.{key} = {raw:?} is longer than the maximum supported \
3807 retention window ({} years). Remove the key entirely to keep the \
3808 data forever, which is what a window that long means in practice \
3809 (see docs/guide/data-retention.md)",
3810 u64::from(u32::MAX) / (365 * 86_400)
3811 )));
3812 }
3813 }
3814
3815 if crate::task::parse_duration(&self.sweep_interval).is_none() {
3819 return Err(ConfigError::Validation(format!(
3820 "retention.sweep_interval = {:?} is not a valid non-zero duration: use a \
3821 string like \"1h\" or \"30m\" (see docs/guide/data-retention.md)",
3822 self.sweep_interval
3823 )));
3824 }
3825 Ok(())
3826 }
3827}
3828
3829#[derive(Debug, Clone, Deserialize)]
3831pub struct SchedulerConfig {
3832 #[serde(default)]
3834 pub backend: SchedulerBackend,
3835 #[serde(default = "default_scheduler_lease_ttl_secs")]
3837 pub lease_ttl_secs: u64,
3838 #[serde(default)]
3840 pub replica_id: Option<String>,
3841 #[serde(default = "default_scheduler_key_prefix")]
3843 pub key_prefix: String,
3844}
3845
3846impl SchedulerConfig {
3847 #[must_use]
3849 pub fn resolved_replica_id(&self) -> String {
3850 self.replica_id
3851 .as_ref()
3852 .filter(|id| !id.trim().is_empty())
3853 .cloned()
3854 .or_else(|| std::env::var("FLY_MACHINE_ID").ok())
3855 .or_else(|| std::env::var("HOSTNAME").ok())
3856 .unwrap_or_else(|| format!("pid-{}", std::process::id()))
3857 }
3858
3859 pub fn validate(&self) -> Result<(), ConfigError> {
3866 if self.lease_ttl_secs == 0 {
3867 return Err(ConfigError::Validation(
3868 "scheduler.lease_ttl_secs must be greater than zero".to_owned(),
3869 ));
3870 }
3871 if self.key_prefix.trim().is_empty() {
3872 return Err(ConfigError::Validation(
3873 "scheduler.key_prefix must not be empty".to_owned(),
3874 ));
3875 }
3876 Ok(())
3877 }
3878}
3879
3880impl Default for SchedulerConfig {
3881 fn default() -> Self {
3882 Self {
3883 backend: SchedulerBackend::default(),
3884 lease_ttl_secs: default_scheduler_lease_ttl_secs(),
3885 replica_id: None,
3886 key_prefix: default_scheduler_key_prefix(),
3887 }
3888 }
3889}
3890
3891const fn default_scheduler_lease_ttl_secs() -> u64 {
3892 300
3893}
3894
3895fn default_scheduler_key_prefix() -> String {
3896 "autumn:scheduler".to_owned()
3897}
3898
3899#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
3901#[serde(rename_all = "lowercase")]
3902#[non_exhaustive]
3903pub enum IdempotencyBackend {
3904 #[default]
3906 Memory,
3907 Redis,
3909 Database,
3911}
3912
3913impl IdempotencyBackend {
3914 #[must_use]
3916 pub fn from_env_value(value: &str) -> Option<Self> {
3917 match value.trim().to_ascii_lowercase().as_str() {
3918 "memory" | "mem" => Some(Self::Memory),
3919 "redis" => Some(Self::Redis),
3920 "database" | "db" => Some(Self::Database),
3921 _ => None,
3922 }
3923 }
3924}
3925
3926#[derive(Debug, Clone, Deserialize)]
3928pub struct IdempotencyRedisConfig {
3929 pub url: Option<String>,
3931 #[serde(default = "default_idempotency_redis_key_prefix")]
3933 pub key_prefix: String,
3934}
3935
3936impl Default for IdempotencyRedisConfig {
3937 fn default() -> Self {
3938 Self {
3939 url: None,
3940 key_prefix: default_idempotency_redis_key_prefix(),
3941 }
3942 }
3943}
3944
3945fn default_idempotency_redis_key_prefix() -> String {
3946 "autumn:idempotency".to_owned()
3947}
3948
3949#[derive(Debug, Clone, Deserialize)]
3951pub struct IdempotencyConfig {
3952 #[serde(default)]
3961 pub enabled: Option<bool>,
3962 #[serde(default)]
3964 pub backend: IdempotencyBackend,
3965 #[serde(default = "default_idempotency_ttl_secs")]
3967 pub ttl_secs: u64,
3968 #[serde(default = "default_idempotency_in_flight_ttl_secs")]
3976 pub in_flight_ttl_secs: u64,
3977 #[serde(default)]
3979 pub allow_memory_in_production: bool,
3980 #[serde(default)]
3982 pub redis: IdempotencyRedisConfig,
3983}
3984
3985impl Default for IdempotencyConfig {
3986 fn default() -> Self {
3987 Self {
3988 enabled: None,
3989 backend: IdempotencyBackend::default(),
3990 ttl_secs: default_idempotency_ttl_secs(),
3991 in_flight_ttl_secs: default_idempotency_in_flight_ttl_secs(),
3992 allow_memory_in_production: false,
3993 redis: IdempotencyRedisConfig::default(),
3994 }
3995 }
3996}
3997
3998const fn default_idempotency_ttl_secs() -> u64 {
3999 86_400
4000}
4001
4002const fn default_idempotency_in_flight_ttl_secs() -> u64 {
4003 crate::idempotency::DEFAULT_IN_FLIGHT_TTL.as_secs()
4004}
4005
4006#[derive(Debug, Clone, Deserialize)]
4021pub struct OpenApiRuntimeConfig {
4022 #[serde(default = "default_openapi_enabled")]
4027 pub enabled: bool,
4028 #[serde(default = "default_openapi_path")]
4032 pub path: String,
4033}
4034
4035impl Default for OpenApiRuntimeConfig {
4036 fn default() -> Self {
4037 Self {
4038 enabled: default_openapi_enabled(),
4039 path: default_openapi_path(),
4040 }
4041 }
4042}
4043
4044const fn default_openapi_enabled() -> bool {
4045 true
4046}
4047
4048fn default_openapi_path() -> String {
4049 "/openapi.json".to_owned()
4050}
4051
4052#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
4057#[serde(default)]
4058pub struct OutboxConfig {
4059 pub enabled: bool,
4062 pub poll_interval_ms: u64,
4064 pub batch_size: usize,
4066 pub max_attempts: u32,
4068 pub initial_backoff_ms: u64,
4070 pub max_backoff_ms: u64,
4072 pub lease_ms: u64,
4075 pub retention_ms: u64,
4078}
4079
4080impl OutboxConfig {
4081 pub fn validate(&self) -> Result<(), ConfigError> {
4090 for (key, value) in [
4091 (
4092 "batch_size",
4093 u64::try_from(self.batch_size).unwrap_or(u64::MAX),
4094 ),
4095 ("max_attempts", u64::from(self.max_attempts)),
4096 ("lease_ms", self.lease_ms),
4097 ] {
4098 if value == 0 {
4099 return Err(ConfigError::Validation(format!(
4100 "outbox.{key} must be greater than zero"
4101 )));
4102 }
4103 }
4104 if self.max_backoff_ms < self.initial_backoff_ms {
4105 return Err(ConfigError::Validation(
4106 "outbox.max_backoff_ms must not be smaller than outbox.initial_backoff_ms"
4107 .to_owned(),
4108 ));
4109 }
4110 Ok(())
4111 }
4112}
4113
4114impl Default for OutboxConfig {
4115 fn default() -> Self {
4116 Self {
4117 enabled: false,
4118 poll_interval_ms: 500,
4119 batch_size: 100,
4120 max_attempts: 10,
4121 initial_backoff_ms: 1_000,
4122 max_backoff_ms: 300_000,
4123 lease_ms: 60_000,
4124 retention_ms: 604_800_000,
4125 }
4126 }
4127}
4128
4129#[derive(Debug, Clone, Deserialize)]
4131pub struct JobConfig {
4132 #[serde(default = "default_job_backend")]
4140 pub backend: String,
4141 #[serde(default = "default_job_workers")]
4143 pub workers: usize,
4144 #[serde(default = "default_job_max_attempts")]
4146 pub max_attempts: u32,
4147 #[serde(default = "default_job_backoff_ms")]
4149 pub initial_backoff_ms: u64,
4150 #[serde(default = "default_job_max_backoff_ms")]
4153 pub max_backoff_ms: u64,
4154 #[serde(default)]
4158 pub default_timeout_ms: u64,
4159 #[serde(default)]
4166 pub queues: JobQueuesConfig,
4167 #[serde(default)]
4174 pub pin: Vec<String>,
4175 #[serde(default)]
4180 pub fleet: JobFleetConfig,
4181 #[serde(default)]
4183 pub redis: JobRedisConfig,
4184 #[serde(default)]
4186 pub postgres: JobPostgresConfig,
4187 #[serde(default)]
4189 pub sqlite: JobSqliteConfig,
4190 #[serde(default)]
4193 pub tracking: JobTrackingConfig,
4194 #[serde(default)]
4196 pub tenants: JobTenantsConfig,
4197}
4198
4199#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
4204pub struct JobTenantsConfig {
4205 #[serde(default)]
4208 pub max_concurrent: usize,
4209 #[serde(default)]
4213 pub lanes: u16,
4214 #[serde(default)]
4216 pub lanes_per_tenant: u16,
4217}
4218
4219impl Default for JobConfig {
4220 fn default() -> Self {
4221 Self {
4222 backend: default_job_backend(),
4223 workers: default_job_workers(),
4224 max_attempts: default_job_max_attempts(),
4225 initial_backoff_ms: default_job_backoff_ms(),
4226 max_backoff_ms: default_job_max_backoff_ms(),
4227 default_timeout_ms: 0,
4228 queues: JobQueuesConfig::default(),
4229 pin: Vec::new(),
4230 fleet: JobFleetConfig::default(),
4231 redis: JobRedisConfig::default(),
4232 postgres: JobPostgresConfig::default(),
4233 sqlite: JobSqliteConfig::default(),
4234 tracking: JobTrackingConfig::default(),
4235 tenants: JobTenantsConfig::default(),
4236 }
4237 }
4238}
4239
4240#[derive(Debug, Clone, PartialEq, Eq)]
4243pub struct JobQueue {
4244 pub name: String,
4246 pub weight: u32,
4249 pub concurrency: Option<usize>,
4253 pub reserved: Option<usize>,
4257}
4258
4259impl JobQueue {
4260 #[must_use]
4262 pub fn new(name: impl Into<String>, weight: u32) -> Self {
4263 Self {
4264 name: name.into(),
4265 weight,
4266 concurrency: None,
4267 reserved: None,
4268 }
4269 }
4270}
4271
4272#[derive(Debug, Clone, PartialEq, Eq)]
4278pub struct JobQueuesConfig {
4279 pub queues: Vec<JobQueue>,
4281 pub strict: bool,
4284}
4285
4286impl Default for JobQueuesConfig {
4287 fn default() -> Self {
4288 Self::single_default()
4289 }
4290}
4291
4292impl JobQueuesConfig {
4293 #[must_use]
4295 pub fn single_default() -> Self {
4296 Self {
4297 queues: vec![JobQueue::new("default", 1)],
4298 strict: true,
4299 }
4300 }
4301
4302 #[must_use]
4304 pub fn strict_list<I, S>(names: I) -> Self
4305 where
4306 I: IntoIterator<Item = S>,
4307 S: Into<String>,
4308 {
4309 let queues: Vec<JobQueue> = names
4310 .into_iter()
4311 .map(|name| JobQueue::new(name, 1))
4312 .collect();
4313 if queues.is_empty() {
4314 Self::single_default()
4315 } else {
4316 Self {
4317 queues,
4318 strict: true,
4319 }
4320 }
4321 }
4322
4323 #[must_use]
4326 pub fn weighted<I, S>(entries: I) -> Self
4327 where
4328 I: IntoIterator<Item = (S, u32)>,
4329 S: Into<String>,
4330 {
4331 let queues: Vec<JobQueue> = entries
4332 .into_iter()
4333 .map(|(name, weight)| JobQueue::new(name, weight.max(1)))
4334 .collect();
4335 if queues.is_empty() {
4336 Self::single_default()
4337 } else {
4338 Self {
4339 queues,
4340 strict: false,
4341 }
4342 }
4343 }
4344
4345 #[must_use]
4350 pub fn weighted_specs(queues: Vec<JobQueue>) -> Self {
4351 if queues.is_empty() {
4352 Self::single_default()
4353 } else {
4354 Self {
4355 queues: queues
4356 .into_iter()
4357 .map(|mut q| {
4358 q.weight = q.weight.max(1);
4359 q
4360 })
4361 .collect(),
4362 strict: false,
4363 }
4364 }
4365 }
4366}
4367
4368#[derive(Debug, Clone)]
4372enum JobQueueValue {
4373 Weight(u32),
4374 Spec {
4375 weight: Option<u32>,
4376 concurrency: Option<usize>,
4377 reserved: Option<usize>,
4378 },
4379}
4380
4381impl<'de> serde::Deserialize<'de> for JobQueueValue {
4382 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
4383 use serde::de::{MapAccess, Visitor};
4384 use std::fmt;
4385
4386 struct ValueVisitor;
4387
4388 impl<'de> Visitor<'de> for ValueVisitor {
4389 type Value = JobQueueValue;
4390
4391 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
4392 f.write_str(
4393 "a queue weight (e.g. critical = 4) or a queue table \
4394 (e.g. critical = { weight = 4, concurrency = 8, reserved = 2 })",
4395 )
4396 }
4397
4398 fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
4399 Ok(JobQueueValue::Weight(
4400 u32::try_from(v).map_err(|_| E::custom("queue weight is too large"))?,
4401 ))
4402 }
4403
4404 fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
4405 if v < 0 {
4406 return Err(E::custom("queue weight must not be negative"));
4407 }
4408 self.visit_u64(u64::try_from(v).unwrap_or(0))
4409 }
4410
4411 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
4412 let mut weight = None;
4413 let mut concurrency = None;
4414 let mut reserved = None;
4415 while let Some(key) = map.next_key::<String>()? {
4416 match key.as_str() {
4417 "weight" => weight = Some(map.next_value::<u32>()?),
4418 "concurrency" => concurrency = Some(map.next_value::<usize>()?),
4419 "reserved" => reserved = Some(map.next_value::<usize>()?),
4420 other => {
4421 return Err(serde::de::Error::custom(format!(
4422 "unknown queue setting '{other}' (expected weight, concurrency, \
4423 or reserved)"
4424 )));
4425 }
4426 }
4427 }
4428 Ok(JobQueueValue::Spec {
4429 weight,
4430 concurrency,
4431 reserved,
4432 })
4433 }
4434 }
4435
4436 d.deserialize_any(ValueVisitor)
4437 }
4438}
4439
4440impl<'de> serde::Deserialize<'de> for JobQueuesConfig {
4441 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
4442 use serde::de::{MapAccess, SeqAccess, Visitor};
4443 use std::fmt;
4444
4445 struct JobQueuesVisitor;
4446
4447 impl<'de> Visitor<'de> for JobQueuesVisitor {
4448 type Value = JobQueuesConfig;
4449
4450 fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
4451 f.write_str(
4452 "an ordered list of queue names (e.g. queues = [\"critical\", \"default\"]) \
4453 or a weight table (e.g. [jobs.queues] critical = 4, default = 1)",
4454 )
4455 }
4456
4457 fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
4458 let mut names = Vec::new();
4459 let mut seen = std::collections::HashSet::new();
4460 while let Some(name) = seq.next_element::<String>()? {
4461 if !seen.insert(name.clone()) {
4462 return Err(serde::de::Error::custom(format!(
4463 "duplicate queue name '{name}' in queues list"
4464 )));
4465 }
4466 names.push(name);
4467 }
4468 Ok(JobQueuesConfig::strict_list(names))
4469 }
4470
4471 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
4472 let mut queues: Vec<JobQueue> = Vec::new();
4473 while let Some((k, value)) = map.next_entry::<String, JobQueueValue>()? {
4474 let (weight, concurrency, reserved) = match value {
4475 JobQueueValue::Weight(w) => (w, None, None),
4476 JobQueueValue::Spec {
4477 weight,
4478 concurrency,
4479 reserved,
4480 } => (weight.unwrap_or(1), concurrency, reserved),
4481 };
4482 if weight == 0 {
4483 return Err(serde::de::Error::custom(format!(
4484 "queue '{k}' weight must be at least 1 (got 0); \
4485 to disable a queue remove it from the list"
4486 )));
4487 }
4488 queues.push(JobQueue {
4489 name: k,
4490 weight,
4491 concurrency,
4492 reserved,
4493 });
4494 }
4495 Ok(JobQueuesConfig::weighted_specs(queues))
4496 }
4497 }
4498
4499 d.deserialize_any(JobQueuesVisitor)
4500 }
4501}
4502
4503#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
4533pub struct JobFleetConfig {
4534 #[serde(default)]
4538 pub tiers: Vec<Vec<String>>,
4539
4540 #[serde(default)]
4545 pub manifest: Option<String>,
4546
4547 #[serde(default)]
4551 pub declared_queues: Vec<String>,
4552}
4553
4554#[derive(Debug, Clone, Deserialize)]
4556pub struct JobRedisConfig {
4557 #[serde(default)]
4559 pub url: Option<String>,
4560 #[serde(default = "default_jobs_redis_prefix")]
4562 pub key_prefix: String,
4563 #[serde(default = "default_jobs_redis_visibility_timeout_ms")]
4565 pub visibility_timeout_ms: u64,
4566 #[serde(default = "default_jobs_redis_dead_letter_limit")]
4571 pub dead_letter_limit: usize,
4572}
4573
4574impl Default for JobRedisConfig {
4575 fn default() -> Self {
4576 Self {
4577 url: None,
4578 key_prefix: default_jobs_redis_prefix(),
4579 visibility_timeout_ms: default_jobs_redis_visibility_timeout_ms(),
4580 dead_letter_limit: default_jobs_redis_dead_letter_limit(),
4581 }
4582 }
4583}
4584
4585#[derive(Debug, Clone, Deserialize)]
4587pub struct JobPostgresConfig {
4588 #[serde(default = "default_jobs_pg_visibility_timeout_ms")]
4593 pub visibility_timeout_ms: u64,
4594 #[serde(default)]
4599 pub shard_local: bool,
4600}
4601
4602impl Default for JobPostgresConfig {
4603 fn default() -> Self {
4604 Self {
4605 visibility_timeout_ms: default_jobs_pg_visibility_timeout_ms(),
4606 shard_local: false,
4607 }
4608 }
4609}
4610
4611#[derive(Debug, Clone, Deserialize)]
4613pub struct JobSqliteConfig {
4614 #[serde(default = "default_jobs_sqlite_visibility_timeout_ms")]
4619 pub visibility_timeout_ms: u64,
4620 #[serde(default = "default_jobs_sqlite_poll_interval_ms")]
4626 pub poll_interval_ms: u64,
4627}
4628
4629impl Default for JobSqliteConfig {
4630 fn default() -> Self {
4631 Self {
4632 visibility_timeout_ms: default_jobs_sqlite_visibility_timeout_ms(),
4633 poll_interval_ms: default_jobs_sqlite_poll_interval_ms(),
4634 }
4635 }
4636}
4637
4638const fn default_jobs_sqlite_visibility_timeout_ms() -> u64 {
4639 30_000
4640}
4641
4642const fn default_jobs_sqlite_poll_interval_ms() -> u64 {
4643 250
4644}
4645
4646#[derive(Debug, Clone, Deserialize)]
4648pub struct JobTrackingConfig {
4649 #[serde(default = "default_jobs_tracking_ttl_secs")]
4652 pub ttl_secs: u64,
4653 #[serde(default = "default_jobs_tracking_route_enabled")]
4656 pub route_enabled: bool,
4657}
4658
4659impl Default for JobTrackingConfig {
4660 fn default() -> Self {
4661 Self {
4662 ttl_secs: default_jobs_tracking_ttl_secs(),
4663 route_enabled: default_jobs_tracking_route_enabled(),
4664 }
4665 }
4666}
4667
4668const fn default_jobs_tracking_ttl_secs() -> u64 {
4669 86_400
4670}
4671
4672const fn default_jobs_tracking_route_enabled() -> bool {
4673 true
4674}
4675
4676const fn default_jobs_pg_visibility_timeout_ms() -> u64 {
4677 30_000
4678}
4679
4680fn default_job_backend() -> String {
4681 "local".to_owned()
4682}
4683
4684const fn default_job_workers() -> usize {
4685 1
4686}
4687
4688const fn default_job_max_attempts() -> u32 {
4689 5
4690}
4691
4692const fn default_job_backoff_ms() -> u64 {
4693 250
4694}
4695
4696const fn default_job_max_backoff_ms() -> u64 {
4697 crate::backoff::DEFAULT_JOB_MAX_BACKOFF_MS
4698}
4699
4700fn default_jobs_redis_prefix() -> String {
4701 "autumn:jobs".to_owned()
4702}
4703
4704const fn default_jobs_redis_dead_letter_limit() -> usize {
4705 10_000
4706}
4707
4708const fn default_jobs_redis_visibility_timeout_ms() -> u64 {
4709 30_000
4710}
4711
4712const PRE_1890_STRICT_PARENTS: &[&str] = &[
4721 "",
4722 "database",
4723 "deploy",
4724 "server",
4725 "server.timeouts",
4726 "server.tls",
4727 "server.tls.acme",
4728];
4729
4730fn unknown_key_was_previously_strict(schema_parent: &str) -> bool {
4735 schema_parent == "profile" || PRE_1890_STRICT_PARENTS.contains(&schema_parent)
4736}
4737
4738#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4757pub(crate) enum UnknownRootPolicy {
4758 Strict,
4760 LenientWarn,
4763}
4764
4765const MANUAL_SCHEMA_SECTIONS: &[(&str, &[&str])] = &[
4780 ("time_zone", &["identifier", "sources"]),
4782];
4783
4784impl AutumnConfig {
4785 pub fn validate_push(&self) -> Result<(), crate::push::PushError> {
4796 self.push.load_vapid_key().map(|_| ())
4797 }
4798
4799 #[must_use]
4808 pub fn profile_admission_default(&self) -> Option<usize> {
4809 matches!(self.profile.as_deref(), Some("prod" | "production")).then(|| {
4810 self.database
4811 .effective_primary_pool_size()
4812 .saturating_mul(PROD_REQUESTS_PER_POOL_CONNECTION)
4813 .max(PROD_MIN_ADMISSION_LIMIT)
4814 })
4815 }
4816}
4817
4818impl AutumnConfig {
4819 #[must_use]
4821 #[allow(clippy::significant_drop_tightening)]
4822 pub fn get_schema_keys() -> HashMap<String, HashSet<String>> {
4823 const MAX_PASSES: usize = 8;
4831 let de = SchemaDeserializer::new();
4832 let mut prev_rejected: Vec<String> = Vec::new();
4833 for _ in 0..MAX_PASSES {
4834 de.rejected
4835 .lock()
4836 .unwrap_or_else(std::sync::PoisonError::into_inner)
4837 .clear();
4838 let _ = Self::deserialize(de.clone());
4839 let mut rejected: Vec<String> = std::mem::take(
4840 &mut de
4841 .rejected
4842 .lock()
4843 .unwrap_or_else(std::sync::PoisonError::into_inner),
4844 );
4845 rejected.sort();
4846 rejected.dedup();
4847 if rejected.is_empty() {
4848 break;
4849 }
4850 let mut advanced = false;
4851 {
4852 let mut probes = de
4853 .any_probe
4854 .lock()
4855 .unwrap_or_else(std::sync::PoisonError::into_inner);
4856 for p in &rejected {
4857 let cur = probes.get(p).copied().unwrap_or(AnyProbe::Str);
4858 let next = match cur {
4859 AnyProbe::Str => AnyProbe::Map,
4860 AnyProbe::Map | AnyProbe::Seq => AnyProbe::Seq,
4861 };
4862 if next != cur {
4863 advanced = true;
4864 }
4865 probes.insert(p.clone(), next);
4866 }
4867 }
4868 if !advanced && rejected == prev_rejected {
4871 break;
4872 }
4873 prev_rejected = rejected;
4874 }
4875 {
4879 let mut schema = de
4880 .schema
4881 .lock()
4882 .unwrap_or_else(std::sync::PoisonError::into_inner);
4883 for (section, keys) in MANUAL_SCHEMA_SECTIONS {
4884 let entry = schema.entry((*section).to_owned()).or_default();
4885 for k in *keys {
4886 entry.insert((*k).to_owned());
4887 }
4888 }
4889 }
4890 de.into_schema()
4891 }
4892
4893 #[must_use]
4905 pub fn schema_leaf_paths() -> std::collections::BTreeSet<String> {
4906 let schema = Self::get_schema_keys();
4907 let mut leaves = std::collections::BTreeSet::new();
4908 for (parent, fields) in &schema {
4909 for field in fields {
4910 let leaf = if parent.is_empty() {
4911 field.clone()
4912 } else {
4913 format!("{parent}.{field}")
4914 };
4915 leaves.insert(leaf);
4916 }
4917 }
4918 leaves
4919 }
4920
4921 #[must_use]
4924 pub fn validate_toml(
4925 content: &str,
4926 schema: &HashMap<String, HashSet<String>>,
4927 ) -> Vec<(String, Option<String>)> {
4928 Self::validate_toml_detailed(content, schema, &BTreeSet::new())
4929 .into_iter()
4930 .map(|(path, sug, _parent, _is_table, _is_top_level)| (path, sug))
4931 .collect()
4932 }
4933
4934 #[must_use]
4958 pub(crate) fn validate_toml_detailed(
4959 content: &str,
4960 schema: &HashMap<String, HashSet<String>>,
4961 plugin_config_roots: &BTreeSet<String>,
4962 ) -> Vec<(String, Option<String>, String, bool, bool)> {
4963 let Ok(table) = toml::from_str::<toml::Table>(content) else {
4964 return Vec::new();
4965 };
4966
4967 let mut errors = Vec::new();
4968 let mut path = Vec::new();
4969 Self::validate_toml_table(&table, &mut path, schema, plugin_config_roots, &mut errors);
4970 errors
4971 }
4972
4973 #[allow(clippy::too_many_lines)]
4974 fn validate_toml_table(
4975 table: &toml::Table,
4976 path: &mut Vec<String>,
4977 schema: &HashMap<String, HashSet<String>>,
4978 plugin_config_roots: &BTreeSet<String>,
4979 errors: &mut Vec<(String, Option<String>, String, bool, bool)>,
4980 ) {
4981 let mut schema_path_parts = Vec::new();
4982 if path.len() >= 2 && path[0] == "profile" {
4983 schema_path_parts.extend(path[2..].iter().cloned());
4984 } else {
4985 schema_path_parts.extend(path.iter().cloned());
4986 }
4987 let schema_path = schema_path_parts.join(".");
4988
4989 if let Some(valid_keys) = schema.get(&schema_path) {
4990 for (k, val) in table {
4991 if path.is_empty() && k == "profile" {
4992 path.push(k.clone());
4993 match val {
4994 toml::Value::Table(t) => {
4995 Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
4996 }
4997 toml::Value::Array(arr) => {
4998 for item in arr {
4999 if let toml::Value::Table(t) = item {
5000 Self::validate_toml_table(
5001 t,
5002 path,
5003 schema,
5004 plugin_config_roots,
5005 errors,
5006 );
5007 }
5008 }
5009 }
5010 _ => {}
5011 }
5012 path.pop();
5013 continue;
5014 }
5015
5016 if valid_keys.contains(k) {
5017 path.push(k.clone());
5018 match val {
5019 toml::Value::Table(t) => {
5020 Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
5021 }
5022 toml::Value::Array(arr) => {
5023 for item in arr {
5024 if let toml::Value::Table(t) = item {
5025 Self::validate_toml_table(
5026 t,
5027 path,
5028 schema,
5029 plugin_config_roots,
5030 errors,
5031 );
5032 }
5033 }
5034 }
5035 _ => {}
5036 }
5037 path.pop();
5038 } else if path.is_empty() && plugin_config_roots.contains(k) && val.is_table() {
5039 } else {
5066 let mut full_path_parts = path.clone();
5067 full_path_parts.push(k.clone());
5068 let full_path = full_path_parts.join(".");
5069
5070 let mut closest: Option<&str> = None;
5071 let mut min_dist = usize::MAX;
5072 for valid_key in valid_keys {
5073 let dist = levenshtein(k, valid_key);
5074 if dist <= 2 && dist < min_dist {
5075 min_dist = dist;
5076 closest = Some(valid_key);
5077 }
5078 }
5079
5080 let suggestion = closest.map(|c| {
5081 let mut sug_parts = path.clone();
5082 sug_parts.push(c.to_string());
5083 sug_parts.join(".")
5084 });
5085
5086 errors.push((
5087 full_path,
5088 suggestion,
5089 schema_path.clone(),
5090 val.is_table(),
5091 path.is_empty(),
5092 ));
5093 }
5094 }
5095 } else if path.len() == 1 && path[0] == "profile" {
5096 for (k, val) in table {
5097 if let toml::Value::Table(t) = val {
5098 path.push(k.clone());
5099 Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
5100 path.pop();
5101 } else {
5102 let mut full_path_parts = path.clone();
5103 full_path_parts.push(k.clone());
5104 errors.push((
5105 full_path_parts.join("."),
5106 None,
5107 schema_path.clone(),
5108 val.is_table(),
5109 path.is_empty(),
5110 ));
5111 }
5112 }
5113 } else if path.is_empty() {
5114 let root_keys = schema.get("").cloned().unwrap_or_default();
5115 for (k, val) in table {
5116 if k == "profile" || root_keys.contains(k) {
5117 path.push(k.clone());
5118 match val {
5119 toml::Value::Table(t) => {
5120 Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
5121 }
5122 toml::Value::Array(arr) => {
5123 for item in arr {
5124 if let toml::Value::Table(t) = item {
5125 Self::validate_toml_table(
5126 t,
5127 path,
5128 schema,
5129 plugin_config_roots,
5130 errors,
5131 );
5132 }
5133 }
5134 }
5135 _ => {}
5136 }
5137 path.pop();
5138 } else if plugin_config_roots.contains(k) && val.is_table() {
5139 } else {
5150 let mut closest: Option<&str> = None;
5151 let mut min_dist = usize::MAX;
5152 for valid_key in &root_keys {
5153 let dist = levenshtein(k, valid_key);
5154 if dist <= 2 && dist < min_dist {
5155 min_dist = dist;
5156 closest = Some(valid_key);
5157 }
5158 }
5159 errors.push((
5160 k.clone(),
5161 closest.map(String::from),
5162 schema_path.clone(),
5163 val.is_table(),
5164 path.is_empty(),
5165 ));
5166 }
5167 }
5168 }
5169 }
5170
5171 #[must_use]
5176 pub const fn credentials(&self) -> &crate::credentials::CredentialsStore {
5177 &self.credentials
5178 }
5179
5180 pub fn load() -> Result<Self, ConfigError> {
5201 Self::load_policy(UnknownRootPolicy::Strict)
5202 }
5203
5204 pub fn load_lenient_unknown_roots() -> Result<Self, ConfigError> {
5227 Self::load_policy(UnknownRootPolicy::LenientWarn)
5228 }
5229
5230 fn load_policy(root_policy: UnknownRootPolicy) -> Result<Self, ConfigError> {
5231 let base = OsEnv;
5238 let profile = resolve_profile(&base);
5239 let dir = crate::dotenv::dotenv_base_dir(&base);
5244 let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
5245 .map_err(|e| ConfigError::Dotenv(e.to_string()))?;
5246 let env = crate::dotenv::DotenvEnv::new(&base, vars);
5247 Self::load_with_env_and_plugin_roots_policy(&env, &BTreeSet::new(), root_policy)
5252 }
5253
5254 pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
5265 Self::load_with_env_and_plugin_roots_policy(
5266 env,
5267 &BTreeSet::new(),
5268 UnknownRootPolicy::Strict,
5269 )
5270 }
5271
5272 pub fn load_with_env_and_plugin_roots(
5299 env: &dyn Env,
5300 plugin_config_roots: &BTreeSet<String>,
5301 ) -> Result<Self, ConfigError> {
5302 Self::load_with_env_and_plugin_roots_policy(
5303 env,
5304 plugin_config_roots,
5305 UnknownRootPolicy::Strict,
5306 )
5307 }
5308
5309 pub fn load_with_env_lenient_unknown_roots(env: &dyn Env) -> Result<Self, ConfigError> {
5329 Self::load_with_env_and_plugin_roots_policy(
5330 env,
5331 &BTreeSet::new(),
5332 UnknownRootPolicy::LenientWarn,
5333 )
5334 }
5335
5336 fn load_with_env_and_plugin_roots_policy(
5346 env: &dyn Env,
5347 plugin_config_roots: &BTreeSet<String>,
5348 root_policy: UnknownRootPolicy,
5349 ) -> Result<Self, ConfigError> {
5350 let selected_profile_input = resolve_profile_input(env);
5351 let profile =
5352 normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned());
5353 let mut has_inline_profile_section = false;
5354
5355 let mut merged = profile_defaults_as_toml(&profile);
5358
5359 if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
5361 deep_merge(&mut merged, base.clone());
5362
5363 for profile_name in profile_lookup_names(&profile) {
5365 if let Some(inline_profile) = profile_section_from_base_toml(&base, profile_name) {
5366 deep_merge(&mut merged, inline_profile);
5367 has_inline_profile_section = true;
5368 }
5369 }
5370 }
5371
5372 let mut has_profile_file = false;
5374 for profile_name in profile_override_file_lookup_names(&profile, &selected_profile_input) {
5375 let profile_path = find_config_file_named(&format!("autumn-{profile_name}.toml"), env);
5376 if let Some(profile_toml) = load_raw_toml(&profile_path)? {
5377 deep_merge(&mut merged, profile_toml);
5378 has_profile_file = true;
5379 break;
5380 }
5381 }
5382 if !has_profile_file
5383 && should_warn_missing_profile_file(&profile, has_inline_profile_section)
5384 {
5385 warn_profile_typo(&profile);
5386 }
5387
5388 let toml_str =
5390 toml::to_string(&merged).expect("internal error: failed to serialize merged config");
5391 let mut config: Self = toml::from_str(&toml_str)?;
5392 config.profile = Some(profile);
5393
5394 config.apply_env_overrides_with_env(env);
5396
5397 if config.server.strict_config {
5399 let enforce_all = config.server.strict_config_enforce_all
5400 || env
5401 .var("AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL")
5402 .is_ok_and(|v| v == "true" || v == "1");
5403 Self::run_strict_unknown_key_check(
5404 &toml_str,
5405 enforce_all,
5406 plugin_config_roots,
5407 root_policy,
5408 )?;
5409 }
5410
5411 let empty_table = toml::Table::new();
5416 let merged_table = merged.as_table().unwrap_or(&empty_table);
5417 for f in detect_deprecated_keys(merged_table, env, DEPRECATED_CONFIG_KEYS) {
5418 eprintln!(
5423 "Warning: deprecated configuration key `{}` is still honored but will be removed \
5424 in {}; deprecated since {} (replacement: {}; source: {:?})",
5425 f.path,
5426 f.remove_in,
5427 f.since,
5428 f.replacement.as_deref().unwrap_or("none — remove this key"),
5429 f.source,
5430 );
5431 tracing::warn!(
5432 deprecated_key = f.path.as_str(),
5433 replacement = f.replacement.as_deref().unwrap_or("none; remove this key"),
5434 since = f.since.as_str(),
5435 remove_in = f.remove_in.as_str(),
5436 source = ?f.source,
5437 "deprecated configuration key in use; it is still honored but scheduled for removal"
5438 );
5439 }
5440
5441 #[cfg(feature = "mail")]
5442 if config.profile.as_deref() == Some("dev") && !has_mail_transport_source(&merged, env) {
5443 config.mail.transport = crate::mail::Transport::Log;
5444 }
5445
5446 config.validate()?;
5447
5448 let base_dir: PathBuf = env
5449 .var("AUTUMN_MANIFEST_DIR")
5450 .map_or_else(|_| PathBuf::from("."), PathBuf::from);
5451 let cred_profile = config.profile.as_deref().unwrap_or("dev");
5452 let master_key_override = env.var("AUTUMN_MASTER_KEY").ok();
5453 config.credentials = crate::credentials::load_credentials_with_key_override(
5454 cred_profile,
5455 &base_dir,
5456 master_key_override.as_deref(),
5457 )
5458 .map_err(|e| ConfigError::Credentials(e.to_string()))?;
5459
5460 #[cfg(feature = "oauth2")]
5461 {
5462 config.expand_oauth2_providers();
5463 }
5464
5465 Ok(config)
5466 }
5467
5468 fn run_strict_unknown_key_check(
5476 toml_str: &str,
5477 enforce_all: bool,
5478 plugin_config_roots: &BTreeSet<String>,
5479 root_policy: UnknownRootPolicy,
5480 ) -> Result<(), ConfigError> {
5481 let schema = Self::get_schema_keys();
5482 let errors = Self::validate_toml_detailed(toml_str, &schema, plugin_config_roots);
5483
5484 let mut hard_errors = Vec::new();
5485 let mut warn_only = Vec::new();
5486 let mut opaque_roots = Vec::new();
5487 for (path, sug, schema_parent, is_table, is_top_level) in errors {
5488 if root_policy == UnknownRootPolicy::LenientWarn
5529 && schema_parent.is_empty()
5530 && is_top_level
5531 && is_table
5532 {
5533 opaque_roots.push(path);
5534 continue;
5535 }
5536 if enforce_all || unknown_key_was_previously_strict(&schema_parent) {
5537 hard_errors.push((path, sug));
5538 } else {
5539 warn_only.push((path, sug));
5540 }
5541 }
5542
5543 if !opaque_roots.is_empty() {
5550 let roots = opaque_roots.join(", ");
5551 let count = opaque_roots.len();
5552 eprintln!(
5553 "deploy config: accepting {count} unknown top-level config section(s) as \
5554 opaque — the deployed app runs the authoritative strict check, so each must \
5555 be a section the app declares (e.g. a plugin config table) or the app will \
5556 reject it at boot: {roots}. A typo here will make the app fail to start."
5557 );
5558 tracing::warn!(
5559 unknown_top_level_roots = roots.as_str(),
5560 count,
5561 "deploy config: accepting unknown top-level config section(s) as opaque; the \
5562 deployed app runs the authoritative strict check, so each must be a section \
5563 the app declares (e.g. a plugin config table) or it will reject it at boot — \
5564 a typo here will make the app fail to start (#2063)"
5565 );
5566 }
5567
5568 for (path, sug) in &warn_only {
5576 let hint = sug
5577 .as_deref()
5578 .map_or_else(String::new, |s| format!(" — did you mean \"{s}\"?"));
5579 eprintln!(
5580 "Warning: unknown configuration key \"{path}\"{hint}. It is ignored and \
5581 falls back to defaults. This will become a hard error in a future \
5582 release; set server.strict_config_enforce_all = true to enforce now."
5583 );
5584 tracing::warn!(
5585 unknown_key = path.as_str(),
5586 suggestion = sug.as_deref().unwrap_or(""),
5587 "unknown configuration key in a section newly covered by strict \
5588 validation; ignored for now (warn-first rollout, #1890), will hard-fail \
5589 once enforcement is promoted"
5590 );
5591 }
5592
5593 if !hard_errors.is_empty() {
5594 let err_messages: Vec<String> = hard_errors
5595 .into_iter()
5596 .map(|(path, sug)| {
5597 sug.map_or_else(
5598 || format!("unknown key \"{path}\""),
5599 |s| format!("unknown key \"{path}\" — did you mean \"{s}\"?"),
5600 )
5601 })
5602 .collect();
5603 return Err(ConfigError::Validation(format!(
5604 "Strict config check failed. Unknown keys in configuration: {}",
5605 err_messages.join(", ")
5606 )));
5607 }
5608 Ok(())
5609 }
5610
5611 #[cfg(feature = "oauth2")]
5613 fn expand_oauth2_providers(&mut self) {
5614 let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
5615 for name in provider_names {
5616 if let (Some(preset), Some(p)) = (
5618 crate::auth::provider_preset(&name),
5619 self.auth.oauth2.providers.get_mut(&name),
5620 ) {
5621 if p.authorize_url.is_empty() {
5622 p.authorize_url = preset.authorize_url;
5623 }
5624 if p.token_url.is_empty() {
5625 p.token_url = preset.token_url;
5626 }
5627 if p.userinfo_url.is_none() {
5628 p.userinfo_url = preset.userinfo_url;
5629 }
5630 if p.scope.is_empty() || p.scope == "default" {
5631 p.scope = preset.scope;
5632 }
5633 if p.issuer.is_none() {
5634 p.issuer = preset.issuer;
5635 }
5636 if p.jwks_url.is_none() {
5637 p.jwks_url = preset.jwks_url;
5638 }
5639 if p.discovery_url.is_none() {
5640 p.discovery_url = preset.discovery_url;
5641 }
5642 }
5643
5644 if let Some(p) = self.auth.oauth2.providers.get_mut(&name) {
5646 let normalized_name = name
5647 .chars()
5648 .map(|c| if c.is_alphanumeric() { c } else { '_' })
5649 .collect::<String>()
5650 .to_lowercase();
5651
5652 let id_key = format!("oauth2_{normalized_name}_client_id");
5653 if p.client_id.is_empty() {
5654 if let Some(id) = self.credentials.get::<String>(&id_key) {
5655 p.client_id = id;
5656 } else if let Some(id) = self
5657 .credentials
5658 .get::<String>(&format!("oauth2_{name}_client_id"))
5659 {
5660 p.client_id = id;
5661 }
5662 }
5663 let secret_key = format!("oauth2_{normalized_name}_client_secret");
5664 if p.client_secret.is_empty() {
5665 if let Some(secret) = self.credentials.get::<String>(&secret_key) {
5666 p.client_secret = secret;
5667 } else if let Some(secret) = self
5668 .credentials
5669 .get::<String>(&format!("oauth2_{name}_client_secret"))
5670 {
5671 p.client_secret = secret;
5672 }
5673 }
5674 }
5675 }
5676 }
5677
5678 pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
5689 match std::fs::read_to_string(path) {
5690 Ok(contents) => {
5691 let config: Self = toml::from_str(&contents)?;
5692 config.validate()?;
5693 Ok(config)
5694 }
5695 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
5696 Err(e) => Err(ConfigError::Io(e)),
5697 }
5698 }
5699
5700 pub fn validate(&self) -> Result<(), ConfigError> {
5706 self.database.validate()?;
5707 self.server.http.validate()?;
5708 self.realtime.validate()?;
5709 self.cors.validate()?;
5710 self.server.admission.validate()?;
5711 #[cfg(feature = "http-client")]
5712 self.http.client.adaptive_throttle.validate()?;
5713 #[cfg(feature = "http-client")]
5714 self.http.client.retry_budget.validate()?;
5715 self.scheduler.validate()?;
5716 self.outbox.validate()?;
5717 self.retention.validate()?;
5721 self.validate_retention_against_replay_protection()?;
5722 if self.database.has_shards()
5727 && self.database.effective_primary_url().is_none()
5728 && (self.scheduler.backend == SchedulerBackend::Postgres
5729 || self.jobs.backend == "postgres")
5730 {
5731 return Err(ConfigError::Validation(
5732 "jobs/scheduler require a control database: set database.primary_url (or \
5733 database.url) alongside [[database.shards]] — framework state such as \
5734 autumn_jobs and scheduler locks is not sharded (see docs/guide/sharding.md)"
5735 .to_owned(),
5736 ));
5737 }
5738 if self.jobs.postgres.shard_local
5741 && (self.jobs.backend != "postgres" || !self.database.has_shards())
5742 {
5743 return Err(ConfigError::Validation(
5744 "jobs.postgres.shard_local = true needs jobs.backend = \"postgres\" and \
5745 [[database.shards]] (see docs/guide/cell-isolation.md)"
5746 .to_owned(),
5747 ));
5748 }
5749 let is_production = matches!(self.profile.as_deref(), Some("prod" | "production"));
5750 self.security
5751 .webhooks
5752 .validate(is_production)
5753 .map_err(|error| ConfigError::Validation(error.to_string()))?;
5754 #[cfg(feature = "mail")]
5755 self.mail.validate(self.profile.as_deref())?;
5756 self.time_zone.validate()?;
5757 self.shadow.validate().map_err(ConfigError::Validation)?;
5761 self.fault_injection
5763 .validate(self.profile.as_deref())
5764 .map_err(ConfigError::Validation)?;
5765 self.cluster.validate()?;
5768 self.metrics.validate()?;
5772 self.health.validate()?;
5774 self.cost.validate().map_err(ConfigError::Validation)?;
5777 self.aeo.validate().map_err(ConfigError::Validation)?;
5779 if let Some(replication) = &self.replication {
5784 let errors = replication.validation_errors();
5785 if !errors.is_empty() {
5786 return Err(ConfigError::Validation(format!(
5787 "invalid [replication] configuration:\n - {}",
5788 errors.join("\n - ")
5789 )));
5790 }
5791 }
5792 Ok(())
5801 }
5802
5803 fn validate_retention_against_replay_protection(&self) -> Result<(), ConfigError> {
5818 let Some(window) = self.retention.window("webhook_replay") else {
5819 return Ok(());
5820 };
5821 for endpoint in &self.security.webhooks.endpoints {
5822 if !endpoint.replay_protection {
5823 continue;
5824 }
5825 let replay = std::time::Duration::from_secs(endpoint.replay_window_secs);
5826 if window < replay {
5827 return Err(ConfigError::Validation(format!(
5828 "retention.webhook_replay ({window_secs}s) is shorter than the \
5829 replay_window_secs ({replay_secs}s) of webhook endpoint {name:?}: \
5830 expiring replay markers early would silently weaken replay \
5831 protection. Lower that endpoint's replay_window_secs instead, or \
5832 widen retention.webhook_replay (see \
5833 docs/guide/data-retention.md).",
5834 window_secs = window.as_secs(),
5835 replay_secs = endpoint.replay_window_secs,
5836 name = endpoint.name,
5837 )));
5838 }
5839 }
5840 Ok(())
5841 }
5842
5843 pub fn apply_retention_caps(&mut self) {
5871 let cap_job_tracking = self.jobs.backend != "postgres";
5880 let caps: [(&str, &mut u64); 3] = [
5881 ("idempotency", &mut self.idempotency.ttl_secs),
5882 ("sessions", &mut self.session.max_age_secs),
5883 ("job_tracking", &mut self.jobs.tracking.ttl_secs),
5884 ];
5885 for (key, target) in caps {
5886 if key == "job_tracking" && !cap_job_tracking {
5887 continue;
5888 }
5889 if let Some(window) = self.retention.window(key) {
5890 *target = (*target).min(window.as_secs());
5891 }
5892 }
5893 }
5894
5895 pub fn apply_env_overrides(&mut self) {
6020 self.apply_env_overrides_with_env(&OsEnv);
6021 }
6022
6023 pub fn apply_env_overrides_with_env(&mut self, env: &dyn Env) {
6025 self.apply_server_env_overrides_with_env(env);
6026 self.apply_deploy_env_overrides_with_env(env);
6027 self.apply_database_env_overrides_with_env(env);
6028 self.apply_log_env_overrides_with_env(env);
6029 self.apply_telemetry_env_overrides_with_env(env);
6030 self.apply_health_env_overrides_with_env(env);
6031 self.apply_cors_env_overrides_with_env(env);
6032 self.apply_session_env_overrides_with_env(env);
6033 self.apply_cache_env_overrides_with_env(env);
6034 self.apply_channels_env_overrides_with_env(env);
6035 self.apply_realtime_env_overrides_with_env(env);
6036 self.apply_jobs_env_overrides_with_env(env);
6037 self.apply_outbox_env_overrides_with_env(env);
6038 self.apply_scheduler_env_overrides_with_env(env);
6039 self.apply_retention_env_overrides_with_env(env);
6040 self.apply_role_env_overrides_with_env(env);
6041 self.apply_auth_env_overrides_with_env(env);
6042 self.apply_security_env_overrides_with_env(env);
6043 self.apply_bot_protection_env_overrides_with_env(env);
6044 self.apply_idempotency_env_overrides_with_env(env);
6045 self.apply_dev_env_overrides_with_env(env);
6046 self.apply_observability_env_overrides_with_env(env);
6047 self.apply_cost_env_overrides_with_env(env);
6048 self.apply_compression_env_overrides_with_env(env);
6049 self.apply_actuator_env_overrides_with_env(env);
6050 self.apply_metrics_env_overrides_with_env(env);
6051 #[cfg(feature = "reporting")]
6052 self.apply_reporting_env_overrides_with_env(env);
6053 #[cfg(feature = "reporting")]
6054 self.apply_failure_capture_env_overrides_with_env(env);
6055 #[cfg(feature = "storage")]
6056 self.apply_storage_env_overrides_with_env(env);
6057 self.apply_backup_env_overrides_with_env(env);
6058 self.apply_replication_env_overrides_with_env(env);
6059 #[cfg(feature = "mail")]
6060 self.apply_mail_env_overrides_with_env(env);
6061 #[cfg(feature = "maud")]
6062 self.apply_stories_env_overrides_with_env(env);
6063 self.apply_resilience_env_overrides_with_env(env);
6064 self.apply_time_zone_env_overrides_with_env(env);
6065 self.apply_alerts_env_overrides_with_env(env);
6066 self.apply_tenancy_env_overrides_with_env(env);
6067 self.apply_cluster_env_overrides_with_env(env);
6068 self.apply_push_env_overrides_with_env(env);
6069 self.apply_shadow_env_overrides_with_env(env);
6070 parse_env_bool(
6073 env,
6074 "AUTUMN_FAULT_INJECTION__ENABLED",
6075 &mut self.fault_injection.enabled,
6076 );
6077 }
6078
6079 fn apply_push_env_overrides_with_env(&mut self, env: &dyn Env) {
6089 if let Ok(value) = env.var("AUTUMN_PUSH__PRIVATE_KEY") {
6098 self.push.private_key = Some(secrecy::SecretString::from(value));
6099 }
6100 parse_env_option_string(env, "AUTUMN_PUSH__PUBLIC_KEY", &mut self.push.public_key);
6101 parse_env_option_string(env, "AUTUMN_PUSH__SUBJECT", &mut self.push.subject);
6102 parse_env_option(env, "AUTUMN_PUSH__TTL_SECS", &mut self.push.ttl_secs);
6103 }
6104
6105 fn apply_shadow_env_overrides_with_env(&mut self, env: &dyn Env) {
6106 parse_env_bool(env, "AUTUMN_SHADOW__ENABLED", &mut self.shadow.enabled);
6107 parse_env_option_string(env, "AUTUMN_SHADOW__TARGET", &mut self.shadow.target);
6108 parse_env(
6109 env,
6110 "AUTUMN_SHADOW__SAMPLE_RATE",
6111 &mut self.shadow.sample_rate,
6112 );
6113 parse_env_csv_non_empty(env, "AUTUMN_SHADOW__ROUTES", &mut self.shadow.routes);
6118 parse_env(
6119 env,
6120 "AUTUMN_SHADOW__TIMEOUT_MS",
6121 &mut self.shadow.timeout_ms,
6122 );
6123 parse_env(
6124 env,
6125 "AUTUMN_SHADOW__MAX_IN_FLIGHT",
6126 &mut self.shadow.max_in_flight,
6127 );
6128 parse_env(
6129 env,
6130 "AUTUMN_SHADOW__MAX_BODY_BYTES",
6131 &mut self.shadow.max_body_bytes,
6132 );
6133 parse_env(
6134 env,
6135 "AUTUMN_SHADOW__MAX_RECORDS",
6136 &mut self.shadow.max_records,
6137 );
6138 parse_env(
6139 env,
6140 "AUTUMN_SHADOW__MAX_SAMPLE_BYTES",
6141 &mut self.shadow.max_sample_bytes,
6142 );
6143 }
6144
6145 fn apply_cluster_env_overrides_with_env(&mut self, env: &dyn Env) {
6146 parse_env_bool(env, "AUTUMN_CLUSTER__ENABLED", &mut self.cluster.enabled);
6147 parse_env_option_secret(env, "AUTUMN_CLUSTER__SECRET", &mut self.cluster.secret);
6148 parse_env_string(
6149 env,
6150 "AUTUMN_CLUSTER__CLUSTER_NAME",
6151 &mut self.cluster.cluster_name,
6152 );
6153 parse_env_string(
6154 env,
6155 "AUTUMN_CLUSTER__BIND_ADDR",
6156 &mut self.cluster.bind_addr,
6157 );
6158 parse_env_option_string(
6159 env,
6160 "AUTUMN_CLUSTER__ADVERTISE_ADDR",
6161 &mut self.cluster.advertise_addr,
6162 );
6163 parse_env_csv(
6164 env,
6165 "AUTUMN_CLUSTER__SEED_PEERS",
6166 &mut self.cluster.seed_peers,
6167 );
6168 parse_env_option_string(env, "AUTUMN_CLUSTER__NODE_ID", &mut self.cluster.node_id);
6169 parse_env(
6170 env,
6171 "AUTUMN_CLUSTER__PUSH_INTERVAL_MS",
6172 &mut self.cluster.push_interval_ms,
6173 );
6174 parse_env(
6175 env,
6176 "AUTUMN_CLUSTER__SUSPICION_TIMEOUT_MS",
6177 &mut self.cluster.suspicion_timeout_ms,
6178 );
6179 }
6180
6181 fn apply_tenancy_env_overrides_with_env(&mut self, env: &dyn Env) {
6182 parse_env_bool(env, "AUTUMN_TENANCY__ENABLED", &mut self.tenancy.enabled);
6183 parse_env_string(env, "AUTUMN_TENANCY__SOURCE", &mut self.tenancy.source);
6184 parse_env_string(
6185 env,
6186 "AUTUMN_TENANCY__HEADER_NAME",
6187 &mut self.tenancy.header_name,
6188 );
6189 parse_env_string(
6190 env,
6191 "AUTUMN_TENANCY__SESSION_KEY",
6192 &mut self.tenancy.session_key,
6193 );
6194 parse_env_string(
6195 env,
6196 "AUTUMN_TENANCY__JWT_CLAIM",
6197 &mut self.tenancy.jwt_claim,
6198 );
6199 parse_env_option_secret(
6200 env,
6201 "AUTUMN_TENANCY__JWT_SECRET",
6202 &mut self.tenancy.jwt_secret,
6203 );
6204 parse_env_option_string(
6205 env,
6206 "AUTUMN_TENANCY__JWT_ISSUER",
6207 &mut self.tenancy.jwt_issuer,
6208 );
6209 parse_env_option_string(
6210 env,
6211 "AUTUMN_TENANCY__JWT_AUDIENCE",
6212 &mut self.tenancy.jwt_audience,
6213 );
6214 parse_env_option_string(
6215 env,
6216 "AUTUMN_TENANCY__BASE_DOMAIN",
6217 &mut self.tenancy.base_domain,
6218 );
6219 parse_env_option_string(
6220 env,
6221 "AUTUMN_TENANCY__LOGIN_REDIRECT",
6222 &mut self.tenancy.login_redirect,
6223 );
6224 parse_env_csv(
6225 env,
6226 "AUTUMN_TENANCY__PUBLIC_PATHS",
6227 &mut self.tenancy.public_paths,
6228 );
6229 parse_env(
6230 env,
6231 "AUTUMN_TENANCY__QUOTA_BYTES",
6232 &mut self.tenancy.quota_bytes,
6233 );
6234 parse_env(
6235 env,
6236 "AUTUMN_TENANCY__MAX_CELLS",
6237 &mut self.tenancy.max_cells,
6238 );
6239 parse_env(
6240 env,
6241 "AUTUMN_TENANCY__IDLE_TTL_SECS",
6242 &mut self.tenancy.idle_ttl_secs,
6243 );
6244 parse_env(
6245 env,
6246 "AUTUMN_TENANCY__MAX_CONCURRENT_REQUESTS",
6247 &mut self.tenancy.max_concurrent_requests,
6248 );
6249 parse_env(
6250 env,
6251 "AUTUMN_TENANCY__MAX_DB_CONNECTIONS",
6252 &mut self.tenancy.max_db_connections,
6253 );
6254 }
6255
6256 fn apply_alerts_env_overrides_with_env(&mut self, env: &dyn Env) {
6257 parse_env_bool(env, "AUTUMN_ALERTS__ENABLED", &mut self.alerts.enabled);
6258 parse_env_option_string(env, "AUTUMN_ALERTS__EMAIL", &mut self.alerts.email);
6259 parse_env_option_string(
6260 env,
6261 "AUTUMN_ALERTS__WEBHOOK_URL",
6262 &mut self.alerts.webhook_url,
6263 );
6264 parse_env_option_string(
6265 env,
6266 "AUTUMN_ALERTS__WEBHOOK_SECRET",
6267 &mut self.alerts.webhook_secret,
6268 );
6269 parse_env_option_string(
6270 env,
6271 "AUTUMN_ALERTS__PAGERDUTY_ROUTING_KEY",
6272 &mut self.alerts.pagerduty_routing_key,
6273 );
6274 parse_env_option_string(
6275 env,
6276 "AUTUMN_ALERTS__PAGERDUTY_URL",
6277 &mut self.alerts.pagerduty_url,
6278 );
6279 parse_env_option_string(
6280 env,
6281 "AUTUMN_ALERTS__SLACK_WEBHOOK_URL",
6282 &mut self.alerts.slack_webhook_url,
6283 );
6284 parse_env_option_string(
6285 env,
6286 "AUTUMN_ALERTS__DISCORD_WEBHOOK_URL",
6287 &mut self.alerts.discord_webhook_url,
6288 );
6289 parse_env(
6293 env,
6294 "AUTUMN_ALERTS__PAGERDUTY_SEVERITIES",
6295 &mut self.alerts.pagerduty_severities,
6296 );
6297 parse_env(
6298 env,
6299 "AUTUMN_ALERTS__SLACK_SEVERITIES",
6300 &mut self.alerts.slack_severities,
6301 );
6302 parse_env(
6303 env,
6304 "AUTUMN_ALERTS__DISCORD_SEVERITIES",
6305 &mut self.alerts.discord_severities,
6306 );
6307 parse_env_bool(
6308 env,
6309 "AUTUMN_ALERTS__CUSTOM_CHANNEL",
6310 &mut self.alerts.custom_channel,
6311 );
6312 parse_env(
6313 env,
6314 "AUTUMN_ALERTS__DEDUP_WINDOW_SECS",
6315 &mut self.alerts.dedup_window_secs,
6316 );
6317 parse_env(
6318 env,
6319 "AUTUMN_ALERTS__HEALTH_GRACE_SECS",
6320 &mut self.alerts.health_grace_secs,
6321 );
6322 parse_env(
6323 env,
6324 "AUTUMN_ALERTS__ERROR_RATE_THRESHOLD",
6325 &mut self.alerts.error_rate_threshold,
6326 );
6327 parse_env(
6328 env,
6329 "AUTUMN_ALERTS__ERROR_RATE_MIN_REQUESTS",
6330 &mut self.alerts.error_rate_min_requests,
6331 );
6332 parse_env(
6333 env,
6334 "AUTUMN_ALERTS__EVAL_INTERVAL_SECS",
6335 &mut self.alerts.eval_interval_secs,
6336 );
6337 }
6338
6339 fn apply_time_zone_env_overrides_with_env(&mut self, env: &dyn Env) {
6340 parse_env_string(
6341 env,
6342 "AUTUMN_TIME_ZONE__IDENTIFIER",
6343 &mut self.time_zone.identifier,
6344 );
6345 }
6346
6347 #[cfg(feature = "reporting")]
6348 fn apply_reporting_env_overrides_with_env(&mut self, env: &dyn Env) {
6349 parse_env_bool(
6350 env,
6351 "AUTUMN_REPORTING__ENABLED",
6352 &mut self.reporting.enabled,
6353 );
6354 parse_env(
6355 env,
6356 "AUTUMN_REPORTING__SAMPLE_RATE",
6357 &mut self.reporting.sample_rate,
6358 );
6359 }
6360
6361 #[cfg(feature = "reporting")]
6362 fn apply_failure_capture_env_overrides_with_env(&mut self, env: &dyn Env) {
6363 parse_env_bool(
6364 env,
6365 "AUTUMN_FAILURE_CAPTURE__ENABLED",
6366 &mut self.failure_capture.enabled,
6367 );
6368 parse_env_string(
6369 env,
6370 "AUTUMN_FAILURE_CAPTURE__DIR",
6371 &mut self.failure_capture.dir,
6372 );
6373 parse_env(
6374 env,
6375 "AUTUMN_FAILURE_CAPTURE__MAX_BODY_BYTES",
6376 &mut self.failure_capture.max_body_bytes,
6377 );
6378 parse_env(
6379 env,
6380 "AUTUMN_FAILURE_CAPTURE__MAX_CAPSULE_BYTES",
6381 &mut self.failure_capture.max_capsule_bytes,
6382 );
6383 parse_env(
6384 env,
6385 "AUTUMN_FAILURE_CAPTURE__MAX_CAPSULES",
6386 &mut self.failure_capture.max_capsules,
6387 );
6388 }
6389
6390 fn apply_dev_env_overrides_with_env(&mut self, env: &dyn Env) {
6391 parse_env_string(
6392 env,
6393 "AUTUMN_DEV__INSPECTOR_PATH",
6394 &mut self.dev.inspector_path,
6395 );
6396 parse_env(
6397 env,
6398 "AUTUMN_DEV__INSPECTOR_CAPACITY",
6399 &mut self.dev.inspector_capacity,
6400 );
6401 parse_env(
6402 env,
6403 "AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD",
6404 &mut self.dev.inspector_n_plus_one_threshold,
6405 );
6406 }
6407
6408 fn apply_compression_env_overrides_with_env(&mut self, env: &dyn Env) {
6409 parse_env_bool(
6410 env,
6411 "AUTUMN_COMPRESSION__ENABLED",
6412 &mut self.compression.enabled,
6413 );
6414 }
6415
6416 fn apply_cost_env_overrides_with_env(&mut self, env: &dyn Env) {
6417 parse_env_bool(env, "AUTUMN_COST__ENABLED", &mut self.cost.enabled);
6418 parse_env_option(
6419 env,
6420 "AUTUMN_COST__DEFER_THRESHOLD",
6421 &mut self.cost.defer_threshold,
6422 );
6423 parse_env(
6424 env,
6425 "AUTUMN_COST__DEFER_RECHECK_SECS",
6426 &mut self.cost.defer_recheck_secs,
6427 );
6428 parse_env(env, "AUTUMN_COST__MAX_TENANTS", &mut self.cost.max_tenants);
6429 parse_env(
6430 env,
6431 "AUTUMN_COST__SIGNAL_REFRESH_SECS",
6432 &mut self.cost.signal_refresh_secs,
6433 );
6434 }
6435
6436 fn apply_observability_env_overrides_with_env(&mut self, env: &dyn Env) {
6437 parse_env_option_bool(
6438 env,
6439 "AUTUMN_OBSERVABILITY__SERVER_TIMING",
6440 &mut self.observability.server_timing,
6441 );
6442 }
6443
6444 #[cfg(feature = "maud")]
6445 fn apply_stories_env_overrides_with_env(&mut self, env: &dyn Env) {
6446 parse_env_bool(env, "AUTUMN_STORIES__ENABLED", &mut self.stories.enabled);
6447 }
6448
6449 fn apply_actuator_env_overrides_with_env(&mut self, env: &dyn Env) {
6450 parse_env_string(env, "AUTUMN_ACTUATOR__PREFIX", &mut self.actuator.prefix);
6451 parse_env_bool(
6452 env,
6453 "AUTUMN_ACTUATOR__SENSITIVE",
6454 &mut self.actuator.sensitive,
6455 );
6456 parse_env_bool(
6460 env,
6461 "AUTUMN_ACTUATOR__PROMETHEUS",
6462 &mut self.actuator.prometheus,
6463 );
6464 }
6465
6466 fn apply_metrics_env_overrides_with_env(&mut self, env: &dyn Env) {
6467 parse_env(
6468 env,
6469 "AUTUMN_METRICS__MAX_SERIES_PER_METRIC",
6470 &mut self.metrics.max_series_per_metric,
6471 );
6472 parse_env(
6473 env,
6474 "AUTUMN_METRICS__MAX_INSTRUMENTS",
6475 &mut self.metrics.max_instruments,
6476 );
6477 parse_env(
6478 env,
6479 "AUTUMN_METRICS__MAX_LABELS_PER_SERIES",
6480 &mut self.metrics.max_labels_per_series,
6481 );
6482 }
6483
6484 fn apply_idempotency_env_overrides_with_env(&mut self, env: &dyn Env) {
6485 parse_env_option_bool(
6486 env,
6487 "AUTUMN_IDEMPOTENCY__ENABLED",
6488 &mut self.idempotency.enabled,
6489 );
6490 if let Ok(val) = env.var("AUTUMN_IDEMPOTENCY__BACKEND") {
6491 match IdempotencyBackend::from_env_value(&val) {
6492 Some(backend) => self.idempotency.backend = backend,
6493 None => eprintln!(
6494 "Warning: unrecognised AUTUMN_IDEMPOTENCY__BACKEND value {val:?}; ignoring"
6495 ),
6496 }
6497 }
6498 parse_env(
6499 env,
6500 "AUTUMN_IDEMPOTENCY__TTL_SECS",
6501 &mut self.idempotency.ttl_secs,
6502 );
6503 parse_env(
6504 env,
6505 "AUTUMN_IDEMPOTENCY__IN_FLIGHT_TTL_SECS",
6506 &mut self.idempotency.in_flight_ttl_secs,
6507 );
6508 parse_env_bool(
6509 env,
6510 "AUTUMN_IDEMPOTENCY__ALLOW_MEMORY_IN_PRODUCTION",
6511 &mut self.idempotency.allow_memory_in_production,
6512 );
6513 parse_env_string(
6514 env,
6515 "AUTUMN_IDEMPOTENCY__REDIS__URL",
6516 self.idempotency.redis.url.get_or_insert_with(String::new),
6517 );
6518 parse_env_string(
6519 env,
6520 "AUTUMN_IDEMPOTENCY__REDIS__KEY_PREFIX",
6521 &mut self.idempotency.redis.key_prefix,
6522 );
6523 }
6524
6525 fn apply_admission_env_overrides_with_env(&mut self, env: &dyn Env) {
6527 let admission = &mut self.server.admission;
6528 parse_env(env, "AUTUMN_SERVER__ADMISSION__MODE", &mut admission.mode);
6529 parse_env(
6530 env,
6531 "AUTUMN_SERVER__ADMISSION__ALGORITHM",
6532 &mut admission.algorithm,
6533 );
6534 parse_env(
6535 env,
6536 "AUTUMN_SERVER__ADMISSION__MIN_LIMIT",
6537 &mut admission.min_limit,
6538 );
6539 parse_env_option(
6540 env,
6541 "AUTUMN_SERVER__ADMISSION__MAX_LIMIT",
6542 &mut admission.max_limit,
6543 );
6544 parse_env(
6545 env,
6546 "AUTUMN_SERVER__ADMISSION__INITIAL_LIMIT",
6547 &mut admission.initial_limit,
6548 );
6549 parse_env(
6550 env,
6551 "AUTUMN_SERVER__ADMISSION__LATENCY_THRESHOLD_MS",
6552 &mut admission.latency_threshold_ms,
6553 );
6554 parse_env_bool(
6555 env,
6556 "AUTUMN_SERVER__ADMISSION__TRUST_CRITICALITY_HEADER",
6557 &mut admission.trust_criticality_header,
6558 );
6559 parse_env(
6560 env,
6561 "AUTUMN_SERVER__ADMISSION__PARTITIONS__DEFAULT",
6562 &mut admission.partitions.default,
6563 );
6564 parse_env(
6565 env,
6566 "AUTUMN_SERVER__ADMISSION__PARTITIONS__SHEDDABLE",
6567 &mut admission.partitions.sheddable,
6568 );
6569 }
6570
6571 fn apply_server_http_env_overrides_with_env(&mut self, env: &dyn Env) {
6573 let http = &mut self.server.http;
6574 parse_env_option(
6575 env,
6576 "AUTUMN_SERVER__HTTP__HEADER_READ_TIMEOUT_MS",
6577 &mut http.header_read_timeout_ms,
6578 );
6579 parse_env_option(
6580 env,
6581 "AUTUMN_SERVER__HTTP__KEEP_ALIVE_TIMEOUT_MS",
6582 &mut http.keep_alive_timeout_ms,
6583 );
6584 parse_env_option(
6585 env,
6586 "AUTUMN_SERVER__HTTP__MAX_HEADER_BYTES",
6587 &mut http.max_header_bytes,
6588 );
6589 parse_env_option(
6590 env,
6591 "AUTUMN_SERVER__HTTP__HTTP2_MAX_CONCURRENT_STREAMS",
6592 &mut http.http2_max_concurrent_streams,
6593 );
6594 parse_env_option(
6595 env,
6596 "AUTUMN_SERVER__HTTP__MAX_CONNECTIONS",
6597 &mut http.max_connections,
6598 );
6599 }
6600
6601 fn apply_server_env_overrides_with_env(&mut self, env: &dyn Env) {
6602 parse_env(env, "AUTUMN_SERVER__PORT", &mut self.server.port);
6603 self.apply_server_http_env_overrides_with_env(env);
6604 parse_env_string(env, "AUTUMN_SERVER__HOST", &mut self.server.host);
6605 parse_env(
6606 env,
6607 "AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
6608 &mut self.server.shutdown_timeout_secs,
6609 );
6610 parse_env(
6611 env,
6612 "AUTUMN_SERVER__PRESTOP_GRACE_SECS",
6613 &mut self.server.prestop_grace_secs,
6614 );
6615 parse_env(
6616 env,
6617 "AUTUMN_SERVER__UPGRADE__ENABLED",
6618 &mut self.server.upgrade.enabled,
6619 );
6620 parse_env(
6621 env,
6622 "AUTUMN_SERVER__UPGRADE__READY_TIMEOUT_SECS",
6623 &mut self.server.upgrade.ready_timeout_secs,
6624 );
6625 parse_env_option(
6626 env,
6627 "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS",
6628 &mut self.server.timeouts.request_timeout_ms,
6629 );
6630 parse_env_bool(
6631 env,
6632 "AUTUMN_SERVER__TIMEOUTS__ACCEPT_DEADLINE_HEADER",
6633 &mut self.server.timeouts.accept_deadline_header,
6634 );
6635 parse_env_option_string(
6636 env,
6637 "AUTUMN_SERVER__UNIX_SOCKET",
6638 &mut self.server.unix_socket,
6639 );
6640 parse_env_option(
6641 env,
6642 "AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS",
6643 &mut self.server.max_concurrent_requests,
6644 );
6645 parse_env_bool(
6646 env,
6647 "AUTUMN_SERVER__STRICT_CONFIG",
6648 &mut self.server.strict_config,
6649 );
6650 parse_env_option_string(
6651 env,
6652 "AUTUMN_SERVER__CAPACITY_CONTRACT",
6653 &mut self.server.capacity_contract,
6654 );
6655 self.apply_admission_env_overrides_with_env(env);
6656
6657 let tls_cert = env.var("AUTUMN_SERVER__TLS__CERT_PATH").ok();
6664 let tls_key = env.var("AUTUMN_SERVER__TLS__KEY_PATH").ok();
6665 let tls_reload = env.var("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS").ok();
6666 let tls_handshake = env.var("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS").ok();
6667 if tls_cert.is_some()
6668 || tls_key.is_some()
6669 || tls_reload.is_some()
6670 || tls_handshake.is_some()
6671 {
6672 let tls = self.server.tls.get_or_insert_with(TlsConfig::empty_for_env);
6673 if let Some(cert) = tls_cert {
6674 tls.cert_path = Some(PathBuf::from(cert));
6675 }
6676 if let Some(key) = tls_key {
6677 tls.key_path = Some(PathBuf::from(key));
6678 }
6679 if let Some(reload) = tls_reload.and_then(|v| v.trim().parse::<u64>().ok()) {
6680 tls.reload_interval_secs = reload;
6681 }
6682 if let Some(handshake) = tls_handshake.and_then(|v| v.trim().parse::<u64>().ok()) {
6683 tls.handshake_timeout_secs = handshake;
6684 }
6685 }
6686 }
6687
6688 fn apply_deploy_env_overrides_with_env(&mut self, env: &dyn Env) {
6689 apply_deploy_env_overrides(&mut self.deploy, env);
6690 }
6691
6692 fn apply_replica_lag_and_pooler_env_overrides(&mut self, env: &dyn Env) {
6694 parse_env_option(
6695 env,
6696 "AUTUMN_DATABASE__REPLICA_MAX_LAG_MS",
6697 &mut self.database.replica_max_lag_ms,
6698 );
6699 parse_env_bool(
6700 env,
6701 "AUTUMN_DATABASE__WARN_ON_POOLER",
6702 &mut self.database.warn_on_pooler,
6703 );
6704 }
6705
6706 fn apply_database_env_overrides_with_env(&mut self, env: &dyn Env) {
6707 if let Ok(val) = env.var("AUTUMN_DATABASE__URL") {
6708 self.database.url = Some(val);
6709 self.database.primary_url = None;
6710 }
6711 parse_env_option_string(
6712 env,
6713 "AUTUMN_DATABASE__PRIMARY_URL",
6714 &mut self.database.primary_url,
6715 );
6716 parse_env_option_string(
6717 env,
6718 "AUTUMN_DATABASE__REPLICA_URL",
6719 &mut self.database.replica_url,
6720 );
6721 parse_env(
6722 env,
6723 "AUTUMN_DATABASE__POOL_SIZE",
6724 &mut self.database.pool_size,
6725 );
6726 parse_env_option(
6727 env,
6728 "AUTUMN_DATABASE__PRIMARY_POOL_SIZE",
6729 &mut self.database.primary_pool_size,
6730 );
6731 parse_env_option(
6732 env,
6733 "AUTUMN_DATABASE__REPLICA_POOL_SIZE",
6734 &mut self.database.replica_pool_size,
6735 );
6736 parse_env(
6737 env,
6738 "AUTUMN_DATABASE__REPLICA_FALLBACK",
6739 &mut self.database.replica_fallback,
6740 );
6741 parse_env(
6742 env,
6743 "AUTUMN_DATABASE__READ_YOUR_WRITES",
6744 &mut self.database.read_your_writes,
6745 );
6746 parse_env(
6747 env,
6748 "AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS",
6749 &mut self.database.pin_after_write_secs,
6750 );
6751 self.apply_replica_lag_and_pooler_env_overrides(env);
6752 parse_env(
6753 env,
6754 "AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
6755 &mut self.database.connect_timeout_secs,
6756 );
6757 parse_env(
6758 env,
6759 "AUTUMN_DATABASE__STARTUP_WAIT_SECS",
6760 &mut self.database.startup_wait_secs,
6761 );
6762 parse_env_option_bool(
6763 env,
6764 "AUTUMN_DATABASE__AUTO_MIGRATE",
6765 &mut self.database.auto_migrate,
6766 );
6767 parse_env_bool(
6768 env,
6769 "AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
6770 &mut self.database.auto_migrate_in_production,
6771 );
6772 parse_env_bool(
6773 env,
6774 "AUTUMN_DATABASE__DIRECTORY_SHARD_ROUTER",
6775 &mut self.database.directory_shard_router,
6776 );
6777 parse_env_option_duration(
6778 env,
6779 "AUTUMN_DATABASE__STATEMENT_TIMEOUT",
6780 &mut self.database.statement_timeout,
6781 );
6782 parse_env_option_duration(
6783 env,
6784 "AUTUMN_DATABASE__IDLE_IN_TRANSACTION_TIMEOUT",
6785 &mut self.database.idle_in_transaction_timeout,
6786 );
6787 let mut lock_timeout = Some(self.database.migration_lock_timeout);
6789 parse_env_option_duration(
6790 env,
6791 "AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT",
6792 &mut lock_timeout,
6793 );
6794 if let Some(timeout) = lock_timeout {
6795 self.database.migration_lock_timeout = timeout;
6796 }
6797 parse_env(
6798 env,
6799 "AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES",
6800 &mut self.database.migration_lock_retries,
6801 );
6802 self.apply_shard_env_overrides(env);
6803 self.apply_fleet_env_overrides(env);
6804 }
6805
6806 fn apply_fleet_env_overrides(&mut self, env: &dyn Env) {
6811 if self.database.fleet.is_none() {
6812 let mode = env.var("AUTUMN_DATABASE__FLEET__MODE").ok();
6813 let root = env.var("AUTUMN_DATABASE__FLEET__ROOT").ok();
6814 let (Some(mode), Some(root)) = (mode, root) else {
6815 return;
6816 };
6817 let Ok(mode) = mode.parse::<crate::fleet_layout::FleetMode>() else {
6818 eprintln!("Warning: AUTUMN_DATABASE__FLEET__MODE={mode:?} is not valid, ignoring");
6819 return;
6820 };
6821 self.database.fleet = Some(DatabaseFleetConfig {
6822 mode,
6823 root,
6824 path: None,
6825 max_open: default_fleet_max_open(),
6826 pool_size: default_fleet_pool_size(),
6827 create_on_demand: None,
6828 idle_close_secs: default_fleet_idle_close_secs(),
6829 restore_missing: false,
6830 ownership: None,
6831 });
6832 }
6833 let Some(fleet) = self.database.fleet.as_mut() else {
6834 return;
6835 };
6836 parse_env(env, "AUTUMN_DATABASE__FLEET__MODE", &mut fleet.mode);
6837 parse_env(env, "AUTUMN_DATABASE__FLEET__ROOT", &mut fleet.root);
6838 parse_env_option_string(env, "AUTUMN_DATABASE__FLEET__PATH", &mut fleet.path);
6839 parse_env(env, "AUTUMN_DATABASE__FLEET__MAX_OPEN", &mut fleet.max_open);
6840 parse_env(
6841 env,
6842 "AUTUMN_DATABASE__FLEET__POOL_SIZE",
6843 &mut fleet.pool_size,
6844 );
6845 parse_env_option_bool(
6846 env,
6847 "AUTUMN_DATABASE__FLEET__CREATE_ON_DEMAND",
6848 &mut fleet.create_on_demand,
6849 );
6850 parse_env(
6851 env,
6852 "AUTUMN_DATABASE__FLEET__IDLE_CLOSE_SECS",
6853 &mut fleet.idle_close_secs,
6854 );
6855 parse_env_bool(
6856 env,
6857 "AUTUMN_DATABASE__FLEET__RESTORE_MISSING",
6858 &mut fleet.restore_missing,
6859 );
6860 if fleet.ownership.is_none() {
6862 if let Ok(owner) = env.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER") {
6863 fleet.ownership = Some(FleetOwnershipConfig {
6864 owner,
6865 slots: Vec::new(),
6866 lease_secs: default_fleet_lease_secs(),
6867 });
6868 } else if env.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS").is_ok()
6869 || env
6870 .var("AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS")
6871 .is_ok()
6872 {
6873 eprintln!(
6874 "Warning: AUTUMN_DATABASE__FLEET__OWNERSHIP__* is set without \
6875 AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER or a [database.fleet.ownership] \
6876 section, ignoring"
6877 );
6878 }
6879 }
6880 let Some(ownership) = fleet.ownership.as_mut() else {
6881 return;
6882 };
6883 parse_env_string(
6884 env,
6885 "AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER",
6886 &mut ownership.owner,
6887 );
6888 if let Ok(slots) = env.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS") {
6890 ownership.slots = slots
6891 .split(',')
6892 .map(str::trim)
6893 .filter(|token| !token.is_empty())
6894 .map(|token| SlotSpec::Range(token.to_owned()))
6895 .collect();
6896 }
6897 parse_env(
6898 env,
6899 "AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS",
6900 &mut ownership.lease_secs,
6901 );
6902 }
6903
6904 fn apply_shard_env_overrides(&mut self, env: &dyn Env) {
6914 const MAX_ENV_SHARDS: usize = 64;
6915 for i in 0..MAX_ENV_SHARDS {
6916 let key = |field: &str| format!("AUTUMN_DATABASE__SHARDS__{i}__{field}");
6917 if i >= self.database.shards.len() {
6918 let (Ok(name), Ok(primary_url)) =
6919 (env.var(&key("NAME")), env.var(&key("PRIMARY_URL")))
6920 else {
6921 break;
6922 };
6923 self.database.shards.push(ShardConfig {
6924 name,
6925 primary_url,
6926 slots: None,
6927 replica_url: None,
6928 primary_pool_size: None,
6929 replica_pool_size: None,
6930 replica_fallback: None,
6931 });
6932 }
6933 let shard = &mut self.database.shards[i];
6934 parse_env_string(env, &key("NAME"), &mut shard.name);
6935 parse_env_string(env, &key("PRIMARY_URL"), &mut shard.primary_url);
6936 if let Ok(val) = env.var(&key("SLOTS")) {
6938 shard.slots = Some(
6939 val.split(',')
6940 .map(|token| SlotSpec::Range(token.trim().to_owned()))
6941 .collect(),
6942 );
6943 }
6944 parse_env_option_string(env, &key("REPLICA_URL"), &mut shard.replica_url);
6945 parse_env_option(env, &key("PRIMARY_POOL_SIZE"), &mut shard.primary_pool_size);
6946 parse_env_option(env, &key("REPLICA_POOL_SIZE"), &mut shard.replica_pool_size);
6947 parse_env_option(env, &key("REPLICA_FALLBACK"), &mut shard.replica_fallback);
6948 }
6949 }
6950
6951 fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
6952 parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
6953 parse_env_bool(env, "AUTUMN_LOG__ACCESS_LOG", &mut self.log.access_log);
6954 parse_env_csv(
6955 env,
6956 "AUTUMN_LOG__ACCESS_LOG_EXCLUDE",
6957 &mut self.log.access_log_exclude,
6958 );
6959 if let Ok(val) = env.var("AUTUMN_LOG__FORMAT") {
6960 match val.as_str() {
6961 "Auto" => self.log.format = LogFormat::Auto,
6962 "Pretty" => self.log.format = LogFormat::Pretty,
6963 "Json" => self.log.format = LogFormat::Json,
6964 _ => eprintln!(
6965 "Warning: AUTUMN_LOG__FORMAT={val:?} is not valid \
6966 (expected Auto, Pretty, or Json), ignoring"
6967 ),
6968 }
6969 }
6970 }
6971
6972 fn apply_telemetry_env_overrides_with_env(&mut self, env: &dyn Env) {
6973 parse_env_bool(
6975 env,
6976 "AUTUMN_TELEMETRY__ENABLED",
6977 &mut self.telemetry.enabled,
6978 );
6979 parse_env_string(
6980 env,
6981 "AUTUMN_TELEMETRY__SERVICE_NAME",
6982 &mut self.telemetry.service_name,
6983 );
6984 parse_env_option_string(
6985 env,
6986 "AUTUMN_TELEMETRY__SERVICE_NAMESPACE",
6987 &mut self.telemetry.service_namespace,
6988 );
6989 parse_env_string(
6990 env,
6991 "AUTUMN_TELEMETRY__SERVICE_VERSION",
6992 &mut self.telemetry.service_version,
6993 );
6994 parse_env_string(
6995 env,
6996 "AUTUMN_TELEMETRY__ENVIRONMENT",
6997 &mut self.telemetry.environment,
6998 );
6999 parse_env_option_string(
7000 env,
7001 "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
7002 &mut self.telemetry.otlp_endpoint,
7003 );
7004 if let Ok(val) = env.var("AUTUMN_TELEMETRY__PROTOCOL") {
7005 match TelemetryProtocol::from_env_value(&val) {
7006 Some(protocol) => self.telemetry.protocol = protocol,
7007 None => eprintln!(
7008 "Warning: AUTUMN_TELEMETRY__PROTOCOL={val:?} is not valid \
7009 (expected Grpc or HttpProtobuf), ignoring"
7010 ),
7011 }
7012 }
7013 parse_env_bool(env, "AUTUMN_TELEMETRY__STRICT", &mut self.telemetry.strict);
7014 if let Ok(val) = env.var("AUTUMN_TELEMETRY__SAMPLE_RATIO") {
7015 match val.trim().parse::<f64>() {
7016 Ok(ratio) => self.telemetry.sample_ratio = ratio,
7017 Err(_) => eprintln!(
7018 "Warning: AUTUMN_TELEMETRY__SAMPLE_RATIO={val:?} is not a number, ignoring"
7019 ),
7020 }
7021 }
7022 }
7023
7024 fn apply_health_env_overrides_with_env(&mut self, env: &dyn Env) {
7025 parse_env_string(env, "AUTUMN_HEALTH__PATH", &mut self.health.path);
7026 parse_env_string(env, "AUTUMN_HEALTH__LIVE_PATH", &mut self.health.live_path);
7027 parse_env_string(
7028 env,
7029 "AUTUMN_HEALTH__READY_PATH",
7030 &mut self.health.ready_path,
7031 );
7032 parse_env_string(
7033 env,
7034 "AUTUMN_HEALTH__STARTUP_PATH",
7035 &mut self.health.startup_path,
7036 );
7037 parse_env_bool(env, "AUTUMN_HEALTH__DETAILED", &mut self.health.detailed);
7038 parse_env_bool(env, "AUTUMN_HEALTH__ENABLED", &mut self.health.enabled);
7039 parse_env(
7040 env,
7041 "AUTUMN_HEALTH__CACHE_TTL_MS",
7042 &mut self.health.cache_ttl_ms,
7043 );
7044 parse_env(
7045 env,
7046 "AUTUMN_HEALTH__PING_TIMEOUT_MS",
7047 &mut self.health.ping_timeout_ms,
7048 );
7049 parse_env_bool(
7050 env,
7051 "AUTUMN_HEALTH__REDIS_READINESS",
7052 &mut self.health.redis_readiness,
7053 );
7054 parse_env_bool(
7055 env,
7056 "AUTUMN_HEALTH__DB_READINESS",
7057 &mut self.health.db_readiness,
7058 );
7059 }
7060
7061 fn apply_cors_env_overrides_with_env(&mut self, env: &dyn Env) {
7062 parse_env_csv(
7063 env,
7064 "AUTUMN_CORS__ALLOWED_ORIGINS",
7065 &mut self.cors.allowed_origins,
7066 );
7067 parse_env_csv(
7068 env,
7069 "AUTUMN_CORS__ALLOWED_METHODS",
7070 &mut self.cors.allowed_methods,
7071 );
7072 parse_env_csv(
7073 env,
7074 "AUTUMN_CORS__ALLOWED_HEADERS",
7075 &mut self.cors.allowed_headers,
7076 );
7077 parse_env_bool(
7078 env,
7079 "AUTUMN_CORS__ALLOW_CREDENTIALS",
7080 &mut self.cors.allow_credentials,
7081 );
7082 parse_env(
7083 env,
7084 "AUTUMN_CORS__MAX_AGE_SECS",
7085 &mut self.cors.max_age_secs,
7086 );
7087 }
7088
7089 fn apply_session_env_overrides_with_env(&mut self, env: &dyn Env) {
7090 parse_env_string(
7091 env,
7092 "AUTUMN_SESSION__COOKIE_NAME",
7093 &mut self.session.cookie_name,
7094 );
7095 if let Ok(val) = env.var("AUTUMN_SESSION__BACKEND") {
7096 match crate::session::SessionBackend::from_env_value(&val) {
7097 Some(backend) => self.session.backend = backend,
7098 None => eprintln!(
7099 "Warning: AUTUMN_SESSION__BACKEND={val:?} is not valid \
7100 (expected memory or redis), ignoring"
7101 ),
7102 }
7103 }
7104 parse_env(
7105 env,
7106 "AUTUMN_SESSION__MAX_AGE_SECS",
7107 &mut self.session.max_age_secs,
7108 );
7109 parse_env_bool(env, "AUTUMN_SESSION__SECURE", &mut self.session.secure);
7110 parse_env_string(
7111 env,
7112 "AUTUMN_SESSION__SAME_SITE",
7113 &mut self.session.same_site,
7114 );
7115 parse_env_bool(
7116 env,
7117 "AUTUMN_SESSION__HTTP_ONLY",
7118 &mut self.session.http_only,
7119 );
7120 parse_env_string(env, "AUTUMN_SESSION__PATH", &mut self.session.path);
7121 parse_env_bool(
7122 env,
7123 "AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION",
7124 &mut self.session.allow_memory_in_production,
7125 );
7126 parse_env_option_string(
7127 env,
7128 "AUTUMN_SESSION__REDIS__URL",
7129 &mut self.session.redis.url,
7130 );
7131 parse_env_string(
7132 env,
7133 "AUTUMN_SESSION__REDIS__KEY_PREFIX",
7134 &mut self.session.redis.key_prefix,
7135 );
7136 }
7137
7138 fn apply_cache_env_overrides_with_env(&mut self, env: &dyn Env) {
7139 if let Ok(val) = env.var("AUTUMN_CACHE__BACKEND") {
7140 match CacheBackend::from_env_value(&val) {
7141 Some(backend) => self.cache.backend = backend,
7142 None => eprintln!(
7143 "Warning: AUTUMN_CACHE__BACKEND={val:?} is not valid \
7144 (expected memory or redis), ignoring"
7145 ),
7146 }
7147 }
7148 parse_env_option_string(env, "AUTUMN_CACHE__REDIS__URL", &mut self.cache.redis.url);
7149 parse_env_string(
7150 env,
7151 "AUTUMN_CACHE__REDIS__KEY_PREFIX",
7152 &mut self.cache.redis.key_prefix,
7153 );
7154 }
7155
7156 fn apply_realtime_env_overrides_with_env(&mut self, env: &dyn Env) {
7157 let realtime = &mut self.realtime;
7158 parse_env_option(
7159 env,
7160 "AUTUMN_REALTIME__MAX_CONNECTIONS",
7161 &mut realtime.max_connections,
7162 );
7163 parse_env_option(
7164 env,
7165 "AUTUMN_REALTIME__MAX_MESSAGE_BYTES",
7166 &mut realtime.max_message_bytes,
7167 );
7168 parse_env_option(
7169 env,
7170 "AUTUMN_REALTIME__PING_INTERVAL_MS",
7171 &mut realtime.ping_interval_ms,
7172 );
7173 parse_env_option(
7174 env,
7175 "AUTUMN_REALTIME__IDLE_TIMEOUT_MS",
7176 &mut realtime.idle_timeout_ms,
7177 );
7178 }
7179
7180 fn apply_channels_env_overrides_with_env(&mut self, env: &dyn Env) {
7181 if let Ok(val) = env.var("AUTUMN_CHANNELS__BACKEND") {
7182 match ChannelBackend::from_env_value(&val) {
7183 Some(backend) => self.channels.backend = backend,
7184 None => eprintln!(
7185 "Warning: AUTUMN_CHANNELS__BACKEND={val:?} is not valid \
7186 (expected in_process or redis), ignoring"
7187 ),
7188 }
7189 }
7190 parse_env(
7191 env,
7192 "AUTUMN_CHANNELS__CAPACITY",
7193 &mut self.channels.capacity,
7194 );
7195 parse_env(
7196 env,
7197 "AUTUMN_CHANNELS__REPLAY_BUFFER",
7198 &mut self.channels.replay_buffer,
7199 );
7200 parse_env_option_string(
7201 env,
7202 "AUTUMN_CHANNELS__REDIS__URL",
7203 &mut self.channels.redis.url,
7204 );
7205 parse_env_string(
7206 env,
7207 "AUTUMN_CHANNELS__REDIS__KEY_PREFIX",
7208 &mut self.channels.redis.key_prefix,
7209 );
7210 }
7211
7212 fn apply_jobs_env_overrides_with_env(&mut self, env: &dyn Env) {
7213 parse_env_string(env, "AUTUMN_JOBS__BACKEND", &mut self.jobs.backend);
7214 parse_env(env, "AUTUMN_JOBS__WORKERS", &mut self.jobs.workers);
7215 if let Ok(val) = env.var("AUTUMN_JOBS__PIN") {
7216 self.jobs.pin = val
7217 .split(',')
7218 .map(str::trim)
7219 .filter(|s| !s.is_empty())
7220 .map(str::to_owned)
7221 .collect();
7222 }
7223 parse_env(
7224 env,
7225 "AUTUMN_JOBS__MAX_ATTEMPTS",
7226 &mut self.jobs.max_attempts,
7227 );
7228 parse_env(
7229 env,
7230 "AUTUMN_JOBS__INITIAL_BACKOFF_MS",
7231 &mut self.jobs.initial_backoff_ms,
7232 );
7233 parse_env(
7234 env,
7235 "AUTUMN_JOBS__MAX_BACKOFF_MS",
7236 &mut self.jobs.max_backoff_ms,
7237 );
7238 parse_env(
7239 env,
7240 "AUTUMN_JOBS__DEFAULT_TIMEOUT_MS",
7241 &mut self.jobs.default_timeout_ms,
7242 );
7243 parse_env_option_string(env, "AUTUMN_JOBS__REDIS__URL", &mut self.jobs.redis.url);
7244 parse_env_string(
7245 env,
7246 "AUTUMN_JOBS__REDIS__KEY_PREFIX",
7247 &mut self.jobs.redis.key_prefix,
7248 );
7249 parse_env(
7250 env,
7251 "AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS",
7252 &mut self.jobs.redis.visibility_timeout_ms,
7253 );
7254 parse_env(
7255 env,
7256 "AUTUMN_JOBS__REDIS__DEAD_LETTER_LIMIT",
7257 &mut self.jobs.redis.dead_letter_limit,
7258 );
7259 parse_env(
7260 env,
7261 "AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS",
7262 &mut self.jobs.postgres.visibility_timeout_ms,
7263 );
7264 parse_env_bool(
7265 env,
7266 "AUTUMN_JOBS__POSTGRES__SHARD_LOCAL",
7267 &mut self.jobs.postgres.shard_local,
7268 );
7269 parse_env(
7270 env,
7271 "AUTUMN_JOBS__TENANTS__MAX_CONCURRENT",
7272 &mut self.jobs.tenants.max_concurrent,
7273 );
7274 parse_env(
7275 env,
7276 "AUTUMN_JOBS__TENANTS__LANES",
7277 &mut self.jobs.tenants.lanes,
7278 );
7279 parse_env(
7280 env,
7281 "AUTUMN_JOBS__TENANTS__LANES_PER_TENANT",
7282 &mut self.jobs.tenants.lanes_per_tenant,
7283 );
7284 parse_env(
7285 env,
7286 "AUTUMN_JOBS__SQLITE__VISIBILITY_TIMEOUT_MS",
7287 &mut self.jobs.sqlite.visibility_timeout_ms,
7288 );
7289 parse_env(
7290 env,
7291 "AUTUMN_JOBS__SQLITE__POLL_INTERVAL_MS",
7292 &mut self.jobs.sqlite.poll_interval_ms,
7293 );
7294 parse_env(
7295 env,
7296 "AUTUMN_JOBS__TRACKING__TTL_SECS",
7297 &mut self.jobs.tracking.ttl_secs,
7298 );
7299 parse_env_bool(
7300 env,
7301 "AUTUMN_JOBS__TRACKING__ROUTE_ENABLED",
7302 &mut self.jobs.tracking.route_enabled,
7303 );
7304 }
7305
7306 fn apply_outbox_env_overrides_with_env(&mut self, env: &dyn Env) {
7307 parse_env_bool(env, "AUTUMN_OUTBOX__ENABLED", &mut self.outbox.enabled);
7308 parse_env(
7309 env,
7310 "AUTUMN_OUTBOX__POLL_INTERVAL_MS",
7311 &mut self.outbox.poll_interval_ms,
7312 );
7313 parse_env(
7314 env,
7315 "AUTUMN_OUTBOX__BATCH_SIZE",
7316 &mut self.outbox.batch_size,
7317 );
7318 parse_env(
7319 env,
7320 "AUTUMN_OUTBOX__MAX_ATTEMPTS",
7321 &mut self.outbox.max_attempts,
7322 );
7323 parse_env(
7324 env,
7325 "AUTUMN_OUTBOX__INITIAL_BACKOFF_MS",
7326 &mut self.outbox.initial_backoff_ms,
7327 );
7328 parse_env(
7329 env,
7330 "AUTUMN_OUTBOX__MAX_BACKOFF_MS",
7331 &mut self.outbox.max_backoff_ms,
7332 );
7333 parse_env(env, "AUTUMN_OUTBOX__LEASE_MS", &mut self.outbox.lease_ms);
7334 parse_env(
7335 env,
7336 "AUTUMN_OUTBOX__RETENTION_MS",
7337 &mut self.outbox.retention_ms,
7338 );
7339 }
7340
7341 fn apply_role_env_overrides_with_env(&mut self, env: &dyn Env) {
7342 if let Ok(val) = env.var("AUTUMN_ROLE") {
7343 match ProcessRole::from_env_value(&val) {
7344 Some(role) => self.role = role,
7345 None => eprintln!(
7346 "Warning: AUTUMN_ROLE={val:?} is not valid \
7347 (expected combined, web, or worker), ignoring"
7348 ),
7349 }
7350 }
7351 }
7352
7353 fn apply_retention_env_overrides_with_env(&mut self, env: &dyn Env) {
7360 parse_env_string(
7361 env,
7362 "AUTUMN_RETENTION__SWEEP_INTERVAL",
7363 &mut self.retention.sweep_interval,
7364 );
7365 parse_env_option_string(
7366 env,
7367 "AUTUMN_RETENTION__JOB_HISTORY",
7368 &mut self.retention.job_history,
7369 );
7370 parse_env_option_string(
7371 env,
7372 "AUTUMN_RETENTION__COMMIT_HOOKS",
7373 &mut self.retention.commit_hooks,
7374 );
7375 parse_env_option_string(
7376 env,
7377 "AUTUMN_RETENTION__JOB_TRACKING",
7378 &mut self.retention.job_tracking,
7379 );
7380 parse_env_option_string(
7381 env,
7382 "AUTUMN_RETENTION__IDEMPOTENCY",
7383 &mut self.retention.idempotency,
7384 );
7385 parse_env_option_string(
7386 env,
7387 "AUTUMN_RETENTION__EXPERIMENT_ASSIGNMENTS",
7388 &mut self.retention.experiment_assignments,
7389 );
7390 parse_env_option_string(
7391 env,
7392 "AUTUMN_RETENTION__WEBHOOK_REPLAY",
7393 &mut self.retention.webhook_replay,
7394 );
7395 parse_env_option_string(
7396 env,
7397 "AUTUMN_RETENTION__SESSIONS",
7398 &mut self.retention.sessions,
7399 );
7400 parse_env_option_string(
7401 env,
7402 "AUTUMN_RETENTION__AUDIT_ARCHIVES",
7403 &mut self.retention.audit_archives,
7404 );
7405 parse_env_option_string(
7406 env,
7407 "AUTUMN_RETENTION__CUSTOM_DOMAINS",
7408 &mut self.retention.custom_domains,
7409 );
7410 }
7411
7412 fn apply_scheduler_env_overrides_with_env(&mut self, env: &dyn Env) {
7413 if let Ok(val) = env.var("AUTUMN_SCHEDULER__BACKEND") {
7414 match SchedulerBackend::from_env_value(&val) {
7415 Some(backend) => self.scheduler.backend = backend,
7416 None => eprintln!(
7417 "Warning: AUTUMN_SCHEDULER__BACKEND={val:?} is not valid \
7418 (expected in_process, postgres or sqlite), ignoring"
7419 ),
7420 }
7421 }
7422 parse_env(
7423 env,
7424 "AUTUMN_SCHEDULER__LEASE_TTL_SECS",
7425 &mut self.scheduler.lease_ttl_secs,
7426 );
7427 parse_env_option_string(
7428 env,
7429 "AUTUMN_SCHEDULER__REPLICA_ID",
7430 &mut self.scheduler.replica_id,
7431 );
7432 parse_env_string(
7433 env,
7434 "AUTUMN_SCHEDULER__KEY_PREFIX",
7435 &mut self.scheduler.key_prefix,
7436 );
7437 }
7438
7439 fn apply_auth_env_overrides_with_env(&mut self, env: &dyn Env) {
7440 parse_env(env, "AUTUMN_AUTH__BCRYPT_COST", &mut self.auth.bcrypt_cost);
7441 parse_env_string(env, "AUTUMN_AUTH__SESSION_KEY", &mut self.auth.session_key);
7442 parse_env(
7443 env,
7444 "AUTUMN_AUTH__LOCKOUT__ENABLED",
7445 &mut self.auth.lockout.enabled,
7446 );
7447 parse_env(
7448 env,
7449 "AUTUMN_AUTH__LOCKOUT__THRESHOLD",
7450 &mut self.auth.lockout.threshold,
7451 );
7452 parse_env(
7453 env,
7454 "AUTUMN_AUTH__LOCKOUT__WINDOW_SECS",
7455 &mut self.auth.lockout.window_secs,
7456 );
7457 parse_env(
7458 env,
7459 "AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS",
7460 &mut self.auth.lockout.cooloff_secs,
7461 );
7462 parse_env(
7463 env,
7464 "AUTUMN_AUTH__PASSWORD__MIN_LENGTH",
7465 &mut self.auth.password.min_length,
7466 );
7467 parse_env_bool(
7468 env,
7469 "AUTUMN_AUTH__PASSWORD__REJECT_COMMON",
7470 &mut self.auth.password.reject_common,
7471 );
7472 if let Ok(val) = env.var("AUTUMN_AUTH__PASSWORD__BREACH_CHECK") {
7473 match val.as_str() {
7474 "off" => self.auth.password.breach_check = crate::auth::BreachCheck::Off,
7475 "fail_open" => self.auth.password.breach_check = crate::auth::BreachCheck::FailOpen,
7476 "fail_closed" => {
7477 self.auth.password.breach_check = crate::auth::BreachCheck::FailClosed;
7478 }
7479 other => eprintln!(
7480 "Warning: AUTUMN_AUTH__PASSWORD__BREACH_CHECK={other:?} is not valid \
7481 (expected off, fail_open, or fail_closed), ignoring"
7482 ),
7483 }
7484 }
7485 parse_env_bool(
7486 env,
7487 "AUTUMN_AUTH__REMEMBER__ENABLED",
7488 &mut self.auth.remember.enabled,
7489 );
7490 parse_env(
7491 env,
7492 "AUTUMN_AUTH__REMEMBER__DURATION_SECS",
7493 &mut self.auth.remember.duration_secs,
7494 );
7495 parse_env_string(
7496 env,
7497 "AUTUMN_AUTH__REMEMBER__COOKIE_NAME",
7498 &mut self.auth.remember.cookie_name,
7499 );
7500 parse_env(
7501 env,
7502 "AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES",
7503 &mut self.auth.magic_link.ttl_minutes,
7504 );
7505 parse_env(
7506 env,
7507 "AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS",
7508 &mut self.auth.magic_link.email_cooldown_secs,
7509 );
7510 #[cfg(feature = "oauth2")]
7511 {
7512 let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
7513 for name in provider_names {
7514 let upper = name
7515 .chars()
7516 .map(|c| if c.is_alphanumeric() { c } else { '_' })
7517 .collect::<String>()
7518 .to_uppercase();
7519
7520 let client_id_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_ID");
7521 if let Ok(id) = env.var(&client_id_var)
7522 && !id.is_empty()
7523 && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
7524 {
7525 p.client_id = id;
7526 }
7527
7528 let client_secret_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_SECRET");
7529 if let Ok(secret) = env.var(&client_secret_var)
7530 && !secret.is_empty()
7531 && let Some(p) = self.auth.oauth2.providers.get_mut(&name)
7532 {
7533 p.client_secret = secret;
7534 }
7535 }
7536 }
7537 }
7538
7539 #[allow(clippy::too_many_lines)]
7541 fn apply_security_env_overrides_with_env(&mut self, env: &dyn Env) {
7542 parse_env_string(
7543 env,
7544 "AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS",
7545 &mut self.security.headers.x_frame_options,
7546 );
7547 parse_env_bool(
7548 env,
7549 "AUTUMN_SECURITY__HEADERS__X_CONTENT_TYPE_OPTIONS",
7550 &mut self.security.headers.x_content_type_options,
7551 );
7552 parse_env_bool(
7553 env,
7554 "AUTUMN_SECURITY__HEADERS__STRICT_TRANSPORT_SECURITY",
7555 &mut self.security.headers.strict_transport_security,
7556 );
7557 parse_env(
7558 env,
7559 "AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS",
7560 &mut self.security.headers.hsts_max_age_secs,
7561 );
7562 parse_env_string(
7563 env,
7564 "AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY",
7565 &mut self.security.headers.content_security_policy,
7566 );
7567 parse_env_string(
7568 env,
7569 "AUTUMN_SECURITY__HEADERS__REFERRER_POLICY",
7570 &mut self.security.headers.referrer_policy,
7571 );
7572 parse_env_string(
7573 env,
7574 "AUTUMN_SECURITY__HEADERS__PERMISSIONS_POLICY",
7575 &mut self.security.headers.permissions_policy,
7576 );
7577
7578 parse_env_bool(
7580 env,
7581 "AUTUMN_SECURITY__CSRF__ENABLED",
7582 &mut self.security.csrf.enabled,
7583 );
7584 parse_env_string(
7585 env,
7586 "AUTUMN_SECURITY__CSRF__TOKEN_HEADER",
7587 &mut self.security.csrf.token_header,
7588 );
7589 parse_env_string(
7590 env,
7591 "AUTUMN_SECURITY__CSRF__COOKIE_NAME",
7592 &mut self.security.csrf.cookie_name,
7593 );
7594 parse_env(
7595 env,
7596 "AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES",
7597 &mut self.security.csrf.token_scan_bytes,
7598 );
7599
7600 self.apply_rate_limit_env_overrides_with_env(env);
7601
7602 parse_env(
7604 env,
7605 "AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES",
7606 &mut self.security.upload.max_request_size_bytes,
7607 );
7608 parse_env(
7609 env,
7610 "AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES",
7611 &mut self.security.upload.max_file_size_bytes,
7612 );
7613 parse_env_csv(
7614 env,
7615 "AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES",
7616 &mut self.security.upload.allowed_mime_types,
7617 );
7618 parse_env_bool(
7619 env,
7620 "AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
7621 &mut self.security.upload.reject_on_content_type_mismatch,
7622 );
7623
7624 if let Ok(value) = env.var("AUTUMN_SECURITY__FORBIDDEN_RESPONSE") {
7626 match value.parse::<crate::authorization::ForbiddenResponse>() {
7627 Ok(parsed) => self.security.forbidden_response = parsed,
7628 Err(err) => tracing::warn!(
7629 "ignoring invalid AUTUMN_SECURITY__FORBIDDEN_RESPONSE={value:?}: {err}"
7630 ),
7631 }
7632 }
7633 parse_env_bool(
7634 env,
7635 "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
7636 &mut self.security.allow_unauthorized_repository_api,
7637 );
7638
7639 parse_env_option_string(
7641 env,
7642 "AUTUMN_SECURITY__SIGNING_SECRET",
7643 &mut self.security.signing_secret.secret,
7644 );
7645 parse_env_csv(
7646 env,
7647 "AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS",
7648 &mut self.security.trusted_hosts.hosts,
7649 );
7650
7651 parse_env_csv(
7653 env,
7654 "AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES",
7655 &mut self.security.trusted_proxies.ranges,
7656 );
7657 parse_env_bool(
7658 env,
7659 "AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS",
7660 &mut self.security.trusted_proxies.trust_forwarded_headers,
7661 );
7662 if let Ok(val) = env.var("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS") {
7663 if let Ok(hops) = val.trim().parse::<u32>() {
7664 self.security.trusted_proxies.trusted_hops = Some(hops);
7665 } else {
7666 tracing::warn!(
7667 "ignoring invalid AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS={val:?}: \
7668 expected a non-negative integer"
7669 );
7670 }
7671 }
7672
7673 self.security.webhooks.apply_env_overrides_with_env(env);
7674 }
7675
7676 fn apply_bot_protection_env_overrides_with_env(&mut self, env: &dyn Env) {
7677 parse_env_bool(
7678 env,
7679 "AUTUMN_BOT_PROTECTION__ENABLED",
7680 &mut self.bot_protection.enabled,
7681 );
7682 parse_env_bool(
7683 env,
7684 "AUTUMN_BOT_PROTECTION__DEV_BYPASS",
7685 &mut self.bot_protection.dev_bypass,
7686 );
7687 if let Ok(val) = env.var("AUTUMN_BOT_PROTECTION__PROVIDER") {
7688 match val.to_lowercase().as_str() {
7689 "turnstile" => {
7690 self.bot_protection.provider =
7691 crate::security::captcha::CaptchaProviderKind::Turnstile;
7692 }
7693 "hcaptcha" => {
7694 self.bot_protection.provider =
7695 crate::security::captcha::CaptchaProviderKind::HCaptcha;
7696 }
7697 _ => tracing::warn!(
7698 "ignoring unrecognised AUTUMN_BOT_PROTECTION__PROVIDER={val:?}: \
7699 expected \"turnstile\" or \"hcaptcha\""
7700 ),
7701 }
7702 }
7703 parse_env_option_string(
7704 env,
7705 "AUTUMN_BOT_PROTECTION__SITE_KEY",
7706 &mut self.bot_protection.site_key,
7707 );
7708 parse_env_option_string(
7709 env,
7710 "AUTUMN_BOT_PROTECTION__SECRET_KEY",
7711 &mut self.bot_protection.secret_key,
7712 );
7713 parse_env_option_string(
7714 env,
7715 "AUTUMN_BOT_PROTECTION__FORM_FIELD",
7716 &mut self.bot_protection.form_field,
7717 );
7718 }
7719
7720 fn apply_rate_limit_env_overrides_with_env(&mut self, env: &dyn Env) {
7721 parse_env_bool(
7722 env,
7723 "AUTUMN_SECURITY__RATE_LIMIT__ENABLED",
7724 &mut self.security.rate_limit.enabled,
7725 );
7726 parse_env(
7727 env,
7728 "AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND",
7729 &mut self.security.rate_limit.requests_per_second,
7730 );
7731 parse_env(
7732 env,
7733 "AUTUMN_SECURITY__RATE_LIMIT__BURST",
7734 &mut self.security.rate_limit.burst,
7735 );
7736 parse_env_bool(
7737 env,
7738 "AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS",
7739 &mut self.security.rate_limit.trust_forwarded_headers,
7740 );
7741 parse_env_csv(
7742 env,
7743 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
7744 &mut self.security.rate_limit.trusted_proxies,
7745 );
7746 if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY") {
7747 match crate::security::config::KeyStrategy::from_env_value(&val) {
7748 Some(strategy) => self.security.rate_limit.key_strategy = strategy,
7749 None => eprintln!(
7750 "Warning: AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY={val:?} is not valid \
7751 (expected ip, api_token, or authenticated_principal), ignoring"
7752 ),
7753 }
7754 }
7755 if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__BACKEND") {
7758 match crate::security::config::RateLimitBackend::from_env_value(&val) {
7759 Some(backend) => self.security.rate_limit.backend = backend,
7760 None => eprintln!(
7761 "Warning: AUTUMN_SECURITY__RATE_LIMIT__BACKEND={val:?} is not valid \
7762 (expected memory or redis), ignoring"
7763 ),
7764 }
7765 }
7766 #[cfg(feature = "redis")]
7767 {
7768 use crate::security::config::RateLimitBackendFailure;
7769 if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE") {
7770 match RateLimitBackendFailure::from_env_value(&val) {
7771 Some(mode) => self.security.rate_limit.on_backend_failure = mode,
7772 None => eprintln!(
7773 "Warning: AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE={val:?} is not \
7774 valid (expected fail_open or fail_closed), ignoring"
7775 ),
7776 }
7777 }
7778 parse_env_option_string(
7779 env,
7780 "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
7781 &mut self.security.rate_limit.redis.url,
7782 );
7783 parse_env_string(
7784 env,
7785 "AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX",
7786 &mut self.security.rate_limit.redis.key_prefix,
7787 );
7788 }
7789 }
7790
7791 #[cfg(feature = "storage")]
7792 fn apply_storage_env_overrides_with_env(&mut self, env: &dyn Env) {
7793 if let Ok(val) = env.var("AUTUMN_STORAGE__BACKEND") {
7794 match crate::storage::StorageBackend::from_env_value(&val) {
7795 Some(backend) => self.storage.backend = backend,
7796 None => eprintln!(
7797 "Warning: AUTUMN_STORAGE__BACKEND={val:?} is not valid \
7798 (expected disabled, local, or s3), ignoring"
7799 ),
7800 }
7801 }
7802 parse_env_string(
7803 env,
7804 "AUTUMN_STORAGE__DEFAULT_PROVIDER",
7805 &mut self.storage.default_provider,
7806 );
7807 parse_env_bool(
7808 env,
7809 "AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION",
7810 &mut self.storage.allow_local_in_production,
7811 );
7812 if let Ok(val) = env.var("AUTUMN_STORAGE__LOCAL__ROOT") {
7813 self.storage.local.root = PathBuf::from(val);
7814 }
7815 parse_env_string(
7816 env,
7817 "AUTUMN_STORAGE__LOCAL__MOUNT_PATH",
7818 &mut self.storage.local.mount_path,
7819 );
7820 parse_env(
7821 env,
7822 "AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS",
7823 &mut self.storage.local.default_url_expiry_secs,
7824 );
7825 parse_env_option_string(
7826 env,
7827 "AUTUMN_STORAGE__LOCAL__SIGNING_KEY",
7828 &mut self.storage.local.signing_key,
7829 );
7830 parse_env_option_string(
7831 env,
7832 "AUTUMN_STORAGE__S3__BUCKET",
7833 &mut self.storage.s3.bucket,
7834 );
7835 parse_env_option_string(
7836 env,
7837 "AUTUMN_STORAGE__S3__REGION",
7838 &mut self.storage.s3.region,
7839 );
7840 parse_env_option_string(
7841 env,
7842 "AUTUMN_STORAGE__S3__ENDPOINT",
7843 &mut self.storage.s3.endpoint,
7844 );
7845 parse_env_option_string(
7846 env,
7847 "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
7848 &mut self.storage.s3.public_base_url,
7849 );
7850 parse_env_option_string(
7851 env,
7852 "AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV",
7853 &mut self.storage.s3.access_key_id_env,
7854 );
7855 parse_env_option_string(
7856 env,
7857 "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
7858 &mut self.storage.s3.secret_access_key_env,
7859 );
7860 parse_env_bool(
7861 env,
7862 "AUTUMN_STORAGE__S3__FORCE_PATH_STYLE",
7863 &mut self.storage.s3.force_path_style,
7864 );
7865 parse_env(
7866 env,
7867 "AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS",
7868 &mut self.storage.s3.default_url_expiry_secs,
7869 );
7870 parse_env(
7871 env,
7872 "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES",
7873 &mut self.storage.variants.max_source_bytes,
7874 );
7875 parse_env(
7876 env,
7877 "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH",
7878 &mut self.storage.variants.max_source_width,
7879 );
7880 parse_env(
7881 env,
7882 "AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT",
7883 &mut self.storage.variants.max_source_height,
7884 );
7885 }
7886
7887 fn apply_backup_env_overrides_with_env(&mut self, env: &dyn Env) {
7893 const OFFSITE_DEST_KEYS: &[&str] = &[
7908 "AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
7909 "AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
7910 "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
7911 ];
7912 let has_dest_key = OFFSITE_DEST_KEYS.iter().any(|k| env.var(k).is_ok());
7913 let auto_upload_truthy = env
7914 .var("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD")
7915 .is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true"));
7916 if self.backup.offsite.is_none() && !has_dest_key && !auto_upload_truthy {
7917 return;
7918 }
7919 let offsite = self
7920 .backup
7921 .offsite
7922 .get_or_insert_with(|| Box::new(OffsiteBackupConfig::default()));
7923 parse_env_option_string(
7924 env,
7925 "AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
7926 &mut offsite.s3.bucket,
7927 );
7928 parse_env_option_string(
7929 env,
7930 "AUTUMN_BACKUP__OFFSITE__S3__REGION",
7931 &mut offsite.s3.region,
7932 );
7933 parse_env_option_string(
7934 env,
7935 "AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
7936 &mut offsite.s3.endpoint,
7937 );
7938 parse_env_option_string(
7939 env,
7940 "AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
7941 &mut offsite.s3.access_key_id_env,
7942 );
7943 parse_env_option_string(
7944 env,
7945 "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
7946 &mut offsite.s3.secret_access_key_env,
7947 );
7948 parse_env_bool(
7949 env,
7950 "AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE",
7951 &mut offsite.s3.force_path_style,
7952 );
7953 parse_env_option_string(env, "AUTUMN_BACKUP__OFFSITE__PREFIX", &mut offsite.prefix);
7954 parse_env_option(env, "AUTUMN_BACKUP__OFFSITE__KEEP", &mut offsite.keep);
7955 parse_env_bool(
7956 env,
7957 "AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
7958 &mut offsite.auto_upload,
7959 );
7960 parse_env_bool(
7961 env,
7962 "AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
7963 &mut offsite.allow_shared_bucket,
7964 );
7965 }
7966
7967 fn apply_replication_env_overrides_with_env(&mut self, env: &dyn Env) {
7973 const DEST_KEYS: &[&str] = &[
7979 "AUTUMN_REPLICATION__S3__BUCKET",
7980 "AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV",
7981 "AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV",
7982 "AUTUMN_REPLICATION__PATH",
7983 ];
7984 const S3_KEYS: &[&str] = &[
7992 "AUTUMN_REPLICATION__S3__BUCKET",
7993 "AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV",
7994 "AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV",
7995 ];
7996 let has_dest_key = DEST_KEYS.iter().any(|k| env.var(k).is_ok());
7997 let enabled_truthy = env
7998 .var("AUTUMN_REPLICATION__ENABLED")
7999 .is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true"));
8000 if self.replication.is_none() && !has_dest_key && !enabled_truthy {
8001 return;
8002 }
8003 let replication = self
8004 .replication
8005 .get_or_insert_with(|| Box::new(ReplicationConfig::default()));
8006
8007 parse_env_bool(env, "AUTUMN_REPLICATION__ENABLED", &mut replication.enabled);
8008 parse_env(
8009 env,
8010 "AUTUMN_REPLICATION__RPO_SECS",
8011 &mut replication.rpo_secs,
8012 );
8013 parse_env_option(
8014 env,
8015 "AUTUMN_REPLICATION__SYNC_INTERVAL_SECS",
8016 &mut replication.sync_interval_secs,
8017 );
8018 parse_env(
8019 env,
8020 "AUTUMN_REPLICATION__SNAPSHOT_INTERVAL_SECS",
8021 &mut replication.snapshot_interval_secs,
8022 );
8023 parse_env(
8024 env,
8025 "AUTUMN_REPLICATION__MAX_WAL_BYTES",
8026 &mut replication.max_wal_bytes,
8027 );
8028 parse_env(
8029 env,
8030 "AUTUMN_REPLICATION__RETENTION_HOURS",
8031 &mut replication.retention_hours,
8032 );
8033 parse_env(
8034 env,
8035 "AUTUMN_REPLICATION__VERIFY_INTERVAL_SECS",
8036 &mut replication.verify_interval_secs,
8037 );
8038 parse_env_option_string(env, "AUTUMN_REPLICATION__PREFIX", &mut replication.prefix);
8039 parse_env_option_string(env, "AUTUMN_REPLICATION__PATH", &mut replication.path);
8040 parse_env_bool(
8041 env,
8042 "AUTUMN_REPLICATION__ALLOW_SHARED_BUCKET",
8043 &mut replication.allow_shared_bucket,
8044 );
8045
8046 if replication.s3.is_none() && !S3_KEYS.iter().any(|k| env.var(k).is_ok()) {
8047 return;
8048 }
8049 let s3 = replication
8050 .s3
8051 .get_or_insert_with(ReplicationS3Config::default);
8052 parse_env_option_string(env, "AUTUMN_REPLICATION__S3__BUCKET", &mut s3.bucket);
8053 parse_env_option_string(env, "AUTUMN_REPLICATION__S3__REGION", &mut s3.region);
8054 parse_env_option_string(env, "AUTUMN_REPLICATION__S3__ENDPOINT", &mut s3.endpoint);
8055 parse_env_option_string(
8056 env,
8057 "AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV",
8058 &mut s3.access_key_id_env,
8059 );
8060 parse_env_option_string(
8061 env,
8062 "AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV",
8063 &mut s3.secret_access_key_env,
8064 );
8065 parse_env_bool(
8066 env,
8067 "AUTUMN_REPLICATION__S3__FORCE_PATH_STYLE",
8068 &mut s3.force_path_style,
8069 );
8070 }
8071
8072 #[cfg(feature = "mail")]
8073 fn apply_mail_env_overrides_with_env(&mut self, env: &dyn Env) {
8074 if let Ok(val) = env.var("AUTUMN_MAIL__TRANSPORT") {
8075 match crate::mail::Transport::from_env_value(&val) {
8076 Some(transport) => self.mail.transport = transport,
8077 None => eprintln!(
8078 "Warning: AUTUMN_MAIL__TRANSPORT={val:?} is not valid \
8079 (expected log, file, smtp, or disabled), ignoring"
8080 ),
8081 }
8082 }
8083 parse_env_option_string(env, "AUTUMN_MAIL__FROM", &mut self.mail.from);
8084 parse_env_option_string(env, "AUTUMN_MAIL__REPLY_TO", &mut self.mail.reply_to);
8085 parse_env_bool(
8086 env,
8087 "AUTUMN_MAIL__ALLOW_LOG_IN_PRODUCTION",
8088 &mut self.mail.allow_log_in_production,
8089 );
8090 parse_env_bool(
8091 env,
8092 "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
8093 &mut self.mail.allow_in_process_deliver_later_in_production,
8094 );
8095 parse_env_bool(env, "AUTUMN_MAIL__PREVIEW", &mut self.mail.preview);
8096 parse_env_option_string(
8097 env,
8098 "AUTUMN_MAIL__UNSUBSCRIBE_BASE_URL",
8099 &mut self.mail.unsubscribe_base_url,
8100 );
8101 parse_env_option_string(
8102 env,
8103 "AUTUMN_MAIL__UNSUBSCRIBE_MAILTO",
8104 &mut self.mail.unsubscribe_mailto,
8105 );
8106 if let Ok(val) = env.var("AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS") {
8107 match val.parse::<i64>() {
8108 Ok(days) => self.mail.unsubscribe_token_ttl_days = days,
8109 Err(_) => eprintln!(
8110 "Warning: AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS={val:?} is not a valid integer, ignoring"
8111 ),
8112 }
8113 }
8114 parse_env_bool(
8115 env,
8116 "AUTUMN_MAIL__MOUNT_UNSUBSCRIBE_ENDPOINT",
8117 &mut self.mail.mount_unsubscribe_endpoint,
8118 );
8119 parse_env_bool(env, "AUTUMN_MAIL__INLINE_CSS", &mut self.mail.inline_css);
8120 if let Ok(val) = env.var("AUTUMN_MAIL__FILE_DIR") {
8121 self.mail.file_dir = PathBuf::from(val);
8122 }
8123 parse_env_option_string(env, "AUTUMN_MAIL__SMTP__HOST", &mut self.mail.smtp.host);
8124 if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__PORT") {
8125 match val.parse::<u16>() {
8126 Ok(port) => self.mail.smtp.port = Some(port),
8127 Err(_) => {
8128 eprintln!("Warning: AUTUMN_MAIL__SMTP__PORT={val:?} is not valid, ignoring");
8129 }
8130 }
8131 }
8132 parse_env_option_string(
8133 env,
8134 "AUTUMN_MAIL__SMTP__USERNAME",
8135 &mut self.mail.smtp.username,
8136 );
8137 parse_env_option_string(
8138 env,
8139 "AUTUMN_MAIL__SMTP__PASSWORD_ENV",
8140 &mut self.mail.smtp.password_env,
8141 );
8142 if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__TLS") {
8143 match crate::mail::TlsMode::from_env_value(&val) {
8144 Some(tls) => self.mail.smtp.tls = tls,
8145 None => eprintln!(
8146 "Warning: AUTUMN_MAIL__SMTP__TLS={val:?} is not valid \
8147 (expected disabled, starttls, or tls), ignoring"
8148 ),
8149 }
8150 }
8151 }
8152
8153 #[must_use]
8155 pub fn profile_name(&self) -> Option<&str> {
8156 self.profile.as_deref()
8157 }
8158}
8159
8160#[derive(Debug, Clone, Default, Deserialize)]
8174pub struct RequestTimeoutsConfig {
8175 #[serde(default)]
8202 pub request_timeout_ms: Option<u64>,
8203
8204 #[serde(default)]
8209 pub accept_deadline_header: bool,
8210}
8211
8212#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
8227pub struct HttpServerConfig {
8228 #[serde(default)]
8233 pub header_read_timeout_ms: Option<u64>,
8234
8235 #[serde(default)]
8238 pub keep_alive_timeout_ms: Option<u64>,
8239
8240 #[serde(default)]
8243 pub max_header_bytes: Option<usize>,
8244
8245 #[serde(default)]
8247 pub http2_max_concurrent_streams: Option<u32>,
8248
8249 #[serde(default)]
8253 pub max_connections: Option<usize>,
8254}
8255
8256impl HttpServerConfig {
8257 pub const MIN_HEADER_BYTES: usize = 8192;
8259
8260 pub const MAX_CONNECTIONS: usize = tokio::sync::Semaphore::MAX_PERMITS;
8263
8264 pub fn validate(&self) -> Result<(), ConfigError> {
8272 if let Some(bytes) = self.max_header_bytes
8273 && bytes < Self::MIN_HEADER_BYTES
8274 {
8275 return Err(ConfigError::Validation(format!(
8276 "server.http.max_header_bytes must be {} or more (got {bytes})",
8277 Self::MIN_HEADER_BYTES
8278 )));
8279 }
8280 if self.http2_max_concurrent_streams == Some(0) {
8281 return Err(ConfigError::Validation(
8282 "server.http.http2_max_concurrent_streams must be greater than zero".to_owned(),
8283 ));
8284 }
8285 if let Some(max) = self.max_connections
8286 && max > Self::MAX_CONNECTIONS
8287 {
8288 return Err(ConfigError::Validation(format!(
8289 "server.http.max_connections must be {} or less (got {max})",
8290 Self::MAX_CONNECTIONS
8291 )));
8292 }
8293 Ok(())
8294 }
8295}
8296
8297#[derive(Debug, Clone, Deserialize)]
8303pub struct UpgradeConfig {
8304 #[serde(default = "default_upgrade_enabled")]
8309 pub enabled: bool,
8310
8311 #[serde(default = "default_upgrade_ready_timeout")]
8318 pub ready_timeout_secs: u64,
8319}
8320
8321#[derive(Debug, Clone, Deserialize)]
8344pub struct ServerConfig {
8345 #[serde(default = "default_port")]
8347 pub port: u16,
8348
8349 #[serde(default = "default_host")]
8354 pub host: String,
8355
8356 #[serde(default)]
8361 pub strict_config: bool,
8362
8363 #[serde(default)]
8372 pub strict_config_enforce_all: bool,
8373
8374 #[serde(default = "default_shutdown_timeout")]
8381 pub shutdown_timeout_secs: u64,
8382
8383 #[serde(default = "default_prestop_grace")]
8392 pub prestop_grace_secs: u64,
8393
8394 #[serde(default)]
8398 pub upgrade: UpgradeConfig,
8399
8400 #[serde(default)]
8406 pub timeouts: RequestTimeoutsConfig,
8407
8408 #[serde(default)]
8410 pub http: HttpServerConfig,
8411
8412 #[serde(default)]
8422 pub unix_socket: Option<String>,
8423
8424 #[serde(default)]
8449 pub max_concurrent_requests: Option<usize>,
8450
8451 #[serde(default)]
8468 pub capacity_contract: Option<String>,
8469
8470 #[serde(default)]
8488 pub tls: Option<TlsConfig>,
8489
8490 #[serde(default)]
8495 pub admission: AdmissionConfig,
8496}
8497
8498#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
8500#[serde(rename_all = "lowercase")]
8501#[non_exhaustive]
8502pub enum AdmissionMode {
8503 #[default]
8506 Static,
8507 Adaptive,
8509}
8510
8511impl std::str::FromStr for AdmissionMode {
8512 type Err = String;
8513
8514 fn from_str(s: &str) -> Result<Self, Self::Err> {
8515 match s.trim().to_ascii_lowercase().as_str() {
8516 "static" => Ok(Self::Static),
8517 "adaptive" => Ok(Self::Adaptive),
8518 other => Err(format!("unknown admission mode {other:?}")),
8519 }
8520 }
8521}
8522
8523#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
8525#[serde(rename_all = "lowercase")]
8526#[non_exhaustive]
8527pub enum AdmissionAlgorithm {
8528 #[default]
8530 Gradient2,
8531 Vegas,
8533 Aimd,
8537}
8538
8539impl std::str::FromStr for AdmissionAlgorithm {
8540 type Err = String;
8541
8542 fn from_str(s: &str) -> Result<Self, Self::Err> {
8543 match s.trim().to_ascii_lowercase().as_str() {
8544 "gradient2" => Ok(Self::Gradient2),
8545 "vegas" => Ok(Self::Vegas),
8546 "aimd" => Ok(Self::Aimd),
8547 other => Err(format!("unknown admission algorithm {other:?}")),
8548 }
8549 }
8550}
8551
8552#[derive(Debug, Clone, PartialEq, Deserialize)]
8570#[non_exhaustive]
8571pub struct AdmissionConfig {
8572 #[serde(default)]
8574 pub mode: AdmissionMode,
8575 #[serde(default)]
8577 pub algorithm: AdmissionAlgorithm,
8578 #[serde(default = "default_admission_min_limit")]
8580 pub min_limit: usize,
8581 #[serde(default)]
8585 pub max_limit: Option<usize>,
8586 #[serde(default = "default_admission_initial_limit")]
8588 pub initial_limit: usize,
8589 #[serde(default = "default_admission_latency_threshold_ms")]
8592 pub latency_threshold_ms: u64,
8593 #[serde(default)]
8598 pub trust_criticality_header: bool,
8599 #[serde(default)]
8601 pub partitions: AdmissionPartitionsConfig,
8602}
8603
8604pub const DEFAULT_ADMISSION_MAX_LIMIT: usize = 1000;
8606
8607const fn default_admission_min_limit() -> usize {
8608 8
8609}
8610
8611const fn default_admission_initial_limit() -> usize {
8612 20
8613}
8614
8615const fn default_admission_latency_threshold_ms() -> u64 {
8616 1000
8617}
8618
8619impl Default for AdmissionConfig {
8620 fn default() -> Self {
8621 Self {
8622 mode: AdmissionMode::default(),
8623 algorithm: AdmissionAlgorithm::default(),
8624 min_limit: default_admission_min_limit(),
8625 max_limit: None,
8626 initial_limit: default_admission_initial_limit(),
8627 latency_threshold_ms: default_admission_latency_threshold_ms(),
8628 trust_criticality_header: false,
8629 partitions: AdmissionPartitionsConfig::default(),
8630 }
8631 }
8632}
8633
8634#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
8637#[non_exhaustive]
8638pub struct AdmissionPartitionsConfig {
8639 #[serde(default = "default_partition_default")]
8641 pub default: f64,
8642 #[serde(default = "default_partition_sheddable")]
8644 pub sheddable: f64,
8645}
8646
8647const fn default_partition_default() -> f64 {
8648 1.0
8649}
8650
8651const fn default_partition_sheddable() -> f64 {
8652 0.5
8653}
8654
8655impl Default for AdmissionPartitionsConfig {
8656 fn default() -> Self {
8657 Self {
8658 default: default_partition_default(),
8659 sheddable: default_partition_sheddable(),
8660 }
8661 }
8662}
8663
8664impl AdmissionConfig {
8665 pub fn partition_shares(
8671 &self,
8672 ) -> Result<crate::admission::PartitionShares, crate::admission::AdmissionConfigError> {
8673 crate::admission::PartitionShares::new(self.partitions.default, self.partitions.sheddable)
8674 }
8675
8676 pub fn limit_bounds(
8686 &self,
8687 ceiling: Option<usize>,
8688 ) -> Result<crate::admission::LimitBounds, crate::admission::AdmissionConfigError> {
8689 let (min, max) = match (self.max_limit, ceiling) {
8690 (Some(max), _) => (self.min_limit, max),
8691 (None, Some(ceiling)) if ceiling > 0 => (self.min_limit.min(ceiling), ceiling),
8692 (None, _) => (self.min_limit, DEFAULT_ADMISSION_MAX_LIMIT),
8693 };
8694 crate::admission::LimitBounds::new(min, max, self.initial_limit)
8695 }
8696
8697 pub fn validate(&self) -> Result<(), ConfigError> {
8703 self.partition_shares()
8704 .map_err(|e| ConfigError::Validation(e.to_string()))?;
8705 let max = self.max_limit.unwrap_or(usize::MAX);
8707 crate::admission::LimitBounds::new(self.min_limit, max, self.initial_limit)
8708 .map_err(|e| ConfigError::Validation(e.to_string()))?;
8709 Ok(())
8710 }
8711}
8712
8713#[derive(Debug, Clone, Deserialize)]
8732pub struct TlsConfig {
8733 #[serde(default)]
8741 pub cert_path: Option<PathBuf>,
8742
8743 #[serde(default)]
8746 pub key_path: Option<PathBuf>,
8747
8748 #[serde(default = "default_tls_reload_interval_secs")]
8752 pub reload_interval_secs: u64,
8753
8754 #[serde(default = "default_tls_handshake_timeout_secs")]
8760 pub handshake_timeout_secs: u64,
8761
8762 #[serde(default)]
8770 pub acme: Option<AcmeConfig>,
8771
8772 #[serde(default)]
8779 pub client_auth: Option<ClientAuthConfig>,
8780}
8781
8782impl TlsConfig {
8783 #[must_use]
8790 pub fn client_auth_active(&self) -> bool {
8791 self.client_auth
8792 .as_ref()
8793 .is_some_and(|c| c.mode.requests_certificate())
8794 }
8795 const fn empty_for_env() -> Self {
8800 Self {
8801 cert_path: None,
8802 key_path: None,
8803 reload_interval_secs: default_tls_reload_interval_secs(),
8804 handshake_timeout_secs: default_tls_handshake_timeout_secs(),
8805 acme: None,
8806 client_auth: None,
8807 }
8808 }
8809
8810 pub fn validate(&self) -> Result<(), String> {
8824 let has_cert = self.cert_path.is_some();
8825 let has_key = self.key_path.is_some();
8826 let static_configured = has_cert || has_key;
8827 let acme_configured = self.acme.is_some();
8828
8829 match (static_configured, acme_configured) {
8830 (true, true) => {
8831 return Err(
8832 "[server.tls] sets a static cert_path/key_path AND [server.tls.acme]; \
8833 choose exactly one — remove the static cert to use ACME, or remove \
8834 [server.tls.acme] to serve the static certificate"
8835 .to_owned(),
8836 );
8837 }
8838 (false, false) => {
8839 return Err(
8840 "[server.tls] must configure exactly one of: a static certificate \
8841 (cert_path AND key_path) or automatic provisioning ([server.tls.acme] \
8842 with domains + contact_email)"
8843 .to_owned(),
8844 );
8845 }
8846 (true, false) => {
8847 if !(has_cert && has_key) {
8848 return Err("[server.tls] cert_path and key_path must be set together; \
8849 set both, or configure [server.tls.acme] instead"
8850 .to_owned());
8851 }
8852 }
8853 (false, true) => {}
8854 }
8855
8856 if let Some(acme) = &self.acme {
8857 acme.validate()?;
8858 }
8859
8860 if let Some(client_auth) = &self.client_auth {
8861 client_auth.validate()?;
8862 }
8863
8864 Ok(())
8865 }
8866}
8867
8868#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
8873#[serde(rename_all = "lowercase")]
8874#[non_exhaustive]
8875pub enum ClientAuthMode {
8876 #[default]
8878 Off,
8879 Optional,
8883 Required,
8885}
8886
8887impl ClientAuthMode {
8888 #[must_use]
8890 pub const fn requests_certificate(self) -> bool {
8891 !matches!(self, Self::Off)
8892 }
8893
8894 #[must_use]
8896 pub const fn mandates_certificate(self) -> bool {
8897 matches!(self, Self::Required)
8898 }
8899
8900 #[must_use]
8902 pub const fn as_str(self) -> &'static str {
8903 match self {
8904 Self::Off => "off",
8905 Self::Optional => "optional",
8906 Self::Required => "required",
8907 }
8908 }
8909}
8910
8911#[derive(Debug, Clone, Default, Deserialize)]
8932pub struct ClientAuthConfig {
8933 #[serde(default)]
8935 pub mode: ClientAuthMode,
8936
8937 #[serde(default)]
8941 pub ca_bundle_path: Option<PathBuf>,
8942
8943 #[serde(default)]
8948 pub crl_path: Option<PathBuf>,
8949
8950 #[serde(default)]
8955 pub required_paths: Vec<String>,
8956
8957 #[serde(default = "default_tls_reload_interval_secs")]
8960 pub reload_interval_secs: u64,
8961}
8962
8963impl ClientAuthConfig {
8964 pub fn validate(&self) -> Result<(), String> {
8970 if self.mode.requests_certificate() {
8971 match &self.ca_bundle_path {
8972 None => {
8973 return Err(format!(
8974 "[server.tls.client_auth] mode = \"{}\" needs ca_bundle_path — the PEM \
8975 bundle of client CAs to verify against",
8976 self.mode.as_str()
8977 ));
8978 }
8979 Some(path) if path.to_str().is_none_or(|p| p.trim().is_empty()) => {
8980 return Err(
8981 "[server.tls.client_auth] ca_bundle_path is empty; set it to the PEM \
8982 bundle of client CAs, or remove the section"
8983 .to_owned(),
8984 );
8985 }
8986 Some(_) => {}
8987 }
8988 } else if !self.required_paths.is_empty() {
8989 return Err(
8990 "[server.tls.client_auth] lists required_paths but mode = \"off\", so no \
8991 certificate is ever requested and those routes would reject every request; \
8992 set mode = \"optional\" or \"required\""
8993 .to_owned(),
8994 );
8995 }
8996
8997 if self
8998 .crl_path
8999 .as_ref()
9000 .is_some_and(|p| p.to_str().is_none_or(|s| s.trim().is_empty()))
9001 {
9002 return Err(
9003 "[server.tls.client_auth] crl_path is empty; set it to a PEM revocation list, \
9004 or remove the key"
9005 .to_owned(),
9006 );
9007 }
9008
9009 for path in &self.required_paths {
9010 if !path.starts_with('/') {
9011 return Err(format!(
9012 "[server.tls.client_auth] required_paths entry `{path}` must be a rooted \
9013 path prefix starting with `/`"
9014 ));
9015 }
9016 if path.contains("//") {
9024 return Err(format!(
9025 "[server.tls.client_auth] required_paths entry `{path}` contains an empty \
9026 path segment; write it as a canonical prefix like `/internal/`"
9027 ));
9028 }
9029 if path
9030 .split('/')
9031 .any(|segment| segment == "." || segment == "..")
9032 {
9033 return Err(format!(
9034 "[server.tls.client_auth] required_paths entry `{path}` contains a dot \
9035 segment; write the resolved prefix instead"
9036 ));
9037 }
9038 }
9039
9040 Ok(())
9041 }
9042}
9043
9044#[derive(Debug, Clone, Deserialize)]
9067pub struct AcmeConfig {
9068 pub domains: Vec<String>,
9072
9073 pub contact_email: String,
9076
9077 #[serde(default)]
9081 pub directory: AcmeDirectory,
9082
9083 #[serde(default = "default_acme_cache_dir")]
9086 pub cache_dir: PathBuf,
9087
9088 #[serde(default = "default_acme_http_challenge_port")]
9092 pub http_challenge_port: u16,
9093
9094 #[serde(default = "default_acme_renew_before_days")]
9097 pub renew_before_days: u32,
9098
9099 #[serde(default)]
9127 pub ca_root_path: Option<PathBuf>,
9128
9129 #[serde(default)]
9137 pub dns: Option<AcmeDnsConfig>,
9138
9139 #[serde(default)]
9146 pub custom_domains: Option<CustomDomainsConfig>,
9147}
9148
9149#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
9157#[serde(deny_unknown_fields)]
9158pub struct CustomDomainsConfig {
9159 #[serde(default)]
9162 pub enabled: bool,
9163
9164 #[serde(default)]
9166 pub ingress_hostname: Option<String>,
9167
9168 #[serde(default)]
9175 pub ingress_ipv4: Vec<String>,
9176
9177 #[serde(default)]
9179 pub ingress_ipv6: Vec<String>,
9180
9181 #[serde(default = "default_custom_domains_dir")]
9183 pub store_dir: PathBuf,
9184
9185 #[serde(default = "default_custom_domains_max")]
9187 pub max_domains: usize,
9188
9189 #[serde(default = "default_custom_domains_cert_cache")]
9192 pub cert_cache_size: usize,
9193
9194 #[serde(default = "default_custom_domains_per_domain_per_day")]
9196 pub issuance_per_domain_per_day: u32,
9197
9198 #[serde(default = "default_custom_domains_global_per_hour")]
9200 pub issuance_global_per_hour: u32,
9201
9202 #[serde(default = "default_custom_domains_base_backoff")]
9205 pub failure_backoff_secs: u64,
9206
9207 #[serde(default = "default_custom_domains_max_backoff")]
9209 pub max_failure_backoff_secs: u64,
9210
9211 #[serde(default = "default_custom_domains_poll_secs")]
9213 pub poll_interval_secs: u64,
9214
9215 #[serde(default = "default_acme_dns_resolvers")]
9220 pub resolvers: Vec<String>,
9221}
9222
9223impl Default for CustomDomainsConfig {
9224 fn default() -> Self {
9225 Self {
9226 enabled: false,
9227 ingress_hostname: None,
9228 ingress_ipv4: Vec::new(),
9229 ingress_ipv6: Vec::new(),
9230 store_dir: default_custom_domains_dir(),
9231 max_domains: default_custom_domains_max(),
9232 cert_cache_size: default_custom_domains_cert_cache(),
9233 issuance_per_domain_per_day: default_custom_domains_per_domain_per_day(),
9234 issuance_global_per_hour: default_custom_domains_global_per_hour(),
9235 failure_backoff_secs: default_custom_domains_base_backoff(),
9236 max_failure_backoff_secs: default_custom_domains_max_backoff(),
9237 poll_interval_secs: default_custom_domains_poll_secs(),
9238 resolvers: default_acme_dns_resolvers(),
9239 }
9240 }
9241}
9242
9243impl CustomDomainsConfig {
9244 pub fn resolver_addrs(&self) -> Result<Vec<std::net::SocketAddr>, String> {
9251 self.resolvers
9252 .iter()
9253 .map(|entry| parse_resolver_addr("[server.tls.acme.custom_domains]", entry))
9254 .collect()
9255 }
9256
9257 fn validate_ingress_hostname(&self) -> Result<(), String> {
9262 if let Some(raw) = self
9263 .ingress_hostname
9264 .as_ref()
9265 .filter(|h| !h.trim().is_empty())
9266 {
9267 let canonical = raw.trim().trim_end_matches('.').to_ascii_lowercase();
9268 match crate::custom_domain::normalize_hostname(raw) {
9269 Ok(host) if host == canonical => {}
9270 Ok(host) => {
9271 return Err(format!(
9272 "[server.tls.acme.custom_domains] ingress_hostname `{raw}` must be a bare \
9273 DNS name (`{host}`): a scheme, port or path cannot be a CNAME target, so \
9274 tenants would be given a record that never resolves"
9275 ));
9276 }
9277 Err(e) => {
9278 return Err(format!(
9279 "[server.tls.acme.custom_domains] ingress_hostname `{raw}` is not a usable \
9280 DNS name: {e}. Tenants are given it as their CNAME target, so every \
9281 subdomain would stay at pending_dns"
9282 ));
9283 }
9284 }
9285 }
9286 Ok(())
9287 }
9288
9289 pub fn validate(&self) -> Result<(), String> {
9295 if !self.enabled {
9296 return Ok(());
9297 }
9298 if self
9299 .ingress_hostname
9300 .as_ref()
9301 .is_none_or(|h| h.trim().is_empty())
9302 && self.ingress_ipv4.is_empty()
9303 && self.ingress_ipv6.is_empty()
9304 {
9305 return Err(
9306 "[server.tls.acme.custom_domains] needs somewhere for tenants to point DNS: set \
9307 ingress_hostname (the CNAME target for tenant subdomains) and/or ingress_ipv4 / \
9308 ingress_ipv6 (the A/AAAA records apex domains need). Without one, no tenant can \
9309 be given usable DNS instructions and no domain can ever verify"
9310 .to_owned(),
9311 );
9312 }
9313 self.validate_ingress_hostname()?;
9314 for (key, values) in [
9315 ("ingress_ipv4", &self.ingress_ipv4),
9316 ("ingress_ipv6", &self.ingress_ipv6),
9317 ] {
9318 for value in values {
9319 let parsed = if key == "ingress_ipv4" {
9320 value.trim().parse::<std::net::Ipv4Addr>().is_ok()
9321 } else {
9322 value.trim().parse::<std::net::Ipv6Addr>().is_ok()
9323 };
9324 if !parsed {
9325 return Err(format!(
9326 "[server.tls.acme.custom_domains] {key} entry `{value}` is not a valid \
9327 address; a tenant would be given a DNS record that points nowhere"
9328 ));
9329 }
9330 }
9331 }
9332 if self.max_domains == 0 {
9333 return Err(
9334 "[server.tls.acme.custom_domains] max_domains must be at least 1; 0 rejects every \
9335 registration"
9336 .to_owned(),
9337 );
9338 }
9339 if self.cert_cache_size == 0 {
9340 return Err(
9341 "[server.tls.acme.custom_domains] cert_cache_size must be at least 1: a zero-sized \
9342 cache would re-read every certificate on every handshake"
9343 .to_owned(),
9344 );
9345 }
9346 if self.issuance_per_domain_per_day == 0 || self.issuance_global_per_hour == 0 {
9347 return Err(
9348 "[server.tls.acme.custom_domains] issuance_per_domain_per_day and \
9349 issuance_global_per_hour must both be at least 1; 0 refuses every order and no \
9350 domain can ever become active"
9351 .to_owned(),
9352 );
9353 }
9354 if self.failure_backoff_secs == 0 {
9355 return Err(
9356 "[server.tls.acme.custom_domains] failure_backoff_secs must be at least 1: a zero \
9357 backoff retries a permanently broken domain every tick and burns the CA's rate \
9358 limits"
9359 .to_owned(),
9360 );
9361 }
9362 if self.max_failure_backoff_secs < self.failure_backoff_secs {
9363 return Err(format!(
9364 "[server.tls.acme.custom_domains] max_failure_backoff_secs ({}) must be at least \
9365 failure_backoff_secs ({}): the cap is applied to the doubling delay, so a smaller \
9366 cap silently disables the backoff",
9367 self.max_failure_backoff_secs, self.failure_backoff_secs
9368 ));
9369 }
9370 if self.poll_interval_secs == 0 {
9371 return Err(
9372 "[server.tls.acme.custom_domains] poll_interval_secs must be at least 1; 0 spins \
9373 the orchestrator"
9374 .to_owned(),
9375 );
9376 }
9377 if self.resolvers.is_empty() {
9378 return Err(
9379 "[server.tls.acme.custom_domains] resolvers must list at least one DNS resolver: \
9380 without one no ownership TXT record can be read, so no domain can verify"
9381 .to_owned(),
9382 );
9383 }
9384 self.resolver_addrs()?;
9385 Ok(())
9386 }
9387
9388 #[must_use]
9394 pub fn ingress(&self) -> crate::custom_domain::ExpectedIngress {
9395 crate::custom_domain::ExpectedIngress {
9396 hostname: self
9397 .ingress_hostname
9398 .as_ref()
9399 .and_then(|h| crate::custom_domain::normalize_hostname(h).ok()),
9400 ipv4: self
9401 .ingress_ipv4
9402 .iter()
9403 .filter_map(|a| a.trim().parse().ok())
9404 .collect(),
9405 ipv6: self
9406 .ingress_ipv6
9407 .iter()
9408 .filter_map(|a| a.trim().parse().ok())
9409 .collect(),
9410 }
9411 }
9412}
9413
9414impl AcmeConfig {
9415 pub fn validate(&self) -> Result<(), String> {
9422 if self.domains.is_empty() {
9423 return Err(
9424 "[server.tls.acme] domains must list at least one domain to request a \
9425 certificate for"
9426 .to_owned(),
9427 );
9428 }
9429 if self.contact_email.trim().is_empty() {
9430 return Err(
9431 "[server.tls.acme] contact_email must be set (the ACME CA requires an account \
9432 contact for expiry notifications)"
9433 .to_owned(),
9434 );
9435 }
9436 if self.http_challenge_port == 0 {
9437 return Err(
9438 "[server.tls.acme] http_challenge_port must not be 0: port 0 binds an ephemeral \
9439 OS-assigned port that the ACME HTTP-01 validator (which always connects on port \
9440 80) can never reach, so every issuance fails. Use 80, or the port a front-end \
9441 forwards `:80` to"
9442 .to_owned(),
9443 );
9444 }
9445 if self.renew_before_days >= 90 {
9454 return Err(format!(
9455 "[server.tls.acme] renew_before_days ({}) must be less than 90: it is compared \
9456 against the issued certificate's remaining validity, and publicly-trusted CAs \
9457 (e.g. Let's Encrypt) issue certificates that live at most ~90 days. A value >= \
9458 the certificate lifetime keeps the cert perpetually inside its renew-before \
9459 window, so the renewal loop would order a fresh certificate every hour and burn \
9460 the CA's rate limits. Use a smaller value (default 30)",
9461 self.renew_before_days
9462 ));
9463 }
9464 if let Some(path) = &self.ca_root_path
9465 && path.to_str().is_none_or(|p| p.trim().is_empty())
9466 {
9467 return Err(
9468 "[server.tls.acme] ca_root_path is set but blank: either remove it (to use the \
9469 platform trust store, which is correct for Let's Encrypt staging and \
9470 production) or point it at the PEM root that signs your ACME directory's HTTPS \
9471 certificate"
9472 .to_owned(),
9473 );
9474 }
9475 for (index, domain) in self.domains.iter().enumerate() {
9476 let trimmed = domain.trim();
9477 if trimmed.is_empty() {
9478 return Err(format!(
9479 "[server.tls.acme] domains must not contain blank entries (entry at index \
9480 {index} is empty or whitespace-only)"
9481 ));
9482 }
9483 if trimmed.contains('*') {
9488 if self.dns.is_none() {
9489 return Err(format!(
9490 "[server.tls.acme] wildcard domain `{trimmed}` needs the DNS-01 \
9491 challenge: an ACME CA will not validate a wildcard identifier over \
9492 HTTP-01. Add a [server.tls.acme.dns] section naming your DNS provider \
9493 (and the credentials-store key holding its API token), or list explicit \
9494 hostnames instead"
9495 ));
9496 }
9497 let Some(base) = trimmed.strip_prefix("*.") else {
9498 return Err(format!(
9499 "[server.tls.acme] domain `{trimmed}` is not a usable wildcard: a \
9500 wildcard SAN must be written as `*.` followed by the base domain (e.g. \
9501 `*.myapp.com`) — a `*` anywhere else is not matched by any client"
9502 ));
9503 };
9504 if base.is_empty() || base.contains('*') {
9505 return Err(format!(
9506 "[server.tls.acme] domain `{trimmed}` is not a usable wildcard: exactly \
9507 one leading `*.` is allowed and the base domain after it must be \
9508 non-empty (e.g. `*.myapp.com`)"
9509 ));
9510 }
9511 }
9512 if domain != trimmed {
9521 return Err(format!(
9522 "[server.tls.acme] domain `{domain}` (entry at index {index}) has leading or \
9523 trailing whitespace: the entry is used verbatim as the certificate's SAN and \
9524 as the ACME order's DNS identifier, so the padded value would be requested \
9525 as-is. Write it as `{trimmed}`"
9526 ));
9527 }
9528 }
9529 if let Some(dns) = &self.dns {
9530 dns.validate()?;
9531 }
9532 if let Some(custom) = &self.custom_domains {
9533 custom.validate()?;
9534 }
9535 Ok(())
9536 }
9537
9538 #[must_use]
9546 pub fn covers_host(&self, host: &str) -> bool {
9547 self.domains.iter().any(|san| san_covers_host(san, host))
9548 }
9549}
9550
9551#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
9578#[serde(deny_unknown_fields)]
9579pub struct AcmeDnsConfig {
9580 pub provider: AcmeDnsProvider,
9582
9583 #[serde(default = "default_acme_dns_credential")]
9586 pub credential: String,
9587
9588 #[serde(default = "default_acme_dns_propagation_timeout_secs")]
9592 pub propagation_timeout_secs: u64,
9593
9594 #[serde(default = "default_acme_dns_poll_interval_secs")]
9596 pub poll_interval_secs: u64,
9597
9598 #[serde(default = "default_acme_dns_resolvers")]
9601 pub resolvers: Vec<String>,
9602
9603 #[serde(default)]
9609 pub command: Vec<String>,
9610}
9611
9612impl AcmeDnsConfig {
9613 pub fn resolver_addrs(&self) -> Result<Vec<std::net::SocketAddr>, String> {
9620 self.resolvers
9621 .iter()
9622 .map(|entry| parse_resolver_addr("[server.tls.acme.dns]", entry))
9623 .collect()
9624 }
9625
9626 pub fn validate(&self) -> Result<(), String> {
9632 if self.credential.trim().is_empty() {
9633 return Err(
9634 "[server.tls.acme.dns] credential must name the key in the encrypted \
9635 credentials store that holds the DNS provider's API credential (run \
9636 `autumn credentials edit`). It is a key NAME, never the token itself"
9637 .to_owned(),
9638 );
9639 }
9640 match self.provider {
9641 AcmeDnsProvider::Exec => {
9642 if self
9643 .command
9644 .first()
9645 .is_some_and(|program| !program.trim().is_empty() && program != program.trim())
9646 {
9647 return Err(format!(
9648 "[server.tls.acme.dns] command's program `{}` has leading or trailing \
9649 whitespace: it is passed to the OS verbatim, so the padded value would \
9650 fail to execute. Write it without the padding",
9651 self.command[0]
9652 ));
9653 }
9654 if self.command.first().is_none_or(|p| p.trim().is_empty()) {
9655 return Err(
9656 "[server.tls.acme.dns] provider = \"exec\" requires a non-empty `command` \
9657 argv array (e.g. command = [\"/usr/local/bin/dns-hook\"]); autumn appends \
9658 `present`/`cleanup`, the record FQDN and the TXT value as further \
9659 arguments"
9660 .to_owned(),
9661 );
9662 }
9663 }
9664 AcmeDnsProvider::Cloudflare | AcmeDnsProvider::Route53 => {
9665 if !self.command.is_empty() {
9666 return Err(format!(
9667 "[server.tls.acme.dns] command is set but provider is \"{}\": the command \
9668 would never run. Remove it, or set provider = \"exec\" to use the \
9669 external-hook escape hatch",
9670 self.provider.as_str()
9671 ));
9672 }
9673 }
9674 }
9675 if self.propagation_timeout_secs == 0 {
9676 return Err(
9677 "[server.tls.acme.dns] propagation_timeout_secs must not be 0: a zero budget \
9678 fails every order before a freshly-written TXT record could ever be visible. \
9679 Use the default (300) or a larger value for a slow-propagating zone"
9680 .to_owned(),
9681 );
9682 }
9683 if self.poll_interval_secs == 0 {
9684 return Err(
9685 "[server.tls.acme.dns] poll_interval_secs must not be 0: it would busy-loop the \
9686 configured resolvers. Use the default (5)"
9687 .to_owned(),
9688 );
9689 }
9690 if self.propagation_timeout_secs > MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS {
9691 return Err(format!(
9692 "[server.tls.acme.dns] propagation_timeout_secs ({}) must be at most {}: the wait \
9693 is computed as an instant `now + timeout`, and an unbounded value overflows that \
9694 and panics the renewal task, leaving the self-signed placeholder served \
9695 indefinitely. An hour is far past any real provider's propagation time",
9696 self.propagation_timeout_secs, MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS
9697 ));
9698 }
9699 if self.poll_interval_secs > self.propagation_timeout_secs {
9700 return Err(format!(
9701 "[server.tls.acme.dns] poll_interval_secs ({}) is greater than \
9702 propagation_timeout_secs ({}): the record would be probed once and the wait \
9703 would time out without ever re-checking. Lower poll_interval_secs",
9704 self.poll_interval_secs, self.propagation_timeout_secs
9705 ));
9706 }
9707 if self.resolvers.is_empty() {
9708 return Err(
9709 "[server.tls.acme.dns] resolvers must list at least one DNS resolver to confirm \
9710 TXT propagation against (default: 1.1.1.1:53 and 8.8.8.8:53)"
9711 .to_owned(),
9712 );
9713 }
9714 self.resolver_addrs()?;
9715 Ok(())
9716 }
9717}
9718
9719fn parse_resolver_addr(section: &str, entry: &str) -> Result<std::net::SocketAddr, String> {
9722 let trimmed = entry.trim();
9723 if let Ok(addr) = trimmed.parse::<std::net::SocketAddr>() {
9724 return Ok(addr);
9725 }
9726 if let Ok(ip) = trimmed.parse::<std::net::IpAddr>() {
9727 return Ok(std::net::SocketAddr::new(ip, 53));
9728 }
9729 Err(format!(
9730 "{section} resolvers entry `{entry}` is not a resolver address: write it as an IP \
9731 (`1.1.1.1`, port 53 implied) or `IP:port` (`1.1.1.1:53`). Hostnames are not accepted — \
9732 resolving the resolver would defeat the purpose of the DNS check"
9733 ))
9734}
9735
9736#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
9742#[serde(rename_all = "snake_case")]
9743#[non_exhaustive]
9744pub enum AcmeDnsProvider {
9745 Cloudflare,
9747 Route53,
9749 Exec,
9752}
9753
9754impl AcmeDnsProvider {
9755 #[must_use]
9757 pub const fn as_str(self) -> &'static str {
9758 match self {
9759 Self::Cloudflare => "cloudflare",
9760 Self::Route53 => "route53",
9761 Self::Exec => "exec",
9762 }
9763 }
9764}
9765
9766impl std::fmt::Display for AcmeDnsProvider {
9767 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
9768 f.write_str(self.as_str())
9769 }
9770}
9771
9772#[must_use]
9781pub fn san_covers_host(san: &str, host: &str) -> bool {
9782 let san = san.trim().trim_end_matches('.').to_ascii_lowercase();
9783 let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
9784 if san.is_empty() || host.is_empty() {
9785 return false;
9786 }
9787 let Some(suffix) = san.strip_prefix("*.") else {
9788 return san == host;
9789 };
9790 if suffix.is_empty() {
9791 return false;
9792 }
9793 let Some(label) = host
9796 .strip_suffix(&suffix)
9797 .and_then(|prefix| prefix.strip_suffix('.'))
9798 else {
9799 return false;
9800 };
9801 !label.is_empty() && !label.contains('.')
9802}
9803
9804#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
9806#[serde(rename_all = "snake_case")]
9807pub enum AcmeDirectory {
9808 #[default]
9811 Staging,
9812 Production,
9814 Custom {
9816 url: String,
9818 },
9819}
9820
9821#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
9823#[serde(rename_all = "snake_case")]
9824#[non_exhaustive]
9825pub enum ReplicaFallback {
9826 #[default]
9828 FailReadiness,
9829 Primary,
9831}
9832
9833impl std::str::FromStr for ReplicaFallback {
9834 type Err = ();
9835
9836 fn from_str(value: &str) -> Result<Self, Self::Err> {
9837 match value.trim().to_ascii_lowercase().as_str() {
9838 "fail_readiness" | "fail-readiness" | "fail" => Ok(Self::FailReadiness),
9839 "primary" | "fallback_to_primary" | "fallback-to-primary" => Ok(Self::Primary),
9840 _ => Err(()),
9841 }
9842 }
9843}
9844
9845#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
9857#[serde(rename_all = "snake_case")]
9858#[non_exhaustive]
9859pub enum ReadYourWrites {
9860 #[default]
9863 Off,
9864 Request,
9869 Session,
9875}
9876
9877impl std::str::FromStr for ReadYourWrites {
9878 type Err = ();
9879
9880 fn from_str(value: &str) -> Result<Self, Self::Err> {
9881 match value.trim().to_ascii_lowercase().as_str() {
9882 "off" => Ok(Self::Off),
9883 "request" => Ok(Self::Request),
9884 "session" => Ok(Self::Session),
9885 _ => Err(()),
9886 }
9887 }
9888}
9889
9890#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
9896#[serde(untagged)]
9897pub enum SlotSpec {
9898 Index(u16),
9900 Range(String),
9902}
9903
9904impl SlotSpec {
9905 pub fn expand(&self) -> Result<Vec<u16>, String> {
9912 match self {
9913 Self::Index(slot) => Ok(vec![*slot]),
9914 Self::Range(spec) => {
9915 let spec = spec.trim();
9916 let parse = |s: &str| {
9917 s.trim()
9918 .parse::<u16>()
9919 .map_err(|_| format!("invalid slot {s:?} in {spec:?}"))
9920 };
9921 match spec.split_once('-') {
9922 None => Ok(vec![parse(spec)?]),
9923 Some((start, end)) => {
9924 let (start, end) = (parse(start)?, parse(end)?);
9925 if start > end {
9926 return Err(format!("inverted slot range {spec:?}"));
9927 }
9928 Ok((start..=end).collect())
9929 }
9930 }
9931 }
9932 }
9933 }
9934}
9935
9936#[derive(Debug, Clone, Default, Deserialize)]
9980pub struct ShardConfig {
9981 pub name: String,
9987
9988 pub primary_url: String,
9990
9991 #[serde(default)]
9999 pub slots: Option<Vec<SlotSpec>>,
10000
10001 #[serde(default)]
10003 pub replica_url: Option<String>,
10004
10005 #[serde(default)]
10008 pub primary_pool_size: Option<usize>,
10009
10010 #[serde(default)]
10013 pub replica_pool_size: Option<usize>,
10014
10015 #[serde(default)]
10018 pub replica_fallback: Option<ReplicaFallback>,
10019}
10020
10021impl ShardConfig {
10022 #[must_use]
10024 pub fn effective_primary_pool_size(&self, defaults: &DatabaseConfig) -> usize {
10025 self.primary_pool_size
10026 .unwrap_or_else(|| defaults.effective_primary_pool_size())
10027 }
10028
10029 #[must_use]
10031 pub fn effective_replica_pool_size(&self, defaults: &DatabaseConfig) -> usize {
10032 self.replica_pool_size
10033 .unwrap_or_else(|| defaults.effective_replica_pool_size())
10034 }
10035
10036 #[must_use]
10038 pub fn effective_replica_fallback(&self, defaults: &DatabaseConfig) -> ReplicaFallback {
10039 self.replica_fallback.unwrap_or(defaults.replica_fallback)
10040 }
10041}
10042
10043#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10068pub enum DatabaseBackend {
10069 Postgres,
10071 Sqlite,
10074}
10075
10076impl DatabaseBackend {
10077 #[must_use]
10081 pub fn detect(target: &str) -> Option<Self> {
10082 if is_sqlite_target(target) {
10085 Some(Self::Sqlite)
10086 } else if is_pg_connection_string(target) {
10087 Some(Self::Postgres)
10088 } else {
10089 None
10090 }
10091 }
10092
10093 const fn as_str(self) -> &'static str {
10095 match self {
10096 Self::Postgres => "postgres",
10097 Self::Sqlite => "sqlite",
10098 }
10099 }
10100}
10101
10102impl std::fmt::Display for DatabaseBackend {
10103 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
10104 f.write_str(self.as_str())
10105 }
10106}
10107
10108fn is_sqlite_target(s: &str) -> bool {
10112 s.starts_with("sqlite:") || s.starts_with("file:")
10114}
10115
10116#[derive(Debug, Clone, Deserialize)]
10149pub struct DatabaseConfig {
10150 #[serde(default)]
10159 pub url: Option<String>,
10160
10161 #[serde(default)]
10166 pub primary_url: Option<String>,
10167
10168 #[serde(default)]
10173 pub replica_url: Option<String>,
10174
10175 #[serde(default = "default_pool_size")]
10180 pub pool_size: usize,
10181
10182 #[serde(default)]
10184 pub primary_pool_size: Option<usize>,
10185
10186 #[serde(default)]
10188 pub replica_pool_size: Option<usize>,
10189
10190 #[serde(default)]
10193 pub replica_fallback: ReplicaFallback,
10194
10195 #[serde(default)]
10203 pub read_your_writes: ReadYourWrites,
10204
10205 #[serde(default = "default_pin_after_write_secs")]
10213 pub pin_after_write_secs: u64,
10214
10215 #[serde(default)]
10223 pub replica_max_lag_ms: Option<u64>,
10224
10225 #[serde(default = "default_warn_on_pooler")]
10230 pub warn_on_pooler: bool,
10231
10232 #[serde(default = "default_connect_timeout")]
10236 pub connect_timeout_secs: u64,
10237
10238 #[serde(default)]
10247 pub startup_wait_secs: u64,
10248
10249 #[serde(default)]
10264 pub auto_migrate: Option<bool>,
10265
10266 #[serde(default)]
10275 pub auto_migrate_in_production: bool,
10276
10277 #[serde(deserialize_with = "deserialize_option_duration", default)]
10285 pub statement_timeout: Option<std::time::Duration>,
10286
10287 #[serde(deserialize_with = "deserialize_option_duration", default)]
10295 pub idle_in_transaction_timeout: Option<std::time::Duration>,
10296
10297 #[serde(
10307 deserialize_with = "deserialize_duration",
10308 default = "default_migration_lock_timeout"
10309 )]
10310 pub migration_lock_timeout: std::time::Duration,
10311
10312 #[serde(default = "default_migration_lock_retries")]
10318 pub migration_lock_retries: u32,
10319
10320 #[serde(
10322 deserialize_with = "deserialize_duration",
10323 default = "default_slow_query_threshold"
10324 )]
10325 pub slow_query_threshold: std::time::Duration,
10326
10327 #[serde(default)]
10335 pub shards: Vec<ShardConfig>,
10336
10337 #[serde(default)]
10347 pub directory_shard_router: bool,
10348
10349 #[serde(default = "default_max_connections_warn_threshold")]
10358 pub max_connections_warn_threshold: usize,
10359
10360 #[serde(default)]
10370 pub fleet: Option<DatabaseFleetConfig>,
10371}
10372
10373#[derive(Debug, Clone, Deserialize)]
10389pub struct DatabaseFleetConfig {
10390 pub mode: crate::fleet_layout::FleetMode,
10392
10393 pub root: String,
10397
10398 #[serde(default)]
10401 pub path: Option<String>,
10402
10403 #[serde(default = "default_fleet_max_open")]
10408 pub max_open: usize,
10409
10410 #[serde(default = "default_fleet_pool_size")]
10413 pub pool_size: usize,
10414
10415 #[serde(default)]
10421 pub create_on_demand: Option<bool>,
10422
10423 #[serde(default = "default_fleet_idle_close_secs")]
10426 pub idle_close_secs: u64,
10427
10428 #[serde(default)]
10436 pub restore_missing: bool,
10437
10438 #[serde(default)]
10441 pub ownership: Option<FleetOwnershipConfig>,
10442}
10443
10444#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
10454pub struct FleetOwnershipConfig {
10455 pub owner: String,
10459
10460 #[serde(default)]
10463 pub slots: Vec<SlotSpec>,
10464
10465 #[serde(default = "default_fleet_lease_secs")]
10469 pub lease_secs: u64,
10470}
10471
10472impl FleetOwnershipConfig {
10473 pub fn ranges(&self) -> Result<Vec<(u16, u16)>, String> {
10479 if self.slots.is_empty() {
10480 return Ok(vec![(0, SLOT_COUNT - 1)]);
10481 }
10482 let mut ranges = Vec::with_capacity(self.slots.len());
10483 for spec in &self.slots {
10484 let range = match spec {
10485 SlotSpec::Index(slot) => (*slot, *slot),
10486 SlotSpec::Range(text) => {
10487 let slots = spec.expand()?;
10488 match (slots.first(), slots.last()) {
10489 (Some(start), Some(end)) => (*start, *end),
10490 _ => return Err(format!("empty slot range {text:?}")),
10491 }
10492 }
10493 };
10494 if range.1 >= SLOT_COUNT {
10495 return Err(format!(
10496 "slot {} is out of range (0-{})",
10497 range.1,
10498 SLOT_COUNT - 1
10499 ));
10500 }
10501 ranges.push(range);
10502 }
10503 ranges.sort_unstable();
10504 if let Some(pair) = ranges.windows(2).find(|pair| pair[0].1 >= pair[1].0) {
10505 return Err(format!(
10506 "slot ranges {}-{} and {}-{} overlap",
10507 pair[0].0, pair[0].1, pair[1].0, pair[1].1
10508 ));
10509 }
10510 Ok(ranges)
10511 }
10512
10513 pub fn validate(&self) -> Result<(), ConfigError> {
10519 let owner = &self.owner;
10520 if owner.is_empty()
10521 || owner.len() > 128
10522 || owner.chars().any(char::is_control)
10523 || owner.trim() != owner
10524 {
10525 return Err(ConfigError::Validation(
10526 "database.fleet.ownership.owner must be 1 to 128 bytes, with no control \
10527 characters and no white space at the start or end"
10528 .to_owned(),
10529 ));
10530 }
10531 self.ranges()
10532 .map_err(|e| ConfigError::Validation(format!("database.fleet.ownership.slots: {e}")))?;
10533 if !(3..=3600).contains(&self.lease_secs) {
10534 return Err(ConfigError::Validation(
10535 "database.fleet.ownership.lease_secs must be 3 to 3600".to_owned(),
10536 ));
10537 }
10538 Ok(())
10539 }
10540}
10541
10542const fn default_fleet_lease_secs() -> u64 {
10543 30
10544}
10545
10546impl DatabaseFleetConfig {
10547 #[must_use]
10549 pub fn path_template(&self) -> &str {
10550 self.path
10551 .as_deref()
10552 .unwrap_or_else(|| self.mode.default_path())
10553 }
10554
10555 #[must_use]
10557 pub fn effective_create_on_demand(&self) -> bool {
10558 self.create_on_demand
10559 .unwrap_or_else(|| self.mode.default_create_on_demand())
10560 }
10561
10562 pub fn validate(&self) -> Result<(), ConfigError> {
10569 if self.root.trim().is_empty() {
10570 return Err(ConfigError::Validation(
10571 "database.fleet.root must name a directory".to_owned(),
10572 ));
10573 }
10574 if self.root.starts_with("sqlite:") || self.root.starts_with("file:") {
10575 return Err(ConfigError::Validation(format!(
10576 "database.fleet.root {:?} is a directory, not a database URL",
10577 self.root
10578 )));
10579 }
10580 crate::fleet_layout::FleetPathTemplate::parse(self.path_template(), self.mode)
10581 .map_err(|e| ConfigError::Validation(e.to_string()))?;
10582 if self.max_open == 0 {
10583 return Err(ConfigError::Validation(
10584 "database.fleet.max_open must be at least 1".to_owned(),
10585 ));
10586 }
10587 if self.pool_size == 0 {
10588 return Err(ConfigError::Validation(
10589 "database.fleet.pool_size must be at least 1".to_owned(),
10590 ));
10591 }
10592 if let Some(ownership) = &self.ownership {
10593 ownership.validate()?;
10594 }
10595 Ok(())
10596 }
10597}
10598
10599const fn default_fleet_max_open() -> usize {
10600 256
10601}
10602
10603const fn default_fleet_pool_size() -> usize {
10604 2
10605}
10606
10607const fn default_fleet_idle_close_secs() -> u64 {
10608 300
10609}
10610
10611pub(crate) const fn should_warn_total_connections(total: usize, threshold: usize) -> bool {
10616 threshold != 0 && total >= threshold
10617}
10618
10619fn format_slot_ranges(slots: &[usize]) -> String {
10622 fn render(start: usize, end: usize) -> String {
10623 if start == end {
10624 start.to_string()
10625 } else {
10626 format!("{start}-{end}")
10627 }
10628 }
10629 let mut ranges: Vec<String> = Vec::new();
10630 let mut iter = slots.iter().copied();
10631 let Some(mut start) = iter.next() else {
10632 return String::new();
10633 };
10634 let mut end = start;
10635 for slot in iter {
10636 if slot != end + 1 {
10637 ranges.push(render(start, end));
10638 start = slot;
10639 }
10640 end = slot;
10641 }
10642 ranges.push(render(start, end));
10643 ranges.join(", ")
10644}
10645
10646pub const SLOT_COUNT: u16 = 16384;
10658
10659#[derive(Debug, Clone, PartialEq, Eq)]
10667pub struct ShardSlotAssignment {
10668 pub name: String,
10669 pub ranges: String,
10670}
10671
10672pub fn check_stored_slot_map(
10689 auto_split: bool,
10690 computed: &[ShardSlotAssignment],
10691 stored: Option<&[ShardSlotAssignment]>,
10692) -> Result<(), String> {
10693 fn to_map(assignments: &[ShardSlotAssignment]) -> std::collections::BTreeMap<&str, &str> {
10694 assignments
10695 .iter()
10696 .map(|a| (a.name.as_str(), a.ranges.as_str()))
10697 .collect()
10698 }
10699 if !auto_split {
10700 return Ok(());
10701 }
10702 let Some(stored) = stored else {
10703 return Ok(());
10704 };
10705 if to_map(computed) == to_map(stored) {
10706 return Ok(());
10707 }
10708 let computed_names: Vec<&str> = computed.iter().map(|a| a.name.as_str()).collect();
10709 let stored_names: Vec<&str> = stored.iter().map(|a| a.name.as_str()).collect();
10710 Err(format!(
10711 "shard slot map mismatch — auto-split with {} shards ({}) produces a different \
10712 map than the stored map ({} shards: {}). Set explicit [[database.shards]] slot \
10713 ranges matching the stored map, then move data between shards deliberately \
10714 before changing the topology.",
10715 computed.len(),
10716 computed_names.join(", "),
10717 stored.len(),
10718 stored_names.join(", "),
10719 ))
10720}
10721
10722pub fn database_backend_consistency(
10749 url: Option<&str>,
10750 primary_url: Option<&str>,
10751 replica_url: Option<&str>,
10752 has_shards: bool,
10753) -> Result<(), String> {
10754 let Some(primary_backend) = primary_url.or(url).and_then(DatabaseBackend::detect) else {
10755 return Ok(());
10756 };
10757
10758 if primary_backend == DatabaseBackend::Sqlite {
10759 if replica_url.is_some() {
10762 return Err(
10763 "database.replica_url is set but the primary target is SQLite; \
10764 read replicas require the postgres backend"
10765 .to_owned(),
10766 );
10767 }
10768 if has_shards {
10770 return Err(
10771 "database.shards are configured but the primary target is SQLite; \
10772 database shards require the postgres backend"
10773 .to_owned(),
10774 );
10775 }
10776 }
10777
10778 for (field, url) in [("database.url", url), ("database.replica_url", replica_url)] {
10783 if let Some(url) = url
10784 && DatabaseBackend::detect(url) != Some(primary_backend)
10785 {
10786 return Err(format!(
10787 "{field} does not match the primary database backend \
10788 ({primary_backend}); every configured database role must use \
10789 the same backend"
10790 ));
10791 }
10792 }
10793
10794 Ok(())
10795}
10796
10797impl DatabaseConfig {
10798 #[must_use]
10800 pub fn effective_primary_url(&self) -> Option<&str> {
10801 self.primary_url.as_deref().or(self.url.as_deref())
10802 }
10803
10804 #[must_use]
10818 pub fn effective_primary_postgres_url(&self) -> Option<&str> {
10819 self.effective_primary_url()
10820 .filter(|url| DatabaseBackend::detect(url) == Some(DatabaseBackend::Postgres))
10821 }
10822
10823 #[must_use]
10825 pub fn effective_primary_pool_size(&self) -> usize {
10826 self.primary_pool_size.unwrap_or(self.pool_size)
10827 }
10828
10829 #[must_use]
10831 pub fn effective_replica_pool_size(&self) -> usize {
10832 self.replica_pool_size.unwrap_or(self.pool_size)
10833 }
10834
10835 #[must_use]
10837 pub const fn has_shards(&self) -> bool {
10838 !self.shards.is_empty()
10839 }
10840
10841 pub fn resolved_slot_map(&self) -> Result<Vec<usize>, ConfigError> {
10859 let slot_count = usize::from(SLOT_COUNT);
10860
10861 if self.shards.is_empty() {
10862 return Ok(Vec::new());
10863 }
10864
10865 let declared = self.shards.iter().filter(|s| s.slots.is_some()).count();
10866 if declared != 0 && declared != self.shards.len() {
10867 return Err(ConfigError::Validation(
10868 "database.shards: either every shard must declare `slots` or none may \
10869 (mixing explicit and auto-assigned slots is ambiguous)"
10870 .to_owned(),
10871 ));
10872 }
10873
10874 if declared == 0 {
10875 if self.shards.len() > slot_count {
10877 return Err(ConfigError::Validation(format!(
10878 "database.shards: at most {slot_count} shards are supported \
10879 (one per logical slot), got {}",
10880 self.shards.len()
10881 )));
10882 }
10883 let n = self.shards.len();
10884 return Ok((0..slot_count).map(|slot| slot * n / slot_count).collect());
10885 }
10886
10887 let mut map: Vec<Option<usize>> = vec![None; slot_count];
10888 for (idx, shard) in self.shards.iter().enumerate() {
10889 let specs = shard.slots.as_deref().unwrap_or_default();
10890 for spec in specs {
10891 let slots = spec.expand().map_err(|e| {
10892 ConfigError::Validation(format!("database.shards[{idx}].slots: {e}"))
10893 })?;
10894 for slot in slots {
10895 if usize::from(slot) >= slot_count {
10896 return Err(ConfigError::Validation(format!(
10897 "database.shards[{idx}].slots: slot {slot} is out of range \
10898 (slots are 0..{slot_count})"
10899 )));
10900 }
10901 if let Some(owner) = map[usize::from(slot)] {
10902 return Err(ConfigError::Validation(format!(
10903 "database.shards[{idx}].slots: slot {slot} is already owned \
10904 by shard {:?}",
10905 self.shards[owner].name
10906 )));
10907 }
10908 map[usize::from(slot)] = Some(idx);
10909 }
10910 }
10911 }
10912 let unassigned: Vec<usize> = map
10913 .iter()
10914 .enumerate()
10915 .filter_map(|(slot, owner)| owner.is_none().then_some(slot))
10916 .collect();
10917 if !unassigned.is_empty() {
10918 return Err(ConfigError::Validation(format!(
10919 "database.shards: slot map must cover every slot in 0..{slot_count}; \
10920 unassigned slots: {}",
10921 format_slot_ranges(&unassigned)
10922 )));
10923 }
10924 Ok(map.into_iter().flatten().collect())
10926 }
10927
10928 #[must_use]
10935 pub fn shards_auto_split(&self) -> bool {
10936 self.has_shards() && self.shards.iter().all(|s| s.slots.is_none())
10937 }
10938
10939 pub fn resolved_shard_assignments(&self) -> Result<Vec<ShardSlotAssignment>, ConfigError> {
10951 let slot_map = self.resolved_slot_map()?;
10952 let n = self.shards.len();
10953 let mut per_shard: Vec<Vec<usize>> = vec![Vec::new(); n];
10954 for (slot, &owner) in slot_map.iter().enumerate() {
10955 per_shard[owner].push(slot);
10956 }
10957 Ok(self
10958 .shards
10959 .iter()
10960 .enumerate()
10961 .map(|(idx, shard)| ShardSlotAssignment {
10962 name: shard.name.clone(),
10963 ranges: format_slot_ranges(&per_shard[idx]),
10964 })
10965 .collect())
10966 }
10967
10968 fn validate_backend_consistency(&self) -> Result<(), ConfigError> {
10976 database_backend_consistency(
10980 self.url.as_deref(),
10981 self.primary_url.as_deref(),
10982 self.replica_url.as_deref(),
10983 !self.shards.is_empty(),
10984 )
10985 .map_err(ConfigError::Validation)
10986 }
10987
10988 pub fn validate(&self) -> Result<(), ConfigError> {
10995 for (field, url) in [
10996 ("database.url", self.url.as_deref()),
10997 ("database.primary_url", self.primary_url.as_deref()),
10998 ("database.replica_url", self.replica_url.as_deref()),
10999 ] {
11000 if let Some(url) = url
11008 && DatabaseBackend::detect(url).is_none()
11009 {
11010 let label = if field == "database.url" {
11011 "database URL"
11012 } else {
11013 field
11014 };
11015 let target = crate::db_url::redact_target(url);
11021 return Err(ConfigError::Validation(format!(
11022 "Invalid {label}: must start with postgres:// or postgresql://, or be a \
11023 keyword/value connection string \
11024 (e.g. \"host=db user=app dbname=app sslmode=require\"), got {target:?}"
11025 )));
11026 }
11027 }
11028
11029 if self.replica_url.is_some() && self.effective_primary_url().is_none() {
11030 return Err(ConfigError::Validation(
11031 "database.replica_url requires database.primary_url or database.url".to_owned(),
11032 ));
11033 }
11034
11035 self.validate_backend_consistency()?;
11036
11037 let mut seen_names = std::collections::HashSet::new();
11038 for (idx, shard) in self.shards.iter().enumerate() {
11039 if shard.name.is_empty() {
11040 return Err(ConfigError::Validation(format!(
11041 "database.shards[{idx}].name must not be empty"
11042 )));
11043 }
11044 if !shard
11045 .name
11046 .chars()
11047 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
11048 {
11049 return Err(ConfigError::Validation(format!(
11050 "database.shards[{idx}].name {:?} is invalid: shard names are used in \
11051 metric tags and health component names and must match [a-z0-9_-]",
11052 shard.name
11053 )));
11054 }
11055 if !seen_names.insert(shard.name.as_str()) {
11056 return Err(ConfigError::Validation(format!(
11057 "database.shards[{idx}].name {:?} is declared more than once; \
11058 shard names must be unique",
11059 shard.name
11060 )));
11061 }
11062 for (field, url) in [
11063 ("primary_url", Some(shard.primary_url.as_str())),
11064 ("replica_url", shard.replica_url.as_deref()),
11065 ] {
11066 if let Some(url) = url
11067 && !is_pg_connection_string(url)
11068 {
11069 let target = crate::db_url::redact_target(url);
11070 return Err(ConfigError::Validation(format!(
11071 "Invalid database.shards[{idx}].{field}: must start with \
11072 postgres:// or postgresql://, or be a keyword/value \
11073 connection string \
11074 (e.g. \"host=db user=app dbname=app sslmode=require\"), got {target:?}"
11075 )));
11076 }
11077 }
11078 }
11079 self.resolved_slot_map()?;
11080 self.validate_fleet()?;
11081 Ok(())
11082 }
11083
11084 fn validate_fleet(&self) -> Result<(), ConfigError> {
11086 let Some(fleet) = &self.fleet else {
11087 return Ok(());
11088 };
11089 fleet.validate()?;
11090 if self.has_shards() {
11091 return Err(ConfigError::Validation(
11092 "database.fleet and [[database.shards]] are both configured; a fleet routes \
11093 tenant data to its own SQLite files, so declare one or the other"
11094 .to_owned(),
11095 ));
11096 }
11097 if self.directory_shard_router {
11098 return Err(ConfigError::Validation(
11099 "database.directory_shard_router is set with database.fleet; the directory \
11100 router is a Postgres control table, and a fleet routes by its mode"
11101 .to_owned(),
11102 ));
11103 }
11104 let control = self.effective_primary_url();
11105 if control.and_then(DatabaseBackend::detect) != Some(DatabaseBackend::Sqlite)
11106 || control.is_some_and(is_in_memory_sqlite_target)
11107 {
11108 return Err(ConfigError::Validation(
11109 "database.fleet needs a file-backed sqlite: control database in database.url; \
11110 framework state (sessions, jobs, flags) lives there while tenant data lives \
11111 in the fleet"
11112 .to_owned(),
11113 ));
11114 }
11115 if let Some(control_file) = control.and_then(sqlite_url_file) {
11116 let root = lexical_absolute(Path::new(&fleet.root));
11117 if control_file.starts_with(&root) {
11118 return Err(ConfigError::Validation(format!(
11119 "the control database {} is inside database.fleet.root {}; a tenant or \
11120 slot path could resolve to it and migrate it as a tenant. Put the \
11121 control database outside the fleet root",
11122 control_file.display(),
11123 root.display()
11124 )));
11125 }
11126 }
11127 Ok(())
11128 }
11129}
11130
11131pub(crate) fn sqlite_url_file(url: &str) -> Option<PathBuf> {
11135 if is_in_memory_sqlite_target(url) {
11136 return None;
11137 }
11138 let rest = url
11139 .strip_prefix("sqlite://")
11140 .or_else(|| url.strip_prefix("sqlite:"))
11141 .unwrap_or(url);
11142 let path = rest.strip_prefix("file:").map_or_else(
11149 || rest.to_owned(),
11150 |uri| {
11151 let uri = uri.strip_prefix("//").map_or(uri, |authority_and_path| {
11152 let (authority, path) = authority_and_path.split_at(
11153 authority_and_path
11154 .find('/')
11155 .unwrap_or(authority_and_path.len()),
11156 );
11157 if authority.is_empty() || authority.eq_ignore_ascii_case("localhost") {
11158 path
11159 } else {
11160 ""
11161 }
11162 });
11163 percent_decode_uri_path(uri.split(['?', '#']).next().unwrap_or_default())
11164 },
11165 );
11166 (!path.is_empty()).then(|| lexical_absolute(Path::new(&path)))
11167}
11168
11169fn percent_decode_uri_path(input: &str) -> String {
11174 let bytes = input.as_bytes();
11175 let mut out = Vec::with_capacity(bytes.len());
11176 let mut i = 0;
11177 while i < bytes.len() {
11178 let decoded = (bytes[i] == b'%')
11179 .then(|| bytes.get(i + 1..i + 3))
11180 .flatten()
11181 .filter(|hex| hex.iter().all(u8::is_ascii_hexdigit))
11182 .and_then(|hex| std::str::from_utf8(hex).ok())
11183 .and_then(|hex| u8::from_str_radix(hex, 16).ok());
11184 match decoded {
11185 Some(0) => break,
11186 Some(byte) => {
11187 out.push(byte);
11188 i += 3;
11189 }
11190 None => {
11191 out.push(bytes[i]);
11192 i += 1;
11193 }
11194 }
11195 }
11196 String::from_utf8_lossy(&out).into_owned()
11197}
11198
11199fn lexical_absolute(path: &Path) -> PathBuf {
11202 let joined = if path.is_absolute() {
11203 path.to_path_buf()
11204 } else {
11205 std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
11206 };
11207 let mut out = PathBuf::new();
11208 for part in joined.components() {
11209 match part {
11210 std::path::Component::CurDir => {}
11211 std::path::Component::ParentDir => {
11212 out.pop();
11213 }
11214 other => out.push(other),
11215 }
11216 }
11217 out
11218}
11219
11220fn is_pg_connection_string(s: &str) -> bool {
11227 crate::pg_conn_str::is_url(s) || crate::pg_conn_str::is_keyword_value(s)
11228}
11229
11230#[derive(Debug, Clone, Deserialize)]
11246pub struct LogConfig {
11247 #[serde(default = "default_log_level")]
11252 pub level: String,
11253
11254 #[serde(default)]
11256 pub format: LogFormat,
11257
11258 #[serde(default)]
11260 pub filter_parameters: Vec<String>,
11261
11262 #[serde(default)]
11264 pub unfilter_parameters: Vec<String>,
11265
11266 #[serde(default = "default_access_log")]
11275 pub access_log: bool,
11276
11277 #[serde(default = "default_access_log_exclude")]
11287 pub access_log_exclude: Vec<String>,
11288
11289 #[serde(default)]
11296 pub capture: crate::log::capture::LogCaptureConfig,
11297}
11298
11299#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
11318#[non_exhaustive]
11319pub enum LogFormat {
11320 #[default]
11322 Auto,
11323 Pretty,
11325 Json,
11327}
11328
11329#[derive(Debug, Clone, Deserialize)]
11334pub struct TelemetryConfig {
11335 #[serde(default)]
11337 pub enabled: bool,
11338
11339 #[serde(default = "default_telemetry_service_name")]
11341 pub service_name: String,
11342
11343 #[serde(default)]
11345 pub service_namespace: Option<String>,
11346
11347 #[serde(default = "default_telemetry_service_version")]
11349 pub service_version: String,
11350
11351 #[serde(default = "default_telemetry_environment")]
11353 pub environment: String,
11354
11355 #[serde(default)]
11357 pub otlp_endpoint: Option<String>,
11358
11359 #[serde(default)]
11361 pub protocol: TelemetryProtocol,
11362
11363 #[serde(default)]
11365 pub strict: bool,
11366
11367 #[serde(default = "default_telemetry_sample_ratio")]
11373 pub sample_ratio: f64,
11374}
11375
11376const fn default_telemetry_sample_ratio() -> f64 {
11377 1.0
11378}
11379
11380#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
11382#[non_exhaustive]
11383pub enum TelemetryProtocol {
11384 #[serde(alias = "grpc", alias = "GRPC")]
11386 #[default]
11387 Grpc,
11388 #[serde(
11390 alias = "http-protobuf",
11391 alias = "http_protobuf",
11392 alias = "HTTP_PROTOBUF"
11393 )]
11394 HttpProtobuf,
11395}
11396
11397impl TelemetryProtocol {
11398 fn from_env_value(value: &str) -> Option<Self> {
11399 match value {
11400 "Grpc" | "grpc" | "GRPC" => Some(Self::Grpc),
11401 "HttpProtobuf" | "http-protobuf" | "http_protobuf" | "HTTP_PROTOBUF"
11402 | "httpprotobuf" => Some(Self::HttpProtobuf),
11403 _ => None,
11404 }
11405 }
11406}
11407
11408#[allow(
11431 clippy::struct_excessive_bools,
11432 reason = "independent on/off switches of one config section"
11433)]
11434#[derive(Debug, Clone, Deserialize)]
11435pub struct HealthConfig {
11436 #[serde(default = "default_health_enabled")]
11441 pub enabled: bool,
11442
11443 #[serde(default = "default_health_path")]
11447 pub path: String,
11448
11449 #[serde(default = "default_live_path")]
11451 pub live_path: String,
11452
11453 #[serde(default = "default_ready_path")]
11455 pub ready_path: String,
11456
11457 #[serde(default = "default_startup_path")]
11459 pub startup_path: String,
11460
11461 #[serde(default)]
11465 pub detailed: bool,
11466
11467 #[serde(default = "default_health_cache_ttl_ms")]
11472 pub cache_ttl_ms: u64,
11473
11474 #[serde(default = "default_health_ping_timeout_ms")]
11479 pub ping_timeout_ms: u64,
11480
11481 #[serde(default = "default_health_db_readiness")]
11484 pub db_readiness: bool,
11485
11486 #[serde(default)]
11491 pub redis_readiness: bool,
11492}
11493
11494impl HealthConfig {
11495 #[must_use]
11497 pub const fn cache_ttl(&self) -> std::time::Duration {
11498 std::time::Duration::from_millis(self.cache_ttl_ms)
11499 }
11500
11501 #[must_use]
11503 pub const fn ping_timeout(&self) -> std::time::Duration {
11504 std::time::Duration::from_millis(self.ping_timeout_ms)
11505 }
11506
11507 pub fn validate(&self) -> Result<(), ConfigError> {
11513 if self.ping_timeout_ms == 0 {
11514 return Err(ConfigError::Validation(
11515 "health.ping_timeout_ms must be greater than 0: a zero limit fails every \
11516 database and Redis ping, so /ready never returns 200"
11517 .to_owned(),
11518 ));
11519 }
11520 Ok(())
11521 }
11522}
11523
11524const fn default_health_db_readiness() -> bool {
11525 true
11526}
11527
11528const fn default_health_cache_ttl_ms() -> u64 {
11529 1_000
11530}
11531
11532const fn default_health_ping_timeout_ms() -> u64 {
11533 2_000
11534}
11535
11536#[derive(Debug, Clone, Deserialize)]
11542pub struct ActuatorConfig {
11543 #[serde(default = "default_actuator_prefix")]
11545 pub prefix: String,
11546
11547 #[serde(default)]
11550 pub sensitive: bool,
11551
11552 #[serde(default = "default_actuator_prometheus")]
11560 pub prometheus: bool,
11561}
11562
11563impl Default for ActuatorConfig {
11564 fn default() -> Self {
11565 Self {
11566 prefix: default_actuator_prefix(),
11567 sensitive: false,
11568 prometheus: default_actuator_prometheus(),
11569 }
11570 }
11571}
11572
11573fn default_actuator_prefix() -> String {
11574 "/actuator".to_owned()
11575}
11576
11577const fn default_actuator_prometheus() -> bool {
11578 true
11579}
11580
11581#[derive(Debug, Clone, Deserialize)]
11627pub struct MetricsConfig {
11628 #[serde(default = "default_max_series_per_metric")]
11635 pub max_series_per_metric: usize,
11636
11637 #[serde(default = "default_max_instruments")]
11642 pub max_instruments: usize,
11643
11644 #[serde(default = "default_max_labels_per_series")]
11654 pub max_labels_per_series: usize,
11655}
11656
11657impl Default for MetricsConfig {
11658 fn default() -> Self {
11659 Self {
11660 max_series_per_metric: default_max_series_per_metric(),
11661 max_instruments: default_max_instruments(),
11662 max_labels_per_series: default_max_labels_per_series(),
11663 }
11664 }
11665}
11666
11667impl MetricsConfig {
11668 #[must_use]
11670 pub const fn limits(&self) -> crate::metrics::Limits {
11671 crate::metrics::Limits {
11672 max_series_per_metric: self.max_series_per_metric,
11673 max_instruments: self.max_instruments,
11674 max_labels_per_series: self.max_labels_per_series,
11675 }
11676 }
11677
11678 pub fn validate(&self) -> Result<(), ConfigError> {
11684 self.limits().validate().map_err(ConfigError::Validation)
11685 }
11686}
11687
11688const fn default_max_series_per_metric() -> usize {
11689 crate::metrics::DEFAULT_MAX_SERIES_PER_METRIC
11690}
11691
11692const fn default_max_instruments() -> usize {
11693 crate::metrics::DEFAULT_MAX_INSTRUMENTS
11694}
11695
11696const fn default_max_labels_per_series() -> usize {
11697 crate::metrics::DEFAULT_MAX_LABELS_PER_SERIES
11698}
11699
11700#[derive(Debug, Clone, Deserialize)]
11737pub struct CorsConfig {
11738 #[serde(default)]
11743 pub allowed_origins: Vec<String>,
11744
11745 #[serde(default = "default_cors_methods")]
11748 pub allowed_methods: Vec<String>,
11749
11750 #[serde(default = "default_cors_headers")]
11753 pub allowed_headers: Vec<String>,
11754
11755 #[serde(default)]
11758 pub allow_credentials: bool,
11759
11760 #[serde(default = "default_cors_max_age")]
11763 pub max_age_secs: u64,
11764}
11765
11766impl Default for CorsConfig {
11767 fn default() -> Self {
11768 Self {
11769 allowed_origins: Vec::new(),
11770 allowed_methods: default_cors_methods(),
11771 allowed_headers: default_cors_headers(),
11772 allow_credentials: false,
11773 max_age_secs: default_cors_max_age(),
11774 }
11775 }
11776}
11777
11778impl CorsConfig {
11779 pub fn validate(&self) -> Result<(), ConfigError> {
11788 if self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") {
11789 return Err(ConfigError::Validation(
11790 "CORS: allow_credentials=true is incompatible with allowed_origins=[\"*\"]; \
11791 list explicit origins instead (browsers reject the wildcard+credentials combo)"
11792 .to_owned(),
11793 ));
11794 }
11795 Ok(())
11796 }
11797}
11798
11799fn default_cors_methods() -> Vec<String> {
11800 vec![
11801 "GET".to_owned(),
11802 "POST".to_owned(),
11803 "PUT".to_owned(),
11804 "DELETE".to_owned(),
11805 "PATCH".to_owned(),
11806 "OPTIONS".to_owned(),
11807 ]
11808}
11809
11810fn default_cors_headers() -> Vec<String> {
11811 vec!["Content-Type".to_owned(), "Authorization".to_owned()]
11812}
11813
11814const fn default_cors_max_age() -> u64 {
11815 86400
11816}
11817
11818#[derive(Debug, Clone, Deserialize, Default)]
11862pub struct CompressionConfig {
11863 #[serde(default)]
11871 pub enabled: bool,
11872}
11873
11874#[doc(hidden)]
11887pub fn apply_deploy_env_overrides(deploy: &mut Option<DeployConfig>, env: &dyn Env) {
11888 const KEYS: [&str; 18] = [
11894 "AUTUMN_DEPLOY__HOST",
11895 "AUTUMN_DEPLOY__HOSTS",
11896 "AUTUMN_DEPLOY__USER",
11897 "AUTUMN_DEPLOY__SSH_PORT",
11898 "AUTUMN_DEPLOY__APP_NAME",
11899 "AUTUMN_DEPLOY__APP_DIR",
11900 "AUTUMN_DEPLOY__SERVICE_NAME",
11901 "AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
11902 "AUTUMN_DEPLOY__KEEP_RELEASES",
11903 "AUTUMN_DEPLOY__PROFILE",
11904 "AUTUMN_DEPLOY__TLS__ENABLED",
11905 "AUTUMN_DEPLOY__TLS__HOST",
11906 "AUTUMN_DEPLOY__INSTALL_PROXY",
11907 "AUTUMN_DEPLOY__BAKE__DURATION_SECS",
11908 "AUTUMN_DEPLOY__BAKE__INTERVAL_SECS",
11909 "AUTUMN_DEPLOY__BAKE__MIN_REQUESTS",
11910 "AUTUMN_DEPLOY__BAKE__MAX_ERROR_RATE",
11911 "AUTUMN_DEPLOY__BAKE__MAX_P99_MS",
11912 ];
11913 if !KEYS.iter().any(|key| env.var(key).is_ok()) {
11914 return;
11915 }
11916 let deploy = deploy.get_or_insert_with(DeployConfig::default);
11917 let env_set_host = env
11932 .var("AUTUMN_DEPLOY__HOST")
11933 .is_ok_and(|value| !value.trim().is_empty());
11934 let env_set_hosts = env
11935 .var("AUTUMN_DEPLOY__HOSTS")
11936 .is_ok_and(|value| value.split(',').any(|entry| !entry.trim().is_empty()));
11937 parse_env_option_string(env, "AUTUMN_DEPLOY__HOST", &mut deploy.host);
11938 parse_env_csv_non_empty(env, "AUTUMN_DEPLOY__HOSTS", &mut deploy.hosts);
11946 if env_set_hosts && !env_set_host {
11955 deploy.host = None;
11956 } else if env_set_host && !env_set_hosts {
11957 deploy.hosts.clear();
11958 }
11959 parse_env_string(env, "AUTUMN_DEPLOY__USER", &mut deploy.user);
11960 parse_env(env, "AUTUMN_DEPLOY__SSH_PORT", &mut deploy.ssh_port);
11961 parse_env_option_string(env, "AUTUMN_DEPLOY__APP_NAME", &mut deploy.app_name);
11962 parse_env_option_string(env, "AUTUMN_DEPLOY__APP_DIR", &mut deploy.app_dir);
11963 parse_env_option_string(env, "AUTUMN_DEPLOY__SERVICE_NAME", &mut deploy.service_name);
11964 parse_env(
11965 env,
11966 "AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
11967 &mut deploy.readiness_timeout_secs,
11968 );
11969 parse_env(
11970 env,
11971 "AUTUMN_DEPLOY__KEEP_RELEASES",
11972 &mut deploy.keep_releases,
11973 );
11974 parse_env_string(env, "AUTUMN_DEPLOY__PROFILE", &mut deploy.profile);
11975 parse_env_bool(env, "AUTUMN_DEPLOY__TLS__ENABLED", &mut deploy.tls.enabled);
11978 parse_env_option_string(env, "AUTUMN_DEPLOY__TLS__HOST", &mut deploy.tls.host);
11979 parse_env_bool(
11983 env,
11984 "AUTUMN_DEPLOY__INSTALL_PROXY",
11985 &mut deploy.install_proxy,
11986 );
11987 let bake = &mut deploy.bake;
11989 parse_env(
11990 env,
11991 "AUTUMN_DEPLOY__BAKE__DURATION_SECS",
11992 &mut bake.duration_secs,
11993 );
11994 parse_env(
11995 env,
11996 "AUTUMN_DEPLOY__BAKE__INTERVAL_SECS",
11997 &mut bake.interval_secs,
11998 );
11999 parse_env(
12000 env,
12001 "AUTUMN_DEPLOY__BAKE__MIN_REQUESTS",
12002 &mut bake.min_requests,
12003 );
12004 parse_env_option(
12005 env,
12006 "AUTUMN_DEPLOY__BAKE__MAX_ERROR_RATE",
12007 &mut bake.max_error_rate,
12008 );
12009 parse_env_option(env, "AUTUMN_DEPLOY__BAKE__MAX_P99_MS", &mut bake.max_p99_ms);
12010}
12011
12012fn parse_env<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut T) {
12014 if let Ok(val) = env.var(key) {
12015 match val.parse::<T>() {
12016 Ok(v) => *target = v,
12017 Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
12018 }
12019 }
12020}
12021
12022fn parse_env_option_string(env: &dyn Env, key: &str, target: &mut Option<String>) {
12023 if let Ok(val) = env.var(key) {
12024 *target = if val.is_empty() { None } else { Some(val) };
12025 }
12026}
12027
12028fn parse_env_option_secret(env: &dyn Env, key: &str, target: &mut Option<secrecy::SecretString>) {
12033 if let Ok(val) = env.var(key) {
12034 let trimmed = val.trim();
12035 *target = if trimmed.is_empty() {
12036 None
12037 } else {
12038 Some(secrecy::SecretString::from(trimmed.to_owned()))
12039 };
12040 }
12041}
12042
12043fn parse_env_option<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut Option<T>) {
12044 if let Ok(val) = env.var(key) {
12045 if val.is_empty() {
12046 *target = None;
12047 } else {
12048 match val.parse::<T>() {
12049 Ok(v) => *target = Some(v),
12050 Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
12051 }
12052 }
12053 }
12054}
12055
12056fn parse_env_option_duration(env: &dyn Env, key: &str, target: &mut Option<std::time::Duration>) {
12059 if let Ok(val) = env.var(key) {
12060 let val = val.trim();
12061 if val.is_empty() {
12062 *target = None;
12063 } else {
12064 match parse_duration_str(val) {
12065 Ok(d) => *target = Some(d),
12066 Err(e) => eprintln!("Warning: {key}={val:?} is not valid ({e}), ignoring"),
12067 }
12068 }
12069 }
12070}
12071
12072fn parse_env_string(env: &dyn Env, key: &str, target: &mut String) {
12073 if let Ok(val) = env.var(key) {
12074 *target = val;
12075 }
12076}
12077
12078fn parse_env_bool(env: &dyn Env, key: &str, target: &mut bool) {
12079 if let Ok(val) = env.var(key) {
12080 match val.as_str() {
12081 "true" | "1" => *target = true,
12082 "false" | "0" => *target = false,
12083 _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
12084 }
12085 }
12086}
12087
12088fn parse_env_option_bool(env: &dyn Env, key: &str, target: &mut Option<bool>) {
12089 if let Ok(val) = env.var(key) {
12090 match val.as_str() {
12091 "true" | "1" => *target = Some(true),
12092 "false" | "0" => *target = Some(false),
12093 _ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
12094 }
12095 }
12096}
12097
12098fn parse_env_csv(env: &dyn Env, key: &str, target: &mut Vec<String>) {
12099 if let Ok(val) = env.var(key) {
12100 *target = val.split(',').map(|s| s.trim().to_owned()).collect();
12101 }
12102}
12103
12104fn parse_env_csv_non_empty(env: &dyn Env, key: &str, target: &mut Vec<String>) {
12121 if let Ok(val) = env.var(key) {
12122 *target = val
12123 .split(',')
12124 .map(str::trim)
12125 .filter(|entry| !entry.is_empty())
12126 .map(ToOwned::to_owned)
12127 .collect();
12128 }
12129}
12130
12131const fn default_port() -> u16 {
12134 3000
12135}
12136
12137fn default_host() -> String {
12138 "127.0.0.1".to_owned()
12139}
12140
12141const fn default_shutdown_timeout() -> u64 {
12142 30
12143}
12144
12145const fn default_prestop_grace() -> u64 {
12146 5
12147}
12148
12149const fn default_pool_size() -> usize {
12150 10
12151}
12152
12153const fn default_migration_lock_timeout() -> std::time::Duration {
12154 std::time::Duration::from_secs(5)
12155}
12156
12157const fn default_migration_lock_retries() -> u32 {
12158 5
12159}
12160
12161pub const PROD_REQUESTS_PER_POOL_CONNECTION: usize = 32;
12170
12171pub const PROD_MIN_ADMISSION_LIMIT: usize = 256;
12175
12176const fn default_max_connections_warn_threshold() -> usize {
12177 100
12178}
12179
12180const fn default_connect_timeout() -> u64 {
12181 5
12182}
12183
12184const fn default_pin_after_write_secs() -> u64 {
12185 5
12186}
12187
12188fn default_log_level() -> String {
12189 "info".to_owned()
12190}
12191
12192const fn default_access_log() -> bool {
12193 true
12194}
12195
12196fn default_access_log_exclude() -> Vec<String> {
12197 vec![
12198 "/health".to_owned(),
12199 "/live".to_owned(),
12200 "/ready".to_owned(),
12201 "/startup".to_owned(),
12202 "/actuator".to_owned(),
12203 "/static".to_owned(),
12204 ]
12205}
12206
12207fn default_telemetry_service_name() -> String {
12208 "autumn-app".to_owned()
12209}
12210
12211fn default_telemetry_service_version() -> String {
12212 "unknown".to_owned()
12213}
12214
12215fn default_telemetry_environment() -> String {
12216 "development".to_owned()
12217}
12218
12219const fn default_tls_reload_interval_secs() -> u64 {
12225 60
12226}
12227
12228const fn default_tls_handshake_timeout_secs() -> u64 {
12234 10
12235}
12236
12237fn default_deploy_user() -> String {
12239 "root".to_owned()
12240}
12241
12242const fn default_deploy_ssh_port() -> u16 {
12244 22
12245}
12246
12247const fn default_deploy_readiness_timeout_secs() -> u64 {
12249 60
12250}
12251
12252const fn default_deploy_keep_releases() -> u32 {
12254 3
12255}
12256
12257fn default_deploy_profile() -> String {
12260 "prod".to_owned()
12261}
12262
12263fn default_acme_cache_dir() -> PathBuf {
12266 PathBuf::from("config/acme")
12267}
12268
12269const fn default_acme_http_challenge_port() -> u16 {
12272 80
12273}
12274
12275const fn default_acme_renew_before_days() -> u32 {
12279 30
12280}
12281
12282fn default_custom_domains_dir() -> PathBuf {
12284 PathBuf::from("config/acme/domains")
12285}
12286
12287const fn default_custom_domains_max() -> usize {
12289 1000
12290}
12291
12292const fn default_custom_domains_cert_cache() -> usize {
12294 256
12295}
12296
12297const fn default_custom_domains_per_domain_per_day() -> u32 {
12299 5
12300}
12301
12302const fn default_custom_domains_global_per_hour() -> u32 {
12304 50
12305}
12306
12307const fn default_custom_domains_base_backoff() -> u64 {
12309 300
12310}
12311
12312const fn default_custom_domains_max_backoff() -> u64 {
12314 86_400
12315}
12316
12317const fn default_custom_domains_poll_secs() -> u64 {
12319 60
12320}
12321
12322fn default_acme_dns_credential() -> String {
12325 "acme_dns".to_owned()
12326}
12327
12328const fn default_acme_dns_propagation_timeout_secs() -> u64 {
12332 300
12333}
12334
12335const fn default_acme_dns_poll_interval_secs() -> u64 {
12338 5
12339}
12340
12341const MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS: u64 = 3600;
12348
12349fn default_acme_dns_resolvers() -> Vec<String> {
12353 vec!["1.1.1.1:53".to_owned(), "8.8.8.8:53".to_owned()]
12354}
12355
12356const fn default_health_enabled() -> bool {
12357 true
12358}
12359
12360fn default_health_path() -> String {
12361 "/health".to_owned()
12362}
12363
12364fn default_live_path() -> String {
12365 "/live".to_owned()
12366}
12367
12368const fn default_warn_on_pooler() -> bool {
12369 true
12370}
12371
12372const fn default_upgrade_enabled() -> bool {
12373 true
12374}
12375
12376const fn default_upgrade_ready_timeout() -> u64 {
12377 30
12378}
12379
12380impl Default for UpgradeConfig {
12381 fn default() -> Self {
12382 Self {
12383 enabled: default_upgrade_enabled(),
12384 ready_timeout_secs: default_upgrade_ready_timeout(),
12385 }
12386 }
12387}
12388
12389fn default_ready_path() -> String {
12390 "/ready".to_owned()
12391}
12392
12393fn default_startup_path() -> String {
12394 "/startup".to_owned()
12395}
12396
12397pub const DRAIN_MARGIN_SECS: u64 = 5;
12402
12403impl ServerConfig {
12404 #[must_use]
12409 pub fn drain_window_warning(&self) -> Option<String> {
12410 let timeout_ms = self.timeouts.request_timeout_ms.filter(|ms| *ms > 0)?;
12411 let safe_secs = timeout_ms.div_ceil(1_000).saturating_add(DRAIN_MARGIN_SECS);
12412 (self.shutdown_timeout_secs < safe_secs).then(|| {
12413 format!(
12414 "server.shutdown_timeout_secs ({}) is too short for \
12415 server.timeouts.request_timeout_ms ({timeout_ms}): a request that starts \
12416 just before shutdown can be stopped. Set shutdown_timeout_secs to {safe_secs} \
12417 or more.",
12418 self.shutdown_timeout_secs
12419 )
12420 })
12421 }
12422}
12423
12424impl Default for ServerConfig {
12427 fn default() -> Self {
12428 Self {
12429 port: default_port(),
12430 host: default_host(),
12431 strict_config: false,
12432 strict_config_enforce_all: false,
12433 shutdown_timeout_secs: default_shutdown_timeout(),
12434 prestop_grace_secs: default_prestop_grace(),
12435 upgrade: UpgradeConfig::default(),
12436 timeouts: RequestTimeoutsConfig::default(),
12437 http: HttpServerConfig::default(),
12438 unix_socket: None,
12439 max_concurrent_requests: None,
12440 capacity_contract: None,
12441 tls: None,
12442 admission: AdmissionConfig::default(),
12443 }
12444 }
12445}
12446
12447impl Default for DatabaseConfig {
12448 fn default() -> Self {
12449 Self {
12450 url: None,
12451 primary_url: None,
12452 replica_url: None,
12453 pool_size: default_pool_size(),
12454 primary_pool_size: None,
12455 replica_pool_size: None,
12456 replica_fallback: ReplicaFallback::default(),
12457 read_your_writes: ReadYourWrites::default(),
12458 pin_after_write_secs: default_pin_after_write_secs(),
12459 replica_max_lag_ms: None,
12460 warn_on_pooler: true,
12461 connect_timeout_secs: default_connect_timeout(),
12462 startup_wait_secs: 0,
12463 auto_migrate: None,
12464 auto_migrate_in_production: false,
12465 statement_timeout: None,
12466 idle_in_transaction_timeout: None,
12467 migration_lock_timeout: default_migration_lock_timeout(),
12468 migration_lock_retries: default_migration_lock_retries(),
12469 slow_query_threshold: default_slow_query_threshold(),
12470 shards: Vec::new(),
12471 directory_shard_router: false,
12472 max_connections_warn_threshold: default_max_connections_warn_threshold(),
12473 fleet: None,
12474 }
12475 }
12476}
12477
12478impl Default for LogConfig {
12479 fn default() -> Self {
12480 Self {
12481 level: default_log_level(),
12482 format: LogFormat::default(),
12483 filter_parameters: Vec::new(),
12484 unfilter_parameters: Vec::new(),
12485 access_log: default_access_log(),
12486 access_log_exclude: default_access_log_exclude(),
12487 capture: crate::log::capture::LogCaptureConfig::default(),
12488 }
12489 }
12490}
12491
12492impl Default for TelemetryConfig {
12493 fn default() -> Self {
12494 Self {
12495 enabled: false,
12496 service_name: default_telemetry_service_name(),
12497 service_namespace: None,
12498 service_version: default_telemetry_service_version(),
12499 environment: default_telemetry_environment(),
12500 otlp_endpoint: None,
12501 protocol: TelemetryProtocol::default(),
12502 strict: false,
12503 sample_ratio: default_telemetry_sample_ratio(),
12504 }
12505 }
12506}
12507
12508impl Default for HealthConfig {
12509 fn default() -> Self {
12510 Self {
12511 enabled: default_health_enabled(),
12512 path: default_health_path(),
12513 live_path: default_live_path(),
12514 ready_path: default_ready_path(),
12515 startup_path: default_startup_path(),
12516 detailed: false,
12517 cache_ttl_ms: default_health_cache_ttl_ms(),
12518 ping_timeout_ms: default_health_ping_timeout_ms(),
12519 db_readiness: default_health_db_readiness(),
12520 redis_readiness: false,
12521 }
12522 }
12523}
12524
12525pub trait ConfigLoader: Send + Sync + 'static {
12556 fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
12564}
12565
12566#[derive(Debug, Default, Clone)]
12572pub struct TomlEnvConfigLoader {
12573 allowed_plugin_roots: BTreeSet<String>,
12579}
12580
12581impl TomlEnvConfigLoader {
12582 #[must_use]
12584 pub const fn new() -> Self {
12585 Self {
12586 allowed_plugin_roots: BTreeSet::new(),
12587 }
12588 }
12589
12590 #[must_use]
12600 pub fn with_plugin_config_roots(mut self, roots: BTreeSet<String>) -> Self {
12601 self.allowed_plugin_roots = roots;
12602 self
12603 }
12604}
12605
12606impl ConfigLoader for TomlEnvConfigLoader {
12607 async fn load(&self) -> Result<AutumnConfig, ConfigError> {
12608 let base = OsEnv;
12616 let profile = resolve_profile(&base);
12617 let dir = crate::dotenv::dotenv_base_dir(&base);
12622 let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
12623 .map_err(|e| ConfigError::Dotenv(e.to_string()))?;
12624 let env = crate::dotenv::DotenvEnv::new(&base, vars);
12625 AutumnConfig::load_with_env_and_plugin_roots(&env, &self.allowed_plugin_roots)
12626 }
12627}
12628
12629const fn default_slow_query_threshold() -> std::time::Duration {
12630 std::time::Duration::from_millis(500)
12631}
12632
12633pub fn parse_duration_str(s: &str) -> Result<std::time::Duration, String> {
12640 if s.is_empty() {
12641 return Err("duration string is empty".to_owned());
12642 }
12643
12644 if let Ok(ms) = s.parse::<u64>() {
12646 return Ok(std::time::Duration::from_millis(ms));
12647 }
12648
12649 if let Some(val_str) = s.strip_suffix("ms") {
12651 let val = val_str
12652 .parse::<u64>()
12653 .map_err(|e| format!("invalid duration integer: {e}"))?;
12654 return Ok(std::time::Duration::from_millis(val));
12655 }
12656
12657 if let Some(val_str) = s.strip_suffix('s') {
12658 let val = val_str
12659 .parse::<u64>()
12660 .map_err(|e| format!("invalid duration integer: {e}"))?;
12661 return Ok(std::time::Duration::from_secs(val));
12662 }
12663
12664 if let Some(val_str) = s.strip_suffix('m') {
12665 let val = val_str
12666 .parse::<u64>()
12667 .map_err(|e| format!("invalid duration integer: {e}"))?;
12668 let secs = val.checked_mul(60).ok_or_else(|| {
12669 format!("duration overflow: '{s}' exceeds maximum representable value")
12670 })?;
12671 return Ok(std::time::Duration::from_secs(secs));
12672 }
12673
12674 if let Some(val_str) = s.strip_suffix('h') {
12675 let val = val_str
12676 .parse::<u64>()
12677 .map_err(|e| format!("invalid duration integer: {e}"))?;
12678 let secs = val.checked_mul(3600).ok_or_else(|| {
12679 format!("duration overflow: '{s}' exceeds maximum representable value")
12680 })?;
12681 return Ok(std::time::Duration::from_secs(secs));
12682 }
12683
12684 Err(format!("invalid duration format: '{s}'"))
12685}
12686
12687pub fn deserialize_duration<'de, D>(deserializer: D) -> Result<std::time::Duration, D::Error>
12695where
12696 D: serde::Deserializer<'de>,
12697{
12698 use serde::Deserialize;
12699
12700 #[derive(Deserialize)]
12701 #[serde(untagged)]
12702 enum DurationOrStr {
12703 String(String),
12704 Integer(u64),
12705 }
12706
12707 match DurationOrStr::deserialize(deserializer)? {
12708 DurationOrStr::String(s) => parse_duration_str(&s).map_err(serde::de::Error::custom),
12709 DurationOrStr::Integer(i) => Ok(std::time::Duration::from_millis(i)),
12710 }
12711}
12712
12713pub fn deserialize_option_duration<'de, D>(
12721 deserializer: D,
12722) -> Result<Option<std::time::Duration>, D::Error>
12723where
12724 D: serde::Deserializer<'de>,
12725{
12726 use serde::Deserialize;
12727
12728 #[derive(Deserialize)]
12729 struct Wrapper(#[serde(deserialize_with = "deserialize_duration")] std::time::Duration);
12730
12731 Option::<Wrapper>::deserialize(deserializer).map(|opt| opt.map(|w| w.0))
12732}
12733
12734#[derive(Debug, Clone, Deserialize)]
12736pub struct TenancyConfig {
12737 #[serde(default)]
12739 pub enabled: bool,
12740
12741 #[serde(default = "default_tenancy_source")]
12744 pub source: String,
12745
12746 #[serde(default = "default_tenancy_header_name")]
12748 pub header_name: String,
12749
12750 #[serde(default = "default_tenancy_session_key")]
12752 pub session_key: String,
12753
12754 #[serde(default = "default_tenancy_jwt_claim")]
12756 pub jwt_claim: String,
12757
12758 #[serde(default)]
12764 pub jwt_secret: Option<secrecy::SecretString>,
12765
12766 #[serde(default)]
12768 pub jwt_issuer: Option<String>,
12769
12770 #[serde(default)]
12774 pub jwt_audience: Option<String>,
12775
12776 #[serde(default)]
12778 pub base_domain: Option<String>,
12779
12780 #[serde(default)]
12787 pub public_paths: Vec<String>,
12788
12789 #[serde(default)]
12795 pub login_redirect: Option<String>,
12796
12797 #[serde(default)]
12800 pub quota_bytes: usize,
12801
12802 #[serde(default)]
12805 pub max_cells: usize,
12806
12807 #[serde(default)]
12810 pub idle_ttl_secs: u64,
12811
12812 #[serde(default)]
12817 pub max_concurrent_requests: usize,
12818
12819 #[serde(default)]
12823 pub max_db_connections: usize,
12824}
12825
12826fn default_tenancy_source() -> String {
12827 "header".to_string()
12828}
12829
12830fn default_tenancy_header_name() -> String {
12831 "x-tenant-id".to_string()
12832}
12833
12834fn default_tenancy_session_key() -> String {
12835 "tenant_id".to_string()
12836}
12837
12838fn default_tenancy_jwt_claim() -> String {
12839 "tenant_id".to_string()
12840}
12841
12842impl Default for TenancyConfig {
12843 fn default() -> Self {
12844 Self {
12845 enabled: false,
12846 source: default_tenancy_source(),
12847 header_name: default_tenancy_header_name(),
12848 session_key: default_tenancy_session_key(),
12849 jwt_claim: default_tenancy_jwt_claim(),
12850 jwt_secret: None,
12851 jwt_issuer: None,
12852 jwt_audience: None,
12853 base_domain: None,
12854 public_paths: Vec::new(),
12855 login_redirect: None,
12856 quota_bytes: 0,
12857 max_cells: 0,
12858 idle_ttl_secs: 0,
12859 max_concurrent_requests: 0,
12860 max_db_connections: 0,
12861 }
12862 }
12863}
12864
12865#[derive(Debug, Clone, Default, Deserialize)]
12869pub struct ResilienceConfig {
12870 #[serde(default)]
12872 pub circuit_breaker: CircuitBreakerConfig,
12873}
12874
12875#[derive(Debug, Clone, Default, Deserialize)]
12877pub struct CircuitBreakerConfig {
12878 #[serde(default)]
12880 pub defaults: CircuitBreakerPolicyConfig,
12881 #[serde(default)]
12883 pub hosts: std::collections::HashMap<String, CircuitBreakerPolicyConfig>,
12884}
12885
12886#[derive(Debug, Clone, Default, Deserialize)]
12888pub struct CircuitBreakerPolicyConfig {
12889 pub failure_ratio_threshold: Option<f64>,
12891 pub sample_window_secs: Option<u64>,
12893 pub minimum_sample_count: Option<u64>,
12895 pub open_duration_secs: Option<u64>,
12897 pub half_open_trial_count: Option<u64>,
12899 pub slow_call_duration_threshold_ms: Option<u64>,
12902 pub slow_call_rate_threshold: Option<f64>,
12905 pub cancelled_call_outcome: Option<crate::circuit_breaker::CancelledCallOutcome>,
12908}
12909
12910impl AutumnConfig {
12911 fn apply_resilience_env_overrides_with_env(&mut self, env: &dyn Env) {
12912 parse_env_option(
12913 env,
12914 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD",
12915 &mut self
12916 .resilience
12917 .circuit_breaker
12918 .defaults
12919 .failure_ratio_threshold,
12920 );
12921 parse_env_option(
12922 env,
12923 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SAMPLE_WINDOW_SECS",
12924 &mut self.resilience.circuit_breaker.defaults.sample_window_secs,
12925 );
12926 parse_env_option(
12927 env,
12928 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__MINIMUM_SAMPLE_COUNT",
12929 &mut self
12930 .resilience
12931 .circuit_breaker
12932 .defaults
12933 .minimum_sample_count,
12934 );
12935 parse_env_option(
12936 env,
12937 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__OPEN_DURATION_SECS",
12938 &mut self.resilience.circuit_breaker.defaults.open_duration_secs,
12939 );
12940 parse_env_option(
12941 env,
12942 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__HALF_OPEN_TRIAL_COUNT",
12943 &mut self
12944 .resilience
12945 .circuit_breaker
12946 .defaults
12947 .half_open_trial_count,
12948 );
12949 let defaults = &mut self.resilience.circuit_breaker.defaults;
12950 parse_env_option(
12951 env,
12952 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_DURATION_THRESHOLD_MS",
12953 &mut defaults.slow_call_duration_threshold_ms,
12954 );
12955 parse_env_option(
12956 env,
12957 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_RATE_THRESHOLD",
12958 &mut defaults.slow_call_rate_threshold,
12959 );
12960 parse_env_option(
12961 env,
12962 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__CANCELLED_CALL_OUTCOME",
12963 &mut defaults.cancelled_call_outcome,
12964 );
12965 }
12966}
12967
12968use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor};
12969use std::collections::{BTreeSet, HashMap, HashSet};
12970use std::sync::{Arc, Mutex};
12971
12972#[derive(Clone, Copy, PartialEq, Eq)]
12973enum AnyProbe {
12974 Str,
12975 Map,
12976 Seq,
12977}
12978
12979#[derive(Clone)]
12980pub struct SchemaDeserializer {
12981 path: Vec<String>,
12982 schema: Arc<Mutex<HashMap<String, HashSet<String>>>>,
12983 any_probe: Arc<Mutex<HashMap<String, AnyProbe>>>,
12988 rejected: Arc<Mutex<Vec<String>>>,
12990}
12991
12992impl Default for SchemaDeserializer {
12993 fn default() -> Self {
12994 Self::new()
12995 }
12996}
12997
12998impl SchemaDeserializer {
12999 #[must_use]
13000 pub fn new() -> Self {
13001 Self {
13002 path: Vec::new(),
13003 schema: Arc::new(Mutex::new(HashMap::new())),
13004 any_probe: Arc::new(Mutex::new(HashMap::new())),
13005 rejected: Arc::new(Mutex::new(Vec::new())),
13006 }
13007 }
13008
13009 #[must_use]
13010 pub fn into_schema(self) -> HashMap<String, HashSet<String>> {
13011 let lock = self
13012 .schema
13013 .lock()
13014 .unwrap_or_else(std::sync::PoisonError::into_inner);
13015 lock.clone()
13016 }
13017}
13018
13019impl<'de> de::Deserializer<'de> for SchemaDeserializer {
13020 type Error = serde::de::value::Error;
13021
13022 fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13023 where
13024 V: Visitor<'de>,
13025 {
13026 let path = self.path.join(".");
13035 let probe = self
13036 .any_probe
13037 .lock()
13038 .unwrap_or_else(std::sync::PoisonError::into_inner)
13039 .get(&path)
13040 .copied()
13041 .unwrap_or(AnyProbe::Str);
13042 let result = match probe {
13043 AnyProbe::Str => visitor.visit_str("0"),
13046 AnyProbe::Map => visitor.visit_map(SchemaMapAccess {
13050 fields: [].iter(),
13051 current_field: None,
13052 deserializer: self.clone(),
13053 }),
13054 AnyProbe::Seq => visitor.visit_seq(SchemaSeqAccess {
13055 done: true,
13056 deserializer: self.clone(),
13057 }),
13058 };
13059 if result.is_err() {
13060 self.rejected
13061 .lock()
13062 .unwrap_or_else(std::sync::PoisonError::into_inner)
13063 .push(path);
13064 }
13065 result
13066 }
13067
13068 fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13069 where
13070 V: Visitor<'de>,
13071 {
13072 visitor.visit_bool(false)
13073 }
13074
13075 fn deserialize_i8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13076 where
13077 V: Visitor<'de>,
13078 {
13079 visitor.visit_i8(0)
13080 }
13081
13082 fn deserialize_i16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13083 where
13084 V: Visitor<'de>,
13085 {
13086 visitor.visit_i16(0)
13087 }
13088
13089 fn deserialize_i32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13090 where
13091 V: Visitor<'de>,
13092 {
13093 visitor.visit_i32(0)
13094 }
13095
13096 fn deserialize_i64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13097 where
13098 V: Visitor<'de>,
13099 {
13100 visitor.visit_i64(0)
13101 }
13102
13103 fn deserialize_u8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13104 where
13105 V: Visitor<'de>,
13106 {
13107 visitor.visit_u8(0)
13108 }
13109
13110 fn deserialize_u16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13111 where
13112 V: Visitor<'de>,
13113 {
13114 visitor.visit_u16(0)
13115 }
13116
13117 fn deserialize_u32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13118 where
13119 V: Visitor<'de>,
13120 {
13121 visitor.visit_u32(0)
13122 }
13123
13124 fn deserialize_u64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13125 where
13126 V: Visitor<'de>,
13127 {
13128 visitor.visit_u64(0)
13129 }
13130
13131 fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13132 where
13133 V: Visitor<'de>,
13134 {
13135 visitor.visit_f32(0.0)
13136 }
13137
13138 fn deserialize_f64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13139 where
13140 V: Visitor<'de>,
13141 {
13142 visitor.visit_f64(0.0)
13143 }
13144
13145 fn deserialize_char<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13146 where
13147 V: Visitor<'de>,
13148 {
13149 visitor.visit_char('\0')
13150 }
13151
13152 fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13153 where
13154 V: Visitor<'de>,
13155 {
13156 visitor.visit_str("")
13157 }
13158
13159 fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13160 where
13161 V: Visitor<'de>,
13162 {
13163 visitor.visit_string(String::new())
13164 }
13165
13166 fn deserialize_bytes<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13167 where
13168 V: Visitor<'de>,
13169 {
13170 visitor.visit_bytes(&[])
13171 }
13172
13173 fn deserialize_byte_buf<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13174 where
13175 V: Visitor<'de>,
13176 {
13177 visitor.visit_byte_buf(Vec::new())
13178 }
13179
13180 fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13181 where
13182 V: Visitor<'de>,
13183 {
13184 visitor.visit_some(self)
13185 }
13186
13187 fn deserialize_unit<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13188 where
13189 V: Visitor<'de>,
13190 {
13191 visitor.visit_unit()
13192 }
13193
13194 fn deserialize_unit_struct<V>(
13195 self,
13196 _name: &'static str,
13197 visitor: V,
13198 ) -> Result<V::Value, Self::Error>
13199 where
13200 V: Visitor<'de>,
13201 {
13202 visitor.visit_unit()
13203 }
13204
13205 fn deserialize_newtype_struct<V>(
13206 self,
13207 _name: &'static str,
13208 visitor: V,
13209 ) -> Result<V::Value, Self::Error>
13210 where
13211 V: Visitor<'de>,
13212 {
13213 visitor.visit_newtype_struct(self)
13214 }
13215
13216 fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13217 where
13218 V: Visitor<'de>,
13219 {
13220 visitor.visit_seq(SchemaSeqAccess {
13221 done: false,
13222 deserializer: self,
13223 })
13224 }
13225
13226 fn deserialize_tuple<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
13227 where
13228 V: Visitor<'de>,
13229 {
13230 self.deserialize_seq(visitor)
13231 }
13232
13233 fn deserialize_tuple_struct<V>(
13234 self,
13235 _name: &'static str,
13236 _len: usize,
13237 visitor: V,
13238 ) -> Result<V::Value, Self::Error>
13239 where
13240 V: Visitor<'de>,
13241 {
13242 self.deserialize_seq(visitor)
13243 }
13244
13245 fn deserialize_map<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13246 where
13247 V: Visitor<'de>,
13248 {
13249 visitor.visit_map(SchemaMapAccess {
13250 fields: [].iter(),
13251 current_field: None,
13252 deserializer: self,
13253 })
13254 }
13255
13256 fn deserialize_struct<V>(
13257 self,
13258 _name: &'static str,
13259 fields: &'static [&'static str],
13260 visitor: V,
13261 ) -> Result<V::Value, Self::Error>
13262 where
13263 V: Visitor<'de>,
13264 {
13265 let path_str = self.path.join(".");
13266 {
13267 let mut schema = self.schema.lock().unwrap();
13268 schema.insert(path_str, fields.iter().map(|&s| s.to_string()).collect());
13269 }
13270
13271 visitor.visit_map(SchemaMapAccess {
13272 fields: fields.iter(),
13273 current_field: None,
13274 deserializer: self,
13275 })
13276 }
13277
13278 fn deserialize_enum<V>(
13279 self,
13280 _name: &'static str,
13281 variants: &'static [&'static str],
13282 visitor: V,
13283 ) -> Result<V::Value, Self::Error>
13284 where
13285 V: Visitor<'de>,
13286 {
13287 visitor.visit_enum(SchemaEnumAccess {
13296 variant: variants.first().copied().unwrap_or_default(),
13297 })
13298 }
13299
13300 fn deserialize_identifier<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13301 where
13302 V: Visitor<'de>,
13303 {
13304 visitor.visit_str("")
13305 }
13306
13307 fn deserialize_ignored_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
13308 where
13309 V: Visitor<'de>,
13310 {
13311 visitor.visit_unit()
13312 }
13313}
13314
13315struct SchemaSeqAccess {
13316 done: bool,
13317 deserializer: SchemaDeserializer,
13318}
13319
13320impl<'de> SeqAccess<'de> for SchemaSeqAccess {
13321 type Error = serde::de::value::Error;
13322
13323 fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
13324 where
13325 T: DeserializeSeed<'de>,
13326 {
13327 if self.done {
13328 Ok(None)
13329 } else {
13330 self.done = true;
13331 seed.deserialize(self.deserializer.clone()).map(Some)
13332 }
13333 }
13334}
13335
13336struct SchemaMapAccess {
13337 fields: std::slice::Iter<'static, &'static str>,
13338 current_field: Option<&'static str>,
13339 deserializer: SchemaDeserializer,
13340}
13341
13342impl<'de> MapAccess<'de> for SchemaMapAccess {
13343 type Error = serde::de::value::Error;
13344
13345 fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
13346 where
13347 K: DeserializeSeed<'de>,
13348 {
13349 if let Some(&field) = self.fields.next() {
13350 self.current_field = Some(field);
13351 seed.deserialize(de::value::StrDeserializer::new(field))
13352 .map(Some)
13353 } else {
13354 Ok(None)
13355 }
13356 }
13357
13358 fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
13359 where
13360 V: DeserializeSeed<'de>,
13361 {
13362 let field = self.current_field.take().unwrap();
13363 let mut new_path = self.deserializer.path.clone();
13364 new_path.push(field.to_string());
13365
13366 let nested = SchemaDeserializer {
13367 path: new_path,
13368 schema: self.deserializer.schema.clone(),
13369 any_probe: self.deserializer.any_probe.clone(),
13370 rejected: self.deserializer.rejected.clone(),
13371 };
13372 seed.deserialize(nested)
13373 }
13374}
13375
13376struct SchemaEnumAccess {
13377 variant: &'static str,
13381}
13382
13383impl<'de> de::EnumAccess<'de> for SchemaEnumAccess {
13384 type Error = serde::de::value::Error;
13385 type Variant = Self;
13386
13387 fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
13388 where
13389 V: de::DeserializeSeed<'de>,
13390 {
13391 let val = seed.deserialize(de::value::StrDeserializer::new(self.variant))?;
13392 Ok((val, self))
13393 }
13394}
13395
13396impl<'de> de::VariantAccess<'de> for SchemaEnumAccess {
13397 type Error = serde::de::value::Error;
13398
13399 fn unit_variant(self) -> Result<(), Self::Error> {
13400 Ok(())
13401 }
13402
13403 fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
13404 where
13405 T: de::DeserializeSeed<'de>,
13406 {
13407 seed.deserialize(SchemaDeserializer::new())
13408 }
13409
13410 fn tuple_variant<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
13411 where
13412 V: Visitor<'de>,
13413 {
13414 visitor.visit_unit()
13415 }
13416
13417 fn struct_variant<V>(
13418 self,
13419 _fields: &'static [&'static str],
13420 visitor: V,
13421 ) -> Result<V::Value, Self::Error>
13422 where
13423 V: Visitor<'de>,
13424 {
13425 visitor.visit_unit()
13426 }
13427}
13428
13429#[cfg(test)]
13430mod tests {
13431
13432 use super::*;
13433
13434 struct FakeEnv(std::collections::HashMap<String, String>);
13435 impl Env for FakeEnv {
13436 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
13437 self.0
13438 .get(key)
13439 .cloned()
13440 .ok_or(std::env::VarError::NotPresent)
13441 }
13442 }
13443
13444 #[test]
13445 fn test_schema_extractor() {
13446 let keys = AutumnConfig::get_schema_keys();
13447 assert!(keys.contains_key(""));
13448 let root_keys = &keys[""];
13449 assert!(root_keys.contains("server"));
13450 assert!(root_keys.contains("database"));
13451
13452 assert!(keys.contains_key("server"));
13453 assert!(keys["server"].contains("port"));
13454 assert!(keys["server"].contains("host"));
13455
13456 assert!(keys.contains_key("database"));
13457 assert!(keys["database"].contains("primary_url"));
13458 }
13459
13460 #[cfg(feature = "acme")]
13468 #[test]
13469 fn acme_enum_field_does_not_truncate_schema_traversal() {
13470 let keys = AutumnConfig::get_schema_keys();
13471 assert!(
13472 keys.contains_key("server.tls.acme"),
13473 "acme section must be in the schema"
13474 );
13475 assert!(
13476 keys.contains_key("database"),
13477 "database schema dropped: the acme enum truncated traversal"
13478 );
13479 assert!(keys["database"].contains("primary_url"));
13480
13481 let errs = AutumnConfig::validate_toml("[database]\nprimry_url = \"x\"\n", &keys);
13484 assert_eq!(
13485 errs,
13486 vec![(
13487 "database.primry_url".to_owned(),
13488 Some("database.primary_url".to_owned())
13489 )]
13490 );
13491 }
13492
13493 #[test]
13494 fn test_strict_config_startup_fails_on_typo() {
13495 let temp = tempfile::tempdir().unwrap();
13496 let config_path = temp.path().join("autumn.toml");
13497 std::fs::write(
13498 &config_path,
13499 "[database]\nprimry_url = \"postgres://localhost/db\"",
13500 )
13501 .unwrap();
13502
13503 let env = FakeEnv(
13504 [
13505 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
13506 (
13507 "AUTUMN_MANIFEST_DIR".to_owned(),
13508 temp.path().to_str().unwrap().to_owned(),
13509 ),
13510 ]
13511 .into(),
13512 );
13513
13514 let res = AutumnConfig::load_with_env(&env);
13515 assert!(res.is_err());
13516 let err_str = format!("{:?}", res.err().unwrap());
13517 assert!(err_str.contains("primry_url"));
13518 }
13519
13520 fn strict_prod_env_2063(temp: &std::path::Path) -> FakeEnv {
13525 FakeEnv(
13526 [
13527 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13528 (
13529 "AUTUMN_MANIFEST_DIR".to_owned(),
13530 temp.to_str().unwrap().to_owned(),
13531 ),
13532 ]
13533 .into(),
13534 )
13535 }
13536
13537 #[test]
13542 fn deploy_cli_lenient_accepts_plugin_owned_top_level_root() {
13543 let temp = tempfile::tempdir().unwrap();
13544 std::fs::write(
13545 temp.path().join("autumn.toml"),
13546 "[server]\nstrict_config = true\n\n[media]\nmediamtx_host = \"cdn.example\"\n",
13547 )
13548 .unwrap();
13549 let env = strict_prod_env_2063(temp.path());
13550
13551 let strict = AutumnConfig::load_with_env(&env);
13553 assert!(
13554 strict.is_err(),
13555 "app boot must stay strict for unknown plugin roots: {strict:?}"
13556 );
13557 let strict_err = format!("{:?}", strict.err().unwrap());
13558 assert!(
13559 strict_err.contains("media"),
13560 "strict error should name the unknown root: {strict_err}"
13561 );
13562
13563 let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13565 assert!(
13566 lenient.is_ok(),
13567 "deploy CLI must accept plugin-owned [media] under strict_config: {lenient:?}"
13568 );
13569 }
13570
13571 #[test]
13574 fn deploy_cli_lenient_accepts_arbitrary_unknown_top_level_root() {
13575 let temp = tempfile::tempdir().unwrap();
13576 std::fs::write(
13577 temp.path().join("autumn.toml"),
13578 "[server]\nstrict_config = true\n\n[definitely_not_a_root]\nx = 1\n",
13579 )
13580 .unwrap();
13581 let env = strict_prod_env_2063(temp.path());
13582
13583 assert!(
13584 AutumnConfig::load_with_env(&env).is_err(),
13585 "app boot must reject an unknown top-level root"
13586 );
13587 assert!(
13588 AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_ok(),
13589 "deploy CLI must accept an unknown top-level root as opaque"
13590 );
13591 }
13592
13593 #[test]
13597 fn deploy_cli_lenient_still_rejects_known_section_typo() {
13598 let temp = tempfile::tempdir().unwrap();
13599 std::fs::write(
13600 temp.path().join("autumn.toml"),
13601 "[server]\nstrict_config = true\n\n[database]\nprimry_url = \"postgres://localhost/db\"\n",
13602 )
13603 .unwrap();
13604 let env = strict_prod_env_2063(temp.path());
13605
13606 let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13607 assert!(
13608 res.is_err(),
13609 "known-section typo must still hard-fail under the lenient CLI load: {res:?}"
13610 );
13611 let err = format!("{:?}", res.err().unwrap());
13612 assert!(
13613 err.contains("primry_url"),
13614 "error should name the known-section typo: {err}"
13615 );
13616 }
13617
13618 #[test]
13621 fn deploy_cli_lenient_still_rejects_malformed_toml() {
13622 let temp = tempfile::tempdir().unwrap();
13623 std::fs::write(
13624 temp.path().join("autumn.toml"),
13625 "[server]\nstrict_config = true\n\nthis is not = = valid toml\n",
13626 )
13627 .unwrap();
13628 let env = strict_prod_env_2063(temp.path());
13629
13630 assert!(
13631 AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_err(),
13632 "malformed TOML must still fail under the lenient CLI load"
13633 );
13634 }
13635
13636 #[test]
13645 fn deploy_cli_lenient_still_rejects_profile_prefixed_root() {
13646 let temp = tempfile::tempdir().unwrap();
13647 std::fs::write(
13648 temp.path().join("autumn.toml"),
13649 "[server]\nstrict_config = true\n\n[profile.prod.media]\nmediamtx_host = \"cdn.example\"\n",
13650 )
13651 .unwrap();
13652 let env = strict_prod_env_2063(temp.path());
13653
13654 let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13655 assert!(
13656 res.is_err(),
13657 "a profile-prefixed root ([profile.prod.media]) must stay strict under \
13658 the lenient CLI load — it is not a true top-level root and the deployed \
13659 app rejects it at boot: {res:?}"
13660 );
13661 let err = format!("{:?}", res.err().unwrap());
13662 assert!(
13663 err.contains("media"),
13664 "error should name the profile-prefixed root: {err}"
13665 );
13666 }
13667
13668 #[test]
13673 fn deploy_cli_lenient_still_rejects_profile_prefixed_unknown_root() {
13674 let temp = tempfile::tempdir().unwrap();
13675 std::fs::write(
13676 temp.path().join("autumn.toml"),
13677 "[server]\nstrict_config = true\n\n[profile.prod.definitely_unknown]\nx = 1\n",
13678 )
13679 .unwrap();
13680 let env = strict_prod_env_2063(temp.path());
13681
13682 let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13683 assert!(
13684 res.is_err(),
13685 "a profile-prefixed unknown root must stay strict under the lenient CLI \
13686 load: {res:?}"
13687 );
13688 let err = format!("{:?}", res.err().unwrap());
13689 assert!(
13690 err.contains("definitely_unknown"),
13691 "error should name the profile-prefixed unknown root: {err}"
13692 );
13693 }
13694
13695 #[test]
13704 fn deploy_cli_lenient_still_rejects_non_table_root() {
13705 let temp = tempfile::tempdir().unwrap();
13707 std::fs::write(
13708 temp.path().join("autumn.toml"),
13709 "[server]\nstrict_config = true\n\nmedia = \"enabled\"\n",
13710 )
13711 .unwrap();
13712 let env = strict_prod_env_2063(temp.path());
13713
13714 let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13715 assert!(
13716 res.is_err(),
13717 "a SCALAR top-level root (media = \"enabled\") must hard-fail under the \
13718 lenient CLI load — it is not a table and the deployed app rejects it at \
13719 boot: {res:?}"
13720 );
13721 let err = format!("{:?}", res.err().unwrap());
13722 assert!(
13723 err.contains("media"),
13724 "error should name the non-table root: {err}"
13725 );
13726
13727 let temp2 = tempfile::tempdir().unwrap();
13729 std::fs::write(
13730 temp2.path().join("autumn.toml"),
13731 "[server]\nstrict_config = true\n\nmedia = [\"a\", \"b\"]\n",
13732 )
13733 .unwrap();
13734 let env2 = strict_prod_env_2063(temp2.path());
13735
13736 let res2 = AutumnConfig::load_with_env_lenient_unknown_roots(&env2);
13737 assert!(
13738 res2.is_err(),
13739 "an ARRAY top-level root (media = [\"a\", \"b\"]) must hard-fail under the \
13740 lenient CLI load — it is not a table and the deployed app rejects it at \
13741 boot: {res2:?}"
13742 );
13743 let err2 = format!("{:?}", res2.err().unwrap());
13744 assert!(
13745 err2.contains("media"),
13746 "error should name the non-table root: {err2}"
13747 );
13748 }
13749
13750 #[test]
13759 fn deploy_cli_lenient_accepts_quoted_dotted_top_level_root() {
13760 let temp = tempfile::tempdir().unwrap();
13764 std::fs::write(
13765 temp.path().join("autumn.toml"),
13766 "[server]\nstrict_config = true\n\n[\"my.plugin\"]\nenabled = true\n",
13767 )
13768 .unwrap();
13769 let env = strict_prod_env_2063(temp.path());
13770
13771 let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
13772 assert!(
13773 lenient.is_ok(),
13774 "deploy CLI must leniently accept a quoted-dotted TOP-LEVEL table root \
13775 ([\"my.plugin\"]) — it is a true top-level plugin root the app accepts at \
13776 boot, and top-level-ness is structural (empty parent path), not \
13777 `path.contains('.')`: {lenient:?}"
13778 );
13779
13780 let temp2 = tempfile::tempdir().unwrap();
13784 std::fs::write(
13785 temp2.path().join("autumn.toml"),
13786 "\"my.plugin\" = { enabled = true }\n\n[server]\nstrict_config = true\n",
13787 )
13788 .unwrap();
13789 let env2 = strict_prod_env_2063(temp2.path());
13790 assert!(
13791 AutumnConfig::load_with_env_lenient_unknown_roots(&env2).is_ok(),
13792 "deploy CLI must accept the inline-table quoted-dotted top-level root too"
13793 );
13794 }
13795
13796 #[test]
13800 fn post_database_section_typo_warns_but_does_not_fail() {
13801 let temp = tempfile::tempdir().unwrap();
13802 let config_path = temp.path().join("autumn.toml");
13803 std::fs::write(&config_path, "[log]\nbogus_zzz = true\n").unwrap();
13804
13805 let env = FakeEnv(
13806 [
13807 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
13808 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13813 (
13814 "AUTUMN_MANIFEST_DIR".to_owned(),
13815 temp.path().to_str().unwrap().to_owned(),
13816 ),
13817 ]
13818 .into(),
13819 );
13820
13821 let res = AutumnConfig::load_with_env(&env);
13822 assert!(
13823 res.is_ok(),
13824 "a post-database section typo must warn (not fail) under warn-first rollout: {res:?}"
13825 );
13826 }
13827
13828 #[test]
13831 fn post_database_section_typo_fails_under_enforce_all() {
13832 let temp = tempfile::tempdir().unwrap();
13833 let config_path = temp.path().join("autumn.toml");
13834 std::fs::write(
13835 &config_path,
13836 "[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n[log]\nbogus_zzz = true\n",
13837 )
13838 .unwrap();
13839
13840 let env = FakeEnv(
13841 [
13842 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13847 (
13848 "AUTUMN_MANIFEST_DIR".to_owned(),
13849 temp.path().to_str().unwrap().to_owned(),
13850 ),
13851 ]
13852 .into(),
13853 );
13854
13855 let res = AutumnConfig::load_with_env(&env);
13856 assert!(
13857 res.is_err(),
13858 "strict_config_enforce_all must hard-fail the post-database typo"
13859 );
13860 let err_str = format!("{:?}", res.err().unwrap());
13861 assert!(
13862 err_str.contains("bogus_zzz"),
13863 "error should name the key: {err_str}"
13864 );
13865 }
13866
13867 #[test]
13870 fn pre_database_section_typo_still_hard_fails() {
13871 let temp = tempfile::tempdir().unwrap();
13872 let config_path = temp.path().join("autumn.toml");
13873 std::fs::write(&config_path, "[server]\nbogus_zzz = true\n").unwrap();
13874
13875 let env = FakeEnv(
13876 [
13877 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
13878 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13882 (
13883 "AUTUMN_MANIFEST_DIR".to_owned(),
13884 temp.path().to_str().unwrap().to_owned(),
13885 ),
13886 ]
13887 .into(),
13888 );
13889
13890 let res = AutumnConfig::load_with_env(&env);
13891 assert!(
13892 res.is_err(),
13893 "an unknown [server] key must still hard-fail (pre-fix strictness preserved)"
13894 );
13895 let err_str = format!("{:?}", res.err().unwrap());
13896 assert!(
13897 err_str.contains("bogus_zzz"),
13898 "error should name the key: {err_str}"
13899 );
13900 }
13901
13902 fn plugin_roots(names: &[&str]) -> BTreeSet<String> {
13912 names.iter().map(|s| (*s).to_owned()).collect()
13913 }
13914
13915 fn strict_prod_env(dir: &std::path::Path, enforce_all: bool) -> FakeEnv {
13916 let mut vars = vec![
13917 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
13918 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
13919 (
13920 "AUTUMN_MANIFEST_DIR".to_owned(),
13921 dir.to_str().unwrap().to_owned(),
13922 ),
13923 ];
13924 if enforce_all {
13925 vars.push((
13926 "AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL".to_owned(),
13927 "true".to_owned(),
13928 ));
13929 }
13930 FakeEnv(vars.into_iter().collect())
13931 }
13932
13933 #[test]
13936 fn strict_config_accepts_registered_plugin_root() {
13937 let temp = tempfile::tempdir().unwrap();
13938 std::fs::write(
13939 temp.path().join("autumn.toml"),
13940 "[media]\nqueue = \"media\"\n[media.mediamtx]\napi_base = \"http://localhost:9997\"\n",
13941 )
13942 .unwrap();
13943
13944 let env = strict_prod_env(temp.path(), false);
13945 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
13946 assert!(
13947 res.is_ok(),
13948 "a registered [media] root must boot under strict_config: {res:?}"
13949 );
13950 }
13951
13952 #[test]
13960 fn strict_config_accepts_quoted_dotted_registered_plugin_root() {
13961 let temp = tempfile::tempdir().unwrap();
13962 std::fs::write(
13963 temp.path().join("autumn.toml"),
13964 "[\"my.plugin\"]\nenabled = true\n[\"my.plugin\".nested]\nx = 1\n",
13965 )
13966 .unwrap();
13967
13968 let env = strict_prod_env(temp.path(), false);
13969 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["my.plugin"]));
13970 assert!(
13971 res.is_ok(),
13972 "a registered quoted-dotted top-level root ([\"my.plugin\"]) must boot \
13973 under strict_config, exactly as deploy leniency accepts it: {res:?}"
13974 );
13975 }
13976
13977 #[test]
13983 fn strict_config_rejects_non_table_registered_plugin_root() {
13984 let temp = tempfile::tempdir().unwrap();
13987 std::fs::write(temp.path().join("autumn.toml"), "media = \"enabled\"\n").unwrap();
13988
13989 let env = strict_prod_env(temp.path(), false);
13990 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
13991 assert!(
13992 res.is_err(),
13993 "a scalar-valued registered root (media = \"enabled\") must hard-fail \
13994 under strict_config, not be exempted as an opaque table: {res:?}"
13995 );
13996 assert!(
13997 format!("{:?}", res.err().unwrap()).contains("media"),
13998 "error should name the malformed media root"
13999 );
14000
14001 let temp_arr = tempfile::tempdir().unwrap();
14004 std::fs::write(
14005 temp_arr.path().join("autumn.toml"),
14006 "media = [\"a\", \"b\"]\n",
14007 )
14008 .unwrap();
14009
14010 let env_arr = strict_prod_env(temp_arr.path(), false);
14011 let res_arr =
14012 AutumnConfig::load_with_env_and_plugin_roots(&env_arr, &plugin_roots(&["media"]));
14013 assert!(
14014 res_arr.is_err(),
14015 "an array-valued registered root (media = [\"a\", \"b\"]) must hard-fail \
14016 under strict_config, not be exempted as an opaque table: {res_arr:?}"
14017 );
14018 assert!(
14019 format!("{:?}", res_arr.err().unwrap()).contains("media"),
14020 "error should name the malformed media array root"
14021 );
14022 }
14023
14024 #[test]
14027 fn strict_config_rejects_unregistered_plugin_root() {
14028 let temp = tempfile::tempdir().unwrap();
14029 std::fs::write(
14030 temp.path().join("autumn.toml"),
14031 "[media]\nqueue = \"media\"\n",
14032 )
14033 .unwrap();
14034
14035 let env = strict_prod_env(temp.path(), false);
14036 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
14037 assert!(
14038 res.is_err(),
14039 "an unregistered [media] root must still hard-fail under strict_config"
14040 );
14041 assert!(format!("{:?}", res.err().unwrap()).contains("media"));
14042 }
14043
14044 #[test]
14047 fn strict_config_still_rejects_other_unknown_root_when_plugin_registered() {
14048 let temp = tempfile::tempdir().unwrap();
14049 std::fs::write(
14050 temp.path().join("autumn.toml"),
14051 "[media]\nqueue = \"media\"\n\n[definitely_not_a_root]\nx = 1\n",
14052 )
14053 .unwrap();
14054
14055 let env = strict_prod_env(temp.path(), false);
14056 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
14057 assert!(
14058 res.is_err(),
14059 "an unrelated unknown root must still hard-fail even with [media] registered"
14060 );
14061 let err_str = format!("{:?}", res.err().unwrap());
14062 assert!(
14063 err_str.contains("definitely_not_a_root"),
14064 "error should name the unknown root: {err_str}"
14065 );
14066 }
14067
14068 #[test]
14072 fn registered_plugin_root_is_opaque_under_enforce_all() {
14073 let temp = tempfile::tempdir().unwrap();
14074 std::fs::write(
14075 temp.path().join("autumn.toml"),
14076 "[media]\nwholly_made_up = true\n[media.deeply.nested]\nalso_bogus = 42\n",
14077 )
14078 .unwrap();
14079
14080 let env = strict_prod_env(temp.path(), true);
14081 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
14082 assert!(
14083 res.is_ok(),
14084 "enforce_all must NOT flag children of a registered opaque root: {res:?}"
14085 );
14086 }
14087
14088 #[test]
14097 fn strict_config_still_rejects_profile_prefixed_plugin_root() {
14098 let temp = tempfile::tempdir().unwrap();
14099 std::fs::write(
14100 temp.path().join("autumn.toml"),
14101 "[profile.prod.media]\nwholly_made_up = true\n\
14102 [profile.prod.media.deeply.nested]\nalso_bogus = 42\n",
14103 )
14104 .unwrap();
14105
14106 let env = strict_prod_env(temp.path(), true);
14107 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
14108 assert!(
14109 res.is_err(),
14110 "a profile-prefixed plugin root ([profile.prod.media]) must stay strict \
14111 and be rejected — the plugin reads only the top-level [media] table, so \
14112 exempting the profile layer would boot silently on default config: {res:?}"
14113 );
14114 let err_str = format!("{:?}", res.err().unwrap());
14115 assert!(
14116 err_str.contains("media"),
14117 "error should name the media/profile root: {err_str}"
14118 );
14119 }
14120
14121 #[test]
14126 fn strict_config_rejects_profile_prefixed_unknown_root() {
14127 let temp = tempfile::tempdir().unwrap();
14128 std::fs::write(
14129 temp.path().join("autumn.toml"),
14130 "[profile.prod.definitely_not_a_root]\nx = 1\n",
14131 )
14132 .unwrap();
14133
14134 let env = strict_prod_env(temp.path(), false);
14135 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
14136 assert!(
14137 res.is_err(),
14138 "a profile-prefixed genuinely-unknown root must still hard-fail even \
14139 with [media] registered (the fix must not blanket-allow profile subtrees)"
14140 );
14141 let err_str = format!("{:?}", res.err().unwrap());
14142 assert!(
14143 err_str.contains("definitely_not_a_root"),
14144 "error should name the unknown root: {err_str}"
14145 );
14146 }
14147
14148 #[test]
14152 fn non_strict_config_tolerates_media_root_without_registration() {
14153 let temp = tempfile::tempdir().unwrap();
14154 std::fs::write(
14155 temp.path().join("autumn.toml"),
14156 "[server]\nstrict_config = false\n\n[media]\nqueue = \"media\"\n",
14157 )
14158 .unwrap();
14159
14160 let env = FakeEnv(
14161 [
14162 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14163 (
14164 "AUTUMN_MANIFEST_DIR".to_owned(),
14165 temp.path().to_str().unwrap().to_owned(),
14166 ),
14167 ]
14168 .into(),
14169 );
14170 let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
14171 assert!(
14172 res.is_ok(),
14173 "non-strict config must tolerate an unregistered [media] root: {res:?}"
14174 );
14175 }
14176
14177 #[test]
14185 fn malformed_profile_entry_still_hard_fails() {
14186 let temp = tempfile::tempdir().unwrap();
14189 let config_path = temp.path().join("autumn.toml");
14190 std::fs::write(&config_path, "[profile]\ndev = \"prod\"\n").unwrap();
14191
14192 let env = FakeEnv(
14193 [
14194 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
14195 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14199 (
14200 "AUTUMN_MANIFEST_DIR".to_owned(),
14201 temp.path().to_str().unwrap().to_owned(),
14202 ),
14203 ]
14204 .into(),
14205 );
14206
14207 let res = AutumnConfig::load_with_env(&env);
14208 assert!(
14209 res.is_err(),
14210 "a malformed [profile] entry is a structural error that must keep \
14211 hard-failing under strict_config (not be demoted to warn-only): {res:?}"
14212 );
14213 assert!(
14214 matches!(res.err().unwrap(), ConfigError::Validation(_)),
14215 "malformed profile entry must fail as a validation error"
14216 );
14217
14218 let temp2 = tempfile::tempdir().unwrap();
14222 let config_path2 = temp2.path().join("autumn.toml");
14223 std::fs::write(&config_path2, "[resilience]\nboguz = 1\n").unwrap();
14224
14225 let env2 = FakeEnv(
14226 [
14227 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
14228 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14233 (
14234 "AUTUMN_MANIFEST_DIR".to_owned(),
14235 temp2.path().to_str().unwrap().to_owned(),
14236 ),
14237 ]
14238 .into(),
14239 );
14240
14241 let res2 = AutumnConfig::load_with_env(&env2);
14242 assert!(
14243 res2.is_ok(),
14244 "a newly-#1890-covered section typo must still only warn under \
14245 strict_config (enforce_all off), proving the profile fix is narrow: {res2:?}"
14246 );
14247 }
14248
14249 #[test]
14259 fn dotted_profile_name_preserves_strictness() {
14260 let temp = tempfile::tempdir().unwrap();
14263 let config_path = temp.path().join("autumn.toml");
14264 std::fs::write(
14265 &config_path,
14266 "[profile.\"prod.eu\".server]\nbogus_zzz = true\n",
14267 )
14268 .unwrap();
14269
14270 let env = FakeEnv(
14271 [
14272 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
14273 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14277 (
14278 "AUTUMN_MANIFEST_DIR".to_owned(),
14279 temp.path().to_str().unwrap().to_owned(),
14280 ),
14281 ]
14282 .into(),
14283 );
14284
14285 let res = AutumnConfig::load_with_env(&env);
14286 assert!(
14287 res.is_err(),
14288 "a [server] typo under a quoted dotted profile name must hard-fail \
14289 (pre-#1890 strictness must not be downgraded by string-splitting the \
14290 joined path): {res:?}"
14291 );
14292 let err_str = format!("{:?}", res.err().unwrap());
14293 assert!(
14294 err_str.contains("server") && err_str.contains("bogus_zzz"),
14295 "hard-fail must be for the [server] typo (right reason): {err_str}"
14296 );
14297
14298 let temp2 = tempfile::tempdir().unwrap();
14301 let config_path2 = temp2.path().join("autumn.toml");
14302 std::fs::write(
14303 &config_path2,
14304 "[profile.\"prod.eu\".resilience]\nboguz = 1\n",
14305 )
14306 .unwrap();
14307
14308 let env2 = FakeEnv(
14309 [
14310 ("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
14311 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
14312 (
14313 "AUTUMN_MANIFEST_DIR".to_owned(),
14314 temp2.path().to_str().unwrap().to_owned(),
14315 ),
14316 ]
14317 .into(),
14318 );
14319
14320 let res2 = AutumnConfig::load_with_env(&env2);
14321 assert!(
14322 res2.is_ok(),
14323 "a newly-#1890-covered section typo under a quoted dotted profile name \
14324 must still only warn under strict_config (enforce_all off): {res2:?}"
14325 );
14326 }
14327
14328 #[test]
14332 fn statement_timeout_duration_field_loads() {
14333 let temp = tempfile::tempdir().unwrap();
14334 let config_path = temp.path().join("autumn.toml");
14335
14336 std::fs::write(&config_path, "[database]\nstatement_timeout = \"30s\"\n").unwrap();
14337 let env = FakeEnv(
14338 [(
14339 "AUTUMN_MANIFEST_DIR".to_owned(),
14340 temp.path().to_str().unwrap().to_owned(),
14341 )]
14342 .into(),
14343 );
14344 let config =
14345 AutumnConfig::load_with_env(&env).expect("config with duration string must load");
14346 assert_eq!(
14347 config.database.statement_timeout,
14348 Some(std::time::Duration::from_secs(30))
14349 );
14350
14351 std::fs::write(&config_path, "[database]\nstatement_timeout = 250\n").unwrap();
14353 let config =
14354 AutumnConfig::load_with_env(&env).expect("config with integer duration must load");
14355 assert_eq!(
14356 config.database.statement_timeout,
14357 Some(std::time::Duration::from_millis(250))
14358 );
14359 }
14360
14361 #[test]
14362 fn should_warn_total_connections_at_and_above_threshold() {
14363 assert!(should_warn_total_connections(100, 100));
14365 assert!(should_warn_total_connections(250, 100));
14366 assert!(!should_warn_total_connections(99, 100));
14367 }
14368
14369 #[test]
14370 fn should_warn_total_connections_zero_threshold_disables() {
14371 assert!(!should_warn_total_connections(0, 0));
14373 assert!(!should_warn_total_connections(10_000, 0));
14374 }
14375
14376 #[test]
14377 fn database_config_default_warn_threshold_is_100() {
14378 assert_eq!(
14379 DatabaseConfig::default().max_connections_warn_threshold,
14380 100
14381 );
14382 }
14383
14384 struct MockConfigLoader {
14386 config: AutumnConfig,
14387 }
14388
14389 impl ConfigLoader for MockConfigLoader {
14390 async fn load(&self) -> Result<AutumnConfig, ConfigError> {
14391 Ok(self.config.clone())
14392 }
14393 }
14394
14395 #[tokio::test]
14396 async fn config_loader_trait_returns_supplied_config() {
14397 let mut custom = AutumnConfig::default();
14398 custom.server.port = 9999;
14399 custom.profile = Some("integration-test".to_owned());
14400
14401 let loader = MockConfigLoader {
14402 config: custom.clone(),
14403 };
14404 let resolved = loader.load().await.expect("mock loader should succeed");
14405
14406 assert_eq!(resolved.server.port, 9999);
14407 assert_eq!(resolved.profile.as_deref(), Some("integration-test"));
14408 }
14409
14410 #[test]
14411 fn validate_does_not_error_on_redis_backend_without_url() {
14412 let mut config = AutumnConfig::default();
14420 config.session.backend = crate::session::SessionBackend::Redis;
14421 config.session.redis.url = None;
14422
14423 config.validate().expect(
14424 "validate() must accept redis-backend-without-url so custom \
14425 session store overrides aren't blocked at boot",
14426 );
14427 }
14428
14429 #[tokio::test]
14430 async fn default_toml_env_loader_succeeds_without_files() {
14431 let loader = TomlEnvConfigLoader::new();
14434 let resolved = loader.load().await.expect("default loader should succeed");
14435 assert_eq!(resolved.server.port, 3000);
14437 }
14438
14439 #[test]
14440 fn database_config_validate_none() {
14441 let config = DatabaseConfig {
14442 url: None,
14443 ..Default::default()
14444 };
14445 assert!(config.validate().is_ok());
14446 }
14447
14448 #[test]
14449 fn database_config_validate_valid_postgres() {
14450 let config = DatabaseConfig {
14451 url: Some("postgres://user:pass@localhost:5432/db".to_string()),
14452 ..Default::default()
14453 };
14454 assert!(config.validate().is_ok());
14455 }
14456
14457 #[test]
14458 fn database_config_validate_valid_postgresql() {
14459 let config = DatabaseConfig {
14460 url: Some("postgresql://user:pass@localhost:5432/db".to_string()),
14461 ..Default::default()
14462 };
14463 assert!(config.validate().is_ok());
14464 }
14465
14466 #[test]
14467 fn database_config_validate_invalid_scheme() {
14468 let config = DatabaseConfig {
14469 url: Some("mysql://user:pass@localhost:3306/db".to_string()),
14470 ..Default::default()
14471 };
14472 let result = config.validate();
14473 assert!(result.is_err());
14474 match result {
14475 Err(ConfigError::Validation(msg)) => {
14476 assert!(msg.contains("must start with postgres:// or postgresql://"));
14479 }
14480 _ => panic!("Expected ConfigError::Validation"),
14481 }
14482 }
14483
14484 #[test]
14489 fn database_config_validate_does_not_echo_credentials() {
14490 for url in [
14491 "mysql://user:hunter2@localhost:3306/db",
14492 "redis://:hunter2@localhost:6379",
14493 "amqp://app:hunter2@broker/vhost",
14494 ] {
14495 let config = DatabaseConfig {
14496 url: Some(url.to_owned()),
14497 ..Default::default()
14498 };
14499 let Err(ConfigError::Validation(msg)) = config.validate() else {
14500 panic!("{url} must be refused");
14501 };
14502 assert!(!msg.contains("hunter2"), "password leaked in: {msg}");
14503 assert!(msg.contains("localhost") || msg.contains("broker"), "{msg}");
14505 }
14506 }
14507
14508 #[test]
14509 fn database_shard_url_validation_does_not_echo_credentials() {
14510 let config = DatabaseConfig {
14511 shards: vec![ShardConfig {
14512 name: "shard0".to_owned(),
14513 primary_url: "mysql://user:hunter2@localhost:3306/db".to_owned(),
14514 slots: None,
14515 replica_url: None,
14516 primary_pool_size: None,
14517 replica_pool_size: None,
14518 replica_fallback: None,
14519 }],
14520 ..Default::default()
14521 };
14522 let Err(ConfigError::Validation(msg)) = config.validate() else {
14523 panic!("a non-Postgres shard url must be refused");
14524 };
14525 assert!(!msg.contains("hunter2"), "password leaked in: {msg}");
14526 }
14527
14528 #[test]
14529 fn server_defaults() {
14530 let config = ServerConfig::default();
14531 assert_eq!(config.port, 3000);
14532 assert_eq!(config.host, "127.0.0.1");
14533 assert_eq!(config.shutdown_timeout_secs, 30);
14534 }
14535
14536 #[test]
14537 fn database_defaults() {
14538 let config = DatabaseConfig::default();
14539 assert!(config.url.is_none());
14540 assert_eq!(config.pool_size, 10);
14541 assert_eq!(config.connect_timeout_secs, 5);
14542 }
14543
14544 #[test]
14545 fn database_validate_none_url_is_ok() {
14546 let config = DatabaseConfig {
14547 url: None,
14548 ..Default::default()
14549 };
14550 assert!(config.validate().is_ok());
14551 }
14552
14553 #[test]
14554 fn database_validate_postgres_url_is_ok() {
14555 let config = DatabaseConfig {
14556 url: Some("postgres://user:pass@localhost/db".to_string()),
14557 ..Default::default()
14558 };
14559 assert!(config.validate().is_ok());
14560 }
14561
14562 #[test]
14563 fn database_validate_postgresql_url_is_ok() {
14564 let config = DatabaseConfig {
14565 url: Some("postgresql://user:pass@localhost/db".to_string()),
14566 ..Default::default()
14567 };
14568 assert!(config.validate().is_ok());
14569 }
14570
14571 #[test]
14572 fn database_validate_invalid_url_is_err() {
14573 let config = DatabaseConfig {
14574 url: Some("mysql://user:pass@localhost/db".to_string()),
14575 ..Default::default()
14576 };
14577 let result = config.validate();
14578 assert!(result.is_err());
14579 if let Err(ConfigError::Validation(msg)) = result {
14580 assert!(msg.contains("Invalid database URL"));
14581 assert!(msg.contains("must start with postgres:// or postgresql://"));
14582 } else {
14583 panic!("Expected ConfigError::Validation");
14584 }
14585 }
14586
14587 #[test]
14588 fn database_topology_deserializes_primary_and_replica_urls() {
14589 let config: AutumnConfig = toml::from_str(
14590 r#"
14591[database]
14592primary_url = "postgres://primary.example/app"
14593replica_url = "postgres://replica.example/app"
14594primary_pool_size = 12
14595replica_pool_size = 4
14596replica_fallback = "primary"
14597"#,
14598 )
14599 .expect("database topology config should parse");
14600
14601 assert_eq!(
14602 config.database.primary_url.as_deref(),
14603 Some("postgres://primary.example/app")
14604 );
14605 assert_eq!(
14606 config.database.replica_url.as_deref(),
14607 Some("postgres://replica.example/app")
14608 );
14609 assert_eq!(config.database.primary_pool_size, Some(12));
14610 assert_eq!(config.database.replica_pool_size, Some(4));
14611 assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
14612 assert_eq!(
14613 config.database.effective_primary_url(),
14614 Some("postgres://primary.example/app")
14615 );
14616 assert_eq!(config.database.effective_primary_pool_size(), 12);
14617 assert_eq!(config.database.effective_replica_pool_size(), 4);
14618 }
14619
14620 #[test]
14621 fn database_topology_keeps_url_as_single_primary_compatibility_path() {
14622 let config: AutumnConfig = toml::from_str(
14623 r#"
14624[database]
14625url = "postgres://single.example/app"
14626pool_size = 7
14627"#,
14628 )
14629 .expect("legacy database.url config should parse");
14630
14631 assert_eq!(
14632 config.database.effective_primary_url(),
14633 Some("postgres://single.example/app")
14634 );
14635 assert_eq!(config.database.effective_primary_pool_size(), 7);
14636 assert_eq!(config.database.effective_replica_pool_size(), 7);
14637 assert!(config.database.replica_url.is_none());
14638 }
14639
14640 #[test]
14641 fn database_topology_rejects_replica_without_primary() {
14642 let config = DatabaseConfig {
14643 replica_url: Some("postgres://replica.example/app".to_owned()),
14644 ..Default::default()
14645 };
14646
14647 let result = config.validate();
14648
14649 assert!(result.is_err());
14650 let Err(ConfigError::Validation(message)) = result else {
14651 panic!("expected database topology validation error");
14652 };
14653 assert!(message.contains("database.replica_url"));
14654 assert!(message.contains("database.primary_url"));
14655 }
14656
14657 #[test]
14658 fn time_zone_identifier_env_override_applies() {
14659 let env = MockEnv::new().with("AUTUMN_TIME_ZONE__IDENTIFIER", "America/New_York");
14660 let mut config = AutumnConfig::default();
14661 assert_eq!(config.time_zone.identifier, "UTC");
14662
14663 config.apply_env_overrides_with_env(&env);
14664
14665 assert_eq!(config.time_zone.identifier, "America/New_York");
14666 assert!(config.time_zone.validate().is_ok());
14667 }
14668
14669 #[test]
14670 fn alerts_severities_env_overrides_apply() {
14671 let env = MockEnv::new()
14675 .with("AUTUMN_ALERTS__SLACK_SEVERITIES", "critical")
14676 .with("AUTUMN_ALERTS__PAGERDUTY_SEVERITIES", "all")
14677 .with("AUTUMN_ALERTS__DISCORD_SEVERITIES", "critical");
14678 let mut config = AutumnConfig::default();
14679 assert_eq!(
14681 config.alerts.slack_severities,
14682 crate::alerts::AlertRouting::All
14683 );
14684
14685 config.apply_env_overrides_with_env(&env);
14686
14687 assert_eq!(
14688 config.alerts.slack_severities,
14689 crate::alerts::AlertRouting::Critical,
14690 "AUTUMN_ALERTS__SLACK_SEVERITIES=critical must set the Slack channel routing"
14691 );
14692 assert_eq!(
14693 config.alerts.discord_severities,
14694 crate::alerts::AlertRouting::Critical
14695 );
14696 assert_eq!(
14697 config.alerts.pagerduty_severities,
14698 crate::alerts::AlertRouting::All
14699 );
14700 }
14701
14702 #[test]
14703 fn database_topology_env_overrides_role_fields() {
14704 let env = MockEnv::new()
14705 .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app")
14706 .with("AUTUMN_DATABASE__REPLICA_URL", "postgres://replica.env/app")
14707 .with("AUTUMN_DATABASE__PRIMARY_POOL_SIZE", "9")
14708 .with("AUTUMN_DATABASE__REPLICA_POOL_SIZE", "3")
14709 .with("AUTUMN_DATABASE__REPLICA_FALLBACK", "primary");
14710 let mut config = AutumnConfig::default();
14711
14712 config.apply_env_overrides_with_env(&env);
14713
14714 assert_eq!(
14715 config.database.primary_url.as_deref(),
14716 Some("postgres://primary.env/app")
14717 );
14718 assert_eq!(
14719 config.database.replica_url.as_deref(),
14720 Some("postgres://replica.env/app")
14721 );
14722 assert_eq!(config.database.primary_pool_size, Some(9));
14723 assert_eq!(config.database.replica_pool_size, Some(3));
14724 assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
14725 }
14726
14727 #[test]
14728 fn database_shards_parse_from_toml_with_effective_fallbacks() {
14729 let config: AutumnConfig = toml::from_str(
14730 r#"
14731[database]
14732primary_url = "postgres://control.example/app"
14733pool_size = 8
14734replica_fallback = "primary"
14735
14736[[database.shards]]
14737name = "shard0"
14738primary_url = "postgres://shard0.example/app"
14739
14740[[database.shards]]
14741name = "shard1"
14742primary_url = "postgres://shard1.example/app"
14743replica_url = "postgres://shard1-ro.example/app"
14744primary_pool_size = 3
14745replica_pool_size = 2
14746replica_fallback = "fail_readiness"
14747"#,
14748 )
14749 .expect("sharded database config should parse");
14750
14751 let db = &config.database;
14752 assert!(db.has_shards());
14753 assert_eq!(db.shards.len(), 2);
14754
14755 let shard0 = &db.shards[0];
14756 assert_eq!(shard0.name, "shard0");
14757 assert_eq!(shard0.primary_url, "postgres://shard0.example/app");
14758 assert!(shard0.replica_url.is_none());
14759 assert_eq!(shard0.effective_primary_pool_size(db), 8);
14761 assert_eq!(shard0.effective_replica_pool_size(db), 8);
14762 assert_eq!(
14763 shard0.effective_replica_fallback(db),
14764 ReplicaFallback::Primary
14765 );
14766
14767 let shard1 = &db.shards[1];
14768 assert_eq!(shard1.effective_primary_pool_size(db), 3);
14769 assert_eq!(shard1.effective_replica_pool_size(db), 2);
14770 assert_eq!(
14771 shard1.effective_replica_fallback(db),
14772 ReplicaFallback::FailReadiness
14773 );
14774
14775 config.validate().expect("sharded config should validate");
14776 }
14777
14778 #[test]
14779 fn database_shards_default_to_empty() {
14780 let config = AutumnConfig::default();
14781 assert!(!config.database.has_shards());
14782 assert!(config.database.shards.is_empty());
14783 }
14784
14785 #[test]
14786 fn database_shard_env_overrides_existing_entry_fields() {
14787 let mut config: AutumnConfig = toml::from_str(
14788 r#"
14789[[database.shards]]
14790name = "shard0"
14791primary_url = "postgres://toml.example/app"
14792"#,
14793 )
14794 .expect("config should parse");
14795 let env = MockEnv::new()
14796 .with(
14797 "AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
14798 "postgres://env.example/app",
14799 )
14800 .with(
14801 "AUTUMN_DATABASE__SHARDS__0__REPLICA_URL",
14802 "postgres://env-ro.example/app",
14803 )
14804 .with("AUTUMN_DATABASE__SHARDS__0__PRIMARY_POOL_SIZE", "5")
14805 .with("AUTUMN_DATABASE__SHARDS__0__REPLICA_FALLBACK", "primary");
14806
14807 config.apply_env_overrides_with_env(&env);
14808
14809 let shard = &config.database.shards[0];
14810 assert_eq!(shard.name, "shard0");
14811 assert_eq!(shard.primary_url, "postgres://env.example/app");
14812 assert_eq!(
14813 shard.replica_url.as_deref(),
14814 Some("postgres://env-ro.example/app")
14815 );
14816 assert_eq!(shard.primary_pool_size, Some(5));
14817 assert_eq!(shard.replica_fallback, Some(ReplicaFallback::Primary));
14818 }
14819
14820 #[test]
14821 fn database_shard_env_appends_new_entry_when_name_and_primary_url_present() {
14822 let mut config = AutumnConfig::default();
14823 let env = MockEnv::new()
14824 .with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0")
14825 .with(
14826 "AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
14827 "postgres://shard0.env/app",
14828 )
14829 .with("AUTUMN_DATABASE__SHARDS__1__NAME", "shard1")
14830 .with(
14831 "AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
14832 "postgres://shard1.env/app",
14833 )
14834 .with("AUTUMN_DATABASE__SHARDS__3__NAME", "orphan")
14836 .with(
14837 "AUTUMN_DATABASE__SHARDS__3__PRIMARY_URL",
14838 "postgres://orphan.env/app",
14839 );
14840
14841 config.apply_env_overrides_with_env(&env);
14842
14843 assert_eq!(config.database.shards.len(), 2);
14844 assert_eq!(config.database.shards[0].name, "shard0");
14845 assert_eq!(config.database.shards[1].name, "shard1");
14846 }
14847
14848 #[test]
14849 fn database_shard_env_does_not_append_incomplete_entry() {
14850 let mut config = AutumnConfig::default();
14851 let env = MockEnv::new().with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0");
14853
14854 config.apply_env_overrides_with_env(&env);
14855
14856 assert!(config.database.shards.is_empty());
14857 }
14858
14859 fn shard(name: &str, primary_url: &str) -> ShardConfig {
14860 ShardConfig {
14861 name: name.to_owned(),
14862 primary_url: primary_url.to_owned(),
14863 slots: None,
14864 replica_url: None,
14865 primary_pool_size: None,
14866 replica_pool_size: None,
14867 replica_fallback: None,
14868 }
14869 }
14870
14871 fn shard_with_slots(name: &str, primary_url: &str, slots: &[&str]) -> ShardConfig {
14872 let mut config = shard(name, primary_url);
14873 config.slots = Some(
14874 slots
14875 .iter()
14876 .map(|spec| SlotSpec::Range((*spec).to_owned()))
14877 .collect(),
14878 );
14879 config
14880 }
14881
14882 #[test]
14883 fn slot_spec_expands_indices_and_ranges() {
14884 assert_eq!(SlotSpec::Index(5).expand().unwrap(), vec![5]);
14885 assert_eq!(SlotSpec::Range("7".to_owned()).expand().unwrap(), vec![7]);
14886 assert_eq!(
14887 SlotSpec::Range("3-6".to_owned()).expand().unwrap(),
14888 vec![3, 4, 5, 6]
14889 );
14890 assert!(SlotSpec::Range("6-3".to_owned()).expand().is_err());
14891 assert!(SlotSpec::Range("x-3".to_owned()).expand().is_err());
14892 assert!(SlotSpec::Range(String::new()).expand().is_err());
14893 }
14894
14895 #[test]
14896 fn slot_map_auto_splits_contiguously_by_declaration_order() {
14897 let config = DatabaseConfig {
14898 shards: vec![
14899 shard("a", "postgres://a/app"),
14900 shard("b", "postgres://b/app"),
14901 shard("c", "postgres://c/app"),
14902 ],
14903 ..Default::default()
14904 };
14905 let map = config
14906 .resolved_slot_map()
14907 .expect("auto-split should resolve");
14908 assert_eq!(map.len(), usize::from(SLOT_COUNT));
14909 assert_eq!((map[0], map[5461]), (0, 0));
14911 assert_eq!((map[5462], map[10922]), (1, 1));
14912 assert_eq!((map[10923], map[16383]), (2, 2));
14913 assert!(map.windows(2).all(|w| w[0] <= w[1]), "must be contiguous");
14914 for owner in 0..3 {
14915 let count = map.iter().filter(|&&o| o == owner).count();
14916 assert!(
14917 (5461..=5462).contains(&count),
14918 "shard {owner} owns {count} slots (expected near-even split)"
14919 );
14920 }
14921 }
14922
14923 #[test]
14924 fn slot_map_uses_explicit_assignments_regardless_of_order() {
14925 let config = DatabaseConfig {
14926 shards: vec![
14927 shard_with_slots("late", "postgres://late/app", &["8192-16383"]),
14928 shard_with_slots("early", "postgres://early/app", &["0-8191"]),
14929 ],
14930 ..Default::default()
14931 };
14932 let map = config
14933 .resolved_slot_map()
14934 .expect("explicit map should resolve");
14935 assert!(map[..8192].iter().all(|&owner| owner == 1));
14936 assert!(map[8192..].iter().all(|&owner| owner == 0));
14937 }
14938
14939 #[test]
14940 fn slot_map_allows_drained_shard_with_empty_slots() {
14941 let config = DatabaseConfig {
14942 shards: vec![
14943 shard_with_slots("live", "postgres://live/app", &["0-16383"]),
14944 shard_with_slots("drained", "postgres://drained/app", &[]),
14945 ],
14946 ..Default::default()
14947 };
14948 let map = config
14949 .resolved_slot_map()
14950 .expect("drained shard is allowed");
14951 assert_eq!(map.len(), usize::from(SLOT_COUNT));
14952 assert!(map.iter().all(|&owner| owner == 0));
14953 }
14954
14955 #[test]
14956 fn slot_map_rejects_mixed_declared_and_undeclared_slots() {
14957 let config = DatabaseConfig {
14958 shards: vec![
14959 shard_with_slots("a", "postgres://a/app", &["0-16383"]),
14960 shard("b", "postgres://b/app"),
14961 ],
14962 ..Default::default()
14963 };
14964 assert!(config.resolved_slot_map().is_err());
14965 }
14966
14967 #[test]
14968 fn slot_map_rejects_overlap_gap_and_out_of_range() {
14969 let config = DatabaseConfig {
14971 shards: vec![
14972 shard_with_slots("a", "postgres://a/app", &["0-8192"]),
14973 shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
14974 ],
14975 ..Default::default()
14976 };
14977 let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
14978 panic!("overlapping slots should fail");
14979 };
14980 assert!(message.contains("already owned"));
14981
14982 let config = DatabaseConfig {
14984 shards: vec![
14985 shard_with_slots("a", "postgres://a/app", &["0-8000"]),
14986 shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
14987 ],
14988 ..Default::default()
14989 };
14990 let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
14991 panic!("uncovered slots should fail");
14992 };
14993 assert!(message.contains("unassigned"));
14994 assert!(message.contains("8001-8191"), "got: {message}");
14995
14996 let config = DatabaseConfig {
14998 shards: vec![shard_with_slots("a", "postgres://a/app", &["0-16384"])],
14999 ..Default::default()
15000 };
15001 assert!(config.resolved_slot_map().is_err());
15002 }
15003
15004 #[test]
15005 fn slot_map_rejects_more_shards_than_slots() {
15006 let config = DatabaseConfig {
15007 shards: (0..=usize::from(SLOT_COUNT))
15008 .map(|i| shard(&format!("s{i}"), "postgres://s/app"))
15009 .collect(),
15010 ..Default::default()
15011 };
15012 let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
15013 panic!("more shards than slots cannot auto-split");
15014 };
15015 assert!(message.contains("at most"), "got: {message}");
15016 }
15017
15018 #[test]
15019 fn slots_parse_from_toml_ints_and_ranges() {
15020 let config: AutumnConfig = toml::from_str(
15021 r#"
15022[[database.shards]]
15023name = "a"
15024primary_url = "postgres://a/app"
15025slots = ["0-8191", 8192, "8193"]
15026
15027[[database.shards]]
15028name = "b"
15029primary_url = "postgres://b/app"
15030slots = ["8194-16383"]
15031"#,
15032 )
15033 .expect("slots config should parse");
15034 let map = config
15035 .database
15036 .resolved_slot_map()
15037 .expect("mixed int/range specs should resolve");
15038 assert!(map[..8194].iter().all(|&owner| owner == 0));
15039 assert!(map[8194..].iter().all(|&owner| owner == 1));
15040 config.validate().expect("config should validate");
15041 }
15042
15043 #[test]
15044 fn slot_env_overrides_assignments() {
15045 let mut config = AutumnConfig::default();
15046 let env = MockEnv::new()
15047 .with("AUTUMN_DATABASE__SHARDS__0__NAME", "a")
15048 .with(
15049 "AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
15050 "postgres://a/app",
15051 )
15052 .with("AUTUMN_DATABASE__SHARDS__0__SLOTS", "0-8191, 12288-16383")
15053 .with("AUTUMN_DATABASE__SHARDS__1__NAME", "b")
15054 .with(
15055 "AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
15056 "postgres://b/app",
15057 )
15058 .with("AUTUMN_DATABASE__SHARDS__1__SLOTS", "8192-12287");
15059
15060 config.apply_env_overrides_with_env(&env);
15061
15062 let map = config
15063 .database
15064 .resolved_slot_map()
15065 .expect("env slot specs should resolve");
15066 assert!(map[..8192].iter().all(|&owner| owner == 0));
15067 assert!(map[8192..12288].iter().all(|&owner| owner == 1));
15068 assert!(map[12288..].iter().all(|&owner| owner == 0));
15069 }
15070
15071 #[test]
15072 fn slot_ranges_format_compactly() {
15073 assert_eq!(format_slot_ranges(&[]), "");
15074 assert_eq!(format_slot_ranges(&[3]), "3");
15075 assert_eq!(format_slot_ranges(&[0, 1, 2, 5, 7, 8]), "0-2, 5, 7-8");
15076 }
15077
15078 #[test]
15079 fn database_shard_validation_rejects_bad_names() {
15080 for bad_name in ["", "Shard0", "shard 0", "shard:0", "shärd"] {
15081 let config = DatabaseConfig {
15082 shards: vec![shard(bad_name, "postgres://s0.example/app")],
15083 ..Default::default()
15084 };
15085 assert!(
15086 config.validate().is_err(),
15087 "shard name should be rejected: {bad_name:?}"
15088 );
15089 }
15090 }
15091
15092 #[test]
15093 fn database_shard_validation_rejects_duplicate_names() {
15094 let config = DatabaseConfig {
15095 shards: vec![
15096 shard("shard0", "postgres://a.example/app"),
15097 shard("shard0", "postgres://b.example/app"),
15098 ],
15099 ..Default::default()
15100 };
15101 let Err(ConfigError::Validation(message)) = config.validate() else {
15102 panic!("duplicate shard names should fail validation");
15103 };
15104 assert!(message.contains("unique"));
15105 }
15106
15107 #[test]
15108 fn database_shard_validation_rejects_bad_urls() {
15109 let config = DatabaseConfig {
15110 shards: vec![shard("shard0", "mysql://s0.example/app")],
15111 ..Default::default()
15112 };
15113 assert!(config.validate().is_err());
15114
15115 let mut with_bad_replica = shard("shard0", "postgres://s0.example/app");
15116 with_bad_replica.replica_url = Some("http://s0-ro.example/app".to_owned());
15117 let config = DatabaseConfig {
15118 shards: vec![with_bad_replica],
15119 ..Default::default()
15120 };
15121 assert!(config.validate().is_err());
15122 }
15123
15124 #[test]
15125 fn database_shards_without_control_role_are_allowed() {
15126 let config = DatabaseConfig {
15127 shards: vec![shard("shard0", "postgres://s0.example/app")],
15128 ..Default::default()
15129 };
15130 config
15131 .validate()
15132 .expect("shards without a control role should validate");
15133 }
15134
15135 #[test]
15136 fn postgres_scheduler_with_shards_requires_control_database() {
15137 let mut config = AutumnConfig::default();
15138 config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
15139 config.scheduler.backend = SchedulerBackend::Postgres;
15140
15141 let Err(ConfigError::Validation(message)) = config.validate() else {
15142 panic!("postgres scheduler without a control database should fail validation");
15143 };
15144 assert!(message.contains("control database"));
15145
15146 config.database.primary_url = Some("postgres://control.example/app".to_owned());
15147 config
15148 .validate()
15149 .expect("control role should satisfy the scheduler requirement");
15150 }
15151
15152 #[test]
15153 fn postgres_jobs_with_shards_requires_control_database() {
15154 let mut config = AutumnConfig::default();
15155 config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
15156 config.jobs.backend = "postgres".to_owned();
15157
15158 assert!(config.validate().is_err());
15159
15160 config.database.url = Some("postgres://control.example/app".to_owned());
15161 config
15162 .validate()
15163 .expect("legacy url should satisfy the jobs requirement");
15164 }
15165
15166 #[test]
15167 fn shard_local_jobs_need_postgres_jobs_and_shards() {
15168 let mut config = AutumnConfig::default();
15169 config.jobs.postgres.shard_local = true;
15170 let Err(ConfigError::Validation(message)) = config.validate() else {
15171 panic!("shard_local without postgres jobs and shards should fail validation");
15172 };
15173 assert!(message.contains("shard_local"), "{message}");
15174
15175 config.jobs.backend = "postgres".to_owned();
15176 assert!(config.validate().is_err(), "shard_local without shards");
15177
15178 config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
15179 config.database.primary_url = Some("postgres://control.example/app".to_owned());
15180 config
15181 .validate()
15182 .expect("postgres jobs with shards satisfy shard_local");
15183 }
15184
15185 #[test]
15186 fn database_validate_url_edge_cases() {
15187 let invalid_urls = vec![
15188 "POSTGRES://localhost/db",
15189 "postgres:/localhost/db",
15190 "postgres:localhost/db",
15191 "http://postgres",
15192 " postgres://localhost/db",
15193 "",
15194 ];
15195
15196 for invalid_url in invalid_urls {
15197 let config = DatabaseConfig {
15198 url: Some(invalid_url.to_string()),
15199 ..Default::default()
15200 };
15201 assert!(
15202 config.validate().is_err(),
15203 "URL should be invalid: {invalid_url}"
15204 );
15205 }
15206 }
15207
15208 #[test]
15209 fn autumn_config_validate_ok() {
15210 let config = AutumnConfig::default();
15211 assert!(config.validate().is_ok());
15212 }
15213
15214 #[test]
15215 fn autumn_config_validate_no_longer_errors_on_invalid_session_backend() {
15216 let mut config = AutumnConfig::default();
15224 config.session.backend = crate::session::SessionBackend::Redis;
15225 config.session.redis.url = None;
15226
15227 config
15228 .validate()
15229 .expect("validate() must accept invalid session backend so custom store can override");
15230 }
15231
15232 #[test]
15233 fn autumn_config_validate_database_err() {
15234 let mut config = AutumnConfig::default();
15235 config.database.url = Some("mysql://localhost/test".to_string());
15236 assert!(config.validate().is_err());
15237 }
15238
15239 #[test]
15240 fn log_defaults() {
15241 let config = LogConfig::default();
15242 assert_eq!(config.level, "info");
15243 assert_eq!(config.format, LogFormat::Auto);
15244 }
15245
15246 #[test]
15247 fn telemetry_defaults() {
15248 let config = TelemetryConfig::default();
15249 assert!(!config.enabled);
15250 assert_eq!(config.service_name, "autumn-app");
15251 assert!(config.service_namespace.is_none());
15252 assert_eq!(config.service_version, "unknown");
15253 assert_eq!(config.environment, "development");
15254 assert!(config.otlp_endpoint.is_none());
15255 assert_eq!(config.protocol, TelemetryProtocol::Grpc);
15256 assert!(!config.strict);
15257 }
15258
15259 #[test]
15260 fn health_defaults() {
15261 let config = HealthConfig::default();
15262 assert_eq!(config.path, "/health");
15263 assert_eq!(config.live_path, "/live");
15264 assert_eq!(config.ready_path, "/ready");
15265 assert_eq!(config.startup_path, "/startup");
15266 assert!(!config.detailed);
15267 }
15268
15269 #[test]
15270 fn top_level_default_populates_all_sections() {
15271 let config = AutumnConfig::default();
15272 assert_eq!(config.server.port, 3000);
15273 assert!(config.database.url.is_none());
15274 assert_eq!(config.log.level, "info");
15275 assert_eq!(config.health.path, "/health");
15276 }
15277
15278 #[test]
15279 fn deserialize_empty_object_uses_all_defaults() {
15280 let config: AutumnConfig = serde_json::from_str("{}").expect("empty object should parse");
15281 assert_eq!(config.server.port, 3000);
15282 assert_eq!(config.server.host, "127.0.0.1");
15283 assert_eq!(config.server.shutdown_timeout_secs, 30);
15284 assert!(config.database.url.is_none());
15285 assert_eq!(config.database.pool_size, 10);
15286 assert_eq!(config.database.connect_timeout_secs, 5);
15287 assert!(!config.database.auto_migrate_in_production);
15288 assert_eq!(config.log.level, "info");
15289 assert_eq!(config.log.format, LogFormat::Auto);
15290 assert_eq!(config.health.path, "/health");
15291 }
15292
15293 #[test]
15294 fn deserialize_partial_config_merges_with_defaults() {
15295 let json = r#"{"server": {"port": 8080}}"#;
15296 let config: AutumnConfig = serde_json::from_str(json).expect("partial config should parse");
15297 assert_eq!(config.server.port, 8080);
15298 assert_eq!(config.server.host, "127.0.0.1");
15299 assert_eq!(config.database.pool_size, 10);
15300 assert_eq!(config.log.level, "info");
15301 }
15302
15303 #[test]
15304 fn log_format_variants_deserialize() {
15305 let auto: LogFormat = serde_json::from_str(r#""Auto""#).expect("Auto");
15306 let pretty: LogFormat = serde_json::from_str(r#""Pretty""#).expect("Pretty");
15307 let json: LogFormat = serde_json::from_str(r#""Json""#).expect("Json");
15308 assert_eq!(auto, LogFormat::Auto);
15309 assert_eq!(pretty, LogFormat::Pretty);
15310 assert_eq!(json, LogFormat::Json);
15311 }
15312
15313 #[test]
15316 fn load_missing_file_returns_defaults() {
15317 let config = AutumnConfig::load_from(Path::new("this_file_does_not_exist.toml")).unwrap();
15318 assert_eq!(config.server.port, 3000);
15319 assert!(config.database.url.is_none());
15320 }
15321
15322 #[test]
15323 fn load_valid_full_config() {
15324 let dir = tempfile::tempdir().unwrap();
15325 let path = dir.path().join("autumn.toml");
15326 std::fs::write(
15327 &path,
15328 r#"
15329[server]
15330port = 8080
15331host = "0.0.0.0"
15332shutdown_timeout_secs = 60
15333
15334[database]
15335url = "postgres://user:pass@db:5432/myapp"
15336pool_size = 20
15337connect_timeout_secs = 10
15338auto_migrate_in_production = true
15339
15340[log]
15341level = "debug"
15342format = "Json"
15343
15344[health]
15345path = "/healthz"
15346"#,
15347 )
15348 .unwrap();
15349
15350 let config = AutumnConfig::load_from(&path).unwrap();
15351 assert_eq!(config.server.port, 8080);
15352 assert_eq!(config.server.host, "0.0.0.0");
15353 assert_eq!(config.server.shutdown_timeout_secs, 60);
15354 assert_eq!(
15355 config.database.url.as_deref(),
15356 Some("postgres://user:pass@db:5432/myapp")
15357 );
15358 assert_eq!(config.database.pool_size, 20);
15359 assert_eq!(config.database.connect_timeout_secs, 10);
15360 assert!(config.database.auto_migrate_in_production);
15361 assert_eq!(config.log.level, "debug");
15362 assert_eq!(config.log.format, LogFormat::Json);
15363 assert_eq!(config.health.path, "/healthz");
15364 }
15365
15366 #[test]
15367 fn load_partial_config_merges_with_defaults() {
15368 let dir = tempfile::tempdir().unwrap();
15369 let path = dir.path().join("autumn.toml");
15370 std::fs::write(&path, "[server]\nport = 9090\n").unwrap();
15371
15372 let config = AutumnConfig::load_from(&path).unwrap();
15373 assert_eq!(config.server.port, 9090);
15374 assert_eq!(config.server.host, "127.0.0.1");
15375 assert_eq!(config.database.pool_size, 10);
15376 assert_eq!(config.log.level, "info");
15377 }
15378
15379 #[test]
15380 fn access_log_defaults_on_with_probe_and_asset_exclusions() {
15381 let log = LogConfig::default();
15382 assert!(log.access_log);
15383 assert_eq!(
15384 log.access_log_exclude,
15385 vec![
15386 "/health",
15387 "/live",
15388 "/ready",
15389 "/startup",
15390 "/actuator",
15391 "/static"
15392 ]
15393 );
15394 }
15395
15396 #[test]
15397 fn env_override_access_log_off() {
15398 let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG", "false");
15399 let mut config = AutumnConfig::default();
15400 config.apply_env_overrides_with_env(&env);
15401 assert!(!config.log.access_log);
15402 }
15403
15404 #[test]
15405 fn env_override_access_log_exclude_csv() {
15406 let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG_EXCLUDE", "/internal, /probes");
15407 let mut config = AutumnConfig::default();
15408 config.apply_env_overrides_with_env(&env);
15409 assert_eq!(config.log.access_log_exclude, vec!["/internal", "/probes"]);
15410 }
15411
15412 #[test]
15413 fn access_log_is_configurable_from_toml() {
15414 let dir = tempfile::tempdir().unwrap();
15415 let path = dir.path().join("autumn.toml");
15416 std::fs::write(
15417 &path,
15418 "[log]\naccess_log = false\naccess_log_exclude = [\"/internal\"]\n",
15419 )
15420 .unwrap();
15421
15422 let config = AutumnConfig::load_from(&path).unwrap();
15423 assert!(!config.log.access_log);
15424 assert_eq!(config.log.access_log_exclude, vec!["/internal"]);
15425 }
15426
15427 #[test]
15428 fn load_invalid_toml_returns_error() {
15429 let dir = tempfile::tempdir().unwrap();
15430 let path = dir.path().join("autumn.toml");
15431 std::fs::write(&path, "not valid [[[toml").unwrap();
15432
15433 let result = AutumnConfig::load_from(&path);
15434 assert!(result.is_err());
15435 let err = result.unwrap_err();
15436 assert!(err.to_string().contains("invalid autumn.toml"));
15437 }
15438
15439 #[test]
15440 fn load_empty_file_returns_defaults() {
15441 let dir = tempfile::tempdir().unwrap();
15442 let path = dir.path().join("autumn.toml");
15443 std::fs::write(&path, "").unwrap();
15444
15445 let config = AutumnConfig::load_from(&path).unwrap();
15446 assert_eq!(config.server.port, 3000);
15447 }
15448
15449 #[test]
15452 fn env_override_database_url() {
15453 let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://override:5432/test");
15454 let mut config = AutumnConfig::default();
15455 config.apply_env_overrides_with_env(&env);
15456 assert_eq!(
15457 config.database.url.as_deref(),
15458 Some("postgres://override:5432/test")
15459 );
15460 }
15461
15462 #[test]
15463 fn metrics_section_defaults_to_the_shipped_caps() {
15464 let config = AutumnConfig::default();
15467 assert_eq!(config.metrics.limits(), crate::metrics::Limits::default());
15468
15469 let parsed: AutumnConfig =
15470 toml::from_str("[server]\nport = 3000\n").expect("config without [metrics] parses");
15471 assert_eq!(parsed.metrics.limits(), crate::metrics::Limits::default());
15472 }
15473
15474 #[test]
15475 fn metrics_section_parses_a_raised_cap() {
15476 let parsed: AutumnConfig =
15477 toml::from_str("[metrics]\nmax_series_per_metric = 500\nmax_labels_per_series = 12\n")
15478 .expect("[metrics] parses");
15479
15480 assert_eq!(parsed.metrics.max_series_per_metric, 500);
15481 assert_eq!(parsed.metrics.max_labels_per_series, 12);
15482 assert_eq!(
15483 parsed.metrics.max_instruments,
15484 crate::metrics::DEFAULT_MAX_INSTRUMENTS,
15485 "an unset key keeps its default rather than zeroing"
15486 );
15487 parsed.metrics.validate().expect("in-range caps validate");
15488 }
15489
15490 #[test]
15491 fn env_override_metrics_caps() {
15492 let env = MockEnv::new()
15493 .with("AUTUMN_METRICS__MAX_SERIES_PER_METRIC", "500")
15494 .with("AUTUMN_METRICS__MAX_INSTRUMENTS", "512")
15495 .with("AUTUMN_METRICS__MAX_LABELS_PER_SERIES", "12");
15496 let mut config = AutumnConfig::default();
15497 config.apply_env_overrides_with_env(&env);
15498
15499 assert_eq!(
15500 config.metrics.limits(),
15501 crate::metrics::Limits {
15502 max_series_per_metric: 500,
15503 max_instruments: 512,
15504 max_labels_per_series: 12,
15505 }
15506 );
15507 }
15508
15509 #[test]
15510 fn validate_rejects_an_out_of_range_metrics_cap() {
15511 let mut config = AutumnConfig::default();
15514 config.metrics.max_series_per_metric = 0;
15515
15516 let error = config
15517 .validate()
15518 .expect_err("a zero cap must not validate")
15519 .to_string();
15520 assert!(
15521 error.contains("max_series_per_metric"),
15522 "the error must name the key at fault; got {error}"
15523 );
15524
15525 let mut config = AutumnConfig::default();
15526 config.metrics.max_labels_per_series = crate::metrics::MAX_LABELS_PER_SERIES_CEILING + 1;
15527 assert!(
15528 config.validate().is_err(),
15529 "a cap above its ceiling must not validate"
15530 );
15531
15532 AutumnConfig::default()
15533 .validate()
15534 .expect("the default [metrics] section validates");
15535 }
15536
15537 #[test]
15538 fn env_override_actuator_prometheus_disables() {
15539 let env = MockEnv::new().with("AUTUMN_ACTUATOR__PROMETHEUS", "false");
15542 let mut config = AutumnConfig::default();
15543 assert!(config.actuator.prometheus, "default should be enabled");
15544 config.apply_env_overrides_with_env(&env);
15545 assert!(
15546 !config.actuator.prometheus,
15547 "AUTUMN_ACTUATOR__PROMETHEUS=false must disable the scrape endpoint"
15548 );
15549 }
15550
15551 #[test]
15552 fn env_override_actuator_sensitive() {
15553 let env = MockEnv::new().with("AUTUMN_ACTUATOR__SENSITIVE", "true");
15554 let mut config = AutumnConfig::default();
15555 assert!(!config.actuator.sensitive);
15556 config.apply_env_overrides_with_env(&env);
15557 assert!(config.actuator.sensitive);
15558 }
15559
15560 #[test]
15561 fn env_override_upload_reject_on_content_type_mismatch() {
15562 let env = MockEnv::new().with(
15563 "AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
15564 "true",
15565 );
15566 let mut config = AutumnConfig::default();
15567 assert!(!config.security.upload.reject_on_content_type_mismatch);
15568 config.apply_env_overrides_with_env(&env);
15569 assert!(config.security.upload.reject_on_content_type_mismatch);
15570 }
15571
15572 #[test]
15573 fn env_override_actuator_prefix() {
15574 let env = MockEnv::new().with("AUTUMN_ACTUATOR__PREFIX", "/ops");
15575 let mut config = AutumnConfig::default();
15576 config.apply_env_overrides_with_env(&env);
15577 assert_eq!(config.actuator.prefix, "/ops");
15578 }
15579
15580 #[test]
15581 fn env_override_database_url_wins_over_file_primary_url() {
15582 let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://env.example/app");
15583 let mut config = AutumnConfig::default();
15584 config.database.primary_url = Some("postgres://file.example/app".to_owned());
15585
15586 config.apply_env_overrides_with_env(&env);
15587
15588 assert_eq!(
15589 config.database.effective_primary_url(),
15590 Some("postgres://env.example/app")
15591 );
15592 assert!(config.database.primary_url.is_none());
15593 }
15594
15595 #[test]
15596 fn env_override_database_primary_url_wins_over_legacy_database_url() {
15597 let env = MockEnv::new()
15598 .with("AUTUMN_DATABASE__URL", "postgres://legacy.env/app")
15599 .with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app");
15600 let mut config = AutumnConfig::default();
15601 config.database.primary_url = Some("postgres://file.example/app".to_owned());
15602
15603 config.apply_env_overrides_with_env(&env);
15604
15605 assert_eq!(
15606 config.database.effective_primary_url(),
15607 Some("postgres://primary.env/app")
15608 );
15609 assert_eq!(
15610 config.database.url.as_deref(),
15611 Some("postgres://legacy.env/app")
15612 );
15613 }
15614
15615 #[test]
15616 fn env_override_pool_size() {
15617 let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "25");
15618 let mut config = AutumnConfig::default();
15619 config.apply_env_overrides_with_env(&env);
15620 assert_eq!(config.database.pool_size, 25);
15621 }
15622
15623 #[cfg(feature = "reporting")]
15624 #[test]
15625 fn env_override_reporting() {
15626 let env = MockEnv::new()
15627 .with("AUTUMN_REPORTING__ENABLED", "false")
15628 .with("AUTUMN_REPORTING__SAMPLE_RATE", "0.1");
15629 let mut config = AutumnConfig::default();
15630 assert!(config.reporting.enabled);
15631 assert!((config.reporting.sample_rate - 1.0).abs() < f64::EPSILON);
15632 config.apply_env_overrides_with_env(&env);
15633 assert!(!config.reporting.enabled);
15634 assert!((config.reporting.sample_rate - 0.1).abs() < f64::EPSILON);
15635 }
15636
15637 #[cfg(feature = "reporting")]
15638 #[test]
15639 fn failure_capture_defaults_are_off() {
15640 let config = AutumnConfig::default();
15641 assert!(
15642 !config.failure_capture.enabled,
15643 "capture writes production request data to disk; it must be opt-in"
15644 );
15645 assert_eq!(config.failure_capture.dir, "tmp/autumn-capsules");
15646 assert_eq!(config.failure_capture.max_body_bytes, 65_536);
15647 assert_eq!(config.failure_capture.max_capsule_bytes, 1_048_576);
15648 assert_eq!(config.failure_capture.max_capsules, 50);
15649 }
15650
15651 #[cfg(feature = "reporting")]
15652 #[test]
15653 fn failure_capture_env_overrides_apply() {
15654 let env = MockEnv::new()
15655 .with("AUTUMN_FAILURE_CAPTURE__ENABLED", "true")
15656 .with("AUTUMN_FAILURE_CAPTURE__DIR", "/var/tmp/capsules")
15657 .with("AUTUMN_FAILURE_CAPTURE__MAX_BODY_BYTES", "1024")
15658 .with("AUTUMN_FAILURE_CAPTURE__MAX_CAPSULE_BYTES", "2048")
15659 .with("AUTUMN_FAILURE_CAPTURE__MAX_CAPSULES", "5");
15660 let mut config = AutumnConfig::default();
15661 config.apply_env_overrides_with_env(&env);
15662
15663 assert!(config.failure_capture.enabled);
15664 assert_eq!(config.failure_capture.dir, "/var/tmp/capsules");
15665 assert_eq!(config.failure_capture.max_body_bytes, 1024);
15666 assert_eq!(config.failure_capture.max_capsule_bytes, 2048);
15667 assert_eq!(config.failure_capture.max_capsules, 5);
15668 }
15669
15670 #[cfg(feature = "reporting")]
15679 #[test]
15680 fn failure_capture_child_keys_are_strictly_validated() {
15681 let leaves = AutumnConfig::schema_leaf_paths();
15682 for key in [
15683 "failure_capture.enabled",
15684 "failure_capture.dir",
15685 "failure_capture.max_body_bytes",
15686 "failure_capture.max_capsule_bytes",
15687 "failure_capture.max_capsules",
15688 ] {
15689 assert!(
15690 leaves.contains(key),
15691 "{key} must be a schema leaf so strict validation descends into \
15692 [failure_capture]; if this fails, the section was likely moved below \
15693 `database` in AutumnConfig"
15694 );
15695 }
15696
15697 let schema = AutumnConfig::get_schema_keys();
15698 let errors = AutumnConfig::validate_toml("[failure_capture]\nenabledd = true\n", &schema);
15699 assert!(
15700 errors
15701 .iter()
15702 .any(|(path, _)| path == "failure_capture.enabledd"),
15703 "a bogus [failure_capture] child key must be rejected by strict validation, \
15704 got: {errors:?}"
15705 );
15706 }
15707
15708 #[test]
15713 fn cluster_enabled_requires_secret() {
15714 let mut config = AutumnConfig::default();
15715 config.cluster.enabled = true;
15716 config.cluster.secret = None;
15717
15718 let result = config.cluster.validate();
15719 assert!(
15720 result.is_err(),
15721 "[cluster] enabled = true with no secret must fail validation, got {result:?}"
15722 );
15723 let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15724 assert!(
15725 message.contains("secret"),
15726 "the error must name the missing key so an operator can fix it; got {message:?}"
15727 );
15728
15729 assert!(
15731 AutumnConfig::default().cluster.validate().is_ok(),
15732 "a disabled [cluster] section must validate with no secret at all"
15733 );
15734 }
15735
15736 #[test]
15737 fn cluster_rejects_short_secret() {
15738 let mut config = AutumnConfig::default();
15739 config.cluster.enabled = true;
15740 config.cluster.secret = Some(secrecy::SecretString::from("too-short".to_owned()));
15741
15742 let result = config.cluster.validate();
15743 assert!(
15744 result.is_err(),
15745 "a secret shorter than {MIN_CLUSTER_SECRET_LEN} bytes must be refused, got {result:?}"
15746 );
15747
15748 config.cluster.secret = Some(secrecy::SecretString::from(
15750 "a-perfectly-adequate-cluster-secret".to_owned(),
15751 ));
15752 assert!(
15753 config.cluster.validate().is_ok(),
15754 "a secret of at least {MIN_CLUSTER_SECRET_LEN} bytes must be accepted"
15755 );
15756 }
15757
15758 #[test]
15761 fn cluster_rejects_suspicion_below_3x_push_interval() {
15762 let mut config = AutumnConfig::default();
15763 config.cluster.enabled = true;
15764 config.cluster.secret = Some(secrecy::SecretString::from(
15765 "a-perfectly-adequate-cluster-secret".to_owned(),
15766 ));
15767 config.cluster.push_interval_ms = 500;
15768 config.cluster.suspicion_timeout_ms = 1_000;
15769
15770 let result = config.cluster.validate();
15771 assert!(
15772 result.is_err(),
15773 "suspicion_timeout_ms must be at least {MIN_CLUSTER_SUSPICION_MULTIPLE}x \
15774 push_interval_ms, got {result:?}"
15775 );
15776
15777 config.cluster.suspicion_timeout_ms = 1_500;
15778 assert!(
15779 config.cluster.validate().is_ok(),
15780 "exactly {MIN_CLUSTER_SUSPICION_MULTIPLE}x the push interval must be accepted"
15781 );
15782
15783 config.cluster.push_interval_ms = 1;
15785 config.cluster.suspicion_timeout_ms = 3;
15786 assert!(
15787 config.cluster.validate().is_err(),
15788 "push_interval_ms below {MIN_CLUSTER_PUSH_INTERVAL_MS}ms must be refused"
15789 );
15790 }
15791
15792 #[test]
15797 fn cluster_rejects_node_id_with_cell_separator() {
15798 let mut config = AutumnConfig::default();
15799 config.cluster.enabled = true;
15800 config.cluster.secret = Some(secrecy::SecretString::from(
15801 "a-perfectly-adequate-cluster-secret".to_owned(),
15802 ));
15803
15804 config.cluster.node_id = Some("node#1".to_owned());
15805 assert!(
15806 config.cluster.validate().is_err(),
15807 "a node_id containing '#' must be refused: it is the counter cell-key separator"
15808 );
15809
15810 config.cluster.node_id = Some("node-1".to_owned());
15811 config.cluster.cluster_name = "orch#ard".to_owned();
15812 assert!(
15813 config.cluster.validate().is_err(),
15814 "a cluster_name containing '#' must be refused for the same reason"
15815 );
15816
15817 config.cluster.cluster_name = "orchard".to_owned();
15818 assert!(
15819 config.cluster.validate().is_ok(),
15820 "…and separator-free names must be accepted, or the rule is vacuous"
15821 );
15822 }
15823
15824 #[test]
15829 fn cluster_rejects_ephemeral_port_where_a_peer_must_dial() {
15830 let mut config = AutumnConfig::default();
15831 config.cluster.enabled = true;
15832 config.cluster.secret = Some(secrecy::SecretString::from(
15833 "a-perfectly-adequate-cluster-secret".to_owned(),
15834 ));
15835
15836 config.cluster.bind_addr = "127.0.0.1:0".to_owned();
15838 assert!(
15839 config.cluster.validate().is_ok(),
15840 "bind_addr with port 0 is the ephemeral-bind spelling and must be accepted"
15841 );
15842
15843 config.cluster.advertise_addr = Some("10.0.0.4:0".to_owned());
15844 let result = config.cluster.validate();
15845 assert!(
15846 result.is_err(),
15847 "an explicit advertise_addr on port 0 must be refused: peers dial it verbatim, \
15848 got {result:?}"
15849 );
15850 let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15851 assert!(
15852 message.contains("cluster.advertise_addr"),
15853 "the error must name the offending key; got {message:?}"
15854 );
15855
15856 config.cluster.advertise_addr = Some("10.0.0.4:7946".to_owned());
15857 assert!(
15858 config.cluster.validate().is_ok(),
15859 "…and a real advertised port must be accepted, or the rule is vacuous"
15860 );
15861
15862 config.cluster.seed_peers = vec!["10.0.0.5:7946".to_owned(), "10.0.0.6:0".to_owned()];
15863 let result = config.cluster.validate();
15864 assert!(
15865 result.is_err(),
15866 "a seed peer on port 0 is equally undialable and must be refused, got {result:?}"
15867 );
15868 let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15869 assert!(
15870 message.contains("cluster.seed_peers[1]"),
15871 "the error must name which seed is wrong; got {message:?}"
15872 );
15873
15874 config.cluster.seed_peers = vec!["10.0.0.5:7946".to_owned()];
15875 assert!(
15876 config.cluster.validate().is_ok(),
15877 "…and dialable seeds must be accepted"
15878 );
15879 }
15880
15881 #[test]
15886 fn cluster_rejects_a_wildcard_advertised_address() {
15887 let mut config = AutumnConfig::default();
15888 config.cluster.enabled = true;
15889 config.cluster.secret = Some(secrecy::SecretString::from(
15890 "a-perfectly-adequate-cluster-secret".to_owned(),
15891 ));
15892
15893 config.cluster.bind_addr = "0.0.0.0:7946".to_owned();
15896 let result = config.cluster.validate();
15897 assert!(
15898 result.is_err(),
15899 "binding a wildcard with no advertise_addr must be refused: the node \
15900 would gossip 0.0.0.0 as its dial address, got {result:?}"
15901 );
15902 let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15903 assert!(
15904 message.contains("cluster.bind_addr"),
15905 "the error must name the key the operator has to fix; got {message:?}"
15906 );
15907
15908 config.cluster.advertise_addr = Some("0.0.0.0:7946".to_owned());
15910 let result = config.cluster.validate();
15911 assert!(
15912 result.is_err(),
15913 "an explicit wildcard advertise_addr must be refused too, got {result:?}"
15914 );
15915 let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15916 assert!(
15917 message.contains("cluster.advertise_addr"),
15918 "…and must blame advertise_addr, not the (legal) wildcard bind; \
15919 got {message:?}"
15920 );
15921
15922 config.cluster.advertise_addr = Some("10.0.1.7:7946".to_owned());
15926 assert!(
15927 config.cluster.validate().is_ok(),
15928 "0.0.0.0 bind + explicit advertise_addr is the documented spelling \
15929 and must be accepted"
15930 );
15931
15932 config.cluster.enabled = false;
15934 config.cluster.advertise_addr = None;
15935 assert!(
15936 config.cluster.validate().is_ok(),
15937 "a disabled section advertises nothing, so the wildcard rule must \
15938 not fire on it"
15939 );
15940 }
15941
15942 #[test]
15945 fn cluster_rejects_over_long_idents() {
15946 let mut config = AutumnConfig::default();
15947 config.cluster.enabled = true;
15948 config.cluster.secret = Some(secrecy::SecretString::from(
15949 "a-perfectly-adequate-cluster-secret".to_owned(),
15950 ));
15951
15952 let at_cap = "n".repeat(MAX_CLUSTER_IDENT_LEN);
15953 let over_cap = "n".repeat(MAX_CLUSTER_IDENT_LEN.saturating_add(1));
15954
15955 config.cluster.node_id = Some(at_cap.clone());
15956 assert!(
15957 config.cluster.validate().is_ok(),
15958 "exactly {MAX_CLUSTER_IDENT_LEN} bytes must be accepted, or the cap \
15959 is off by one"
15960 );
15961
15962 config.cluster.node_id = Some(over_cap.clone());
15963 let result = config.cluster.validate();
15964 assert!(
15965 result.is_err(),
15966 "a node_id one byte over {MAX_CLUSTER_IDENT_LEN} must be refused, \
15967 got {result:?}"
15968 );
15969 let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15970 assert!(
15971 message.contains("cluster.node_id"),
15972 "the error must name the offending key; got {message:?}"
15973 );
15974
15975 config.cluster.node_id = Some("node-a".to_owned());
15976 config.cluster.cluster_name = over_cap;
15977 let result = config.cluster.validate();
15978 assert!(
15979 result.is_err(),
15980 "an over-long cluster_name must be refused for the same reason, \
15981 got {result:?}"
15982 );
15983 let message = result.err().map(|e| e.to_string()).unwrap_or_default();
15984 assert!(
15985 message.contains("cluster.cluster_name"),
15986 "…naming cluster_name this time; got {message:?}"
15987 );
15988
15989 config.cluster.cluster_name = at_cap;
15990 assert!(
15991 config.cluster.validate().is_ok(),
15992 "…and a cluster_name at the cap must be accepted, or the rule is vacuous"
15993 );
15994 }
15995
15996 #[test]
15997 fn shadow_env_overrides_apply() {
15998 let env = MockEnv::new()
15999 .with("AUTUMN_SHADOW__ENABLED", "true")
16000 .with("AUTUMN_SHADOW__TARGET", "http://127.0.0.1:9091")
16001 .with("AUTUMN_SHADOW__SAMPLE_RATE", "0.25")
16002 .with("AUTUMN_SHADOW__ROUTES", "/api/*, /status")
16003 .with("AUTUMN_SHADOW__TIMEOUT_MS", "750")
16004 .with("AUTUMN_SHADOW__MAX_IN_FLIGHT", "16")
16005 .with("AUTUMN_SHADOW__MAX_BODY_BYTES", "4096")
16006 .with("AUTUMN_SHADOW__MAX_RECORDS", "12")
16007 .with("AUTUMN_SHADOW__MAX_SAMPLE_BYTES", "256");
16008 let mut config = AutumnConfig::default();
16009 config.apply_env_overrides_with_env(&env);
16010
16011 assert!(config.shadow.enabled);
16012 assert_eq!(
16013 config.shadow.target.as_deref(),
16014 Some("http://127.0.0.1:9091")
16015 );
16016 assert!((config.shadow.sample_rate - 0.25).abs() < f64::EPSILON);
16017 assert_eq!(config.shadow.routes, vec!["/api/*", "/status"]);
16018 assert_eq!(config.shadow.timeout_ms, 750);
16019 assert_eq!(config.shadow.max_in_flight, 16);
16020 assert_eq!(config.shadow.max_body_bytes, 4096);
16021 assert_eq!(config.shadow.max_records, 12);
16022 assert_eq!(config.shadow.max_sample_bytes, 256);
16023 assert!(config.validate().is_ok());
16024 }
16025
16026 #[test]
16027 fn an_empty_shadow_routes_override_does_not_disable_mirroring() {
16028 let env = MockEnv::new().with("AUTUMN_SHADOW__ROUTES", "");
16034 let mut config = AutumnConfig::default();
16035 config.apply_env_overrides_with_env(&env);
16036 assert!(config.shadow.routes.is_empty());
16037 }
16038
16039 #[test]
16040 fn top_level_validation_rejects_an_unusable_shadow_section() {
16041 let mut config = AutumnConfig::default();
16044 config.shadow.enabled = true;
16045 let error = config.validate().expect_err("must reject");
16046 assert!(
16047 error.to_string().contains("shadow.target"),
16048 "the boot failure must name the missing key, got: {error}"
16049 );
16050 }
16051
16052 #[test]
16053 fn cluster_env_overrides_apply() {
16054 let env = MockEnv::new()
16055 .with("AUTUMN_CLUSTER__ENABLED", "true")
16056 .with("AUTUMN_CLUSTER__SECRET", "a-shared-cluster-secret-value")
16057 .with("AUTUMN_CLUSTER__CLUSTER_NAME", "orchard")
16058 .with("AUTUMN_CLUSTER__BIND_ADDR", "0.0.0.0:7946")
16059 .with("AUTUMN_CLUSTER__ADVERTISE_ADDR", "10.0.0.4:7946")
16060 .with("AUTUMN_CLUSTER__SEED_PEERS", "10.0.0.5:7946, 10.0.0.6:7946")
16061 .with("AUTUMN_CLUSTER__NODE_ID", "node-a")
16062 .with("AUTUMN_CLUSTER__PUSH_INTERVAL_MS", "250")
16063 .with("AUTUMN_CLUSTER__SUSPICION_TIMEOUT_MS", "1500");
16064 let mut config = AutumnConfig::default();
16065 config.apply_env_overrides_with_env(&env);
16066
16067 assert!(config.cluster.enabled);
16068 assert_eq!(
16069 config
16070 .cluster
16071 .secret
16072 .as_ref()
16073 .map(|s| secrecy::ExposeSecret::expose_secret(s).to_owned()),
16074 Some("a-shared-cluster-secret-value".to_owned())
16075 );
16076 assert_eq!(config.cluster.cluster_name, "orchard");
16077 assert_eq!(config.cluster.bind_addr, "0.0.0.0:7946");
16078 assert_eq!(
16079 config.cluster.advertise_addr.as_deref(),
16080 Some("10.0.0.4:7946")
16081 );
16082 assert_eq!(
16083 config.cluster.seed_peers,
16084 vec!["10.0.0.5:7946".to_owned(), "10.0.0.6:7946".to_owned()],
16085 "AUTUMN_CLUSTER__SEED_PEERS must split on commas and trim"
16086 );
16087 assert_eq!(config.cluster.node_id.as_deref(), Some("node-a"));
16088 assert_eq!(config.cluster.push_interval_ms, 250);
16089 assert_eq!(config.cluster.suspicion_timeout_ms, 1_500);
16090 }
16091
16092 #[test]
16093 fn env_override_connect_timeout() {
16094 let env = MockEnv::new().with("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "15");
16095 let mut config = AutumnConfig::default();
16096 config.apply_env_overrides_with_env(&env);
16097 assert_eq!(config.database.connect_timeout_secs, 15);
16098 }
16099
16100 #[test]
16101 fn env_override_read_your_writes() {
16102 let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "request");
16103 let mut config = AutumnConfig::default();
16104 config.apply_env_overrides_with_env(&env);
16105 assert_eq!(config.database.read_your_writes, ReadYourWrites::Request);
16106 }
16107
16108 #[test]
16109 fn env_override_read_your_writes_session() {
16110 let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "session");
16111 let mut config = AutumnConfig::default();
16112 config.apply_env_overrides_with_env(&env);
16113 assert_eq!(config.database.read_your_writes, ReadYourWrites::Session);
16114 }
16115
16116 #[test]
16117 fn env_override_pin_after_write_secs() {
16118 let env = MockEnv::new().with("AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS", "10");
16119 let mut config = AutumnConfig::default();
16120 config.apply_env_overrides_with_env(&env);
16121 assert_eq!(config.database.pin_after_write_secs, 10);
16122 }
16123
16124 #[test]
16125 fn replica_max_lag_and_pooler_warning_config() {
16126 let config = AutumnConfig::default();
16127 assert_eq!(config.database.replica_max_lag_ms, None);
16128 assert!(config.database.warn_on_pooler);
16129
16130 let parsed: AutumnConfig =
16131 toml::from_str("[database]\nreplica_max_lag_ms = 2500\nwarn_on_pooler = false\n")
16132 .unwrap();
16133 assert_eq!(parsed.database.replica_max_lag_ms, Some(2500));
16134 assert!(!parsed.database.warn_on_pooler);
16135
16136 let env = MockEnv::new()
16137 .with("AUTUMN_DATABASE__REPLICA_MAX_LAG_MS", "750")
16138 .with("AUTUMN_DATABASE__WARN_ON_POOLER", "false");
16139 let mut config = AutumnConfig::default();
16140 config.apply_env_overrides_with_env(&env);
16141 assert_eq!(config.database.replica_max_lag_ms, Some(750));
16142 assert!(!config.database.warn_on_pooler);
16143 }
16144
16145 #[test]
16146 fn env_override_invalid_pool_size_ignored() {
16147 let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "not_a_number");
16148 let mut config = AutumnConfig::default();
16149 config.apply_env_overrides_with_env(&env);
16150 assert_eq!(config.database.pool_size, 10);
16151 }
16152
16153 #[test]
16156 fn env_override_magic_link_ttl_minutes() {
16157 let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45");
16158 let mut config = AutumnConfig::default();
16159 config.apply_env_overrides_with_env(&env);
16160 assert_eq!(config.auth.magic_link.ttl_minutes, 45);
16161 }
16162
16163 #[test]
16164 fn env_override_magic_link_email_cooldown_secs() {
16165 let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
16166 let mut config = AutumnConfig::default();
16167 config.apply_env_overrides_with_env(&env);
16168 assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
16169 }
16170
16171 #[test]
16172 fn env_override_magic_link_overrides_toml_value() {
16173 let env = MockEnv::new()
16174 .with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45")
16175 .with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
16176 let mut config = AutumnConfig::default();
16177 config.auth.magic_link.ttl_minutes = 30;
16179 config.auth.magic_link.email_cooldown_secs = 200;
16180 config.apply_env_overrides_with_env(&env);
16181 assert_eq!(config.auth.magic_link.ttl_minutes, 45);
16182 assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
16183 }
16184
16185 #[test]
16186 fn env_unset_leaves_magic_link_toml_value_intact() {
16187 let env = MockEnv::new();
16188 let mut config = AutumnConfig::default();
16189 config.auth.magic_link.ttl_minutes = 30;
16191 config.auth.magic_link.email_cooldown_secs = 200;
16192 config.apply_env_overrides_with_env(&env);
16193 assert_eq!(config.auth.magic_link.ttl_minutes, 30);
16194 assert_eq!(config.auth.magic_link.email_cooldown_secs, 200);
16195 }
16196
16197 #[test]
16198 fn env_override_invalid_magic_link_ttl_minutes_ignored() {
16199 let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "not_a_number");
16200 let mut config = AutumnConfig::default();
16201 config.auth.magic_link.ttl_minutes = 30;
16203 config.apply_env_overrides_with_env(&env);
16204 assert_eq!(config.auth.magic_link.ttl_minutes, 30);
16205 }
16206
16207 #[test]
16210 fn startup_wait_secs_default_is_zero() {
16211 assert_eq!(DatabaseConfig::default().startup_wait_secs, 0);
16212 }
16213
16214 #[test]
16215 fn env_override_startup_wait_secs() {
16216 let env = MockEnv::new().with("AUTUMN_DATABASE__STARTUP_WAIT_SECS", "60");
16217 let mut config = AutumnConfig::default();
16218 config.apply_env_overrides_with_env(&env);
16219 assert_eq!(config.database.startup_wait_secs, 60);
16220 }
16221
16222 #[test]
16223 fn startup_wait_secs_parses_from_toml() {
16224 let config: AutumnConfig = toml::from_str("[database]\nstartup_wait_secs = 30").unwrap();
16225 assert_eq!(config.database.startup_wait_secs, 30);
16226 }
16227
16228 #[cfg(feature = "storage")]
16229 #[test]
16230 fn env_override_storage_fields() {
16231 let env = MockEnv::new()
16232 .with("AUTUMN_STORAGE__BACKEND", "s3")
16233 .with("AUTUMN_STORAGE__DEFAULT_PROVIDER", "media")
16234 .with("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true")
16235 .with("AUTUMN_STORAGE__LOCAL__ROOT", "var/blobs")
16236 .with("AUTUMN_STORAGE__LOCAL__MOUNT_PATH", "/files")
16237 .with("AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS", "42")
16238 .with("AUTUMN_STORAGE__LOCAL__SIGNING_KEY", "secret")
16239 .with("AUTUMN_STORAGE__S3__BUCKET", "uploads")
16240 .with("AUTUMN_STORAGE__S3__REGION", "us-east-1")
16241 .with("AUTUMN_STORAGE__S3__ENDPOINT", "https://s3.example.test")
16242 .with(
16243 "AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
16244 "https://cdn.example.test",
16245 )
16246 .with("AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV", "AWS_ACCESS_KEY_ID")
16247 .with(
16248 "AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
16249 "AWS_SECRET_ACCESS_KEY",
16250 )
16251 .with("AUTUMN_STORAGE__S3__FORCE_PATH_STYLE", "true")
16252 .with("AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS", "99")
16253 .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES", "5242880")
16254 .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH", "2000")
16255 .with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT", "1500");
16256 let mut config = AutumnConfig::default();
16257
16258 config.apply_env_overrides_with_env(&env);
16259
16260 assert_eq!(config.storage.backend, crate::storage::StorageBackend::S3);
16261 assert_eq!(config.storage.default_provider, "media");
16262 assert!(config.storage.allow_local_in_production);
16263 assert_eq!(config.storage.local.root, PathBuf::from("var/blobs"));
16264 assert_eq!(config.storage.local.mount_path, "/files");
16265 assert_eq!(config.storage.local.default_url_expiry_secs, 42);
16266 assert_eq!(config.storage.local.signing_key.as_deref(), Some("secret"));
16267 assert_eq!(config.storage.s3.bucket.as_deref(), Some("uploads"));
16268 assert_eq!(config.storage.s3.region.as_deref(), Some("us-east-1"));
16269 assert_eq!(
16270 config.storage.s3.endpoint.as_deref(),
16271 Some("https://s3.example.test")
16272 );
16273 assert_eq!(
16274 config.storage.s3.public_base_url.as_deref(),
16275 Some("https://cdn.example.test")
16276 );
16277 assert_eq!(
16278 config.storage.s3.access_key_id_env.as_deref(),
16279 Some("AWS_ACCESS_KEY_ID")
16280 );
16281 assert_eq!(
16282 config.storage.s3.secret_access_key_env.as_deref(),
16283 Some("AWS_SECRET_ACCESS_KEY")
16284 );
16285 assert!(config.storage.s3.force_path_style);
16286 assert_eq!(config.storage.s3.default_url_expiry_secs, 99);
16287 assert_eq!(config.storage.variants.max_source_bytes, 5_242_880);
16288 assert_eq!(config.storage.variants.max_source_width, 2_000);
16289 assert_eq!(config.storage.variants.max_source_height, 1_500);
16290 }
16291
16292 #[test]
16295 fn replication_parses_from_toml() {
16296 let toml = r#"
16297 [replication]
16298 enabled = true
16299 rpo_secs = 5
16300 sync_interval_secs = 2
16301 snapshot_interval_secs = 900
16302 max_wal_bytes = 8388608
16303 retention_hours = 48
16304 verify_interval_secs = 3600
16305 prefix = "db"
16306 allow_shared_bucket = true
16307
16308 [replication.s3]
16309 bucket = "myapp-replicas"
16310 region = "auto"
16311 endpoint = "https://acct.r2.cloudflarestorage.com"
16312 access_key_id_env = "AUTUMN_REPLICA_ACCESS_KEY_ID"
16313 secret_access_key_env = "AUTUMN_REPLICA_SECRET_ACCESS_KEY"
16314 force_path_style = true
16315 "#;
16316 let config: AutumnConfig = toml::from_str(toml).expect("parse");
16317 let replication = *config.replication.expect("section present");
16318 assert!(replication.enabled);
16319 assert_eq!(replication.rpo_secs, 5);
16320 assert_eq!(
16321 replication.sync_interval(),
16322 std::time::Duration::from_secs(2)
16323 );
16324 assert_eq!(replication.snapshot_interval_secs, 900);
16325 assert_eq!(replication.max_wal_bytes, 8_388_608);
16326 assert_eq!(replication.retention_hours, 48);
16327 assert_eq!(
16328 replication.verify_interval(),
16329 Some(std::time::Duration::from_secs(3600))
16330 );
16331 assert_eq!(replication.prefix.as_deref(), Some("db"));
16332 assert!(replication.allow_shared_bucket);
16333 let s3 = replication.s3.expect("s3 destination");
16334 assert_eq!(s3.bucket.as_deref(), Some("myapp-replicas"));
16335 assert_eq!(s3.region.as_deref(), Some("auto"));
16336 assert_eq!(
16337 s3.endpoint.as_deref(),
16338 Some("https://acct.r2.cloudflarestorage.com")
16339 );
16340 assert_eq!(
16341 s3.access_key_id_env.as_deref(),
16342 Some("AUTUMN_REPLICA_ACCESS_KEY_ID")
16343 );
16344 assert_eq!(
16345 s3.secret_access_key_env.as_deref(),
16346 Some("AUTUMN_REPLICA_SECRET_ACCESS_KEY")
16347 );
16348 assert!(s3.force_path_style);
16349 }
16350
16351 #[test]
16352 fn replication_defaults_to_absent_and_carries_the_documented_rpo() {
16353 let config = AutumnConfig::default();
16354 assert!(config.replication.is_none());
16355
16356 let defaults = ReplicationConfig::default();
16357 assert!(!defaults.enabled, "replication is opt-in");
16358 assert_eq!(defaults.rpo_secs, 10, "AC #2: at most 10s of data loss");
16359 assert_eq!(defaults.retention_hours, 168);
16360 assert_eq!(defaults.max_wal_bytes, 16 * 1024 * 1024);
16361 assert!(
16362 !defaults.allow_shared_bucket,
16363 "#1619's distinct-bucket posture"
16364 );
16365 assert!(defaults.validation_errors().is_empty(), "disabled is valid");
16366 }
16367
16368 #[test]
16369 fn replication_validation_names_every_problem() {
16370 let no_destination = ReplicationConfig {
16371 enabled: true,
16372 ..ReplicationConfig::default()
16373 };
16374 assert!(
16375 no_destination
16376 .validation_errors()
16377 .iter()
16378 .any(|e| e.contains("no destination is configured")),
16379 "{:?}",
16380 no_destination.validation_errors()
16381 );
16382
16383 let both = ReplicationConfig {
16384 enabled: true,
16385 path: Some("/mnt/replica".to_owned()),
16386 s3: Some(ReplicationS3Config {
16387 bucket: Some("b".to_owned()),
16388 ..ReplicationS3Config::default()
16389 }),
16390 ..ReplicationConfig::default()
16391 };
16392 assert!(
16393 both.validation_errors()
16394 .iter()
16395 .any(|e| e.contains("pick exactly one destination"))
16396 );
16397
16398 let no_credentials = ReplicationConfig {
16399 enabled: true,
16400 s3: Some(ReplicationS3Config {
16401 bucket: Some("b".to_owned()),
16402 ..ReplicationS3Config::default()
16403 }),
16404 rpo_secs: 0,
16405 retention_hours: 0,
16406 ..ReplicationConfig::default()
16407 };
16408 let errors = no_credentials.validation_errors();
16409 assert!(
16410 errors.iter().any(|e| e.contains("access_key_id_env")),
16411 "{errors:?}"
16412 );
16413 assert!(errors.iter().any(|e| e.contains("rpo_secs")), "{errors:?}");
16414 assert!(
16415 errors.iter().any(|e| e.contains("retention_hours")),
16416 "{errors:?}"
16417 );
16418 }
16419
16420 #[test]
16421 fn env_override_replication_fields() {
16422 let env = MockEnv::new()
16423 .with("AUTUMN_REPLICATION__ENABLED", "true")
16424 .with("AUTUMN_REPLICATION__RPO_SECS", "20")
16425 .with("AUTUMN_REPLICATION__SYNC_INTERVAL_SECS", "7")
16426 .with("AUTUMN_REPLICATION__SNAPSHOT_INTERVAL_SECS", "600")
16427 .with("AUTUMN_REPLICATION__MAX_WAL_BYTES", "1048576")
16428 .with("AUTUMN_REPLICATION__RETENTION_HOURS", "12")
16429 .with("AUTUMN_REPLICATION__VERIFY_INTERVAL_SECS", "0")
16430 .with("AUTUMN_REPLICATION__PREFIX", "replicas")
16431 .with("AUTUMN_REPLICATION__ALLOW_SHARED_BUCKET", "true")
16432 .with("AUTUMN_REPLICATION__S3__BUCKET", "from-env")
16433 .with("AUTUMN_REPLICATION__S3__REGION", "auto")
16434 .with(
16435 "AUTUMN_REPLICATION__S3__ENDPOINT",
16436 "https://minio.test:9000",
16437 )
16438 .with("AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV", "KEY")
16439 .with("AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV", "SECRET")
16440 .with("AUTUMN_REPLICATION__S3__FORCE_PATH_STYLE", "true");
16441 let mut config = AutumnConfig::default();
16442 config.apply_env_overrides_with_env(&env);
16443
16444 let replication = *config.replication.expect("materialized from env alone");
16445 assert!(replication.enabled);
16446 assert_eq!(replication.rpo_secs, 20);
16447 assert_eq!(replication.sync_interval_secs, Some(7));
16448 assert_eq!(replication.snapshot_interval_secs, 600);
16449 assert_eq!(replication.max_wal_bytes, 1_048_576);
16450 assert_eq!(replication.retention_hours, 12);
16451 assert_eq!(replication.verify_interval(), None);
16452 assert_eq!(replication.prefix.as_deref(), Some("replicas"));
16453 assert!(replication.allow_shared_bucket);
16454 let s3 = replication.s3.expect("s3 from env");
16455 assert_eq!(s3.bucket.as_deref(), Some("from-env"));
16456 assert_eq!(s3.endpoint.as_deref(), Some("https://minio.test:9000"));
16457 assert!(s3.force_path_style);
16458 }
16459
16460 #[test]
16461 fn replication_env_does_not_materialize_a_section_from_optional_keys_alone() {
16462 let env = MockEnv::new()
16465 .with("AUTUMN_REPLICATION__S3__REGION", "auto")
16466 .with("AUTUMN_REPLICATION__PREFIX", "db")
16467 .with("AUTUMN_REPLICATION__ENABLED", "false");
16468 let mut config = AutumnConfig::default();
16469 config.apply_env_overrides_with_env(&env);
16470 assert!(config.replication.is_none());
16471 }
16472
16473 #[test]
16474 fn a_path_destination_from_env_never_grows_an_empty_s3_section() {
16475 let env = MockEnv::new()
16476 .with("AUTUMN_REPLICATION__ENABLED", "true")
16477 .with("AUTUMN_REPLICATION__PATH", "/mnt/replica");
16478 let mut config = AutumnConfig::default();
16479 config.apply_env_overrides_with_env(&env);
16480 let replication = *config.replication.expect("materialized");
16481 assert_eq!(replication.path.as_deref(), Some("/mnt/replica"));
16482 assert!(replication.s3.is_none(), "no S3 key was set");
16483 assert!(replication.validation_errors().is_empty());
16484 }
16485
16486 #[test]
16487 fn replication_env_overlays_a_toml_section() {
16488 let mut config: AutumnConfig = toml::from_str(
16489 "[replication]\nenabled = false\nretention_hours = 24\npath = \"/from-toml\"",
16490 )
16491 .expect("parse");
16492 let env = MockEnv::new().with("AUTUMN_REPLICATION__ENABLED", "true");
16493 config.apply_env_overrides_with_env(&env);
16494 let replication = *config.replication.expect("section");
16495 assert!(replication.enabled, "env flips the TOML toggle");
16496 assert_eq!(replication.retention_hours, 24, "TOML value survives");
16497 assert_eq!(replication.path.as_deref(), Some("/from-toml"));
16498 }
16499
16500 #[test]
16501 fn backup_offsite_parses_from_toml() {
16502 let toml = r#"
16503 [backup.offsite]
16504 prefix = "db"
16505 keep = 5
16506 auto_upload = true
16507 allow_shared_bucket = true
16508
16509 [backup.offsite.s3]
16510 bucket = "offsite-backups"
16511 region = "auto"
16512 endpoint = "https://minio.example.test"
16513 access_key_id_env = "OFFSITE_KEY_ID"
16514 secret_access_key_env = "OFFSITE_SECRET"
16515 force_path_style = true
16516 "#;
16517 let config: AutumnConfig = toml::from_str(toml).unwrap();
16518 let offsite = config.backup.offsite.expect("offsite section present");
16519 assert_eq!(offsite.prefix.as_deref(), Some("db"));
16520 assert_eq!(offsite.keep, Some(5));
16521 assert!(offsite.auto_upload);
16522 assert!(offsite.allow_shared_bucket);
16523 assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite-backups"));
16524 assert_eq!(offsite.s3.region.as_deref(), Some("auto"));
16525 assert_eq!(
16526 offsite.s3.endpoint.as_deref(),
16527 Some("https://minio.example.test")
16528 );
16529 assert_eq!(
16531 offsite.s3.access_key_id_env.as_deref(),
16532 Some("OFFSITE_KEY_ID")
16533 );
16534 assert_eq!(
16535 offsite.s3.secret_access_key_env.as_deref(),
16536 Some("OFFSITE_SECRET")
16537 );
16538 assert!(offsite.s3.force_path_style);
16539 }
16540
16541 #[test]
16542 fn backup_offsite_defaults_to_none() {
16543 let config = AutumnConfig::default();
16544 assert!(config.backup.offsite.is_none());
16545 }
16546
16547 #[test]
16548 fn env_override_backup_offsite_fields() {
16549 let env = MockEnv::new()
16550 .with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
16551 .with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
16552 .with(
16553 "AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
16554 "https://s3.offsite.test",
16555 )
16556 .with("AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV", "OFF_KEY")
16557 .with(
16558 "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
16559 "OFF_SECRET",
16560 )
16561 .with("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true")
16562 .with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
16563 .with("AUTUMN_BACKUP__OFFSITE__KEEP", "3")
16564 .with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true")
16565 .with("AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET", "true");
16566 let mut config = AutumnConfig::default();
16567
16568 config.apply_env_overrides_with_env(&env);
16569
16570 let offsite = config.backup.offsite.expect("materialized from env");
16571 assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
16572 assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
16573 assert_eq!(
16574 offsite.s3.endpoint.as_deref(),
16575 Some("https://s3.offsite.test")
16576 );
16577 assert_eq!(offsite.s3.access_key_id_env.as_deref(), Some("OFF_KEY"));
16578 assert_eq!(
16579 offsite.s3.secret_access_key_env.as_deref(),
16580 Some("OFF_SECRET")
16581 );
16582 assert!(offsite.s3.force_path_style);
16583 assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
16584 assert_eq!(offsite.keep, Some(3));
16585 assert!(offsite.auto_upload);
16586 assert!(offsite.allow_shared_bucket);
16587 }
16588
16589 #[test]
16590 fn env_override_backup_offsite_absent_stays_none() {
16591 let env = MockEnv::new();
16593 let mut config = AutumnConfig::default();
16594 config.apply_env_overrides_with_env(&env);
16595 assert!(config.backup.offsite.is_none());
16596 }
16597
16598 #[test]
16599 fn env_override_backup_offsite_lone_opt_out_toggle_stays_none() {
16600 for key in [
16604 "AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
16605 "AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
16606 ] {
16607 let env = MockEnv::new().with(key, "false");
16608 let mut config = AutumnConfig::default();
16609 config.apply_env_overrides_with_env(&env);
16610 assert!(
16611 config.backup.offsite.is_none(),
16612 "{key}=false must not materialize an offsite section",
16613 );
16614 }
16615 }
16616
16617 #[test]
16618 fn env_override_backup_offsite_truthy_auto_upload_materializes() {
16619 let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true");
16622 let mut config = AutumnConfig::default();
16623 config.apply_env_overrides_with_env(&env);
16624 let offsite = config
16625 .backup
16626 .offsite
16627 .expect("auto_upload=true materializes offsite");
16628 assert!(offsite.auto_upload);
16629 }
16630
16631 #[test]
16632 fn env_override_backup_offsite_destination_key_materializes() {
16633 let env = MockEnv::new()
16636 .with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
16637 .with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "false");
16638 let mut config = AutumnConfig::default();
16639 config.apply_env_overrides_with_env(&env);
16640 let offsite = config
16641 .backup
16642 .offsite
16643 .expect("a bucket key materializes offsite");
16644 assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
16645 assert!(!offsite.auto_upload);
16646 }
16647
16648 #[test]
16649 fn env_override_backup_offsite_lone_optional_key_stays_none() {
16650 for (key, val) in [
16656 ("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-east-1"),
16657 ("AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT", "https://s3.test"),
16658 ("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true"),
16659 ("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly"),
16660 ("AUTUMN_BACKUP__OFFSITE__KEEP", "3"),
16661 ] {
16662 let env = MockEnv::new().with(key, val);
16663 let mut config = AutumnConfig::default();
16664 config.apply_env_overrides_with_env(&env);
16665 assert!(
16666 config.backup.offsite.is_none(),
16667 "{key} is optional-only and must not materialize an offsite section",
16668 );
16669 }
16670 }
16671
16672 #[test]
16673 fn env_override_backup_offsite_credential_key_materializes() {
16674 for key in [
16677 "AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
16678 "AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
16679 ] {
16680 let env = MockEnv::new().with(key, "SOME_ENV_NAME");
16681 let mut config = AutumnConfig::default();
16682 config.apply_env_overrides_with_env(&env);
16683 assert!(
16684 config.backup.offsite.is_some(),
16685 "{key} is a required credential signal and must materialize offsite",
16686 );
16687 }
16688 }
16689
16690 #[test]
16691 fn env_override_backup_offsite_bucket_only_materializes() {
16692 let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite");
16694 let mut config = AutumnConfig::default();
16695 config.apply_env_overrides_with_env(&env);
16696 let offsite = config
16697 .backup
16698 .offsite
16699 .expect("a bucket key materializes offsite");
16700 assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
16701 }
16702
16703 #[test]
16704 fn env_override_backup_offsite_region_only_applied_when_materialized() {
16705 let env = MockEnv::new()
16708 .with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
16709 .with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
16710 .with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
16711 .with("AUTUMN_BACKUP__OFFSITE__KEEP", "5");
16712 let mut config = AutumnConfig::default();
16713 config.apply_env_overrides_with_env(&env);
16714 let offsite = config.backup.offsite.expect("bucket materializes offsite");
16715 assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
16716 assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
16717 assert_eq!(offsite.keep, Some(5));
16718 }
16719
16720 #[test]
16721 fn env_override_database_auto_migrate_in_production() {
16722 let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION", "true");
16723 let mut config = AutumnConfig::default();
16724 config.apply_env_overrides_with_env(&env);
16725 assert!(config.database.auto_migrate_in_production);
16726 }
16727
16728 #[test]
16729 fn database_auto_migrate_defaults_to_none() {
16730 assert_eq!(DatabaseConfig::default().auto_migrate, None);
16733 }
16734
16735 #[test]
16736 fn env_override_database_auto_migrate() {
16737 let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE", "true");
16740 let mut config = AutumnConfig::default();
16741 assert_eq!(config.database.auto_migrate, None);
16742 config.apply_env_overrides_with_env(&env);
16743 assert_eq!(config.database.auto_migrate, Some(true));
16744
16745 let env_false = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE", "false");
16746 let mut config_false = AutumnConfig::default();
16747 config_false.apply_env_overrides_with_env(&env_false);
16748 assert_eq!(config_false.database.auto_migrate, Some(false));
16749 }
16750
16751 #[test]
16752 fn env_override_jobs_fields() {
16753 let env = MockEnv::new()
16754 .with("AUTUMN_JOBS__BACKEND", "redis")
16755 .with("AUTUMN_JOBS__WORKERS", "8")
16756 .with("AUTUMN_JOBS__MAX_ATTEMPTS", "12")
16757 .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "750")
16758 .with("AUTUMN_JOBS__MAX_BACKOFF_MS", "90000")
16759 .with("AUTUMN_JOBS__REDIS__URL", "redis://jobs:6379/2")
16760 .with("AUTUMN_JOBS__REDIS__KEY_PREFIX", "myapp:jobs")
16761 .with("AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS", "45000");
16762 let mut config = AutumnConfig::default();
16763 config.apply_env_overrides_with_env(&env);
16764
16765 assert_eq!(config.jobs.backend, "redis");
16766 assert_eq!(config.jobs.workers, 8);
16767 assert_eq!(config.jobs.max_attempts, 12);
16768 assert_eq!(config.jobs.initial_backoff_ms, 750);
16769 assert_eq!(config.jobs.max_backoff_ms, 90_000);
16770 assert_eq!(
16771 config.jobs.redis.url.as_deref(),
16772 Some("redis://jobs:6379/2")
16773 );
16774 assert_eq!(config.jobs.redis.key_prefix, "myapp:jobs");
16775 assert_eq!(config.jobs.redis.visibility_timeout_ms, 45_000);
16776 }
16777
16778 #[test]
16781 fn jobs_default_timeout_ms_defaults_to_zero_and_overrides() {
16782 assert_eq!(AutumnConfig::default().jobs.default_timeout_ms, 0);
16783
16784 let config: AutumnConfig = toml::from_str("[jobs]\ndefault_timeout_ms = 30000\n").unwrap();
16785 assert_eq!(config.jobs.default_timeout_ms, 30_000);
16786
16787 let env = MockEnv::new().with("AUTUMN_JOBS__DEFAULT_TIMEOUT_MS", "1500");
16788 let mut config = AutumnConfig::default();
16789 config.apply_env_overrides_with_env(&env);
16790 assert_eq!(config.jobs.default_timeout_ms, 1_500);
16791 }
16792
16793 #[test]
16796 fn retention_config_defaults_leave_every_dataset_unset() {
16797 let config = AutumnConfig::default();
16801 assert!(
16802 config.retention.job_history.is_none(),
16803 "job_history must default to unset"
16804 );
16805 assert!(config.retention.job_tracking.is_none());
16806 assert!(config.retention.idempotency.is_none());
16807 assert!(config.retention.experiment_assignments.is_none());
16808 assert!(config.retention.webhook_replay.is_none());
16809 assert!(config.retention.sessions.is_none());
16810 assert!(config.retention.audit_archives.is_none());
16811 assert!(
16812 !config.retention.any_window_configured(),
16813 "a default config must register no retention sweep at all"
16814 );
16815 assert_eq!(
16816 config.retention.sweep_interval, "1h",
16817 "the sweep cadence has a documented default even when no window is set"
16818 );
16819 }
16820
16821 #[test]
16822 fn retention_toml_deserializes_every_dataset_window() {
16823 let config: AutumnConfig = toml::from_str(
16824 r#"
16825 [retention]
16826 sweep_interval = "30m"
16827 job_history = "90d"
16828 job_tracking = "7d"
16829 idempotency = "2d"
16830 experiment_assignments = "365d"
16831 webhook_replay = "3d"
16832 sessions = "30d"
16833 audit_archives = "400d"
16834 "#,
16835 )
16836 .unwrap();
16837
16838 assert_eq!(config.retention.sweep_interval, "30m");
16839 assert_eq!(config.retention.job_history.as_deref(), Some("90d"));
16840 assert_eq!(config.retention.job_tracking.as_deref(), Some("7d"));
16841 assert_eq!(config.retention.idempotency.as_deref(), Some("2d"));
16842 assert_eq!(
16843 config.retention.experiment_assignments.as_deref(),
16844 Some("365d")
16845 );
16846 assert_eq!(config.retention.webhook_replay.as_deref(), Some("3d"));
16847 assert_eq!(config.retention.sessions.as_deref(), Some("30d"));
16848 assert_eq!(config.retention.audit_archives.as_deref(), Some("400d"));
16849 assert!(config.retention.any_window_configured());
16850 config.validate().expect("a well-formed section validates");
16851 }
16852
16853 #[test]
16854 fn env_override_retention_windows() {
16855 let env = MockEnv::new()
16856 .with("AUTUMN_RETENTION__SWEEP_INTERVAL", "15m")
16857 .with("AUTUMN_RETENTION__JOB_HISTORY", "45d")
16858 .with("AUTUMN_RETENTION__JOB_TRACKING", "2d")
16859 .with("AUTUMN_RETENTION__IDEMPOTENCY", "12h")
16860 .with("AUTUMN_RETENTION__EXPERIMENT_ASSIGNMENTS", "180d")
16861 .with("AUTUMN_RETENTION__WEBHOOK_REPLAY", "1d")
16862 .with("AUTUMN_RETENTION__SESSIONS", "14d")
16863 .with("AUTUMN_RETENTION__AUDIT_ARCHIVES", "365d")
16864 .with("AUTUMN_RETENTION__CUSTOM_DOMAINS", "30d");
16865 let mut config = AutumnConfig::default();
16866 config.apply_env_overrides_with_env(&env);
16867
16868 assert_eq!(config.retention.sweep_interval, "15m");
16869 assert_eq!(config.retention.job_history.as_deref(), Some("45d"));
16870 assert_eq!(config.retention.job_tracking.as_deref(), Some("2d"));
16871 assert_eq!(config.retention.idempotency.as_deref(), Some("12h"));
16872 assert_eq!(
16873 config.retention.experiment_assignments.as_deref(),
16874 Some("180d")
16875 );
16876 assert_eq!(config.retention.webhook_replay.as_deref(), Some("1d"));
16877 assert_eq!(config.retention.sessions.as_deref(), Some("14d"));
16878 assert_eq!(config.retention.audit_archives.as_deref(), Some("365d"));
16879 assert_eq!(config.retention.custom_domains.as_deref(), Some("30d"));
16880 }
16881
16882 #[test]
16883 fn env_override_retention_empty_value_clears_a_window() {
16884 let env = MockEnv::new().with("AUTUMN_RETENTION__JOB_HISTORY", "");
16888 let mut config = AutumnConfig::default();
16889 config.retention.job_history = Some("90d".to_owned());
16890 config.apply_env_overrides_with_env(&env);
16891
16892 assert!(config.retention.job_history.is_none());
16893 }
16894
16895 #[test]
16896 fn retention_validate_rejects_an_unparseable_window() {
16897 let mut config = AutumnConfig::default();
16898 config.retention.job_history = Some("ninety days".to_owned());
16899 let error = config
16900 .validate()
16901 .expect_err("an unparseable duration must fail boot, not be ignored");
16902 let message = error.to_string();
16903 assert!(
16904 message.contains("retention.job_history"),
16905 "the error must name the offending key: {message}"
16906 );
16907 }
16908
16909 #[test]
16910 fn retention_validate_rejects_a_zero_window() {
16911 let mut config = AutumnConfig::default();
16914 config.retention.sessions = Some("0s".to_owned());
16915 let error = config.validate().expect_err("a zero window must fail boot");
16916 assert!(error.to_string().contains("retention.sessions"), "{error}");
16917 }
16918
16919 #[test]
16920 fn retention_validate_rejects_an_unparseable_sweep_interval() {
16921 let mut config = AutumnConfig::default();
16922 config.retention.job_history = Some("30d".to_owned());
16923 config.retention.sweep_interval = "whenever".to_owned();
16924 let error = config.validate().expect_err("bad cadence must fail boot");
16925 assert!(
16926 error.to_string().contains("retention.sweep_interval"),
16927 "{error}"
16928 );
16929 }
16930
16931 #[test]
16932 fn retention_validate_accepts_a_default_config() {
16933 AutumnConfig::default()
16936 .validate()
16937 .expect("an unconfigured [retention] section must validate");
16938 }
16939
16940 #[test]
16941 fn job_tracking_config_defaults_ttl_86400_and_route_enabled() {
16942 let config = AutumnConfig::default();
16943 assert_eq!(config.jobs.tracking.ttl_secs, 86_400);
16944 assert!(config.jobs.tracking.route_enabled);
16945 }
16946
16947 #[test]
16948 fn env_override_jobs_tracking_fields() {
16949 let env = MockEnv::new()
16950 .with("AUTUMN_JOBS__TRACKING__TTL_SECS", "3600")
16951 .with("AUTUMN_JOBS__TRACKING__ROUTE_ENABLED", "false");
16952 let mut config = AutumnConfig::default();
16953 config.apply_env_overrides_with_env(&env);
16954
16955 assert_eq!(config.jobs.tracking.ttl_secs, 3_600);
16956 assert!(!config.jobs.tracking.route_enabled);
16957 }
16958
16959 #[test]
16960 fn jobs_toml_deserializes_tracking_fields() {
16961 let config: AutumnConfig = toml::from_str(
16962 r"
16963 [jobs.tracking]
16964 ttl_secs = 7200
16965 route_enabled = false
16966 ",
16967 )
16968 .unwrap();
16969
16970 assert_eq!(config.jobs.tracking.ttl_secs, 7_200);
16971 assert!(!config.jobs.tracking.route_enabled);
16972 }
16973
16974 #[test]
16975 fn jobs_toml_deserializes_redis_visibility_timeout() {
16976 let config: AutumnConfig = toml::from_str(
16977 r#"
16978 [jobs]
16979 backend = "redis"
16980
16981 [jobs.redis]
16982 url = "redis://localhost:6379/5"
16983 key_prefix = "demo:jobs"
16984 visibility_timeout_ms = 15000
16985 "#,
16986 )
16987 .unwrap();
16988
16989 assert_eq!(config.jobs.backend, "redis");
16990 assert_eq!(
16991 config.jobs.redis.url.as_deref(),
16992 Some("redis://localhost:6379/5")
16993 );
16994 assert_eq!(config.jobs.redis.key_prefix, "demo:jobs");
16995 assert_eq!(config.jobs.redis.visibility_timeout_ms, 15_000);
16996 }
16997
16998 #[test]
17000 fn jobs_redis_dead_letter_limit_defaults_parses_and_overrides() {
17001 let defaults = JobRedisConfig::default();
17003 assert_eq!(defaults.dead_letter_limit, 10_000);
17004 assert_eq!(AutumnConfig::default().jobs.redis.dead_letter_limit, 10_000);
17005
17006 let config: AutumnConfig =
17007 toml::from_str("[jobs.redis]\ndead_letter_limit = 0\n").expect("dead_letter_limit");
17008 assert_eq!(config.jobs.redis.dead_letter_limit, 0);
17009
17010 let config: AutumnConfig = toml::from_str("[jobs.redis]\nkey_prefix = \"x\"\n").unwrap();
17011 assert_eq!(config.jobs.redis.dead_letter_limit, 10_000);
17012
17013 let env = MockEnv::new().with("AUTUMN_JOBS__REDIS__DEAD_LETTER_LIMIT", "250");
17014 let mut config = AutumnConfig::default();
17015 config.apply_env_overrides_with_env(&env);
17016 assert_eq!(config.jobs.redis.dead_letter_limit, 250);
17017 }
17018
17019 #[test]
17020 fn outbox_validate_rejects_zero_values() {
17021 assert!(OutboxConfig::default().validate().is_ok());
17022 for config in [
17023 OutboxConfig {
17024 lease_ms: 0,
17025 ..OutboxConfig::default()
17026 },
17027 OutboxConfig {
17028 max_attempts: 0,
17029 ..OutboxConfig::default()
17030 },
17031 OutboxConfig {
17032 batch_size: 0,
17033 ..OutboxConfig::default()
17034 },
17035 ] {
17036 let error = config.validate().unwrap_err().to_string();
17037 assert!(error.contains("must be greater than zero"), "{error}");
17038 }
17039 let error = OutboxConfig {
17040 initial_backoff_ms: 300_000,
17041 max_backoff_ms: 1_000,
17042 ..OutboxConfig::default()
17043 }
17044 .validate()
17045 .unwrap_err()
17046 .to_string();
17047 assert!(error.contains("max_backoff_ms"), "{error}");
17048 }
17049
17050 #[test]
17051 fn outbox_defaults_parse_and_env_overrides() {
17052 let defaults = AutumnConfig::default().outbox;
17053 assert!(!defaults.enabled, "the outbox is off by default");
17054 assert_eq!(defaults, OutboxConfig::default());
17055 assert_eq!(defaults.max_attempts, 10);
17056
17057 let config: AutumnConfig =
17058 toml::from_str("[outbox]\nenabled = true\nlease_ms = 5000\n").expect("outbox");
17059 assert!(config.outbox.enabled);
17060 assert_eq!(config.outbox.lease_ms, 5_000);
17061 assert_eq!(config.outbox.batch_size, 100, "unset keys keep defaults");
17062
17063 let env = MockEnv::new()
17064 .with("AUTUMN_OUTBOX__ENABLED", "true")
17065 .with("AUTUMN_OUTBOX__POLL_INTERVAL_MS", "50")
17066 .with("AUTUMN_OUTBOX__BATCH_SIZE", "7")
17067 .with("AUTUMN_OUTBOX__MAX_ATTEMPTS", "3")
17068 .with("AUTUMN_OUTBOX__INITIAL_BACKOFF_MS", "20")
17069 .with("AUTUMN_OUTBOX__MAX_BACKOFF_MS", "200")
17070 .with("AUTUMN_OUTBOX__LEASE_MS", "900")
17071 .with("AUTUMN_OUTBOX__RETENTION_MS", "1000");
17072 let mut config = AutumnConfig::default();
17073 config.apply_env_overrides_with_env(&env);
17074 assert_eq!(
17075 config.outbox,
17076 OutboxConfig {
17077 enabled: true,
17078 poll_interval_ms: 50,
17079 batch_size: 7,
17080 max_attempts: 3,
17081 initial_backoff_ms: 20,
17082 max_backoff_ms: 200,
17083 lease_ms: 900,
17084 retention_ms: 1_000,
17085 }
17086 );
17087 }
17088
17089 #[test]
17090 fn job_queues_defaults_to_single_default_queue() {
17091 let config = AutumnConfig::default();
17092 assert!(config.jobs.queues.strict);
17093 assert_eq!(config.jobs.queues.queues.len(), 1);
17094 assert_eq!(config.jobs.queues.queues[0].name, "default");
17095 assert_eq!(config.jobs.queues.queues[0].weight, 1);
17096 }
17097
17098 #[test]
17099 fn jobs_without_queues_key_keeps_single_default_queue() {
17100 let config: AutumnConfig = toml::from_str(
17101 r#"
17102 [jobs]
17103 backend = "local"
17104 workers = 4
17105 "#,
17106 )
17107 .unwrap();
17108 assert!(config.jobs.queues.strict);
17109 assert_eq!(config.jobs.queues.queues.len(), 1);
17110 assert_eq!(config.jobs.queues.queues[0].name, "default");
17111 }
17112
17113 #[test]
17114 fn job_queues_parse_ordered_list_as_strict_priority() {
17115 let config: AutumnConfig = toml::from_str(
17116 r#"
17117 [jobs]
17118 backend = "local"
17119 queues = ["critical", "default", "low"]
17120 "#,
17121 )
17122 .unwrap();
17123 assert!(config.jobs.queues.strict, "list form is strict priority");
17124 let names: Vec<&str> = config
17125 .jobs
17126 .queues
17127 .queues
17128 .iter()
17129 .map(|q| q.name.as_str())
17130 .collect();
17131 assert_eq!(names, ["critical", "default", "low"]);
17132 assert!(config.jobs.queues.queues.iter().all(|q| q.weight == 1));
17133 }
17134
17135 #[test]
17136 fn job_queues_parse_weight_map_as_weighted() {
17137 let config: AutumnConfig = toml::from_str(
17138 r#"
17139 [jobs]
17140 backend = "local"
17141
17142 [jobs.queues]
17143 critical = 4
17144 default = 2
17145 low = 1
17146 "#,
17147 )
17148 .unwrap();
17149 assert!(!config.jobs.queues.strict, "map form is weighted");
17150 let weight = |name: &str| {
17151 config
17152 .jobs
17153 .queues
17154 .queues
17155 .iter()
17156 .find(|q| q.name == name)
17157 .map(|q| q.weight)
17158 };
17159 assert_eq!(weight("critical"), Some(4));
17160 assert_eq!(weight("default"), Some(2));
17161 assert_eq!(weight("low"), Some(1));
17162 }
17163
17164 #[test]
17165 fn job_queues_strict_list_rejects_duplicate_names() {
17166 let err = toml::from_str::<AutumnConfig>(
17167 r#"
17168 [jobs]
17169 queues = ["critical", "default", "critical"]
17170 "#,
17171 )
17172 .unwrap_err()
17173 .to_string();
17174 assert!(
17175 err.contains("duplicate queue name") && err.contains("critical"),
17176 "unexpected error: {err}"
17177 );
17178 }
17179
17180 #[test]
17181 fn job_queues_table_form_parses_caps_and_reserved_slots() {
17182 let config: AutumnConfig = toml::from_str(
17185 r"
17186 [jobs.queues]
17187 critical = { weight = 3, reserved = 2 }
17188 bulk = { weight = 1, concurrency = 4 }
17189 default = 2
17190 ",
17191 )
17192 .unwrap();
17193 assert!(!config.jobs.queues.strict, "table form is weighted");
17194 let find = |name: &str| {
17195 config
17196 .jobs
17197 .queues
17198 .queues
17199 .iter()
17200 .find(|q| q.name == name)
17201 .cloned()
17202 .unwrap()
17203 };
17204 let critical = find("critical");
17205 assert_eq!(critical.weight, 3);
17206 assert_eq!(critical.reserved, Some(2));
17207 assert_eq!(critical.concurrency, None);
17208 let bulk = find("bulk");
17209 assert_eq!(bulk.weight, 1);
17210 assert_eq!(bulk.concurrency, Some(4));
17211 assert_eq!(bulk.reserved, None);
17212 let default = find("default");
17214 assert_eq!(default.weight, 2);
17215 assert_eq!(default.concurrency, None);
17216 assert_eq!(default.reserved, None);
17217 }
17218
17219 #[test]
17220 fn job_queues_table_form_defaults_weight_to_one() {
17221 let config: AutumnConfig = toml::from_str(
17222 r"
17223 [jobs.queues]
17224 critical = { reserved = 1 }
17225 ",
17226 )
17227 .unwrap();
17228 let critical = &config.jobs.queues.queues[0];
17229 assert_eq!(critical.weight, 1, "omitted weight defaults to 1");
17230 assert_eq!(critical.reserved, Some(1));
17231 }
17232
17233 #[test]
17234 fn job_queues_table_form_rejects_zero_weight() {
17235 let err = toml::from_str::<AutumnConfig>(
17236 r"
17237 [jobs.queues]
17238 critical = { weight = 0, reserved = 1 }
17239 ",
17240 )
17241 .unwrap_err()
17242 .to_string();
17243 assert!(
17244 err.contains("weight must be at least 1") && err.contains("critical"),
17245 "unexpected error: {err}"
17246 );
17247 }
17248
17249 #[test]
17250 fn job_queues_table_form_rejects_unknown_setting() {
17251 let err = toml::from_str::<AutumnConfig>(
17252 r"
17253 [jobs.queues]
17254 critical = { weight = 1, bogus = 3 }
17255 ",
17256 )
17257 .unwrap_err()
17258 .to_string();
17259 assert!(err.contains("bogus"), "unexpected error: {err}");
17260 }
17261
17262 #[test]
17263 fn jobs_pin_defaults_empty_and_parses_from_toml() {
17264 let default = AutumnConfig::default();
17265 assert!(default.jobs.pin.is_empty(), "pin is empty by default (AC4)");
17266 let config: AutumnConfig = toml::from_str(
17267 r#"
17268 [jobs]
17269 pin = ["critical", "default"]
17270 "#,
17271 )
17272 .unwrap();
17273 assert_eq!(config.jobs.pin, vec!["critical", "default"]);
17274 }
17275
17276 #[test]
17283 fn jobs_fleet_tiers_parse_from_toml() {
17284 let default = AutumnConfig::default();
17285 assert!(
17286 default.jobs.fleet.tiers.is_empty(),
17287 "no fleet topology is declared by default (AC4)"
17288 );
17289 let config: AutumnConfig = toml::from_str(
17290 r#"
17291 [jobs.fleet]
17292 tiers = [["critical"], ["bulk", "default"], []]
17293 "#,
17294 )
17295 .unwrap();
17296 assert_eq!(
17297 config.jobs.fleet.tiers,
17298 vec![
17299 vec!["critical".to_owned()],
17300 vec!["bulk".to_owned(), "default".to_owned()],
17301 Vec::<String>::new(),
17304 ],
17305 );
17306 }
17307
17308 #[test]
17312 fn declared_fleet_topology_boots_under_enforce_all() {
17313 let temp = tempfile::tempdir().unwrap();
17314 let config_path = temp.path().join("autumn.toml");
17315 std::fs::write(
17316 &config_path,
17317 "[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n\
17318 [jobs]\npin = [\"critical\"]\n\n\
17319 [jobs.fleet]\ntiers = [[\"critical\"], [\"bulk\", \"default\"]]\n",
17320 )
17321 .unwrap();
17322
17323 let env = FakeEnv(
17324 [
17325 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
17326 (
17327 "AUTUMN_MANIFEST_DIR".to_owned(),
17328 temp.path().to_str().unwrap().to_owned(),
17329 ),
17330 ]
17331 .into(),
17332 );
17333
17334 let res = AutumnConfig::load_with_env(&env);
17335 assert!(
17336 res.is_ok(),
17337 "a declared [jobs.fleet] topology must not be an unknown config key: {res:?}"
17338 );
17339 let config = res.unwrap();
17340 assert_eq!(config.jobs.fleet.tiers.len(), 2);
17341 assert_eq!(config.jobs.pin, vec!["critical".to_owned()]);
17342 }
17343
17344 #[test]
17349 fn jobs_fleet_typo_is_caught_by_strict_config() {
17350 let temp = tempfile::tempdir().unwrap();
17351 let config_path = temp.path().join("autumn.toml");
17352 std::fs::write(
17353 &config_path,
17354 "[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n\
17355 [jobs.fleet]\nteirs = [[\"critical\"]]\n",
17356 )
17357 .unwrap();
17358
17359 let env = FakeEnv(
17360 [
17361 ("AUTUMN_ENV".to_owned(), "prod".to_owned()),
17362 (
17363 "AUTUMN_MANIFEST_DIR".to_owned(),
17364 temp.path().to_str().unwrap().to_owned(),
17365 ),
17366 ]
17367 .into(),
17368 );
17369
17370 let res = AutumnConfig::load_with_env(&env);
17371 assert!(
17372 res.is_err(),
17373 "a typo inside [jobs.fleet] must not be silently accepted"
17374 );
17375 let err_str = format!("{:?}", res.err().unwrap());
17376 assert!(
17377 err_str.contains("teirs"),
17378 "error should name the typo: {err_str}"
17379 );
17380 }
17381
17382 #[test]
17383 fn jobs_pin_env_override_is_comma_separated() {
17384 let env = MockEnv::new().with("AUTUMN_JOBS__PIN", "critical, bulk ,");
17385 let mut config = AutumnConfig::default();
17386 config.apply_jobs_env_overrides_with_env(&env);
17387 assert_eq!(
17388 config.jobs.pin,
17389 vec!["critical".to_string(), "bulk".to_string()],
17390 "trims whitespace and drops empty entries"
17391 );
17392 }
17393
17394 #[test]
17395 fn job_queues_weighted_rejects_zero_weight() {
17396 let err = toml::from_str::<AutumnConfig>(
17397 r"
17398 [jobs.queues]
17399 critical = 4
17400 default = 0
17401 ",
17402 )
17403 .unwrap_err()
17404 .to_string();
17405 assert!(
17406 err.contains("weight must be at least 1") && err.contains("default"),
17407 "unexpected error: {err}"
17408 );
17409 }
17410
17411 #[test]
17412 fn channels_defaults_to_in_process_backend() {
17413 let config = AutumnConfig::default();
17414
17415 assert_eq!(config.channels.backend, ChannelBackend::InProcess);
17416 assert_eq!(config.channels.capacity, 32);
17417 assert_eq!(config.channels.replay_buffer, 256);
17418 assert_eq!(config.channels.redis.key_prefix, "autumn:channels");
17419 assert!(config.channels.redis.url.is_none());
17420 }
17421
17422 #[test]
17423 fn channels_env_overrides_fields() {
17424 let env = MockEnv::new()
17425 .with("AUTUMN_CHANNELS__BACKEND", "redis")
17426 .with("AUTUMN_CHANNELS__CAPACITY", "128")
17427 .with("AUTUMN_CHANNELS__REPLAY_BUFFER", "512")
17428 .with("AUTUMN_CHANNELS__REDIS__URL", "redis://channels:6379/4")
17429 .with("AUTUMN_CHANNELS__REDIS__KEY_PREFIX", "myapp:channels");
17430 let mut config = AutumnConfig::default();
17431
17432 config.apply_env_overrides_with_env(&env);
17433
17434 assert_eq!(config.channels.backend, ChannelBackend::Redis);
17435 assert_eq!(config.channels.capacity, 128);
17436 assert_eq!(config.channels.replay_buffer, 512);
17437 assert_eq!(
17438 config.channels.redis.url.as_deref(),
17439 Some("redis://channels:6379/4")
17440 );
17441 assert_eq!(config.channels.redis.key_prefix, "myapp:channels");
17442 }
17443
17444 #[test]
17445 fn channels_toml_deserializes_redis_backend() {
17446 let config: AutumnConfig = toml::from_str(
17447 r#"
17448 [channels]
17449 backend = "redis"
17450 capacity = 64
17451
17452 [channels.redis]
17453 url = "redis://localhost:6379/5"
17454 key_prefix = "demo:channels"
17455 "#,
17456 )
17457 .unwrap();
17458
17459 assert_eq!(config.channels.backend, ChannelBackend::Redis);
17460 assert_eq!(config.channels.capacity, 64);
17461 assert_eq!(
17462 config.channels.redis.url.as_deref(),
17463 Some("redis://localhost:6379/5")
17464 );
17465 assert_eq!(config.channels.redis.key_prefix, "demo:channels");
17466 }
17467
17468 #[test]
17469 fn env_override_invalid_jobs_numeric_values_ignored() {
17470 let env = MockEnv::new()
17471 .with("AUTUMN_JOBS__WORKERS", "many")
17472 .with("AUTUMN_JOBS__MAX_ATTEMPTS", "a_lot")
17473 .with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "soon");
17474 let mut config = AutumnConfig::default();
17475 config.apply_env_overrides_with_env(&env);
17476
17477 assert_eq!(config.jobs.workers, 1);
17478 assert_eq!(config.jobs.max_attempts, 5);
17479 assert_eq!(config.jobs.initial_backoff_ms, 250);
17480 }
17481
17482 #[test]
17485 fn env_override_server_port() {
17486 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
17487 let mut config = AutumnConfig::default();
17488 config.apply_env_overrides_with_env(&env);
17489 assert_eq!(config.server.port, 8080);
17490 }
17491
17492 #[test]
17493 fn parse_env_works() {
17494 let env = MockEnv::new().with("SOME_NUM", "123");
17495 let mut target: u32 = 0;
17496 parse_env(&env, "SOME_NUM", &mut target);
17497 assert_eq!(target, 123);
17498
17499 let env_err = MockEnv::new().with("SOME_NUM", "abc");
17500 let mut target_err: u32 = 0;
17501 parse_env(&env_err, "SOME_NUM", &mut target_err);
17502 assert_eq!(target_err, 0); }
17504
17505 #[test]
17506 fn parse_env_option_string_works() {
17507 let env = MockEnv::new().with("SOME_OPT", "val");
17508 let mut target = None;
17509 parse_env_option_string(&env, "SOME_OPT", &mut target);
17510 assert_eq!(target, Some("val".to_string()));
17511
17512 let env_empty = MockEnv::new().with("SOME_OPT", "");
17513 let mut target_empty = Some("old".to_string());
17514 parse_env_option_string(&env_empty, "SOME_OPT", &mut target_empty);
17515 assert_eq!(target_empty, None);
17516 }
17517
17518 #[test]
17519 fn parse_env_string_works() {
17520 let env = MockEnv::new().with("SOME_STR", "val");
17521 let mut target = "old".to_string();
17522 parse_env_string(&env, "SOME_STR", &mut target);
17523 assert_eq!(target, "val");
17524 }
17525
17526 fn cfg_with_profile(profile: Option<&str>) -> AutumnConfig {
17529 AutumnConfig {
17530 profile: profile.map(str::to_owned),
17531 ..Default::default()
17532 }
17533 }
17534
17535 #[test]
17536 fn server_timing_defaults_on_in_dev_profile() {
17537 let cfg = cfg_with_profile(Some("dev"));
17538 assert!(server_timing_enabled(&cfg));
17539
17540 let cfg = cfg_with_profile(Some("development"));
17541 assert!(server_timing_enabled(&cfg));
17542 }
17543
17544 #[test]
17545 fn server_timing_defaults_off_in_prod_and_test_profiles() {
17546 let cfg = cfg_with_profile(Some("prod"));
17547 assert!(!server_timing_enabled(&cfg));
17548
17549 let cfg = cfg_with_profile(Some("production"));
17550 assert!(!server_timing_enabled(&cfg));
17551
17552 let cfg = cfg_with_profile(Some("test"));
17553 assert!(!server_timing_enabled(&cfg));
17554
17555 let cfg = cfg_with_profile(None);
17556 assert!(!server_timing_enabled(&cfg));
17557 }
17558
17559 #[test]
17560 fn server_timing_explicit_config_overrides_profile_default() {
17561 let mut cfg = cfg_with_profile(Some("prod"));
17562 cfg.observability.server_timing = Some(true);
17563 assert!(server_timing_enabled(&cfg));
17564
17565 let mut cfg = cfg_with_profile(Some("dev"));
17566 cfg.observability.server_timing = Some(false);
17567 assert!(!server_timing_enabled(&cfg));
17568 }
17569
17570 #[test]
17571 fn server_timing_env_override_wires_into_dispatcher() {
17572 let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "true");
17573 let mut config = cfg_with_profile(Some("prod"));
17574 config.apply_env_overrides_with_env(&env);
17575 assert_eq!(config.observability.server_timing, Some(true));
17576 assert!(server_timing_enabled(&config));
17577
17578 let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "false");
17579 let mut config = cfg_with_profile(Some("dev"));
17580 config.apply_env_overrides_with_env(&env);
17581 assert_eq!(config.observability.server_timing, Some(false));
17582 assert!(!server_timing_enabled(&config));
17583 }
17584
17585 #[test]
17586 fn cost_section_defaults_and_toml() {
17587 let config = AutumnConfig::default();
17588 assert!(!config.cost.enabled);
17589 assert_eq!(config.cost.defer_threshold, None);
17590 assert_eq!(config.cost.defer_recheck_secs, 30);
17591 assert_eq!(config.cost.max_tenants, 1000);
17592 assert_eq!(config.cost.signal_refresh_secs, 5);
17593
17594 let config: AutumnConfig =
17595 toml::from_str("[cost]\nenabled = true\ndefer_threshold = 400.5\n").expect("parse");
17596 assert!(config.cost.enabled);
17597 assert_eq!(config.cost.defer_threshold, Some(400.5));
17598 assert_eq!(
17599 config.cost.defer_recheck_secs, 30,
17600 "unset keys keep defaults"
17601 );
17602 }
17603
17604 #[test]
17605 fn cost_validation_names_the_bad_key() {
17606 assert!(CostConfig::default().validate().is_ok());
17607 let cost = CostConfig {
17608 defer_recheck_secs: 0,
17609 ..CostConfig::default()
17610 };
17611 assert!(cost.validate().unwrap_err().contains("defer_recheck_secs"));
17612 let cost = CostConfig {
17613 signal_refresh_secs: 0,
17614 ..CostConfig::default()
17615 };
17616 assert!(cost.validate().unwrap_err().contains("signal_refresh_secs"));
17617 for bad in [-1.0, f64::NAN, f64::INFINITY] {
17618 let cost = CostConfig {
17619 defer_threshold: Some(bad),
17620 ..CostConfig::default()
17621 };
17622 assert!(cost.validate().unwrap_err().contains("defer_threshold"));
17623 }
17624 let mut config = AutumnConfig::default();
17625 config.cost.defer_recheck_secs = 0;
17626 assert!(config.validate().is_err(), "AutumnConfig::validate runs it");
17627 }
17628
17629 #[test]
17630 fn cost_env_overrides_wire_into_dispatcher() {
17631 let env = MockEnv::new()
17632 .with("AUTUMN_COST__ENABLED", "true")
17633 .with("AUTUMN_COST__DEFER_THRESHOLD", "250")
17634 .with("AUTUMN_COST__DEFER_RECHECK_SECS", "10")
17635 .with("AUTUMN_COST__MAX_TENANTS", "7")
17636 .with("AUTUMN_COST__SIGNAL_REFRESH_SECS", "2");
17637 let mut config = AutumnConfig::default();
17638 config.apply_env_overrides_with_env(&env);
17639 assert!(config.cost.enabled);
17640 assert_eq!(config.cost.defer_threshold, Some(250.0));
17641 assert_eq!(config.cost.defer_recheck_secs, 10);
17642 assert_eq!(config.cost.max_tenants, 7);
17643 assert_eq!(config.cost.signal_refresh_secs, 2);
17644 }
17645
17646 #[test]
17647 fn parse_env_bool_works() {
17648 let env = MockEnv::new().with("SOME_BOOL", "true");
17649 let mut target = false;
17650 parse_env_bool(&env, "SOME_BOOL", &mut target);
17651 assert!(target);
17652
17653 let env2 = MockEnv::new().with("SOME_BOOL", "1");
17654 let mut target2 = false;
17655 parse_env_bool(&env2, "SOME_BOOL", &mut target2);
17656 assert!(target2);
17657
17658 let env3 = MockEnv::new().with("SOME_BOOL", "0");
17659 let mut target3 = true;
17660 parse_env_bool(&env3, "SOME_BOOL", &mut target3);
17661 assert!(!target3);
17662
17663 let env_err = MockEnv::new().with("SOME_BOOL", "invalid");
17664 let mut target_err = true;
17665 parse_env_bool(&env_err, "SOME_BOOL", &mut target_err);
17666 assert!(target_err); }
17668
17669 #[test]
17670 fn parse_env_csv_works() {
17671 let env = MockEnv::new().with("SOME_CSV", "a, b,c");
17672 let mut target = vec![];
17673 parse_env_csv(&env, "SOME_CSV", &mut target);
17674 assert_eq!(target, vec!["a", "b", "c"]);
17675 }
17676
17677 #[test]
17678 fn env_override_tenancy_quota_bytes() {
17679 let env = MockEnv::new();
17681 let mut config = AutumnConfig::default();
17682 config.apply_env_overrides_with_env(&env);
17683 assert_eq!(config.tenancy.quota_bytes, 0);
17684
17685 let env = MockEnv::new().with("AUTUMN_TENANCY__QUOTA_BYTES", "1048576");
17687 let mut config = AutumnConfig::default();
17688 config.apply_env_overrides_with_env(&env);
17689 assert_eq!(config.tenancy.quota_bytes, 1_048_576);
17690 }
17691
17692 #[test]
17693 fn env_override_tenancy_enabled() {
17694 let env = MockEnv::new();
17696 let mut config = AutumnConfig::default();
17697 config.apply_env_overrides_with_env(&env);
17698 assert!(!config.tenancy.enabled);
17699
17700 let env = MockEnv::new().with("AUTUMN_TENANCY__ENABLED", "true");
17701 let mut config = AutumnConfig::default();
17702 config.apply_env_overrides_with_env(&env);
17703 assert!(config.tenancy.enabled);
17704 }
17705
17706 #[test]
17707 fn env_override_tenancy_string_fields() {
17708 let env = MockEnv::new()
17709 .with("AUTUMN_TENANCY__SOURCE", "jwt")
17710 .with("AUTUMN_TENANCY__HEADER_NAME", "x-org")
17711 .with("AUTUMN_TENANCY__SESSION_KEY", "org_id")
17712 .with("AUTUMN_TENANCY__JWT_CLAIM", "org")
17713 .with("AUTUMN_TENANCY__JWT_ISSUER", "https://issuer.example")
17714 .with("AUTUMN_TENANCY__JWT_AUDIENCE", "autumn-api")
17715 .with("AUTUMN_TENANCY__BASE_DOMAIN", "apps.example.com")
17716 .with("AUTUMN_TENANCY__LOGIN_REDIRECT", "/login")
17717 .with("AUTUMN_TENANCY__PUBLIC_PATHS", "/login, /signup ,/assets");
17718 let mut config = AutumnConfig::default();
17719 config.apply_env_overrides_with_env(&env);
17720 assert_eq!(config.tenancy.source, "jwt");
17721 assert_eq!(config.tenancy.header_name, "x-org");
17722 assert_eq!(config.tenancy.session_key, "org_id");
17723 assert_eq!(config.tenancy.jwt_claim, "org");
17724 assert_eq!(
17725 config.tenancy.jwt_issuer.as_deref(),
17726 Some("https://issuer.example")
17727 );
17728 assert_eq!(config.tenancy.jwt_audience.as_deref(), Some("autumn-api"));
17729 assert_eq!(
17730 config.tenancy.base_domain.as_deref(),
17731 Some("apps.example.com")
17732 );
17733 assert_eq!(config.tenancy.login_redirect.as_deref(), Some("/login"));
17734 assert_eq!(
17735 config.tenancy.public_paths,
17736 vec!["/login", "/signup", "/assets"]
17737 );
17738 }
17739
17740 #[test]
17741 fn env_override_isolation_knobs() {
17742 let env = MockEnv::new()
17744 .with("AUTUMN_TENANCY__MAX_CONCURRENT_REQUESTS", "32")
17745 .with("AUTUMN_TENANCY__MAX_DB_CONNECTIONS", "4")
17746 .with("AUTUMN_JOBS__TENANTS__MAX_CONCURRENT", "2")
17747 .with("AUTUMN_JOBS__TENANTS__LANES", "8")
17748 .with("AUTUMN_JOBS__TENANTS__LANES_PER_TENANT", "3")
17749 .with("AUTUMN_JOBS__POSTGRES__SHARD_LOCAL", "true");
17750 let mut config = AutumnConfig::default();
17751 config.apply_env_overrides_with_env(&env);
17752 assert_eq!(config.tenancy.max_concurrent_requests, 32);
17753 assert_eq!(config.tenancy.max_db_connections, 4);
17754 assert_eq!(config.jobs.tenants.max_concurrent, 2);
17755 assert_eq!(config.jobs.tenants.lanes, 8);
17756 assert_eq!(config.jobs.tenants.lanes_per_tenant, 3);
17757 assert!(config.jobs.postgres.shard_local);
17758 }
17759
17760 #[test]
17761 fn env_override_tenancy_eviction_knobs() {
17762 let env = MockEnv::new();
17764 let mut config = AutumnConfig::default();
17765 config.apply_env_overrides_with_env(&env);
17766 assert_eq!(config.tenancy.max_cells, 0);
17767 assert_eq!(config.tenancy.idle_ttl_secs, 0);
17768
17769 let env = MockEnv::new()
17770 .with("AUTUMN_TENANCY__MAX_CELLS", "512")
17771 .with("AUTUMN_TENANCY__IDLE_TTL_SECS", "900");
17772 let mut config = AutumnConfig::default();
17773 config.apply_env_overrides_with_env(&env);
17774 assert_eq!(config.tenancy.max_cells, 512);
17775 assert_eq!(config.tenancy.idle_ttl_secs, 900);
17776 }
17777
17778 #[test]
17779 fn env_override_tenancy_secret() {
17780 use secrecy::ExposeSecret;
17781
17782 let env = MockEnv::new();
17784 let mut config = AutumnConfig::default();
17785 config.apply_env_overrides_with_env(&env);
17786 assert!(config.tenancy.jwt_secret.is_none());
17787
17788 let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", " s3cr3t-signing-key ");
17790 let mut config = AutumnConfig::default();
17791 config.apply_env_overrides_with_env(&env);
17792 assert_eq!(
17793 config
17794 .tenancy
17795 .jwt_secret
17796 .as_ref()
17797 .map(|s| s.expose_secret().to_owned()),
17798 Some("s3cr3t-signing-key".to_string())
17799 );
17800
17801 let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", " ");
17803 let mut config = AutumnConfig::default();
17804 config.tenancy.jwt_secret = Some(secrecy::SecretString::from("preexisting".to_string()));
17805 config.apply_env_overrides_with_env(&env);
17806 assert!(config.tenancy.jwt_secret.is_none());
17807 }
17808
17809 #[test]
17810 fn env_override_rate_limit_trusted_proxies() {
17811 let env = MockEnv::new().with(
17812 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
17813 "10.0.0.10, 203.0.113.0/24",
17814 );
17815 let mut config = AutumnConfig::default();
17816 config.apply_env_overrides_with_env(&env);
17817 assert_eq!(
17818 config.security.rate_limit.trusted_proxies,
17819 vec!["10.0.0.10", "203.0.113.0/24"]
17820 );
17821 }
17822
17823 #[test]
17824 fn env_override_rate_limit_backend_redis() {
17825 use crate::security::config::RateLimitBackend;
17826 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "redis");
17827 let mut config = AutumnConfig::default();
17828 config.apply_env_overrides_with_env(&env);
17829 assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Redis);
17830 }
17831
17832 #[test]
17833 fn env_override_rate_limit_backend_memory() {
17834 use crate::security::config::RateLimitBackend;
17835 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "memory");
17836 let mut config = AutumnConfig::default();
17837 config.apply_env_overrides_with_env(&env);
17838 assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
17839 }
17840
17841 #[test]
17842 fn env_override_rate_limit_backend_invalid_ignored() {
17843 use crate::security::config::RateLimitBackend;
17844 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "postgres");
17845 let mut config = AutumnConfig::default();
17846 config.apply_env_overrides_with_env(&env);
17847 assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
17848 }
17849
17850 #[cfg(feature = "redis")]
17851 #[test]
17852 fn env_override_rate_limit_on_backend_failure_fail_closed() {
17853 use crate::security::config::RateLimitBackendFailure;
17854 let env = MockEnv::new().with(
17855 "AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE",
17856 "fail_closed",
17857 );
17858 let mut config = AutumnConfig::default();
17859 config.apply_env_overrides_with_env(&env);
17860 assert_eq!(
17861 config.security.rate_limit.on_backend_failure,
17862 RateLimitBackendFailure::FailClosed
17863 );
17864 }
17865
17866 #[cfg(feature = "redis")]
17867 #[test]
17868 fn env_override_rate_limit_on_backend_failure_invalid_ignored() {
17869 use crate::security::config::RateLimitBackendFailure;
17870 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE", "explode");
17871 let mut config = AutumnConfig::default();
17872 config.apply_env_overrides_with_env(&env);
17873 assert_eq!(
17874 config.security.rate_limit.on_backend_failure,
17875 RateLimitBackendFailure::FailOpen
17876 );
17877 }
17878
17879 #[cfg(feature = "redis")]
17880 #[test]
17881 fn env_override_rate_limit_redis_url() {
17882 let env = MockEnv::new().with(
17883 "AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
17884 "redis://myhost:6379",
17885 );
17886 let mut config = AutumnConfig::default();
17887 config.apply_env_overrides_with_env(&env);
17888 assert_eq!(
17889 config.security.rate_limit.redis.url.as_deref(),
17890 Some("redis://myhost:6379")
17891 );
17892 }
17893
17894 #[cfg(feature = "redis")]
17895 #[test]
17896 fn env_override_rate_limit_redis_key_prefix() {
17897 let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX", "prod:rl");
17898 let mut config = AutumnConfig::default();
17899 config.apply_env_overrides_with_env(&env);
17900 assert_eq!(config.security.rate_limit.redis.key_prefix, "prod:rl");
17901 }
17902
17903 #[test]
17904 fn env_override_server_host() {
17905 let env = MockEnv::new().with("AUTUMN_SERVER__HOST", "0.0.0.0");
17906 let mut config = AutumnConfig::default();
17907 config.apply_env_overrides_with_env(&env);
17908 assert_eq!(config.server.host, "0.0.0.0");
17909 }
17910
17911 #[test]
17912 fn env_override_server_shutdown_timeout() {
17913 let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "60");
17914 let mut config = AutumnConfig::default();
17915 config.apply_env_overrides_with_env(&env);
17916 assert_eq!(config.server.shutdown_timeout_secs, 60);
17917 }
17918
17919 #[test]
17920 fn env_override_invalid_server_port_ignored() {
17921 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "not_a_port");
17922 let mut config = AutumnConfig::default();
17923 config.apply_env_overrides_with_env(&env);
17924 assert_eq!(config.server.port, 3000);
17925 }
17926
17927 #[test]
17928 fn env_override_invalid_shutdown_timeout_ignored() {
17929 let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "forever");
17930 let mut config = AutumnConfig::default();
17931 config.apply_env_overrides_with_env(&env);
17932 assert_eq!(config.server.shutdown_timeout_secs, 30);
17933 }
17934
17935 #[test]
17936 fn server_config_defaults_unix_socket_none() {
17937 let config = AutumnConfig::default();
17938 assert!(config.server.unix_socket.is_none());
17939 }
17940
17941 #[test]
17942 fn env_override_server_unix_socket() {
17943 let env = MockEnv::new().with("AUTUMN_SERVER__UNIX_SOCKET", "/run/autumn/app.sock");
17944 let mut config = AutumnConfig::default();
17945 config.apply_env_overrides_with_env(&env);
17946 assert_eq!(
17947 config.server.unix_socket.as_deref(),
17948 Some("/run/autumn/app.sock")
17949 );
17950 }
17951
17952 #[test]
17953 fn unix_socket_parses_from_toml() {
17954 let config: AutumnConfig = toml::from_str(
17955 r#"
17956 [server]
17957 unix_socket = "/tmp/autumn.sock"
17958 "#,
17959 )
17960 .expect("config with server.unix_socket should parse");
17961 assert_eq!(
17962 config.server.unix_socket.as_deref(),
17963 Some("/tmp/autumn.sock")
17964 );
17965 }
17966
17967 #[test]
17970 fn server_upgrade_config_defaults_enabled_with_a_30s_readiness_budget() {
17971 let config = AutumnConfig::default();
17975 assert!(config.server.upgrade.enabled);
17976 assert_eq!(config.server.upgrade.ready_timeout_secs, 30);
17977 }
17978
17979 #[test]
17980 fn server_upgrade_parses_from_toml() {
17981 let config: AutumnConfig = toml::from_str(
17982 r"
17983 [server.upgrade]
17984 enabled = false
17985 ready_timeout_secs = 5
17986 ",
17987 )
17988 .expect("config with [server.upgrade] should parse");
17989 assert!(!config.server.upgrade.enabled);
17990 assert_eq!(config.server.upgrade.ready_timeout_secs, 5);
17991 }
17992
17993 #[test]
17994 fn env_overrides_server_upgrade() {
17995 let env = MockEnv::new()
17997 .with("AUTUMN_SERVER__UPGRADE__ENABLED", "false")
17998 .with("AUTUMN_SERVER__UPGRADE__READY_TIMEOUT_SECS", "90");
17999 let mut config = AutumnConfig::default();
18000 config.apply_env_overrides_with_env(&env);
18001 assert!(!config.server.upgrade.enabled);
18002 assert_eq!(config.server.upgrade.ready_timeout_secs, 90);
18003 }
18004
18005 #[test]
18008 fn server_config_defaults_tls_none() {
18009 let config = AutumnConfig::default();
18011 assert!(config.server.tls.is_none());
18012 }
18013
18014 #[test]
18015 fn server_tls_parses_from_toml() {
18016 let config: AutumnConfig = toml::from_str(
18017 r#"
18018 [server.tls]
18019 cert_path = "/etc/autumn/tls/fullchain.pem"
18020 key_path = "/etc/autumn/tls/privkey.pem"
18021 "#,
18022 )
18023 .expect("config with [server.tls] should parse");
18024 let tls = config.server.tls.expect("tls configured");
18025 assert_eq!(
18026 tls.cert_path,
18027 Some(std::path::PathBuf::from("/etc/autumn/tls/fullchain.pem"))
18028 );
18029 assert_eq!(
18030 tls.key_path,
18031 Some(std::path::PathBuf::from("/etc/autumn/tls/privkey.pem"))
18032 );
18033 assert_eq!(tls.reload_interval_secs, 60);
18035 assert_eq!(tls.handshake_timeout_secs, 10);
18036 assert!(tls.acme.is_none());
18038 assert!(tls.validate().is_ok());
18039 }
18040
18041 #[test]
18042 fn server_tls_handshake_timeout_parses_from_toml() {
18043 let config: AutumnConfig = toml::from_str(
18044 r#"
18045 [server.tls]
18046 cert_path = "cert.pem"
18047 key_path = "key.pem"
18048 handshake_timeout_secs = 25
18049 "#,
18050 )
18051 .expect("config with [server.tls] handshake_timeout_secs should parse");
18052 assert_eq!(config.server.tls.unwrap().handshake_timeout_secs, 25);
18053 }
18054
18055 #[test]
18056 fn server_tls_reload_interval_parses_from_toml() {
18057 let config: AutumnConfig = toml::from_str(
18058 r#"
18059 [server.tls]
18060 cert_path = "cert.pem"
18061 key_path = "key.pem"
18062 reload_interval_secs = 120
18063 "#,
18064 )
18065 .expect("config with [server.tls] reload_interval_secs should parse");
18066 assert_eq!(config.server.tls.unwrap().reload_interval_secs, 120);
18067 }
18068
18069 #[test]
18070 fn env_override_materializes_server_tls() {
18071 let env = MockEnv::new()
18074 .with("AUTUMN_SERVER__TLS__CERT_PATH", "/env/cert.pem")
18075 .with("AUTUMN_SERVER__TLS__KEY_PATH", "/env/key.pem")
18076 .with("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS", "90")
18077 .with("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS", "5");
18078 let mut config = AutumnConfig::default();
18079 assert!(config.server.tls.is_none());
18080 config.apply_env_overrides_with_env(&env);
18081 let tls = config.server.tls.expect("env should materialize tls");
18082 assert_eq!(
18083 tls.cert_path,
18084 Some(std::path::PathBuf::from("/env/cert.pem"))
18085 );
18086 assert_eq!(tls.key_path, Some(std::path::PathBuf::from("/env/key.pem")));
18087 assert_eq!(tls.reload_interval_secs, 90);
18088 assert_eq!(tls.handshake_timeout_secs, 5);
18089 }
18090
18091 #[test]
18092 fn env_override_updates_existing_server_tls_cert() {
18093 let mut config: AutumnConfig = toml::from_str(
18096 r#"
18097 [server.tls]
18098 cert_path = "toml-cert.pem"
18099 key_path = "toml-key.pem"
18100 "#,
18101 )
18102 .unwrap();
18103 let env = MockEnv::new().with("AUTUMN_SERVER__TLS__CERT_PATH", "override-cert.pem");
18104 config.apply_env_overrides_with_env(&env);
18105 let tls = config.server.tls.expect("tls configured");
18106 assert_eq!(
18107 tls.cert_path,
18108 Some(std::path::PathBuf::from("override-cert.pem"))
18109 );
18110 assert_eq!(tls.key_path, Some(std::path::PathBuf::from("toml-key.pem")));
18111 }
18112
18113 #[test]
18114 fn no_tls_env_leaves_tls_none() {
18115 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
18116 let mut config = AutumnConfig::default();
18117 config.apply_env_overrides_with_env(&env);
18118 assert!(config.server.tls.is_none());
18119 }
18120
18121 #[test]
18124 fn deploy_absent_is_none() {
18125 let config = AutumnConfig::default();
18128 assert!(config.deploy.is_none());
18129 let parsed: AutumnConfig = toml::from_str("[server]\nport = 3000\n")
18130 .expect("config without [deploy] should parse");
18131 assert!(parsed.deploy.is_none());
18132 }
18133
18134 #[test]
18135 fn deploy_defaults_from_bare_table() {
18136 let config: AutumnConfig =
18139 toml::from_str("[deploy]\n").expect("bare [deploy] table should parse");
18140 let deploy = config.deploy.expect("deploy configured");
18141 assert_eq!(deploy.host, None);
18142 assert_eq!(deploy.user, "root");
18143 assert_eq!(deploy.ssh_port, 22);
18144 assert_eq!(deploy.app_name, None);
18145 assert_eq!(deploy.app_dir, None);
18146 assert_eq!(deploy.service_name, None);
18147 assert_eq!(deploy.readiness_timeout_secs, 60);
18148 assert_eq!(deploy.keep_releases, 3);
18149 assert!(deploy.install_proxy);
18154 }
18155
18156 #[test]
18157 fn deploy_full_table_parses() {
18158 let config: AutumnConfig = toml::from_str(
18159 r#"
18160 [deploy]
18161 host = "203.0.113.10"
18162 user = "deploy"
18163 ssh_port = 2222
18164 app_name = "myapp"
18165 app_dir = "/srv/myapp"
18166 service_name = "myapp-web"
18167 readiness_timeout_secs = 90
18168 keep_releases = 5
18169 install_proxy = false
18170 "#,
18171 )
18172 .expect("full [deploy] table should parse");
18173 let deploy = config.deploy.expect("deploy configured");
18174 assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
18175 assert_eq!(deploy.user, "deploy");
18176 assert_eq!(deploy.ssh_port, 2222);
18177 assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
18178 assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
18179 assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
18180 assert_eq!(deploy.readiness_timeout_secs, 90);
18181 assert_eq!(deploy.keep_releases, 5);
18182 assert!(
18183 !deploy.install_proxy,
18184 "the host-prep opt-out parses (#1607)"
18185 );
18186 assert!(deploy.validate().is_ok());
18187 }
18188
18189 #[test]
18190 fn deploy_validate_rejects_missing_host() {
18191 let missing = DeployConfig::default();
18193 let err = missing
18194 .validate()
18195 .expect_err("missing host must be rejected");
18196 assert!(
18197 err.contains("host"),
18198 "error should name the missing key: {err}"
18199 );
18200
18201 let blank = DeployConfig {
18203 host: Some(" ".to_owned()),
18204 ..DeployConfig::default()
18205 };
18206 assert!(blank.validate().is_err());
18207
18208 let ok = DeployConfig {
18210 host: Some("example.com".to_owned()),
18211 ..DeployConfig::default()
18212 };
18213 assert!(ok.validate().is_ok());
18214 }
18215
18216 #[test]
18217 fn env_override_materializes_deploy() {
18218 let env = MockEnv::new()
18221 .with("AUTUMN_DEPLOY__HOST", "203.0.113.10")
18222 .with("AUTUMN_DEPLOY__USER", "deploy")
18223 .with("AUTUMN_DEPLOY__SSH_PORT", "2222")
18224 .with("AUTUMN_DEPLOY__APP_NAME", "myapp")
18225 .with("AUTUMN_DEPLOY__APP_DIR", "/srv/myapp")
18226 .with("AUTUMN_DEPLOY__SERVICE_NAME", "myapp-web")
18227 .with("AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS", "90")
18228 .with("AUTUMN_DEPLOY__KEEP_RELEASES", "5")
18229 .with("AUTUMN_DEPLOY__PROFILE", "staging");
18230 let mut config = AutumnConfig::default();
18231 assert!(config.deploy.is_none());
18232 config.apply_env_overrides_with_env(&env);
18233 let deploy = config.deploy.expect("env should materialize deploy");
18234 assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
18235 assert_eq!(deploy.user, "deploy");
18236 assert_eq!(deploy.ssh_port, 2222);
18237 assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
18238 assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
18239 assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
18240 assert_eq!(deploy.readiness_timeout_secs, 90);
18241 assert_eq!(deploy.keep_releases, 5);
18242 assert_eq!(deploy.profile, "staging");
18243 assert!(deploy.validate().is_ok());
18244 }
18245
18246 #[test]
18247 fn env_override_declines_deploy_host_preparation() {
18248 let env = MockEnv::new().with("AUTUMN_DEPLOY__INSTALL_PROXY", "false");
18253 let mut config = AutumnConfig::default();
18254 assert!(config.deploy.is_none());
18255 config.apply_env_overrides_with_env(&env);
18256 let deploy = config.deploy.expect("env should materialize deploy");
18257 assert!(!deploy.install_proxy);
18258
18259 let mut from_toml: AutumnConfig =
18261 toml::from_str("[deploy]\nhost = \"203.0.113.10\"\ninstall_proxy = true\n").unwrap();
18262 from_toml.apply_env_overrides_with_env(&env);
18263 assert!(!from_toml.deploy.expect("deploy configured").install_proxy);
18264 }
18265
18266 #[test]
18267 fn deploy_bake_defaults_to_off() {
18268 let config: AutumnConfig = toml::from_str("[deploy]\nhost = \"h\"\n").unwrap();
18269 let bake = config.deploy.expect("deploy").bake;
18270 assert_eq!(bake.duration_secs, 0);
18271 assert_eq!(bake.interval_secs, 10);
18272 assert_eq!(bake.min_requests, 20);
18273 assert!(bake.max_error_rate.is_none());
18274 assert!(bake.max_p99_ms.is_none());
18275 }
18276
18277 #[test]
18278 fn deploy_bake_parses_from_toml() {
18279 let config: AutumnConfig = toml::from_str(
18280 "[deploy]\nhost = \"h\"\n\n[deploy.bake]\nduration_secs = 300\n\
18281 interval_secs = 15\nmin_requests = 50\nmax_error_rate = 0.01\nmax_p99_ms = 400\n",
18282 )
18283 .unwrap();
18284 let bake = config.deploy.expect("deploy").bake;
18285 assert_eq!(bake.duration_secs, 300);
18286 assert_eq!(bake.interval_secs, 15);
18287 assert_eq!(bake.min_requests, 50);
18288 assert_eq!(bake.max_error_rate, Some(0.01));
18289 assert_eq!(bake.max_p99_ms, Some(400));
18290 }
18291
18292 #[test]
18293 fn env_override_wins_over_toml_deploy_bake_and_ignores_a_bad_value() {
18294 let mut config: AutumnConfig = toml::from_str(
18295 "[deploy]\nhost = \"h\"\n\n[deploy.bake]\nduration_secs = 300\nmax_p99_ms = 400\n",
18296 )
18297 .unwrap();
18298 let env = MockEnv::new()
18299 .with("AUTUMN_DEPLOY__BAKE__DURATION_SECS", "60")
18300 .with("AUTUMN_DEPLOY__BAKE__MAX_P99_MS", "5m");
18302 config.apply_env_overrides_with_env(&env);
18303 let bake = config.deploy.expect("deploy").bake;
18304 assert_eq!(bake.duration_secs, 60);
18305 assert_eq!(bake.max_p99_ms, Some(400));
18306 }
18307
18308 #[test]
18309 fn env_override_sets_every_deploy_bake_key() {
18310 let env = MockEnv::new()
18312 .with("AUTUMN_DEPLOY__BAKE__DURATION_SECS", "120")
18313 .with("AUTUMN_DEPLOY__BAKE__INTERVAL_SECS", "5")
18314 .with("AUTUMN_DEPLOY__BAKE__MIN_REQUESTS", "7")
18315 .with("AUTUMN_DEPLOY__BAKE__MAX_ERROR_RATE", "0.02")
18316 .with("AUTUMN_DEPLOY__BAKE__MAX_P99_MS", "300");
18317 let mut config = AutumnConfig::default();
18318 config.apply_env_overrides_with_env(&env);
18319 let bake = config.deploy.expect("env should materialize deploy").bake;
18320 assert_eq!(bake.duration_secs, 120);
18321 assert_eq!(bake.interval_secs, 5);
18322 assert_eq!(bake.min_requests, 7);
18323 assert_eq!(bake.max_error_rate, Some(0.02));
18324 assert_eq!(bake.max_p99_ms, Some(300));
18325
18326 let only = MockEnv::new().with("AUTUMN_DEPLOY__BAKE__MAX_P99_MS", "250");
18327 let mut config = AutumnConfig::default();
18328 config.apply_env_overrides_with_env(&only);
18329 assert_eq!(
18330 config.deploy.expect("materialized").bake.max_p99_ms,
18331 Some(250)
18332 );
18333 }
18334
18335 #[test]
18336 fn env_override_sets_deploy_tls_enabled_and_host() {
18337 let env = MockEnv::new()
18341 .with("AUTUMN_DEPLOY__TLS__ENABLED", "true")
18342 .with("AUTUMN_DEPLOY__TLS__HOST", "app.example.com");
18343 let mut config = AutumnConfig::default();
18344 assert!(config.deploy.is_none());
18345 config.apply_env_overrides_with_env(&env);
18346 let deploy = config.deploy.expect("env should materialize deploy");
18347 assert!(deploy.tls.enabled);
18348 assert_eq!(deploy.tls.host.as_deref(), Some("app.example.com"));
18349 }
18350
18351 #[test]
18352 fn env_override_wins_over_toml_deploy_tls_host() {
18353 let mut config: AutumnConfig = toml::from_str(
18355 r#"
18356 [deploy]
18357 host = "203.0.113.10"
18358
18359 [deploy.tls]
18360 enabled = true
18361 host = "toml.example.com"
18362 "#,
18363 )
18364 .unwrap();
18365 assert_eq!(
18366 config.deploy.as_ref().unwrap().tls.host.as_deref(),
18367 Some("toml.example.com"),
18368 );
18369
18370 let env = MockEnv::new().with("AUTUMN_DEPLOY__TLS__HOST", "env.example.com");
18372 config.apply_env_overrides_with_env(&env);
18373 let deploy = config.deploy.unwrap();
18374 assert!(deploy.tls.enabled);
18375 assert_eq!(deploy.tls.host.as_deref(), Some("env.example.com"));
18376 }
18377
18378 #[test]
18379 fn deploy_profile_defaults_to_production() {
18380 let config: AutumnConfig = toml::from_str(
18383 r#"
18384 [deploy]
18385 host = "203.0.113.10"
18386 "#,
18387 )
18388 .unwrap();
18389 let deploy = config.deploy.expect("deploy configured");
18390 assert_eq!(deploy.profile, "prod");
18391 assert_eq!(DeployConfig::default().profile, "prod");
18393 }
18394
18395 #[test]
18396 fn deploy_profile_honors_toml_and_env_override() {
18397 let mut config: AutumnConfig = toml::from_str(
18399 r#"
18400 [deploy]
18401 host = "toml-host"
18402 profile = "staging"
18403 "#,
18404 )
18405 .unwrap();
18406 assert_eq!(config.deploy.as_ref().unwrap().profile, "staging");
18407
18408 let env = MockEnv::new().with("AUTUMN_DEPLOY__PROFILE", "prod");
18410 config.apply_env_overrides_with_env(&env);
18411 assert_eq!(config.deploy.unwrap().profile, "prod");
18412 }
18413
18414 #[test]
18415 fn env_override_materializes_deploy_from_single_host() {
18416 let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "198.51.100.7");
18419 let mut config = AutumnConfig::default();
18420 assert!(config.deploy.is_none());
18421 config.apply_env_overrides_with_env(&env);
18422 let deploy = config.deploy.expect("env should materialize deploy");
18423 assert_eq!(deploy.host.as_deref(), Some("198.51.100.7"));
18424 assert_eq!(deploy.user, "root");
18426 assert_eq!(deploy.ssh_port, 22);
18427 assert_eq!(deploy.readiness_timeout_secs, 60);
18428 assert_eq!(deploy.keep_releases, 3);
18429 }
18430
18431 #[test]
18432 fn env_override_updates_existing_deploy_host() {
18433 let mut config: AutumnConfig = toml::from_str(
18436 r#"
18437 [deploy]
18438 host = "toml-host"
18439 user = "deploy"
18440 ssh_port = 2200
18441 "#,
18442 )
18443 .unwrap();
18444 let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "env-host");
18445 config.apply_env_overrides_with_env(&env);
18446 let deploy = config.deploy.expect("deploy configured");
18447 assert_eq!(deploy.host.as_deref(), Some("env-host"));
18448 assert_eq!(deploy.user, "deploy");
18449 assert_eq!(deploy.ssh_port, 2200);
18450 }
18451
18452 #[test]
18453 fn env_override_parses_deploy_ssh_port_u16() {
18454 let env = MockEnv::new()
18455 .with("AUTUMN_DEPLOY__HOST", "example.com")
18456 .with("AUTUMN_DEPLOY__SSH_PORT", "65535");
18457 let mut config = AutumnConfig::default();
18458 config.apply_env_overrides_with_env(&env);
18459 let deploy = config.deploy.expect("env should materialize deploy");
18460 assert_eq!(deploy.ssh_port, 65_535_u16);
18461 }
18462
18463 #[test]
18464 fn no_deploy_env_leaves_deploy_none() {
18465 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
18466 let mut config = AutumnConfig::default();
18467 config.apply_env_overrides_with_env(&env);
18468 assert!(config.deploy.is_none());
18469 }
18470
18471 #[test]
18474 fn deploy_hosts_list_parses_in_declaration_order_and_defaults_to_empty() {
18475 let config: AutumnConfig = toml::from_str(
18480 r#"
18481 [deploy]
18482 hosts = ["web-1.example.com", "web-2.example.com", "web-3.example.com"]
18483 "#,
18484 )
18485 .expect("[deploy] hosts list should parse");
18486 let deploy = config.deploy.expect("deploy configured");
18487 assert_eq!(
18488 deploy.hosts,
18489 vec![
18490 "web-1.example.com".to_owned(),
18491 "web-2.example.com".to_owned(),
18492 "web-3.example.com".to_owned(),
18493 ],
18494 "hosts must parse in declaration order (the documented rollout order), got: {:?}",
18495 deploy.hosts
18496 );
18497 assert_eq!(
18498 deploy.host, None,
18499 "the fleet spelling must not populate the legacy scalar, got: {:?}",
18500 deploy.host
18501 );
18502
18503 let bare: AutumnConfig =
18504 toml::from_str("[deploy]\nhost = \"203.0.113.10\"\n").expect("legacy table parses");
18505 let bare_deploy = bare.deploy.expect("deploy configured");
18506 assert!(
18507 bare_deploy.hosts.is_empty(),
18508 "a pre-#1621 [deploy] table must resolve to an empty hosts list, got: {:?}",
18509 bare_deploy.hosts
18510 );
18511 assert!(
18512 DeployConfig::default().hosts.is_empty(),
18513 "the hand-written Default impl must seed an empty hosts list, got: {:?}",
18514 DeployConfig::default().hosts
18515 );
18516 }
18517
18518 #[test]
18519 fn deploy_validate_rejects_host_and_hosts_set_together() {
18520 let cfg = DeployConfig {
18526 host: Some("203.0.113.10".to_owned()),
18527 hosts: vec!["web-1.example.com".to_owned()],
18528 ..DeployConfig::default()
18529 };
18530 let err = cfg
18531 .validate()
18532 .expect_err("host + hosts together must be rejected");
18533 assert!(
18534 err.contains("[deploy] host") && err.contains("[deploy] hosts"),
18535 "the mutual-exclusion error must name BOTH keys so the operator knows \
18536 which one to delete, got: {err}"
18537 );
18538 }
18539
18540 #[test]
18541 fn deploy_validate_rejects_blank_and_duplicate_hosts_entries() {
18542 let blank = DeployConfig {
18547 hosts: vec!["web-1.example.com".to_owned(), " ".to_owned()],
18548 ..DeployConfig::default()
18549 };
18550 let blank_err = blank
18551 .validate()
18552 .expect_err("a blank hosts entry must be rejected");
18553 assert!(
18554 blank_err.contains("hosts") && blank_err.contains('1'),
18555 "the blank-entry error must name `hosts` and the 0-based index, got: {blank_err}"
18556 );
18557
18558 let duplicate = DeployConfig {
18559 hosts: vec![
18560 "web-1.example.com".to_owned(),
18561 " web-1.example.com ".to_owned(),
18562 ],
18563 ..DeployConfig::default()
18564 };
18565 let duplicate_err = duplicate
18566 .validate()
18567 .expect_err("a duplicate hosts entry must be rejected");
18568 assert!(
18569 duplicate_err.contains("web-1.example.com"),
18570 "the duplicate error must name the repeated value, got: {duplicate_err}"
18571 );
18572
18573 let ok = DeployConfig {
18574 hosts: vec![
18575 "web-1.example.com".to_owned(),
18576 "web-2.example.com".to_owned(),
18577 ],
18578 ..DeployConfig::default()
18579 };
18580 assert!(
18581 ok.validate().is_ok(),
18582 "a well-formed fleet list must validate, got: {:?}",
18583 ok.validate()
18584 );
18585 }
18586
18587 #[test]
18588 fn env_override_materializes_deploy_from_hosts_only() {
18589 let env = MockEnv::new().with(
18595 "AUTUMN_DEPLOY__HOSTS",
18596 "web-1.example.com,web-2.example.com",
18597 );
18598 let mut config = AutumnConfig::default();
18599 assert!(config.deploy.is_none());
18600 config.apply_env_overrides_with_env(&env);
18601 let deploy = config
18602 .deploy
18603 .expect("AUTUMN_DEPLOY__HOSTS alone must materialize the [deploy] table");
18604 assert_eq!(
18605 deploy.hosts,
18606 vec![
18607 "web-1.example.com".to_owned(),
18608 "web-2.example.com".to_owned(),
18609 ],
18610 "the CSV env value must parse into the ordered fleet list, got: {:?}",
18611 deploy.hosts
18612 );
18613 assert_eq!(deploy.host, None, "got: {:?}", deploy.host);
18615 assert_eq!(deploy.user, "root");
18616 assert_eq!(deploy.ssh_port, 22);
18617 }
18618
18619 #[test]
18620 fn env_override_hosts_replaces_the_whole_toml_list() {
18621 let mut config: AutumnConfig = toml::from_str(
18625 r#"
18626 [deploy]
18627 hosts = ["toml-1", "toml-2", "toml-3"]
18628 "#,
18629 )
18630 .expect("[deploy] hosts list should parse");
18631 let env = MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", "env-1, env-2");
18632 config.apply_env_overrides_with_env(&env);
18633 let deploy = config.deploy.expect("deploy configured");
18634 assert_eq!(
18635 deploy.hosts,
18636 vec!["env-1".to_owned(), "env-2".to_owned()],
18637 "env must REPLACE the TOML fleet list (and trim each entry), got: {:?}",
18638 deploy.hosts
18639 );
18640 }
18641
18642 #[test]
18643 fn an_empty_deploy_hosts_env_override_behaves_as_unset() {
18644 for blank in ["", " ", ",", " , "] {
18653 let mut config: AutumnConfig = toml::from_str(
18654 r#"
18655 [deploy]
18656 host = "203.0.113.10"
18657 "#,
18658 )
18659 .expect("[deploy] host should parse");
18660 let env = MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", blank);
18661 config.apply_env_overrides_with_env(&env);
18662 let deploy = config.deploy.expect("deploy configured");
18663 assert!(
18664 deploy.hosts.is_empty(),
18665 "AUTUMN_DEPLOY__HOSTS={blank:?} must behave as unset, got: {:?}",
18666 deploy.hosts
18667 );
18668 assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
18669 assert!(
18670 deploy.validate().is_ok(),
18671 "a blank fleet override must not trip the mutual-exclusion refusal, got: {:?}",
18672 deploy.validate()
18673 );
18674 }
18675 }
18676
18677 #[test]
18678 fn a_trailing_comma_in_the_deploy_hosts_env_override_is_tolerated() {
18679 for (value, expected) in [
18684 ("10.0.0.1,10.0.0.2,", vec!["10.0.0.1", "10.0.0.2"]),
18685 ("10.0.0.1,,10.0.0.2", vec!["10.0.0.1", "10.0.0.2"]),
18686 (" 10.0.0.1 , 10.0.0.2 , ", vec!["10.0.0.1", "10.0.0.2"]),
18687 ] {
18688 let mut config = AutumnConfig::default();
18689 let env = MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", value);
18690 config.apply_env_overrides_with_env(&env);
18691 let deploy = config.deploy.expect("deploy configured");
18692 assert_eq!(
18693 deploy.hosts,
18694 expected
18695 .iter()
18696 .map(|h| (*h).to_owned())
18697 .collect::<Vec<String>>(),
18698 "AUTUMN_DEPLOY__HOSTS={value:?} must drop blank segments, got: {:?}",
18699 deploy.hosts
18700 );
18701 assert!(
18702 deploy.validate().is_ok(),
18703 "a tolerated trailing comma must not refuse the fleet, got: {:?}",
18704 deploy.validate()
18705 );
18706 }
18707 }
18708
18709 #[test]
18710 fn a_non_empty_deploy_host_env_override_clears_the_toml_alternate_spelling() {
18711 let mut fleet_over_scalar: AutumnConfig = toml::from_str(
18718 r#"
18719 [deploy]
18720 host = "203.0.113.10"
18721 "#,
18722 )
18723 .expect("[deploy] host should parse");
18724 fleet_over_scalar.apply_env_overrides_with_env(
18725 &MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", "web-1,web-2"),
18726 );
18727 let deploy = fleet_over_scalar.deploy.expect("deploy configured");
18728 assert_eq!(
18729 deploy.hosts,
18730 vec!["web-1".to_owned(), "web-2".to_owned()],
18731 "the env fleet list must win, got: {:?}",
18732 deploy.hosts
18733 );
18734 assert_eq!(
18735 deploy.host, None,
18736 "a non-empty AUTUMN_DEPLOY__HOSTS must CLEAR the TOML `host`, got: {:?}",
18737 deploy.host
18738 );
18739 assert!(
18740 deploy.validate().is_ok(),
18741 "env-over-TOML retarget must not trip mutual exclusion, got: {:?}",
18742 deploy.validate()
18743 );
18744
18745 let mut scalar_over_fleet: AutumnConfig = toml::from_str(
18746 r#"
18747 [deploy]
18748 hosts = ["toml-1", "toml-2"]
18749 "#,
18750 )
18751 .expect("[deploy] hosts should parse");
18752 scalar_over_fleet
18753 .apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOST", "single-1"));
18754 let deploy = scalar_over_fleet.deploy.expect("deploy configured");
18755 assert_eq!(deploy.host.as_deref(), Some("single-1"));
18756 assert!(
18757 deploy.hosts.is_empty(),
18758 "a non-empty AUTUMN_DEPLOY__HOST must CLEAR the TOML `hosts`, got: {:?}",
18759 deploy.hosts
18760 );
18761 assert!(
18762 deploy.validate().is_ok(),
18763 "env-over-TOML narrowing must not trip mutual exclusion, got: {:?}",
18764 deploy.validate()
18765 );
18766 }
18767
18768 #[test]
18769 fn two_conflicting_non_empty_deploy_host_env_overrides_are_still_refused() {
18770 for toml_src in [
18776 "[deploy]\n",
18777 "[deploy]\nhost = \"toml-host\"\n",
18778 "[deploy]\nhosts = [\"toml-1\"]\n",
18779 ] {
18780 let mut config: AutumnConfig =
18781 toml::from_str(toml_src).expect("[deploy] table should parse");
18782 config.apply_env_overrides_with_env(
18783 &MockEnv::new()
18784 .with("AUTUMN_DEPLOY__HOST", "env-single")
18785 .with("AUTUMN_DEPLOY__HOSTS", "env-1,env-2"),
18786 );
18787 let deploy = config.deploy.expect("deploy configured");
18788 assert_eq!(
18789 deploy.host.as_deref(),
18790 Some("env-single"),
18791 "both env spellings must survive so the conflict is visible ({toml_src:?})"
18792 );
18793 assert_eq!(
18794 deploy.hosts,
18795 vec!["env-1".to_owned(), "env-2".to_owned()],
18796 "both env spellings must survive so the conflict is visible ({toml_src:?})"
18797 );
18798 assert!(
18799 deploy.validate().is_err(),
18800 "two conflicting NON-EMPTY env spellings must still be refused ({toml_src:?})",
18801 );
18802 }
18803 }
18804
18805 #[test]
18806 fn an_empty_deploy_host_env_override_never_clears_the_toml_alternate_spelling() {
18807 for blank in ["", " ", ",", " , "] {
18813 let mut config: AutumnConfig = toml::from_str(
18814 r#"
18815 [deploy]
18816 host = "203.0.113.10"
18817 "#,
18818 )
18819 .expect("[deploy] host should parse");
18820 config
18821 .apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", blank));
18822 let deploy = config.deploy.expect("deploy configured");
18823 assert_eq!(
18824 deploy.host.as_deref(),
18825 Some("203.0.113.10"),
18826 "AUTUMN_DEPLOY__HOSTS={blank:?} must not clear the TOML `host`"
18827 );
18828 assert!(deploy.hosts.is_empty());
18829 }
18830
18831 for blank in ["", " "] {
18832 let mut config: AutumnConfig = toml::from_str(
18833 r#"
18834 [deploy]
18835 hosts = ["toml-1", "toml-2"]
18836 "#,
18837 )
18838 .expect("[deploy] hosts should parse");
18839 config.apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOST", blank));
18840 let deploy = config.deploy.expect("deploy configured");
18841 assert_eq!(
18842 deploy.hosts,
18843 vec!["toml-1".to_owned(), "toml-2".to_owned()],
18844 "AUTUMN_DEPLOY__HOST={blank:?} must not clear the TOML `hosts`"
18845 );
18846 }
18847 let mut config = AutumnConfig {
18850 deploy: Some(DeployConfig {
18851 host: Some("toml-host".to_owned()),
18852 ..DeployConfig::default()
18853 }),
18854 ..AutumnConfig::default()
18855 };
18856 config.apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOST", ""));
18857 assert_eq!(config.deploy.expect("deploy configured").host, None);
18858 }
18859
18860 #[test]
18863 fn client_auth_absent_is_none_and_off() {
18864 let config: AutumnConfig = toml::from_str(
18865 r#"
18866 [server.tls]
18867 cert_path = "cert.pem"
18868 key_path = "key.pem"
18869 "#,
18870 )
18871 .expect("server-only TLS should parse");
18872 let tls = config.server.tls.expect("tls configured");
18873 assert!(tls.client_auth.is_none());
18875 assert!(!tls.client_auth_active());
18876 assert!(tls.validate().is_ok());
18877 }
18878
18879 #[test]
18880 fn client_auth_parses_modes_and_paths() {
18881 let config: AutumnConfig = toml::from_str(
18882 r#"
18883 [server.tls]
18884 cert_path = "cert.pem"
18885 key_path = "key.pem"
18886
18887 [server.tls.client_auth]
18888 mode = "required"
18889 ca_bundle_path = "/etc/autumn/tls/client-ca.pem"
18890 crl_path = "/etc/autumn/tls/client-ca.crl.pem"
18891 required_paths = ["/internal/"]
18892 "#,
18893 )
18894 .expect("[server.tls.client_auth] should parse");
18895 let tls = config.server.tls.expect("tls configured");
18896 let ca = tls.client_auth.as_ref().expect("client_auth configured");
18897 assert_eq!(ca.mode, ClientAuthMode::Required);
18898 assert_eq!(
18899 ca.ca_bundle_path,
18900 Some(PathBuf::from("/etc/autumn/tls/client-ca.pem"))
18901 );
18902 assert_eq!(
18903 ca.crl_path,
18904 Some(PathBuf::from("/etc/autumn/tls/client-ca.crl.pem"))
18905 );
18906 assert_eq!(ca.required_paths, vec!["/internal/".to_owned()]);
18907 assert_eq!(ca.reload_interval_secs, 60);
18908 assert!(tls.client_auth_active());
18909 assert!(tls.validate().is_ok());
18910 }
18911
18912 #[test]
18913 fn client_auth_mode_defaults_to_off() {
18914 let config: AutumnConfig = toml::from_str(
18915 r#"
18916 [server.tls]
18917 cert_path = "cert.pem"
18918 key_path = "key.pem"
18919
18920 [server.tls.client_auth]
18921 "#,
18922 )
18923 .expect("an empty [server.tls.client_auth] should parse");
18924 let tls = config.server.tls.expect("tls configured");
18925 let ca = tls.client_auth.as_ref().expect("client_auth present");
18926 assert_eq!(ca.mode, ClientAuthMode::Off);
18927 assert!(!tls.client_auth_active());
18929 assert!(tls.validate().is_ok());
18930 }
18931
18932 #[test]
18933 fn client_auth_requires_a_ca_bundle_when_enabled() {
18934 let config: AutumnConfig = toml::from_str(
18935 r#"
18936 [server.tls]
18937 cert_path = "cert.pem"
18938 key_path = "key.pem"
18939
18940 [server.tls.client_auth]
18941 mode = "optional"
18942 "#,
18943 )
18944 .expect("parse");
18945 let err = config
18946 .server
18947 .tls
18948 .expect("tls configured")
18949 .validate()
18950 .expect_err("client auth without a trust store is invalid");
18951 assert!(err.contains("ca_bundle_path"), "unhelpful message: {err}");
18952 }
18953
18954 #[test]
18955 fn client_auth_rejects_blank_paths() {
18956 let ca = ClientAuthConfig {
18957 mode: ClientAuthMode::Required,
18958 ca_bundle_path: Some(PathBuf::new()),
18959 ..ClientAuthConfig::default()
18960 };
18961 let err = ca
18962 .validate()
18963 .expect_err("a blank ca_bundle_path is invalid");
18964 assert!(err.contains("ca_bundle_path"), "unhelpful message: {err}");
18965
18966 let ca = ClientAuthConfig {
18967 mode: ClientAuthMode::Required,
18968 ca_bundle_path: Some(PathBuf::from("ca.pem")),
18969 crl_path: Some(PathBuf::new()),
18970 ..ClientAuthConfig::default()
18971 };
18972 let err = ca.validate().expect_err("a blank crl_path is invalid");
18973 assert!(err.contains("crl_path"), "unhelpful message: {err}");
18974 }
18975
18976 #[test]
18977 fn client_auth_rejects_required_paths_when_mode_is_off() {
18978 let ca = ClientAuthConfig {
18982 required_paths: vec!["/internal/".to_owned()],
18983 ..ClientAuthConfig::default()
18984 };
18985 let err = ca
18986 .validate()
18987 .expect_err("required_paths with mode = off is invalid");
18988 assert!(err.contains("required_paths"), "unhelpful message: {err}");
18989 }
18990
18991 #[test]
18992 fn client_auth_rejects_relative_required_paths() {
18993 let ca = ClientAuthConfig {
18994 mode: ClientAuthMode::Optional,
18995 ca_bundle_path: Some(PathBuf::from("ca.pem")),
18996 required_paths: vec!["internal/".to_owned()],
18997 ..ClientAuthConfig::default()
18998 };
18999 let err = ca.validate().expect_err("a required_path must be rooted");
19000 assert!(err.contains("required_paths"), "unhelpful message: {err}");
19001 }
19002
19003 #[test]
19004 fn client_auth_rejects_noncanonical_required_paths() {
19005 for bad in [
19010 "/internal//",
19011 "//internal",
19012 "/internal/./keys",
19013 "/internal/../admin",
19014 ] {
19015 let ca = ClientAuthConfig {
19016 mode: ClientAuthMode::Required,
19017 ca_bundle_path: Some(PathBuf::from("ca.pem")),
19018 required_paths: vec![bad.to_owned()],
19019 ..ClientAuthConfig::default()
19020 };
19021 let Err(err) = ca.validate() else {
19022 panic!("`{bad}` should be rejected");
19023 };
19024 assert!(err.contains("required_paths"), "unhelpful message: {err}");
19025 }
19026
19027 for good in ["/internal", "/internal/", "/internal/keys", "/"] {
19029 let ca = ClientAuthConfig {
19030 mode: ClientAuthMode::Required,
19031 ca_bundle_path: Some(PathBuf::from("ca.pem")),
19032 required_paths: vec![good.to_owned()],
19033 ..ClientAuthConfig::default()
19034 };
19035 assert!(ca.validate().is_ok(), "`{good}` should be accepted");
19036 }
19037 }
19038
19039 #[test]
19040 fn client_auth_mode_requires_a_certificate_only_when_required() {
19041 assert!(!ClientAuthMode::Off.requests_certificate());
19042 assert!(ClientAuthMode::Optional.requests_certificate());
19043 assert!(ClientAuthMode::Required.requests_certificate());
19044 assert!(!ClientAuthMode::Optional.mandates_certificate());
19045 assert!(ClientAuthMode::Required.mandates_certificate());
19046 }
19047
19048 fn tls_static(cert: Option<&str>, key: Option<&str>) -> TlsConfig {
19051 TlsConfig {
19052 cert_path: cert.map(PathBuf::from),
19053 key_path: key.map(PathBuf::from),
19054 reload_interval_secs: default_tls_reload_interval_secs(),
19055 handshake_timeout_secs: default_tls_handshake_timeout_secs(),
19056 acme: None,
19057 client_auth: None,
19058 }
19059 }
19060
19061 fn acme_cfg(domains: &[&str], email: &str) -> AcmeConfig {
19062 AcmeConfig {
19063 domains: domains.iter().map(|d| (*d).to_owned()).collect(),
19064 contact_email: email.to_owned(),
19065 directory: AcmeDirectory::Staging,
19066 cache_dir: default_acme_cache_dir(),
19067 http_challenge_port: default_acme_http_challenge_port(),
19068 renew_before_days: default_acme_renew_before_days(),
19069 ca_root_path: None,
19070 dns: None,
19071 custom_domains: None,
19072 }
19073 }
19074
19075 #[test]
19076 fn acme_parses_from_toml_with_defaults() {
19077 let config: AutumnConfig = toml::from_str(
19078 r#"
19079 [server.tls.acme]
19080 domains = ["app.example.com"]
19081 contact_email = "ops@example.com"
19082 "#,
19083 )
19084 .expect("config with [server.tls.acme] should parse");
19085 let tls = config.server.tls.expect("tls configured");
19086 let acme = tls.acme.as_ref().expect("acme configured");
19087 assert_eq!(acme.domains, vec!["app.example.com".to_owned()]);
19088 assert_eq!(acme.contact_email, "ops@example.com");
19089 assert_eq!(acme.directory, AcmeDirectory::Staging);
19091 assert_eq!(acme.cache_dir, PathBuf::from("config/acme"));
19092 assert_eq!(acme.http_challenge_port, 80);
19093 assert_eq!(acme.renew_before_days, 30);
19094 assert!(tls.validate().is_ok());
19095 }
19096
19097 #[test]
19098 fn acme_ca_root_path_defaults_to_unset_and_round_trips() {
19099 let acme = acme_cfg(&["app.example.com"], "ops@example.com");
19102 assert_eq!(acme.ca_root_path, None);
19103 assert!(acme.validate().is_ok());
19104
19105 let config: AutumnConfig = toml::from_str(
19106 r#"
19107 [server.tls.acme]
19108 domains = ["app.example.com"]
19109 contact_email = "ops@example.com"
19110 directory = { custom = { url = "https://pebble.test/dir" } }
19111 ca_root_path = "config/pebble-root.pem"
19112 "#,
19113 )
19114 .expect("ca_root_path should parse");
19115 let acme = config.server.tls.unwrap().acme.unwrap();
19116 assert_eq!(
19117 acme.ca_root_path,
19118 Some(PathBuf::from("config/pebble-root.pem"))
19119 );
19120 assert!(acme.validate().is_ok());
19121 }
19122
19123 #[test]
19124 fn acme_rejects_a_blank_ca_root_path() {
19125 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19129 acme.ca_root_path = Some(PathBuf::new());
19130 let err = acme
19131 .validate()
19132 .expect_err("a blank ca_root_path is invalid");
19133 assert!(err.contains("ca_root_path"), "unhelpful message: {err}");
19134 }
19135
19136 #[test]
19137 fn acme_directory_custom_parses() {
19138 let config: AutumnConfig = toml::from_str(
19139 r#"
19140 [server.tls.acme]
19141 domains = ["a.example.com"]
19142 contact_email = "ops@example.com"
19143 directory = { custom = { url = "https://pebble.test/dir" } }
19144 "#,
19145 )
19146 .expect("custom directory should parse");
19147 let acme = config.server.tls.unwrap().acme.unwrap();
19148 assert_eq!(
19149 acme.directory,
19150 AcmeDirectory::Custom {
19151 url: "https://pebble.test/dir".to_owned()
19152 }
19153 );
19154 }
19155
19156 #[test]
19157 fn validate_static_only_ok() {
19158 assert!(tls_static(Some("c.pem"), Some("k.pem")).validate().is_ok());
19159 }
19160
19161 #[test]
19162 fn validate_acme_only_ok() {
19163 let mut cfg = tls_static(None, None);
19164 cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
19165 assert!(cfg.validate().is_ok());
19166 }
19167
19168 #[test]
19169 fn validate_both_static_and_acme_rejected() {
19170 let mut cfg = tls_static(Some("c.pem"), Some("k.pem"));
19171 cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
19172 let err = cfg.validate().unwrap_err();
19173 assert!(err.contains("choose exactly one"), "got: {err}");
19174 }
19175
19176 #[test]
19177 fn validate_neither_static_nor_acme_rejected() {
19178 let err = tls_static(None, None).validate().unwrap_err();
19179 assert!(err.contains("exactly one of"), "got: {err}");
19180 }
19181
19182 #[test]
19183 fn validate_cert_without_key_rejected() {
19184 let err = tls_static(Some("c.pem"), None).validate().unwrap_err();
19185 assert!(err.contains("set together"), "got: {err}");
19186 }
19187
19188 #[test]
19189 fn validate_acme_empty_domains_rejected() {
19190 let mut cfg = tls_static(None, None);
19191 cfg.acme = Some(acme_cfg(&[], "ops@example.com"));
19192 let err = cfg.validate().unwrap_err();
19193 assert!(err.contains("at least one domain"), "got: {err}");
19194 }
19195
19196 #[test]
19197 fn validate_acme_empty_email_rejected() {
19198 let mut cfg = tls_static(None, None);
19199 cfg.acme = Some(acme_cfg(&["app.example.com"], " "));
19200 let err = cfg.validate().unwrap_err();
19201 assert!(err.contains("contact_email"), "got: {err}");
19202 }
19203
19204 #[test]
19205 fn validate_acme_wildcard_domain_without_dns_provider_rejected() {
19206 let mut cfg = tls_static(None, None);
19207 cfg.acme = Some(acme_cfg(&["*.example.com"], "ops@example.com"));
19208 let err = cfg.validate().unwrap_err();
19209 assert!(err.contains("[server.tls.acme.dns]"), "got: {err}");
19210 assert!(err.contains("wildcard"), "got: {err}");
19211 }
19212
19213 #[test]
19217 fn validate_acme_blank_domain_entry_rejected() {
19218 let mut cfg = tls_static(None, None);
19219 cfg.acme = Some(acme_cfg(&[""], "ops@example.com"));
19220 let err = cfg.validate().unwrap_err();
19221 assert!(err.contains("blank entries"), "got: {err}");
19222
19223 let mut cfg = tls_static(None, None);
19225 cfg.acme = Some(acme_cfg(&[" "], "ops@example.com"));
19226 let err = cfg.validate().unwrap_err();
19227 assert!(err.contains("blank entries"), "got: {err}");
19228 }
19229
19230 #[test]
19237 fn acme_numeric_fields_reject_non_integer_toml_values() {
19238 for key in ["http_challenge_port", "renew_before_days"] {
19239 for bad in ["\"30\"", "30.5", "true", "-1", "4294967296"] {
19240 let src = format!(
19241 "[server.tls.acme]\ndomains = [\"app.example.com\"]\n\
19242 contact_email = \"ops@example.com\"\n{key} = {bad}\n"
19243 );
19244 assert!(
19245 toml::from_str::<AutumnConfig>(&src).is_err(),
19246 "{key} = {bad} must fail to deserialize"
19247 );
19248 }
19249 let src = format!(
19252 "[server.tls.acme]\ndomains = [\"app.example.com\"]\n\
19253 contact_email = \"ops@example.com\"\n{key} = 45\n"
19254 );
19255 assert!(
19256 toml::from_str::<AutumnConfig>(&src).is_ok(),
19257 "{key} = 45 must deserialize"
19258 );
19259 }
19260 }
19261
19262 #[test]
19269 fn validate_acme_whitespace_padded_domain_rejected() {
19270 for padded in [
19271 " app.example.com",
19272 "app.example.com ",
19273 "\tapp.example.com\n",
19274 ] {
19275 let mut cfg = tls_static(None, None);
19276 cfg.acme = Some(acme_cfg(&[padded], "ops@example.com"));
19277 let err = cfg
19278 .validate()
19279 .expect_err("a whitespace-padded domain must be rejected");
19280 assert!(
19281 err.contains("whitespace"),
19282 "message must name the problem: {err}"
19283 );
19284 assert!(
19285 err.contains(&format!("`{padded}`")),
19286 "message must echo the RAW padded entry, not only the trimmed \
19287 spelling it suggests: {err}"
19288 );
19289 assert!(
19290 err.contains("`app.example.com`"),
19291 "message must name the trimmed spelling to use: {err}"
19292 );
19293 }
19294
19295 let mut cfg = tls_static(None, None);
19298 cfg.acme = Some(acme_cfg(
19299 &["app.example.com", " www.example.com "],
19300 "ops@example.com",
19301 ));
19302 let err = cfg.validate().unwrap_err();
19303 assert!(
19304 err.contains("index 1"),
19305 "message must name the index: {err}"
19306 );
19307
19308 let mut cfg = tls_static(None, None);
19310 cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
19311 assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19312 }
19313
19314 #[test]
19319 fn validate_acme_padded_domain_rule_does_not_shadow_blank_or_wildcard() {
19320 let mut cfg = tls_static(None, None);
19321 cfg.acme = Some(acme_cfg(&[" "], "ops@example.com"));
19322 let err = cfg.validate().unwrap_err();
19323 assert!(err.contains("blank entries"), "got: {err}");
19324
19325 let mut cfg = tls_static(None, None);
19326 cfg.acme = Some(acme_cfg(&[" *.example.com "], "ops@example.com"));
19327 let err = cfg.validate().unwrap_err();
19328 assert!(err.contains("wildcard"), "got: {err}");
19329 assert!(err.contains("[server.tls.acme.dns]"), "got: {err}");
19330 }
19331
19332 #[test]
19336 fn validate_acme_zero_http_challenge_port_rejected() {
19337 let mut cfg = tls_static(None, None);
19338 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19339 acme.http_challenge_port = 0;
19340 cfg.acme = Some(acme);
19341 let err = cfg.validate().unwrap_err();
19342 assert!(err.contains("http_challenge_port"), "got: {err}");
19343 }
19344
19345 #[test]
19351 fn validate_acme_renew_before_days_at_or_above_cert_lifetime_rejected() {
19352 let mut cfg = tls_static(None, None);
19354 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19355 acme.renew_before_days = 100;
19356 cfg.acme = Some(acme);
19357 let err = cfg.validate().unwrap_err();
19358 assert!(err.contains("renew_before_days"), "got: {err}");
19359 assert!(err.contains("rate limits"), "got: {err}");
19360
19361 let mut cfg = tls_static(None, None);
19364 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19365 acme.renew_before_days = 90;
19366 cfg.acme = Some(acme);
19367 assert!(
19368 cfg.validate().is_err(),
19369 "renew_before_days == 90 must be rejected"
19370 );
19371
19372 let mut cfg = tls_static(None, None);
19374 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19375 acme.renew_before_days = 30;
19376 cfg.acme = Some(acme);
19377 assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19378
19379 let mut cfg = tls_static(None, None);
19381 let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
19382 acme.renew_before_days = 89;
19383 cfg.acme = Some(acme);
19384 assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19385 }
19386
19387 fn acme_dns_cfg(provider: AcmeDnsProvider) -> AcmeDnsConfig {
19390 AcmeDnsConfig {
19391 provider,
19392 credential: default_acme_dns_credential(),
19393 propagation_timeout_secs: default_acme_dns_propagation_timeout_secs(),
19394 poll_interval_secs: default_acme_dns_poll_interval_secs(),
19395 resolvers: default_acme_dns_resolvers(),
19396 command: Vec::new(),
19397 }
19398 }
19399
19400 #[test]
19401 fn acme_dns_section_parses_with_defaults() {
19402 let config: AutumnConfig = toml::from_str(
19403 r#"
19404 [server.tls.acme]
19405 domains = ["myapp.com", "*.myapp.com"]
19406 contact_email = "ops@myapp.com"
19407
19408 [server.tls.acme.dns]
19409 provider = "cloudflare"
19410 "#,
19411 )
19412 .expect("[server.tls.acme.dns] should parse");
19413 let tls = config.server.tls.expect("tls configured");
19414 let acme = tls.acme.as_ref().expect("acme configured");
19415 let dns = acme.dns.as_ref().expect("dns configured");
19416 assert_eq!(dns.provider, AcmeDnsProvider::Cloudflare);
19417 assert_eq!(dns.credential, "acme_dns");
19419 assert_eq!(dns.propagation_timeout_secs, 300);
19420 assert_eq!(dns.poll_interval_secs, 5);
19421 assert!(!dns.resolvers.is_empty());
19422 assert!(tls.validate().is_ok(), "got: {:?}", tls.validate());
19423 }
19424
19425 #[test]
19426 fn acme_dns_provider_names_round_trip() {
19427 for (spelling, expected) in [
19428 ("cloudflare", AcmeDnsProvider::Cloudflare),
19429 ("route53", AcmeDnsProvider::Route53),
19430 ("exec", AcmeDnsProvider::Exec),
19431 ] {
19432 let extra = if expected == AcmeDnsProvider::Exec {
19433 "\ncommand = [\"/usr/local/bin/dns-hook\"]"
19434 } else {
19435 ""
19436 };
19437 let src = format!(
19438 "[server.tls.acme]\ndomains = [\"*.myapp.com\"]\n\
19439 contact_email = \"ops@myapp.com\"\n\
19440 [server.tls.acme.dns]\nprovider = \"{spelling}\"{extra}\n"
19441 );
19442 let config: AutumnConfig =
19443 toml::from_str(&src).unwrap_or_else(|e| panic!("{spelling} should parse: {e}"));
19444 let acme = config.server.tls.unwrap().acme.unwrap();
19445 assert_eq!(acme.dns.as_ref().unwrap().provider, expected);
19446 assert!(acme.validate().is_ok(), "{spelling}: {:?}", acme.validate());
19447 }
19448 }
19449
19450 #[test]
19455 fn acme_dns_section_rejects_an_inline_secret() {
19456 for secret_key in [
19457 "api_token",
19458 "token",
19459 "secret_access_key",
19460 "access_key_id",
19461 "api_key",
19462 ] {
19463 let src = format!(
19464 "[server.tls.acme]\ndomains = [\"*.myapp.com\"]\n\
19465 contact_email = \"ops@myapp.com\"\n\
19466 [server.tls.acme.dns]\nprovider = \"cloudflare\"\n{secret_key} = \"s3cret\"\n"
19467 );
19468 let err = toml::from_str::<AutumnConfig>(&src)
19469 .err()
19470 .unwrap_or_else(|| panic!("{secret_key} in autumn.toml must be rejected"));
19471 let msg = err.to_string();
19472 assert!(
19473 msg.contains(secret_key),
19474 "the rejection must name the offending key: {msg}"
19475 );
19476 }
19477 }
19478
19479 #[test]
19482 fn validate_acme_wildcard_requires_a_dns_provider() {
19483 let mut cfg = tls_static(None, None);
19484 cfg.acme = Some(acme_cfg(&["*.myapp.com"], "ops@myapp.com"));
19485 let err = cfg
19486 .validate()
19487 .expect_err("a wildcard without [server.tls.acme.dns] must be rejected");
19488 assert!(err.contains("wildcard"), "got: {err}");
19489 assert!(
19490 err.contains("[server.tls.acme.dns]"),
19491 "the message must name the section that fixes it: {err}"
19492 );
19493 assert!(err.contains("DNS-01"), "got: {err}");
19494
19495 let mut cfg = tls_static(None, None);
19497 let mut acme = acme_cfg(&["myapp.com", "*.myapp.com"], "ops@myapp.com");
19498 acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Cloudflare));
19499 cfg.acme = Some(acme);
19500 assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19501 }
19502
19503 #[test]
19505 fn validate_acme_rejects_malformed_wildcards() {
19506 for bad in ["*", "*.", "*myapp.com", "app.*.myapp.com", "*.*.myapp.com"] {
19507 let mut cfg = tls_static(None, None);
19508 let mut acme = acme_cfg(&[bad], "ops@myapp.com");
19509 acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Cloudflare));
19510 cfg.acme = Some(acme);
19511 let err = cfg.validate().expect_err(&format!(
19512 "`{bad}` is not a usable wildcard and must be rejected"
19513 ));
19514 assert!(
19515 err.contains(bad),
19516 "the message must echo the offending entry `{bad}`: {err}"
19517 );
19518 }
19519
19520 let mut cfg = tls_static(None, None);
19522 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19523 acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Cloudflare));
19524 cfg.acme = Some(acme);
19525 assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19526 }
19527
19528 #[test]
19529 fn validate_acme_dns_exec_requires_a_command() {
19530 let mut cfg = tls_static(None, None);
19531 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19532 acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Exec));
19533 cfg.acme = Some(acme);
19534 let err = cfg
19535 .validate()
19536 .expect_err("provider = \"exec\" with no command must be rejected");
19537 assert!(err.contains("command"), "got: {err}");
19538
19539 let mut cfg = tls_static(None, None);
19542 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19543 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19544 dns.command = vec!["/usr/local/bin/dns-hook".to_owned()];
19545 acme.dns = Some(dns);
19546 cfg.acme = Some(acme);
19547 let err = cfg
19548 .validate()
19549 .expect_err("command on cloudflare is invalid");
19550 assert!(err.contains("command"), "got: {err}");
19551 }
19552
19553 #[test]
19554 fn validate_acme_dns_rejects_unusable_propagation_bounds() {
19555 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19557 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19558 dns.propagation_timeout_secs = 0;
19559 acme.dns = Some(dns);
19560 let err = acme.validate().expect_err("zero timeout is invalid");
19561 assert!(err.contains("propagation_timeout_secs"), "got: {err}");
19562
19563 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19565 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19566 dns.poll_interval_secs = 0;
19567 acme.dns = Some(dns);
19568 let err = acme.validate().expect_err("zero poll interval is invalid");
19569 assert!(err.contains("poll_interval_secs"), "got: {err}");
19570
19571 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19573 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19574 dns.poll_interval_secs = 600;
19575 dns.propagation_timeout_secs = 60;
19576 acme.dns = Some(dns);
19577 let err = acme.validate().expect_err("interval > timeout is invalid");
19578 assert!(err.contains("poll_interval_secs"), "got: {err}");
19579 }
19580
19581 #[test]
19585 fn validate_acme_dns_rejects_an_unbounded_propagation_timeout() {
19586 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19587 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19588 dns.propagation_timeout_secs = u64::MAX;
19589 acme.dns = Some(dns);
19590 let err = acme
19591 .validate()
19592 .expect_err("an unbounded propagation budget is invalid");
19593 assert!(err.contains("propagation_timeout_secs"), "got: {err}");
19594
19595 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19597 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19598 dns.propagation_timeout_secs = MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS;
19599 acme.dns = Some(dns);
19600 assert!(acme.validate().is_ok(), "got: {:?}", acme.validate());
19601 }
19602
19603 #[test]
19606 fn validate_acme_dns_rejects_an_unusable_exec_program() {
19607 for bad in [
19608 vec![String::new()],
19609 vec![" ".to_owned(), "arg".to_owned()],
19610 ] {
19611 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19612 let mut dns = acme_dns_cfg(AcmeDnsProvider::Exec);
19613 dns.command = bad.clone();
19614 acme.dns = Some(dns);
19615 let err = acme
19616 .validate()
19617 .expect_err(&format!("`{bad:?}` is not a usable exec command"));
19618 assert!(err.contains("command"), "got: {err}");
19619 }
19620
19621 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19622 let mut dns = acme_dns_cfg(AcmeDnsProvider::Exec);
19623 dns.command = vec![" /usr/local/bin/hook ".to_owned()];
19624 acme.dns = Some(dns);
19625 let err = acme.validate().expect_err("a padded program is invalid");
19626 assert!(err.contains("whitespace"), "got: {err}");
19627 }
19628
19629 #[test]
19630 fn validate_acme_dns_rejects_a_blank_or_unparseable_resolver() {
19631 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19632 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19633 dns.resolvers = Vec::new();
19634 acme.dns = Some(dns);
19635 let err = acme.validate().expect_err("no resolvers is invalid");
19636 assert!(err.contains("resolvers"), "got: {err}");
19637
19638 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19639 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19640 dns.resolvers = vec!["not a resolver".to_owned()];
19641 acme.dns = Some(dns);
19642 let err = acme.validate().expect_err("garbage resolver is invalid");
19643 assert!(err.contains("resolvers"), "got: {err}");
19644 }
19645
19646 #[test]
19647 fn acme_dns_resolver_addresses_accept_bare_ips_and_explicit_ports() {
19648 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19649 dns.resolvers = vec!["1.1.1.1".to_owned(), "9.9.9.9:5353".to_owned()];
19650 let addrs = dns.resolver_addrs().expect("resolvers parse");
19651 assert_eq!(addrs[0].port(), 53, "a bare IP defaults to port 53");
19652 assert_eq!(addrs[1].port(), 5353);
19653 }
19654
19655 #[test]
19656 fn validate_acme_dns_rejects_a_blank_credential_reference() {
19657 for blank in ["", " "] {
19658 let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
19659 let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
19660 dns.credential = blank.to_owned();
19661 acme.dns = Some(dns);
19662 let err = acme.validate().expect_err("a blank credential is invalid");
19663 assert!(err.contains("credential"), "got: {err}");
19664 }
19665 }
19666
19667 #[test]
19670 fn san_covers_host_matches_rfc6125_wildcard_rules() {
19671 assert!(san_covers_host("myapp.com", "myapp.com"));
19673 assert!(
19674 san_covers_host("MyApp.COM", "myapp.com"),
19675 "case-insensitive"
19676 );
19677 assert!(!san_covers_host("myapp.com", "tenant1.myapp.com"));
19678
19679 assert!(san_covers_host("*.myapp.com", "tenant1.myapp.com"));
19681 assert!(
19682 !san_covers_host("*.myapp.com", "a.b.myapp.com"),
19683 "a wildcard must not span a dot"
19684 );
19685 assert!(
19686 !san_covers_host("*.myapp.com", "myapp.com"),
19687 "a wildcard does not cover the apex"
19688 );
19689 assert!(!san_covers_host("*.myapp.com", "myapp.com.evil.com"));
19690 assert!(!san_covers_host("*.myapp.com", ""));
19691
19692 assert!(san_covers_host("*.myapp.com", "tenant1.myapp.com."));
19694 }
19695
19696 #[test]
19697 fn acme_config_covers_host_uses_every_san() {
19698 let acme = acme_cfg(&["myapp.com", "*.myapp.com"], "ops@myapp.com");
19699 assert!(acme.covers_host("myapp.com"));
19700 assert!(acme.covers_host("tenant42.myapp.com"));
19701 assert!(!acme.covers_host("other.example.com"));
19702 assert!(!acme.covers_host("deep.tenant42.myapp.com"));
19703 }
19704
19705 #[test]
19708 fn validate_acme_valid_domains_and_port_ok() {
19709 let mut cfg = tls_static(None, None);
19710 cfg.acme = Some(acme_cfg(
19711 &["app.example.com", "www.example.com"],
19712 "ops@example.com",
19713 ));
19714 assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
19715 }
19716
19717 #[test]
19720 fn server_config_defaults_capacity_contract_none() {
19721 let config = AutumnConfig::default();
19722 assert!(config.server.capacity_contract.is_none());
19723 }
19724
19725 #[test]
19726 fn capacity_contract_parses_from_toml() {
19727 let config: AutumnConfig = toml::from_str(
19728 r#"
19729 [server]
19730 capacity_contract = "capacity.lock"
19731 "#,
19732 )
19733 .expect("config with server.capacity_contract should parse");
19734 assert_eq!(
19735 config.server.capacity_contract.as_deref(),
19736 Some("capacity.lock")
19737 );
19738 }
19739
19740 #[test]
19741 fn env_override_server_capacity_contract() {
19742 let env = MockEnv::new().with("AUTUMN_SERVER__CAPACITY_CONTRACT", "deploy/capacity.lock");
19743 let mut config = AutumnConfig::default();
19744 config.apply_env_overrides_with_env(&env);
19745 assert_eq!(
19746 config.server.capacity_contract.as_deref(),
19747 Some("deploy/capacity.lock")
19748 );
19749 }
19750
19751 #[test]
19754 fn admission_defaults_change_nothing() {
19755 let a = AutumnConfig::default().server.admission;
19756 assert_eq!(a.mode, AdmissionMode::Static);
19757 assert_eq!(a.algorithm, AdmissionAlgorithm::Gradient2);
19758 assert!(!a.trust_criticality_header);
19759 assert_eq!(
19760 a.partition_shares().unwrap(),
19761 crate::admission::PartitionShares::default()
19762 );
19763 }
19764
19765 #[test]
19766 fn admission_parses_from_toml() {
19767 let config: AutumnConfig = toml::from_str(
19768 r#"
19769 [server.admission]
19770 mode = "adaptive"
19771 algorithm = "vegas"
19772 min_limit = 4
19773 max_limit = 400
19774 initial_limit = 40
19775 latency_threshold_ms = 250
19776 trust_criticality_header = true
19777 [server.admission.partitions]
19778 default = 0.9
19779 sheddable = 0.4
19780 "#,
19781 )
19782 .expect("server.admission should parse");
19783 let a = &config.server.admission;
19784 assert_eq!(a.mode, AdmissionMode::Adaptive);
19785 assert_eq!(a.algorithm, AdmissionAlgorithm::Vegas);
19786 assert_eq!(
19787 (a.min_limit, a.max_limit, a.initial_limit),
19788 (4, Some(400), 40)
19789 );
19790 assert_eq!(a.latency_threshold_ms, 250);
19791 assert!(a.trust_criticality_header);
19792 let shares = a.partition_shares().unwrap();
19793 assert_eq!(
19794 shares.threshold(crate::admission::Criticality::Default, 100),
19795 90
19796 );
19797 assert_eq!(
19798 shares.threshold(crate::admission::Criticality::Sheddable, 100),
19799 40
19800 );
19801 config.validate().expect("valid admission config");
19802 }
19803
19804 #[test]
19805 fn admission_rejects_an_unknown_algorithm() {
19806 let err = toml::from_str::<AutumnConfig>(
19807 r#"
19808 [server.admission]
19809 algorithm = "hystrix"
19810 "#,
19811 );
19812 assert!(err.is_err());
19813 }
19814
19815 #[test]
19816 fn admission_env_overrides() {
19817 let env = MockEnv::new()
19818 .with("AUTUMN_SERVER__ADMISSION__MODE", "adaptive")
19819 .with("AUTUMN_SERVER__ADMISSION__ALGORITHM", "aimd")
19820 .with("AUTUMN_SERVER__ADMISSION__MIN_LIMIT", "2")
19821 .with("AUTUMN_SERVER__ADMISSION__MAX_LIMIT", "50")
19822 .with("AUTUMN_SERVER__ADMISSION__INITIAL_LIMIT", "10")
19823 .with("AUTUMN_SERVER__ADMISSION__LATENCY_THRESHOLD_MS", "99")
19824 .with("AUTUMN_SERVER__ADMISSION__TRUST_CRITICALITY_HEADER", "true")
19825 .with("AUTUMN_SERVER__ADMISSION__PARTITIONS__DEFAULT", "0.8")
19826 .with("AUTUMN_SERVER__ADMISSION__PARTITIONS__SHEDDABLE", "0.2");
19827 let mut config = AutumnConfig::default();
19828 config.apply_env_overrides_with_env(&env);
19829 let a = &config.server.admission;
19830 assert_eq!(a.mode, AdmissionMode::Adaptive);
19831 assert_eq!(a.algorithm, AdmissionAlgorithm::Aimd);
19832 assert_eq!(
19833 (a.min_limit, a.max_limit, a.initial_limit),
19834 (2, Some(50), 10)
19835 );
19836 assert_eq!(a.latency_threshold_ms, 99);
19837 assert!(a.trust_criticality_header);
19838 assert!((a.partitions.default - 0.8).abs() < f64::EPSILON);
19839 assert!((a.partitions.sheddable - 0.2).abs() < f64::EPSILON);
19840 }
19841
19842 #[cfg(feature = "http-client")]
19843 #[test]
19844 fn adaptive_throttle_parses_and_validates() {
19845 let config: AutumnConfig = toml::from_str(
19846 r"
19847 [http.client.adaptive_throttle]
19848 enabled = true
19849 k = 1.5
19850 window_secs = 60
19851 ",
19852 )
19853 .expect("adaptive_throttle should parse");
19854 let t = config.http.client.adaptive_throttle;
19855 assert!(t.enabled);
19856 assert!((t.k - 1.5).abs() < f64::EPSILON);
19857 assert_eq!(t.window_secs, 60);
19858 config.validate().expect("valid");
19859 assert!(
19860 !AutumnConfig::default()
19861 .http
19862 .client
19863 .adaptive_throttle
19864 .enabled
19865 );
19866
19867 let mut bad = AutumnConfig::default();
19868 bad.http.client.adaptive_throttle.k = 0.5;
19869 assert!(bad.validate().is_err(), "k < 1");
19870 let mut bad = AutumnConfig::default();
19871 bad.http.client.adaptive_throttle.window_secs = 0;
19872 assert!(bad.validate().is_err(), "window 0");
19873 }
19874
19875 #[test]
19876 fn admission_validate_rejects_bad_bounds_and_shares() {
19877 let mut config = AutumnConfig::default();
19878 config.server.admission.min_limit = 0;
19879 assert!(config.validate().is_err(), "min_limit = 0");
19880
19881 let mut config = AutumnConfig::default();
19882 config.server.admission.min_limit = 10;
19883 config.server.admission.max_limit = Some(5);
19884 assert!(config.validate().is_err(), "min_limit > max_limit");
19885
19886 let mut config = AutumnConfig::default();
19887 config.server.admission.partitions.sheddable = 0.9;
19888 config.server.admission.partitions.default = 0.5;
19889 assert!(config.validate().is_err(), "sheddable > default");
19890 }
19891
19892 #[test]
19895 fn server_config_defaults_max_concurrent_requests_none() {
19896 let config = AutumnConfig::default();
19899 assert!(config.server.max_concurrent_requests.is_none());
19900 }
19901
19902 #[test]
19903 fn max_concurrent_requests_parses_from_toml() {
19904 let config: AutumnConfig = toml::from_str(
19905 r"
19906 [server]
19907 max_concurrent_requests = 64
19908 ",
19909 )
19910 .expect("config with server.max_concurrent_requests should parse");
19911 assert_eq!(config.server.max_concurrent_requests, Some(64));
19912 }
19913
19914 #[test]
19915 fn env_override_server_max_concurrent_requests() {
19916 let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "128");
19917 let mut config = AutumnConfig::default();
19918 config.apply_env_overrides_with_env(&env);
19919 assert_eq!(config.server.max_concurrent_requests, Some(128));
19920 }
19921
19922 #[test]
19923 fn env_override_invalid_max_concurrent_requests_ignored() {
19924 let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "not_a_number");
19925 let mut config = AutumnConfig::default();
19926 config.apply_env_overrides_with_env(&env);
19927 assert!(config.server.max_concurrent_requests.is_none());
19928 }
19929
19930 #[test]
19931 fn env_override_empty_max_concurrent_requests_clears_to_none() {
19932 let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "");
19934 let mut config = AutumnConfig::default();
19935 config.server.max_concurrent_requests = Some(64);
19936 config.apply_env_overrides_with_env(&env);
19937 assert!(config.server.max_concurrent_requests.is_none());
19938 }
19939
19940 #[test]
19943 fn env_override_log_level() {
19944 let env = MockEnv::new().with("AUTUMN_LOG__LEVEL", "debug");
19945 let mut config = AutumnConfig::default();
19946 config.apply_env_overrides_with_env(&env);
19947 assert_eq!(config.log.level, "debug");
19948 }
19949
19950 #[test]
19951 fn env_override_log_format_json() {
19952 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Json");
19953 let mut config = AutumnConfig::default();
19954 config.apply_env_overrides_with_env(&env);
19955 assert_eq!(config.log.format, LogFormat::Json);
19956 }
19957
19958 #[test]
19959 fn env_override_log_format_pretty() {
19960 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Pretty");
19961 let mut config = AutumnConfig::default();
19962 config.apply_env_overrides_with_env(&env);
19963 assert_eq!(config.log.format, LogFormat::Pretty);
19964 }
19965
19966 #[test]
19967 fn env_override_invalid_log_format_ignored() {
19968 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "yaml");
19969 let mut config = AutumnConfig::default();
19970 config.apply_env_overrides_with_env(&env);
19971 assert_eq!(config.log.format, LogFormat::Auto);
19972 }
19973
19974 #[test]
19977 fn env_override_telemetry_fields() {
19978 let env = MockEnv::new()
19979 .with("AUTUMN_TELEMETRY__ENABLED", "true")
19980 .with("AUTUMN_TELEMETRY__SERVICE_NAME", "orders-api")
19981 .with("AUTUMN_TELEMETRY__SERVICE_NAMESPACE", "acme")
19982 .with("AUTUMN_TELEMETRY__SERVICE_VERSION", "1.2.3")
19983 .with("AUTUMN_TELEMETRY__ENVIRONMENT", "production")
19984 .with(
19985 "AUTUMN_TELEMETRY__OTLP_ENDPOINT",
19986 "http://otel-collector:4317",
19987 )
19988 .with("AUTUMN_TELEMETRY__PROTOCOL", "HTTP_PROTOBUF")
19989 .with("AUTUMN_TELEMETRY__STRICT", "true");
19990 let mut config = AutumnConfig::default();
19991 config.apply_env_overrides_with_env(&env);
19992 assert!(config.telemetry.enabled);
19993 assert_eq!(config.telemetry.service_name, "orders-api");
19994 assert_eq!(config.telemetry.service_namespace.as_deref(), Some("acme"));
19995 assert_eq!(config.telemetry.service_version, "1.2.3");
19996 assert_eq!(config.telemetry.environment, "production");
19997 assert_eq!(
19998 config.telemetry.otlp_endpoint.as_deref(),
19999 Some("http://otel-collector:4317")
20000 );
20001 assert_eq!(config.telemetry.protocol, TelemetryProtocol::HttpProtobuf);
20002 assert!(config.telemetry.strict);
20003 }
20004
20005 #[test]
20006 fn env_override_telemetry_sample_ratio() {
20007 let env = MockEnv::new().with("AUTUMN_TELEMETRY__SAMPLE_RATIO", "0.25");
20008 let mut config = AutumnConfig::default();
20009 config.apply_env_overrides_with_env(&env);
20010 assert!((config.telemetry.sample_ratio - 0.25).abs() < f64::EPSILON);
20011
20012 let env = MockEnv::new().with("AUTUMN_TELEMETRY__SAMPLE_RATIO", "half");
20013 let mut config = AutumnConfig::default();
20014 config.apply_env_overrides_with_env(&env);
20015 assert!((config.telemetry.sample_ratio - 1.0).abs() < f64::EPSILON);
20016 }
20017
20018 #[test]
20019 fn env_override_invalid_telemetry_protocol_ignored() {
20020 let env = MockEnv::new().with("AUTUMN_TELEMETRY__PROTOCOL", "zipkin");
20021 let mut config = AutumnConfig::default();
20022 config.apply_env_overrides_with_env(&env);
20023 assert_eq!(config.telemetry.protocol, TelemetryProtocol::Grpc);
20024 }
20025
20026 #[test]
20027 fn env_override_health_path() {
20028 let env = MockEnv::new().with("AUTUMN_HEALTH__PATH", "/healthz");
20029 let mut config = AutumnConfig::default();
20030 config.apply_env_overrides_with_env(&env);
20031 assert_eq!(config.health.path, "/healthz");
20032 }
20033
20034 #[test]
20035 fn env_override_probe_paths() {
20036 let env = MockEnv::new()
20037 .with("AUTUMN_HEALTH__LIVE_PATH", "/livez")
20038 .with("AUTUMN_HEALTH__READY_PATH", "/readyz")
20039 .with("AUTUMN_HEALTH__STARTUP_PATH", "/startupz");
20040 let mut config = AutumnConfig::default();
20041 config.apply_env_overrides_with_env(&env);
20042 assert_eq!(config.health.live_path, "/livez");
20043 assert_eq!(config.health.ready_path, "/readyz");
20044 assert_eq!(config.health.startup_path, "/startupz");
20045 }
20046
20047 #[test]
20050 fn env_overrides_toml_values() {
20051 let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "9999");
20052 let dir = tempfile::tempdir().unwrap();
20053 let path = dir.path().join("autumn.toml");
20054 std::fs::write(&path, "[server]\nport = 4000\n").unwrap();
20055 let mut config = AutumnConfig::load_from(&path).unwrap();
20056 config.apply_env_overrides_with_env(&env);
20057 assert_eq!(config.server.port, 9999); }
20059
20060 #[test]
20063 fn validate_rejects_invalid_url_scheme() {
20064 let config = DatabaseConfig {
20065 url: Some("mysql://localhost/test".to_owned()),
20066 ..Default::default()
20067 };
20068 let result = config.validate();
20069 assert!(result.is_err());
20070 assert!(
20071 result
20072 .unwrap_err()
20073 .to_string()
20074 .contains("must start with postgres://")
20075 );
20076 }
20077
20078 #[test]
20079 fn validate_accepts_postgres_url() {
20080 let config = DatabaseConfig {
20081 url: Some("postgres://localhost/test".to_owned()),
20082 ..Default::default()
20083 };
20084 assert!(config.validate().is_ok());
20085 }
20086
20087 #[test]
20088 fn validate_accepts_postgresql_url() {
20089 let config = DatabaseConfig {
20090 url: Some("postgresql://localhost/test".to_owned()),
20091 ..Default::default()
20092 };
20093 assert!(config.validate().is_ok());
20094 }
20095
20096 #[test]
20097 fn validate_accepts_no_url() {
20098 let config = DatabaseConfig::default();
20099 assert!(config.validate().is_ok());
20100 }
20101
20102 #[test]
20103 fn validate_accepts_keyword_value_connection_strings() {
20104 for url in [
20108 "host=db user=app dbname=app",
20109 "host=db user=app sslmode=require",
20110 "host=db sslmode = require",
20111 "host=db password='p w' sslmode='verify-full'",
20112 "host=db password=https://looks-like-a-url sslmode=require",
20113 ] {
20114 let config = DatabaseConfig {
20115 url: Some(url.to_owned()),
20116 ..Default::default()
20117 };
20118 assert!(
20119 config.validate().is_ok(),
20120 "keyword/value string must validate: {url}"
20121 );
20122 }
20123 let config = DatabaseConfig {
20125 primary_url: Some("host=db user=app sslmode=require".to_owned()),
20126 ..Default::default()
20127 };
20128 assert!(config.validate().is_ok());
20129 let config = DatabaseConfig {
20130 primary_url: Some("postgres://db-control/app".to_owned()),
20131 shards: vec![ShardConfig {
20132 name: "s0".to_owned(),
20133 primary_url: "host=db-shard0 user=app dbname=app".to_owned(),
20134 replica_url: None,
20135 slots: None,
20136 primary_pool_size: None,
20137 replica_pool_size: None,
20138 replica_fallback: None,
20139 }],
20140 ..Default::default()
20141 };
20142 assert!(
20143 config.validate().is_ok(),
20144 "shard URLs accept the keyword form too: {:?}",
20145 config.validate()
20146 );
20147 }
20148
20149 #[test]
20150 fn validate_still_rejects_garbage_connection_strings() {
20151 for url in [
20152 "mysql://localhost/test",
20153 "mysql://localhost/test?a=b",
20154 "not a connection string",
20155 "localhost",
20156 "host=",
20157 "host='unterminated",
20158 ] {
20159 let config = DatabaseConfig {
20160 url: Some(url.to_owned()),
20161 ..Default::default()
20162 };
20163 let err = config
20164 .validate()
20165 .expect_err(&format!("garbage must be rejected: {url:?}"))
20166 .to_string();
20167 assert!(
20168 err.contains("must start with postgres:// or postgresql://"),
20169 "the error must stay clear about accepted forms, got: {err}"
20170 );
20171 }
20172 }
20173
20174 #[test]
20177 fn detect_backend_postgres_urls() {
20178 for url in [
20179 "postgres://localhost/app",
20180 "postgresql://user:pass@db:5432/app",
20181 ] {
20182 assert_eq!(
20183 DatabaseBackend::detect(url),
20184 Some(DatabaseBackend::Postgres),
20185 "{url} should detect as postgres"
20186 );
20187 }
20188 }
20189
20190 #[test]
20191 fn detect_backend_postgres_keyword_value() {
20192 assert_eq!(
20195 DatabaseBackend::detect("host=db user=app sslmode=require"),
20196 Some(DatabaseBackend::Postgres)
20197 );
20198 }
20199
20200 #[test]
20201 fn detect_backend_sqlite_schemes() {
20202 for url in [
20203 "sqlite:///var/lib/app.db", "sqlite://./relative.db",
20205 "sqlite::memory:",
20206 "sqlite:app.db", "file:app.db", ] {
20209 assert_eq!(
20210 DatabaseBackend::detect(url),
20211 Some(DatabaseBackend::Sqlite),
20212 "{url} should detect as sqlite"
20213 );
20214 }
20215 }
20216
20217 #[test]
20218 fn detect_backend_bare_path_is_unrecognized() {
20219 for target in ["/var/lib/app.db", "./app.db", "app.db", "C:\\db\\app.db"] {
20223 assert_eq!(
20224 DatabaseBackend::detect(target),
20225 None,
20226 "{target} must not be auto-detected as a backend"
20227 );
20228 }
20229 }
20230
20231 #[test]
20232 fn detect_backend_garbage_is_unrecognized() {
20233 for target in ["mysql://localhost/app", "not a connection string", "host="] {
20234 assert_eq!(DatabaseBackend::detect(target), None, "{target}");
20235 }
20236 }
20237
20238 #[test]
20241 fn validate_accepts_sqlite_url() {
20242 let config = DatabaseConfig {
20243 url: Some("sqlite:///var/lib/app.db".to_owned()),
20244 ..Default::default()
20245 };
20246 assert!(
20247 config.validate().is_ok(),
20248 "a sqlite:// target must be accepted as valid config: {:?}",
20249 config.validate()
20250 );
20251 }
20252
20253 #[test]
20254 fn validate_accepts_sqlite_primary_url() {
20255 let config = DatabaseConfig {
20256 primary_url: Some("sqlite::memory:".to_owned()),
20257 ..Default::default()
20258 };
20259 assert!(config.validate().is_ok(), "{:?}", config.validate());
20260 }
20261
20262 #[test]
20263 fn validate_rejects_replica_url_on_sqlite() {
20264 let config = DatabaseConfig {
20265 primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
20266 replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
20267 ..Default::default()
20268 };
20269 let err = config
20270 .validate()
20271 .expect_err("read replicas must be refused on sqlite")
20272 .to_string();
20273 assert!(
20274 err.contains("read replicas require the postgres backend"),
20275 "message must name the postgres requirement, got: {err}"
20276 );
20277 }
20278
20279 #[test]
20280 fn validate_rejects_shards_on_sqlite() {
20281 let config = DatabaseConfig {
20282 primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
20283 shards: vec![ShardConfig {
20284 name: "s0".to_owned(),
20285 primary_url: "postgres://db-shard0/app".to_owned(),
20286 replica_url: None,
20287 slots: None,
20288 primary_pool_size: None,
20289 replica_pool_size: None,
20290 replica_fallback: None,
20291 }],
20292 ..Default::default()
20293 };
20294 let err = config
20295 .validate()
20296 .expect_err("shards must be refused on sqlite")
20297 .to_string();
20298 assert!(
20299 err.contains("database shards require the postgres backend"),
20300 "message must name the postgres requirement, got: {err}"
20301 );
20302 }
20303
20304 fn fleet(mode: crate::fleet_layout::FleetMode) -> DatabaseFleetConfig {
20305 DatabaseFleetConfig {
20306 mode,
20307 root: "/var/lib/app/fleet".to_owned(),
20308 path: None,
20309 max_open: 16,
20310 pool_size: 2,
20311 create_on_demand: None,
20312 idle_close_secs: 300,
20313 restore_missing: false,
20314 ownership: None,
20315 }
20316 }
20317
20318 fn fleet_db(fleet_config: DatabaseFleetConfig) -> DatabaseConfig {
20319 DatabaseConfig {
20320 url: Some("sqlite:///var/lib/app/control.db".to_owned()),
20321 fleet: Some(fleet_config),
20322 ..Default::default()
20323 }
20324 }
20325
20326 #[test]
20327 #[allow(
20328 clippy::literal_string_with_formatting_args,
20329 reason = "fleet path templates use {placeholders}"
20330 )]
20331 fn validate_accepts_a_fleet_on_a_sqlite_control_database() {
20332 use crate::fleet_layout::FleetMode;
20333 fleet_db(fleet(FleetMode::Tenant)).validate().unwrap();
20334 fleet_db(fleet(FleetMode::Slot)).validate().unwrap();
20335 let tenant = fleet(FleetMode::Tenant);
20336 assert_eq!(tenant.path_template(), "{bucket}/{tenant}.db");
20337 assert!(!tenant.effective_create_on_demand());
20338 let slot = fleet(FleetMode::Slot);
20339 assert_eq!(slot.path_template(), "{bucket}/slot-{slot}.db");
20340 assert!(slot.effective_create_on_demand());
20341 }
20342
20343 #[test]
20344 fn validate_rejects_a_fleet_without_a_sqlite_control_database() {
20345 use crate::fleet_layout::FleetMode;
20346 for url in [
20347 None,
20348 Some("postgres://db/app"),
20349 Some("sqlite::memory:"),
20351 Some("sqlite:"),
20352 Some("sqlite://"),
20353 Some("file::memory:?cache=shared"),
20354 Some("sqlite:file:app?mode=memory&cache=shared"),
20355 ] {
20356 let config = DatabaseConfig {
20357 url: url.map(str::to_owned),
20358 fleet: Some(fleet(FleetMode::Tenant)),
20359 ..Default::default()
20360 };
20361 let err = config
20362 .validate()
20363 .expect_err("needs a file-backed sqlite control database")
20364 .to_string();
20365 assert!(err.contains("sqlite: control database"), "{url:?}: {err}");
20366 }
20367 }
20368
20369 #[test]
20370 fn percent_decoding_matches_sqlite_uri_paths() {
20371 assert_eq!(percent_decode_uri_path("/a/%66leet/b"), "/a/fleet/b");
20372 assert_eq!(percent_decode_uri_path("/a%2Fb"), "/a/b");
20373 assert_eq!(percent_decode_uri_path("/50%"), "/50%", "trailing % stays");
20374 assert_eq!(percent_decode_uri_path("/x%zz"), "/x%zz", "non-hex stays");
20375 assert_eq!(
20376 percent_decode_uri_path("/x%+1"),
20377 "/x%+1",
20378 "a sign is not hex"
20379 );
20380 assert_eq!(
20381 percent_decode_uri_path("/db%00tail"),
20382 "/db",
20383 "NUL ends the path"
20384 );
20385 }
20386
20387 #[test]
20388 fn validate_rejects_a_control_database_inside_the_fleet_root() {
20389 use crate::fleet_layout::FleetMode;
20390 for url in [
20393 "sqlite:///var/lib/app/control.db",
20394 "sqlite:///var/lib/app/./sub/../control.db",
20395 "sqlite:/var/lib/app/control.db?mode=rwc",
20396 "sqlite:file:/var/lib/app/control.db?cache=private",
20397 "sqlite:file:///var/lib/app/control.db",
20398 ] {
20399 let mut config = fleet_db(DatabaseFleetConfig {
20400 root: "/var/lib/app".to_owned(),
20401 path: Some("{tenant}.db".to_owned()),
20402 ..fleet(FleetMode::Tenant)
20403 });
20404 config.url = Some(url.to_owned());
20405 let err = config
20406 .validate()
20407 .expect_err("control database inside the fleet root")
20408 .to_string();
20409 assert!(err.contains("inside database.fleet.root"), "{url}: {err}");
20410 }
20411 for url in [
20414 "file:/var/lib/app/%66leet/control.db",
20415 "sqlite:file:/var/lib/app/%66leet/control.db?mode=rwc",
20416 "sqlite:file:///var/lib/app/fleet/control.db#frag",
20417 "file://localhost/var/lib/app/fleet/control.db",
20419 "sqlite:file://LOCALHOST/var/lib/app/fleet/control.db",
20420 ] {
20421 let mut config = fleet_db(fleet(FleetMode::Tenant));
20422 config.url = Some(url.to_owned());
20423 let err = config
20424 .validate()
20425 .expect_err("decoded control path is inside the fleet root")
20426 .to_string();
20427 assert!(err.contains("inside database.fleet.root"), "{url}: {err}");
20428 }
20429 let mut literal = fleet_db(DatabaseFleetConfig {
20432 root: "/srv/fleet?data".to_owned(),
20433 ..fleet(FleetMode::Tenant)
20434 });
20435 literal.url = Some("sqlite:///srv/fleet?data/control.db".to_owned());
20436 let err = literal
20437 .validate()
20438 .expect_err("a literal `?` path inside the root")
20439 .to_string();
20440 assert!(err.contains("inside database.fleet.root"), "{err}");
20441 fleet_db(fleet(FleetMode::Tenant)).validate().unwrap();
20444 let mut sibling = fleet_db(fleet(FleetMode::Tenant));
20445 sibling.url = Some("sqlite:///var/lib/app/fleet-control.db".to_owned());
20446 sibling.validate().unwrap();
20447 }
20448
20449 #[test]
20450 fn validate_rejects_a_fleet_alongside_shards_or_the_directory_router() {
20451 use crate::fleet_layout::FleetMode;
20452 let mut config = fleet_db(fleet(FleetMode::Slot));
20453 config.directory_shard_router = true;
20454 let err = config.validate().unwrap_err().to_string();
20455 assert!(err.contains("directory_shard_router"), "{err}");
20456
20457 let mut config = fleet_db(fleet(FleetMode::Slot));
20461 config.shards = vec![shard("s0", "postgres://db-shard0/app")];
20462 assert!(config.validate().is_err());
20463 let err = config.validate_fleet().unwrap_err().to_string();
20464 assert!(err.contains("declare one or the other"), "{err}");
20465 }
20466
20467 #[test]
20468 fn validate_rejects_malformed_fleet_sections() {
20469 use crate::fleet_layout::FleetMode;
20470 type Mutate = fn(&mut DatabaseFleetConfig);
20471 let cases: [(&str, Mutate); 6] = [
20472 ("root", |f| f.root = " ".to_owned()),
20473 ("not a database URL", |f| f.root = "sqlite:///x".to_owned()),
20474 ("{tenant}", |f| f.path = Some("{bucket}/all.db".to_owned())),
20475 ("..", |f| f.path = Some("../{tenant}.db".to_owned())),
20476 ("max_open", |f| f.max_open = 0),
20477 ("pool_size", |f| f.pool_size = 0),
20478 ];
20479 for (needle, mutate) in cases {
20480 let mut section = fleet(FleetMode::Tenant);
20481 mutate(&mut section);
20482 let err = fleet_db(section).validate().unwrap_err().to_string();
20483 assert!(err.contains(needle), "expected {needle:?} in {err}");
20484 }
20485 }
20486
20487 #[test]
20488 fn fleet_env_overrides_create_and_adjust_the_section() {
20489 use crate::fleet_layout::FleetMode;
20490 let mut config = AutumnConfig::default();
20491 config.apply_env_overrides_with_env(
20493 &MockEnv::new().with("AUTUMN_DATABASE__FLEET__MODE", "slot"),
20494 );
20495 assert!(config.database.fleet.is_none());
20496
20497 let env = MockEnv::new()
20498 .with("AUTUMN_DATABASE__FLEET__MODE", "tenant")
20499 .with("AUTUMN_DATABASE__FLEET__ROOT", "/data/fleet")
20500 .with("AUTUMN_DATABASE__FLEET__PATH", "{tenant}/main.db")
20501 .with("AUTUMN_DATABASE__FLEET__MAX_OPEN", "9")
20502 .with("AUTUMN_DATABASE__FLEET__POOL_SIZE", "1")
20503 .with("AUTUMN_DATABASE__FLEET__CREATE_ON_DEMAND", "true")
20504 .with("AUTUMN_DATABASE__FLEET__IDLE_CLOSE_SECS", "0")
20505 .with("AUTUMN_DATABASE__FLEET__RESTORE_MISSING", "true");
20506 config.apply_env_overrides_with_env(&env);
20507 let fleet = config.database.fleet.expect("env creates the fleet");
20508 assert_eq!(fleet.mode, FleetMode::Tenant);
20509 assert_eq!(fleet.root, "/data/fleet");
20510 assert_eq!(fleet.path_template(), "{tenant}/main.db");
20511 assert_eq!(fleet.max_open, 9);
20512 assert_eq!(fleet.pool_size, 1);
20513 assert!(fleet.effective_create_on_demand());
20514 assert_eq!(fleet.idle_close_secs, 0);
20515 assert!(fleet.restore_missing);
20516 }
20517
20518 #[test]
20519 fn fleet_ownership_deserializes_and_validates() {
20520 let config: AutumnConfig = toml::from_str(
20521 r#"
20522 [database]
20523 url = "sqlite:///var/lib/app/control.db"
20524
20525 [database.fleet]
20526 mode = "slot"
20527 root = "/var/lib/app/fleet"
20528
20529 [database.fleet.ownership]
20530 owner = "host-a"
20531 slots = ["0-8191", 9000]
20532 "#,
20533 )
20534 .unwrap();
20535 let ownership = config
20536 .database
20537 .fleet
20538 .as_ref()
20539 .and_then(|f| f.ownership.clone())
20540 .unwrap();
20541 assert_eq!(ownership.lease_secs, 30);
20542 assert_eq!(ownership.ranges().unwrap(), vec![(0, 8191), (9000, 9000)]);
20543 config.database.validate().unwrap();
20544
20545 let every = FleetOwnershipConfig {
20546 owner: "a".to_owned(),
20547 slots: Vec::new(),
20548 lease_secs: 30,
20549 };
20550 assert_eq!(every.ranges().unwrap(), vec![(0, SLOT_COUNT - 1)]);
20551 let bad = |owner: &str, slots: &[&str], lease_secs| {
20552 let mut fleet = fleet(crate::fleet_layout::FleetMode::Slot);
20553 fleet.ownership = Some(FleetOwnershipConfig {
20554 owner: owner.to_owned(),
20555 slots: slots
20556 .iter()
20557 .map(|s| SlotSpec::Range((*s).to_owned()))
20558 .collect(),
20559 lease_secs,
20560 });
20561 fleet_db(fleet).validate().unwrap_err().to_string()
20562 };
20563 assert!(bad("", &[], 30).contains("owner"));
20564 assert!(bad("a", &["0-10", "10-20"], 30).contains("overlap"));
20565 assert!(bad("a", &["0-16384"], 30).contains("out of range"));
20566 assert!(bad("a", &["9-1"], 30).contains("inverted"));
20567 assert!(bad("a", &[], 2).contains("lease_secs"));
20568 assert!(bad("a", &[], 3601).contains("lease_secs"));
20569 assert!(bad(" a", &[], 30).contains("white space"));
20570 }
20571
20572 #[test]
20573 fn fleet_ownership_env_overrides_create_and_adjust_the_section() {
20574 let mut config = AutumnConfig::default();
20575 config.database.fleet = Some(fleet(crate::fleet_layout::FleetMode::Slot));
20576 config.apply_env_overrides_with_env(
20577 &MockEnv::new()
20578 .with("AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER", "host-b")
20579 .with("AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS", "0-99, 200,")
20580 .with("AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS", "12"),
20581 );
20582 let ownership = config.database.fleet.unwrap().ownership.unwrap();
20583 assert_eq!(ownership.owner, "host-b");
20584 assert_eq!(ownership.ranges().unwrap(), vec![(0, 99), (200, 200)]);
20585 assert_eq!(ownership.lease_secs, 12);
20586 }
20587
20588 #[test]
20589 fn fleet_section_deserializes_from_toml() {
20590 use crate::fleet_layout::FleetMode;
20591 let config: AutumnConfig = toml::from_str(
20592 r#"
20593 [database]
20594 url = "sqlite:///var/lib/app/control.db"
20595
20596 [database.fleet]
20597 mode = "slot"
20598 root = "/var/lib/app/fleet"
20599 "#,
20600 )
20601 .unwrap();
20602 let fleet = config.database.fleet.as_ref().unwrap();
20603 assert_eq!(fleet.mode, FleetMode::Slot);
20604 assert_eq!(fleet.max_open, 256);
20605 assert_eq!(fleet.pool_size, 2);
20606 assert_eq!(fleet.idle_close_secs, 300);
20607 config.database.validate().unwrap();
20608 }
20609
20610 #[test]
20611 fn validate_rejects_backend_mismatch_across_roles() {
20612 let config = DatabaseConfig {
20615 primary_url: Some("postgres://db-primary/app".to_owned()),
20616 replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
20617 ..Default::default()
20618 };
20619 let err = config
20620 .validate()
20621 .expect_err("mixed backends must be refused")
20622 .to_string();
20623 assert!(
20624 err.contains("database.replica_url")
20625 && err.contains("does not match the primary database backend"),
20626 "message must name the offending field and the mismatch, got: {err}"
20627 );
20628 }
20629
20630 #[test]
20631 fn validate_rejects_sqlite_primary_with_postgres_url() {
20632 let config = DatabaseConfig {
20635 primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
20636 url: Some("postgres://db-primary/app".to_owned()),
20637 ..Default::default()
20638 };
20639 let err = config
20640 .validate()
20641 .expect_err("mixed backends must be refused")
20642 .to_string();
20643 assert!(
20644 err.contains("database.url")
20645 && err.contains("does not match the primary database backend"),
20646 "got: {err}"
20647 );
20648 }
20649
20650 #[test]
20651 fn validate_postgres_app_with_replica_still_valid() {
20652 let config = DatabaseConfig {
20655 primary_url: Some("postgres://db-primary/app".to_owned()),
20656 replica_url: Some("postgres://db-replica/app".to_owned()),
20657 ..Default::default()
20658 };
20659 assert!(config.validate().is_ok(), "{:?}", config.validate());
20660 }
20661
20662 #[test]
20665 fn resolve_profile_from_autumn_env() {
20666 let env = MockEnv::new().with("AUTUMN_ENV", "prod");
20667 let profile = resolve_profile(&env);
20668 assert_eq!(profile, "prod");
20669 }
20670
20671 #[test]
20672 fn resolve_profile_from_legacy_env() {
20673 let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
20674 let profile = resolve_profile(&env);
20675 assert_eq!(profile, "staging");
20676 }
20677
20678 #[test]
20679 fn resolve_profile_prefers_autumn_env_over_legacy_alias() {
20680 let env = MockEnv::new()
20681 .with("AUTUMN_ENV", "dev")
20682 .with("AUTUMN_PROFILE", "prod");
20683 let profile = resolve_profile(&env);
20684 assert_eq!(profile, "dev");
20685 }
20686
20687 #[test]
20688 fn resolve_profile_normalizes_production_alias() {
20689 let env = MockEnv::new().with("AUTUMN_ENV", "production");
20690 let profile = resolve_profile(&env);
20691 assert_eq!(profile, "prod");
20692 }
20693
20694 #[test]
20695 fn resolve_profile_normalizes_development_alias_with_whitespace() {
20696 let env = MockEnv::new().with("AUTUMN_ENV", " development ");
20697 let profile = resolve_profile(&env);
20698 assert_eq!(profile, "dev");
20699 }
20700
20701 #[test]
20702 fn resolve_profile_normalizes_uppercase_dev_and_prod() {
20703 let prod_env = MockEnv::new().with("AUTUMN_ENV", "PROD");
20704 let prod = resolve_profile(&prod_env);
20705 assert_eq!(prod, "prod");
20706
20707 let dev_env = MockEnv::new().with("AUTUMN_ENV", "DEV");
20708 let dev = resolve_profile(&dev_env);
20709 assert_eq!(dev, "dev");
20710 }
20711
20712 #[test]
20713 fn resolve_profile_preserves_case_for_custom_profiles() {
20714 let env = MockEnv::new().with("AUTUMN_ENV", "QA");
20715 let profile = resolve_profile(&env);
20716 assert_eq!(profile, "QA");
20717 }
20718
20719 #[test]
20720 fn resolve_profile_auto_detect_debug() {
20721 let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "1");
20722 let profile = resolve_profile(&env);
20723 assert_eq!(profile, "dev");
20724 }
20725
20726 #[test]
20727 fn resolve_profile_auto_detect_release() {
20728 let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "0");
20729 let profile = resolve_profile(&env);
20730 assert_eq!(profile, "prod");
20731 }
20732
20733 #[test]
20734 fn resolve_profile_defaults_to_dev_when_no_signal_present() {
20735 let env = MockEnv::new();
20736 let profile = resolve_profile(&env);
20737 assert_eq!(profile, "dev");
20738 }
20739
20740 #[test]
20741 fn dev_profile_smart_defaults() {
20742 let defaults = profile_defaults_as_toml("dev");
20743 let toml_str = toml::to_string(&defaults).unwrap();
20744 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20745
20746 assert_eq!(config.log.level, "debug");
20747 assert_eq!(config.log.format, LogFormat::Pretty);
20748 assert_eq!(config.server.host, "127.0.0.1");
20749 assert_eq!(config.server.shutdown_timeout_secs, 1);
20750 assert_eq!(
20751 config.server.prestop_grace_secs, 0,
20752 "dev profile must set prestop_grace_secs = 0 so Ctrl-C is instant"
20753 );
20754 assert_eq!(config.telemetry.environment, "development");
20755 assert!(config.health.detailed);
20756 assert_eq!(config.cors.allowed_origins, vec!["*"]);
20757 assert!(
20758 config.security.trusted_proxies.trust_forwarded_headers,
20759 "dev profile must trust forwarded headers from loopback"
20760 );
20761 assert!(
20762 config
20763 .security
20764 .trusted_proxies
20765 .ranges
20766 .contains(&"127.0.0.0/8".to_owned()),
20767 "dev profile must include 127.0.0.0/8 as trusted proxy range"
20768 );
20769 assert!(
20770 config
20771 .security
20772 .trusted_proxies
20773 .ranges
20774 .contains(&"::1/128".to_owned()),
20775 "dev profile must include ::1/128 as trusted proxy range"
20776 );
20777 }
20778
20779 #[test]
20780 fn prod_profile_smart_defaults() {
20781 let defaults = profile_defaults_as_toml("prod");
20782 let toml_str = toml::to_string(&defaults).unwrap();
20783 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20784
20785 assert_eq!(config.log.level, "info");
20786 assert_eq!(config.log.format, LogFormat::Json);
20787 assert_eq!(config.server.host, "0.0.0.0");
20788 assert_eq!(config.server.shutdown_timeout_secs, 35);
20789 assert_eq!(config.telemetry.environment, "production");
20790 assert!(!config.health.detailed);
20791 assert!(
20793 config.security.headers.strict_transport_security,
20794 "prod profile must auto-enable Strict-Transport-Security"
20795 );
20796 assert_eq!(config.security.headers.x_frame_options, "DENY");
20798 assert!(config.security.headers.x_content_type_options);
20799 assert!(!config.security.headers.content_security_policy.is_empty());
20800 }
20801
20802 #[test]
20803 fn profile_shutdown_defaults_match_the_smart_defaults() {
20804 assert_eq!(profile_shutdown_defaults("dev"), (0, 1));
20805 assert_eq!(profile_shutdown_defaults("development"), (0, 1));
20806 assert_eq!(profile_shutdown_defaults(" dev "), (0, 1));
20807 assert_eq!(profile_shutdown_defaults("prod"), (5, 35));
20808 assert_eq!(profile_shutdown_defaults("production"), (5, 35));
20809 let server = ServerConfig::default();
20810 assert_eq!(
20811 profile_shutdown_defaults("staging"),
20812 (server.prestop_grace_secs, server.shutdown_timeout_secs)
20813 );
20814 }
20815
20816 #[test]
20817 fn dev_profile_does_not_auto_enable_hsts() {
20818 let defaults = profile_defaults_as_toml("dev");
20819 let toml_str = toml::to_string(&defaults).unwrap();
20820 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20821
20822 assert!(
20823 !config.security.headers.strict_transport_security,
20824 "dev profile must not force HSTS on (local http development)"
20825 );
20826 }
20827
20828 #[test]
20829 fn custom_profile_no_smart_defaults() {
20830 let defaults = profile_defaults_as_toml("staging");
20831 assert_eq!(defaults, toml::Value::Table(toml::map::Map::new()));
20832 }
20833
20834 #[test]
20835 fn deep_merge_tables() {
20836 let mut base: toml::Value = toml::from_str(
20837 r#"
20838 [server]
20839 port = 3000
20840 host = "127.0.0.1"
20841 [database]
20842 pool_size = 10
20843 "#,
20844 )
20845 .unwrap();
20846
20847 let overlay: toml::Value = toml::from_str(
20848 r#"
20849 [server]
20850 port = 8080
20851 [database]
20852 url = "postgres://localhost/test"
20853 "#,
20854 )
20855 .unwrap();
20856
20857 deep_merge(&mut base, overlay);
20858
20859 assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
20861 assert_eq!(
20863 base["server"]["host"],
20864 toml::Value::String("127.0.0.1".into())
20865 );
20866 assert_eq!(
20868 base["database"]["url"],
20869 toml::Value::String("postgres://localhost/test".into())
20870 );
20871 assert_eq!(base["database"]["pool_size"], toml::Value::Integer(10));
20873 }
20874
20875 #[test]
20876 fn profile_toml_overrides_base_toml() {
20877 let dir = tempfile::tempdir().unwrap();
20878 let base_path = dir.path().join("autumn.toml");
20879 let dev_path = dir.path().join("autumn-dev.toml");
20880
20881 std::fs::write(
20882 &base_path,
20883 r"
20884 [server]
20885 port = 3000
20886 [database]
20887 pool_size = 10
20888 ",
20889 )
20890 .unwrap();
20891
20892 std::fs::write(
20893 &dev_path,
20894 r#"
20895 [database]
20896 url = "postgres://localhost/myapp_dev"
20897 "#,
20898 )
20899 .unwrap();
20900
20901 let mut merged = toml::Value::Table(toml::map::Map::new());
20903 let base = load_raw_toml(&base_path).unwrap().unwrap();
20904 deep_merge(&mut merged, base);
20905 let profile = load_raw_toml(&dev_path).unwrap().unwrap();
20906 deep_merge(&mut merged, profile);
20907
20908 let toml_str = toml::to_string(&merged).unwrap();
20909 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20910
20911 assert_eq!(config.server.port, 3000); assert_eq!(config.database.pool_size, 10); assert_eq!(
20914 config.database.url.as_deref(),
20915 Some("postgres://localhost/myapp_dev")
20916 ); }
20918
20919 #[test]
20920 fn inline_profile_section_overrides_base_toml() {
20921 let mut merged = toml::Value::Table(toml::map::Map::new());
20922 let base: toml::Value = toml::from_str(
20923 r#"
20924 [server]
20925 port = 3000
20926
20927 [log]
20928 level = "info"
20929
20930 [profile.dev.log]
20931 level = "debug"
20932 "#,
20933 )
20934 .unwrap();
20935
20936 deep_merge(&mut merged, base.clone());
20937 let inline = profile_section_from_base_toml(&base, "dev").unwrap();
20938 deep_merge(&mut merged, inline);
20939
20940 let toml_str = toml::to_string(&merged).unwrap();
20941 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
20942 assert_eq!(config.server.port, 3000);
20943 assert_eq!(config.log.level, "debug");
20944 }
20945
20946 #[test]
20947 fn levenshtein_basic() {
20948 assert_eq!(levenshtein("dev", "dev"), 0);
20949 assert_eq!(levenshtein("dev", "dve"), 2); assert_eq!(levenshtein("prod", "prodd"), 1);
20951 assert_eq!(levenshtein("prod", "prd"), 1);
20952 assert_eq!(levenshtein("staging", "dev"), 7);
20953 }
20954
20955 #[test]
20956 fn env_override_health_detailed() {
20957 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "true");
20958 let mut config = AutumnConfig::default();
20959 config.apply_env_overrides_with_env(&env);
20960 assert!(config.health.detailed);
20961 }
20962
20963 #[test]
20964 fn env_overrides_health_dependency_checks() {
20965 let env = MockEnv::new()
20966 .with("AUTUMN_HEALTH__CACHE_TTL_MS", "250")
20967 .with("AUTUMN_HEALTH__PING_TIMEOUT_MS", "750")
20968 .with("AUTUMN_HEALTH__DB_READINESS", "false")
20969 .with("AUTUMN_HEALTH__REDIS_READINESS", "true");
20970 let mut config = AutumnConfig::default();
20971 config.apply_env_overrides_with_env(&env);
20972 assert_eq!(
20973 config.health.cache_ttl(),
20974 std::time::Duration::from_millis(250)
20975 );
20976 assert_eq!(
20977 config.health.ping_timeout(),
20978 std::time::Duration::from_millis(750)
20979 );
20980 assert!(!config.health.db_readiness);
20981 assert!(config.health.redis_readiness);
20982 }
20983
20984 #[test]
20985 fn health_dependency_checks_parse_from_toml() {
20986 let config: AutumnConfig = toml::from_str(
20987 "[health]\ncache_ttl_ms = 0\nping_timeout_ms = 300\ndb_readiness = false\n",
20988 )
20989 .expect("valid toml");
20990 assert_eq!(config.health.cache_ttl_ms, 0);
20991 assert_eq!(config.health.ping_timeout_ms, 300);
20992 assert!(!config.health.db_readiness);
20993 assert!(!config.health.redis_readiness);
20994 }
20995
20996 #[test]
20997 fn zero_ping_timeout_is_rejected() {
20998 let mut config = AutumnConfig::default();
20999 config.health.ping_timeout_ms = 0;
21000 let error = config.validate().expect_err("zero ping timeout");
21001 assert!(
21002 error.to_string().contains("health.ping_timeout_ms"),
21003 "{error}"
21004 );
21005 }
21006
21007 #[test]
21008 fn profile_name_accessor() {
21009 let mut config = AutumnConfig::default();
21010 assert!(config.profile_name().is_none());
21011
21012 config.profile = Some("dev".to_owned());
21013 assert_eq!(config.profile_name(), Some("dev"));
21014 }
21015
21016 #[test]
21019 fn find_config_file_falls_back_to_cwd() {
21020 let env = MockEnv::new();
21022 let path = find_config_file_named("autumn.toml", &env);
21023 assert_eq!(path, PathBuf::from("autumn.toml"));
21024 }
21025
21026 #[test]
21027 fn find_config_file_uses_manifest_dir_when_file_exists() {
21028 let dir = tempfile::tempdir().unwrap();
21029 let config_path = dir.path().join("autumn.toml");
21030 std::fs::write(&config_path, "").unwrap();
21031
21032 let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21033 let path = find_config_file_named("autumn.toml", &env);
21034 assert_eq!(path, config_path);
21035 }
21036
21037 #[test]
21038 fn find_config_file_falls_back_when_manifest_dir_missing_file() {
21039 let dir = tempfile::tempdir().unwrap();
21040 let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21042 let path = find_config_file_named("nonexistent.toml", &env);
21043 assert_eq!(path, PathBuf::from("nonexistent.toml"));
21044 }
21045
21046 #[test]
21047 fn resolve_profile_cli_flag_exact_match() {
21048 let env = MockEnv::new();
21053 let profile = resolve_profile(&env);
21055 drop(profile);
21059 }
21060
21061 #[test]
21062 fn deep_merge_non_table_overlay_replaces_base() {
21063 let mut base: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
21066 let overlay = toml::Value::String("not_a_table".into());
21067
21068 deep_merge(&mut base, overlay);
21071 assert!(base.is_table());
21073 assert_eq!(base["server"]["port"], toml::Value::Integer(3000));
21074 }
21075
21076 #[test]
21077 fn deep_merge_when_base_not_table() {
21078 let mut base = toml::Value::String("original".into());
21080 let overlay: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
21081
21082 deep_merge(&mut base, overlay);
21083 assert_eq!(base, toml::Value::String("original".into()));
21085 }
21086
21087 #[test]
21088 fn suggest_profile_close_match() {
21089 assert_eq!(suggest_profile("dve"), Some("dev"));
21091 }
21092
21093 #[test]
21094 fn suggest_profile_no_match_when_distant() {
21095 assert_eq!(suggest_profile("xyz"), None);
21097 }
21098
21099 #[test]
21100 fn suggest_profile_exact_known_profile() {
21101 assert_eq!(suggest_profile("dev"), Some("dev"));
21103 assert_eq!(suggest_profile("prod"), Some("prod"));
21104 }
21105
21106 #[test]
21107 fn suggest_profile_prd() {
21108 assert_eq!(suggest_profile("prd"), Some("prod"));
21110 }
21111
21112 #[test]
21113 fn warn_profile_typo_runs_without_panic() {
21114 warn_profile_typo("dve");
21115 warn_profile_typo("xyz");
21116 }
21117
21118 #[test]
21119 fn should_warn_missing_profile_file_custom_without_inline() {
21120 assert!(should_warn_missing_profile_file("staging", false));
21121 }
21122
21123 #[test]
21124 fn should_not_warn_missing_profile_file_custom_with_inline() {
21125 assert!(!should_warn_missing_profile_file("staging", true));
21126 }
21127
21128 #[test]
21129 fn should_not_warn_missing_profile_file_dev_or_prod() {
21130 assert!(!should_warn_missing_profile_file("dev", false));
21131 assert!(!should_warn_missing_profile_file("prod", false));
21132 }
21133
21134 #[test]
21135 fn levenshtein_threshold_in_warn_profile_typo() {
21136 assert!(levenshtein("dve", "dev") <= 2);
21137 assert!(levenshtein("xyz", "dev") > 2);
21138 assert!(levenshtein("xyz", "prod") > 2);
21139 }
21140
21141 #[test]
21142 fn env_override_cors_allowed_origins() {
21143 let env = MockEnv::new().with(
21144 "AUTUMN_CORS__ALLOWED_ORIGINS",
21145 "https://a.com, https://b.com",
21146 );
21147 let mut config = AutumnConfig::default();
21148 config.apply_env_overrides_with_env(&env);
21149 assert_eq!(
21150 config.cors.allowed_origins,
21151 vec!["https://a.com", "https://b.com"]
21152 );
21153 }
21154
21155 #[test]
21156 fn env_override_cors_allow_credentials() {
21157 let env = MockEnv::new().with("AUTUMN_CORS__ALLOW_CREDENTIALS", "true");
21158 let mut config = AutumnConfig::default();
21159 config.apply_env_overrides_with_env(&env);
21160 assert!(config.cors.allow_credentials);
21161 }
21162
21163 #[test]
21164 fn env_override_cors_max_age() {
21165 let env = MockEnv::new().with("AUTUMN_CORS__MAX_AGE_SECS", "3600");
21166 let mut config = AutumnConfig::default();
21167 config.apply_env_overrides_with_env(&env);
21168 assert_eq!(config.cors.max_age_secs, 3600);
21169 }
21170
21171 #[test]
21172 fn cors_validate_rejects_wildcard_with_credentials() {
21173 let mut config = AutumnConfig::default();
21174 config.cors.allowed_origins = vec!["*".to_owned()];
21175 config.cors.allow_credentials = true;
21176
21177 let result = config.validate();
21178 match result {
21179 Err(ConfigError::Validation(msg)) => {
21180 assert!(
21181 msg.contains("allow_credentials") && msg.contains('*'),
21182 "message should mention credentials and wildcard, got: {msg}"
21183 );
21184 }
21185 other => panic!("expected ConfigError::Validation, got {other:?}"),
21186 }
21187 }
21188
21189 #[test]
21190 fn cors_validate_accepts_wildcard_without_credentials() {
21191 let mut config = AutumnConfig::default();
21192 config.cors.allowed_origins = vec!["*".to_owned()];
21193 config.cors.allow_credentials = false;
21194 assert!(config.validate().is_ok());
21195 }
21196
21197 #[test]
21198 fn cors_validate_accepts_explicit_origins_with_credentials() {
21199 let mut config = AutumnConfig::default();
21200 config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
21201 config.cors.allow_credentials = true;
21202 assert!(config.validate().is_ok());
21203 }
21204
21205 #[test]
21206 fn load_uses_profile_layering() {
21207 let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
21210
21211 let config = AutumnConfig::load_with_env(&env).unwrap();
21212 assert_eq!(config.profile.as_deref(), Some("dev"));
21214 assert_eq!(config.log.level, "debug"); assert_eq!(config.log.format, LogFormat::Pretty); assert!(config.health.detailed); }
21218
21219 #[test]
21220 fn load_custom_profile_without_toml_warns() {
21221 let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
21225
21226 let config = AutumnConfig::load_with_env(&env).unwrap();
21227 assert_eq!(config.profile.as_deref(), Some("staging"));
21228 assert_eq!(config.server.port, 3000);
21230 assert_eq!(config.log.level, "info");
21231 }
21232
21233 #[test]
21234 fn load_dev_profile_no_profile_toml_no_warn() {
21235 let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
21238
21239 let config = AutumnConfig::load_with_env(&env).unwrap();
21240 assert_eq!(config.profile.as_deref(), Some("dev"));
21241 }
21242
21243 #[test]
21244 fn load_custom_profile_uses_inline_profile_without_legacy_file() {
21245 let dir = tempfile::tempdir().unwrap();
21246 let base_path = dir.path().join("autumn.toml");
21247 std::fs::write(
21248 &base_path,
21249 r"
21250 [server]
21251 port = 3000
21252
21253 [profile.staging.server]
21254 port = 4100
21255 ",
21256 )
21257 .unwrap();
21258
21259 let env = MockEnv::new()
21260 .with("AUTUMN_ENV", "staging")
21261 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21262
21263 let config = AutumnConfig::load_with_env(&env).unwrap();
21264 assert_eq!(config.profile.as_deref(), Some("staging"));
21265 assert_eq!(config.server.port, 4100);
21266 }
21267
21268 #[test]
21269 fn load_production_profile_reads_inline_profile_production_section() {
21270 let dir = tempfile::tempdir().unwrap();
21271 let base_path = dir.path().join("autumn.toml");
21272 std::fs::write(
21273 &base_path,
21274 r"
21275 [profile.production.server]
21276 port = 4200
21277 ",
21278 )
21279 .unwrap();
21280
21281 let env = MockEnv::new()
21282 .with("AUTUMN_ENV", "production")
21283 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21284
21285 let config = AutumnConfig::load_with_env(&env).unwrap();
21286 assert_eq!(config.profile.as_deref(), Some("prod"));
21287 assert_eq!(config.server.port, 4200);
21288 }
21289
21290 #[test]
21291 fn load_production_profile_reads_legacy_autumn_production_toml() {
21292 let dir = tempfile::tempdir().unwrap();
21293 let production_path = dir.path().join("autumn-production.toml");
21294 std::fs::write(
21295 &production_path,
21296 r"
21297 [server]
21298 port = 4300
21299 ",
21300 )
21301 .unwrap();
21302
21303 let env = MockEnv::new()
21304 .with("AUTUMN_ENV", "production")
21305 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21306
21307 let config = AutumnConfig::load_with_env(&env).unwrap();
21308 assert_eq!(config.profile.as_deref(), Some("prod"));
21309 assert_eq!(config.server.port, 4300);
21310 }
21311
21312 #[test]
21313 fn load_prod_prefers_autumn_prod_toml_before_production_alias() {
21314 let dir = tempfile::tempdir().unwrap();
21315 let prod_path = dir.path().join("autumn-prod.toml");
21316 let production_path = dir.path().join("autumn-production.toml");
21317
21318 std::fs::write(
21319 &prod_path,
21320 r"
21321 [server]
21322 port = 4400
21323 ",
21324 )
21325 .unwrap();
21326 std::fs::write(&production_path, "[server\nport = 4500").unwrap();
21328
21329 let env = MockEnv::new()
21330 .with("AUTUMN_ENV", "prod")
21331 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21332
21333 let config = AutumnConfig::load_with_env(&env).unwrap();
21334 assert_eq!(config.profile.as_deref(), Some("prod"));
21335 assert_eq!(config.server.port, 4400);
21336 }
21337
21338 #[test]
21339 fn load_production_prefers_autumn_production_toml_before_prod_alias() {
21340 let dir = tempfile::tempdir().unwrap();
21341 let prod_path = dir.path().join("autumn-prod.toml");
21342 let production_path = dir.path().join("autumn-production.toml");
21343
21344 std::fs::write(
21345 &production_path,
21346 r"
21347 [server]
21348 port = 4500
21349 ",
21350 )
21351 .unwrap();
21352 std::fs::write(&prod_path, "[server\nport = 4400").unwrap();
21354
21355 let env = MockEnv::new()
21356 .with("AUTUMN_ENV", "production")
21357 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
21358
21359 let config = AutumnConfig::load_with_env(&env).unwrap();
21360 assert_eq!(config.profile.as_deref(), Some("prod"));
21361 assert_eq!(config.server.port, 4500);
21362 }
21363
21364 #[test]
21365 fn load_from_io_error_is_not_swallowed() {
21366 let dir = tempfile::tempdir().unwrap();
21369 let result = AutumnConfig::load_from(dir.path());
21370 assert!(result.is_err());
21371 }
21372
21373 #[test]
21374 fn load_raw_toml_missing_file_returns_none() {
21375 let result = load_raw_toml(Path::new("this_file_does_not_exist_12345.toml")).unwrap();
21376 assert!(result.is_none());
21377 }
21378
21379 #[test]
21380 fn load_raw_toml_directory_returns_io_error() {
21381 let dir = tempfile::tempdir().unwrap();
21385 let result = load_raw_toml(dir.path());
21386 assert!(result.is_err());
21387 }
21388
21389 #[test]
21390 fn load_raw_toml_valid_file_returns_some() {
21391 let dir = tempfile::tempdir().unwrap();
21392 let path = dir.path().join("test.toml");
21393 std::fs::write(&path, "[server]\nport = 3000\n").unwrap();
21394 let result = load_raw_toml(&path).unwrap();
21395 assert!(result.is_some());
21396 assert_eq!(
21397 result.unwrap()["server"]["port"],
21398 toml::Value::Integer(3000)
21399 );
21400 }
21401
21402 #[test]
21403 fn env_override_log_format_auto() {
21404 let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
21406 let mut config = AutumnConfig::default();
21407 config.log.format = LogFormat::Json;
21409 config.apply_env_overrides_with_env(&env);
21410 assert_eq!(config.log.format, LogFormat::Auto);
21411 }
21412
21413 #[test]
21414 fn env_override_health_detailed_false() {
21415 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
21417 let mut config = AutumnConfig::default();
21418 config.health.detailed = true; config.apply_env_overrides_with_env(&env);
21420 assert!(!config.health.detailed);
21421 }
21422
21423 #[test]
21424 fn env_override_health_detailed_zero() {
21425 let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "0");
21426 let mut config = AutumnConfig::default();
21427 config.health.detailed = true;
21428 config.apply_env_overrides_with_env(&env);
21429 assert!(!config.health.detailed);
21430 }
21431
21432 #[test]
21433 fn health_enabled_defaults_true() {
21434 assert!(HealthConfig::default().enabled);
21437 }
21438
21439 #[test]
21440 fn env_override_health_enabled_false() {
21441 let env = MockEnv::new().with("AUTUMN_HEALTH__ENABLED", "false");
21444 let mut config = AutumnConfig::default();
21445 assert!(config.health.enabled); config.apply_env_overrides_with_env(&env);
21447 assert!(!config.health.enabled);
21448 }
21449
21450 #[test]
21451 fn cors_defaults() {
21452 let cors = CorsConfig::default();
21453 assert!(cors.allowed_origins.is_empty());
21454 assert_eq!(cors.allowed_methods.len(), 6);
21455 assert!(cors.allowed_methods.contains(&"GET".to_owned()));
21456 assert!(cors.allowed_headers.contains(&"Content-Type".to_owned()));
21457 assert!(!cors.allow_credentials);
21458 assert_eq!(cors.max_age_secs, 86400);
21459 }
21460
21461 #[test]
21462 fn cors_in_full_config_defaults() {
21463 let config = AutumnConfig::default();
21464 assert!(config.cors.allowed_origins.is_empty());
21465 }
21466
21467 #[test]
21468 fn actuator_defaults() {
21469 let config = ActuatorConfig::default();
21470 assert_eq!(config.prefix, "/actuator");
21471 assert!(!config.sensitive);
21472 assert!(config.prometheus);
21475 }
21476
21477 #[test]
21478 fn actuator_prometheus_can_be_disabled_via_toml() {
21479 let toml = r"
21480 sensitive = false
21481 prometheus = false
21482 ";
21483 let config: ActuatorConfig = toml::from_str(toml).unwrap();
21484 assert!(!config.sensitive);
21485 assert!(!config.prometheus);
21486 }
21487
21488 #[test]
21489 fn actuator_prefix_in_full_config() {
21490 let config = AutumnConfig::default();
21491 assert_eq!(config.actuator.prefix, "/actuator");
21492 }
21493
21494 #[test]
21495 fn deep_merge_handles_deep_nesting() {
21496 let mut base = toml::Value::Table(toml::map::Map::new());
21497 let mut overlay = toml::Value::Table(toml::map::Map::new());
21498
21499 let mut current_base = &mut base;
21501 let mut current_overlay = &mut overlay;
21502
21503 for _ in 0..10_000 {
21504 if let toml::Value::Table(t) = current_base {
21505 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
21506 current_base = t.get_mut("x").unwrap();
21507 }
21508 if let toml::Value::Table(t) = current_overlay {
21509 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
21510 current_overlay = t.get_mut("x").unwrap();
21511 }
21512 }
21513
21514 if let toml::Value::Table(t) = current_overlay {
21516 t.insert("y".to_owned(), toml::Value::Integer(42));
21517 }
21518
21519 std::thread::Builder::new()
21522 .stack_size(32 * 1024 * 1024)
21523 .spawn(move || {
21524 deep_merge(&mut base, overlay);
21525 std::mem::forget(base);
21527 })
21528 .unwrap()
21529 .join()
21530 .unwrap();
21531 }
21532
21533 #[test]
21534 fn deep_merge_stops_at_max_depth() {
21535 let mut base = toml::Value::Table(toml::map::Map::new());
21536 let mut overlay = toml::Value::Table(toml::map::Map::new());
21537
21538 let mut current_base = &mut base;
21540 let mut current_overlay = &mut overlay;
21541
21542 for _ in 0..=MAX_MERGE_DEPTH {
21543 if let toml::Value::Table(t) = current_base {
21544 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
21545 current_base = t.get_mut("x").unwrap();
21546 }
21547 if let toml::Value::Table(t) = current_overlay {
21548 t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
21549 current_overlay = t.get_mut("x").unwrap();
21550 }
21551 }
21552
21553 if let toml::Value::Table(t) = current_overlay {
21555 t.insert("deep_value".to_owned(), toml::Value::Integer(123));
21556 }
21557
21558 deep_merge(&mut base, overlay);
21559
21560 let mut current_base_check = &base;
21562 for _ in 0..=MAX_MERGE_DEPTH {
21563 if let toml::Value::Table(t) = current_base_check {
21564 current_base_check = t.get("x").unwrap();
21565 }
21566 }
21567
21568 if let toml::Value::Table(t) = current_base_check {
21569 assert!(
21570 !t.contains_key("deep_value"),
21571 "Value beyond MAX_MERGE_DEPTH should not be merged"
21572 );
21573 } else {
21574 panic!("Expected a table");
21575 }
21576 }
21577
21578 #[test]
21581 fn env_override_forbidden_response_403() {
21582 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "403");
21583 let mut config = AutumnConfig::default();
21584 config.apply_env_overrides_with_env(&env);
21585 assert_eq!(
21586 config.security.forbidden_response,
21587 crate::authorization::ForbiddenResponse::Forbidden403
21588 );
21589 }
21590
21591 #[test]
21592 fn env_override_forbidden_response_404() {
21593 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "404");
21594 let mut config = AutumnConfig::default();
21595 config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
21597 config.apply_env_overrides_with_env(&env);
21598 assert_eq!(
21599 config.security.forbidden_response,
21600 crate::authorization::ForbiddenResponse::NotFound404
21601 );
21602 }
21603
21604 #[test]
21605 fn env_override_forbidden_response_invalid_keeps_existing() {
21606 let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "418");
21607 let mut config = AutumnConfig::default();
21608 config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
21609 config.apply_env_overrides_with_env(&env);
21610 assert_eq!(
21612 config.security.forbidden_response,
21613 crate::authorization::ForbiddenResponse::Forbidden403
21614 );
21615 }
21616
21617 #[test]
21618 fn env_override_allow_unauthorized_repository_api() {
21619 let env = MockEnv::new().with("AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API", "true");
21620 let mut config = AutumnConfig::default();
21621 assert!(!config.security.allow_unauthorized_repository_api);
21622 config.apply_env_overrides_with_env(&env);
21623 assert!(config.security.allow_unauthorized_repository_api);
21624 }
21625
21626 #[test]
21627 fn env_override_allow_unauthorized_repository_api_false_overrides_toml_true() {
21628 let env = MockEnv::new().with(
21629 "AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
21630 "false",
21631 );
21632 let mut config = AutumnConfig::default();
21633 config.security.allow_unauthorized_repository_api = true;
21634 config.apply_env_overrides_with_env(&env);
21635 assert!(!config.security.allow_unauthorized_repository_api);
21636 }
21637
21638 #[test]
21639 fn env_override_csrf_token_scan_bytes() {
21640 let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "8388608");
21641 let mut config = AutumnConfig::default();
21642 assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
21644 config.apply_env_overrides_with_env(&env);
21645 assert_eq!(config.security.csrf.token_scan_bytes, 8_388_608);
21646 }
21647
21648 #[test]
21649 fn env_override_csrf_token_scan_bytes_invalid_is_ignored() {
21650 let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "not-a-number");
21651 let mut config = AutumnConfig::default();
21652 config.apply_env_overrides_with_env(&env);
21653 assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
21655 }
21656
21657 #[test]
21660 fn openapi_runtime_config_defaults_enabled() {
21661 let config = AutumnConfig::default();
21663 assert!(
21664 config.openapi_runtime.enabled,
21665 "[openapi] must default to enabled = true"
21666 );
21667 assert_eq!(
21668 config.openapi_runtime.path, "/openapi.json",
21669 "[openapi] must default to path = \"/openapi.json\""
21670 );
21671 }
21672
21673 #[test]
21674 fn openapi_runtime_config_can_be_disabled_via_toml() {
21675 let toml_str = "
21676[openapi]
21677enabled = false
21678";
21679 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
21680 assert!(
21681 !config.openapi_runtime.enabled,
21682 "[openapi] enabled = false must deserialize correctly"
21683 );
21684 }
21685
21686 #[test]
21687 fn openapi_runtime_config_path_can_be_customized() {
21688 let toml_str = r#"
21689[openapi]
21690path = "/api-spec.json"
21691"#;
21692 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
21693 assert_eq!(
21694 config.openapi_runtime.path, "/api-spec.json",
21695 "[openapi] path must deserialize correctly"
21696 );
21697 }
21698
21699 #[test]
21700 fn cache_env_overrides_fields() {
21701 let env = MockEnv::new()
21702 .with("AUTUMN_CACHE__BACKEND", "redis")
21703 .with("AUTUMN_CACHE__REDIS__URL", "redis://cache:6379/1")
21704 .with("AUTUMN_CACHE__REDIS__KEY_PREFIX", "myapp:cache");
21705 let mut config = AutumnConfig::default();
21706
21707 config.apply_env_overrides_with_env(&env);
21708
21709 assert!(config.cache.is_redis(), "backend should be redis");
21710 assert_eq!(
21711 config.cache.redis.url.as_deref(),
21712 Some("redis://cache:6379/1")
21713 );
21714 assert_eq!(config.cache.redis.key_prefix, "myapp:cache");
21715 }
21716
21717 #[test]
21718 fn cache_backend_from_env_value_invalid_is_none() {
21719 assert!(CacheBackend::from_env_value("postgres").is_none());
21720 assert!(CacheBackend::from_env_value("").is_none());
21721 }
21722
21723 #[test]
21724 fn scheduler_validate_rejects_zero_lease_ttl() {
21725 let cfg = SchedulerConfig {
21726 lease_ttl_secs: 0,
21727 ..SchedulerConfig::default()
21728 };
21729 assert!(cfg.validate().is_err(), "zero lease_ttl_secs must fail");
21730 }
21731
21732 #[test]
21733 fn scheduler_validate_rejects_empty_key_prefix() {
21734 let cfg = SchedulerConfig {
21735 key_prefix: " ".to_owned(),
21736 ..SchedulerConfig::default()
21737 };
21738 assert!(cfg.validate().is_err(), "blank key_prefix must fail");
21739 }
21740
21741 #[test]
21742 fn scheduler_validate_ok_with_defaults() {
21743 assert!(SchedulerConfig::default().validate().is_ok());
21744 }
21745
21746 #[test]
21747 fn scheduler_resolved_replica_id_uses_explicit_value() {
21748 let cfg = SchedulerConfig {
21749 replica_id: Some("my-pod".to_owned()),
21750 ..SchedulerConfig::default()
21751 };
21752 assert_eq!(cfg.resolved_replica_id(), "my-pod");
21753 }
21754
21755 #[test]
21756 fn scheduler_resolved_replica_id_falls_back_to_pid() {
21757 let cfg = SchedulerConfig {
21758 replica_id: None,
21759 ..SchedulerConfig::default()
21760 };
21761 assert!(!cfg.resolved_replica_id().is_empty());
21764 }
21765
21766 #[cfg(feature = "mail")]
21767 #[test]
21768 fn mail_allow_in_process_deliver_later_in_production_is_overridable_via_env() {
21769 let env = MockEnv::new()
21770 .with(
21771 "AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
21772 "true",
21773 )
21774 .with("AUTUMN_MAIL__TRANSPORT", "smtp")
21775 .with("AUTUMN_MAIL__SMTP__HOST", "smtp.example.com");
21776
21777 let mut config = AutumnConfig::default();
21778 config.apply_mail_env_overrides_with_env(&env);
21779
21780 assert!(
21781 config.mail.allow_in_process_deliver_later_in_production,
21782 "env var should set allow_in_process_deliver_later_in_production"
21783 );
21784 }
21785
21786 #[cfg(feature = "mail")]
21787 #[test]
21788 fn mail_allow_in_process_deliver_later_in_production_defaults_false() {
21789 let env = MockEnv::new();
21790 let mut config = AutumnConfig::default();
21791 config.apply_mail_env_overrides_with_env(&env);
21792
21793 assert!(
21794 !config.mail.allow_in_process_deliver_later_in_production,
21795 "flag should default to false when env var is not set"
21796 );
21797 }
21798
21799 #[cfg(feature = "mail")]
21800 #[test]
21801 fn mail_inline_css_is_overridable_via_env() {
21802 let env = MockEnv::new().with("AUTUMN_MAIL__INLINE_CSS", "true");
21803
21804 let mut config = AutumnConfig::default();
21805 config.apply_mail_env_overrides_with_env(&env);
21806
21807 assert!(
21808 config.mail.inline_css,
21809 "AUTUMN_MAIL__INLINE_CSS=true should enable inline_css"
21810 );
21811 }
21812
21813 #[cfg(feature = "mail")]
21814 #[test]
21815 fn mail_inline_css_defaults_false() {
21816 let env = MockEnv::new();
21817 let mut config = AutumnConfig::default();
21818 config.apply_mail_env_overrides_with_env(&env);
21819
21820 assert!(
21821 !config.mail.inline_css,
21822 "inline_css should default to false when env var is not set"
21823 );
21824 }
21825
21826 #[test]
21829 fn config_credentials_empty_when_no_directory() {
21830 let env = MockEnv::new();
21831 let config = AutumnConfig::load_with_env(&env).unwrap();
21832 assert!(
21833 config.credentials().is_empty(),
21834 "existing apps without config/credentials/ must boot with an empty credentials store"
21835 );
21836 }
21837
21838 #[test]
21839 fn config_has_credentials_accessor() {
21840 let config = AutumnConfig::default();
21841 let _store = config.credentials();
21842 }
21843
21844 #[test]
21845 fn config_credentials_loaded_when_file_present() {
21846 use crate::credentials::{MasterKey, encrypt};
21847 use tempfile::TempDir;
21848
21849 let tmp = TempDir::new().unwrap();
21850 let key = MasterKey::generate();
21851 let ct = encrypt(&key, b"stripe_key = \"sk_test_xyz\"\n");
21852 std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
21853 std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
21854
21855 let env = MockEnv::new()
21856 .with("AUTUMN_MASTER_KEY", &key.to_hex())
21857 .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
21858 let config = AutumnConfig::load_with_env(&env).unwrap();
21859 let val: Option<String> = config.credentials().get("stripe_key");
21860 assert_eq!(val.as_deref(), Some("sk_test_xyz"));
21861 }
21862
21863 #[cfg(feature = "oauth2")]
21864 #[test]
21865 fn config_resolves_oauth_credentials_by_convention() {
21866 use crate::credentials::{MasterKey, encrypt};
21867 use tempfile::TempDir;
21868
21869 let tmp = TempDir::new().unwrap();
21870 let key = MasterKey::generate();
21871 let ct = encrypt(
21872 &key,
21873 b"oauth2_github_client_id = \"git-id-123\"\noauth2_github_client_secret = \"git-secret-456\"\n",
21874 );
21875 std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
21876 std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
21877
21878 std::fs::create_dir_all(tmp.path().join("config")).unwrap();
21880 let config_toml = r#"
21881[auth.oauth2.github]
21882client_id = ""
21883client_secret = ""
21884authorize_url = "https://github.com/login/oauth/authorize"
21885token_url = "https://github.com/login/oauth/access_token"
21886redirect_uri = "http://localhost:3000/auth/github/callback"
21887"#;
21888 std::fs::write(tmp.path().join("autumn.toml"), config_toml).unwrap();
21889
21890 let env = MockEnv::new()
21891 .with("AUTUMN_MASTER_KEY", &key.to_hex())
21892 .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
21893 let config = AutumnConfig::load_with_env(&env).unwrap();
21894 let github = config.auth.oauth2.providers.get("github").unwrap();
21895 assert_eq!(github.client_id, "git-id-123");
21896 assert_eq!(github.client_secret, "git-secret-456");
21897 }
21898
21899 #[test]
21900 fn config_fails_with_credentials_error_when_key_is_invalid() {
21901 use crate::credentials::encrypt;
21902 use tempfile::TempDir;
21903
21904 let tmp = TempDir::new().unwrap();
21905 let bogus_key = "zz".repeat(32); let ct = encrypt(&crate::credentials::MasterKey::generate(), b"x = \"y\"\n");
21908 std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
21909 std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
21910
21911 let env = MockEnv::new()
21912 .with("AUTUMN_MASTER_KEY", &bogus_key)
21913 .with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
21914 let err = AutumnConfig::load_with_env(&env).unwrap_err();
21915 assert!(
21916 matches!(err, ConfigError::Credentials(_)),
21917 "bad master key should produce ConfigError::Credentials, got {err:?}"
21918 );
21919 }
21920
21921 #[test]
21922 fn test_parse_duration_str() {
21923 assert_eq!(
21924 parse_duration_str("500ms").unwrap(),
21925 std::time::Duration::from_millis(500)
21926 );
21927 assert_eq!(
21928 parse_duration_str("5s").unwrap(),
21929 std::time::Duration::from_secs(5)
21930 );
21931 assert_eq!(
21932 parse_duration_str("2m").unwrap(),
21933 std::time::Duration::from_secs(120)
21934 );
21935 assert_eq!(
21936 parse_duration_str("1h").unwrap(),
21937 std::time::Duration::from_secs(3600)
21938 );
21939 assert_eq!(
21940 parse_duration_str("1000").unwrap(),
21941 std::time::Duration::from_secs(1)
21942 );
21943 assert!(parse_duration_str("abc").is_err());
21944 assert!(parse_duration_str("").is_err());
21945 }
21946
21947 #[test]
21948 fn test_database_config_duration_deserialization() {
21949 #[derive(Debug, Deserialize)]
21950 struct TestConfig {
21951 #[serde(deserialize_with = "deserialize_option_duration", default)]
21952 timeout: Option<std::time::Duration>,
21953 #[serde(deserialize_with = "deserialize_duration")]
21954 threshold: std::time::Duration,
21955 }
21956
21957 let toml_str = r#"
21958 timeout = "2s"
21959 threshold = "100ms"
21960 "#;
21961 let parsed: TestConfig = toml::from_str(toml_str).unwrap();
21962 assert_eq!(parsed.timeout, Some(std::time::Duration::from_secs(2)));
21963 assert_eq!(parsed.threshold, std::time::Duration::from_millis(100));
21964
21965 let toml_str_null = r#"
21966 threshold = "500"
21967 "#;
21968 let parsed_null: TestConfig = toml::from_str(toml_str_null).unwrap();
21969 assert_eq!(parsed_null.timeout, None);
21970 assert_eq!(parsed_null.threshold, std::time::Duration::from_millis(500));
21971 }
21972
21973 #[test]
21976 fn request_timeouts_config_defaults_to_none() {
21977 let config = RequestTimeoutsConfig::default();
21978 assert!(config.request_timeout_ms.is_none());
21979 }
21980
21981 #[test]
21982 fn server_config_timeouts_defaults_to_disabled() {
21983 let config = ServerConfig::default();
21984 assert!(config.timeouts.request_timeout_ms.is_none());
21985 }
21986
21987 #[test]
21988 fn request_timeouts_config_can_be_set_via_toml() {
21989 let toml_str = "request_timeout_ms = 5000";
21990 let config: RequestTimeoutsConfig = toml::from_str(toml_str).unwrap();
21991 assert_eq!(config.request_timeout_ms, Some(5000));
21992 }
21993
21994 #[test]
21995 fn server_config_timeouts_deserialize_nested() {
21996 let toml_str = r#"
21997 port = 3000
21998 host = "127.0.0.1"
21999 shutdown_timeout_secs = 30
22000 prestop_grace_secs = 5
22001
22002 [timeouts]
22003 request_timeout_ms = 15000
22004 "#;
22005 let config: ServerConfig = toml::from_str(toml_str).unwrap();
22006 assert_eq!(config.timeouts.request_timeout_ms, Some(15_000));
22007 }
22008
22009 #[test]
22010 fn prod_drain_window_is_request_timeout_plus_margin() {
22011 let defaults = profile_defaults_as_toml("prod");
22012 let config: AutumnConfig = toml::from_str(&toml::to_string(&defaults).unwrap()).unwrap();
22013 let timeout_secs = config.server.timeouts.request_timeout_ms.unwrap() / 1000;
22014 assert_eq!(
22015 config.server.shutdown_timeout_secs,
22016 timeout_secs + DRAIN_MARGIN_SECS
22017 );
22018 assert!(config.server.drain_window_warning().is_none());
22019 }
22020
22021 #[test]
22022 fn drain_window_shorter_than_request_timeout_is_flagged() {
22023 let mut server = ServerConfig::default();
22024 server.timeouts.request_timeout_ms = Some(30_000);
22025 server.shutdown_timeout_secs = 30;
22026 let warning = server
22027 .drain_window_warning()
22028 .expect("30 s drain, 30 s timeout");
22029 assert!(warning.contains("35"), "names the safe value: {warning}");
22030
22031 server.shutdown_timeout_secs = 35;
22032 assert!(server.drain_window_warning().is_none());
22033
22034 server.timeouts.request_timeout_ms = Some(30_001);
22035 assert!(server.drain_window_warning().is_some(), "rounds up to 36 s");
22036 server.shutdown_timeout_secs = 36;
22037 assert!(server.drain_window_warning().is_none());
22038
22039 server.timeouts.request_timeout_ms = None;
22040 server.shutdown_timeout_secs = 1;
22041 assert!(
22042 server.drain_window_warning().is_none(),
22043 "no timeout, no risk"
22044 );
22045 }
22046
22047 #[test]
22048 fn accept_deadline_header_is_off_by_default_and_configurable() {
22049 assert!(!RequestTimeoutsConfig::default().accept_deadline_header);
22050 let config: ServerConfig =
22051 toml::from_str("[timeouts]\naccept_deadline_header = true\n").unwrap();
22052 assert!(config.timeouts.accept_deadline_header);
22053
22054 let env = MockEnv::new().with("AUTUMN_SERVER__TIMEOUTS__ACCEPT_DEADLINE_HEADER", "true");
22055 let mut config = AutumnConfig::default();
22056 config.apply_env_overrides_with_env(&env);
22057 assert!(config.server.timeouts.accept_deadline_header);
22058 }
22059
22060 #[cfg(feature = "http-client")]
22061 #[test]
22062 fn http_client_deadline_header_and_retry_budget_defaults() {
22063 let config = HttpClientConfig::default();
22064 assert!(config.send_deadline_header);
22065 let budget = &config.retry_budget;
22066 assert!(budget.enabled);
22067 assert_eq!(budget.capacity, 500);
22068 assert_eq!(budget.transient_cost, 14);
22069 assert_eq!(budget.throttling_cost, 5);
22070 assert!((budget.retry_ratio - 0.1).abs() < f64::EPSILON);
22071 }
22072
22073 #[cfg(feature = "http-client")]
22074 #[test]
22075 fn http_client_retry_budget_rejects_bad_values() {
22076 let mut config = AutumnConfig::default();
22077 assert!(config.validate().is_ok());
22078 config.http.client.retry_budget.retry_ratio = 1.5;
22079 assert!(config.validate().is_err());
22080 config.http.client.retry_budget.retry_ratio = f64::NAN;
22081 assert!(config.validate().is_err());
22082 config.http.client.retry_budget.retry_ratio = 0.1;
22083 config.http.client.retry_budget.transient_cost = 0;
22084 assert!(config.validate().is_err());
22085 }
22086
22087 #[cfg(feature = "http-client")]
22088 #[test]
22089 fn a_disabled_retry_budget_is_not_validated() {
22090 let mut config = AutumnConfig::default();
22091 config.http.client.retry_budget.enabled = false;
22092 config.http.client.retry_budget.transient_cost = 0;
22093 config.http.client.retry_budget.throttling_cost = 0;
22094 config.http.client.retry_budget.retry_ratio = 2.0;
22095 assert!(config.validate().is_ok(), "{:?}", config.validate());
22096 }
22097
22098 #[cfg(feature = "http-client")]
22099 #[test]
22100 fn http_client_retry_budget_parses_from_toml() {
22101 let config: HttpConfig = toml::from_str(
22102 "[client]\nsend_deadline_header = false\n\
22103 [client.retry_budget]\nenabled = false\ncapacity = 50\nretry_ratio = 0.2\n",
22104 )
22105 .unwrap();
22106 assert!(!config.client.send_deadline_header);
22107 assert!(!config.client.retry_budget.enabled);
22108 assert_eq!(config.client.retry_budget.capacity, 50);
22109 assert_eq!(config.client.retry_budget.transient_cost, 14);
22110 assert!((config.client.retry_budget.retry_ratio - 0.2).abs() < f64::EPSILON);
22111 }
22112
22113 #[test]
22114 fn autumn_config_server_timeouts_roundtrip() {
22115 let mut config = AutumnConfig::default();
22116 config.server.timeouts.request_timeout_ms = Some(20_000);
22117 assert_eq!(config.server.timeouts.request_timeout_ms, Some(20_000));
22118 }
22119
22120 #[test]
22121 fn server_timeouts_env_var_override() {
22122 struct FakeEnv(std::collections::HashMap<String, String>);
22123 impl Env for FakeEnv {
22124 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
22125 self.0
22126 .get(key)
22127 .cloned()
22128 .ok_or(std::env::VarError::NotPresent)
22129 }
22130 }
22131
22132 let mut config = AutumnConfig::default();
22133 let env = FakeEnv(
22134 [(
22135 "AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS".to_owned(),
22136 "8000".to_owned(),
22137 )]
22138 .into(),
22139 );
22140 config.apply_server_env_overrides_with_env(&env);
22141 assert_eq!(config.server.timeouts.request_timeout_ms, Some(8000));
22142 }
22143
22144 #[test]
22145 fn prod_profile_sets_request_timeout_30s() {
22146 let defaults = profile_defaults_as_toml("prod");
22147 let toml_str = toml::to_string(&defaults).unwrap();
22148 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
22149 assert_eq!(
22150 config.server.timeouts.request_timeout_ms,
22151 Some(30_000),
22152 "prod profile must enable the 30-second request timeout by default"
22153 );
22154 }
22155
22156 #[test]
22157 fn dev_profile_leaves_request_timeout_disabled() {
22158 let defaults = profile_defaults_as_toml("dev");
22159 let toml_str = toml::to_string(&defaults).unwrap();
22160 let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
22161 assert!(
22162 config.server.timeouts.request_timeout_ms.is_none(),
22163 "dev profile must not enable a request timeout by default"
22164 );
22165 }
22166
22167 #[test]
22170 fn server_http_defaults_are_unset() {
22171 assert_eq!(
22172 AutumnConfig::default().server.http,
22173 HttpServerConfig::default()
22174 );
22175 let dev: AutumnConfig =
22176 toml::from_str(&toml::to_string(&profile_defaults_as_toml("dev")).unwrap()).unwrap();
22177 assert_eq!(dev.server.http, HttpServerConfig::default());
22178 }
22179
22180 #[test]
22181 fn prod_profile_sets_safe_server_http_limits() {
22182 let prod: AutumnConfig =
22183 toml::from_str(&toml::to_string(&profile_defaults_as_toml("prod")).unwrap()).unwrap();
22184 let http = prod.server.http;
22185 assert_eq!(http.header_read_timeout_ms, Some(10_000));
22186 assert_eq!(http.keep_alive_timeout_ms, Some(75_000));
22187 assert_eq!(http.max_header_bytes, Some(65_536));
22188 assert_eq!(http.http2_max_concurrent_streams, Some(100));
22189 assert_eq!(http.max_connections, Some(10_000));
22190 }
22191
22192 #[test]
22193 fn server_http_parses_from_toml() {
22194 let config: AutumnConfig = toml::from_str(
22195 r"
22196 [server.http]
22197 header_read_timeout_ms = 500
22198 keep_alive_timeout_ms = 900
22199 max_header_bytes = 16384
22200 http2_max_concurrent_streams = 8
22201 max_connections = 3
22202 ",
22203 )
22204 .unwrap();
22205 assert_eq!(
22206 config.server.http,
22207 HttpServerConfig {
22208 header_read_timeout_ms: Some(500),
22209 keep_alive_timeout_ms: Some(900),
22210 max_header_bytes: Some(16_384),
22211 http2_max_concurrent_streams: Some(8),
22212 max_connections: Some(3),
22213 }
22214 );
22215 }
22216
22217 fn load_3057(profile: &str, toml: &str, vars: &[(&str, &str)]) -> AutumnConfig {
22221 try_load_3057(profile, toml, vars).expect("config loads")
22222 }
22223
22224 fn try_load_3057(
22225 profile: &str,
22226 toml: &str,
22227 vars: &[(&str, &str)],
22228 ) -> Result<AutumnConfig, ConfigError> {
22229 let temp = tempfile::tempdir().unwrap();
22230 std::fs::write(temp.path().join("autumn.toml"), toml).unwrap();
22231 let mut env: HashMap<String, String> = vars
22232 .iter()
22233 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
22234 .collect();
22235 env.insert("AUTUMN_ENV".to_owned(), profile.to_owned());
22236 env.insert(
22237 "AUTUMN_MANIFEST_DIR".to_owned(),
22238 temp.path().to_str().unwrap().to_owned(),
22239 );
22240 AutumnConfig::load_with_env(&FakeEnv(env))
22241 }
22242
22243 #[test]
22244 fn prod_profile_enables_strict_config() {
22245 assert!(load_3057("prod", "", &[]).server.strict_config);
22246 assert!(!load_3057("dev", "", &[]).server.strict_config);
22247 }
22248
22249 #[test]
22250 fn prod_profile_rejects_misspelled_timeout_key() {
22251 let toml = "[database]\nstatment_timeout = \"5s\"\n";
22252 let err = try_load_3057("prod", toml, &[]).expect_err("typo must fail in prod");
22253 assert!(format!("{err:?}").contains("statment_timeout"), "{err:?}");
22254 assert!(try_load_3057("dev", toml, &[]).is_ok());
22255 }
22256
22257 #[test]
22258 fn prod_strict_config_opt_out() {
22259 let toml = "[server]\nstrict_config = false\n[database]\nstatment_timeout = \"5s\"\n";
22260 assert!(!load_3057("prod", toml, &[]).server.strict_config);
22261 let toml = "[database]\nstatment_timeout = \"5s\"\n";
22262 let env = [("AUTUMN_SERVER__STRICT_CONFIG", "false")];
22263 assert!(!load_3057("prod", toml, &env).server.strict_config);
22264 }
22265
22266 #[cfg(not(feature = "sqlite"))]
22267 #[test]
22268 fn prod_profile_sets_database_timeouts() {
22269 let config = load_3057("prod", "", &[]);
22270 assert_eq!(
22271 config.database.statement_timeout,
22272 Some(std::time::Duration::from_secs(30))
22273 );
22274 assert_eq!(
22275 config.database.idle_in_transaction_timeout,
22276 Some(std::time::Duration::from_secs(60))
22277 );
22278 }
22279
22280 #[cfg(feature = "sqlite")]
22282 #[test]
22283 fn prod_profile_leaves_database_timeouts_off_on_sqlite() {
22284 let config = load_3057("prod", "", &[]);
22285 assert_eq!(config.database.statement_timeout, None);
22286 assert_eq!(config.database.idle_in_transaction_timeout, None);
22287 }
22288
22289 #[test]
22290 fn dev_profile_leaves_database_timeouts_off() {
22291 let config = load_3057("dev", "", &[]);
22292 assert_eq!(config.database.statement_timeout, None);
22293 assert_eq!(config.database.idle_in_transaction_timeout, None);
22294 }
22295
22296 #[test]
22297 fn prod_database_timeouts_opt_out_via_toml() {
22298 let toml = "[database]\nstatement_timeout = \"0s\"\nidle_in_transaction_timeout = \"0s\"\n";
22299 let config = load_3057("prod", toml, &[]);
22300 assert_eq!(
22301 config.database.statement_timeout,
22302 Some(std::time::Duration::ZERO)
22303 );
22304 assert_eq!(
22305 config.database.idle_in_transaction_timeout,
22306 Some(std::time::Duration::ZERO)
22307 );
22308 }
22309
22310 #[test]
22311 fn server_http_env_overrides() {
22312 let env = MockEnv::new()
22313 .with("AUTUMN_SERVER__HTTP__HEADER_READ_TIMEOUT_MS", "1500")
22314 .with("AUTUMN_SERVER__HTTP__KEEP_ALIVE_TIMEOUT_MS", "2500")
22315 .with("AUTUMN_SERVER__HTTP__MAX_HEADER_BYTES", "9000")
22316 .with("AUTUMN_SERVER__HTTP__HTTP2_MAX_CONCURRENT_STREAMS", "7")
22317 .with("AUTUMN_SERVER__HTTP__MAX_CONNECTIONS", "42");
22318 let mut config = AutumnConfig::default();
22319 config.apply_server_env_overrides_with_env(&env);
22320 assert_eq!(config.server.http.header_read_timeout_ms, Some(1500));
22321 assert_eq!(config.server.http.keep_alive_timeout_ms, Some(2500));
22322 assert_eq!(config.server.http.max_header_bytes, Some(9000));
22323 assert_eq!(config.server.http.http2_max_concurrent_streams, Some(7));
22324 assert_eq!(config.server.http.max_connections, Some(42));
22325 }
22326
22327 #[test]
22328 fn server_http_rejects_unusable_values() {
22329 let mut config = AutumnConfig::default();
22330 config.server.http.max_header_bytes = Some(1024);
22331 let err = config.validate().unwrap_err().to_string();
22332 assert!(err.contains("server.http.max_header_bytes"), "{err}");
22333
22334 let mut config = AutumnConfig::default();
22335 config.server.http.http2_max_concurrent_streams = Some(0);
22336 let err = config.validate().unwrap_err().to_string();
22337 assert!(err.contains("http2_max_concurrent_streams"), "{err}");
22338
22339 let mut config = AutumnConfig::default();
22340 config.server.http.max_connections = Some(usize::MAX);
22341 let err = config.validate().unwrap_err().to_string();
22342 assert!(err.contains("server.http.max_connections"), "{err}");
22343
22344 let mut config = AutumnConfig::default();
22345 config.server.http.max_header_bytes = Some(HttpServerConfig::MIN_HEADER_BYTES);
22346 config.server.http.max_connections = Some(HttpServerConfig::MAX_CONNECTIONS);
22347 config.validate().unwrap();
22348 }
22349
22350 #[test]
22353 fn realtime_defaults_are_unset_outside_prod() {
22354 assert_eq!(AutumnConfig::default().realtime, RealtimeConfig::default());
22355 let dev: AutumnConfig =
22356 toml::from_str(&toml::to_string(&profile_defaults_as_toml("dev")).unwrap()).unwrap();
22357 assert_eq!(dev.realtime, RealtimeConfig::default());
22358 }
22359
22360 #[test]
22361 fn prod_profile_sets_safe_realtime_limits() {
22362 let prod: AutumnConfig =
22363 toml::from_str(&toml::to_string(&profile_defaults_as_toml("prod")).unwrap()).unwrap();
22364 assert_eq!(
22365 prod.realtime,
22366 RealtimeConfig {
22367 max_connections: None,
22368 max_message_bytes: Some(1_048_576),
22369 ping_interval_ms: Some(30_000),
22370 idle_timeout_ms: Some(120_000),
22371 }
22372 );
22373 }
22374
22375 #[test]
22376 fn database_timeouts_env_overrides() {
22377 let env = [
22378 ("AUTUMN_DATABASE__STATEMENT_TIMEOUT", "0"),
22379 ("AUTUMN_DATABASE__IDLE_IN_TRANSACTION_TIMEOUT", "15s"),
22380 ];
22381 let config = load_3057("prod", "", &env);
22382 assert_eq!(
22383 config.database.statement_timeout,
22384 Some(std::time::Duration::ZERO)
22385 );
22386 assert_eq!(
22387 config.database.idle_in_transaction_timeout,
22388 Some(std::time::Duration::from_secs(15))
22389 );
22390 let env = [("AUTUMN_DATABASE__STATEMENT_TIMEOUT", "")];
22392 assert_eq!(load_3057("prod", "", &env).database.statement_timeout, None);
22393 }
22394
22395 #[test]
22396 fn migration_lock_defaults_apply_to_every_profile() {
22397 for profile in ["dev", "prod", "staging"] {
22398 let db = load_3057(profile, "", &[]).database;
22399 assert_eq!(
22400 db.migration_lock_timeout,
22401 std::time::Duration::from_secs(5),
22402 "{profile}"
22403 );
22404 assert_eq!(db.migration_lock_retries, 5, "{profile}");
22405 }
22406 }
22407
22408 #[test]
22409 fn migration_lock_opt_out() {
22410 let toml = "[database]\nmigration_lock_timeout = \"0s\"\nmigration_lock_retries = 0\n";
22411 let db = load_3057("prod", toml, &[]).database;
22412 assert_eq!(db.migration_lock_timeout, std::time::Duration::ZERO);
22413 assert_eq!(db.migration_lock_retries, 0);
22414 let env = [
22415 ("AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT", "2s"),
22416 ("AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES", "1"),
22417 ];
22418 let db = load_3057("prod", "", &env).database;
22419 assert_eq!(db.migration_lock_timeout, std::time::Duration::from_secs(2));
22420 assert_eq!(db.migration_lock_retries, 1);
22421 let toml = "[database]\nmigration_lock_timeout = \"0s\"\n";
22423 let env = [("AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT", "")];
22424 let db = load_3057("prod", toml, &env).database;
22425 assert_eq!(db.migration_lock_timeout, std::time::Duration::ZERO);
22426 }
22427
22428 #[test]
22429 fn prod_profile_admission_default_is_pool_size_times_k() {
22430 let config = load_3057("prod", "[database]\npool_size = 20\n", &[]);
22431 assert_eq!(
22432 config.profile_admission_default(),
22433 Some(20 * PROD_REQUESTS_PER_POOL_CONNECTION)
22434 );
22435 let config = load_3057(
22437 "prod",
22438 "[database]\npool_size = 20\nprimary_pool_size = 9\n",
22439 &[],
22440 );
22441 assert_eq!(
22442 config.profile_admission_default(),
22443 Some(9 * PROD_REQUESTS_PER_POOL_CONNECTION)
22444 );
22445 let config = load_3057("prod", "[database]\nprimary_pool_size = 2\n", &[]);
22447 assert_eq!(
22448 config.profile_admission_default(),
22449 Some(PROD_MIN_ADMISSION_LIMIT)
22450 );
22451 assert_eq!(load_3057("dev", "", &[]).profile_admission_default(), None);
22452 assert_eq!(
22453 load_3057("staging", "", &[]).profile_admission_default(),
22454 None
22455 );
22456 }
22457
22458 #[test]
22459 fn realtime_env_overrides_and_validation() {
22460 let env = MockEnv::new()
22461 .with("AUTUMN_REALTIME__MAX_CONNECTIONS", "9")
22462 .with("AUTUMN_REALTIME__MAX_MESSAGE_BYTES", "4096")
22463 .with("AUTUMN_REALTIME__PING_INTERVAL_MS", "1000")
22464 .with("AUTUMN_REALTIME__IDLE_TIMEOUT_MS", "5000");
22465 let mut config = AutumnConfig::default();
22466 config.apply_realtime_env_overrides_with_env(&env);
22467 assert_eq!(
22468 config.realtime,
22469 RealtimeConfig {
22470 max_connections: Some(9),
22471 max_message_bytes: Some(4096),
22472 ping_interval_ms: Some(1000),
22473 idle_timeout_ms: Some(5000),
22474 }
22475 );
22476 config.realtime.max_message_bytes = Some(0);
22477 let err = config.validate().unwrap_err().to_string();
22478 assert!(err.contains("realtime.max_message_bytes"), "{err}");
22479 }
22480
22481 #[test]
22482 fn prod_profile_admission_default_resolves_to_a_ceiling() {
22483 let config = load_3057("prod", "", &[]);
22484 let limit = crate::capacity::resolve_configured_admission_limit_with_default(
22485 config.server.max_concurrent_requests,
22486 config.server.capacity_contract.as_deref(),
22487 config.profile_admission_default(),
22488 );
22489 assert_eq!(
22490 limit,
22491 crate::capacity::AdmissionLimit::ProfileDefault(
22492 default_pool_size() * PROD_REQUESTS_PER_POOL_CONNECTION
22493 )
22494 );
22495 }
22496
22497 #[test]
22498 fn prod_admission_default_opt_out() {
22499 for (toml, env) in [
22500 ("[server]\nmax_concurrent_requests = 0\n", vec![]),
22501 ("", vec![("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "0")]),
22502 ] {
22503 let config = load_3057("prod", toml, &env);
22504 let limit = crate::capacity::resolve_configured_admission_limit_with_default(
22505 config.server.max_concurrent_requests,
22506 config.server.capacity_contract.as_deref(),
22507 config.profile_admission_default(),
22508 );
22509 assert_eq!(limit, crate::capacity::AdmissionLimit::Unlimited);
22510 }
22511 }
22512
22513 #[test]
22514 fn test_resilience_config_defaults() {
22515 let config = AutumnConfig::default();
22516 assert!(
22517 config
22518 .resilience
22519 .circuit_breaker
22520 .defaults
22521 .failure_ratio_threshold
22522 .is_none()
22523 );
22524 }
22525
22526 #[test]
22527 fn test_resilience_config_parsing() {
22528 let toml_str = r#"
22529 [resilience.circuit_breaker.defaults]
22530 failure_ratio_threshold = 0.6
22531 sample_window_secs = 20
22532 minimum_sample_count = 15
22533 open_duration_secs = 30
22534 half_open_trial_count = 5
22535
22536 [resilience.circuit_breaker.hosts."api.github.com"]
22537 failure_ratio_threshold = 0.3
22538 open_duration_secs = 10
22539 "#;
22540 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
22541 let cb = &config.resilience.circuit_breaker;
22542 assert_eq!(cb.defaults.failure_ratio_threshold, Some(0.6));
22543 assert_eq!(cb.defaults.sample_window_secs, Some(20));
22544 assert_eq!(cb.defaults.minimum_sample_count, Some(15));
22545 assert_eq!(cb.defaults.open_duration_secs, Some(30));
22546 assert_eq!(cb.defaults.half_open_trial_count, Some(5));
22547
22548 let host_cb = cb.hosts.get("api.github.com").unwrap();
22549 assert_eq!(host_cb.failure_ratio_threshold, Some(0.3));
22550 assert_eq!(host_cb.open_duration_secs, Some(10));
22551 assert!(host_cb.sample_window_secs.is_none());
22552 }
22553
22554 #[test]
22555 fn test_resilience_config_env_overrides() {
22556 struct FakeEnv(std::collections::HashMap<String, String>);
22557 impl Env for FakeEnv {
22558 fn var(&self, key: &str) -> Result<String, std::env::VarError> {
22559 self.0
22560 .get(key)
22561 .cloned()
22562 .ok_or(std::env::VarError::NotPresent)
22563 }
22564 }
22565
22566 let mut config = AutumnConfig::default();
22567 let env = FakeEnv(
22568 [(
22569 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD".to_owned(),
22570 "0.7".to_owned(),
22571 )]
22572 .into(),
22573 );
22574 config.apply_resilience_env_overrides_with_env(&env);
22575 assert_eq!(
22576 config
22577 .resilience
22578 .circuit_breaker
22579 .defaults
22580 .failure_ratio_threshold,
22581 Some(0.7)
22582 );
22583 }
22584
22585 #[test]
22586 fn test_resilience_config_parses_slow_call_keys() {
22587 let toml_str = r#"
22588 [resilience.circuit_breaker.defaults]
22589 slow_call_duration_threshold_ms = 2500
22590 slow_call_rate_threshold = 0.4
22591 cancelled_call_outcome = "failure"
22592
22593 [resilience.circuit_breaker.hosts."api.github.com"]
22594 slow_call_duration_threshold_ms = 0
22595 "#;
22596 let config: AutumnConfig = toml::from_str(toml_str).unwrap();
22597 let cb = &config.resilience.circuit_breaker;
22598 assert_eq!(cb.defaults.slow_call_duration_threshold_ms, Some(2500));
22599 assert_eq!(cb.defaults.slow_call_rate_threshold, Some(0.4));
22600 assert_eq!(
22601 cb.defaults.cancelled_call_outcome,
22602 Some(crate::circuit_breaker::CancelledCallOutcome::Failure)
22603 );
22604 let host_cb = cb.hosts.get("api.github.com").unwrap();
22605 assert_eq!(host_cb.slow_call_duration_threshold_ms, Some(0));
22606 assert!(host_cb.slow_call_rate_threshold.is_none());
22607 }
22608
22609 #[test]
22610 fn test_resilience_config_slow_call_env_overrides() {
22611 let env = MockEnv::new()
22612 .with(
22613 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_DURATION_THRESHOLD_MS",
22614 "1500",
22615 )
22616 .with(
22617 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_RATE_THRESHOLD",
22618 "0.25",
22619 )
22620 .with(
22621 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__CANCELLED_CALL_OUTCOME",
22622 "failure",
22623 );
22624 let mut config = AutumnConfig::default();
22625 config.apply_resilience_env_overrides_with_env(&env);
22626 let defaults = &config.resilience.circuit_breaker.defaults;
22627 assert_eq!(defaults.slow_call_duration_threshold_ms, Some(1500));
22628 assert_eq!(defaults.slow_call_rate_threshold, Some(0.25));
22629 assert_eq!(
22630 defaults.cancelled_call_outcome,
22631 Some(crate::circuit_breaker::CancelledCallOutcome::Failure)
22632 );
22633 }
22634
22635 #[test]
22636 fn test_resilience_config_bad_cancelled_call_outcome_env_is_ignored() {
22637 let env = MockEnv::new().with(
22638 "AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__CANCELLED_CALL_OUTCOME",
22639 "drop",
22640 );
22641 let mut config = AutumnConfig::default();
22642 config.apply_resilience_env_overrides_with_env(&env);
22643 assert!(
22644 config
22645 .resilience
22646 .circuit_breaker
22647 .defaults
22648 .cancelled_call_outcome
22649 .is_none()
22650 );
22651 }
22652
22653 const TEST_REGISTRY: &[DeprecatedKey] = &[DeprecatedKey {
22657 path: "a.b.c",
22658 replacement: Some("a.b.d"),
22659 since: "0.1.0",
22660 remove_in: "1.0.0",
22661 }];
22662
22663 fn merged_with_abc(value: toml::Value) -> toml::Table {
22664 let mut root = toml::Table::new();
22665 let mut b = toml::Table::new();
22666 b.insert("c".to_owned(), value);
22667 let mut a = toml::Table::new();
22668 a.insert("b".to_owned(), toml::Value::Table(b));
22669 root.insert("a".to_owned(), toml::Value::Table(a));
22670 root
22671 }
22672
22673 #[test]
22674 fn red_detect_from_toml_present_emits_finding() {
22675 let merged = merged_with_abc(toml::Value::Integer(1));
22676 let env = MockEnv::new(); let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22678 assert_eq!(findings.len(), 1);
22679 let f = &findings[0];
22680 assert_eq!(f.path, "a.b.c");
22681 assert_eq!(f.replacement.as_deref(), Some("a.b.d"));
22682 assert_eq!(f.since, "0.1.0");
22683 assert_eq!(f.remove_in, "1.0.0");
22684 assert_eq!(f.source, DeprecationSource::Toml);
22685 }
22686
22687 #[test]
22688 fn red_detect_from_env_present_emits_finding() {
22689 let merged = toml::Table::new(); let env = MockEnv::new().with("AUTUMN_A__B__C", "val");
22691 let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22692 assert_eq!(findings.len(), 1);
22693 assert_eq!(findings[0].source, DeprecationSource::Env);
22694 }
22695
22696 #[test]
22697 fn red_detect_dedupe_toml_and_env_single_finding() {
22698 let merged = merged_with_abc(toml::Value::Boolean(true));
22699 let env = MockEnv::new().with("AUTUMN_A__B__C", "true");
22700 let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22701 assert_eq!(findings.len(), 1, "TOML+env should collapse to one finding");
22702 assert_eq!(findings[0].source, DeprecationSource::Both);
22703 }
22704
22705 #[test]
22706 fn red_detect_replacement_only_no_finding() {
22707 let mut merged = toml::Table::new();
22709 let mut b = toml::Table::new();
22710 b.insert("d".to_owned(), toml::Value::Integer(1)); let mut a = toml::Table::new();
22712 a.insert("b".to_owned(), toml::Value::Table(b));
22713 merged.insert("a".to_owned(), toml::Value::Table(a));
22714
22715 let env = MockEnv::new();
22716 let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22717 assert!(
22718 findings.is_empty(),
22719 "only replacement key set — no deprecation warning"
22720 );
22721 }
22722
22723 #[test]
22724 fn red_detect_absent_everywhere_no_finding() {
22725 let merged = toml::Table::new();
22726 let env = MockEnv::new();
22727 let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
22728 assert!(findings.is_empty());
22729 }
22730
22731 #[test]
22732 fn red_env_var_name_mapping() {
22733 assert_eq!(
22734 deprecated_env_var_name("security.rate_limit.trusted_proxies"),
22735 "AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES"
22736 );
22737 assert_eq!(deprecated_env_var_name("a.b.c"), "AUTUMN_A__B__C");
22738 }
22739
22740 #[test]
22741 fn red_toml_path_non_table_mid_segment_not_present() {
22742 let mut root = toml::Table::new();
22744 root.insert("a".to_owned(), toml::Value::Integer(42)); assert!(!toml_path_present(&root, "a.b.c"));
22746 }
22747
22748 #[test]
22749 fn red_schema_leaf_paths_includes_known_paths() {
22750 let leaves = AutumnConfig::schema_leaf_paths();
22758 assert!(
22759 leaves.contains("server.port"),
22760 "server.port must be a schema leaf"
22761 );
22762 assert!(
22763 leaves.contains("server.host"),
22764 "server.host must be a schema leaf"
22765 );
22766 assert!(
22767 leaves.contains("database.url"),
22768 "database.url must be a schema leaf"
22769 );
22770 assert!(
22773 leaves.contains("security"),
22774 "security must appear as a root-level leaf"
22775 );
22776 assert!(
22777 leaves.contains("session"),
22778 "session must appear as a root-level leaf"
22779 );
22780 }
22781
22782 #[test]
22785 fn shards_auto_split_true_when_all_slots_none() {
22786 let config = DatabaseConfig {
22787 shards: vec![
22788 shard("a", "postgres://a/app"),
22789 shard("b", "postgres://b/app"),
22790 ],
22791 ..Default::default()
22792 };
22793 assert!(config.shards_auto_split());
22794 }
22795
22796 #[test]
22797 fn shards_auto_split_false_when_no_shards() {
22798 assert!(!DatabaseConfig::default().shards_auto_split());
22799 }
22800
22801 #[test]
22802 fn shards_auto_split_false_when_any_shard_declares_slots() {
22803 let config = DatabaseConfig {
22804 shards: vec![
22805 shard_with_slots("a", "postgres://a/app", &["0-8191"]),
22806 shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
22807 ],
22808 ..Default::default()
22809 };
22810 assert!(!config.shards_auto_split());
22811 }
22812
22813 #[test]
22814 fn resolved_shard_assignments_two_shards() {
22815 let config = DatabaseConfig {
22816 shards: vec![
22817 shard("s0", "postgres://s0/app"),
22818 shard("s1", "postgres://s1/app"),
22819 ],
22820 ..Default::default()
22821 };
22822 let assignments = config
22823 .resolved_shard_assignments()
22824 .expect("two-shard auto-split should resolve");
22825 assert_eq!(assignments.len(), 2);
22826 assert_eq!(assignments[0].name, "s0");
22827 assert_eq!(assignments[0].ranges, "0-8191");
22828 assert_eq!(assignments[1].name, "s1");
22829 assert_eq!(assignments[1].ranges, "8192-16383");
22830 }
22831
22832 #[test]
22833 fn resolved_shard_assignments_three_shards() {
22834 let config = DatabaseConfig {
22835 shards: vec![
22836 shard("s0", "postgres://s0/app"),
22837 shard("s1", "postgres://s1/app"),
22838 shard("s2", "postgres://s2/app"),
22839 ],
22840 ..Default::default()
22841 };
22842 let assignments = config
22843 .resolved_shard_assignments()
22844 .expect("three-shard auto-split should resolve");
22845 assert_eq!(assignments.len(), 3);
22846 assert_eq!(assignments[0].ranges, "0-5461");
22847 assert_eq!(assignments[1].ranges, "5462-10922");
22848 assert_eq!(assignments[2].ranges, "10923-16383");
22849 }
22850
22851 fn assignment(name: &str, ranges: &str) -> ShardSlotAssignment {
22854 ShardSlotAssignment {
22855 name: name.to_owned(),
22856 ranges: ranges.to_owned(),
22857 }
22858 }
22859
22860 #[test]
22861 fn check_stored_slot_map_explicit_mode_always_ok() {
22862 let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22864 let stored = vec![
22865 assignment("s0", "0-5460"),
22866 assignment("s1", "5461-10922"),
22867 assignment("s2", "10923-16383"),
22868 ];
22869 assert!(check_stored_slot_map(false, &computed, Some(&stored)).is_ok());
22870 }
22871
22872 #[test]
22873 fn check_stored_slot_map_first_boot_no_stored_ok() {
22874 let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22875 assert!(check_stored_slot_map(true, &computed, None).is_ok());
22876 }
22877
22878 #[test]
22879 fn check_stored_slot_map_matching_map_ok() {
22880 let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22881 let stored = vec![assignment("s1", "8192-16383"), assignment("s0", "0-8191")];
22883 assert!(check_stored_slot_map(true, &computed, Some(&stored)).is_ok());
22884 }
22885
22886 #[test]
22887 fn check_stored_slot_map_mismatch_two_to_three_shards_returns_err() {
22888 let computed = vec![
22889 assignment("s0", "0-5460"),
22890 assignment("s1", "5461-10922"),
22891 assignment("s2", "10923-16383"),
22892 ];
22893 let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22894 let err = check_stored_slot_map(true, &computed, Some(&stored))
22895 .expect_err("3-shard auto-split vs 2-shard stored map must fail");
22896 assert!(err.contains("shard slot map mismatch"), "message: {err}");
22897 assert!(err.contains("3 shards"), "message: {err}");
22898 assert!(err.contains("2 shards"), "message: {err}");
22899 }
22900
22901 #[test]
22902 fn check_stored_slot_map_mismatch_shard_rename_returns_err() {
22903 let computed = vec![
22904 assignment("alpha", "0-8191"),
22905 assignment("beta", "8192-16383"),
22906 ];
22907 let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
22908 let err = check_stored_slot_map(true, &computed, Some(&stored))
22909 .expect_err("renamed shards must be detected as mismatch");
22910 assert!(err.contains("shard slot map mismatch"), "message: {err}");
22911 assert!(
22912 err.contains("alpha"),
22913 "message must name computed shards: {err}"
22914 );
22915 assert!(err.contains("s0"), "message must name stored shards: {err}");
22916 }
22917
22918 #[test]
22921 fn process_role_default_is_combined() {
22922 assert_eq!(ProcessRole::default(), ProcessRole::Combined);
22923 assert_eq!(AutumnConfig::default().role, ProcessRole::Combined);
22924 }
22925
22926 #[test]
22927 fn process_role_from_env_value_accepts_aliases_case_insensitively() {
22928 for v in [
22929 "combined",
22930 "COMBINED",
22931 " all ",
22932 "web_and_worker",
22933 "server_and_worker",
22934 ] {
22935 assert_eq!(
22936 ProcessRole::from_env_value(v),
22937 Some(ProcessRole::Combined),
22938 "{v}"
22939 );
22940 }
22941 for v in ["web", "Web", " SERVER ", "http"] {
22942 assert_eq!(
22943 ProcessRole::from_env_value(v),
22944 Some(ProcessRole::Web),
22945 "{v}"
22946 );
22947 }
22948 for v in ["worker", "WORKER", " jobs ", "worker_only"] {
22949 assert_eq!(
22950 ProcessRole::from_env_value(v),
22951 Some(ProcessRole::Worker),
22952 "{v}"
22953 );
22954 }
22955 for v in ["", "webby", "workers", "scheduler", "both"] {
22956 assert_eq!(ProcessRole::from_env_value(v), None, "{v}");
22957 }
22958 }
22959
22960 #[test]
22961 fn process_role_as_str_round_trips_through_from_env_value() {
22962 for role in [ProcessRole::Combined, ProcessRole::Web, ProcessRole::Worker] {
22963 assert_eq!(ProcessRole::from_env_value(role.as_str()), Some(role));
22964 }
22965 }
22966
22967 #[test]
22970 fn scheduler_backend_is_fleet_distributed_only_for_postgres() {
22971 assert!(!SchedulerBackend::InProcess.is_fleet_distributed());
22972 assert!(SchedulerBackend::Postgres.is_fleet_distributed());
22973 }
22974
22975 #[test]
22976 fn process_role_serves_http_and_runs_workers_truth_table() {
22977 assert!(ProcessRole::Combined.serves_http());
22978 assert!(ProcessRole::Combined.runs_workers());
22979 assert!(ProcessRole::Web.serves_http());
22980 assert!(!ProcessRole::Web.runs_workers());
22981 assert!(!ProcessRole::Worker.serves_http());
22982 assert!(ProcessRole::Worker.runs_workers());
22983 }
22984
22985 #[test]
22986 fn process_role_deserializes_from_toml() {
22987 let web: AutumnConfig = toml::from_str("role = \"web\"\n").expect("web role");
22988 assert_eq!(web.role, ProcessRole::Web);
22989 let worker: AutumnConfig = toml::from_str("role = \"worker\"\n").expect("worker role");
22990 assert_eq!(worker.role, ProcessRole::Worker);
22991 let combined: AutumnConfig =
22992 toml::from_str("role = \"combined\"\n").expect("combined role");
22993 assert_eq!(combined.role, ProcessRole::Combined);
22994 let aliased: AutumnConfig = toml::from_str("role = \"all\"\n").expect("all alias");
22996 assert_eq!(aliased.role, ProcessRole::Combined);
22997 let absent: AutumnConfig = toml::from_str("").expect("empty config");
22999 assert_eq!(absent.role, ProcessRole::Combined);
23000 }
23001
23002 #[test]
23003 fn split_role_requires_durable_backend_truth_table() {
23004 assert!(!split_role_requires_durable_backend(
23007 ProcessRole::Combined,
23008 "local"
23009 ));
23010 assert!(!split_role_requires_durable_backend(
23011 ProcessRole::Combined,
23012 "postgres"
23013 ));
23014 assert!(split_role_requires_durable_backend(
23018 ProcessRole::Web,
23019 "local"
23020 ));
23021 assert!(split_role_requires_durable_backend(
23022 ProcessRole::Worker,
23023 "local"
23024 ));
23025 assert!(split_role_requires_durable_backend(
23026 ProcessRole::Web,
23027 "postgresql"
23028 ));
23029 assert!(split_role_requires_durable_backend(ProcessRole::Web, ""));
23030 assert!(split_role_requires_durable_backend(
23031 ProcessRole::Web,
23032 "unknown"
23033 ));
23034 assert!(split_role_requires_durable_backend(
23037 ProcessRole::Web,
23038 "LOCAL"
23039 ));
23040 assert!(!split_role_requires_durable_backend(
23042 ProcessRole::Web,
23043 "postgres"
23044 ));
23045 assert!(!split_role_requires_durable_backend(
23046 ProcessRole::Worker,
23047 "redis"
23048 ));
23049 assert!(!split_role_requires_durable_backend(
23052 ProcessRole::Web,
23053 "sqlite"
23054 ));
23055 assert!(!split_role_requires_durable_backend(
23056 ProcessRole::Worker,
23057 "sqlite"
23058 ));
23059 }
23060
23061 #[test]
23065 fn split_role_on_sqlite_requires_a_file_backed_database() {
23066 for url in [
23068 "sqlite::memory:",
23069 "sqlite://:memory:",
23070 ":memory:",
23071 "file::memory:",
23072 "file::memory:?cache=shared",
23073 "sqlite:file::memory:?cache=shared",
23074 "sqlite://app.db?mode=memory",
23075 "sqlite://",
23078 "sqlite:",
23079 ] {
23080 assert!(
23081 is_in_memory_sqlite_target(url),
23082 "{url} must read as in-memory"
23083 );
23084 assert!(
23085 split_role_requires_file_backed_sqlite(ProcessRole::Web, "sqlite", Some(url)),
23086 "a split role on {url} must be refused"
23087 );
23088 }
23089
23090 for url in ["sqlite:///var/lib/app.db", "sqlite://./app.db", "app.db"] {
23092 assert!(
23093 !is_in_memory_sqlite_target(url),
23094 "{url} must read as file-backed"
23095 );
23096 assert!(
23097 !split_role_requires_file_backed_sqlite(ProcessRole::Web, "sqlite", Some(url)),
23098 "a split role on {url} is valid"
23099 );
23100 }
23101
23102 assert!(!split_role_requires_file_backed_sqlite(
23104 ProcessRole::Combined,
23105 "sqlite",
23106 Some("sqlite::memory:")
23107 ));
23108 assert!(!split_role_requires_file_backed_sqlite(
23110 ProcessRole::Worker,
23111 "postgres",
23112 Some("sqlite::memory:")
23113 ));
23114 assert!(!split_role_requires_file_backed_sqlite(
23116 ProcessRole::Worker,
23117 "sqlite",
23118 None
23119 ));
23120 }
23121
23122 #[test]
23125 fn scheduler_backend_sqlite_is_selectable_and_fleet_distributed() {
23126 assert_eq!(
23127 SchedulerBackend::from_env_value("sqlite"),
23128 Some(SchedulerBackend::Sqlite)
23129 );
23130 assert_eq!(
23131 SchedulerBackend::from_env_value(" SINGLE_HOST "),
23132 Some(SchedulerBackend::Sqlite)
23133 );
23134 assert_eq!(SchedulerBackend::from_env_value("nonsense"), None);
23135 assert!(SchedulerBackend::Sqlite.is_fleet_distributed());
23136 assert!(!SchedulerBackend::InProcess.is_fleet_distributed());
23137
23138 let config: AutumnConfig =
23139 toml::from_str("[scheduler]\nbackend = \"sqlite\"\n").expect("sqlite backend");
23140 assert_eq!(config.scheduler.backend, SchedulerBackend::Sqlite);
23141 let aliased: AutumnConfig =
23142 toml::from_str("[scheduler]\nbackend = \"single_host\"\n").expect("single_host alias");
23143 assert_eq!(aliased.scheduler.backend, SchedulerBackend::Sqlite);
23144
23145 let env = MockEnv::new().with("AUTUMN_SCHEDULER__BACKEND", "sqlite");
23146 let mut config = AutumnConfig::default();
23147 config.apply_env_overrides_with_env(&env);
23148 assert_eq!(config.scheduler.backend, SchedulerBackend::Sqlite);
23149 }
23150
23151 #[test]
23154 fn jobs_sqlite_section_parses_with_defaults() {
23155 let defaults = JobSqliteConfig::default();
23156 assert_eq!(defaults.visibility_timeout_ms, 30_000);
23157 assert_eq!(defaults.poll_interval_ms, 250);
23158
23159 let config: AutumnConfig = toml::from_str(
23160 "[jobs]\nbackend = \"sqlite\"\n\n[jobs.sqlite]\nvisibility_timeout_ms = 5000\n\
23161 poll_interval_ms = 50\n",
23162 )
23163 .expect("sqlite job section");
23164 assert_eq!(config.jobs.backend, "sqlite");
23165 assert_eq!(config.jobs.sqlite.visibility_timeout_ms, 5_000);
23166 assert_eq!(config.jobs.sqlite.poll_interval_ms, 50);
23167
23168 let bare: AutumnConfig =
23170 toml::from_str("[jobs]\nbackend = \"sqlite\"\n").expect("bare jobs section");
23171 assert_eq!(bare.jobs.sqlite.visibility_timeout_ms, 30_000);
23172 }
23173
23174 #[test]
23175 fn autumn_role_env_override_sets_role() {
23176 let env = MockEnv::new().with("AUTUMN_ROLE", "worker");
23177 let mut config = AutumnConfig::default();
23178 config.apply_env_overrides_with_env(&env);
23179 assert_eq!(config.role, ProcessRole::Worker);
23180
23181 let env = MockEnv::new().with("AUTUMN_ROLE", " WEB ");
23182 let mut config = AutumnConfig::default();
23183 config.apply_env_overrides_with_env(&env);
23184 assert_eq!(config.role, ProcessRole::Web);
23185 }
23186
23187 #[test]
23188 fn autumn_role_env_override_ignores_invalid_value_keeping_default() {
23189 let env = MockEnv::new().with("AUTUMN_ROLE", "nonsense");
23190 let mut config = AutumnConfig {
23193 role: ProcessRole::Worker,
23194 ..Default::default()
23195 };
23196 config.apply_env_overrides_with_env(&env);
23197 assert_eq!(config.role, ProcessRole::Worker);
23198
23199 let mut config = AutumnConfig::default();
23201 config.apply_env_overrides_with_env(&env);
23202 assert_eq!(config.role, ProcessRole::Combined);
23203 }
23204}