use std::path::{Path, PathBuf};
use serde::Deserialize;
use thiserror::Error;
use std::sync::OnceLock;
static MACRO_MANIFEST_DIR: OnceLock<String> = OnceLock::new();
static MACRO_IS_DEBUG: OnceLock<bool> = OnceLock::new();
#[doc(hidden)]
pub fn __set_macro_context(manifest_dir: String, is_debug: bool) {
let _ = MACRO_MANIFEST_DIR.set(manifest_dir);
let _ = MACRO_IS_DEBUG.set(is_debug);
}
pub trait Env {
fn var(&self, key: &str) -> Result<String, std::env::VarError>;
}
#[derive(Clone, Default)]
pub struct OsEnv;
impl Env for OsEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
if key == "AUTUMN_MANIFEST_DIR" {
if let Ok(override_val) = std::env::var(key) {
return Ok(override_val);
}
if let Some(dir) = MACRO_MANIFEST_DIR.get() {
return Ok(dir.clone());
}
} else if key == "AUTUMN_IS_DEBUG"
&& let Some(is_debug) = MACRO_IS_DEBUG.get()
{
return Ok(if *is_debug {
"1".to_string()
} else {
"0".to_string()
});
}
std::env::var(key)
}
}
#[derive(Clone, Default)]
pub struct MockEnv {
vars: std::collections::HashMap<String, String>,
}
impl MockEnv {
#[must_use]
pub fn new() -> Self {
Self {
vars: std::collections::HashMap::new(),
}
}
#[must_use]
pub fn with(mut self, key: &str, value: &str) -> Self {
self.vars.insert(key.to_owned(), value.to_owned());
self
}
#[must_use]
pub fn without(mut self, key: &str) -> Self {
self.vars.remove(key);
self
}
}
impl Env for MockEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.vars
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
}
}
fn find_config_file_named(filename: &str, env: &dyn Env) -> PathBuf {
if let Ok(manifest_dir) = env.var("AUTUMN_MANIFEST_DIR") {
let candidate = PathBuf::from(manifest_dir).join(filename);
if candidate.exists() {
return candidate;
}
}
PathBuf::from(filename)
}
fn load_raw_toml(path: &Path) -> Result<Option<toml::Value>, ConfigError> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let table = toml::from_str::<toml::Table>(&contents)?;
Ok(Some(toml::Value::Table(table)))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(ConfigError::Io(e)),
}
}
pub(crate) fn resolve_profile(env: &dyn Env) -> String {
let selected_profile_input = resolve_profile_input(env);
normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned())
}
fn resolve_profile_input(env: &dyn Env) -> String {
if let Ok(profile) = env.var("AUTUMN_ENV") {
let trimmed = profile.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
if let Ok(profile) = env.var("AUTUMN_PROFILE") {
let trimmed = profile.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
let args: Vec<String> = std::env::args().collect();
for (i, arg) in args.iter().enumerate() {
if arg == "--profile"
&& let Some(profile) = args.get(i + 1)
{
let trimmed = profile.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
if let Some(profile) = arg.strip_prefix("--profile=") {
let trimmed = profile.trim();
if !trimmed.is_empty() {
return trimmed.to_owned();
}
}
}
if env.var("AUTUMN_IS_DEBUG").ok().as_deref() == Some("0") {
return "prod".to_owned();
}
"dev".to_owned()
}
#[must_use]
pub fn normalize_profile_name(profile: &str) -> Option<String> {
let trimmed = profile.trim();
if trimmed.is_empty() {
return None;
}
if trimmed.eq_ignore_ascii_case("production") {
return Some("prod".to_owned());
}
if trimmed.eq_ignore_ascii_case("development") {
return Some("dev".to_owned());
}
if trimmed.eq_ignore_ascii_case("prod") {
return Some("prod".to_owned());
}
if trimmed.eq_ignore_ascii_case("dev") {
return Some("dev".to_owned());
}
Some(trimmed.to_owned())
}
#[must_use]
pub(crate) fn profile_is_dev(profile: Option<&str>) -> bool {
matches!(profile, Some("dev" | "development"))
}
fn profile_lookup_names(profile: &str) -> Vec<&str> {
match profile {
"prod" => vec!["production", "prod"],
"dev" => vec!["development", "dev"],
other => vec![other],
}
}
#[must_use]
pub fn profile_override_file_lookup_names(
profile: &str,
selected_profile_input: &str,
) -> Vec<String> {
match profile {
"prod" if selected_profile_input.eq_ignore_ascii_case("production") => {
vec!["production".to_owned(), "prod".to_owned()]
}
"prod" => vec!["prod".to_owned(), "production".to_owned()],
"dev" if selected_profile_input.eq_ignore_ascii_case("development") => {
vec!["development".to_owned(), "dev".to_owned()]
}
"dev" => vec!["dev".to_owned(), "development".to_owned()],
other => vec![other.to_owned()],
}
}
fn profile_section_from_base_toml(base: &toml::Value, profile: &str) -> Option<toml::Value> {
base.get("profile")
.and_then(toml::Value::as_table)
.and_then(|profiles| profiles.get(profile))
.and_then(toml::Value::as_table)
.map(|table| toml::Value::Table(table.clone()))
}
#[must_use]
pub fn profile_shutdown_defaults(profile: &str) -> (u64, u64) {
let name = normalize_profile_name(profile).unwrap_or_else(|| profile.trim().to_owned());
let defaults = profile_defaults_as_toml(&name);
let read = |key: &str, fallback: u64| {
defaults
.get("server")
.and_then(|server| server.get(key))
.and_then(toml::Value::as_integer)
.and_then(|value| u64::try_from(value).ok())
.unwrap_or(fallback)
};
(
read("prestop_grace_secs", default_prestop_grace()),
read("shutdown_timeout_secs", default_shutdown_timeout()),
)
}
fn profile_defaults_as_toml(profile: &str) -> toml::Value {
match profile {
"dev" => toml::from_str(
r#"
[log]
level = "debug"
format = "Pretty"
[telemetry]
environment = "development"
[server]
host = "127.0.0.1"
shutdown_timeout_secs = 1
# Zero-out the prestop grace in dev: there is no load balancer to
# deregister, so the 5-second default would add unnecessary latency
# on every Ctrl-C.
prestop_grace_secs = 0
[health]
detailed = true
[actuator]
sensitive = true
[cors]
allowed_origins = ["*"]
# Dev: enable the local-disk blob store rooted at
# `target/blobs/` automatically when the `storage` feature
# is on. `prod` deliberately leaves `backend = "disabled"`
# so the operator has to opt into either `local` (with
# `allow_local_in_production = true`) or `s3`.
[storage]
backend = "local"
[security.trusted_proxies]
# Dev: trust X-Forwarded-* from loopback only so local reverse
# proxies (nginx, caddy, etc. on 127.0.0.1/::1) work out of the box.
trust_forwarded_headers = true
ranges = ["127.0.0.0/8", "::1/128"]
# Dev: CSRF disabled (default), HSTS off (default)
"#,
)
.expect("valid dev toml"),
"prod" => {
#[cfg_attr(
feature = "sqlite",
allow(unused_mut, reason = "only the Postgres build adds to it")
)]
let mut prod: toml::Value = toml::from_str(
r#"
[log]
level = "info"
format = "Json"
[telemetry]
environment = "production"
[server]
host = "0.0.0.0"
# Request timeout + 5 s margin (issue #3058).
shutdown_timeout_secs = 35
# Prod: a misspelled key fails the boot (#3057).
strict_config = true
[server.timeouts]
request_timeout_ms = 30_000
# Prod: bound slow and idle clients (issue #3065).
[server.http]
header_read_timeout_ms = 10_000
keep_alive_timeout_ms = 75_000
max_header_bytes = 65_536
http2_max_concurrent_streams = 100
max_connections = 10_000
[realtime]
max_message_bytes = 1_048_576
ping_interval_ms = 30_000
idle_timeout_ms = 120_000
[health]
detailed = false
# Prod: strict security -- HSTS on, CSRF enabled, secure cookies
[security.headers]
strict_transport_security = true
[security.csrf]
enabled = true
[session]
secure = true
"#,
)
.expect("valid prod toml");
#[cfg(not(feature = "sqlite"))]
deep_merge(
&mut prod,
toml::from_str(
r#"
[database]
statement_timeout = "30s"
idle_in_transaction_timeout = "60s"
"#,
)
.expect("valid prod database toml"),
);
prod
}
_ => toml::Value::Table(toml::map::Map::new()), }
}
#[cfg(feature = "mail")]
fn has_mail_transport_source(merged: &toml::Value, env: &dyn Env) -> bool {
merged
.get("mail")
.and_then(toml::Value::as_table)
.is_some_and(|mail| mail.contains_key("transport"))
|| env
.var("AUTUMN_MAIL__TRANSPORT")
.ok()
.as_deref()
.is_some_and(|value| crate::mail::Transport::from_env_value(value).is_some())
}
const MAX_MERGE_DEPTH: usize = 16;
fn deep_merge(base: &mut toml::Value, overlay: toml::Value) {
deep_merge_with_depth(base, overlay, 0);
}
fn deep_merge_with_depth(base: &mut toml::Value, overlay: toml::Value, depth: usize) {
if depth > MAX_MERGE_DEPTH {
eprintln!(
"Warning: Configuration merge exceeded max depth ({MAX_MERGE_DEPTH}), ignoring deeper values."
);
return;
}
let toml::Value::Table(overlay_table) = overlay else {
return;
};
let Some(base_table) = base.as_table_mut() else {
return;
};
for (key, overlay_val) in overlay_table {
let is_recursive_merge =
overlay_val.is_table() && base_table.get(&key).is_some_and(toml::Value::is_table);
if is_recursive_merge {
if let Some(base_val) = base_table.get_mut(&key) {
deep_merge_with_depth(base_val, overlay_val, depth + 1);
}
} else {
base_table.insert(key, overlay_val);
}
}
}
fn suggest_profile(profile: &str) -> Option<&'static str> {
let known = ["dev", "prod"];
let mut suggestions: Vec<(&str, usize)> = known
.iter()
.map(|k| (*k, levenshtein(profile, k)))
.filter(|(_, d)| *d <= 2)
.collect();
suggestions.sort_by_key(|(_, d)| *d);
suggestions.first().map(|(name, _)| *name)
}
fn warn_profile_typo(profile: &str) {
if let Some(suggestion) = suggest_profile(profile) {
eprintln!(
"Warning: profile \"{profile}\" has no config file (autumn-{profile}.toml) \
and no smart defaults. Did you mean \"{suggestion}\"?"
);
}
}
fn should_warn_missing_profile_file(profile: &str, has_inline_profile_section: bool) -> bool {
profile != "dev" && profile != "prod" && !has_inline_profile_section
}
#[must_use]
pub fn levenshtein(a: &str, b: &str) -> usize {
let n = b.chars().count();
let mut prev: Vec<usize> = (0..=n).collect();
for (i, a_ch) in a.chars().enumerate() {
let mut prev_diag = prev[0];
prev[0] = i + 1;
for (j, b_ch) in b.chars().enumerate() {
let old_prev = prev[j + 1];
let cost = usize::from(a_ch != b_ch);
prev[j + 1] = (prev[j + 1] + 1).min(prev[j] + 1).min(prev_diag + cost);
prev_diag = old_prev;
}
}
prev[n]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeprecatedKey {
pub path: &'static str,
pub replacement: Option<&'static str>,
pub since: &'static str,
pub remove_in: &'static str,
}
pub static DEPRECATED_CONFIG_KEYS: &[DeprecatedKey] = &[
DeprecatedKey {
path: "security.rate_limit.trusted_proxies",
replacement: Some("security.trusted_proxies.ranges"),
since: "0.5.0",
remove_in: "1.0.0",
},
DeprecatedKey {
path: "security.rate_limit.trust_forwarded_headers",
replacement: Some("security.trusted_proxies.trust_forwarded_headers"),
since: "0.5.0",
remove_in: "1.0.0",
},
];
#[must_use]
pub fn deprecated_config_keys() -> &'static [DeprecatedKey] {
DEPRECATED_CONFIG_KEYS
}
#[must_use]
pub fn deprecated_env_var_name(path: &str) -> String {
format!("AUTUMN_{}", path.to_uppercase().replace('.', "__"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeprecationSource {
Toml,
Env,
Both,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeprecationFinding {
pub path: String,
pub replacement: Option<String>,
pub since: String,
pub remove_in: String,
pub source: DeprecationSource,
}
fn toml_path_present(table: &toml::Table, path: &str) -> bool {
let mut current_table = table;
let mut segments = path.split('.').peekable();
while let Some(segment) = segments.next() {
if segments.peek().is_none() {
return current_table.contains_key(segment);
}
match current_table.get(segment) {
Some(toml::Value::Table(next)) => current_table = next,
_ => return false,
}
}
false
}
#[must_use]
pub fn detect_deprecated_keys(
merged: &toml::Table,
env: &dyn Env,
registry: &[DeprecatedKey],
) -> Vec<DeprecationFinding> {
let mut findings = Vec::new();
for entry in registry {
let in_toml = toml_path_present(merged, entry.path);
let env_name = deprecated_env_var_name(entry.path);
let in_env = env.var(&env_name).is_ok();
let source = match (in_toml, in_env) {
(false, false) => continue,
(true, false) => DeprecationSource::Toml,
(false, true) => DeprecationSource::Env,
(true, true) => DeprecationSource::Both,
};
findings.push(DeprecationFinding {
path: entry.path.to_owned(),
replacement: entry.replacement.map(str::to_owned),
since: entry.since.to_owned(),
remove_in: entry.remove_in.to_owned(),
source,
});
}
findings
}
#[must_use]
pub fn detect_deprecated_keys_for(
profile: &str,
file_table: &toml::Table,
env: &dyn Env,
registry: &[DeprecatedKey],
) -> Vec<DeprecationFinding> {
let mut merged = profile_defaults_as_toml(profile);
deep_merge(&mut merged, toml::Value::Table(file_table.clone()));
let empty_table = toml::Table::new();
let merged_table = merged.as_table().unwrap_or(&empty_table);
detect_deprecated_keys(merged_table, env, registry)
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("failed to read autumn.toml: {0}")]
Io(#[from] std::io::Error),
#[error("invalid autumn.toml: {0}")]
Parse(#[from] toml::de::Error),
#[error("configuration error: {0}")]
Validation(String),
#[error("credentials error: {0}")]
Credentials(String),
#[error("dotenv error: {0}")]
Dotenv(String),
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct BackupConfig {
#[serde(default)]
pub offsite: Option<Box<OffsiteBackupConfig>>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct OffsiteBackupConfig {
#[serde(default)]
pub s3: OffsiteS3Config,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub keep: Option<usize>,
#[serde(default)]
pub auto_upload: bool,
#[serde(default)]
pub allow_shared_bucket: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct OffsiteS3Config {
#[serde(default)]
pub bucket: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub access_key_id_env: Option<String>,
#[serde(default)]
pub secret_access_key_env: Option<String>,
#[serde(default)]
pub force_path_style: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ReplicationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_rpo_secs")]
pub rpo_secs: u64,
#[serde(default)]
pub sync_interval_secs: Option<u64>,
#[serde(default = "default_snapshot_interval_secs")]
pub snapshot_interval_secs: u64,
#[serde(default = "default_max_wal_bytes")]
pub max_wal_bytes: u64,
#[serde(default = "default_retention_hours")]
pub retention_hours: u64,
#[serde(default = "default_verify_interval_secs")]
pub verify_interval_secs: u64,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub allow_shared_bucket: bool,
#[serde(default)]
pub s3: Option<ReplicationS3Config>,
#[serde(default)]
pub path: Option<String>,
}
impl Default for ReplicationConfig {
fn default() -> Self {
Self {
enabled: false,
rpo_secs: default_rpo_secs(),
sync_interval_secs: None,
snapshot_interval_secs: default_snapshot_interval_secs(),
max_wal_bytes: default_max_wal_bytes(),
retention_hours: default_retention_hours(),
verify_interval_secs: default_verify_interval_secs(),
prefix: None,
allow_shared_bucket: false,
s3: None,
path: None,
}
}
}
impl ReplicationConfig {
#[must_use]
pub fn sync_interval(&self) -> std::time::Duration {
let secs = self
.sync_interval_secs
.unwrap_or_else(|| self.rpo_secs.div_euclid(2))
.max(1);
std::time::Duration::from_secs(secs)
}
#[must_use]
pub fn rpo(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.rpo_secs.max(1))
}
#[must_use]
pub fn verify_interval(&self) -> Option<std::time::Duration> {
(self.verify_interval_secs > 0)
.then(|| std::time::Duration::from_secs(self.verify_interval_secs))
}
#[must_use]
pub fn validation_errors(&self) -> Vec<String> {
let mut errors = Vec::new();
if !self.enabled {
return errors;
}
match (&self.s3, &self.path) {
(Some(_), Some(_)) => errors.push(
"[replication] configures both `s3` and `path`; pick exactly one destination"
.to_owned(),
),
(None, None) => errors.push(
"[replication] enabled = true but no destination is configured; add a \
[replication.s3] section (bucket/region/endpoint plus *_env credential \
indirection) or set `path` to a directory"
.to_owned(),
),
(Some(s3), None) => {
if s3.bucket.as_deref().unwrap_or("").trim().is_empty() {
errors.push("[replication.s3] bucket is unset".to_owned());
}
if s3
.access_key_id_env
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
|| s3
.secret_access_key_env
.as_deref()
.unwrap_or("")
.trim()
.is_empty()
{
errors.push(
"[replication.s3] needs access_key_id_env and secret_access_key_env \
(the NAMES of the environment variables the credentials are read \
from — never the credentials themselves)"
.to_owned(),
);
}
}
(None, Some(path)) => {
if path.trim().is_empty() {
errors.push("[replication] path is empty".to_owned());
}
}
}
if self.rpo_secs == 0 {
errors.push("[replication] rpo_secs must be at least 1".to_owned());
}
if let Some(interval) = self.sync_interval_secs
&& self.rpo_secs > 0
&& interval > self.rpo_secs
{
errors.push(format!(
"[replication] sync_interval_secs ({interval}) must not exceed rpo_secs \
({}): shipping less often than the objective cannot meet it",
self.rpo_secs
));
}
if self.retention_hours == 0 {
errors.push("[replication] retention_hours must be at least 1".to_owned());
}
if self.max_wal_bytes < 32 {
errors.push(
"[replication] max_wal_bytes must be at least 32 (the size of a WAL header)"
.to_owned(),
);
}
errors
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ReplicationS3Config {
#[serde(default)]
pub bucket: Option<String>,
#[serde(default)]
pub region: Option<String>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub access_key_id_env: Option<String>,
#[serde(default)]
pub secret_access_key_env: Option<String>,
#[serde(default)]
pub force_path_style: bool,
}
const fn default_rpo_secs() -> u64 {
10
}
const fn default_snapshot_interval_secs() -> u64 {
3600
}
const fn default_max_wal_bytes() -> u64 {
16 * 1024 * 1024
}
const fn default_retention_hours() -> u64 {
168
}
const fn default_verify_interval_secs() -> u64 {
21_600
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct AutumnConfig {
#[serde(skip)]
pub profile: Option<String>,
#[serde(default)]
pub server: ServerConfig,
#[serde(default)]
pub deploy: Option<DeployConfig>,
#[serde(default)]
pub slo: Vec<crate::slo::SloConfig>,
#[cfg(feature = "reporting")]
#[serde(default)]
pub failure_capture: FailureCaptureConfig,
#[serde(default)]
pub cluster: ClusterConfig,
#[serde(default)]
pub shadow: crate::shadow::ShadowConfig,
#[serde(default)]
pub metrics: MetricsConfig,
#[serde(default)]
pub fault_injection: crate::fault_injection::FaultInjectionConfig,
#[serde(default)]
pub database: DatabaseConfig,
#[serde(default)]
pub log: LogConfig,
#[serde(default)]
pub telemetry: TelemetryConfig,
#[serde(default)]
pub health: HealthConfig,
#[serde(default)]
pub actuator: ActuatorConfig,
#[serde(default)]
pub cors: CorsConfig,
#[serde(default)]
pub session: crate::session::SessionConfig,
#[serde(default)]
pub cache: CacheConfig,
#[serde(default)]
pub tenancy: TenancyConfig,
#[serde(default)]
pub push: crate::push::PushConfig,
#[serde(default)]
pub idempotency: IdempotencyConfig,
#[serde(default)]
pub channels: ChannelConfig,
#[serde(default)]
pub realtime: RealtimeConfig,
#[serde(default)]
pub jobs: JobConfig,
#[serde(default)]
pub outbox: OutboxConfig,
#[serde(default)]
pub scheduler: SchedulerConfig,
#[serde(default)]
pub role: ProcessRole,
#[serde(default)]
pub auth: crate::auth::AuthConfig,
#[serde(default)]
pub security: crate::security::config::SecurityConfig,
#[cfg(feature = "i18n")]
#[serde(default)]
pub i18n: crate::i18n::I18nConfig,
#[serde(default)]
pub time_zone: crate::time_zone::TimeZoneConfig,
#[cfg(feature = "storage")]
#[serde(default)]
pub storage: crate::storage::StorageConfig,
#[serde(default)]
pub backup: BackupConfig,
#[serde(default)]
pub replication: Option<Box<ReplicationConfig>>,
#[cfg(feature = "mail")]
#[serde(default)]
pub mail: crate::mail::MailConfig,
#[serde(default, rename = "openapi")]
pub openapi_runtime: OpenApiRuntimeConfig,
#[serde(skip)]
pub credentials: crate::credentials::CredentialsStore,
#[cfg(feature = "http-client")]
#[serde(default, rename = "http")]
pub http: HttpConfig,
#[serde(default)]
pub dev: DevConfig,
#[cfg(feature = "maud")]
#[serde(default)]
pub stories: crate::stories::StoriesConfig,
#[cfg(feature = "reporting")]
#[serde(default)]
pub reporting: ReportingConfig,
#[serde(default)]
pub compression: CompressionConfig,
#[serde(default)]
pub bot_protection: crate::security::captcha::BotProtectionConfig,
#[serde(default)]
pub resilience: ResilienceConfig,
#[serde(default)]
pub seo: SeoConfig,
#[serde(default)]
pub aeo: crate::aeo::AeoConfig,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default)]
pub cost: CostConfig,
#[serde(default)]
pub alerts: Box<crate::alerts::AlertConfig>,
#[serde(default)]
pub retention: RetentionConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct DeployTlsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub host: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeployBakeConfig {
#[serde(default)]
pub duration_secs: u64,
#[serde(default = "default_deploy_bake_interval_secs")]
pub interval_secs: u64,
#[serde(default = "default_deploy_bake_min_requests")]
pub min_requests: u64,
#[serde(default)]
pub max_error_rate: Option<f64>,
#[serde(default)]
pub max_p99_ms: Option<u64>,
}
const fn default_deploy_bake_interval_secs() -> u64 {
10
}
const fn default_deploy_bake_min_requests() -> u64 {
20
}
impl Default for DeployBakeConfig {
fn default() -> Self {
Self {
duration_secs: 0,
interval_secs: default_deploy_bake_interval_secs(),
min_requests: default_deploy_bake_min_requests(),
max_error_rate: None,
max_p99_ms: None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct DeployConfig {
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub hosts: Vec<String>,
#[serde(default = "default_deploy_user")]
pub user: String,
#[serde(default = "default_deploy_ssh_port")]
pub ssh_port: u16,
#[serde(default)]
pub app_name: Option<String>,
#[serde(default)]
pub app_dir: Option<String>,
#[serde(default)]
pub service_name: Option<String>,
#[serde(default = "default_deploy_readiness_timeout_secs")]
pub readiness_timeout_secs: u64,
#[serde(default = "default_deploy_keep_releases")]
pub keep_releases: u32,
#[serde(default = "default_deploy_profile")]
pub profile: String,
#[serde(default)]
pub tls: DeployTlsConfig,
#[serde(default = "default_deploy_install_proxy")]
pub install_proxy: bool,
#[serde(default)]
pub bake: DeployBakeConfig,
}
const fn default_deploy_install_proxy() -> bool {
true
}
impl Default for DeployConfig {
fn default() -> Self {
Self {
host: None,
hosts: Vec::new(),
user: default_deploy_user(),
ssh_port: default_deploy_ssh_port(),
app_name: None,
app_dir: None,
service_name: None,
readiness_timeout_secs: default_deploy_readiness_timeout_secs(),
keep_releases: default_deploy_keep_releases(),
profile: default_deploy_profile(),
tls: DeployTlsConfig::default(),
install_proxy: default_deploy_install_proxy(),
bake: DeployBakeConfig::default(),
}
}
}
impl DeployConfig {
pub fn validate(&self) -> Result<(), String> {
let host = self
.host
.as_deref()
.map(str::trim)
.filter(|host| !host.is_empty());
if host.is_some() && !self.hosts.is_empty() {
return Err(
"[deploy] host and [deploy] hosts are mutually exclusive: keep the \
single-server `[deploy] host = \"<address>\"` or the fleet list \
`[deploy] hosts = [\"<address>\", …]` in autumn.toml, not both (#1621)"
.to_owned(),
);
}
for (index, entry) in self.hosts.iter().enumerate() {
if entry.trim().is_empty() {
return Err(format!(
"[deploy] hosts entry {index} is blank: every fleet entry must be an \
SSH-reachable hostname or IP (#1621)"
));
}
}
let mut seen: Vec<&str> = Vec::with_capacity(self.hosts.len());
for entry in &self.hosts {
let trimmed = entry.trim();
if seen.contains(&trimmed) {
return Err(format!(
"[deploy] hosts lists `{trimmed}` more than once: each fleet host must \
appear exactly once (#1621)"
));
}
seen.push(trimmed);
}
if host.is_none() && self.hosts.is_empty() {
return Err(
"[deploy] requires a target host: set `[deploy] host = \"<address>\"` in \
autumn.toml to the SSH-reachable hostname or IP of your server, or \
`[deploy] hosts = [\"<address>\", …]` for a fleet (#1621)"
.to_owned(),
);
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct CostConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub defer_threshold: Option<f64>,
#[serde(default = "default_cost_defer_recheck_secs")]
pub defer_recheck_secs: u64,
#[serde(default = "default_cost_max_tenants")]
pub max_tenants: usize,
#[serde(default = "default_cost_signal_refresh_secs")]
pub signal_refresh_secs: u64,
}
impl CostConfig {
pub fn validate(&self) -> Result<(), String> {
if self.defer_recheck_secs == 0 {
return Err("cost.defer_recheck_secs must be at least 1".to_owned());
}
if self.signal_refresh_secs == 0 {
return Err("cost.signal_refresh_secs must be at least 1".to_owned());
}
if let Some(threshold) = self.defer_threshold
&& !(threshold.is_finite() && threshold >= 0.0)
{
return Err(format!(
"cost.defer_threshold must be a finite number >= 0, got {threshold}"
));
}
Ok(())
}
}
impl Default for CostConfig {
fn default() -> Self {
Self {
enabled: false,
defer_threshold: None,
defer_recheck_secs: default_cost_defer_recheck_secs(),
max_tenants: default_cost_max_tenants(),
signal_refresh_secs: default_cost_signal_refresh_secs(),
}
}
}
const fn default_cost_defer_recheck_secs() -> u64 {
30
}
const fn default_cost_max_tenants() -> usize {
1000
}
const fn default_cost_signal_refresh_secs() -> u64 {
5
}
#[derive(Debug, Clone, Default, Deserialize, serde::Serialize)]
pub struct ObservabilityConfig {
#[serde(default)]
pub server_timing: Option<bool>,
}
pub(crate) fn server_timing_enabled(cfg: &AutumnConfig) -> bool {
if let Some(explicit) = cfg.observability.server_timing {
return explicit;
}
matches!(cfg.profile.as_deref(), Some("dev" | "development"))
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct SeoConfig {
pub base_url: Option<String>,
#[serde(default)]
pub robots: RobotsConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RobotsConfig {
pub allow_all: Option<bool>,
#[serde(default)]
pub additional_rules: Vec<String>,
pub sitemap_url: Option<String>,
}
#[cfg(feature = "reporting")]
#[derive(Debug, Clone, Deserialize)]
pub struct ReportingConfig {
#[serde(default = "default_reporting_enabled")]
pub enabled: bool,
#[serde(default = "default_reporting_sample_rate")]
pub sample_rate: f64,
}
#[cfg(feature = "reporting")]
impl Default for ReportingConfig {
fn default() -> Self {
Self {
enabled: default_reporting_enabled(),
sample_rate: default_reporting_sample_rate(),
}
}
}
#[cfg(feature = "reporting")]
const fn default_reporting_enabled() -> bool {
true
}
#[cfg(feature = "reporting")]
const fn default_reporting_sample_rate() -> f64 {
1.0
}
#[cfg(feature = "reporting")]
#[derive(Debug, Clone, Deserialize)]
pub struct FailureCaptureConfig {
#[serde(default = "default_failure_capture_enabled")]
pub enabled: bool,
#[serde(default = "default_failure_capture_dir")]
pub dir: String,
#[serde(default = "default_failure_capture_max_body_bytes")]
pub max_body_bytes: usize,
#[serde(default = "default_failure_capture_max_capsule_bytes")]
pub max_capsule_bytes: usize,
#[serde(default = "default_failure_capture_max_capsules")]
pub max_capsules: usize,
}
#[cfg(feature = "reporting")]
impl Default for FailureCaptureConfig {
fn default() -> Self {
Self {
enabled: default_failure_capture_enabled(),
dir: default_failure_capture_dir(),
max_body_bytes: default_failure_capture_max_body_bytes(),
max_capsule_bytes: default_failure_capture_max_capsule_bytes(),
max_capsules: default_failure_capture_max_capsules(),
}
}
}
#[cfg(feature = "reporting")]
const fn default_failure_capture_enabled() -> bool {
false
}
#[cfg(feature = "reporting")]
fn default_failure_capture_dir() -> String {
"tmp/autumn-capsules".to_owned()
}
#[cfg(feature = "reporting")]
const fn default_failure_capture_max_body_bytes() -> usize {
65_536
}
#[cfg(feature = "reporting")]
const fn default_failure_capture_max_capsule_bytes() -> usize {
1_048_576
}
#[cfg(feature = "reporting")]
const fn default_failure_capture_max_capsules() -> usize {
50
}
#[derive(Debug, Clone, Deserialize)]
pub struct DevConfig {
#[serde(default = "default_inspector_path")]
pub inspector_path: String,
#[serde(default = "default_inspector_capacity")]
pub inspector_capacity: usize,
#[serde(default = "default_inspector_n_plus_one_threshold")]
pub inspector_n_plus_one_threshold: usize,
}
impl Default for DevConfig {
fn default() -> Self {
Self {
inspector_path: default_inspector_path(),
inspector_capacity: default_inspector_capacity(),
inspector_n_plus_one_threshold: default_inspector_n_plus_one_threshold(),
}
}
}
fn default_inspector_path() -> String {
"/_autumn/inspect".to_owned()
}
const fn default_inspector_capacity() -> usize {
100
}
const fn default_inspector_n_plus_one_threshold() -> usize {
crate::inspector::DEFAULT_N_PLUS_ONE_THRESHOLD
}
#[cfg(feature = "http-client")]
#[derive(Debug, Clone, Default, Deserialize)]
pub struct HttpConfig {
#[serde(default)]
pub client: HttpClientConfig,
}
#[cfg(feature = "http-client")]
#[derive(Debug, Clone, Deserialize)]
pub struct HttpClientConfig {
#[serde(default = "default_http_timeout_secs")]
pub timeout_secs: u64,
#[serde(default = "default_http_max_retries")]
pub max_retries: u32,
#[serde(default = "default_http_max_retry_after_secs")]
pub max_retry_after_secs: u64,
#[serde(default = "default_http_max_backoff_ms")]
pub max_backoff_ms: u64,
#[serde(default)]
pub base_urls: std::collections::HashMap<String, String>,
#[serde(default = "default_http_send_deadline_header")]
pub send_deadline_header: bool,
#[serde(default)]
pub retry_budget: RetryBudgetConfig,
#[serde(default)]
pub adaptive_throttle: AdaptiveThrottleConfig,
}
#[cfg(feature = "http-client")]
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct RetryBudgetConfig {
#[serde(default = "default_retry_budget_enabled")]
pub enabled: bool,
#[serde(default = "default_retry_budget_capacity")]
pub capacity: u32,
#[serde(default = "default_retry_budget_transient_cost")]
pub transient_cost: u32,
#[serde(default = "default_retry_budget_throttling_cost")]
pub throttling_cost: u32,
#[serde(default = "default_retry_budget_retry_ratio")]
pub retry_ratio: f64,
}
#[cfg(feature = "http-client")]
const fn default_http_send_deadline_header() -> bool {
true
}
#[cfg(feature = "http-client")]
const fn default_retry_budget_enabled() -> bool {
true
}
#[cfg(feature = "http-client")]
const fn default_retry_budget_capacity() -> u32 {
500
}
#[cfg(feature = "http-client")]
const fn default_retry_budget_transient_cost() -> u32 {
14
}
#[cfg(feature = "http-client")]
const fn default_retry_budget_throttling_cost() -> u32 {
5
}
#[cfg(feature = "http-client")]
const fn default_retry_budget_retry_ratio() -> f64 {
0.1
}
#[cfg(feature = "http-client")]
impl RetryBudgetConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if !self.enabled {
return Ok(());
}
if !(0.0..=1.0).contains(&self.retry_ratio) {
return Err(ConfigError::Validation(format!(
"http.client.retry_budget.retry_ratio must be in 0.0..=1.0, got {}",
self.retry_ratio
)));
}
if self.transient_cost == 0 || self.throttling_cost == 0 {
return Err(ConfigError::Validation(
"http.client.retry_budget costs must be 1 or more; set enabled = false \
to turn the budget off"
.to_owned(),
));
}
Ok(())
}
}
#[cfg(feature = "http-client")]
impl Default for RetryBudgetConfig {
fn default() -> Self {
Self {
enabled: default_retry_budget_enabled(),
capacity: default_retry_budget_capacity(),
transient_cost: default_retry_budget_transient_cost(),
throttling_cost: default_retry_budget_throttling_cost(),
retry_ratio: default_retry_budget_retry_ratio(),
}
}
}
#[cfg(feature = "http-client")]
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct AdaptiveThrottleConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_throttle_k")]
pub k: f64,
#[serde(default = "default_throttle_window_secs")]
pub window_secs: u64,
}
#[cfg(feature = "http-client")]
const fn default_throttle_k() -> f64 {
2.0
}
#[cfg(feature = "http-client")]
const fn default_throttle_window_secs() -> u64 {
120
}
#[cfg(feature = "http-client")]
impl Default for AdaptiveThrottleConfig {
fn default() -> Self {
Self {
enabled: false,
k: default_throttle_k(),
window_secs: default_throttle_window_secs(),
}
}
}
#[cfg(feature = "http-client")]
impl AdaptiveThrottleConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if !(self.k >= 1.0 && self.k.is_finite()) {
return Err(ConfigError::Validation(format!(
"http.client.adaptive_throttle.k = {} must be a number >= 1.0: a smaller k \
rejects calls to a healthy host",
self.k
)));
}
if self.window_secs == 0 {
return Err(ConfigError::Validation(
"http.client.adaptive_throttle.window_secs must be at least 1".to_owned(),
));
}
Ok(())
}
}
#[cfg(feature = "http-client")]
const fn default_http_timeout_secs() -> u64 {
30
}
#[cfg(feature = "http-client")]
const fn default_http_max_retries() -> u32 {
3
}
#[cfg(feature = "http-client")]
const fn default_http_max_retry_after_secs() -> u64 {
10
}
#[cfg(feature = "http-client")]
const fn default_http_max_backoff_ms() -> u64 {
crate::backoff::DEFAULT_HTTP_MAX_BACKOFF_MS
}
#[cfg(feature = "http-client")]
impl Default for HttpClientConfig {
fn default() -> Self {
Self {
timeout_secs: default_http_timeout_secs(),
max_retries: default_http_max_retries(),
max_retry_after_secs: default_http_max_retry_after_secs(),
max_backoff_ms: default_http_max_backoff_ms(),
base_urls: std::collections::HashMap::new(),
send_deadline_header: default_http_send_deadline_header(),
retry_budget: RetryBudgetConfig::default(),
adaptive_throttle: AdaptiveThrottleConfig::default(),
}
}
}
impl axum::extract::FromRequestParts<crate::AppState> for AutumnConfig {
type Rejection = crate::AutumnError;
async fn from_request_parts(
_parts: &mut http::request::Parts,
state: &crate::AppState,
) -> Result<Self, Self::Rejection> {
state
.extension::<Self>()
.as_deref()
.cloned()
.ok_or_else(|| crate::AutumnError::service_unavailable_msg("Config is not available"))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChannelBackend {
#[serde(alias = "local", alias = "memory")]
#[default]
InProcess,
Redis,
}
impl ChannelBackend {
#[must_use]
pub fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
"redis" => Some(Self::Redis),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct RealtimeConfig {
#[serde(default)]
pub max_connections: Option<usize>,
#[serde(default)]
pub max_message_bytes: Option<usize>,
#[serde(default)]
pub ping_interval_ms: Option<u64>,
#[serde(default)]
pub idle_timeout_ms: Option<u64>,
}
impl RealtimeConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.max_message_bytes == Some(0) {
return Err(ConfigError::Validation(
"realtime.max_message_bytes must be greater than zero".to_owned(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChannelConfig {
#[serde(default)]
pub backend: ChannelBackend,
#[serde(default = "default_channel_capacity")]
pub capacity: usize,
#[serde(default = "default_channel_replay_buffer")]
pub replay_buffer: usize,
#[serde(default)]
pub redis: ChannelRedisConfig,
}
impl Default for ChannelConfig {
fn default() -> Self {
Self {
backend: ChannelBackend::default(),
capacity: default_channel_capacity(),
replay_buffer: default_channel_replay_buffer(),
redis: ChannelRedisConfig::default(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChannelRedisConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_channels_redis_prefix")]
pub key_prefix: String,
}
impl Default for ChannelRedisConfig {
fn default() -> Self {
Self {
url: None,
key_prefix: default_channels_redis_prefix(),
}
}
}
const fn default_channel_capacity() -> usize {
32
}
const fn default_channel_replay_buffer() -> usize {
256
}
fn default_channels_redis_prefix() -> String {
"autumn:channels".to_owned()
}
#[derive(Debug, Clone, Deserialize)]
pub struct ClusterConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub secret: Option<secrecy::SecretString>,
#[serde(default = "default_cluster_name")]
pub cluster_name: String,
#[serde(default = "default_cluster_bind_addr")]
pub bind_addr: String,
#[serde(default)]
pub advertise_addr: Option<String>,
#[serde(default)]
pub seed_peers: Vec<String>,
#[serde(default)]
pub node_id: Option<String>,
#[serde(default = "default_cluster_push_interval_ms")]
pub push_interval_ms: u64,
#[serde(default = "default_cluster_suspicion_timeout_ms")]
pub suspicion_timeout_ms: u64,
}
fn default_cluster_name() -> String {
"autumn".to_owned()
}
fn default_cluster_bind_addr() -> String {
"127.0.0.1:0".to_owned()
}
const fn default_cluster_push_interval_ms() -> u64 {
500
}
const fn default_cluster_suspicion_timeout_ms() -> u64 {
2_500
}
pub(crate) const MIN_CLUSTER_SECRET_LEN: usize = 16;
pub(crate) const MIN_CLUSTER_PUSH_INTERVAL_MS: u64 = 10;
pub(crate) const MIN_CLUSTER_SUSPICION_MULTIPLE: u64 = 3;
pub(crate) const MAX_CLUSTER_IDENT_LEN: usize = 64;
pub(crate) const CLUSTER_CELL_KEY_SEPARATOR: char = '#';
fn validate_cluster_ident(field: &str, value: &str) -> Result<(), ConfigError> {
if value.trim().is_empty() {
return Err(ConfigError::Validation(format!(
"{field} must not be empty: it identifies this node (or its cluster) in every frame"
)));
}
let len = value.len();
if len > MAX_CLUSTER_IDENT_LEN {
return Err(ConfigError::Validation(format!(
"{field} must be at most {MAX_CLUSTER_IDENT_LEN} bytes, got {len} ({value:?}); it \
travels in every cluster frame and is covered by the MAC"
)));
}
if value.contains(CLUSTER_CELL_KEY_SEPARATOR) {
return Err(ConfigError::Validation(format!(
"{field} must not contain {CLUSTER_CELL_KEY_SEPARATOR:?} ({value:?}): it separates \
the node id from the incarnation in counter cell keys, and an id containing it \
would make two different cells collide"
)));
}
Ok(())
}
fn parse_cluster_addr(field: &str, value: &str) -> Result<std::net::SocketAddr, ConfigError> {
value.parse::<std::net::SocketAddr>().map_err(|error| {
ConfigError::Validation(format!(
"{field} must be a socket address of the form host:port with an IP literal \
(hostnames are not resolved), got {value:?}: {error}"
))
})
}
fn reject_ephemeral_cluster_port(
field: &str,
value: &str,
addr: std::net::SocketAddr,
) -> Result<(), ConfigError> {
if addr.port() == 0 {
return Err(ConfigError::Validation(format!(
"{field} is {value:?}, which no peer can dial: port 0 means \"any free port\" and is \
only meaningful on cluster.bind_addr, where the node advertises the port it was \
actually given (see docs/guide/clustering.md)"
)));
}
Ok(())
}
impl Default for ClusterConfig {
fn default() -> Self {
Self {
enabled: false,
secret: None,
cluster_name: default_cluster_name(),
bind_addr: default_cluster_bind_addr(),
advertise_addr: None,
seed_peers: Vec::new(),
node_id: None,
push_interval_ms: default_cluster_push_interval_ms(),
suspicion_timeout_ms: default_cluster_suspicion_timeout_ms(),
}
}
}
impl ClusterConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.enabled {
self.validate_secret()?;
}
if self.push_interval_ms < MIN_CLUSTER_PUSH_INTERVAL_MS {
return Err(ConfigError::Validation(format!(
"cluster.push_interval_ms must be at least {MIN_CLUSTER_PUSH_INTERVAL_MS}ms, \
got {}",
self.push_interval_ms
)));
}
let min_suspicion = self
.push_interval_ms
.saturating_mul(MIN_CLUSTER_SUSPICION_MULTIPLE);
if self.suspicion_timeout_ms < min_suspicion {
return Err(ConfigError::Validation(format!(
"cluster.suspicion_timeout_ms ({}) must be at least \
{MIN_CLUSTER_SUSPICION_MULTIPLE}x cluster.push_interval_ms ({}), i.e. at least \
{min_suspicion}ms: below that ratio one delayed push evicts a healthy peer and \
the view flaps",
self.suspicion_timeout_ms, self.push_interval_ms
)));
}
validate_cluster_ident("cluster.cluster_name", &self.cluster_name)?;
if let Some(node_id) = self.node_id.as_deref() {
validate_cluster_ident("cluster.node_id", node_id)?;
}
let bind_addr = parse_cluster_addr("cluster.bind_addr", &self.bind_addr)?;
let advertise_addr = match self.advertise_addr.as_deref() {
Some(addr) => {
let parsed = parse_cluster_addr("cluster.advertise_addr", addr)?;
reject_ephemeral_cluster_port("cluster.advertise_addr", addr, parsed)?;
parsed
}
None => bind_addr,
};
for (index, peer) in self.seed_peers.iter().enumerate() {
let field = format!("cluster.seed_peers[{index}]");
let parsed = parse_cluster_addr(&field, peer)?;
reject_ephemeral_cluster_port(&field, peer, parsed)?;
}
if self.enabled && advertise_addr.ip().is_unspecified() {
let source = if self.advertise_addr.is_some() {
"cluster.advertise_addr"
} else {
"cluster.bind_addr"
};
return Err(ConfigError::Validation(format!(
"{source} advertises {advertise_addr}, which no peer can dial: binding a \
wildcard address is fine, but it requires an explicit, non-wildcard \
cluster.advertise_addr (see docs/guide/clustering.md)"
)));
}
Ok(())
}
fn validate_secret(&self) -> Result<(), ConfigError> {
let Some(secret) = self.secret.as_ref() else {
return Err(ConfigError::Validation(
"cluster.secret is required when cluster.enabled = true: the cluster transport \
is authenticated (HMAC-SHA256) and has no unauthenticated mode — set it with \
AUTUMN_CLUSTER__SECRET"
.to_owned(),
));
};
let len = secrecy::ExposeSecret::expose_secret(secret).len();
if len < MIN_CLUSTER_SECRET_LEN {
return Err(ConfigError::Validation(format!(
"cluster.secret must be at least {MIN_CLUSTER_SECRET_LEN} bytes, got {len}: a \
short shared key is a guessable one, and every member signs every frame with it"
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum CacheBackend {
#[default]
Memory,
Redis,
}
impl CacheBackend {
pub(crate) fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"memory" => Some(Self::Memory),
"redis" => Some(Self::Redis),
_ => None,
}
}
}
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct CacheConfig {
#[serde(default)]
pub backend: CacheBackend,
#[serde(default)]
pub redis: CacheRedisConfig,
}
impl CacheConfig {
#[must_use]
pub fn is_memory(&self) -> bool {
self.backend == CacheBackend::Memory
}
#[must_use]
pub fn is_redis(&self) -> bool {
self.backend == CacheBackend::Redis
}
}
#[derive(Debug, Clone, serde::Deserialize)]
pub struct CacheRedisConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_cache_redis_key_prefix")]
pub key_prefix: String,
}
impl Default for CacheRedisConfig {
fn default() -> Self {
Self {
url: None,
key_prefix: default_cache_redis_key_prefix(),
}
}
}
fn default_cache_redis_key_prefix() -> String {
"autumn:cache".to_owned()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SchedulerBackend {
#[serde(alias = "local", alias = "memory")]
#[default]
InProcess,
Postgres,
#[serde(alias = "single_host")]
Sqlite,
}
impl SchedulerBackend {
#[must_use]
pub fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"in_process" | "in-process" | "local" | "memory" => Some(Self::InProcess),
"postgres" | "postgresql" => Some(Self::Postgres),
"sqlite" | "single_host" | "single-host" => Some(Self::Sqlite),
_ => None,
}
}
#[must_use]
pub const fn is_fleet_distributed(self) -> bool {
matches!(self, Self::Postgres | Self::Sqlite)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ProcessRole {
#[serde(
alias = "all",
alias = "combined",
alias = "web_and_worker",
alias = "server_and_worker"
)]
#[default]
Combined,
#[serde(alias = "server", alias = "http")]
Web,
#[serde(alias = "jobs", alias = "worker_only")]
Worker,
}
impl ProcessRole {
#[must_use]
pub fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"combined" | "all" | "web_and_worker" | "server_and_worker" => Some(Self::Combined),
"web" | "server" | "http" => Some(Self::Web),
"worker" | "jobs" | "worker_only" => Some(Self::Worker),
_ => None,
}
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Combined => "combined",
Self::Web => "web",
Self::Worker => "worker",
}
}
#[must_use]
pub const fn serves_http(self) -> bool {
matches!(self, Self::Combined | Self::Web)
}
#[must_use]
pub const fn runs_workers(self) -> bool {
matches!(self, Self::Combined | Self::Worker)
}
}
#[must_use]
pub fn split_role_requires_durable_backend(role: ProcessRole, jobs_backend: &str) -> bool {
role != ProcessRole::Combined && !matches!(jobs_backend, "postgres" | "redis" | "sqlite")
}
#[must_use]
pub fn is_in_memory_sqlite_target(url: &str) -> bool {
if url.starts_with("file:") {
return url == "file::memory:"
|| url.starts_with("file::memory:?")
|| url.contains("mode=memory");
}
let target = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))
.unwrap_or(url);
target.is_empty()
|| target == ":memory:"
|| target == "file::memory:"
|| target.starts_with("file::memory:?")
|| target.contains("mode=memory")
}
#[must_use]
pub fn split_role_requires_file_backed_sqlite(
role: ProcessRole,
jobs_backend: &str,
database_url: Option<&str>,
) -> bool {
role != ProcessRole::Combined
&& jobs_backend == "sqlite"
&& database_url.is_some_and(is_in_memory_sqlite_target)
}
#[derive(Debug, Clone, Deserialize)]
pub struct RetentionConfig {
#[serde(default = "default_retention_sweep_interval")]
pub sweep_interval: String,
#[serde(default)]
pub job_history: Option<String>,
#[serde(default)]
pub commit_hooks: Option<String>,
#[serde(default)]
pub job_tracking: Option<String>,
#[serde(default)]
pub idempotency: Option<String>,
#[serde(default)]
pub experiment_assignments: Option<String>,
#[serde(default)]
pub webhook_replay: Option<String>,
#[serde(default)]
pub sessions: Option<String>,
#[serde(default)]
pub audit_archives: Option<String>,
#[serde(default)]
pub custom_domains: Option<String>,
}
impl Default for RetentionConfig {
fn default() -> Self {
Self {
sweep_interval: default_retention_sweep_interval(),
job_history: None,
commit_hooks: None,
job_tracking: None,
idempotency: None,
experiment_assignments: None,
webhook_replay: None,
sessions: None,
audit_archives: None,
custom_domains: None,
}
}
}
fn default_retention_sweep_interval() -> String {
"1h".to_owned()
}
impl RetentionConfig {
#[must_use]
pub fn windows(&self) -> [(&'static str, Option<&str>); 9] {
[
("job_history", self.job_history.as_deref()),
("commit_hooks", self.commit_hooks.as_deref()),
("job_tracking", self.job_tracking.as_deref()),
("idempotency", self.idempotency.as_deref()),
(
"experiment_assignments",
self.experiment_assignments.as_deref(),
),
("webhook_replay", self.webhook_replay.as_deref()),
("sessions", self.sessions.as_deref()),
("audit_archives", self.audit_archives.as_deref()),
("custom_domains", self.custom_domains.as_deref()),
]
}
#[must_use]
pub fn any_window_configured(&self) -> bool {
self.windows().iter().any(|(_, window)| window.is_some())
}
#[must_use]
pub fn window(&self, key: &str) -> Option<std::time::Duration> {
self.windows()
.into_iter()
.find(|(name, _)| *name == key)
.and_then(|(_, window)| window)
.and_then(crate::task::parse_duration)
}
#[must_use]
pub fn sweep_interval_duration(&self) -> std::time::Duration {
crate::task::parse_duration(&self.sweep_interval)
.unwrap_or(std::time::Duration::from_secs(3_600))
}
pub fn validate(&self) -> Result<(), ConfigError> {
for (key, window) in self.windows() {
let Some(raw) = window else { continue };
let Some(parsed) = crate::task::parse_duration(raw) else {
return Err(ConfigError::Validation(format!(
"retention.{key} = {raw:?} is not a valid non-zero duration: use a \
string like \"90d\", \"12h\", or \"1h 30m\". A zero window would \
purge the dataset as soon as it is written; remove the key entirely \
to keep today's behavior (see docs/guide/data-retention.md)"
)));
};
if parsed.as_secs() > u64::from(u32::MAX) {
return Err(ConfigError::Validation(format!(
"retention.{key} = {raw:?} is longer than the maximum supported \
retention window ({} years). Remove the key entirely to keep the \
data forever, which is what a window that long means in practice \
(see docs/guide/data-retention.md)",
u64::from(u32::MAX) / (365 * 86_400)
)));
}
}
if crate::task::parse_duration(&self.sweep_interval).is_none() {
return Err(ConfigError::Validation(format!(
"retention.sweep_interval = {:?} is not a valid non-zero duration: use a \
string like \"1h\" or \"30m\" (see docs/guide/data-retention.md)",
self.sweep_interval
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct SchedulerConfig {
#[serde(default)]
pub backend: SchedulerBackend,
#[serde(default = "default_scheduler_lease_ttl_secs")]
pub lease_ttl_secs: u64,
#[serde(default)]
pub replica_id: Option<String>,
#[serde(default = "default_scheduler_key_prefix")]
pub key_prefix: String,
}
impl SchedulerConfig {
#[must_use]
pub fn resolved_replica_id(&self) -> String {
self.replica_id
.as_ref()
.filter(|id| !id.trim().is_empty())
.cloned()
.or_else(|| std::env::var("FLY_MACHINE_ID").ok())
.or_else(|| std::env::var("HOSTNAME").ok())
.unwrap_or_else(|| format!("pid-{}", std::process::id()))
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.lease_ttl_secs == 0 {
return Err(ConfigError::Validation(
"scheduler.lease_ttl_secs must be greater than zero".to_owned(),
));
}
if self.key_prefix.trim().is_empty() {
return Err(ConfigError::Validation(
"scheduler.key_prefix must not be empty".to_owned(),
));
}
Ok(())
}
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
backend: SchedulerBackend::default(),
lease_ttl_secs: default_scheduler_lease_ttl_secs(),
replica_id: None,
key_prefix: default_scheduler_key_prefix(),
}
}
}
const fn default_scheduler_lease_ttl_secs() -> u64 {
300
}
fn default_scheduler_key_prefix() -> String {
"autumn:scheduler".to_owned()
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum IdempotencyBackend {
#[default]
Memory,
Redis,
Database,
}
impl IdempotencyBackend {
#[must_use]
pub fn from_env_value(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"memory" | "mem" => Some(Self::Memory),
"redis" => Some(Self::Redis),
"database" | "db" => Some(Self::Database),
_ => None,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct IdempotencyRedisConfig {
pub url: Option<String>,
#[serde(default = "default_idempotency_redis_key_prefix")]
pub key_prefix: String,
}
impl Default for IdempotencyRedisConfig {
fn default() -> Self {
Self {
url: None,
key_prefix: default_idempotency_redis_key_prefix(),
}
}
}
fn default_idempotency_redis_key_prefix() -> String {
"autumn:idempotency".to_owned()
}
#[derive(Debug, Clone, Deserialize)]
pub struct IdempotencyConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub backend: IdempotencyBackend,
#[serde(default = "default_idempotency_ttl_secs")]
pub ttl_secs: u64,
#[serde(default = "default_idempotency_in_flight_ttl_secs")]
pub in_flight_ttl_secs: u64,
#[serde(default)]
pub allow_memory_in_production: bool,
#[serde(default)]
pub redis: IdempotencyRedisConfig,
}
impl Default for IdempotencyConfig {
fn default() -> Self {
Self {
enabled: None,
backend: IdempotencyBackend::default(),
ttl_secs: default_idempotency_ttl_secs(),
in_flight_ttl_secs: default_idempotency_in_flight_ttl_secs(),
allow_memory_in_production: false,
redis: IdempotencyRedisConfig::default(),
}
}
}
const fn default_idempotency_ttl_secs() -> u64 {
86_400
}
const fn default_idempotency_in_flight_ttl_secs() -> u64 {
crate::idempotency::DEFAULT_IN_FLIGHT_TTL.as_secs()
}
#[derive(Debug, Clone, Deserialize)]
pub struct OpenApiRuntimeConfig {
#[serde(default = "default_openapi_enabled")]
pub enabled: bool,
#[serde(default = "default_openapi_path")]
pub path: String,
}
impl Default for OpenApiRuntimeConfig {
fn default() -> Self {
Self {
enabled: default_openapi_enabled(),
path: default_openapi_path(),
}
}
}
const fn default_openapi_enabled() -> bool {
true
}
fn default_openapi_path() -> String {
"/openapi.json".to_owned()
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct OutboxConfig {
pub enabled: bool,
pub poll_interval_ms: u64,
pub batch_size: usize,
pub max_attempts: u32,
pub initial_backoff_ms: u64,
pub max_backoff_ms: u64,
pub lease_ms: u64,
pub retention_ms: u64,
}
impl OutboxConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
for (key, value) in [
(
"batch_size",
u64::try_from(self.batch_size).unwrap_or(u64::MAX),
),
("max_attempts", u64::from(self.max_attempts)),
("lease_ms", self.lease_ms),
] {
if value == 0 {
return Err(ConfigError::Validation(format!(
"outbox.{key} must be greater than zero"
)));
}
}
if self.max_backoff_ms < self.initial_backoff_ms {
return Err(ConfigError::Validation(
"outbox.max_backoff_ms must not be smaller than outbox.initial_backoff_ms"
.to_owned(),
));
}
Ok(())
}
}
impl Default for OutboxConfig {
fn default() -> Self {
Self {
enabled: false,
poll_interval_ms: 500,
batch_size: 100,
max_attempts: 10,
initial_backoff_ms: 1_000,
max_backoff_ms: 300_000,
lease_ms: 60_000,
retention_ms: 604_800_000,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobConfig {
#[serde(default = "default_job_backend")]
pub backend: String,
#[serde(default = "default_job_workers")]
pub workers: usize,
#[serde(default = "default_job_max_attempts")]
pub max_attempts: u32,
#[serde(default = "default_job_backoff_ms")]
pub initial_backoff_ms: u64,
#[serde(default = "default_job_max_backoff_ms")]
pub max_backoff_ms: u64,
#[serde(default)]
pub default_timeout_ms: u64,
#[serde(default)]
pub queues: JobQueuesConfig,
#[serde(default)]
pub pin: Vec<String>,
#[serde(default)]
pub fleet: JobFleetConfig,
#[serde(default)]
pub redis: JobRedisConfig,
#[serde(default)]
pub postgres: JobPostgresConfig,
#[serde(default)]
pub sqlite: JobSqliteConfig,
#[serde(default)]
pub tracking: JobTrackingConfig,
#[serde(default)]
pub tenants: JobTenantsConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
pub struct JobTenantsConfig {
#[serde(default)]
pub max_concurrent: usize,
#[serde(default)]
pub lanes: u16,
#[serde(default)]
pub lanes_per_tenant: u16,
}
impl Default for JobConfig {
fn default() -> Self {
Self {
backend: default_job_backend(),
workers: default_job_workers(),
max_attempts: default_job_max_attempts(),
initial_backoff_ms: default_job_backoff_ms(),
max_backoff_ms: default_job_max_backoff_ms(),
default_timeout_ms: 0,
queues: JobQueuesConfig::default(),
pin: Vec::new(),
fleet: JobFleetConfig::default(),
redis: JobRedisConfig::default(),
postgres: JobPostgresConfig::default(),
sqlite: JobSqliteConfig::default(),
tracking: JobTrackingConfig::default(),
tenants: JobTenantsConfig::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobQueue {
pub name: String,
pub weight: u32,
pub concurrency: Option<usize>,
pub reserved: Option<usize>,
}
impl JobQueue {
#[must_use]
pub fn new(name: impl Into<String>, weight: u32) -> Self {
Self {
name: name.into(),
weight,
concurrency: None,
reserved: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JobQueuesConfig {
pub queues: Vec<JobQueue>,
pub strict: bool,
}
impl Default for JobQueuesConfig {
fn default() -> Self {
Self::single_default()
}
}
impl JobQueuesConfig {
#[must_use]
pub fn single_default() -> Self {
Self {
queues: vec![JobQueue::new("default", 1)],
strict: true,
}
}
#[must_use]
pub fn strict_list<I, S>(names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
let queues: Vec<JobQueue> = names
.into_iter()
.map(|name| JobQueue::new(name, 1))
.collect();
if queues.is_empty() {
Self::single_default()
} else {
Self {
queues,
strict: true,
}
}
}
#[must_use]
pub fn weighted<I, S>(entries: I) -> Self
where
I: IntoIterator<Item = (S, u32)>,
S: Into<String>,
{
let queues: Vec<JobQueue> = entries
.into_iter()
.map(|(name, weight)| JobQueue::new(name, weight.max(1)))
.collect();
if queues.is_empty() {
Self::single_default()
} else {
Self {
queues,
strict: false,
}
}
}
#[must_use]
pub fn weighted_specs(queues: Vec<JobQueue>) -> Self {
if queues.is_empty() {
Self::single_default()
} else {
Self {
queues: queues
.into_iter()
.map(|mut q| {
q.weight = q.weight.max(1);
q
})
.collect(),
strict: false,
}
}
}
}
#[derive(Debug, Clone)]
enum JobQueueValue {
Weight(u32),
Spec {
weight: Option<u32>,
concurrency: Option<usize>,
reserved: Option<usize>,
},
}
impl<'de> serde::Deserialize<'de> for JobQueueValue {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{MapAccess, Visitor};
use std::fmt;
struct ValueVisitor;
impl<'de> Visitor<'de> for ValueVisitor {
type Value = JobQueueValue;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(
"a queue weight (e.g. critical = 4) or a queue table \
(e.g. critical = { weight = 4, concurrency = 8, reserved = 2 })",
)
}
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Self::Value, E> {
Ok(JobQueueValue::Weight(
u32::try_from(v).map_err(|_| E::custom("queue weight is too large"))?,
))
}
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Self::Value, E> {
if v < 0 {
return Err(E::custom("queue weight must not be negative"));
}
self.visit_u64(u64::try_from(v).unwrap_or(0))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut weight = None;
let mut concurrency = None;
let mut reserved = None;
while let Some(key) = map.next_key::<String>()? {
match key.as_str() {
"weight" => weight = Some(map.next_value::<u32>()?),
"concurrency" => concurrency = Some(map.next_value::<usize>()?),
"reserved" => reserved = Some(map.next_value::<usize>()?),
other => {
return Err(serde::de::Error::custom(format!(
"unknown queue setting '{other}' (expected weight, concurrency, \
or reserved)"
)));
}
}
}
Ok(JobQueueValue::Spec {
weight,
concurrency,
reserved,
})
}
}
d.deserialize_any(ValueVisitor)
}
}
impl<'de> serde::Deserialize<'de> for JobQueuesConfig {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
use serde::de::{MapAccess, SeqAccess, Visitor};
use std::fmt;
struct JobQueuesVisitor;
impl<'de> Visitor<'de> for JobQueuesVisitor {
type Value = JobQueuesConfig;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(
"an ordered list of queue names (e.g. queues = [\"critical\", \"default\"]) \
or a weight table (e.g. [jobs.queues] critical = 4, default = 1)",
)
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
let mut names = Vec::new();
let mut seen = std::collections::HashSet::new();
while let Some(name) = seq.next_element::<String>()? {
if !seen.insert(name.clone()) {
return Err(serde::de::Error::custom(format!(
"duplicate queue name '{name}' in queues list"
)));
}
names.push(name);
}
Ok(JobQueuesConfig::strict_list(names))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
let mut queues: Vec<JobQueue> = Vec::new();
while let Some((k, value)) = map.next_entry::<String, JobQueueValue>()? {
let (weight, concurrency, reserved) = match value {
JobQueueValue::Weight(w) => (w, None, None),
JobQueueValue::Spec {
weight,
concurrency,
reserved,
} => (weight.unwrap_or(1), concurrency, reserved),
};
if weight == 0 {
return Err(serde::de::Error::custom(format!(
"queue '{k}' weight must be at least 1 (got 0); \
to disable a queue remove it from the list"
)));
}
queues.push(JobQueue {
name: k,
weight,
concurrency,
reserved,
});
}
Ok(JobQueuesConfig::weighted_specs(queues))
}
}
d.deserialize_any(JobQueuesVisitor)
}
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct JobFleetConfig {
#[serde(default)]
pub tiers: Vec<Vec<String>>,
#[serde(default)]
pub manifest: Option<String>,
#[serde(default)]
pub declared_queues: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobRedisConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default = "default_jobs_redis_prefix")]
pub key_prefix: String,
#[serde(default = "default_jobs_redis_visibility_timeout_ms")]
pub visibility_timeout_ms: u64,
#[serde(default = "default_jobs_redis_dead_letter_limit")]
pub dead_letter_limit: usize,
}
impl Default for JobRedisConfig {
fn default() -> Self {
Self {
url: None,
key_prefix: default_jobs_redis_prefix(),
visibility_timeout_ms: default_jobs_redis_visibility_timeout_ms(),
dead_letter_limit: default_jobs_redis_dead_letter_limit(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobPostgresConfig {
#[serde(default = "default_jobs_pg_visibility_timeout_ms")]
pub visibility_timeout_ms: u64,
#[serde(default)]
pub shard_local: bool,
}
impl Default for JobPostgresConfig {
fn default() -> Self {
Self {
visibility_timeout_ms: default_jobs_pg_visibility_timeout_ms(),
shard_local: false,
}
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobSqliteConfig {
#[serde(default = "default_jobs_sqlite_visibility_timeout_ms")]
pub visibility_timeout_ms: u64,
#[serde(default = "default_jobs_sqlite_poll_interval_ms")]
pub poll_interval_ms: u64,
}
impl Default for JobSqliteConfig {
fn default() -> Self {
Self {
visibility_timeout_ms: default_jobs_sqlite_visibility_timeout_ms(),
poll_interval_ms: default_jobs_sqlite_poll_interval_ms(),
}
}
}
const fn default_jobs_sqlite_visibility_timeout_ms() -> u64 {
30_000
}
const fn default_jobs_sqlite_poll_interval_ms() -> u64 {
250
}
#[derive(Debug, Clone, Deserialize)]
pub struct JobTrackingConfig {
#[serde(default = "default_jobs_tracking_ttl_secs")]
pub ttl_secs: u64,
#[serde(default = "default_jobs_tracking_route_enabled")]
pub route_enabled: bool,
}
impl Default for JobTrackingConfig {
fn default() -> Self {
Self {
ttl_secs: default_jobs_tracking_ttl_secs(),
route_enabled: default_jobs_tracking_route_enabled(),
}
}
}
const fn default_jobs_tracking_ttl_secs() -> u64 {
86_400
}
const fn default_jobs_tracking_route_enabled() -> bool {
true
}
const fn default_jobs_pg_visibility_timeout_ms() -> u64 {
30_000
}
fn default_job_backend() -> String {
"local".to_owned()
}
const fn default_job_workers() -> usize {
1
}
const fn default_job_max_attempts() -> u32 {
5
}
const fn default_job_backoff_ms() -> u64 {
250
}
const fn default_job_max_backoff_ms() -> u64 {
crate::backoff::DEFAULT_JOB_MAX_BACKOFF_MS
}
fn default_jobs_redis_prefix() -> String {
"autumn:jobs".to_owned()
}
const fn default_jobs_redis_dead_letter_limit() -> usize {
10_000
}
const fn default_jobs_redis_visibility_timeout_ms() -> u64 {
30_000
}
const PRE_1890_STRICT_PARENTS: &[&str] = &[
"",
"database",
"deploy",
"server",
"server.timeouts",
"server.tls",
"server.tls.acme",
];
fn unknown_key_was_previously_strict(schema_parent: &str) -> bool {
schema_parent == "profile" || PRE_1890_STRICT_PARENTS.contains(&schema_parent)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UnknownRootPolicy {
Strict,
LenientWarn,
}
const MANUAL_SCHEMA_SECTIONS: &[(&str, &[&str])] = &[
("time_zone", &["identifier", "sources"]),
];
impl AutumnConfig {
pub fn validate_push(&self) -> Result<(), crate::push::PushError> {
self.push.load_vapid_key().map(|_| ())
}
#[must_use]
pub fn profile_admission_default(&self) -> Option<usize> {
matches!(self.profile.as_deref(), Some("prod" | "production")).then(|| {
self.database
.effective_primary_pool_size()
.saturating_mul(PROD_REQUESTS_PER_POOL_CONNECTION)
.max(PROD_MIN_ADMISSION_LIMIT)
})
}
}
impl AutumnConfig {
#[must_use]
#[allow(clippy::significant_drop_tightening)]
pub fn get_schema_keys() -> HashMap<String, HashSet<String>> {
const MAX_PASSES: usize = 8;
let de = SchemaDeserializer::new();
let mut prev_rejected: Vec<String> = Vec::new();
for _ in 0..MAX_PASSES {
de.rejected
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clear();
let _ = Self::deserialize(de.clone());
let mut rejected: Vec<String> = std::mem::take(
&mut de
.rejected
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner),
);
rejected.sort();
rejected.dedup();
if rejected.is_empty() {
break;
}
let mut advanced = false;
{
let mut probes = de
.any_probe
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for p in &rejected {
let cur = probes.get(p).copied().unwrap_or(AnyProbe::Str);
let next = match cur {
AnyProbe::Str => AnyProbe::Map,
AnyProbe::Map | AnyProbe::Seq => AnyProbe::Seq,
};
if next != cur {
advanced = true;
}
probes.insert(p.clone(), next);
}
}
if !advanced && rejected == prev_rejected {
break;
}
prev_rejected = rejected;
}
{
let mut schema = de
.schema
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (section, keys) in MANUAL_SCHEMA_SECTIONS {
let entry = schema.entry((*section).to_owned()).or_default();
for k in *keys {
entry.insert((*k).to_owned());
}
}
}
de.into_schema()
}
#[must_use]
pub fn schema_leaf_paths() -> std::collections::BTreeSet<String> {
let schema = Self::get_schema_keys();
let mut leaves = std::collections::BTreeSet::new();
for (parent, fields) in &schema {
for field in fields {
let leaf = if parent.is_empty() {
field.clone()
} else {
format!("{parent}.{field}")
};
leaves.insert(leaf);
}
}
leaves
}
#[must_use]
pub fn validate_toml(
content: &str,
schema: &HashMap<String, HashSet<String>>,
) -> Vec<(String, Option<String>)> {
Self::validate_toml_detailed(content, schema, &BTreeSet::new())
.into_iter()
.map(|(path, sug, _parent, _is_table, _is_top_level)| (path, sug))
.collect()
}
#[must_use]
pub(crate) fn validate_toml_detailed(
content: &str,
schema: &HashMap<String, HashSet<String>>,
plugin_config_roots: &BTreeSet<String>,
) -> Vec<(String, Option<String>, String, bool, bool)> {
let Ok(table) = toml::from_str::<toml::Table>(content) else {
return Vec::new();
};
let mut errors = Vec::new();
let mut path = Vec::new();
Self::validate_toml_table(&table, &mut path, schema, plugin_config_roots, &mut errors);
errors
}
#[allow(clippy::too_many_lines)]
fn validate_toml_table(
table: &toml::Table,
path: &mut Vec<String>,
schema: &HashMap<String, HashSet<String>>,
plugin_config_roots: &BTreeSet<String>,
errors: &mut Vec<(String, Option<String>, String, bool, bool)>,
) {
let mut schema_path_parts = Vec::new();
if path.len() >= 2 && path[0] == "profile" {
schema_path_parts.extend(path[2..].iter().cloned());
} else {
schema_path_parts.extend(path.iter().cloned());
}
let schema_path = schema_path_parts.join(".");
if let Some(valid_keys) = schema.get(&schema_path) {
for (k, val) in table {
if path.is_empty() && k == "profile" {
path.push(k.clone());
match val {
toml::Value::Table(t) => {
Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
}
toml::Value::Array(arr) => {
for item in arr {
if let toml::Value::Table(t) = item {
Self::validate_toml_table(
t,
path,
schema,
plugin_config_roots,
errors,
);
}
}
}
_ => {}
}
path.pop();
continue;
}
if valid_keys.contains(k) {
path.push(k.clone());
match val {
toml::Value::Table(t) => {
Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
}
toml::Value::Array(arr) => {
for item in arr {
if let toml::Value::Table(t) = item {
Self::validate_toml_table(
t,
path,
schema,
plugin_config_roots,
errors,
);
}
}
}
_ => {}
}
path.pop();
} else if path.is_empty() && plugin_config_roots.contains(k) && val.is_table() {
} else {
let mut full_path_parts = path.clone();
full_path_parts.push(k.clone());
let full_path = full_path_parts.join(".");
let mut closest: Option<&str> = None;
let mut min_dist = usize::MAX;
for valid_key in valid_keys {
let dist = levenshtein(k, valid_key);
if dist <= 2 && dist < min_dist {
min_dist = dist;
closest = Some(valid_key);
}
}
let suggestion = closest.map(|c| {
let mut sug_parts = path.clone();
sug_parts.push(c.to_string());
sug_parts.join(".")
});
errors.push((
full_path,
suggestion,
schema_path.clone(),
val.is_table(),
path.is_empty(),
));
}
}
} else if path.len() == 1 && path[0] == "profile" {
for (k, val) in table {
if let toml::Value::Table(t) = val {
path.push(k.clone());
Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
path.pop();
} else {
let mut full_path_parts = path.clone();
full_path_parts.push(k.clone());
errors.push((
full_path_parts.join("."),
None,
schema_path.clone(),
val.is_table(),
path.is_empty(),
));
}
}
} else if path.is_empty() {
let root_keys = schema.get("").cloned().unwrap_or_default();
for (k, val) in table {
if k == "profile" || root_keys.contains(k) {
path.push(k.clone());
match val {
toml::Value::Table(t) => {
Self::validate_toml_table(t, path, schema, plugin_config_roots, errors);
}
toml::Value::Array(arr) => {
for item in arr {
if let toml::Value::Table(t) = item {
Self::validate_toml_table(
t,
path,
schema,
plugin_config_roots,
errors,
);
}
}
}
_ => {}
}
path.pop();
} else if plugin_config_roots.contains(k) && val.is_table() {
} else {
let mut closest: Option<&str> = None;
let mut min_dist = usize::MAX;
for valid_key in &root_keys {
let dist = levenshtein(k, valid_key);
if dist <= 2 && dist < min_dist {
min_dist = dist;
closest = Some(valid_key);
}
}
errors.push((
k.clone(),
closest.map(String::from),
schema_path.clone(),
val.is_table(),
path.is_empty(),
));
}
}
}
}
#[must_use]
pub const fn credentials(&self) -> &crate::credentials::CredentialsStore {
&self.credentials
}
pub fn load() -> Result<Self, ConfigError> {
Self::load_policy(UnknownRootPolicy::Strict)
}
pub fn load_lenient_unknown_roots() -> Result<Self, ConfigError> {
Self::load_policy(UnknownRootPolicy::LenientWarn)
}
fn load_policy(root_policy: UnknownRootPolicy) -> Result<Self, ConfigError> {
let base = OsEnv;
let profile = resolve_profile(&base);
let dir = crate::dotenv::dotenv_base_dir(&base);
let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
.map_err(|e| ConfigError::Dotenv(e.to_string()))?;
let env = crate::dotenv::DotenvEnv::new(&base, vars);
Self::load_with_env_and_plugin_roots_policy(&env, &BTreeSet::new(), root_policy)
}
pub fn load_with_env(env: &dyn Env) -> Result<Self, ConfigError> {
Self::load_with_env_and_plugin_roots_policy(
env,
&BTreeSet::new(),
UnknownRootPolicy::Strict,
)
}
pub fn load_with_env_and_plugin_roots(
env: &dyn Env,
plugin_config_roots: &BTreeSet<String>,
) -> Result<Self, ConfigError> {
Self::load_with_env_and_plugin_roots_policy(
env,
plugin_config_roots,
UnknownRootPolicy::Strict,
)
}
pub fn load_with_env_lenient_unknown_roots(env: &dyn Env) -> Result<Self, ConfigError> {
Self::load_with_env_and_plugin_roots_policy(
env,
&BTreeSet::new(),
UnknownRootPolicy::LenientWarn,
)
}
fn load_with_env_and_plugin_roots_policy(
env: &dyn Env,
plugin_config_roots: &BTreeSet<String>,
root_policy: UnknownRootPolicy,
) -> Result<Self, ConfigError> {
let selected_profile_input = resolve_profile_input(env);
let profile =
normalize_profile_name(&selected_profile_input).unwrap_or_else(|| "dev".to_owned());
let mut has_inline_profile_section = false;
let mut merged = profile_defaults_as_toml(&profile);
if let Some(base) = load_raw_toml(&find_config_file_named("autumn.toml", env))? {
deep_merge(&mut merged, base.clone());
for profile_name in profile_lookup_names(&profile) {
if let Some(inline_profile) = profile_section_from_base_toml(&base, profile_name) {
deep_merge(&mut merged, inline_profile);
has_inline_profile_section = true;
}
}
}
let mut has_profile_file = false;
for profile_name in profile_override_file_lookup_names(&profile, &selected_profile_input) {
let profile_path = find_config_file_named(&format!("autumn-{profile_name}.toml"), env);
if let Some(profile_toml) = load_raw_toml(&profile_path)? {
deep_merge(&mut merged, profile_toml);
has_profile_file = true;
break;
}
}
if !has_profile_file
&& should_warn_missing_profile_file(&profile, has_inline_profile_section)
{
warn_profile_typo(&profile);
}
let toml_str =
toml::to_string(&merged).expect("internal error: failed to serialize merged config");
let mut config: Self = toml::from_str(&toml_str)?;
config.profile = Some(profile);
config.apply_env_overrides_with_env(env);
if config.server.strict_config {
let enforce_all = config.server.strict_config_enforce_all
|| env
.var("AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL")
.is_ok_and(|v| v == "true" || v == "1");
Self::run_strict_unknown_key_check(
&toml_str,
enforce_all,
plugin_config_roots,
root_policy,
)?;
}
let empty_table = toml::Table::new();
let merged_table = merged.as_table().unwrap_or(&empty_table);
for f in detect_deprecated_keys(merged_table, env, DEPRECATED_CONFIG_KEYS) {
eprintln!(
"Warning: deprecated configuration key `{}` is still honored but will be removed \
in {}; deprecated since {} (replacement: {}; source: {:?})",
f.path,
f.remove_in,
f.since,
f.replacement.as_deref().unwrap_or("none — remove this key"),
f.source,
);
tracing::warn!(
deprecated_key = f.path.as_str(),
replacement = f.replacement.as_deref().unwrap_or("none; remove this key"),
since = f.since.as_str(),
remove_in = f.remove_in.as_str(),
source = ?f.source,
"deprecated configuration key in use; it is still honored but scheduled for removal"
);
}
#[cfg(feature = "mail")]
if config.profile.as_deref() == Some("dev") && !has_mail_transport_source(&merged, env) {
config.mail.transport = crate::mail::Transport::Log;
}
config.validate()?;
let base_dir: PathBuf = env
.var("AUTUMN_MANIFEST_DIR")
.map_or_else(|_| PathBuf::from("."), PathBuf::from);
let cred_profile = config.profile.as_deref().unwrap_or("dev");
let master_key_override = env.var("AUTUMN_MASTER_KEY").ok();
config.credentials = crate::credentials::load_credentials_with_key_override(
cred_profile,
&base_dir,
master_key_override.as_deref(),
)
.map_err(|e| ConfigError::Credentials(e.to_string()))?;
#[cfg(feature = "oauth2")]
{
config.expand_oauth2_providers();
}
Ok(config)
}
fn run_strict_unknown_key_check(
toml_str: &str,
enforce_all: bool,
plugin_config_roots: &BTreeSet<String>,
root_policy: UnknownRootPolicy,
) -> Result<(), ConfigError> {
let schema = Self::get_schema_keys();
let errors = Self::validate_toml_detailed(toml_str, &schema, plugin_config_roots);
let mut hard_errors = Vec::new();
let mut warn_only = Vec::new();
let mut opaque_roots = Vec::new();
for (path, sug, schema_parent, is_table, is_top_level) in errors {
if root_policy == UnknownRootPolicy::LenientWarn
&& schema_parent.is_empty()
&& is_top_level
&& is_table
{
opaque_roots.push(path);
continue;
}
if enforce_all || unknown_key_was_previously_strict(&schema_parent) {
hard_errors.push((path, sug));
} else {
warn_only.push((path, sug));
}
}
if !opaque_roots.is_empty() {
let roots = opaque_roots.join(", ");
let count = opaque_roots.len();
eprintln!(
"deploy config: accepting {count} unknown top-level config section(s) as \
opaque — the deployed app runs the authoritative strict check, so each must \
be a section the app declares (e.g. a plugin config table) or the app will \
reject it at boot: {roots}. A typo here will make the app fail to start."
);
tracing::warn!(
unknown_top_level_roots = roots.as_str(),
count,
"deploy config: accepting unknown top-level config section(s) as opaque; the \
deployed app runs the authoritative strict check, so each must be a section \
the app declares (e.g. a plugin config table) or it will reject it at boot — \
a typo here will make the app fail to start (#2063)"
);
}
for (path, sug) in &warn_only {
let hint = sug
.as_deref()
.map_or_else(String::new, |s| format!(" — did you mean \"{s}\"?"));
eprintln!(
"Warning: unknown configuration key \"{path}\"{hint}. It is ignored and \
falls back to defaults. This will become a hard error in a future \
release; set server.strict_config_enforce_all = true to enforce now."
);
tracing::warn!(
unknown_key = path.as_str(),
suggestion = sug.as_deref().unwrap_or(""),
"unknown configuration key in a section newly covered by strict \
validation; ignored for now (warn-first rollout, #1890), will hard-fail \
once enforcement is promoted"
);
}
if !hard_errors.is_empty() {
let err_messages: Vec<String> = hard_errors
.into_iter()
.map(|(path, sug)| {
sug.map_or_else(
|| format!("unknown key \"{path}\""),
|s| format!("unknown key \"{path}\" — did you mean \"{s}\"?"),
)
})
.collect();
return Err(ConfigError::Validation(format!(
"Strict config check failed. Unknown keys in configuration: {}",
err_messages.join(", ")
)));
}
Ok(())
}
#[cfg(feature = "oauth2")]
fn expand_oauth2_providers(&mut self) {
let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
for name in provider_names {
if let (Some(preset), Some(p)) = (
crate::auth::provider_preset(&name),
self.auth.oauth2.providers.get_mut(&name),
) {
if p.authorize_url.is_empty() {
p.authorize_url = preset.authorize_url;
}
if p.token_url.is_empty() {
p.token_url = preset.token_url;
}
if p.userinfo_url.is_none() {
p.userinfo_url = preset.userinfo_url;
}
if p.scope.is_empty() || p.scope == "default" {
p.scope = preset.scope;
}
if p.issuer.is_none() {
p.issuer = preset.issuer;
}
if p.jwks_url.is_none() {
p.jwks_url = preset.jwks_url;
}
if p.discovery_url.is_none() {
p.discovery_url = preset.discovery_url;
}
}
if let Some(p) = self.auth.oauth2.providers.get_mut(&name) {
let normalized_name = name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.to_lowercase();
let id_key = format!("oauth2_{normalized_name}_client_id");
if p.client_id.is_empty() {
if let Some(id) = self.credentials.get::<String>(&id_key) {
p.client_id = id;
} else if let Some(id) = self
.credentials
.get::<String>(&format!("oauth2_{name}_client_id"))
{
p.client_id = id;
}
}
let secret_key = format!("oauth2_{normalized_name}_client_secret");
if p.client_secret.is_empty() {
if let Some(secret) = self.credentials.get::<String>(&secret_key) {
p.client_secret = secret;
} else if let Some(secret) = self
.credentials
.get::<String>(&format!("oauth2_{name}_client_secret"))
{
p.client_secret = secret;
}
}
}
}
}
pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
match std::fs::read_to_string(path) {
Ok(contents) => {
let config: Self = toml::from_str(&contents)?;
config.validate()?;
Ok(config)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
Err(e) => Err(ConfigError::Io(e)),
}
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.database.validate()?;
self.server.http.validate()?;
self.realtime.validate()?;
self.cors.validate()?;
self.server.admission.validate()?;
#[cfg(feature = "http-client")]
self.http.client.adaptive_throttle.validate()?;
#[cfg(feature = "http-client")]
self.http.client.retry_budget.validate()?;
self.scheduler.validate()?;
self.outbox.validate()?;
self.retention.validate()?;
self.validate_retention_against_replay_protection()?;
if self.database.has_shards()
&& self.database.effective_primary_url().is_none()
&& (self.scheduler.backend == SchedulerBackend::Postgres
|| self.jobs.backend == "postgres")
{
return Err(ConfigError::Validation(
"jobs/scheduler require a control database: set database.primary_url (or \
database.url) alongside [[database.shards]] — framework state such as \
autumn_jobs and scheduler locks is not sharded (see docs/guide/sharding.md)"
.to_owned(),
));
}
if self.jobs.postgres.shard_local
&& (self.jobs.backend != "postgres" || !self.database.has_shards())
{
return Err(ConfigError::Validation(
"jobs.postgres.shard_local = true needs jobs.backend = \"postgres\" and \
[[database.shards]] (see docs/guide/cell-isolation.md)"
.to_owned(),
));
}
let is_production = matches!(self.profile.as_deref(), Some("prod" | "production"));
self.security
.webhooks
.validate(is_production)
.map_err(|error| ConfigError::Validation(error.to_string()))?;
#[cfg(feature = "mail")]
self.mail.validate(self.profile.as_deref())?;
self.time_zone.validate()?;
self.shadow.validate().map_err(ConfigError::Validation)?;
self.fault_injection
.validate(self.profile.as_deref())
.map_err(ConfigError::Validation)?;
self.cluster.validate()?;
self.metrics.validate()?;
self.health.validate()?;
self.cost.validate().map_err(ConfigError::Validation)?;
self.aeo.validate().map_err(ConfigError::Validation)?;
if let Some(replication) = &self.replication {
let errors = replication.validation_errors();
if !errors.is_empty() {
return Err(ConfigError::Validation(format!(
"invalid [replication] configuration:\n - {}",
errors.join("\n - ")
)));
}
}
Ok(())
}
fn validate_retention_against_replay_protection(&self) -> Result<(), ConfigError> {
let Some(window) = self.retention.window("webhook_replay") else {
return Ok(());
};
for endpoint in &self.security.webhooks.endpoints {
if !endpoint.replay_protection {
continue;
}
let replay = std::time::Duration::from_secs(endpoint.replay_window_secs);
if window < replay {
return Err(ConfigError::Validation(format!(
"retention.webhook_replay ({window_secs}s) is shorter than the \
replay_window_secs ({replay_secs}s) of webhook endpoint {name:?}: \
expiring replay markers early would silently weaken replay \
protection. Lower that endpoint's replay_window_secs instead, or \
widen retention.webhook_replay (see \
docs/guide/data-retention.md).",
window_secs = window.as_secs(),
replay_secs = endpoint.replay_window_secs,
name = endpoint.name,
)));
}
}
Ok(())
}
pub fn apply_retention_caps(&mut self) {
let cap_job_tracking = self.jobs.backend != "postgres";
let caps: [(&str, &mut u64); 3] = [
("idempotency", &mut self.idempotency.ttl_secs),
("sessions", &mut self.session.max_age_secs),
("job_tracking", &mut self.jobs.tracking.ttl_secs),
];
for (key, target) in caps {
if key == "job_tracking" && !cap_job_tracking {
continue;
}
if let Some(window) = self.retention.window(key) {
*target = (*target).min(window.as_secs());
}
}
}
pub fn apply_env_overrides(&mut self) {
self.apply_env_overrides_with_env(&OsEnv);
}
pub fn apply_env_overrides_with_env(&mut self, env: &dyn Env) {
self.apply_server_env_overrides_with_env(env);
self.apply_deploy_env_overrides_with_env(env);
self.apply_database_env_overrides_with_env(env);
self.apply_log_env_overrides_with_env(env);
self.apply_telemetry_env_overrides_with_env(env);
self.apply_health_env_overrides_with_env(env);
self.apply_cors_env_overrides_with_env(env);
self.apply_session_env_overrides_with_env(env);
self.apply_cache_env_overrides_with_env(env);
self.apply_channels_env_overrides_with_env(env);
self.apply_realtime_env_overrides_with_env(env);
self.apply_jobs_env_overrides_with_env(env);
self.apply_outbox_env_overrides_with_env(env);
self.apply_scheduler_env_overrides_with_env(env);
self.apply_retention_env_overrides_with_env(env);
self.apply_role_env_overrides_with_env(env);
self.apply_auth_env_overrides_with_env(env);
self.apply_security_env_overrides_with_env(env);
self.apply_bot_protection_env_overrides_with_env(env);
self.apply_idempotency_env_overrides_with_env(env);
self.apply_dev_env_overrides_with_env(env);
self.apply_observability_env_overrides_with_env(env);
self.apply_cost_env_overrides_with_env(env);
self.apply_compression_env_overrides_with_env(env);
self.apply_actuator_env_overrides_with_env(env);
self.apply_metrics_env_overrides_with_env(env);
#[cfg(feature = "reporting")]
self.apply_reporting_env_overrides_with_env(env);
#[cfg(feature = "reporting")]
self.apply_failure_capture_env_overrides_with_env(env);
#[cfg(feature = "storage")]
self.apply_storage_env_overrides_with_env(env);
self.apply_backup_env_overrides_with_env(env);
self.apply_replication_env_overrides_with_env(env);
#[cfg(feature = "mail")]
self.apply_mail_env_overrides_with_env(env);
#[cfg(feature = "maud")]
self.apply_stories_env_overrides_with_env(env);
self.apply_resilience_env_overrides_with_env(env);
self.apply_time_zone_env_overrides_with_env(env);
self.apply_alerts_env_overrides_with_env(env);
self.apply_tenancy_env_overrides_with_env(env);
self.apply_cluster_env_overrides_with_env(env);
self.apply_push_env_overrides_with_env(env);
self.apply_shadow_env_overrides_with_env(env);
parse_env_bool(
env,
"AUTUMN_FAULT_INJECTION__ENABLED",
&mut self.fault_injection.enabled,
);
}
fn apply_push_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(value) = env.var("AUTUMN_PUSH__PRIVATE_KEY") {
self.push.private_key = Some(secrecy::SecretString::from(value));
}
parse_env_option_string(env, "AUTUMN_PUSH__PUBLIC_KEY", &mut self.push.public_key);
parse_env_option_string(env, "AUTUMN_PUSH__SUBJECT", &mut self.push.subject);
parse_env_option(env, "AUTUMN_PUSH__TTL_SECS", &mut self.push.ttl_secs);
}
fn apply_shadow_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_SHADOW__ENABLED", &mut self.shadow.enabled);
parse_env_option_string(env, "AUTUMN_SHADOW__TARGET", &mut self.shadow.target);
parse_env(
env,
"AUTUMN_SHADOW__SAMPLE_RATE",
&mut self.shadow.sample_rate,
);
parse_env_csv_non_empty(env, "AUTUMN_SHADOW__ROUTES", &mut self.shadow.routes);
parse_env(
env,
"AUTUMN_SHADOW__TIMEOUT_MS",
&mut self.shadow.timeout_ms,
);
parse_env(
env,
"AUTUMN_SHADOW__MAX_IN_FLIGHT",
&mut self.shadow.max_in_flight,
);
parse_env(
env,
"AUTUMN_SHADOW__MAX_BODY_BYTES",
&mut self.shadow.max_body_bytes,
);
parse_env(
env,
"AUTUMN_SHADOW__MAX_RECORDS",
&mut self.shadow.max_records,
);
parse_env(
env,
"AUTUMN_SHADOW__MAX_SAMPLE_BYTES",
&mut self.shadow.max_sample_bytes,
);
}
fn apply_cluster_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_CLUSTER__ENABLED", &mut self.cluster.enabled);
parse_env_option_secret(env, "AUTUMN_CLUSTER__SECRET", &mut self.cluster.secret);
parse_env_string(
env,
"AUTUMN_CLUSTER__CLUSTER_NAME",
&mut self.cluster.cluster_name,
);
parse_env_string(
env,
"AUTUMN_CLUSTER__BIND_ADDR",
&mut self.cluster.bind_addr,
);
parse_env_option_string(
env,
"AUTUMN_CLUSTER__ADVERTISE_ADDR",
&mut self.cluster.advertise_addr,
);
parse_env_csv(
env,
"AUTUMN_CLUSTER__SEED_PEERS",
&mut self.cluster.seed_peers,
);
parse_env_option_string(env, "AUTUMN_CLUSTER__NODE_ID", &mut self.cluster.node_id);
parse_env(
env,
"AUTUMN_CLUSTER__PUSH_INTERVAL_MS",
&mut self.cluster.push_interval_ms,
);
parse_env(
env,
"AUTUMN_CLUSTER__SUSPICION_TIMEOUT_MS",
&mut self.cluster.suspicion_timeout_ms,
);
}
fn apply_tenancy_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_TENANCY__ENABLED", &mut self.tenancy.enabled);
parse_env_string(env, "AUTUMN_TENANCY__SOURCE", &mut self.tenancy.source);
parse_env_string(
env,
"AUTUMN_TENANCY__HEADER_NAME",
&mut self.tenancy.header_name,
);
parse_env_string(
env,
"AUTUMN_TENANCY__SESSION_KEY",
&mut self.tenancy.session_key,
);
parse_env_string(
env,
"AUTUMN_TENANCY__JWT_CLAIM",
&mut self.tenancy.jwt_claim,
);
parse_env_option_secret(
env,
"AUTUMN_TENANCY__JWT_SECRET",
&mut self.tenancy.jwt_secret,
);
parse_env_option_string(
env,
"AUTUMN_TENANCY__JWT_ISSUER",
&mut self.tenancy.jwt_issuer,
);
parse_env_option_string(
env,
"AUTUMN_TENANCY__JWT_AUDIENCE",
&mut self.tenancy.jwt_audience,
);
parse_env_option_string(
env,
"AUTUMN_TENANCY__BASE_DOMAIN",
&mut self.tenancy.base_domain,
);
parse_env_option_string(
env,
"AUTUMN_TENANCY__LOGIN_REDIRECT",
&mut self.tenancy.login_redirect,
);
parse_env_csv(
env,
"AUTUMN_TENANCY__PUBLIC_PATHS",
&mut self.tenancy.public_paths,
);
parse_env(
env,
"AUTUMN_TENANCY__QUOTA_BYTES",
&mut self.tenancy.quota_bytes,
);
parse_env(
env,
"AUTUMN_TENANCY__MAX_CELLS",
&mut self.tenancy.max_cells,
);
parse_env(
env,
"AUTUMN_TENANCY__IDLE_TTL_SECS",
&mut self.tenancy.idle_ttl_secs,
);
parse_env(
env,
"AUTUMN_TENANCY__MAX_CONCURRENT_REQUESTS",
&mut self.tenancy.max_concurrent_requests,
);
parse_env(
env,
"AUTUMN_TENANCY__MAX_DB_CONNECTIONS",
&mut self.tenancy.max_db_connections,
);
}
fn apply_alerts_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_ALERTS__ENABLED", &mut self.alerts.enabled);
parse_env_option_string(env, "AUTUMN_ALERTS__EMAIL", &mut self.alerts.email);
parse_env_option_string(
env,
"AUTUMN_ALERTS__WEBHOOK_URL",
&mut self.alerts.webhook_url,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__WEBHOOK_SECRET",
&mut self.alerts.webhook_secret,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__PAGERDUTY_ROUTING_KEY",
&mut self.alerts.pagerduty_routing_key,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__PAGERDUTY_URL",
&mut self.alerts.pagerduty_url,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__SLACK_WEBHOOK_URL",
&mut self.alerts.slack_webhook_url,
);
parse_env_option_string(
env,
"AUTUMN_ALERTS__DISCORD_WEBHOOK_URL",
&mut self.alerts.discord_webhook_url,
);
parse_env(
env,
"AUTUMN_ALERTS__PAGERDUTY_SEVERITIES",
&mut self.alerts.pagerduty_severities,
);
parse_env(
env,
"AUTUMN_ALERTS__SLACK_SEVERITIES",
&mut self.alerts.slack_severities,
);
parse_env(
env,
"AUTUMN_ALERTS__DISCORD_SEVERITIES",
&mut self.alerts.discord_severities,
);
parse_env_bool(
env,
"AUTUMN_ALERTS__CUSTOM_CHANNEL",
&mut self.alerts.custom_channel,
);
parse_env(
env,
"AUTUMN_ALERTS__DEDUP_WINDOW_SECS",
&mut self.alerts.dedup_window_secs,
);
parse_env(
env,
"AUTUMN_ALERTS__HEALTH_GRACE_SECS",
&mut self.alerts.health_grace_secs,
);
parse_env(
env,
"AUTUMN_ALERTS__ERROR_RATE_THRESHOLD",
&mut self.alerts.error_rate_threshold,
);
parse_env(
env,
"AUTUMN_ALERTS__ERROR_RATE_MIN_REQUESTS",
&mut self.alerts.error_rate_min_requests,
);
parse_env(
env,
"AUTUMN_ALERTS__EVAL_INTERVAL_SECS",
&mut self.alerts.eval_interval_secs,
);
}
fn apply_time_zone_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(
env,
"AUTUMN_TIME_ZONE__IDENTIFIER",
&mut self.time_zone.identifier,
);
}
#[cfg(feature = "reporting")]
fn apply_reporting_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_REPORTING__ENABLED",
&mut self.reporting.enabled,
);
parse_env(
env,
"AUTUMN_REPORTING__SAMPLE_RATE",
&mut self.reporting.sample_rate,
);
}
#[cfg(feature = "reporting")]
fn apply_failure_capture_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_FAILURE_CAPTURE__ENABLED",
&mut self.failure_capture.enabled,
);
parse_env_string(
env,
"AUTUMN_FAILURE_CAPTURE__DIR",
&mut self.failure_capture.dir,
);
parse_env(
env,
"AUTUMN_FAILURE_CAPTURE__MAX_BODY_BYTES",
&mut self.failure_capture.max_body_bytes,
);
parse_env(
env,
"AUTUMN_FAILURE_CAPTURE__MAX_CAPSULE_BYTES",
&mut self.failure_capture.max_capsule_bytes,
);
parse_env(
env,
"AUTUMN_FAILURE_CAPTURE__MAX_CAPSULES",
&mut self.failure_capture.max_capsules,
);
}
fn apply_dev_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(
env,
"AUTUMN_DEV__INSPECTOR_PATH",
&mut self.dev.inspector_path,
);
parse_env(
env,
"AUTUMN_DEV__INSPECTOR_CAPACITY",
&mut self.dev.inspector_capacity,
);
parse_env(
env,
"AUTUMN_DEV__INSPECTOR_N_PLUS_ONE_THRESHOLD",
&mut self.dev.inspector_n_plus_one_threshold,
);
}
fn apply_compression_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_COMPRESSION__ENABLED",
&mut self.compression.enabled,
);
}
fn apply_cost_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_COST__ENABLED", &mut self.cost.enabled);
parse_env_option(
env,
"AUTUMN_COST__DEFER_THRESHOLD",
&mut self.cost.defer_threshold,
);
parse_env(
env,
"AUTUMN_COST__DEFER_RECHECK_SECS",
&mut self.cost.defer_recheck_secs,
);
parse_env(env, "AUTUMN_COST__MAX_TENANTS", &mut self.cost.max_tenants);
parse_env(
env,
"AUTUMN_COST__SIGNAL_REFRESH_SECS",
&mut self.cost.signal_refresh_secs,
);
}
fn apply_observability_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_option_bool(
env,
"AUTUMN_OBSERVABILITY__SERVER_TIMING",
&mut self.observability.server_timing,
);
}
#[cfg(feature = "maud")]
fn apply_stories_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_STORIES__ENABLED", &mut self.stories.enabled);
}
fn apply_actuator_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(env, "AUTUMN_ACTUATOR__PREFIX", &mut self.actuator.prefix);
parse_env_bool(
env,
"AUTUMN_ACTUATOR__SENSITIVE",
&mut self.actuator.sensitive,
);
parse_env_bool(
env,
"AUTUMN_ACTUATOR__PROMETHEUS",
&mut self.actuator.prometheus,
);
}
fn apply_metrics_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env(
env,
"AUTUMN_METRICS__MAX_SERIES_PER_METRIC",
&mut self.metrics.max_series_per_metric,
);
parse_env(
env,
"AUTUMN_METRICS__MAX_INSTRUMENTS",
&mut self.metrics.max_instruments,
);
parse_env(
env,
"AUTUMN_METRICS__MAX_LABELS_PER_SERIES",
&mut self.metrics.max_labels_per_series,
);
}
fn apply_idempotency_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_option_bool(
env,
"AUTUMN_IDEMPOTENCY__ENABLED",
&mut self.idempotency.enabled,
);
if let Ok(val) = env.var("AUTUMN_IDEMPOTENCY__BACKEND") {
match IdempotencyBackend::from_env_value(&val) {
Some(backend) => self.idempotency.backend = backend,
None => eprintln!(
"Warning: unrecognised AUTUMN_IDEMPOTENCY__BACKEND value {val:?}; ignoring"
),
}
}
parse_env(
env,
"AUTUMN_IDEMPOTENCY__TTL_SECS",
&mut self.idempotency.ttl_secs,
);
parse_env(
env,
"AUTUMN_IDEMPOTENCY__IN_FLIGHT_TTL_SECS",
&mut self.idempotency.in_flight_ttl_secs,
);
parse_env_bool(
env,
"AUTUMN_IDEMPOTENCY__ALLOW_MEMORY_IN_PRODUCTION",
&mut self.idempotency.allow_memory_in_production,
);
parse_env_string(
env,
"AUTUMN_IDEMPOTENCY__REDIS__URL",
self.idempotency.redis.url.get_or_insert_with(String::new),
);
parse_env_string(
env,
"AUTUMN_IDEMPOTENCY__REDIS__KEY_PREFIX",
&mut self.idempotency.redis.key_prefix,
);
}
fn apply_admission_env_overrides_with_env(&mut self, env: &dyn Env) {
let admission = &mut self.server.admission;
parse_env(env, "AUTUMN_SERVER__ADMISSION__MODE", &mut admission.mode);
parse_env(
env,
"AUTUMN_SERVER__ADMISSION__ALGORITHM",
&mut admission.algorithm,
);
parse_env(
env,
"AUTUMN_SERVER__ADMISSION__MIN_LIMIT",
&mut admission.min_limit,
);
parse_env_option(
env,
"AUTUMN_SERVER__ADMISSION__MAX_LIMIT",
&mut admission.max_limit,
);
parse_env(
env,
"AUTUMN_SERVER__ADMISSION__INITIAL_LIMIT",
&mut admission.initial_limit,
);
parse_env(
env,
"AUTUMN_SERVER__ADMISSION__LATENCY_THRESHOLD_MS",
&mut admission.latency_threshold_ms,
);
parse_env_bool(
env,
"AUTUMN_SERVER__ADMISSION__TRUST_CRITICALITY_HEADER",
&mut admission.trust_criticality_header,
);
parse_env(
env,
"AUTUMN_SERVER__ADMISSION__PARTITIONS__DEFAULT",
&mut admission.partitions.default,
);
parse_env(
env,
"AUTUMN_SERVER__ADMISSION__PARTITIONS__SHEDDABLE",
&mut admission.partitions.sheddable,
);
}
fn apply_server_http_env_overrides_with_env(&mut self, env: &dyn Env) {
let http = &mut self.server.http;
parse_env_option(
env,
"AUTUMN_SERVER__HTTP__HEADER_READ_TIMEOUT_MS",
&mut http.header_read_timeout_ms,
);
parse_env_option(
env,
"AUTUMN_SERVER__HTTP__KEEP_ALIVE_TIMEOUT_MS",
&mut http.keep_alive_timeout_ms,
);
parse_env_option(
env,
"AUTUMN_SERVER__HTTP__MAX_HEADER_BYTES",
&mut http.max_header_bytes,
);
parse_env_option(
env,
"AUTUMN_SERVER__HTTP__HTTP2_MAX_CONCURRENT_STREAMS",
&mut http.http2_max_concurrent_streams,
);
parse_env_option(
env,
"AUTUMN_SERVER__HTTP__MAX_CONNECTIONS",
&mut http.max_connections,
);
}
fn apply_server_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env(env, "AUTUMN_SERVER__PORT", &mut self.server.port);
self.apply_server_http_env_overrides_with_env(env);
parse_env_string(env, "AUTUMN_SERVER__HOST", &mut self.server.host);
parse_env(
env,
"AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS",
&mut self.server.shutdown_timeout_secs,
);
parse_env(
env,
"AUTUMN_SERVER__PRESTOP_GRACE_SECS",
&mut self.server.prestop_grace_secs,
);
parse_env(
env,
"AUTUMN_SERVER__UPGRADE__ENABLED",
&mut self.server.upgrade.enabled,
);
parse_env(
env,
"AUTUMN_SERVER__UPGRADE__READY_TIMEOUT_SECS",
&mut self.server.upgrade.ready_timeout_secs,
);
parse_env_option(
env,
"AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS",
&mut self.server.timeouts.request_timeout_ms,
);
parse_env_bool(
env,
"AUTUMN_SERVER__TIMEOUTS__ACCEPT_DEADLINE_HEADER",
&mut self.server.timeouts.accept_deadline_header,
);
parse_env_option_string(
env,
"AUTUMN_SERVER__UNIX_SOCKET",
&mut self.server.unix_socket,
);
parse_env_option(
env,
"AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS",
&mut self.server.max_concurrent_requests,
);
parse_env_bool(
env,
"AUTUMN_SERVER__STRICT_CONFIG",
&mut self.server.strict_config,
);
parse_env_option_string(
env,
"AUTUMN_SERVER__CAPACITY_CONTRACT",
&mut self.server.capacity_contract,
);
self.apply_admission_env_overrides_with_env(env);
let tls_cert = env.var("AUTUMN_SERVER__TLS__CERT_PATH").ok();
let tls_key = env.var("AUTUMN_SERVER__TLS__KEY_PATH").ok();
let tls_reload = env.var("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS").ok();
let tls_handshake = env.var("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS").ok();
if tls_cert.is_some()
|| tls_key.is_some()
|| tls_reload.is_some()
|| tls_handshake.is_some()
{
let tls = self.server.tls.get_or_insert_with(TlsConfig::empty_for_env);
if let Some(cert) = tls_cert {
tls.cert_path = Some(PathBuf::from(cert));
}
if let Some(key) = tls_key {
tls.key_path = Some(PathBuf::from(key));
}
if let Some(reload) = tls_reload.and_then(|v| v.trim().parse::<u64>().ok()) {
tls.reload_interval_secs = reload;
}
if let Some(handshake) = tls_handshake.and_then(|v| v.trim().parse::<u64>().ok()) {
tls.handshake_timeout_secs = handshake;
}
}
}
fn apply_deploy_env_overrides_with_env(&mut self, env: &dyn Env) {
apply_deploy_env_overrides(&mut self.deploy, env);
}
fn apply_replica_lag_and_pooler_env_overrides(&mut self, env: &dyn Env) {
parse_env_option(
env,
"AUTUMN_DATABASE__REPLICA_MAX_LAG_MS",
&mut self.database.replica_max_lag_ms,
);
parse_env_bool(
env,
"AUTUMN_DATABASE__WARN_ON_POOLER",
&mut self.database.warn_on_pooler,
);
}
fn apply_database_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_DATABASE__URL") {
self.database.url = Some(val);
self.database.primary_url = None;
}
parse_env_option_string(
env,
"AUTUMN_DATABASE__PRIMARY_URL",
&mut self.database.primary_url,
);
parse_env_option_string(
env,
"AUTUMN_DATABASE__REPLICA_URL",
&mut self.database.replica_url,
);
parse_env(
env,
"AUTUMN_DATABASE__POOL_SIZE",
&mut self.database.pool_size,
);
parse_env_option(
env,
"AUTUMN_DATABASE__PRIMARY_POOL_SIZE",
&mut self.database.primary_pool_size,
);
parse_env_option(
env,
"AUTUMN_DATABASE__REPLICA_POOL_SIZE",
&mut self.database.replica_pool_size,
);
parse_env(
env,
"AUTUMN_DATABASE__REPLICA_FALLBACK",
&mut self.database.replica_fallback,
);
parse_env(
env,
"AUTUMN_DATABASE__READ_YOUR_WRITES",
&mut self.database.read_your_writes,
);
parse_env(
env,
"AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS",
&mut self.database.pin_after_write_secs,
);
self.apply_replica_lag_and_pooler_env_overrides(env);
parse_env(
env,
"AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS",
&mut self.database.connect_timeout_secs,
);
parse_env(
env,
"AUTUMN_DATABASE__STARTUP_WAIT_SECS",
&mut self.database.startup_wait_secs,
);
parse_env_option_bool(
env,
"AUTUMN_DATABASE__AUTO_MIGRATE",
&mut self.database.auto_migrate,
);
parse_env_bool(
env,
"AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION",
&mut self.database.auto_migrate_in_production,
);
parse_env_bool(
env,
"AUTUMN_DATABASE__DIRECTORY_SHARD_ROUTER",
&mut self.database.directory_shard_router,
);
parse_env_option_duration(
env,
"AUTUMN_DATABASE__STATEMENT_TIMEOUT",
&mut self.database.statement_timeout,
);
parse_env_option_duration(
env,
"AUTUMN_DATABASE__IDLE_IN_TRANSACTION_TIMEOUT",
&mut self.database.idle_in_transaction_timeout,
);
let mut lock_timeout = Some(self.database.migration_lock_timeout);
parse_env_option_duration(
env,
"AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT",
&mut lock_timeout,
);
if let Some(timeout) = lock_timeout {
self.database.migration_lock_timeout = timeout;
}
parse_env(
env,
"AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES",
&mut self.database.migration_lock_retries,
);
self.apply_shard_env_overrides(env);
self.apply_fleet_env_overrides(env);
}
fn apply_fleet_env_overrides(&mut self, env: &dyn Env) {
if self.database.fleet.is_none() {
let mode = env.var("AUTUMN_DATABASE__FLEET__MODE").ok();
let root = env.var("AUTUMN_DATABASE__FLEET__ROOT").ok();
let (Some(mode), Some(root)) = (mode, root) else {
return;
};
let Ok(mode) = mode.parse::<crate::fleet_layout::FleetMode>() else {
eprintln!("Warning: AUTUMN_DATABASE__FLEET__MODE={mode:?} is not valid, ignoring");
return;
};
self.database.fleet = Some(DatabaseFleetConfig {
mode,
root,
path: None,
max_open: default_fleet_max_open(),
pool_size: default_fleet_pool_size(),
create_on_demand: None,
idle_close_secs: default_fleet_idle_close_secs(),
restore_missing: false,
ownership: None,
});
}
let Some(fleet) = self.database.fleet.as_mut() else {
return;
};
parse_env(env, "AUTUMN_DATABASE__FLEET__MODE", &mut fleet.mode);
parse_env(env, "AUTUMN_DATABASE__FLEET__ROOT", &mut fleet.root);
parse_env_option_string(env, "AUTUMN_DATABASE__FLEET__PATH", &mut fleet.path);
parse_env(env, "AUTUMN_DATABASE__FLEET__MAX_OPEN", &mut fleet.max_open);
parse_env(
env,
"AUTUMN_DATABASE__FLEET__POOL_SIZE",
&mut fleet.pool_size,
);
parse_env_option_bool(
env,
"AUTUMN_DATABASE__FLEET__CREATE_ON_DEMAND",
&mut fleet.create_on_demand,
);
parse_env(
env,
"AUTUMN_DATABASE__FLEET__IDLE_CLOSE_SECS",
&mut fleet.idle_close_secs,
);
parse_env_bool(
env,
"AUTUMN_DATABASE__FLEET__RESTORE_MISSING",
&mut fleet.restore_missing,
);
if fleet.ownership.is_none() {
if let Ok(owner) = env.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER") {
fleet.ownership = Some(FleetOwnershipConfig {
owner,
slots: Vec::new(),
lease_secs: default_fleet_lease_secs(),
});
} else if env.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS").is_ok()
|| env
.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS")
.is_ok()
{
eprintln!(
"Warning: AUTUMN_DATABASE__FLEET__OWNERSHIP__* is set without \
AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER or a [database.fleet.ownership] \
section, ignoring"
);
}
}
let Some(ownership) = fleet.ownership.as_mut() else {
return;
};
parse_env_string(
env,
"AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER",
&mut ownership.owner,
);
if let Ok(slots) = env.var("AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS") {
ownership.slots = slots
.split(',')
.map(str::trim)
.filter(|token| !token.is_empty())
.map(|token| SlotSpec::Range(token.to_owned()))
.collect();
}
parse_env(
env,
"AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS",
&mut ownership.lease_secs,
);
}
fn apply_shard_env_overrides(&mut self, env: &dyn Env) {
const MAX_ENV_SHARDS: usize = 64;
for i in 0..MAX_ENV_SHARDS {
let key = |field: &str| format!("AUTUMN_DATABASE__SHARDS__{i}__{field}");
if i >= self.database.shards.len() {
let (Ok(name), Ok(primary_url)) =
(env.var(&key("NAME")), env.var(&key("PRIMARY_URL")))
else {
break;
};
self.database.shards.push(ShardConfig {
name,
primary_url,
slots: None,
replica_url: None,
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: None,
});
}
let shard = &mut self.database.shards[i];
parse_env_string(env, &key("NAME"), &mut shard.name);
parse_env_string(env, &key("PRIMARY_URL"), &mut shard.primary_url);
if let Ok(val) = env.var(&key("SLOTS")) {
shard.slots = Some(
val.split(',')
.map(|token| SlotSpec::Range(token.trim().to_owned()))
.collect(),
);
}
parse_env_option_string(env, &key("REPLICA_URL"), &mut shard.replica_url);
parse_env_option(env, &key("PRIMARY_POOL_SIZE"), &mut shard.primary_pool_size);
parse_env_option(env, &key("REPLICA_POOL_SIZE"), &mut shard.replica_pool_size);
parse_env_option(env, &key("REPLICA_FALLBACK"), &mut shard.replica_fallback);
}
}
fn apply_log_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(env, "AUTUMN_LOG__LEVEL", &mut self.log.level);
parse_env_bool(env, "AUTUMN_LOG__ACCESS_LOG", &mut self.log.access_log);
parse_env_csv(
env,
"AUTUMN_LOG__ACCESS_LOG_EXCLUDE",
&mut self.log.access_log_exclude,
);
if let Ok(val) = env.var("AUTUMN_LOG__FORMAT") {
match val.as_str() {
"Auto" => self.log.format = LogFormat::Auto,
"Pretty" => self.log.format = LogFormat::Pretty,
"Json" => self.log.format = LogFormat::Json,
_ => eprintln!(
"Warning: AUTUMN_LOG__FORMAT={val:?} is not valid \
(expected Auto, Pretty, or Json), ignoring"
),
}
}
}
fn apply_telemetry_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_TELEMETRY__ENABLED",
&mut self.telemetry.enabled,
);
parse_env_string(
env,
"AUTUMN_TELEMETRY__SERVICE_NAME",
&mut self.telemetry.service_name,
);
parse_env_option_string(
env,
"AUTUMN_TELEMETRY__SERVICE_NAMESPACE",
&mut self.telemetry.service_namespace,
);
parse_env_string(
env,
"AUTUMN_TELEMETRY__SERVICE_VERSION",
&mut self.telemetry.service_version,
);
parse_env_string(
env,
"AUTUMN_TELEMETRY__ENVIRONMENT",
&mut self.telemetry.environment,
);
parse_env_option_string(
env,
"AUTUMN_TELEMETRY__OTLP_ENDPOINT",
&mut self.telemetry.otlp_endpoint,
);
if let Ok(val) = env.var("AUTUMN_TELEMETRY__PROTOCOL") {
match TelemetryProtocol::from_env_value(&val) {
Some(protocol) => self.telemetry.protocol = protocol,
None => eprintln!(
"Warning: AUTUMN_TELEMETRY__PROTOCOL={val:?} is not valid \
(expected Grpc or HttpProtobuf), ignoring"
),
}
}
parse_env_bool(env, "AUTUMN_TELEMETRY__STRICT", &mut self.telemetry.strict);
if let Ok(val) = env.var("AUTUMN_TELEMETRY__SAMPLE_RATIO") {
match val.trim().parse::<f64>() {
Ok(ratio) => self.telemetry.sample_ratio = ratio,
Err(_) => eprintln!(
"Warning: AUTUMN_TELEMETRY__SAMPLE_RATIO={val:?} is not a number, ignoring"
),
}
}
}
fn apply_health_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(env, "AUTUMN_HEALTH__PATH", &mut self.health.path);
parse_env_string(env, "AUTUMN_HEALTH__LIVE_PATH", &mut self.health.live_path);
parse_env_string(
env,
"AUTUMN_HEALTH__READY_PATH",
&mut self.health.ready_path,
);
parse_env_string(
env,
"AUTUMN_HEALTH__STARTUP_PATH",
&mut self.health.startup_path,
);
parse_env_bool(env, "AUTUMN_HEALTH__DETAILED", &mut self.health.detailed);
parse_env_bool(env, "AUTUMN_HEALTH__ENABLED", &mut self.health.enabled);
parse_env(
env,
"AUTUMN_HEALTH__CACHE_TTL_MS",
&mut self.health.cache_ttl_ms,
);
parse_env(
env,
"AUTUMN_HEALTH__PING_TIMEOUT_MS",
&mut self.health.ping_timeout_ms,
);
parse_env_bool(
env,
"AUTUMN_HEALTH__REDIS_READINESS",
&mut self.health.redis_readiness,
);
parse_env_bool(
env,
"AUTUMN_HEALTH__DB_READINESS",
&mut self.health.db_readiness,
);
}
fn apply_cors_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_csv(
env,
"AUTUMN_CORS__ALLOWED_ORIGINS",
&mut self.cors.allowed_origins,
);
parse_env_csv(
env,
"AUTUMN_CORS__ALLOWED_METHODS",
&mut self.cors.allowed_methods,
);
parse_env_csv(
env,
"AUTUMN_CORS__ALLOWED_HEADERS",
&mut self.cors.allowed_headers,
);
parse_env_bool(
env,
"AUTUMN_CORS__ALLOW_CREDENTIALS",
&mut self.cors.allow_credentials,
);
parse_env(
env,
"AUTUMN_CORS__MAX_AGE_SECS",
&mut self.cors.max_age_secs,
);
}
fn apply_session_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(
env,
"AUTUMN_SESSION__COOKIE_NAME",
&mut self.session.cookie_name,
);
if let Ok(val) = env.var("AUTUMN_SESSION__BACKEND") {
match crate::session::SessionBackend::from_env_value(&val) {
Some(backend) => self.session.backend = backend,
None => eprintln!(
"Warning: AUTUMN_SESSION__BACKEND={val:?} is not valid \
(expected memory or redis), ignoring"
),
}
}
parse_env(
env,
"AUTUMN_SESSION__MAX_AGE_SECS",
&mut self.session.max_age_secs,
);
parse_env_bool(env, "AUTUMN_SESSION__SECURE", &mut self.session.secure);
parse_env_string(
env,
"AUTUMN_SESSION__SAME_SITE",
&mut self.session.same_site,
);
parse_env_bool(
env,
"AUTUMN_SESSION__HTTP_ONLY",
&mut self.session.http_only,
);
parse_env_string(env, "AUTUMN_SESSION__PATH", &mut self.session.path);
parse_env_bool(
env,
"AUTUMN_SESSION__ALLOW_MEMORY_IN_PRODUCTION",
&mut self.session.allow_memory_in_production,
);
parse_env_option_string(
env,
"AUTUMN_SESSION__REDIS__URL",
&mut self.session.redis.url,
);
parse_env_string(
env,
"AUTUMN_SESSION__REDIS__KEY_PREFIX",
&mut self.session.redis.key_prefix,
);
}
fn apply_cache_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_CACHE__BACKEND") {
match CacheBackend::from_env_value(&val) {
Some(backend) => self.cache.backend = backend,
None => eprintln!(
"Warning: AUTUMN_CACHE__BACKEND={val:?} is not valid \
(expected memory or redis), ignoring"
),
}
}
parse_env_option_string(env, "AUTUMN_CACHE__REDIS__URL", &mut self.cache.redis.url);
parse_env_string(
env,
"AUTUMN_CACHE__REDIS__KEY_PREFIX",
&mut self.cache.redis.key_prefix,
);
}
fn apply_realtime_env_overrides_with_env(&mut self, env: &dyn Env) {
let realtime = &mut self.realtime;
parse_env_option(
env,
"AUTUMN_REALTIME__MAX_CONNECTIONS",
&mut realtime.max_connections,
);
parse_env_option(
env,
"AUTUMN_REALTIME__MAX_MESSAGE_BYTES",
&mut realtime.max_message_bytes,
);
parse_env_option(
env,
"AUTUMN_REALTIME__PING_INTERVAL_MS",
&mut realtime.ping_interval_ms,
);
parse_env_option(
env,
"AUTUMN_REALTIME__IDLE_TIMEOUT_MS",
&mut realtime.idle_timeout_ms,
);
}
fn apply_channels_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_CHANNELS__BACKEND") {
match ChannelBackend::from_env_value(&val) {
Some(backend) => self.channels.backend = backend,
None => eprintln!(
"Warning: AUTUMN_CHANNELS__BACKEND={val:?} is not valid \
(expected in_process or redis), ignoring"
),
}
}
parse_env(
env,
"AUTUMN_CHANNELS__CAPACITY",
&mut self.channels.capacity,
);
parse_env(
env,
"AUTUMN_CHANNELS__REPLAY_BUFFER",
&mut self.channels.replay_buffer,
);
parse_env_option_string(
env,
"AUTUMN_CHANNELS__REDIS__URL",
&mut self.channels.redis.url,
);
parse_env_string(
env,
"AUTUMN_CHANNELS__REDIS__KEY_PREFIX",
&mut self.channels.redis.key_prefix,
);
}
fn apply_jobs_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(env, "AUTUMN_JOBS__BACKEND", &mut self.jobs.backend);
parse_env(env, "AUTUMN_JOBS__WORKERS", &mut self.jobs.workers);
if let Ok(val) = env.var("AUTUMN_JOBS__PIN") {
self.jobs.pin = val
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect();
}
parse_env(
env,
"AUTUMN_JOBS__MAX_ATTEMPTS",
&mut self.jobs.max_attempts,
);
parse_env(
env,
"AUTUMN_JOBS__INITIAL_BACKOFF_MS",
&mut self.jobs.initial_backoff_ms,
);
parse_env(
env,
"AUTUMN_JOBS__MAX_BACKOFF_MS",
&mut self.jobs.max_backoff_ms,
);
parse_env(
env,
"AUTUMN_JOBS__DEFAULT_TIMEOUT_MS",
&mut self.jobs.default_timeout_ms,
);
parse_env_option_string(env, "AUTUMN_JOBS__REDIS__URL", &mut self.jobs.redis.url);
parse_env_string(
env,
"AUTUMN_JOBS__REDIS__KEY_PREFIX",
&mut self.jobs.redis.key_prefix,
);
parse_env(
env,
"AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS",
&mut self.jobs.redis.visibility_timeout_ms,
);
parse_env(
env,
"AUTUMN_JOBS__REDIS__DEAD_LETTER_LIMIT",
&mut self.jobs.redis.dead_letter_limit,
);
parse_env(
env,
"AUTUMN_JOBS__POSTGRES__VISIBILITY_TIMEOUT_MS",
&mut self.jobs.postgres.visibility_timeout_ms,
);
parse_env_bool(
env,
"AUTUMN_JOBS__POSTGRES__SHARD_LOCAL",
&mut self.jobs.postgres.shard_local,
);
parse_env(
env,
"AUTUMN_JOBS__TENANTS__MAX_CONCURRENT",
&mut self.jobs.tenants.max_concurrent,
);
parse_env(
env,
"AUTUMN_JOBS__TENANTS__LANES",
&mut self.jobs.tenants.lanes,
);
parse_env(
env,
"AUTUMN_JOBS__TENANTS__LANES_PER_TENANT",
&mut self.jobs.tenants.lanes_per_tenant,
);
parse_env(
env,
"AUTUMN_JOBS__SQLITE__VISIBILITY_TIMEOUT_MS",
&mut self.jobs.sqlite.visibility_timeout_ms,
);
parse_env(
env,
"AUTUMN_JOBS__SQLITE__POLL_INTERVAL_MS",
&mut self.jobs.sqlite.poll_interval_ms,
);
parse_env(
env,
"AUTUMN_JOBS__TRACKING__TTL_SECS",
&mut self.jobs.tracking.ttl_secs,
);
parse_env_bool(
env,
"AUTUMN_JOBS__TRACKING__ROUTE_ENABLED",
&mut self.jobs.tracking.route_enabled,
);
}
fn apply_outbox_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(env, "AUTUMN_OUTBOX__ENABLED", &mut self.outbox.enabled);
parse_env(
env,
"AUTUMN_OUTBOX__POLL_INTERVAL_MS",
&mut self.outbox.poll_interval_ms,
);
parse_env(
env,
"AUTUMN_OUTBOX__BATCH_SIZE",
&mut self.outbox.batch_size,
);
parse_env(
env,
"AUTUMN_OUTBOX__MAX_ATTEMPTS",
&mut self.outbox.max_attempts,
);
parse_env(
env,
"AUTUMN_OUTBOX__INITIAL_BACKOFF_MS",
&mut self.outbox.initial_backoff_ms,
);
parse_env(
env,
"AUTUMN_OUTBOX__MAX_BACKOFF_MS",
&mut self.outbox.max_backoff_ms,
);
parse_env(env, "AUTUMN_OUTBOX__LEASE_MS", &mut self.outbox.lease_ms);
parse_env(
env,
"AUTUMN_OUTBOX__RETENTION_MS",
&mut self.outbox.retention_ms,
);
}
fn apply_role_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_ROLE") {
match ProcessRole::from_env_value(&val) {
Some(role) => self.role = role,
None => eprintln!(
"Warning: AUTUMN_ROLE={val:?} is not valid \
(expected combined, web, or worker), ignoring"
),
}
}
}
fn apply_retention_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(
env,
"AUTUMN_RETENTION__SWEEP_INTERVAL",
&mut self.retention.sweep_interval,
);
parse_env_option_string(
env,
"AUTUMN_RETENTION__JOB_HISTORY",
&mut self.retention.job_history,
);
parse_env_option_string(
env,
"AUTUMN_RETENTION__COMMIT_HOOKS",
&mut self.retention.commit_hooks,
);
parse_env_option_string(
env,
"AUTUMN_RETENTION__JOB_TRACKING",
&mut self.retention.job_tracking,
);
parse_env_option_string(
env,
"AUTUMN_RETENTION__IDEMPOTENCY",
&mut self.retention.idempotency,
);
parse_env_option_string(
env,
"AUTUMN_RETENTION__EXPERIMENT_ASSIGNMENTS",
&mut self.retention.experiment_assignments,
);
parse_env_option_string(
env,
"AUTUMN_RETENTION__WEBHOOK_REPLAY",
&mut self.retention.webhook_replay,
);
parse_env_option_string(
env,
"AUTUMN_RETENTION__SESSIONS",
&mut self.retention.sessions,
);
parse_env_option_string(
env,
"AUTUMN_RETENTION__AUDIT_ARCHIVES",
&mut self.retention.audit_archives,
);
parse_env_option_string(
env,
"AUTUMN_RETENTION__CUSTOM_DOMAINS",
&mut self.retention.custom_domains,
);
}
fn apply_scheduler_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_SCHEDULER__BACKEND") {
match SchedulerBackend::from_env_value(&val) {
Some(backend) => self.scheduler.backend = backend,
None => eprintln!(
"Warning: AUTUMN_SCHEDULER__BACKEND={val:?} is not valid \
(expected in_process, postgres or sqlite), ignoring"
),
}
}
parse_env(
env,
"AUTUMN_SCHEDULER__LEASE_TTL_SECS",
&mut self.scheduler.lease_ttl_secs,
);
parse_env_option_string(
env,
"AUTUMN_SCHEDULER__REPLICA_ID",
&mut self.scheduler.replica_id,
);
parse_env_string(
env,
"AUTUMN_SCHEDULER__KEY_PREFIX",
&mut self.scheduler.key_prefix,
);
}
fn apply_auth_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env(env, "AUTUMN_AUTH__BCRYPT_COST", &mut self.auth.bcrypt_cost);
parse_env_string(env, "AUTUMN_AUTH__SESSION_KEY", &mut self.auth.session_key);
parse_env(
env,
"AUTUMN_AUTH__LOCKOUT__ENABLED",
&mut self.auth.lockout.enabled,
);
parse_env(
env,
"AUTUMN_AUTH__LOCKOUT__THRESHOLD",
&mut self.auth.lockout.threshold,
);
parse_env(
env,
"AUTUMN_AUTH__LOCKOUT__WINDOW_SECS",
&mut self.auth.lockout.window_secs,
);
parse_env(
env,
"AUTUMN_AUTH__LOCKOUT__COOLOFF_SECS",
&mut self.auth.lockout.cooloff_secs,
);
parse_env(
env,
"AUTUMN_AUTH__PASSWORD__MIN_LENGTH",
&mut self.auth.password.min_length,
);
parse_env_bool(
env,
"AUTUMN_AUTH__PASSWORD__REJECT_COMMON",
&mut self.auth.password.reject_common,
);
if let Ok(val) = env.var("AUTUMN_AUTH__PASSWORD__BREACH_CHECK") {
match val.as_str() {
"off" => self.auth.password.breach_check = crate::auth::BreachCheck::Off,
"fail_open" => self.auth.password.breach_check = crate::auth::BreachCheck::FailOpen,
"fail_closed" => {
self.auth.password.breach_check = crate::auth::BreachCheck::FailClosed;
}
other => eprintln!(
"Warning: AUTUMN_AUTH__PASSWORD__BREACH_CHECK={other:?} is not valid \
(expected off, fail_open, or fail_closed), ignoring"
),
}
}
parse_env_bool(
env,
"AUTUMN_AUTH__REMEMBER__ENABLED",
&mut self.auth.remember.enabled,
);
parse_env(
env,
"AUTUMN_AUTH__REMEMBER__DURATION_SECS",
&mut self.auth.remember.duration_secs,
);
parse_env_string(
env,
"AUTUMN_AUTH__REMEMBER__COOKIE_NAME",
&mut self.auth.remember.cookie_name,
);
parse_env(
env,
"AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES",
&mut self.auth.magic_link.ttl_minutes,
);
parse_env(
env,
"AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS",
&mut self.auth.magic_link.email_cooldown_secs,
);
#[cfg(feature = "oauth2")]
{
let provider_names: Vec<String> = self.auth.oauth2.providers.keys().cloned().collect();
for name in provider_names {
let upper = name
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.to_uppercase();
let client_id_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_ID");
if let Ok(id) = env.var(&client_id_var)
&& !id.is_empty()
&& let Some(p) = self.auth.oauth2.providers.get_mut(&name)
{
p.client_id = id;
}
let client_secret_var = format!("AUTUMN_AUTH__OAUTH2__{upper}__CLIENT_SECRET");
if let Ok(secret) = env.var(&client_secret_var)
&& !secret.is_empty()
&& let Some(p) = self.auth.oauth2.providers.get_mut(&name)
{
p.client_secret = secret;
}
}
}
}
#[allow(clippy::too_many_lines)]
fn apply_security_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_string(
env,
"AUTUMN_SECURITY__HEADERS__X_FRAME_OPTIONS",
&mut self.security.headers.x_frame_options,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__HEADERS__X_CONTENT_TYPE_OPTIONS",
&mut self.security.headers.x_content_type_options,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__HEADERS__STRICT_TRANSPORT_SECURITY",
&mut self.security.headers.strict_transport_security,
);
parse_env(
env,
"AUTUMN_SECURITY__HEADERS__HSTS_MAX_AGE_SECS",
&mut self.security.headers.hsts_max_age_secs,
);
parse_env_string(
env,
"AUTUMN_SECURITY__HEADERS__CONTENT_SECURITY_POLICY",
&mut self.security.headers.content_security_policy,
);
parse_env_string(
env,
"AUTUMN_SECURITY__HEADERS__REFERRER_POLICY",
&mut self.security.headers.referrer_policy,
);
parse_env_string(
env,
"AUTUMN_SECURITY__HEADERS__PERMISSIONS_POLICY",
&mut self.security.headers.permissions_policy,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__CSRF__ENABLED",
&mut self.security.csrf.enabled,
);
parse_env_string(
env,
"AUTUMN_SECURITY__CSRF__TOKEN_HEADER",
&mut self.security.csrf.token_header,
);
parse_env_string(
env,
"AUTUMN_SECURITY__CSRF__COOKIE_NAME",
&mut self.security.csrf.cookie_name,
);
parse_env(
env,
"AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES",
&mut self.security.csrf.token_scan_bytes,
);
self.apply_rate_limit_env_overrides_with_env(env);
parse_env(
env,
"AUTUMN_SECURITY__UPLOAD__MAX_REQUEST_SIZE_BYTES",
&mut self.security.upload.max_request_size_bytes,
);
parse_env(
env,
"AUTUMN_SECURITY__UPLOAD__MAX_FILE_SIZE_BYTES",
&mut self.security.upload.max_file_size_bytes,
);
parse_env_csv(
env,
"AUTUMN_SECURITY__UPLOAD__ALLOWED_MIME_TYPES",
&mut self.security.upload.allowed_mime_types,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
&mut self.security.upload.reject_on_content_type_mismatch,
);
if let Ok(value) = env.var("AUTUMN_SECURITY__FORBIDDEN_RESPONSE") {
match value.parse::<crate::authorization::ForbiddenResponse>() {
Ok(parsed) => self.security.forbidden_response = parsed,
Err(err) => tracing::warn!(
"ignoring invalid AUTUMN_SECURITY__FORBIDDEN_RESPONSE={value:?}: {err}"
),
}
}
parse_env_bool(
env,
"AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
&mut self.security.allow_unauthorized_repository_api,
);
parse_env_option_string(
env,
"AUTUMN_SECURITY__SIGNING_SECRET",
&mut self.security.signing_secret.secret,
);
parse_env_csv(
env,
"AUTUMN_SECURITY__TRUSTED_HOSTS__HOSTS",
&mut self.security.trusted_hosts.hosts,
);
parse_env_csv(
env,
"AUTUMN_SECURITY__TRUSTED_PROXIES__RANGES",
&mut self.security.trusted_proxies.ranges,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__TRUSTED_PROXIES__TRUST_FORWARDED_HEADERS",
&mut self.security.trusted_proxies.trust_forwarded_headers,
);
if let Ok(val) = env.var("AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS") {
if let Ok(hops) = val.trim().parse::<u32>() {
self.security.trusted_proxies.trusted_hops = Some(hops);
} else {
tracing::warn!(
"ignoring invalid AUTUMN_SECURITY__TRUSTED_PROXIES__TRUSTED_HOPS={val:?}: \
expected a non-negative integer"
);
}
}
self.security.webhooks.apply_env_overrides_with_env(env);
}
fn apply_bot_protection_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_BOT_PROTECTION__ENABLED",
&mut self.bot_protection.enabled,
);
parse_env_bool(
env,
"AUTUMN_BOT_PROTECTION__DEV_BYPASS",
&mut self.bot_protection.dev_bypass,
);
if let Ok(val) = env.var("AUTUMN_BOT_PROTECTION__PROVIDER") {
match val.to_lowercase().as_str() {
"turnstile" => {
self.bot_protection.provider =
crate::security::captcha::CaptchaProviderKind::Turnstile;
}
"hcaptcha" => {
self.bot_protection.provider =
crate::security::captcha::CaptchaProviderKind::HCaptcha;
}
_ => tracing::warn!(
"ignoring unrecognised AUTUMN_BOT_PROTECTION__PROVIDER={val:?}: \
expected \"turnstile\" or \"hcaptcha\""
),
}
}
parse_env_option_string(
env,
"AUTUMN_BOT_PROTECTION__SITE_KEY",
&mut self.bot_protection.site_key,
);
parse_env_option_string(
env,
"AUTUMN_BOT_PROTECTION__SECRET_KEY",
&mut self.bot_protection.secret_key,
);
parse_env_option_string(
env,
"AUTUMN_BOT_PROTECTION__FORM_FIELD",
&mut self.bot_protection.form_field,
);
}
fn apply_rate_limit_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_bool(
env,
"AUTUMN_SECURITY__RATE_LIMIT__ENABLED",
&mut self.security.rate_limit.enabled,
);
parse_env(
env,
"AUTUMN_SECURITY__RATE_LIMIT__REQUESTS_PER_SECOND",
&mut self.security.rate_limit.requests_per_second,
);
parse_env(
env,
"AUTUMN_SECURITY__RATE_LIMIT__BURST",
&mut self.security.rate_limit.burst,
);
parse_env_bool(
env,
"AUTUMN_SECURITY__RATE_LIMIT__TRUST_FORWARDED_HEADERS",
&mut self.security.rate_limit.trust_forwarded_headers,
);
parse_env_csv(
env,
"AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
&mut self.security.rate_limit.trusted_proxies,
);
if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY") {
match crate::security::config::KeyStrategy::from_env_value(&val) {
Some(strategy) => self.security.rate_limit.key_strategy = strategy,
None => eprintln!(
"Warning: AUTUMN_SECURITY__RATE_LIMIT__KEY_STRATEGY={val:?} is not valid \
(expected ip, api_token, or authenticated_principal), ignoring"
),
}
}
if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__BACKEND") {
match crate::security::config::RateLimitBackend::from_env_value(&val) {
Some(backend) => self.security.rate_limit.backend = backend,
None => eprintln!(
"Warning: AUTUMN_SECURITY__RATE_LIMIT__BACKEND={val:?} is not valid \
(expected memory or redis), ignoring"
),
}
}
#[cfg(feature = "redis")]
{
use crate::security::config::RateLimitBackendFailure;
if let Ok(val) = env.var("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE") {
match RateLimitBackendFailure::from_env_value(&val) {
Some(mode) => self.security.rate_limit.on_backend_failure = mode,
None => eprintln!(
"Warning: AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE={val:?} is not \
valid (expected fail_open or fail_closed), ignoring"
),
}
}
parse_env_option_string(
env,
"AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
&mut self.security.rate_limit.redis.url,
);
parse_env_string(
env,
"AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX",
&mut self.security.rate_limit.redis.key_prefix,
);
}
}
#[cfg(feature = "storage")]
fn apply_storage_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_STORAGE__BACKEND") {
match crate::storage::StorageBackend::from_env_value(&val) {
Some(backend) => self.storage.backend = backend,
None => eprintln!(
"Warning: AUTUMN_STORAGE__BACKEND={val:?} is not valid \
(expected disabled, local, or s3), ignoring"
),
}
}
parse_env_string(
env,
"AUTUMN_STORAGE__DEFAULT_PROVIDER",
&mut self.storage.default_provider,
);
parse_env_bool(
env,
"AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION",
&mut self.storage.allow_local_in_production,
);
if let Ok(val) = env.var("AUTUMN_STORAGE__LOCAL__ROOT") {
self.storage.local.root = PathBuf::from(val);
}
parse_env_string(
env,
"AUTUMN_STORAGE__LOCAL__MOUNT_PATH",
&mut self.storage.local.mount_path,
);
parse_env(
env,
"AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS",
&mut self.storage.local.default_url_expiry_secs,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__LOCAL__SIGNING_KEY",
&mut self.storage.local.signing_key,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__BUCKET",
&mut self.storage.s3.bucket,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__REGION",
&mut self.storage.s3.region,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__ENDPOINT",
&mut self.storage.s3.endpoint,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
&mut self.storage.s3.public_base_url,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV",
&mut self.storage.s3.access_key_id_env,
);
parse_env_option_string(
env,
"AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
&mut self.storage.s3.secret_access_key_env,
);
parse_env_bool(
env,
"AUTUMN_STORAGE__S3__FORCE_PATH_STYLE",
&mut self.storage.s3.force_path_style,
);
parse_env(
env,
"AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS",
&mut self.storage.s3.default_url_expiry_secs,
);
parse_env(
env,
"AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES",
&mut self.storage.variants.max_source_bytes,
);
parse_env(
env,
"AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH",
&mut self.storage.variants.max_source_width,
);
parse_env(
env,
"AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT",
&mut self.storage.variants.max_source_height,
);
}
fn apply_backup_env_overrides_with_env(&mut self, env: &dyn Env) {
const OFFSITE_DEST_KEYS: &[&str] = &[
"AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
"AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
"AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
];
let has_dest_key = OFFSITE_DEST_KEYS.iter().any(|k| env.var(k).is_ok());
let auto_upload_truthy = env
.var("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD")
.is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true"));
if self.backup.offsite.is_none() && !has_dest_key && !auto_upload_truthy {
return;
}
let offsite = self
.backup
.offsite
.get_or_insert_with(|| Box::new(OffsiteBackupConfig::default()));
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__BUCKET",
&mut offsite.s3.bucket,
);
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__REGION",
&mut offsite.s3.region,
);
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
&mut offsite.s3.endpoint,
);
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
&mut offsite.s3.access_key_id_env,
);
parse_env_option_string(
env,
"AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
&mut offsite.s3.secret_access_key_env,
);
parse_env_bool(
env,
"AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE",
&mut offsite.s3.force_path_style,
);
parse_env_option_string(env, "AUTUMN_BACKUP__OFFSITE__PREFIX", &mut offsite.prefix);
parse_env_option(env, "AUTUMN_BACKUP__OFFSITE__KEEP", &mut offsite.keep);
parse_env_bool(
env,
"AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
&mut offsite.auto_upload,
);
parse_env_bool(
env,
"AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
&mut offsite.allow_shared_bucket,
);
}
fn apply_replication_env_overrides_with_env(&mut self, env: &dyn Env) {
const DEST_KEYS: &[&str] = &[
"AUTUMN_REPLICATION__S3__BUCKET",
"AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV",
"AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV",
"AUTUMN_REPLICATION__PATH",
];
const S3_KEYS: &[&str] = &[
"AUTUMN_REPLICATION__S3__BUCKET",
"AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV",
"AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV",
];
let has_dest_key = DEST_KEYS.iter().any(|k| env.var(k).is_ok());
let enabled_truthy = env
.var("AUTUMN_REPLICATION__ENABLED")
.is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true"));
if self.replication.is_none() && !has_dest_key && !enabled_truthy {
return;
}
let replication = self
.replication
.get_or_insert_with(|| Box::new(ReplicationConfig::default()));
parse_env_bool(env, "AUTUMN_REPLICATION__ENABLED", &mut replication.enabled);
parse_env(
env,
"AUTUMN_REPLICATION__RPO_SECS",
&mut replication.rpo_secs,
);
parse_env_option(
env,
"AUTUMN_REPLICATION__SYNC_INTERVAL_SECS",
&mut replication.sync_interval_secs,
);
parse_env(
env,
"AUTUMN_REPLICATION__SNAPSHOT_INTERVAL_SECS",
&mut replication.snapshot_interval_secs,
);
parse_env(
env,
"AUTUMN_REPLICATION__MAX_WAL_BYTES",
&mut replication.max_wal_bytes,
);
parse_env(
env,
"AUTUMN_REPLICATION__RETENTION_HOURS",
&mut replication.retention_hours,
);
parse_env(
env,
"AUTUMN_REPLICATION__VERIFY_INTERVAL_SECS",
&mut replication.verify_interval_secs,
);
parse_env_option_string(env, "AUTUMN_REPLICATION__PREFIX", &mut replication.prefix);
parse_env_option_string(env, "AUTUMN_REPLICATION__PATH", &mut replication.path);
parse_env_bool(
env,
"AUTUMN_REPLICATION__ALLOW_SHARED_BUCKET",
&mut replication.allow_shared_bucket,
);
if replication.s3.is_none() && !S3_KEYS.iter().any(|k| env.var(k).is_ok()) {
return;
}
let s3 = replication
.s3
.get_or_insert_with(ReplicationS3Config::default);
parse_env_option_string(env, "AUTUMN_REPLICATION__S3__BUCKET", &mut s3.bucket);
parse_env_option_string(env, "AUTUMN_REPLICATION__S3__REGION", &mut s3.region);
parse_env_option_string(env, "AUTUMN_REPLICATION__S3__ENDPOINT", &mut s3.endpoint);
parse_env_option_string(
env,
"AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV",
&mut s3.access_key_id_env,
);
parse_env_option_string(
env,
"AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV",
&mut s3.secret_access_key_env,
);
parse_env_bool(
env,
"AUTUMN_REPLICATION__S3__FORCE_PATH_STYLE",
&mut s3.force_path_style,
);
}
#[cfg(feature = "mail")]
fn apply_mail_env_overrides_with_env(&mut self, env: &dyn Env) {
if let Ok(val) = env.var("AUTUMN_MAIL__TRANSPORT") {
match crate::mail::Transport::from_env_value(&val) {
Some(transport) => self.mail.transport = transport,
None => eprintln!(
"Warning: AUTUMN_MAIL__TRANSPORT={val:?} is not valid \
(expected log, file, smtp, or disabled), ignoring"
),
}
}
parse_env_option_string(env, "AUTUMN_MAIL__FROM", &mut self.mail.from);
parse_env_option_string(env, "AUTUMN_MAIL__REPLY_TO", &mut self.mail.reply_to);
parse_env_bool(
env,
"AUTUMN_MAIL__ALLOW_LOG_IN_PRODUCTION",
&mut self.mail.allow_log_in_production,
);
parse_env_bool(
env,
"AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
&mut self.mail.allow_in_process_deliver_later_in_production,
);
parse_env_bool(env, "AUTUMN_MAIL__PREVIEW", &mut self.mail.preview);
parse_env_option_string(
env,
"AUTUMN_MAIL__UNSUBSCRIBE_BASE_URL",
&mut self.mail.unsubscribe_base_url,
);
parse_env_option_string(
env,
"AUTUMN_MAIL__UNSUBSCRIBE_MAILTO",
&mut self.mail.unsubscribe_mailto,
);
if let Ok(val) = env.var("AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS") {
match val.parse::<i64>() {
Ok(days) => self.mail.unsubscribe_token_ttl_days = days,
Err(_) => eprintln!(
"Warning: AUTUMN_MAIL__UNSUBSCRIBE_TOKEN_TTL_DAYS={val:?} is not a valid integer, ignoring"
),
}
}
parse_env_bool(
env,
"AUTUMN_MAIL__MOUNT_UNSUBSCRIBE_ENDPOINT",
&mut self.mail.mount_unsubscribe_endpoint,
);
parse_env_bool(env, "AUTUMN_MAIL__INLINE_CSS", &mut self.mail.inline_css);
if let Ok(val) = env.var("AUTUMN_MAIL__FILE_DIR") {
self.mail.file_dir = PathBuf::from(val);
}
parse_env_option_string(env, "AUTUMN_MAIL__SMTP__HOST", &mut self.mail.smtp.host);
if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__PORT") {
match val.parse::<u16>() {
Ok(port) => self.mail.smtp.port = Some(port),
Err(_) => {
eprintln!("Warning: AUTUMN_MAIL__SMTP__PORT={val:?} is not valid, ignoring");
}
}
}
parse_env_option_string(
env,
"AUTUMN_MAIL__SMTP__USERNAME",
&mut self.mail.smtp.username,
);
parse_env_option_string(
env,
"AUTUMN_MAIL__SMTP__PASSWORD_ENV",
&mut self.mail.smtp.password_env,
);
if let Ok(val) = env.var("AUTUMN_MAIL__SMTP__TLS") {
match crate::mail::TlsMode::from_env_value(&val) {
Some(tls) => self.mail.smtp.tls = tls,
None => eprintln!(
"Warning: AUTUMN_MAIL__SMTP__TLS={val:?} is not valid \
(expected disabled, starttls, or tls), ignoring"
),
}
}
}
#[must_use]
pub fn profile_name(&self) -> Option<&str> {
self.profile.as_deref()
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct RequestTimeoutsConfig {
#[serde(default)]
pub request_timeout_ms: Option<u64>,
#[serde(default)]
pub accept_deadline_header: bool,
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct HttpServerConfig {
#[serde(default)]
pub header_read_timeout_ms: Option<u64>,
#[serde(default)]
pub keep_alive_timeout_ms: Option<u64>,
#[serde(default)]
pub max_header_bytes: Option<usize>,
#[serde(default)]
pub http2_max_concurrent_streams: Option<u32>,
#[serde(default)]
pub max_connections: Option<usize>,
}
impl HttpServerConfig {
pub const MIN_HEADER_BYTES: usize = 8192;
pub const MAX_CONNECTIONS: usize = tokio::sync::Semaphore::MAX_PERMITS;
pub fn validate(&self) -> Result<(), ConfigError> {
if let Some(bytes) = self.max_header_bytes
&& bytes < Self::MIN_HEADER_BYTES
{
return Err(ConfigError::Validation(format!(
"server.http.max_header_bytes must be {} or more (got {bytes})",
Self::MIN_HEADER_BYTES
)));
}
if self.http2_max_concurrent_streams == Some(0) {
return Err(ConfigError::Validation(
"server.http.http2_max_concurrent_streams must be greater than zero".to_owned(),
));
}
if let Some(max) = self.max_connections
&& max > Self::MAX_CONNECTIONS
{
return Err(ConfigError::Validation(format!(
"server.http.max_connections must be {} or less (got {max})",
Self::MAX_CONNECTIONS
)));
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct UpgradeConfig {
#[serde(default = "default_upgrade_enabled")]
pub enabled: bool,
#[serde(default = "default_upgrade_ready_timeout")]
pub ready_timeout_secs: u64,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ServerConfig {
#[serde(default = "default_port")]
pub port: u16,
#[serde(default = "default_host")]
pub host: String,
#[serde(default)]
pub strict_config: bool,
#[serde(default)]
pub strict_config_enforce_all: bool,
#[serde(default = "default_shutdown_timeout")]
pub shutdown_timeout_secs: u64,
#[serde(default = "default_prestop_grace")]
pub prestop_grace_secs: u64,
#[serde(default)]
pub upgrade: UpgradeConfig,
#[serde(default)]
pub timeouts: RequestTimeoutsConfig,
#[serde(default)]
pub http: HttpServerConfig,
#[serde(default)]
pub unix_socket: Option<String>,
#[serde(default)]
pub max_concurrent_requests: Option<usize>,
#[serde(default)]
pub capacity_contract: Option<String>,
#[serde(default)]
pub tls: Option<TlsConfig>,
#[serde(default)]
pub admission: AdmissionConfig,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum AdmissionMode {
#[default]
Static,
Adaptive,
}
impl std::str::FromStr for AdmissionMode {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"static" => Ok(Self::Static),
"adaptive" => Ok(Self::Adaptive),
other => Err(format!("unknown admission mode {other:?}")),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum AdmissionAlgorithm {
#[default]
Gradient2,
Vegas,
Aimd,
}
impl std::str::FromStr for AdmissionAlgorithm {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.trim().to_ascii_lowercase().as_str() {
"gradient2" => Ok(Self::Gradient2),
"vegas" => Ok(Self::Vegas),
"aimd" => Ok(Self::Aimd),
other => Err(format!("unknown admission algorithm {other:?}")),
}
}
}
#[derive(Debug, Clone, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct AdmissionConfig {
#[serde(default)]
pub mode: AdmissionMode,
#[serde(default)]
pub algorithm: AdmissionAlgorithm,
#[serde(default = "default_admission_min_limit")]
pub min_limit: usize,
#[serde(default)]
pub max_limit: Option<usize>,
#[serde(default = "default_admission_initial_limit")]
pub initial_limit: usize,
#[serde(default = "default_admission_latency_threshold_ms")]
pub latency_threshold_ms: u64,
#[serde(default)]
pub trust_criticality_header: bool,
#[serde(default)]
pub partitions: AdmissionPartitionsConfig,
}
pub const DEFAULT_ADMISSION_MAX_LIMIT: usize = 1000;
const fn default_admission_min_limit() -> usize {
8
}
const fn default_admission_initial_limit() -> usize {
20
}
const fn default_admission_latency_threshold_ms() -> u64 {
1000
}
impl Default for AdmissionConfig {
fn default() -> Self {
Self {
mode: AdmissionMode::default(),
algorithm: AdmissionAlgorithm::default(),
min_limit: default_admission_min_limit(),
max_limit: None,
initial_limit: default_admission_initial_limit(),
latency_threshold_ms: default_admission_latency_threshold_ms(),
trust_criticality_header: false,
partitions: AdmissionPartitionsConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Deserialize)]
#[non_exhaustive]
pub struct AdmissionPartitionsConfig {
#[serde(default = "default_partition_default")]
pub default: f64,
#[serde(default = "default_partition_sheddable")]
pub sheddable: f64,
}
const fn default_partition_default() -> f64 {
1.0
}
const fn default_partition_sheddable() -> f64 {
0.5
}
impl Default for AdmissionPartitionsConfig {
fn default() -> Self {
Self {
default: default_partition_default(),
sheddable: default_partition_sheddable(),
}
}
}
impl AdmissionConfig {
pub fn partition_shares(
&self,
) -> Result<crate::admission::PartitionShares, crate::admission::AdmissionConfigError> {
crate::admission::PartitionShares::new(self.partitions.default, self.partitions.sheddable)
}
pub fn limit_bounds(
&self,
ceiling: Option<usize>,
) -> Result<crate::admission::LimitBounds, crate::admission::AdmissionConfigError> {
let (min, max) = match (self.max_limit, ceiling) {
(Some(max), _) => (self.min_limit, max),
(None, Some(ceiling)) if ceiling > 0 => (self.min_limit.min(ceiling), ceiling),
(None, _) => (self.min_limit, DEFAULT_ADMISSION_MAX_LIMIT),
};
crate::admission::LimitBounds::new(min, max, self.initial_limit)
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.partition_shares()
.map_err(|e| ConfigError::Validation(e.to_string()))?;
let max = self.max_limit.unwrap_or(usize::MAX);
crate::admission::LimitBounds::new(self.min_limit, max, self.initial_limit)
.map_err(|e| ConfigError::Validation(e.to_string()))?;
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct TlsConfig {
#[serde(default)]
pub cert_path: Option<PathBuf>,
#[serde(default)]
pub key_path: Option<PathBuf>,
#[serde(default = "default_tls_reload_interval_secs")]
pub reload_interval_secs: u64,
#[serde(default = "default_tls_handshake_timeout_secs")]
pub handshake_timeout_secs: u64,
#[serde(default)]
pub acme: Option<AcmeConfig>,
#[serde(default)]
pub client_auth: Option<ClientAuthConfig>,
}
impl TlsConfig {
#[must_use]
pub fn client_auth_active(&self) -> bool {
self.client_auth
.as_ref()
.is_some_and(|c| c.mode.requests_certificate())
}
const fn empty_for_env() -> Self {
Self {
cert_path: None,
key_path: None,
reload_interval_secs: default_tls_reload_interval_secs(),
handshake_timeout_secs: default_tls_handshake_timeout_secs(),
acme: None,
client_auth: None,
}
}
pub fn validate(&self) -> Result<(), String> {
let has_cert = self.cert_path.is_some();
let has_key = self.key_path.is_some();
let static_configured = has_cert || has_key;
let acme_configured = self.acme.is_some();
match (static_configured, acme_configured) {
(true, true) => {
return Err(
"[server.tls] sets a static cert_path/key_path AND [server.tls.acme]; \
choose exactly one — remove the static cert to use ACME, or remove \
[server.tls.acme] to serve the static certificate"
.to_owned(),
);
}
(false, false) => {
return Err(
"[server.tls] must configure exactly one of: a static certificate \
(cert_path AND key_path) or automatic provisioning ([server.tls.acme] \
with domains + contact_email)"
.to_owned(),
);
}
(true, false) => {
if !(has_cert && has_key) {
return Err("[server.tls] cert_path and key_path must be set together; \
set both, or configure [server.tls.acme] instead"
.to_owned());
}
}
(false, true) => {}
}
if let Some(acme) = &self.acme {
acme.validate()?;
}
if let Some(client_auth) = &self.client_auth {
client_auth.validate()?;
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ClientAuthMode {
#[default]
Off,
Optional,
Required,
}
impl ClientAuthMode {
#[must_use]
pub const fn requests_certificate(self) -> bool {
!matches!(self, Self::Off)
}
#[must_use]
pub const fn mandates_certificate(self) -> bool {
matches!(self, Self::Required)
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Off => "off",
Self::Optional => "optional",
Self::Required => "required",
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ClientAuthConfig {
#[serde(default)]
pub mode: ClientAuthMode,
#[serde(default)]
pub ca_bundle_path: Option<PathBuf>,
#[serde(default)]
pub crl_path: Option<PathBuf>,
#[serde(default)]
pub required_paths: Vec<String>,
#[serde(default = "default_tls_reload_interval_secs")]
pub reload_interval_secs: u64,
}
impl ClientAuthConfig {
pub fn validate(&self) -> Result<(), String> {
if self.mode.requests_certificate() {
match &self.ca_bundle_path {
None => {
return Err(format!(
"[server.tls.client_auth] mode = \"{}\" needs ca_bundle_path — the PEM \
bundle of client CAs to verify against",
self.mode.as_str()
));
}
Some(path) if path.to_str().is_none_or(|p| p.trim().is_empty()) => {
return Err(
"[server.tls.client_auth] ca_bundle_path is empty; set it to the PEM \
bundle of client CAs, or remove the section"
.to_owned(),
);
}
Some(_) => {}
}
} else if !self.required_paths.is_empty() {
return Err(
"[server.tls.client_auth] lists required_paths but mode = \"off\", so no \
certificate is ever requested and those routes would reject every request; \
set mode = \"optional\" or \"required\""
.to_owned(),
);
}
if self
.crl_path
.as_ref()
.is_some_and(|p| p.to_str().is_none_or(|s| s.trim().is_empty()))
{
return Err(
"[server.tls.client_auth] crl_path is empty; set it to a PEM revocation list, \
or remove the key"
.to_owned(),
);
}
for path in &self.required_paths {
if !path.starts_with('/') {
return Err(format!(
"[server.tls.client_auth] required_paths entry `{path}` must be a rooted \
path prefix starting with `/`"
));
}
if path.contains("//") {
return Err(format!(
"[server.tls.client_auth] required_paths entry `{path}` contains an empty \
path segment; write it as a canonical prefix like `/internal/`"
));
}
if path
.split('/')
.any(|segment| segment == "." || segment == "..")
{
return Err(format!(
"[server.tls.client_auth] required_paths entry `{path}` contains a dot \
segment; write the resolved prefix instead"
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
pub struct AcmeConfig {
pub domains: Vec<String>,
pub contact_email: String,
#[serde(default)]
pub directory: AcmeDirectory,
#[serde(default = "default_acme_cache_dir")]
pub cache_dir: PathBuf,
#[serde(default = "default_acme_http_challenge_port")]
pub http_challenge_port: u16,
#[serde(default = "default_acme_renew_before_days")]
pub renew_before_days: u32,
#[serde(default)]
pub ca_root_path: Option<PathBuf>,
#[serde(default)]
pub dns: Option<AcmeDnsConfig>,
#[serde(default)]
pub custom_domains: Option<CustomDomainsConfig>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct CustomDomainsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub ingress_hostname: Option<String>,
#[serde(default)]
pub ingress_ipv4: Vec<String>,
#[serde(default)]
pub ingress_ipv6: Vec<String>,
#[serde(default = "default_custom_domains_dir")]
pub store_dir: PathBuf,
#[serde(default = "default_custom_domains_max")]
pub max_domains: usize,
#[serde(default = "default_custom_domains_cert_cache")]
pub cert_cache_size: usize,
#[serde(default = "default_custom_domains_per_domain_per_day")]
pub issuance_per_domain_per_day: u32,
#[serde(default = "default_custom_domains_global_per_hour")]
pub issuance_global_per_hour: u32,
#[serde(default = "default_custom_domains_base_backoff")]
pub failure_backoff_secs: u64,
#[serde(default = "default_custom_domains_max_backoff")]
pub max_failure_backoff_secs: u64,
#[serde(default = "default_custom_domains_poll_secs")]
pub poll_interval_secs: u64,
#[serde(default = "default_acme_dns_resolvers")]
pub resolvers: Vec<String>,
}
impl Default for CustomDomainsConfig {
fn default() -> Self {
Self {
enabled: false,
ingress_hostname: None,
ingress_ipv4: Vec::new(),
ingress_ipv6: Vec::new(),
store_dir: default_custom_domains_dir(),
max_domains: default_custom_domains_max(),
cert_cache_size: default_custom_domains_cert_cache(),
issuance_per_domain_per_day: default_custom_domains_per_domain_per_day(),
issuance_global_per_hour: default_custom_domains_global_per_hour(),
failure_backoff_secs: default_custom_domains_base_backoff(),
max_failure_backoff_secs: default_custom_domains_max_backoff(),
poll_interval_secs: default_custom_domains_poll_secs(),
resolvers: default_acme_dns_resolvers(),
}
}
}
impl CustomDomainsConfig {
pub fn resolver_addrs(&self) -> Result<Vec<std::net::SocketAddr>, String> {
self.resolvers
.iter()
.map(|entry| parse_resolver_addr("[server.tls.acme.custom_domains]", entry))
.collect()
}
fn validate_ingress_hostname(&self) -> Result<(), String> {
if let Some(raw) = self
.ingress_hostname
.as_ref()
.filter(|h| !h.trim().is_empty())
{
let canonical = raw.trim().trim_end_matches('.').to_ascii_lowercase();
match crate::custom_domain::normalize_hostname(raw) {
Ok(host) if host == canonical => {}
Ok(host) => {
return Err(format!(
"[server.tls.acme.custom_domains] ingress_hostname `{raw}` must be a bare \
DNS name (`{host}`): a scheme, port or path cannot be a CNAME target, so \
tenants would be given a record that never resolves"
));
}
Err(e) => {
return Err(format!(
"[server.tls.acme.custom_domains] ingress_hostname `{raw}` is not a usable \
DNS name: {e}. Tenants are given it as their CNAME target, so every \
subdomain would stay at pending_dns"
));
}
}
}
Ok(())
}
pub fn validate(&self) -> Result<(), String> {
if !self.enabled {
return Ok(());
}
if self
.ingress_hostname
.as_ref()
.is_none_or(|h| h.trim().is_empty())
&& self.ingress_ipv4.is_empty()
&& self.ingress_ipv6.is_empty()
{
return Err(
"[server.tls.acme.custom_domains] needs somewhere for tenants to point DNS: set \
ingress_hostname (the CNAME target for tenant subdomains) and/or ingress_ipv4 / \
ingress_ipv6 (the A/AAAA records apex domains need). Without one, no tenant can \
be given usable DNS instructions and no domain can ever verify"
.to_owned(),
);
}
self.validate_ingress_hostname()?;
for (key, values) in [
("ingress_ipv4", &self.ingress_ipv4),
("ingress_ipv6", &self.ingress_ipv6),
] {
for value in values {
let parsed = if key == "ingress_ipv4" {
value.trim().parse::<std::net::Ipv4Addr>().is_ok()
} else {
value.trim().parse::<std::net::Ipv6Addr>().is_ok()
};
if !parsed {
return Err(format!(
"[server.tls.acme.custom_domains] {key} entry `{value}` is not a valid \
address; a tenant would be given a DNS record that points nowhere"
));
}
}
}
if self.max_domains == 0 {
return Err(
"[server.tls.acme.custom_domains] max_domains must be at least 1; 0 rejects every \
registration"
.to_owned(),
);
}
if self.cert_cache_size == 0 {
return Err(
"[server.tls.acme.custom_domains] cert_cache_size must be at least 1: a zero-sized \
cache would re-read every certificate on every handshake"
.to_owned(),
);
}
if self.issuance_per_domain_per_day == 0 || self.issuance_global_per_hour == 0 {
return Err(
"[server.tls.acme.custom_domains] issuance_per_domain_per_day and \
issuance_global_per_hour must both be at least 1; 0 refuses every order and no \
domain can ever become active"
.to_owned(),
);
}
if self.failure_backoff_secs == 0 {
return Err(
"[server.tls.acme.custom_domains] failure_backoff_secs must be at least 1: a zero \
backoff retries a permanently broken domain every tick and burns the CA's rate \
limits"
.to_owned(),
);
}
if self.max_failure_backoff_secs < self.failure_backoff_secs {
return Err(format!(
"[server.tls.acme.custom_domains] max_failure_backoff_secs ({}) must be at least \
failure_backoff_secs ({}): the cap is applied to the doubling delay, so a smaller \
cap silently disables the backoff",
self.max_failure_backoff_secs, self.failure_backoff_secs
));
}
if self.poll_interval_secs == 0 {
return Err(
"[server.tls.acme.custom_domains] poll_interval_secs must be at least 1; 0 spins \
the orchestrator"
.to_owned(),
);
}
if self.resolvers.is_empty() {
return Err(
"[server.tls.acme.custom_domains] resolvers must list at least one DNS resolver: \
without one no ownership TXT record can be read, so no domain can verify"
.to_owned(),
);
}
self.resolver_addrs()?;
Ok(())
}
#[must_use]
pub fn ingress(&self) -> crate::custom_domain::ExpectedIngress {
crate::custom_domain::ExpectedIngress {
hostname: self
.ingress_hostname
.as_ref()
.and_then(|h| crate::custom_domain::normalize_hostname(h).ok()),
ipv4: self
.ingress_ipv4
.iter()
.filter_map(|a| a.trim().parse().ok())
.collect(),
ipv6: self
.ingress_ipv6
.iter()
.filter_map(|a| a.trim().parse().ok())
.collect(),
}
}
}
impl AcmeConfig {
pub fn validate(&self) -> Result<(), String> {
if self.domains.is_empty() {
return Err(
"[server.tls.acme] domains must list at least one domain to request a \
certificate for"
.to_owned(),
);
}
if self.contact_email.trim().is_empty() {
return Err(
"[server.tls.acme] contact_email must be set (the ACME CA requires an account \
contact for expiry notifications)"
.to_owned(),
);
}
if self.http_challenge_port == 0 {
return Err(
"[server.tls.acme] http_challenge_port must not be 0: port 0 binds an ephemeral \
OS-assigned port that the ACME HTTP-01 validator (which always connects on port \
80) can never reach, so every issuance fails. Use 80, or the port a front-end \
forwards `:80` to"
.to_owned(),
);
}
if self.renew_before_days >= 90 {
return Err(format!(
"[server.tls.acme] renew_before_days ({}) must be less than 90: it is compared \
against the issued certificate's remaining validity, and publicly-trusted CAs \
(e.g. Let's Encrypt) issue certificates that live at most ~90 days. A value >= \
the certificate lifetime keeps the cert perpetually inside its renew-before \
window, so the renewal loop would order a fresh certificate every hour and burn \
the CA's rate limits. Use a smaller value (default 30)",
self.renew_before_days
));
}
if let Some(path) = &self.ca_root_path
&& path.to_str().is_none_or(|p| p.trim().is_empty())
{
return Err(
"[server.tls.acme] ca_root_path is set but blank: either remove it (to use the \
platform trust store, which is correct for Let's Encrypt staging and \
production) or point it at the PEM root that signs your ACME directory's HTTPS \
certificate"
.to_owned(),
);
}
for (index, domain) in self.domains.iter().enumerate() {
let trimmed = domain.trim();
if trimmed.is_empty() {
return Err(format!(
"[server.tls.acme] domains must not contain blank entries (entry at index \
{index} is empty or whitespace-only)"
));
}
if trimmed.contains('*') {
if self.dns.is_none() {
return Err(format!(
"[server.tls.acme] wildcard domain `{trimmed}` needs the DNS-01 \
challenge: an ACME CA will not validate a wildcard identifier over \
HTTP-01. Add a [server.tls.acme.dns] section naming your DNS provider \
(and the credentials-store key holding its API token), or list explicit \
hostnames instead"
));
}
let Some(base) = trimmed.strip_prefix("*.") else {
return Err(format!(
"[server.tls.acme] domain `{trimmed}` is not a usable wildcard: a \
wildcard SAN must be written as `*.` followed by the base domain (e.g. \
`*.myapp.com`) — a `*` anywhere else is not matched by any client"
));
};
if base.is_empty() || base.contains('*') {
return Err(format!(
"[server.tls.acme] domain `{trimmed}` is not a usable wildcard: exactly \
one leading `*.` is allowed and the base domain after it must be \
non-empty (e.g. `*.myapp.com`)"
));
}
}
if domain != trimmed {
return Err(format!(
"[server.tls.acme] domain `{domain}` (entry at index {index}) has leading or \
trailing whitespace: the entry is used verbatim as the certificate's SAN and \
as the ACME order's DNS identifier, so the padded value would be requested \
as-is. Write it as `{trimmed}`"
));
}
}
if let Some(dns) = &self.dns {
dns.validate()?;
}
if let Some(custom) = &self.custom_domains {
custom.validate()?;
}
Ok(())
}
#[must_use]
pub fn covers_host(&self, host: &str) -> bool {
self.domains.iter().any(|san| san_covers_host(san, host))
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct AcmeDnsConfig {
pub provider: AcmeDnsProvider,
#[serde(default = "default_acme_dns_credential")]
pub credential: String,
#[serde(default = "default_acme_dns_propagation_timeout_secs")]
pub propagation_timeout_secs: u64,
#[serde(default = "default_acme_dns_poll_interval_secs")]
pub poll_interval_secs: u64,
#[serde(default = "default_acme_dns_resolvers")]
pub resolvers: Vec<String>,
#[serde(default)]
pub command: Vec<String>,
}
impl AcmeDnsConfig {
pub fn resolver_addrs(&self) -> Result<Vec<std::net::SocketAddr>, String> {
self.resolvers
.iter()
.map(|entry| parse_resolver_addr("[server.tls.acme.dns]", entry))
.collect()
}
pub fn validate(&self) -> Result<(), String> {
if self.credential.trim().is_empty() {
return Err(
"[server.tls.acme.dns] credential must name the key in the encrypted \
credentials store that holds the DNS provider's API credential (run \
`autumn credentials edit`). It is a key NAME, never the token itself"
.to_owned(),
);
}
match self.provider {
AcmeDnsProvider::Exec => {
if self
.command
.first()
.is_some_and(|program| !program.trim().is_empty() && program != program.trim())
{
return Err(format!(
"[server.tls.acme.dns] command's program `{}` has leading or trailing \
whitespace: it is passed to the OS verbatim, so the padded value would \
fail to execute. Write it without the padding",
self.command[0]
));
}
if self.command.first().is_none_or(|p| p.trim().is_empty()) {
return Err(
"[server.tls.acme.dns] provider = \"exec\" requires a non-empty `command` \
argv array (e.g. command = [\"/usr/local/bin/dns-hook\"]); autumn appends \
`present`/`cleanup`, the record FQDN and the TXT value as further \
arguments"
.to_owned(),
);
}
}
AcmeDnsProvider::Cloudflare | AcmeDnsProvider::Route53 => {
if !self.command.is_empty() {
return Err(format!(
"[server.tls.acme.dns] command is set but provider is \"{}\": the command \
would never run. Remove it, or set provider = \"exec\" to use the \
external-hook escape hatch",
self.provider.as_str()
));
}
}
}
if self.propagation_timeout_secs == 0 {
return Err(
"[server.tls.acme.dns] propagation_timeout_secs must not be 0: a zero budget \
fails every order before a freshly-written TXT record could ever be visible. \
Use the default (300) or a larger value for a slow-propagating zone"
.to_owned(),
);
}
if self.poll_interval_secs == 0 {
return Err(
"[server.tls.acme.dns] poll_interval_secs must not be 0: it would busy-loop the \
configured resolvers. Use the default (5)"
.to_owned(),
);
}
if self.propagation_timeout_secs > MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS {
return Err(format!(
"[server.tls.acme.dns] propagation_timeout_secs ({}) must be at most {}: the wait \
is computed as an instant `now + timeout`, and an unbounded value overflows that \
and panics the renewal task, leaving the self-signed placeholder served \
indefinitely. An hour is far past any real provider's propagation time",
self.propagation_timeout_secs, MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS
));
}
if self.poll_interval_secs > self.propagation_timeout_secs {
return Err(format!(
"[server.tls.acme.dns] poll_interval_secs ({}) is greater than \
propagation_timeout_secs ({}): the record would be probed once and the wait \
would time out without ever re-checking. Lower poll_interval_secs",
self.poll_interval_secs, self.propagation_timeout_secs
));
}
if self.resolvers.is_empty() {
return Err(
"[server.tls.acme.dns] resolvers must list at least one DNS resolver to confirm \
TXT propagation against (default: 1.1.1.1:53 and 8.8.8.8:53)"
.to_owned(),
);
}
self.resolver_addrs()?;
Ok(())
}
}
fn parse_resolver_addr(section: &str, entry: &str) -> Result<std::net::SocketAddr, String> {
let trimmed = entry.trim();
if let Ok(addr) = trimmed.parse::<std::net::SocketAddr>() {
return Ok(addr);
}
if let Ok(ip) = trimmed.parse::<std::net::IpAddr>() {
return Ok(std::net::SocketAddr::new(ip, 53));
}
Err(format!(
"{section} resolvers entry `{entry}` is not a resolver address: write it as an IP \
(`1.1.1.1`, port 53 implied) or `IP:port` (`1.1.1.1:53`). Hostnames are not accepted — \
resolving the resolver would defeat the purpose of the DNS check"
))
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum AcmeDnsProvider {
Cloudflare,
Route53,
Exec,
}
impl AcmeDnsProvider {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Cloudflare => "cloudflare",
Self::Route53 => "route53",
Self::Exec => "exec",
}
}
}
impl std::fmt::Display for AcmeDnsProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[must_use]
pub fn san_covers_host(san: &str, host: &str) -> bool {
let san = san.trim().trim_end_matches('.').to_ascii_lowercase();
let host = host.trim().trim_end_matches('.').to_ascii_lowercase();
if san.is_empty() || host.is_empty() {
return false;
}
let Some(suffix) = san.strip_prefix("*.") else {
return san == host;
};
if suffix.is_empty() {
return false;
}
let Some(label) = host
.strip_suffix(&suffix)
.and_then(|prefix| prefix.strip_suffix('.'))
else {
return false;
};
!label.is_empty() && !label.contains('.')
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AcmeDirectory {
#[default]
Staging,
Production,
Custom {
url: String,
},
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReplicaFallback {
#[default]
FailReadiness,
Primary,
}
impl std::str::FromStr for ReplicaFallback {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"fail_readiness" | "fail-readiness" | "fail" => Ok(Self::FailReadiness),
"primary" | "fallback_to_primary" | "fallback-to-primary" => Ok(Self::Primary),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ReadYourWrites {
#[default]
Off,
Request,
Session,
}
impl std::str::FromStr for ReadYourWrites {
type Err = ();
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value.trim().to_ascii_lowercase().as_str() {
"off" => Ok(Self::Off),
"request" => Ok(Self::Request),
"session" => Ok(Self::Session),
_ => Err(()),
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum SlotSpec {
Index(u16),
Range(String),
}
impl SlotSpec {
pub fn expand(&self) -> Result<Vec<u16>, String> {
match self {
Self::Index(slot) => Ok(vec![*slot]),
Self::Range(spec) => {
let spec = spec.trim();
let parse = |s: &str| {
s.trim()
.parse::<u16>()
.map_err(|_| format!("invalid slot {s:?} in {spec:?}"))
};
match spec.split_once('-') {
None => Ok(vec![parse(spec)?]),
Some((start, end)) => {
let (start, end) = (parse(start)?, parse(end)?);
if start > end {
return Err(format!("inverted slot range {spec:?}"));
}
Ok((start..=end).collect())
}
}
}
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ShardConfig {
pub name: String,
pub primary_url: String,
#[serde(default)]
pub slots: Option<Vec<SlotSpec>>,
#[serde(default)]
pub replica_url: Option<String>,
#[serde(default)]
pub primary_pool_size: Option<usize>,
#[serde(default)]
pub replica_pool_size: Option<usize>,
#[serde(default)]
pub replica_fallback: Option<ReplicaFallback>,
}
impl ShardConfig {
#[must_use]
pub fn effective_primary_pool_size(&self, defaults: &DatabaseConfig) -> usize {
self.primary_pool_size
.unwrap_or_else(|| defaults.effective_primary_pool_size())
}
#[must_use]
pub fn effective_replica_pool_size(&self, defaults: &DatabaseConfig) -> usize {
self.replica_pool_size
.unwrap_or_else(|| defaults.effective_replica_pool_size())
}
#[must_use]
pub fn effective_replica_fallback(&self, defaults: &DatabaseConfig) -> ReplicaFallback {
self.replica_fallback.unwrap_or(defaults.replica_fallback)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DatabaseBackend {
Postgres,
Sqlite,
}
impl DatabaseBackend {
#[must_use]
pub fn detect(target: &str) -> Option<Self> {
if is_sqlite_target(target) {
Some(Self::Sqlite)
} else if is_pg_connection_string(target) {
Some(Self::Postgres)
} else {
None
}
}
const fn as_str(self) -> &'static str {
match self {
Self::Postgres => "postgres",
Self::Sqlite => "sqlite",
}
}
}
impl std::fmt::Display for DatabaseBackend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
fn is_sqlite_target(s: &str) -> bool {
s.starts_with("sqlite:") || s.starts_with("file:")
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseConfig {
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub primary_url: Option<String>,
#[serde(default)]
pub replica_url: Option<String>,
#[serde(default = "default_pool_size")]
pub pool_size: usize,
#[serde(default)]
pub primary_pool_size: Option<usize>,
#[serde(default)]
pub replica_pool_size: Option<usize>,
#[serde(default)]
pub replica_fallback: ReplicaFallback,
#[serde(default)]
pub read_your_writes: ReadYourWrites,
#[serde(default = "default_pin_after_write_secs")]
pub pin_after_write_secs: u64,
#[serde(default)]
pub replica_max_lag_ms: Option<u64>,
#[serde(default = "default_warn_on_pooler")]
pub warn_on_pooler: bool,
#[serde(default = "default_connect_timeout")]
pub connect_timeout_secs: u64,
#[serde(default)]
pub startup_wait_secs: u64,
#[serde(default)]
pub auto_migrate: Option<bool>,
#[serde(default)]
pub auto_migrate_in_production: bool,
#[serde(deserialize_with = "deserialize_option_duration", default)]
pub statement_timeout: Option<std::time::Duration>,
#[serde(deserialize_with = "deserialize_option_duration", default)]
pub idle_in_transaction_timeout: Option<std::time::Duration>,
#[serde(
deserialize_with = "deserialize_duration",
default = "default_migration_lock_timeout"
)]
pub migration_lock_timeout: std::time::Duration,
#[serde(default = "default_migration_lock_retries")]
pub migration_lock_retries: u32,
#[serde(
deserialize_with = "deserialize_duration",
default = "default_slow_query_threshold"
)]
pub slow_query_threshold: std::time::Duration,
#[serde(default)]
pub shards: Vec<ShardConfig>,
#[serde(default)]
pub directory_shard_router: bool,
#[serde(default = "default_max_connections_warn_threshold")]
pub max_connections_warn_threshold: usize,
#[serde(default)]
pub fleet: Option<DatabaseFleetConfig>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct DatabaseFleetConfig {
pub mode: crate::fleet_layout::FleetMode,
pub root: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default = "default_fleet_max_open")]
pub max_open: usize,
#[serde(default = "default_fleet_pool_size")]
pub pool_size: usize,
#[serde(default)]
pub create_on_demand: Option<bool>,
#[serde(default = "default_fleet_idle_close_secs")]
pub idle_close_secs: u64,
#[serde(default)]
pub restore_missing: bool,
#[serde(default)]
pub ownership: Option<FleetOwnershipConfig>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct FleetOwnershipConfig {
pub owner: String,
#[serde(default)]
pub slots: Vec<SlotSpec>,
#[serde(default = "default_fleet_lease_secs")]
pub lease_secs: u64,
}
impl FleetOwnershipConfig {
pub fn ranges(&self) -> Result<Vec<(u16, u16)>, String> {
if self.slots.is_empty() {
return Ok(vec![(0, SLOT_COUNT - 1)]);
}
let mut ranges = Vec::with_capacity(self.slots.len());
for spec in &self.slots {
let range = match spec {
SlotSpec::Index(slot) => (*slot, *slot),
SlotSpec::Range(text) => {
let slots = spec.expand()?;
match (slots.first(), slots.last()) {
(Some(start), Some(end)) => (*start, *end),
_ => return Err(format!("empty slot range {text:?}")),
}
}
};
if range.1 >= SLOT_COUNT {
return Err(format!(
"slot {} is out of range (0-{})",
range.1,
SLOT_COUNT - 1
));
}
ranges.push(range);
}
ranges.sort_unstable();
if let Some(pair) = ranges.windows(2).find(|pair| pair[0].1 >= pair[1].0) {
return Err(format!(
"slot ranges {}-{} and {}-{} overlap",
pair[0].0, pair[0].1, pair[1].0, pair[1].1
));
}
Ok(ranges)
}
pub fn validate(&self) -> Result<(), ConfigError> {
let owner = &self.owner;
if owner.is_empty()
|| owner.len() > 128
|| owner.chars().any(char::is_control)
|| owner.trim() != owner
{
return Err(ConfigError::Validation(
"database.fleet.ownership.owner must be 1 to 128 bytes, with no control \
characters and no white space at the start or end"
.to_owned(),
));
}
self.ranges()
.map_err(|e| ConfigError::Validation(format!("database.fleet.ownership.slots: {e}")))?;
if !(3..=3600).contains(&self.lease_secs) {
return Err(ConfigError::Validation(
"database.fleet.ownership.lease_secs must be 3 to 3600".to_owned(),
));
}
Ok(())
}
}
const fn default_fleet_lease_secs() -> u64 {
30
}
impl DatabaseFleetConfig {
#[must_use]
pub fn path_template(&self) -> &str {
self.path
.as_deref()
.unwrap_or_else(|| self.mode.default_path())
}
#[must_use]
pub fn effective_create_on_demand(&self) -> bool {
self.create_on_demand
.unwrap_or_else(|| self.mode.default_create_on_demand())
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.root.trim().is_empty() {
return Err(ConfigError::Validation(
"database.fleet.root must name a directory".to_owned(),
));
}
if self.root.starts_with("sqlite:") || self.root.starts_with("file:") {
return Err(ConfigError::Validation(format!(
"database.fleet.root {:?} is a directory, not a database URL",
self.root
)));
}
crate::fleet_layout::FleetPathTemplate::parse(self.path_template(), self.mode)
.map_err(|e| ConfigError::Validation(e.to_string()))?;
if self.max_open == 0 {
return Err(ConfigError::Validation(
"database.fleet.max_open must be at least 1".to_owned(),
));
}
if self.pool_size == 0 {
return Err(ConfigError::Validation(
"database.fleet.pool_size must be at least 1".to_owned(),
));
}
if let Some(ownership) = &self.ownership {
ownership.validate()?;
}
Ok(())
}
}
const fn default_fleet_max_open() -> usize {
256
}
const fn default_fleet_pool_size() -> usize {
2
}
const fn default_fleet_idle_close_secs() -> u64 {
300
}
pub(crate) const fn should_warn_total_connections(total: usize, threshold: usize) -> bool {
threshold != 0 && total >= threshold
}
fn format_slot_ranges(slots: &[usize]) -> String {
fn render(start: usize, end: usize) -> String {
if start == end {
start.to_string()
} else {
format!("{start}-{end}")
}
}
let mut ranges: Vec<String> = Vec::new();
let mut iter = slots.iter().copied();
let Some(mut start) = iter.next() else {
return String::new();
};
let mut end = start;
for slot in iter {
if slot != end + 1 {
ranges.push(render(start, end));
start = slot;
}
end = slot;
}
ranges.push(render(start, end));
ranges.join(", ")
}
pub const SLOT_COUNT: u16 = 16384;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShardSlotAssignment {
pub name: String,
pub ranges: String,
}
pub fn check_stored_slot_map(
auto_split: bool,
computed: &[ShardSlotAssignment],
stored: Option<&[ShardSlotAssignment]>,
) -> Result<(), String> {
fn to_map(assignments: &[ShardSlotAssignment]) -> std::collections::BTreeMap<&str, &str> {
assignments
.iter()
.map(|a| (a.name.as_str(), a.ranges.as_str()))
.collect()
}
if !auto_split {
return Ok(());
}
let Some(stored) = stored else {
return Ok(());
};
if to_map(computed) == to_map(stored) {
return Ok(());
}
let computed_names: Vec<&str> = computed.iter().map(|a| a.name.as_str()).collect();
let stored_names: Vec<&str> = stored.iter().map(|a| a.name.as_str()).collect();
Err(format!(
"shard slot map mismatch — auto-split with {} shards ({}) produces a different \
map than the stored map ({} shards: {}). Set explicit [[database.shards]] slot \
ranges matching the stored map, then move data between shards deliberately \
before changing the topology.",
computed.len(),
computed_names.join(", "),
stored.len(),
stored_names.join(", "),
))
}
pub fn database_backend_consistency(
url: Option<&str>,
primary_url: Option<&str>,
replica_url: Option<&str>,
has_shards: bool,
) -> Result<(), String> {
let Some(primary_backend) = primary_url.or(url).and_then(DatabaseBackend::detect) else {
return Ok(());
};
if primary_backend == DatabaseBackend::Sqlite {
if replica_url.is_some() {
return Err(
"database.replica_url is set but the primary target is SQLite; \
read replicas require the postgres backend"
.to_owned(),
);
}
if has_shards {
return Err(
"database.shards are configured but the primary target is SQLite; \
database shards require the postgres backend"
.to_owned(),
);
}
}
for (field, url) in [("database.url", url), ("database.replica_url", replica_url)] {
if let Some(url) = url
&& DatabaseBackend::detect(url) != Some(primary_backend)
{
return Err(format!(
"{field} does not match the primary database backend \
({primary_backend}); every configured database role must use \
the same backend"
));
}
}
Ok(())
}
impl DatabaseConfig {
#[must_use]
pub fn effective_primary_url(&self) -> Option<&str> {
self.primary_url.as_deref().or(self.url.as_deref())
}
#[must_use]
pub fn effective_primary_postgres_url(&self) -> Option<&str> {
self.effective_primary_url()
.filter(|url| DatabaseBackend::detect(url) == Some(DatabaseBackend::Postgres))
}
#[must_use]
pub fn effective_primary_pool_size(&self) -> usize {
self.primary_pool_size.unwrap_or(self.pool_size)
}
#[must_use]
pub fn effective_replica_pool_size(&self) -> usize {
self.replica_pool_size.unwrap_or(self.pool_size)
}
#[must_use]
pub const fn has_shards(&self) -> bool {
!self.shards.is_empty()
}
pub fn resolved_slot_map(&self) -> Result<Vec<usize>, ConfigError> {
let slot_count = usize::from(SLOT_COUNT);
if self.shards.is_empty() {
return Ok(Vec::new());
}
let declared = self.shards.iter().filter(|s| s.slots.is_some()).count();
if declared != 0 && declared != self.shards.len() {
return Err(ConfigError::Validation(
"database.shards: either every shard must declare `slots` or none may \
(mixing explicit and auto-assigned slots is ambiguous)"
.to_owned(),
));
}
if declared == 0 {
if self.shards.len() > slot_count {
return Err(ConfigError::Validation(format!(
"database.shards: at most {slot_count} shards are supported \
(one per logical slot), got {}",
self.shards.len()
)));
}
let n = self.shards.len();
return Ok((0..slot_count).map(|slot| slot * n / slot_count).collect());
}
let mut map: Vec<Option<usize>> = vec![None; slot_count];
for (idx, shard) in self.shards.iter().enumerate() {
let specs = shard.slots.as_deref().unwrap_or_default();
for spec in specs {
let slots = spec.expand().map_err(|e| {
ConfigError::Validation(format!("database.shards[{idx}].slots: {e}"))
})?;
for slot in slots {
if usize::from(slot) >= slot_count {
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].slots: slot {slot} is out of range \
(slots are 0..{slot_count})"
)));
}
if let Some(owner) = map[usize::from(slot)] {
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].slots: slot {slot} is already owned \
by shard {:?}",
self.shards[owner].name
)));
}
map[usize::from(slot)] = Some(idx);
}
}
}
let unassigned: Vec<usize> = map
.iter()
.enumerate()
.filter_map(|(slot, owner)| owner.is_none().then_some(slot))
.collect();
if !unassigned.is_empty() {
return Err(ConfigError::Validation(format!(
"database.shards: slot map must cover every slot in 0..{slot_count}; \
unassigned slots: {}",
format_slot_ranges(&unassigned)
)));
}
Ok(map.into_iter().flatten().collect())
}
#[must_use]
pub fn shards_auto_split(&self) -> bool {
self.has_shards() && self.shards.iter().all(|s| s.slots.is_none())
}
pub fn resolved_shard_assignments(&self) -> Result<Vec<ShardSlotAssignment>, ConfigError> {
let slot_map = self.resolved_slot_map()?;
let n = self.shards.len();
let mut per_shard: Vec<Vec<usize>> = vec![Vec::new(); n];
for (slot, &owner) in slot_map.iter().enumerate() {
per_shard[owner].push(slot);
}
Ok(self
.shards
.iter()
.enumerate()
.map(|(idx, shard)| ShardSlotAssignment {
name: shard.name.clone(),
ranges: format_slot_ranges(&per_shard[idx]),
})
.collect())
}
fn validate_backend_consistency(&self) -> Result<(), ConfigError> {
database_backend_consistency(
self.url.as_deref(),
self.primary_url.as_deref(),
self.replica_url.as_deref(),
!self.shards.is_empty(),
)
.map_err(ConfigError::Validation)
}
pub fn validate(&self) -> Result<(), ConfigError> {
for (field, url) in [
("database.url", self.url.as_deref()),
("database.primary_url", self.primary_url.as_deref()),
("database.replica_url", self.replica_url.as_deref()),
] {
if let Some(url) = url
&& DatabaseBackend::detect(url).is_none()
{
let label = if field == "database.url" {
"database URL"
} else {
field
};
let target = crate::db_url::redact_target(url);
return Err(ConfigError::Validation(format!(
"Invalid {label}: must start with postgres:// or postgresql://, or be a \
keyword/value connection string \
(e.g. \"host=db user=app dbname=app sslmode=require\"), got {target:?}"
)));
}
}
if self.replica_url.is_some() && self.effective_primary_url().is_none() {
return Err(ConfigError::Validation(
"database.replica_url requires database.primary_url or database.url".to_owned(),
));
}
self.validate_backend_consistency()?;
let mut seen_names = std::collections::HashSet::new();
for (idx, shard) in self.shards.iter().enumerate() {
if shard.name.is_empty() {
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].name must not be empty"
)));
}
if !shard
.name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
{
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].name {:?} is invalid: shard names are used in \
metric tags and health component names and must match [a-z0-9_-]",
shard.name
)));
}
if !seen_names.insert(shard.name.as_str()) {
return Err(ConfigError::Validation(format!(
"database.shards[{idx}].name {:?} is declared more than once; \
shard names must be unique",
shard.name
)));
}
for (field, url) in [
("primary_url", Some(shard.primary_url.as_str())),
("replica_url", shard.replica_url.as_deref()),
] {
if let Some(url) = url
&& !is_pg_connection_string(url)
{
let target = crate::db_url::redact_target(url);
return Err(ConfigError::Validation(format!(
"Invalid database.shards[{idx}].{field}: must start with \
postgres:// or postgresql://, or be a keyword/value \
connection string \
(e.g. \"host=db user=app dbname=app sslmode=require\"), got {target:?}"
)));
}
}
}
self.resolved_slot_map()?;
self.validate_fleet()?;
Ok(())
}
fn validate_fleet(&self) -> Result<(), ConfigError> {
let Some(fleet) = &self.fleet else {
return Ok(());
};
fleet.validate()?;
if self.has_shards() {
return Err(ConfigError::Validation(
"database.fleet and [[database.shards]] are both configured; a fleet routes \
tenant data to its own SQLite files, so declare one or the other"
.to_owned(),
));
}
if self.directory_shard_router {
return Err(ConfigError::Validation(
"database.directory_shard_router is set with database.fleet; the directory \
router is a Postgres control table, and a fleet routes by its mode"
.to_owned(),
));
}
let control = self.effective_primary_url();
if control.and_then(DatabaseBackend::detect) != Some(DatabaseBackend::Sqlite)
|| control.is_some_and(is_in_memory_sqlite_target)
{
return Err(ConfigError::Validation(
"database.fleet needs a file-backed sqlite: control database in database.url; \
framework state (sessions, jobs, flags) lives there while tenant data lives \
in the fleet"
.to_owned(),
));
}
if let Some(control_file) = control.and_then(sqlite_url_file) {
let root = lexical_absolute(Path::new(&fleet.root));
if control_file.starts_with(&root) {
return Err(ConfigError::Validation(format!(
"the control database {} is inside database.fleet.root {}; a tenant or \
slot path could resolve to it and migrate it as a tenant. Put the \
control database outside the fleet root",
control_file.display(),
root.display()
)));
}
}
Ok(())
}
}
pub(crate) fn sqlite_url_file(url: &str) -> Option<PathBuf> {
if is_in_memory_sqlite_target(url) {
return None;
}
let rest = url
.strip_prefix("sqlite://")
.or_else(|| url.strip_prefix("sqlite:"))
.unwrap_or(url);
let path = rest.strip_prefix("file:").map_or_else(
|| rest.to_owned(),
|uri| {
let uri = uri.strip_prefix("//").map_or(uri, |authority_and_path| {
let (authority, path) = authority_and_path.split_at(
authority_and_path
.find('/')
.unwrap_or(authority_and_path.len()),
);
if authority.is_empty() || authority.eq_ignore_ascii_case("localhost") {
path
} else {
""
}
});
percent_decode_uri_path(uri.split(['?', '#']).next().unwrap_or_default())
},
);
(!path.is_empty()).then(|| lexical_absolute(Path::new(&path)))
}
fn percent_decode_uri_path(input: &str) -> String {
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
let decoded = (bytes[i] == b'%')
.then(|| bytes.get(i + 1..i + 3))
.flatten()
.filter(|hex| hex.iter().all(u8::is_ascii_hexdigit))
.and_then(|hex| std::str::from_utf8(hex).ok())
.and_then(|hex| u8::from_str_radix(hex, 16).ok());
match decoded {
Some(0) => break,
Some(byte) => {
out.push(byte);
i += 3;
}
None => {
out.push(bytes[i]);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn lexical_absolute(path: &Path) -> PathBuf {
let joined = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_or_else(|_| path.to_path_buf(), |cwd| cwd.join(path))
};
let mut out = PathBuf::new();
for part in joined.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
fn is_pg_connection_string(s: &str) -> bool {
crate::pg_conn_str::is_url(s) || crate::pg_conn_str::is_keyword_value(s)
}
#[derive(Debug, Clone, Deserialize)]
pub struct LogConfig {
#[serde(default = "default_log_level")]
pub level: String,
#[serde(default)]
pub format: LogFormat,
#[serde(default)]
pub filter_parameters: Vec<String>,
#[serde(default)]
pub unfilter_parameters: Vec<String>,
#[serde(default = "default_access_log")]
pub access_log: bool,
#[serde(default = "default_access_log_exclude")]
pub access_log_exclude: Vec<String>,
#[serde(default)]
pub capture: crate::log::capture::LogCaptureConfig,
}
#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum LogFormat {
#[default]
Auto,
Pretty,
Json,
}
#[derive(Debug, Clone, Deserialize)]
pub struct TelemetryConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_telemetry_service_name")]
pub service_name: String,
#[serde(default)]
pub service_namespace: Option<String>,
#[serde(default = "default_telemetry_service_version")]
pub service_version: String,
#[serde(default = "default_telemetry_environment")]
pub environment: String,
#[serde(default)]
pub otlp_endpoint: Option<String>,
#[serde(default)]
pub protocol: TelemetryProtocol,
#[serde(default)]
pub strict: bool,
#[serde(default = "default_telemetry_sample_ratio")]
pub sample_ratio: f64,
}
const fn default_telemetry_sample_ratio() -> f64 {
1.0
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub enum TelemetryProtocol {
#[serde(alias = "grpc", alias = "GRPC")]
#[default]
Grpc,
#[serde(
alias = "http-protobuf",
alias = "http_protobuf",
alias = "HTTP_PROTOBUF"
)]
HttpProtobuf,
}
impl TelemetryProtocol {
fn from_env_value(value: &str) -> Option<Self> {
match value {
"Grpc" | "grpc" | "GRPC" => Some(Self::Grpc),
"HttpProtobuf" | "http-protobuf" | "http_protobuf" | "HTTP_PROTOBUF"
| "httpprotobuf" => Some(Self::HttpProtobuf),
_ => None,
}
}
}
#[allow(
clippy::struct_excessive_bools,
reason = "independent on/off switches of one config section"
)]
#[derive(Debug, Clone, Deserialize)]
pub struct HealthConfig {
#[serde(default = "default_health_enabled")]
pub enabled: bool,
#[serde(default = "default_health_path")]
pub path: String,
#[serde(default = "default_live_path")]
pub live_path: String,
#[serde(default = "default_ready_path")]
pub ready_path: String,
#[serde(default = "default_startup_path")]
pub startup_path: String,
#[serde(default)]
pub detailed: bool,
#[serde(default = "default_health_cache_ttl_ms")]
pub cache_ttl_ms: u64,
#[serde(default = "default_health_ping_timeout_ms")]
pub ping_timeout_ms: u64,
#[serde(default = "default_health_db_readiness")]
pub db_readiness: bool,
#[serde(default)]
pub redis_readiness: bool,
}
impl HealthConfig {
#[must_use]
pub const fn cache_ttl(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.cache_ttl_ms)
}
#[must_use]
pub const fn ping_timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.ping_timeout_ms)
}
pub fn validate(&self) -> Result<(), ConfigError> {
if self.ping_timeout_ms == 0 {
return Err(ConfigError::Validation(
"health.ping_timeout_ms must be greater than 0: a zero limit fails every \
database and Redis ping, so /ready never returns 200"
.to_owned(),
));
}
Ok(())
}
}
const fn default_health_db_readiness() -> bool {
true
}
const fn default_health_cache_ttl_ms() -> u64 {
1_000
}
const fn default_health_ping_timeout_ms() -> u64 {
2_000
}
#[derive(Debug, Clone, Deserialize)]
pub struct ActuatorConfig {
#[serde(default = "default_actuator_prefix")]
pub prefix: String,
#[serde(default)]
pub sensitive: bool,
#[serde(default = "default_actuator_prometheus")]
pub prometheus: bool,
}
impl Default for ActuatorConfig {
fn default() -> Self {
Self {
prefix: default_actuator_prefix(),
sensitive: false,
prometheus: default_actuator_prometheus(),
}
}
}
fn default_actuator_prefix() -> String {
"/actuator".to_owned()
}
const fn default_actuator_prometheus() -> bool {
true
}
#[derive(Debug, Clone, Deserialize)]
pub struct MetricsConfig {
#[serde(default = "default_max_series_per_metric")]
pub max_series_per_metric: usize,
#[serde(default = "default_max_instruments")]
pub max_instruments: usize,
#[serde(default = "default_max_labels_per_series")]
pub max_labels_per_series: usize,
}
impl Default for MetricsConfig {
fn default() -> Self {
Self {
max_series_per_metric: default_max_series_per_metric(),
max_instruments: default_max_instruments(),
max_labels_per_series: default_max_labels_per_series(),
}
}
}
impl MetricsConfig {
#[must_use]
pub const fn limits(&self) -> crate::metrics::Limits {
crate::metrics::Limits {
max_series_per_metric: self.max_series_per_metric,
max_instruments: self.max_instruments,
max_labels_per_series: self.max_labels_per_series,
}
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.limits().validate().map_err(ConfigError::Validation)
}
}
const fn default_max_series_per_metric() -> usize {
crate::metrics::DEFAULT_MAX_SERIES_PER_METRIC
}
const fn default_max_instruments() -> usize {
crate::metrics::DEFAULT_MAX_INSTRUMENTS
}
const fn default_max_labels_per_series() -> usize {
crate::metrics::DEFAULT_MAX_LABELS_PER_SERIES
}
#[derive(Debug, Clone, Deserialize)]
pub struct CorsConfig {
#[serde(default)]
pub allowed_origins: Vec<String>,
#[serde(default = "default_cors_methods")]
pub allowed_methods: Vec<String>,
#[serde(default = "default_cors_headers")]
pub allowed_headers: Vec<String>,
#[serde(default)]
pub allow_credentials: bool,
#[serde(default = "default_cors_max_age")]
pub max_age_secs: u64,
}
impl Default for CorsConfig {
fn default() -> Self {
Self {
allowed_origins: Vec::new(),
allowed_methods: default_cors_methods(),
allowed_headers: default_cors_headers(),
allow_credentials: false,
max_age_secs: default_cors_max_age(),
}
}
}
impl CorsConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") {
return Err(ConfigError::Validation(
"CORS: allow_credentials=true is incompatible with allowed_origins=[\"*\"]; \
list explicit origins instead (browsers reject the wildcard+credentials combo)"
.to_owned(),
));
}
Ok(())
}
}
fn default_cors_methods() -> Vec<String> {
vec![
"GET".to_owned(),
"POST".to_owned(),
"PUT".to_owned(),
"DELETE".to_owned(),
"PATCH".to_owned(),
"OPTIONS".to_owned(),
]
}
fn default_cors_headers() -> Vec<String> {
vec!["Content-Type".to_owned(), "Authorization".to_owned()]
}
const fn default_cors_max_age() -> u64 {
86400
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct CompressionConfig {
#[serde(default)]
pub enabled: bool,
}
#[doc(hidden)]
pub fn apply_deploy_env_overrides(deploy: &mut Option<DeployConfig>, env: &dyn Env) {
const KEYS: [&str; 18] = [
"AUTUMN_DEPLOY__HOST",
"AUTUMN_DEPLOY__HOSTS",
"AUTUMN_DEPLOY__USER",
"AUTUMN_DEPLOY__SSH_PORT",
"AUTUMN_DEPLOY__APP_NAME",
"AUTUMN_DEPLOY__APP_DIR",
"AUTUMN_DEPLOY__SERVICE_NAME",
"AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
"AUTUMN_DEPLOY__KEEP_RELEASES",
"AUTUMN_DEPLOY__PROFILE",
"AUTUMN_DEPLOY__TLS__ENABLED",
"AUTUMN_DEPLOY__TLS__HOST",
"AUTUMN_DEPLOY__INSTALL_PROXY",
"AUTUMN_DEPLOY__BAKE__DURATION_SECS",
"AUTUMN_DEPLOY__BAKE__INTERVAL_SECS",
"AUTUMN_DEPLOY__BAKE__MIN_REQUESTS",
"AUTUMN_DEPLOY__BAKE__MAX_ERROR_RATE",
"AUTUMN_DEPLOY__BAKE__MAX_P99_MS",
];
if !KEYS.iter().any(|key| env.var(key).is_ok()) {
return;
}
let deploy = deploy.get_or_insert_with(DeployConfig::default);
let env_set_host = env
.var("AUTUMN_DEPLOY__HOST")
.is_ok_and(|value| !value.trim().is_empty());
let env_set_hosts = env
.var("AUTUMN_DEPLOY__HOSTS")
.is_ok_and(|value| value.split(',').any(|entry| !entry.trim().is_empty()));
parse_env_option_string(env, "AUTUMN_DEPLOY__HOST", &mut deploy.host);
parse_env_csv_non_empty(env, "AUTUMN_DEPLOY__HOSTS", &mut deploy.hosts);
if env_set_hosts && !env_set_host {
deploy.host = None;
} else if env_set_host && !env_set_hosts {
deploy.hosts.clear();
}
parse_env_string(env, "AUTUMN_DEPLOY__USER", &mut deploy.user);
parse_env(env, "AUTUMN_DEPLOY__SSH_PORT", &mut deploy.ssh_port);
parse_env_option_string(env, "AUTUMN_DEPLOY__APP_NAME", &mut deploy.app_name);
parse_env_option_string(env, "AUTUMN_DEPLOY__APP_DIR", &mut deploy.app_dir);
parse_env_option_string(env, "AUTUMN_DEPLOY__SERVICE_NAME", &mut deploy.service_name);
parse_env(
env,
"AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS",
&mut deploy.readiness_timeout_secs,
);
parse_env(
env,
"AUTUMN_DEPLOY__KEEP_RELEASES",
&mut deploy.keep_releases,
);
parse_env_string(env, "AUTUMN_DEPLOY__PROFILE", &mut deploy.profile);
parse_env_bool(env, "AUTUMN_DEPLOY__TLS__ENABLED", &mut deploy.tls.enabled);
parse_env_option_string(env, "AUTUMN_DEPLOY__TLS__HOST", &mut deploy.tls.host);
parse_env_bool(
env,
"AUTUMN_DEPLOY__INSTALL_PROXY",
&mut deploy.install_proxy,
);
let bake = &mut deploy.bake;
parse_env(
env,
"AUTUMN_DEPLOY__BAKE__DURATION_SECS",
&mut bake.duration_secs,
);
parse_env(
env,
"AUTUMN_DEPLOY__BAKE__INTERVAL_SECS",
&mut bake.interval_secs,
);
parse_env(
env,
"AUTUMN_DEPLOY__BAKE__MIN_REQUESTS",
&mut bake.min_requests,
);
parse_env_option(
env,
"AUTUMN_DEPLOY__BAKE__MAX_ERROR_RATE",
&mut bake.max_error_rate,
);
parse_env_option(env, "AUTUMN_DEPLOY__BAKE__MAX_P99_MS", &mut bake.max_p99_ms);
}
fn parse_env<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut T) {
if let Ok(val) = env.var(key) {
match val.parse::<T>() {
Ok(v) => *target = v,
Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
}
}
}
fn parse_env_option_string(env: &dyn Env, key: &str, target: &mut Option<String>) {
if let Ok(val) = env.var(key) {
*target = if val.is_empty() { None } else { Some(val) };
}
}
fn parse_env_option_secret(env: &dyn Env, key: &str, target: &mut Option<secrecy::SecretString>) {
if let Ok(val) = env.var(key) {
let trimmed = val.trim();
*target = if trimmed.is_empty() {
None
} else {
Some(secrecy::SecretString::from(trimmed.to_owned()))
};
}
}
fn parse_env_option<T: std::str::FromStr>(env: &dyn Env, key: &str, target: &mut Option<T>) {
if let Ok(val) = env.var(key) {
if val.is_empty() {
*target = None;
} else {
match val.parse::<T>() {
Ok(v) => *target = Some(v),
Err(_) => eprintln!("Warning: {key}={val:?} is not valid, ignoring"),
}
}
}
}
fn parse_env_option_duration(env: &dyn Env, key: &str, target: &mut Option<std::time::Duration>) {
if let Ok(val) = env.var(key) {
let val = val.trim();
if val.is_empty() {
*target = None;
} else {
match parse_duration_str(val) {
Ok(d) => *target = Some(d),
Err(e) => eprintln!("Warning: {key}={val:?} is not valid ({e}), ignoring"),
}
}
}
}
fn parse_env_string(env: &dyn Env, key: &str, target: &mut String) {
if let Ok(val) = env.var(key) {
*target = val;
}
}
fn parse_env_bool(env: &dyn Env, key: &str, target: &mut bool) {
if let Ok(val) = env.var(key) {
match val.as_str() {
"true" | "1" => *target = true,
"false" | "0" => *target = false,
_ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
}
}
}
fn parse_env_option_bool(env: &dyn Env, key: &str, target: &mut Option<bool>) {
if let Ok(val) = env.var(key) {
match val.as_str() {
"true" | "1" => *target = Some(true),
"false" | "0" => *target = Some(false),
_ => eprintln!("Warning: {key}={val:?} is not valid (expected true/false), ignoring"),
}
}
}
fn parse_env_csv(env: &dyn Env, key: &str, target: &mut Vec<String>) {
if let Ok(val) = env.var(key) {
*target = val.split(',').map(|s| s.trim().to_owned()).collect();
}
}
fn parse_env_csv_non_empty(env: &dyn Env, key: &str, target: &mut Vec<String>) {
if let Ok(val) = env.var(key) {
*target = val
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(ToOwned::to_owned)
.collect();
}
}
const fn default_port() -> u16 {
3000
}
fn default_host() -> String {
"127.0.0.1".to_owned()
}
const fn default_shutdown_timeout() -> u64 {
30
}
const fn default_prestop_grace() -> u64 {
5
}
const fn default_pool_size() -> usize {
10
}
const fn default_migration_lock_timeout() -> std::time::Duration {
std::time::Duration::from_secs(5)
}
const fn default_migration_lock_retries() -> u32 {
5
}
pub const PROD_REQUESTS_PER_POOL_CONNECTION: usize = 32;
pub const PROD_MIN_ADMISSION_LIMIT: usize = 256;
const fn default_max_connections_warn_threshold() -> usize {
100
}
const fn default_connect_timeout() -> u64 {
5
}
const fn default_pin_after_write_secs() -> u64 {
5
}
fn default_log_level() -> String {
"info".to_owned()
}
const fn default_access_log() -> bool {
true
}
fn default_access_log_exclude() -> Vec<String> {
vec![
"/health".to_owned(),
"/live".to_owned(),
"/ready".to_owned(),
"/startup".to_owned(),
"/actuator".to_owned(),
"/static".to_owned(),
]
}
fn default_telemetry_service_name() -> String {
"autumn-app".to_owned()
}
fn default_telemetry_service_version() -> String {
"unknown".to_owned()
}
fn default_telemetry_environment() -> String {
"development".to_owned()
}
const fn default_tls_reload_interval_secs() -> u64 {
60
}
const fn default_tls_handshake_timeout_secs() -> u64 {
10
}
fn default_deploy_user() -> String {
"root".to_owned()
}
const fn default_deploy_ssh_port() -> u16 {
22
}
const fn default_deploy_readiness_timeout_secs() -> u64 {
60
}
const fn default_deploy_keep_releases() -> u32 {
3
}
fn default_deploy_profile() -> String {
"prod".to_owned()
}
fn default_acme_cache_dir() -> PathBuf {
PathBuf::from("config/acme")
}
const fn default_acme_http_challenge_port() -> u16 {
80
}
const fn default_acme_renew_before_days() -> u32 {
30
}
fn default_custom_domains_dir() -> PathBuf {
PathBuf::from("config/acme/domains")
}
const fn default_custom_domains_max() -> usize {
1000
}
const fn default_custom_domains_cert_cache() -> usize {
256
}
const fn default_custom_domains_per_domain_per_day() -> u32 {
5
}
const fn default_custom_domains_global_per_hour() -> u32 {
50
}
const fn default_custom_domains_base_backoff() -> u64 {
300
}
const fn default_custom_domains_max_backoff() -> u64 {
86_400
}
const fn default_custom_domains_poll_secs() -> u64 {
60
}
fn default_acme_dns_credential() -> String {
"acme_dns".to_owned()
}
const fn default_acme_dns_propagation_timeout_secs() -> u64 {
300
}
const fn default_acme_dns_poll_interval_secs() -> u64 {
5
}
const MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS: u64 = 3600;
fn default_acme_dns_resolvers() -> Vec<String> {
vec!["1.1.1.1:53".to_owned(), "8.8.8.8:53".to_owned()]
}
const fn default_health_enabled() -> bool {
true
}
fn default_health_path() -> String {
"/health".to_owned()
}
fn default_live_path() -> String {
"/live".to_owned()
}
const fn default_warn_on_pooler() -> bool {
true
}
const fn default_upgrade_enabled() -> bool {
true
}
const fn default_upgrade_ready_timeout() -> u64 {
30
}
impl Default for UpgradeConfig {
fn default() -> Self {
Self {
enabled: default_upgrade_enabled(),
ready_timeout_secs: default_upgrade_ready_timeout(),
}
}
}
fn default_ready_path() -> String {
"/ready".to_owned()
}
fn default_startup_path() -> String {
"/startup".to_owned()
}
pub const DRAIN_MARGIN_SECS: u64 = 5;
impl ServerConfig {
#[must_use]
pub fn drain_window_warning(&self) -> Option<String> {
let timeout_ms = self.timeouts.request_timeout_ms.filter(|ms| *ms > 0)?;
let safe_secs = timeout_ms.div_ceil(1_000).saturating_add(DRAIN_MARGIN_SECS);
(self.shutdown_timeout_secs < safe_secs).then(|| {
format!(
"server.shutdown_timeout_secs ({}) is too short for \
server.timeouts.request_timeout_ms ({timeout_ms}): a request that starts \
just before shutdown can be stopped. Set shutdown_timeout_secs to {safe_secs} \
or more.",
self.shutdown_timeout_secs
)
})
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
port: default_port(),
host: default_host(),
strict_config: false,
strict_config_enforce_all: false,
shutdown_timeout_secs: default_shutdown_timeout(),
prestop_grace_secs: default_prestop_grace(),
upgrade: UpgradeConfig::default(),
timeouts: RequestTimeoutsConfig::default(),
http: HttpServerConfig::default(),
unix_socket: None,
max_concurrent_requests: None,
capacity_contract: None,
tls: None,
admission: AdmissionConfig::default(),
}
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
url: None,
primary_url: None,
replica_url: None,
pool_size: default_pool_size(),
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: ReplicaFallback::default(),
read_your_writes: ReadYourWrites::default(),
pin_after_write_secs: default_pin_after_write_secs(),
replica_max_lag_ms: None,
warn_on_pooler: true,
connect_timeout_secs: default_connect_timeout(),
startup_wait_secs: 0,
auto_migrate: None,
auto_migrate_in_production: false,
statement_timeout: None,
idle_in_transaction_timeout: None,
migration_lock_timeout: default_migration_lock_timeout(),
migration_lock_retries: default_migration_lock_retries(),
slow_query_threshold: default_slow_query_threshold(),
shards: Vec::new(),
directory_shard_router: false,
max_connections_warn_threshold: default_max_connections_warn_threshold(),
fleet: None,
}
}
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: default_log_level(),
format: LogFormat::default(),
filter_parameters: Vec::new(),
unfilter_parameters: Vec::new(),
access_log: default_access_log(),
access_log_exclude: default_access_log_exclude(),
capture: crate::log::capture::LogCaptureConfig::default(),
}
}
}
impl Default for TelemetryConfig {
fn default() -> Self {
Self {
enabled: false,
service_name: default_telemetry_service_name(),
service_namespace: None,
service_version: default_telemetry_service_version(),
environment: default_telemetry_environment(),
otlp_endpoint: None,
protocol: TelemetryProtocol::default(),
strict: false,
sample_ratio: default_telemetry_sample_ratio(),
}
}
}
impl Default for HealthConfig {
fn default() -> Self {
Self {
enabled: default_health_enabled(),
path: default_health_path(),
live_path: default_live_path(),
ready_path: default_ready_path(),
startup_path: default_startup_path(),
detailed: false,
cache_ttl_ms: default_health_cache_ttl_ms(),
ping_timeout_ms: default_health_ping_timeout_ms(),
db_readiness: default_health_db_readiness(),
redis_readiness: false,
}
}
}
pub trait ConfigLoader: Send + Sync + 'static {
fn load(&self) -> impl std::future::Future<Output = Result<AutumnConfig, ConfigError>> + Send;
}
#[derive(Debug, Default, Clone)]
pub struct TomlEnvConfigLoader {
allowed_plugin_roots: BTreeSet<String>,
}
impl TomlEnvConfigLoader {
#[must_use]
pub const fn new() -> Self {
Self {
allowed_plugin_roots: BTreeSet::new(),
}
}
#[must_use]
pub fn with_plugin_config_roots(mut self, roots: BTreeSet<String>) -> Self {
self.allowed_plugin_roots = roots;
self
}
}
impl ConfigLoader for TomlEnvConfigLoader {
async fn load(&self) -> Result<AutumnConfig, ConfigError> {
let base = OsEnv;
let profile = resolve_profile(&base);
let dir = crate::dotenv::dotenv_base_dir(&base);
let vars = crate::dotenv::resolve_dotenv_vars(&dir, &profile, &base)
.map_err(|e| ConfigError::Dotenv(e.to_string()))?;
let env = crate::dotenv::DotenvEnv::new(&base, vars);
AutumnConfig::load_with_env_and_plugin_roots(&env, &self.allowed_plugin_roots)
}
}
const fn default_slow_query_threshold() -> std::time::Duration {
std::time::Duration::from_millis(500)
}
pub fn parse_duration_str(s: &str) -> Result<std::time::Duration, String> {
if s.is_empty() {
return Err("duration string is empty".to_owned());
}
if let Ok(ms) = s.parse::<u64>() {
return Ok(std::time::Duration::from_millis(ms));
}
if let Some(val_str) = s.strip_suffix("ms") {
let val = val_str
.parse::<u64>()
.map_err(|e| format!("invalid duration integer: {e}"))?;
return Ok(std::time::Duration::from_millis(val));
}
if let Some(val_str) = s.strip_suffix('s') {
let val = val_str
.parse::<u64>()
.map_err(|e| format!("invalid duration integer: {e}"))?;
return Ok(std::time::Duration::from_secs(val));
}
if let Some(val_str) = s.strip_suffix('m') {
let val = val_str
.parse::<u64>()
.map_err(|e| format!("invalid duration integer: {e}"))?;
let secs = val.checked_mul(60).ok_or_else(|| {
format!("duration overflow: '{s}' exceeds maximum representable value")
})?;
return Ok(std::time::Duration::from_secs(secs));
}
if let Some(val_str) = s.strip_suffix('h') {
let val = val_str
.parse::<u64>()
.map_err(|e| format!("invalid duration integer: {e}"))?;
let secs = val.checked_mul(3600).ok_or_else(|| {
format!("duration overflow: '{s}' exceeds maximum representable value")
})?;
return Ok(std::time::Duration::from_secs(secs));
}
Err(format!("invalid duration format: '{s}'"))
}
pub fn deserialize_duration<'de, D>(deserializer: D) -> Result<std::time::Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum DurationOrStr {
String(String),
Integer(u64),
}
match DurationOrStr::deserialize(deserializer)? {
DurationOrStr::String(s) => parse_duration_str(&s).map_err(serde::de::Error::custom),
DurationOrStr::Integer(i) => Ok(std::time::Duration::from_millis(i)),
}
}
pub fn deserialize_option_duration<'de, D>(
deserializer: D,
) -> Result<Option<std::time::Duration>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
struct Wrapper(#[serde(deserialize_with = "deserialize_duration")] std::time::Duration);
Option::<Wrapper>::deserialize(deserializer).map(|opt| opt.map(|w| w.0))
}
#[derive(Debug, Clone, Deserialize)]
pub struct TenancyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_tenancy_source")]
pub source: String,
#[serde(default = "default_tenancy_header_name")]
pub header_name: String,
#[serde(default = "default_tenancy_session_key")]
pub session_key: String,
#[serde(default = "default_tenancy_jwt_claim")]
pub jwt_claim: String,
#[serde(default)]
pub jwt_secret: Option<secrecy::SecretString>,
#[serde(default)]
pub jwt_issuer: Option<String>,
#[serde(default)]
pub jwt_audience: Option<String>,
#[serde(default)]
pub base_domain: Option<String>,
#[serde(default)]
pub public_paths: Vec<String>,
#[serde(default)]
pub login_redirect: Option<String>,
#[serde(default)]
pub quota_bytes: usize,
#[serde(default)]
pub max_cells: usize,
#[serde(default)]
pub idle_ttl_secs: u64,
#[serde(default)]
pub max_concurrent_requests: usize,
#[serde(default)]
pub max_db_connections: usize,
}
fn default_tenancy_source() -> String {
"header".to_string()
}
fn default_tenancy_header_name() -> String {
"x-tenant-id".to_string()
}
fn default_tenancy_session_key() -> String {
"tenant_id".to_string()
}
fn default_tenancy_jwt_claim() -> String {
"tenant_id".to_string()
}
impl Default for TenancyConfig {
fn default() -> Self {
Self {
enabled: false,
source: default_tenancy_source(),
header_name: default_tenancy_header_name(),
session_key: default_tenancy_session_key(),
jwt_claim: default_tenancy_jwt_claim(),
jwt_secret: None,
jwt_issuer: None,
jwt_audience: None,
base_domain: None,
public_paths: Vec::new(),
login_redirect: None,
quota_bytes: 0,
max_cells: 0,
idle_ttl_secs: 0,
max_concurrent_requests: 0,
max_db_connections: 0,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ResilienceConfig {
#[serde(default)]
pub circuit_breaker: CircuitBreakerConfig,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CircuitBreakerConfig {
#[serde(default)]
pub defaults: CircuitBreakerPolicyConfig,
#[serde(default)]
pub hosts: std::collections::HashMap<String, CircuitBreakerPolicyConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct CircuitBreakerPolicyConfig {
pub failure_ratio_threshold: Option<f64>,
pub sample_window_secs: Option<u64>,
pub minimum_sample_count: Option<u64>,
pub open_duration_secs: Option<u64>,
pub half_open_trial_count: Option<u64>,
pub slow_call_duration_threshold_ms: Option<u64>,
pub slow_call_rate_threshold: Option<f64>,
pub cancelled_call_outcome: Option<crate::circuit_breaker::CancelledCallOutcome>,
}
impl AutumnConfig {
fn apply_resilience_env_overrides_with_env(&mut self, env: &dyn Env) {
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD",
&mut self
.resilience
.circuit_breaker
.defaults
.failure_ratio_threshold,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SAMPLE_WINDOW_SECS",
&mut self.resilience.circuit_breaker.defaults.sample_window_secs,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__MINIMUM_SAMPLE_COUNT",
&mut self
.resilience
.circuit_breaker
.defaults
.minimum_sample_count,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__OPEN_DURATION_SECS",
&mut self.resilience.circuit_breaker.defaults.open_duration_secs,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__HALF_OPEN_TRIAL_COUNT",
&mut self
.resilience
.circuit_breaker
.defaults
.half_open_trial_count,
);
let defaults = &mut self.resilience.circuit_breaker.defaults;
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_DURATION_THRESHOLD_MS",
&mut defaults.slow_call_duration_threshold_ms,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_RATE_THRESHOLD",
&mut defaults.slow_call_rate_threshold,
);
parse_env_option(
env,
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__CANCELLED_CALL_OUTCOME",
&mut defaults.cancelled_call_outcome,
);
}
}
use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::sync::{Arc, Mutex};
#[derive(Clone, Copy, PartialEq, Eq)]
enum AnyProbe {
Str,
Map,
Seq,
}
#[derive(Clone)]
pub struct SchemaDeserializer {
path: Vec<String>,
schema: Arc<Mutex<HashMap<String, HashSet<String>>>>,
any_probe: Arc<Mutex<HashMap<String, AnyProbe>>>,
rejected: Arc<Mutex<Vec<String>>>,
}
impl Default for SchemaDeserializer {
fn default() -> Self {
Self::new()
}
}
impl SchemaDeserializer {
#[must_use]
pub fn new() -> Self {
Self {
path: Vec::new(),
schema: Arc::new(Mutex::new(HashMap::new())),
any_probe: Arc::new(Mutex::new(HashMap::new())),
rejected: Arc::new(Mutex::new(Vec::new())),
}
}
#[must_use]
pub fn into_schema(self) -> HashMap<String, HashSet<String>> {
let lock = self
.schema
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
lock.clone()
}
}
impl<'de> de::Deserializer<'de> for SchemaDeserializer {
type Error = serde::de::value::Error;
fn deserialize_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
let path = self.path.join(".");
let probe = self
.any_probe
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&path)
.copied()
.unwrap_or(AnyProbe::Str);
let result = match probe {
AnyProbe::Str => visitor.visit_str("0"),
AnyProbe::Map => visitor.visit_map(SchemaMapAccess {
fields: [].iter(),
current_field: None,
deserializer: self.clone(),
}),
AnyProbe::Seq => visitor.visit_seq(SchemaSeqAccess {
done: true,
deserializer: self.clone(),
}),
};
if result.is_err() {
self.rejected
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(path);
}
result
}
fn deserialize_bool<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_bool(false)
}
fn deserialize_i8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_i8(0)
}
fn deserialize_i16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_i16(0)
}
fn deserialize_i32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_i32(0)
}
fn deserialize_i64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_i64(0)
}
fn deserialize_u8<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_u8(0)
}
fn deserialize_u16<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_u16(0)
}
fn deserialize_u32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_u32(0)
}
fn deserialize_u64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_u64(0)
}
fn deserialize_f32<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_f32(0.0)
}
fn deserialize_f64<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_f64(0.0)
}
fn deserialize_char<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_char('\0')
}
fn deserialize_str<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_str("")
}
fn deserialize_string<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_string(String::new())
}
fn deserialize_bytes<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_bytes(&[])
}
fn deserialize_byte_buf<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_byte_buf(Vec::new())
}
fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_some(self)
}
fn deserialize_unit<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
fn deserialize_unit_struct<V>(
self,
_name: &'static str,
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
fn deserialize_newtype_struct<V>(
self,
_name: &'static str,
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_newtype_struct(self)
}
fn deserialize_seq<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_seq(SchemaSeqAccess {
done: false,
deserializer: self,
})
}
fn deserialize_tuple<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.deserialize_seq(visitor)
}
fn deserialize_tuple_struct<V>(
self,
_name: &'static str,
_len: usize,
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
self.deserialize_seq(visitor)
}
fn deserialize_map<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_map(SchemaMapAccess {
fields: [].iter(),
current_field: None,
deserializer: self,
})
}
fn deserialize_struct<V>(
self,
_name: &'static str,
fields: &'static [&'static str],
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
let path_str = self.path.join(".");
{
let mut schema = self.schema.lock().unwrap();
schema.insert(path_str, fields.iter().map(|&s| s.to_string()).collect());
}
visitor.visit_map(SchemaMapAccess {
fields: fields.iter(),
current_field: None,
deserializer: self,
})
}
fn deserialize_enum<V>(
self,
_name: &'static str,
variants: &'static [&'static str],
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_enum(SchemaEnumAccess {
variant: variants.first().copied().unwrap_or_default(),
})
}
fn deserialize_identifier<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_str("")
}
fn deserialize_ignored_any<V>(self, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
}
struct SchemaSeqAccess {
done: bool,
deserializer: SchemaDeserializer,
}
impl<'de> SeqAccess<'de> for SchemaSeqAccess {
type Error = serde::de::value::Error;
fn next_element_seed<T>(&mut self, seed: T) -> Result<Option<T::Value>, Self::Error>
where
T: DeserializeSeed<'de>,
{
if self.done {
Ok(None)
} else {
self.done = true;
seed.deserialize(self.deserializer.clone()).map(Some)
}
}
}
struct SchemaMapAccess {
fields: std::slice::Iter<'static, &'static str>,
current_field: Option<&'static str>,
deserializer: SchemaDeserializer,
}
impl<'de> MapAccess<'de> for SchemaMapAccess {
type Error = serde::de::value::Error;
fn next_key_seed<K>(&mut self, seed: K) -> Result<Option<K::Value>, Self::Error>
where
K: DeserializeSeed<'de>,
{
if let Some(&field) = self.fields.next() {
self.current_field = Some(field);
seed.deserialize(de::value::StrDeserializer::new(field))
.map(Some)
} else {
Ok(None)
}
}
fn next_value_seed<V>(&mut self, seed: V) -> Result<V::Value, Self::Error>
where
V: DeserializeSeed<'de>,
{
let field = self.current_field.take().unwrap();
let mut new_path = self.deserializer.path.clone();
new_path.push(field.to_string());
let nested = SchemaDeserializer {
path: new_path,
schema: self.deserializer.schema.clone(),
any_probe: self.deserializer.any_probe.clone(),
rejected: self.deserializer.rejected.clone(),
};
seed.deserialize(nested)
}
}
struct SchemaEnumAccess {
variant: &'static str,
}
impl<'de> de::EnumAccess<'de> for SchemaEnumAccess {
type Error = serde::de::value::Error;
type Variant = Self;
fn variant_seed<V>(self, seed: V) -> Result<(V::Value, Self::Variant), Self::Error>
where
V: de::DeserializeSeed<'de>,
{
let val = seed.deserialize(de::value::StrDeserializer::new(self.variant))?;
Ok((val, self))
}
}
impl<'de> de::VariantAccess<'de> for SchemaEnumAccess {
type Error = serde::de::value::Error;
fn unit_variant(self) -> Result<(), Self::Error> {
Ok(())
}
fn newtype_variant_seed<T>(self, seed: T) -> Result<T::Value, Self::Error>
where
T: de::DeserializeSeed<'de>,
{
seed.deserialize(SchemaDeserializer::new())
}
fn tuple_variant<V>(self, _len: usize, visitor: V) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
fn struct_variant<V>(
self,
_fields: &'static [&'static str],
visitor: V,
) -> Result<V::Value, Self::Error>
where
V: Visitor<'de>,
{
visitor.visit_unit()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct FakeEnv(std::collections::HashMap<String, String>);
impl Env for FakeEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.0
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
}
}
#[test]
fn test_schema_extractor() {
let keys = AutumnConfig::get_schema_keys();
assert!(keys.contains_key(""));
let root_keys = &keys[""];
assert!(root_keys.contains("server"));
assert!(root_keys.contains("database"));
assert!(keys.contains_key("server"));
assert!(keys["server"].contains("port"));
assert!(keys["server"].contains("host"));
assert!(keys.contains_key("database"));
assert!(keys["database"].contains("primary_url"));
}
#[cfg(feature = "acme")]
#[test]
fn acme_enum_field_does_not_truncate_schema_traversal() {
let keys = AutumnConfig::get_schema_keys();
assert!(
keys.contains_key("server.tls.acme"),
"acme section must be in the schema"
);
assert!(
keys.contains_key("database"),
"database schema dropped: the acme enum truncated traversal"
);
assert!(keys["database"].contains("primary_url"));
let errs = AutumnConfig::validate_toml("[database]\nprimry_url = \"x\"\n", &keys);
assert_eq!(
errs,
vec![(
"database.primry_url".to_owned(),
Some("database.primary_url".to_owned())
)]
);
}
#[test]
fn test_strict_config_startup_fails_on_typo() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(
&config_path,
"[database]\nprimry_url = \"postgres://localhost/db\"",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(res.is_err());
let err_str = format!("{:?}", res.err().unwrap());
assert!(err_str.contains("primry_url"));
}
fn strict_prod_env_2063(temp: &std::path::Path) -> FakeEnv {
FakeEnv(
[
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.to_str().unwrap().to_owned(),
),
]
.into(),
)
}
#[test]
fn deploy_cli_lenient_accepts_plugin_owned_top_level_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[media]\nmediamtx_host = \"cdn.example\"\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let strict = AutumnConfig::load_with_env(&env);
assert!(
strict.is_err(),
"app boot must stay strict for unknown plugin roots: {strict:?}"
);
let strict_err = format!("{:?}", strict.err().unwrap());
assert!(
strict_err.contains("media"),
"strict error should name the unknown root: {strict_err}"
);
let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
lenient.is_ok(),
"deploy CLI must accept plugin-owned [media] under strict_config: {lenient:?}"
);
}
#[test]
fn deploy_cli_lenient_accepts_arbitrary_unknown_top_level_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[definitely_not_a_root]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
assert!(
AutumnConfig::load_with_env(&env).is_err(),
"app boot must reject an unknown top-level root"
);
assert!(
AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_ok(),
"deploy CLI must accept an unknown top-level root as opaque"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_known_section_typo() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[database]\nprimry_url = \"postgres://localhost/db\"\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
res.is_err(),
"known-section typo must still hard-fail under the lenient CLI load: {res:?}"
);
let err = format!("{:?}", res.err().unwrap());
assert!(
err.contains("primry_url"),
"error should name the known-section typo: {err}"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_malformed_toml() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\nthis is not = = valid toml\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
assert!(
AutumnConfig::load_with_env_lenient_unknown_roots(&env).is_err(),
"malformed TOML must still fail under the lenient CLI load"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_profile_prefixed_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[profile.prod.media]\nmediamtx_host = \"cdn.example\"\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
res.is_err(),
"a profile-prefixed root ([profile.prod.media]) must stay strict under \
the lenient CLI load — it is not a true top-level root and the deployed \
app rejects it at boot: {res:?}"
);
let err = format!("{:?}", res.err().unwrap());
assert!(
err.contains("media"),
"error should name the profile-prefixed root: {err}"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_profile_prefixed_unknown_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[profile.prod.definitely_unknown]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
res.is_err(),
"a profile-prefixed unknown root must stay strict under the lenient CLI \
load: {res:?}"
);
let err = format!("{:?}", res.err().unwrap());
assert!(
err.contains("definitely_unknown"),
"error should name the profile-prefixed unknown root: {err}"
);
}
#[test]
fn deploy_cli_lenient_still_rejects_non_table_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\nmedia = \"enabled\"\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let res = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
res.is_err(),
"a SCALAR top-level root (media = \"enabled\") must hard-fail under the \
lenient CLI load — it is not a table and the deployed app rejects it at \
boot: {res:?}"
);
let err = format!("{:?}", res.err().unwrap());
assert!(
err.contains("media"),
"error should name the non-table root: {err}"
);
let temp2 = tempfile::tempdir().unwrap();
std::fs::write(
temp2.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\nmedia = [\"a\", \"b\"]\n",
)
.unwrap();
let env2 = strict_prod_env_2063(temp2.path());
let res2 = AutumnConfig::load_with_env_lenient_unknown_roots(&env2);
assert!(
res2.is_err(),
"an ARRAY top-level root (media = [\"a\", \"b\"]) must hard-fail under the \
lenient CLI load — it is not a table and the deployed app rejects it at \
boot: {res2:?}"
);
let err2 = format!("{:?}", res2.err().unwrap());
assert!(
err2.contains("media"),
"error should name the non-table root: {err2}"
);
}
#[test]
fn deploy_cli_lenient_accepts_quoted_dotted_top_level_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = true\n\n[\"my.plugin\"]\nenabled = true\n",
)
.unwrap();
let env = strict_prod_env_2063(temp.path());
let lenient = AutumnConfig::load_with_env_lenient_unknown_roots(&env);
assert!(
lenient.is_ok(),
"deploy CLI must leniently accept a quoted-dotted TOP-LEVEL table root \
([\"my.plugin\"]) — it is a true top-level plugin root the app accepts at \
boot, and top-level-ness is structural (empty parent path), not \
`path.contains('.')`: {lenient:?}"
);
let temp2 = tempfile::tempdir().unwrap();
std::fs::write(
temp2.path().join("autumn.toml"),
"\"my.plugin\" = { enabled = true }\n\n[server]\nstrict_config = true\n",
)
.unwrap();
let env2 = strict_prod_env_2063(temp2.path());
assert!(
AutumnConfig::load_with_env_lenient_unknown_roots(&env2).is_ok(),
"deploy CLI must accept the inline-table quoted-dotted top-level root too"
);
}
#[test]
fn post_database_section_typo_warns_but_does_not_fail() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(&config_path, "[log]\nbogus_zzz = true\n").unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_ok(),
"a post-database section typo must warn (not fail) under warn-first rollout: {res:?}"
);
}
#[test]
fn post_database_section_typo_fails_under_enforce_all() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(
&config_path,
"[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n[log]\nbogus_zzz = true\n",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_err(),
"strict_config_enforce_all must hard-fail the post-database typo"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("bogus_zzz"),
"error should name the key: {err_str}"
);
}
#[test]
fn pre_database_section_typo_still_hard_fails() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(&config_path, "[server]\nbogus_zzz = true\n").unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_err(),
"an unknown [server] key must still hard-fail (pre-fix strictness preserved)"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("bogus_zzz"),
"error should name the key: {err_str}"
);
}
fn plugin_roots(names: &[&str]) -> BTreeSet<String> {
names.iter().map(|s| (*s).to_owned()).collect()
}
fn strict_prod_env(dir: &std::path::Path, enforce_all: bool) -> FakeEnv {
let mut vars = vec![
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
dir.to_str().unwrap().to_owned(),
),
];
if enforce_all {
vars.push((
"AUTUMN_SERVER__STRICT_CONFIG_ENFORCE_ALL".to_owned(),
"true".to_owned(),
));
}
FakeEnv(vars.into_iter().collect())
}
#[test]
fn strict_config_accepts_registered_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[media]\nqueue = \"media\"\n[media.mediamtx]\napi_base = \"http://localhost:9997\"\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_ok(),
"a registered [media] root must boot under strict_config: {res:?}"
);
}
#[test]
fn strict_config_accepts_quoted_dotted_registered_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[\"my.plugin\"]\nenabled = true\n[\"my.plugin\".nested]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["my.plugin"]));
assert!(
res.is_ok(),
"a registered quoted-dotted top-level root ([\"my.plugin\"]) must boot \
under strict_config, exactly as deploy leniency accepts it: {res:?}"
);
}
#[test]
fn strict_config_rejects_non_table_registered_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("autumn.toml"), "media = \"enabled\"\n").unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_err(),
"a scalar-valued registered root (media = \"enabled\") must hard-fail \
under strict_config, not be exempted as an opaque table: {res:?}"
);
assert!(
format!("{:?}", res.err().unwrap()).contains("media"),
"error should name the malformed media root"
);
let temp_arr = tempfile::tempdir().unwrap();
std::fs::write(
temp_arr.path().join("autumn.toml"),
"media = [\"a\", \"b\"]\n",
)
.unwrap();
let env_arr = strict_prod_env(temp_arr.path(), false);
let res_arr =
AutumnConfig::load_with_env_and_plugin_roots(&env_arr, &plugin_roots(&["media"]));
assert!(
res_arr.is_err(),
"an array-valued registered root (media = [\"a\", \"b\"]) must hard-fail \
under strict_config, not be exempted as an opaque table: {res_arr:?}"
);
assert!(
format!("{:?}", res_arr.err().unwrap()).contains("media"),
"error should name the malformed media array root"
);
}
#[test]
fn strict_config_rejects_unregistered_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[media]\nqueue = \"media\"\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
assert!(
res.is_err(),
"an unregistered [media] root must still hard-fail under strict_config"
);
assert!(format!("{:?}", res.err().unwrap()).contains("media"));
}
#[test]
fn strict_config_still_rejects_other_unknown_root_when_plugin_registered() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[media]\nqueue = \"media\"\n\n[definitely_not_a_root]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_err(),
"an unrelated unknown root must still hard-fail even with [media] registered"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("definitely_not_a_root"),
"error should name the unknown root: {err_str}"
);
}
#[test]
fn registered_plugin_root_is_opaque_under_enforce_all() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[media]\nwholly_made_up = true\n[media.deeply.nested]\nalso_bogus = 42\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), true);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_ok(),
"enforce_all must NOT flag children of a registered opaque root: {res:?}"
);
}
#[test]
fn strict_config_still_rejects_profile_prefixed_plugin_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[profile.prod.media]\nwholly_made_up = true\n\
[profile.prod.media.deeply.nested]\nalso_bogus = 42\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), true);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_err(),
"a profile-prefixed plugin root ([profile.prod.media]) must stay strict \
and be rejected — the plugin reads only the top-level [media] table, so \
exempting the profile layer would boot silently on default config: {res:?}"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("media"),
"error should name the media/profile root: {err_str}"
);
}
#[test]
fn strict_config_rejects_profile_prefixed_unknown_root() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[profile.prod.definitely_not_a_root]\nx = 1\n",
)
.unwrap();
let env = strict_prod_env(temp.path(), false);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &plugin_roots(&["media"]));
assert!(
res.is_err(),
"a profile-prefixed genuinely-unknown root must still hard-fail even \
with [media] registered (the fix must not blanket-allow profile subtrees)"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("definitely_not_a_root"),
"error should name the unknown root: {err_str}"
);
}
#[test]
fn non_strict_config_tolerates_media_root_without_registration() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("autumn.toml"),
"[server]\nstrict_config = false\n\n[media]\nqueue = \"media\"\n",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env_and_plugin_roots(&env, &BTreeSet::new());
assert!(
res.is_ok(),
"non-strict config must tolerate an unregistered [media] root: {res:?}"
);
}
#[test]
fn malformed_profile_entry_still_hard_fails() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(&config_path, "[profile]\ndev = \"prod\"\n").unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_err(),
"a malformed [profile] entry is a structural error that must keep \
hard-failing under strict_config (not be demoted to warn-only): {res:?}"
);
assert!(
matches!(res.err().unwrap(), ConfigError::Validation(_)),
"malformed profile entry must fail as a validation error"
);
let temp2 = tempfile::tempdir().unwrap();
let config_path2 = temp2.path().join("autumn.toml");
std::fs::write(&config_path2, "[resilience]\nboguz = 1\n").unwrap();
let env2 = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp2.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res2 = AutumnConfig::load_with_env(&env2);
assert!(
res2.is_ok(),
"a newly-#1890-covered section typo must still only warn under \
strict_config (enforce_all off), proving the profile fix is narrow: {res2:?}"
);
}
#[test]
fn dotted_profile_name_preserves_strictness() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(
&config_path,
"[profile.\"prod.eu\".server]\nbogus_zzz = true\n",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_err(),
"a [server] typo under a quoted dotted profile name must hard-fail \
(pre-#1890 strictness must not be downgraded by string-splitting the \
joined path): {res:?}"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("server") && err_str.contains("bogus_zzz"),
"hard-fail must be for the [server] typo (right reason): {err_str}"
);
let temp2 = tempfile::tempdir().unwrap();
let config_path2 = temp2.path().join("autumn.toml");
std::fs::write(
&config_path2,
"[profile.\"prod.eu\".resilience]\nboguz = 1\n",
)
.unwrap();
let env2 = FakeEnv(
[
("AUTUMN_SERVER__STRICT_CONFIG".to_owned(), "true".to_owned()),
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp2.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res2 = AutumnConfig::load_with_env(&env2);
assert!(
res2.is_ok(),
"a newly-#1890-covered section typo under a quoted dotted profile name \
must still only warn under strict_config (enforce_all off): {res2:?}"
);
}
#[test]
fn statement_timeout_duration_field_loads() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(&config_path, "[database]\nstatement_timeout = \"30s\"\n").unwrap();
let env = FakeEnv(
[(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
)]
.into(),
);
let config =
AutumnConfig::load_with_env(&env).expect("config with duration string must load");
assert_eq!(
config.database.statement_timeout,
Some(std::time::Duration::from_secs(30))
);
std::fs::write(&config_path, "[database]\nstatement_timeout = 250\n").unwrap();
let config =
AutumnConfig::load_with_env(&env).expect("config with integer duration must load");
assert_eq!(
config.database.statement_timeout,
Some(std::time::Duration::from_millis(250))
);
}
#[test]
fn should_warn_total_connections_at_and_above_threshold() {
assert!(should_warn_total_connections(100, 100));
assert!(should_warn_total_connections(250, 100));
assert!(!should_warn_total_connections(99, 100));
}
#[test]
fn should_warn_total_connections_zero_threshold_disables() {
assert!(!should_warn_total_connections(0, 0));
assert!(!should_warn_total_connections(10_000, 0));
}
#[test]
fn database_config_default_warn_threshold_is_100() {
assert_eq!(
DatabaseConfig::default().max_connections_warn_threshold,
100
);
}
struct MockConfigLoader {
config: AutumnConfig,
}
impl ConfigLoader for MockConfigLoader {
async fn load(&self) -> Result<AutumnConfig, ConfigError> {
Ok(self.config.clone())
}
}
#[tokio::test]
async fn config_loader_trait_returns_supplied_config() {
let mut custom = AutumnConfig::default();
custom.server.port = 9999;
custom.profile = Some("integration-test".to_owned());
let loader = MockConfigLoader {
config: custom.clone(),
};
let resolved = loader.load().await.expect("mock loader should succeed");
assert_eq!(resolved.server.port, 9999);
assert_eq!(resolved.profile.as_deref(), Some("integration-test"));
}
#[test]
fn validate_does_not_error_on_redis_backend_without_url() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
config.session.redis.url = None;
config.validate().expect(
"validate() must accept redis-backend-without-url so custom \
session store overrides aren't blocked at boot",
);
}
#[tokio::test]
async fn default_toml_env_loader_succeeds_without_files() {
let loader = TomlEnvConfigLoader::new();
let resolved = loader.load().await.expect("default loader should succeed");
assert_eq!(resolved.server.port, 3000);
}
#[test]
fn database_config_validate_none() {
let config = DatabaseConfig {
url: None,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_config_validate_valid_postgres() {
let config = DatabaseConfig {
url: Some("postgres://user:pass@localhost:5432/db".to_string()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_config_validate_valid_postgresql() {
let config = DatabaseConfig {
url: Some("postgresql://user:pass@localhost:5432/db".to_string()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_config_validate_invalid_scheme() {
let config = DatabaseConfig {
url: Some("mysql://user:pass@localhost:3306/db".to_string()),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
match result {
Err(ConfigError::Validation(msg)) => {
assert!(msg.contains("must start with postgres:// or postgresql://"));
}
_ => panic!("Expected ConfigError::Validation"),
}
}
#[test]
fn database_config_validate_does_not_echo_credentials() {
for url in [
"mysql://user:hunter2@localhost:3306/db",
"redis://:hunter2@localhost:6379",
"amqp://app:hunter2@broker/vhost",
] {
let config = DatabaseConfig {
url: Some(url.to_owned()),
..Default::default()
};
let Err(ConfigError::Validation(msg)) = config.validate() else {
panic!("{url} must be refused");
};
assert!(!msg.contains("hunter2"), "password leaked in: {msg}");
assert!(msg.contains("localhost") || msg.contains("broker"), "{msg}");
}
}
#[test]
fn database_shard_url_validation_does_not_echo_credentials() {
let config = DatabaseConfig {
shards: vec![ShardConfig {
name: "shard0".to_owned(),
primary_url: "mysql://user:hunter2@localhost:3306/db".to_owned(),
slots: None,
replica_url: None,
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: None,
}],
..Default::default()
};
let Err(ConfigError::Validation(msg)) = config.validate() else {
panic!("a non-Postgres shard url must be refused");
};
assert!(!msg.contains("hunter2"), "password leaked in: {msg}");
}
#[test]
fn server_defaults() {
let config = ServerConfig::default();
assert_eq!(config.port, 3000);
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.shutdown_timeout_secs, 30);
}
#[test]
fn database_defaults() {
let config = DatabaseConfig::default();
assert!(config.url.is_none());
assert_eq!(config.pool_size, 10);
assert_eq!(config.connect_timeout_secs, 5);
}
#[test]
fn database_validate_none_url_is_ok() {
let config = DatabaseConfig {
url: None,
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_validate_postgres_url_is_ok() {
let config = DatabaseConfig {
url: Some("postgres://user:pass@localhost/db".to_string()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_validate_postgresql_url_is_ok() {
let config = DatabaseConfig {
url: Some("postgresql://user:pass@localhost/db".to_string()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn database_validate_invalid_url_is_err() {
let config = DatabaseConfig {
url: Some("mysql://user:pass@localhost/db".to_string()),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
if let Err(ConfigError::Validation(msg)) = result {
assert!(msg.contains("Invalid database URL"));
assert!(msg.contains("must start with postgres:// or postgresql://"));
} else {
panic!("Expected ConfigError::Validation");
}
}
#[test]
fn database_topology_deserializes_primary_and_replica_urls() {
let config: AutumnConfig = toml::from_str(
r#"
[database]
primary_url = "postgres://primary.example/app"
replica_url = "postgres://replica.example/app"
primary_pool_size = 12
replica_pool_size = 4
replica_fallback = "primary"
"#,
)
.expect("database topology config should parse");
assert_eq!(
config.database.primary_url.as_deref(),
Some("postgres://primary.example/app")
);
assert_eq!(
config.database.replica_url.as_deref(),
Some("postgres://replica.example/app")
);
assert_eq!(config.database.primary_pool_size, Some(12));
assert_eq!(config.database.replica_pool_size, Some(4));
assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
assert_eq!(
config.database.effective_primary_url(),
Some("postgres://primary.example/app")
);
assert_eq!(config.database.effective_primary_pool_size(), 12);
assert_eq!(config.database.effective_replica_pool_size(), 4);
}
#[test]
fn database_topology_keeps_url_as_single_primary_compatibility_path() {
let config: AutumnConfig = toml::from_str(
r#"
[database]
url = "postgres://single.example/app"
pool_size = 7
"#,
)
.expect("legacy database.url config should parse");
assert_eq!(
config.database.effective_primary_url(),
Some("postgres://single.example/app")
);
assert_eq!(config.database.effective_primary_pool_size(), 7);
assert_eq!(config.database.effective_replica_pool_size(), 7);
assert!(config.database.replica_url.is_none());
}
#[test]
fn database_topology_rejects_replica_without_primary() {
let config = DatabaseConfig {
replica_url: Some("postgres://replica.example/app".to_owned()),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
let Err(ConfigError::Validation(message)) = result else {
panic!("expected database topology validation error");
};
assert!(message.contains("database.replica_url"));
assert!(message.contains("database.primary_url"));
}
#[test]
fn time_zone_identifier_env_override_applies() {
let env = MockEnv::new().with("AUTUMN_TIME_ZONE__IDENTIFIER", "America/New_York");
let mut config = AutumnConfig::default();
assert_eq!(config.time_zone.identifier, "UTC");
config.apply_env_overrides_with_env(&env);
assert_eq!(config.time_zone.identifier, "America/New_York");
assert!(config.time_zone.validate().is_ok());
}
#[test]
fn alerts_severities_env_overrides_apply() {
let env = MockEnv::new()
.with("AUTUMN_ALERTS__SLACK_SEVERITIES", "critical")
.with("AUTUMN_ALERTS__PAGERDUTY_SEVERITIES", "all")
.with("AUTUMN_ALERTS__DISCORD_SEVERITIES", "critical");
let mut config = AutumnConfig::default();
assert_eq!(
config.alerts.slack_severities,
crate::alerts::AlertRouting::All
);
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.alerts.slack_severities,
crate::alerts::AlertRouting::Critical,
"AUTUMN_ALERTS__SLACK_SEVERITIES=critical must set the Slack channel routing"
);
assert_eq!(
config.alerts.discord_severities,
crate::alerts::AlertRouting::Critical
);
assert_eq!(
config.alerts.pagerduty_severities,
crate::alerts::AlertRouting::All
);
}
#[test]
fn database_topology_env_overrides_role_fields() {
let env = MockEnv::new()
.with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app")
.with("AUTUMN_DATABASE__REPLICA_URL", "postgres://replica.env/app")
.with("AUTUMN_DATABASE__PRIMARY_POOL_SIZE", "9")
.with("AUTUMN_DATABASE__REPLICA_POOL_SIZE", "3")
.with("AUTUMN_DATABASE__REPLICA_FALLBACK", "primary");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.database.primary_url.as_deref(),
Some("postgres://primary.env/app")
);
assert_eq!(
config.database.replica_url.as_deref(),
Some("postgres://replica.env/app")
);
assert_eq!(config.database.primary_pool_size, Some(9));
assert_eq!(config.database.replica_pool_size, Some(3));
assert_eq!(config.database.replica_fallback, ReplicaFallback::Primary);
}
#[test]
fn database_shards_parse_from_toml_with_effective_fallbacks() {
let config: AutumnConfig = toml::from_str(
r#"
[database]
primary_url = "postgres://control.example/app"
pool_size = 8
replica_fallback = "primary"
[[database.shards]]
name = "shard0"
primary_url = "postgres://shard0.example/app"
[[database.shards]]
name = "shard1"
primary_url = "postgres://shard1.example/app"
replica_url = "postgres://shard1-ro.example/app"
primary_pool_size = 3
replica_pool_size = 2
replica_fallback = "fail_readiness"
"#,
)
.expect("sharded database config should parse");
let db = &config.database;
assert!(db.has_shards());
assert_eq!(db.shards.len(), 2);
let shard0 = &db.shards[0];
assert_eq!(shard0.name, "shard0");
assert_eq!(shard0.primary_url, "postgres://shard0.example/app");
assert!(shard0.replica_url.is_none());
assert_eq!(shard0.effective_primary_pool_size(db), 8);
assert_eq!(shard0.effective_replica_pool_size(db), 8);
assert_eq!(
shard0.effective_replica_fallback(db),
ReplicaFallback::Primary
);
let shard1 = &db.shards[1];
assert_eq!(shard1.effective_primary_pool_size(db), 3);
assert_eq!(shard1.effective_replica_pool_size(db), 2);
assert_eq!(
shard1.effective_replica_fallback(db),
ReplicaFallback::FailReadiness
);
config.validate().expect("sharded config should validate");
}
#[test]
fn database_shards_default_to_empty() {
let config = AutumnConfig::default();
assert!(!config.database.has_shards());
assert!(config.database.shards.is_empty());
}
#[test]
fn database_shard_env_overrides_existing_entry_fields() {
let mut config: AutumnConfig = toml::from_str(
r#"
[[database.shards]]
name = "shard0"
primary_url = "postgres://toml.example/app"
"#,
)
.expect("config should parse");
let env = MockEnv::new()
.with(
"AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
"postgres://env.example/app",
)
.with(
"AUTUMN_DATABASE__SHARDS__0__REPLICA_URL",
"postgres://env-ro.example/app",
)
.with("AUTUMN_DATABASE__SHARDS__0__PRIMARY_POOL_SIZE", "5")
.with("AUTUMN_DATABASE__SHARDS__0__REPLICA_FALLBACK", "primary");
config.apply_env_overrides_with_env(&env);
let shard = &config.database.shards[0];
assert_eq!(shard.name, "shard0");
assert_eq!(shard.primary_url, "postgres://env.example/app");
assert_eq!(
shard.replica_url.as_deref(),
Some("postgres://env-ro.example/app")
);
assert_eq!(shard.primary_pool_size, Some(5));
assert_eq!(shard.replica_fallback, Some(ReplicaFallback::Primary));
}
#[test]
fn database_shard_env_appends_new_entry_when_name_and_primary_url_present() {
let mut config = AutumnConfig::default();
let env = MockEnv::new()
.with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0")
.with(
"AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
"postgres://shard0.env/app",
)
.with("AUTUMN_DATABASE__SHARDS__1__NAME", "shard1")
.with(
"AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
"postgres://shard1.env/app",
)
.with("AUTUMN_DATABASE__SHARDS__3__NAME", "orphan")
.with(
"AUTUMN_DATABASE__SHARDS__3__PRIMARY_URL",
"postgres://orphan.env/app",
);
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.shards.len(), 2);
assert_eq!(config.database.shards[0].name, "shard0");
assert_eq!(config.database.shards[1].name, "shard1");
}
#[test]
fn database_shard_env_does_not_append_incomplete_entry() {
let mut config = AutumnConfig::default();
let env = MockEnv::new().with("AUTUMN_DATABASE__SHARDS__0__NAME", "shard0");
config.apply_env_overrides_with_env(&env);
assert!(config.database.shards.is_empty());
}
fn shard(name: &str, primary_url: &str) -> ShardConfig {
ShardConfig {
name: name.to_owned(),
primary_url: primary_url.to_owned(),
slots: None,
replica_url: None,
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: None,
}
}
fn shard_with_slots(name: &str, primary_url: &str, slots: &[&str]) -> ShardConfig {
let mut config = shard(name, primary_url);
config.slots = Some(
slots
.iter()
.map(|spec| SlotSpec::Range((*spec).to_owned()))
.collect(),
);
config
}
#[test]
fn slot_spec_expands_indices_and_ranges() {
assert_eq!(SlotSpec::Index(5).expand().unwrap(), vec![5]);
assert_eq!(SlotSpec::Range("7".to_owned()).expand().unwrap(), vec![7]);
assert_eq!(
SlotSpec::Range("3-6".to_owned()).expand().unwrap(),
vec![3, 4, 5, 6]
);
assert!(SlotSpec::Range("6-3".to_owned()).expand().is_err());
assert!(SlotSpec::Range("x-3".to_owned()).expand().is_err());
assert!(SlotSpec::Range(String::new()).expand().is_err());
}
#[test]
fn slot_map_auto_splits_contiguously_by_declaration_order() {
let config = DatabaseConfig {
shards: vec![
shard("a", "postgres://a/app"),
shard("b", "postgres://b/app"),
shard("c", "postgres://c/app"),
],
..Default::default()
};
let map = config
.resolved_slot_map()
.expect("auto-split should resolve");
assert_eq!(map.len(), usize::from(SLOT_COUNT));
assert_eq!((map[0], map[5461]), (0, 0));
assert_eq!((map[5462], map[10922]), (1, 1));
assert_eq!((map[10923], map[16383]), (2, 2));
assert!(map.windows(2).all(|w| w[0] <= w[1]), "must be contiguous");
for owner in 0..3 {
let count = map.iter().filter(|&&o| o == owner).count();
assert!(
(5461..=5462).contains(&count),
"shard {owner} owns {count} slots (expected near-even split)"
);
}
}
#[test]
fn slot_map_uses_explicit_assignments_regardless_of_order() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("late", "postgres://late/app", &["8192-16383"]),
shard_with_slots("early", "postgres://early/app", &["0-8191"]),
],
..Default::default()
};
let map = config
.resolved_slot_map()
.expect("explicit map should resolve");
assert!(map[..8192].iter().all(|&owner| owner == 1));
assert!(map[8192..].iter().all(|&owner| owner == 0));
}
#[test]
fn slot_map_allows_drained_shard_with_empty_slots() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("live", "postgres://live/app", &["0-16383"]),
shard_with_slots("drained", "postgres://drained/app", &[]),
],
..Default::default()
};
let map = config
.resolved_slot_map()
.expect("drained shard is allowed");
assert_eq!(map.len(), usize::from(SLOT_COUNT));
assert!(map.iter().all(|&owner| owner == 0));
}
#[test]
fn slot_map_rejects_mixed_declared_and_undeclared_slots() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("a", "postgres://a/app", &["0-16383"]),
shard("b", "postgres://b/app"),
],
..Default::default()
};
assert!(config.resolved_slot_map().is_err());
}
#[test]
fn slot_map_rejects_overlap_gap_and_out_of_range() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("a", "postgres://a/app", &["0-8192"]),
shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
],
..Default::default()
};
let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
panic!("overlapping slots should fail");
};
assert!(message.contains("already owned"));
let config = DatabaseConfig {
shards: vec![
shard_with_slots("a", "postgres://a/app", &["0-8000"]),
shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
],
..Default::default()
};
let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
panic!("uncovered slots should fail");
};
assert!(message.contains("unassigned"));
assert!(message.contains("8001-8191"), "got: {message}");
let config = DatabaseConfig {
shards: vec![shard_with_slots("a", "postgres://a/app", &["0-16384"])],
..Default::default()
};
assert!(config.resolved_slot_map().is_err());
}
#[test]
fn slot_map_rejects_more_shards_than_slots() {
let config = DatabaseConfig {
shards: (0..=usize::from(SLOT_COUNT))
.map(|i| shard(&format!("s{i}"), "postgres://s/app"))
.collect(),
..Default::default()
};
let Err(ConfigError::Validation(message)) = config.resolved_slot_map() else {
panic!("more shards than slots cannot auto-split");
};
assert!(message.contains("at most"), "got: {message}");
}
#[test]
fn slots_parse_from_toml_ints_and_ranges() {
let config: AutumnConfig = toml::from_str(
r#"
[[database.shards]]
name = "a"
primary_url = "postgres://a/app"
slots = ["0-8191", 8192, "8193"]
[[database.shards]]
name = "b"
primary_url = "postgres://b/app"
slots = ["8194-16383"]
"#,
)
.expect("slots config should parse");
let map = config
.database
.resolved_slot_map()
.expect("mixed int/range specs should resolve");
assert!(map[..8194].iter().all(|&owner| owner == 0));
assert!(map[8194..].iter().all(|&owner| owner == 1));
config.validate().expect("config should validate");
}
#[test]
fn slot_env_overrides_assignments() {
let mut config = AutumnConfig::default();
let env = MockEnv::new()
.with("AUTUMN_DATABASE__SHARDS__0__NAME", "a")
.with(
"AUTUMN_DATABASE__SHARDS__0__PRIMARY_URL",
"postgres://a/app",
)
.with("AUTUMN_DATABASE__SHARDS__0__SLOTS", "0-8191, 12288-16383")
.with("AUTUMN_DATABASE__SHARDS__1__NAME", "b")
.with(
"AUTUMN_DATABASE__SHARDS__1__PRIMARY_URL",
"postgres://b/app",
)
.with("AUTUMN_DATABASE__SHARDS__1__SLOTS", "8192-12287");
config.apply_env_overrides_with_env(&env);
let map = config
.database
.resolved_slot_map()
.expect("env slot specs should resolve");
assert!(map[..8192].iter().all(|&owner| owner == 0));
assert!(map[8192..12288].iter().all(|&owner| owner == 1));
assert!(map[12288..].iter().all(|&owner| owner == 0));
}
#[test]
fn slot_ranges_format_compactly() {
assert_eq!(format_slot_ranges(&[]), "");
assert_eq!(format_slot_ranges(&[3]), "3");
assert_eq!(format_slot_ranges(&[0, 1, 2, 5, 7, 8]), "0-2, 5, 7-8");
}
#[test]
fn database_shard_validation_rejects_bad_names() {
for bad_name in ["", "Shard0", "shard 0", "shard:0", "shärd"] {
let config = DatabaseConfig {
shards: vec![shard(bad_name, "postgres://s0.example/app")],
..Default::default()
};
assert!(
config.validate().is_err(),
"shard name should be rejected: {bad_name:?}"
);
}
}
#[test]
fn database_shard_validation_rejects_duplicate_names() {
let config = DatabaseConfig {
shards: vec![
shard("shard0", "postgres://a.example/app"),
shard("shard0", "postgres://b.example/app"),
],
..Default::default()
};
let Err(ConfigError::Validation(message)) = config.validate() else {
panic!("duplicate shard names should fail validation");
};
assert!(message.contains("unique"));
}
#[test]
fn database_shard_validation_rejects_bad_urls() {
let config = DatabaseConfig {
shards: vec![shard("shard0", "mysql://s0.example/app")],
..Default::default()
};
assert!(config.validate().is_err());
let mut with_bad_replica = shard("shard0", "postgres://s0.example/app");
with_bad_replica.replica_url = Some("http://s0-ro.example/app".to_owned());
let config = DatabaseConfig {
shards: vec![with_bad_replica],
..Default::default()
};
assert!(config.validate().is_err());
}
#[test]
fn database_shards_without_control_role_are_allowed() {
let config = DatabaseConfig {
shards: vec![shard("shard0", "postgres://s0.example/app")],
..Default::default()
};
config
.validate()
.expect("shards without a control role should validate");
}
#[test]
fn postgres_scheduler_with_shards_requires_control_database() {
let mut config = AutumnConfig::default();
config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
config.scheduler.backend = SchedulerBackend::Postgres;
let Err(ConfigError::Validation(message)) = config.validate() else {
panic!("postgres scheduler without a control database should fail validation");
};
assert!(message.contains("control database"));
config.database.primary_url = Some("postgres://control.example/app".to_owned());
config
.validate()
.expect("control role should satisfy the scheduler requirement");
}
#[test]
fn postgres_jobs_with_shards_requires_control_database() {
let mut config = AutumnConfig::default();
config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
config.jobs.backend = "postgres".to_owned();
assert!(config.validate().is_err());
config.database.url = Some("postgres://control.example/app".to_owned());
config
.validate()
.expect("legacy url should satisfy the jobs requirement");
}
#[test]
fn shard_local_jobs_need_postgres_jobs_and_shards() {
let mut config = AutumnConfig::default();
config.jobs.postgres.shard_local = true;
let Err(ConfigError::Validation(message)) = config.validate() else {
panic!("shard_local without postgres jobs and shards should fail validation");
};
assert!(message.contains("shard_local"), "{message}");
config.jobs.backend = "postgres".to_owned();
assert!(config.validate().is_err(), "shard_local without shards");
config.database.shards = vec![shard("shard0", "postgres://s0.example/app")];
config.database.primary_url = Some("postgres://control.example/app".to_owned());
config
.validate()
.expect("postgres jobs with shards satisfy shard_local");
}
#[test]
fn database_validate_url_edge_cases() {
let invalid_urls = vec![
"POSTGRES://localhost/db",
"postgres:/localhost/db",
"postgres:localhost/db",
"http://postgres",
" postgres://localhost/db",
"",
];
for invalid_url in invalid_urls {
let config = DatabaseConfig {
url: Some(invalid_url.to_string()),
..Default::default()
};
assert!(
config.validate().is_err(),
"URL should be invalid: {invalid_url}"
);
}
}
#[test]
fn autumn_config_validate_ok() {
let config = AutumnConfig::default();
assert!(config.validate().is_ok());
}
#[test]
fn autumn_config_validate_no_longer_errors_on_invalid_session_backend() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
config.session.redis.url = None;
config
.validate()
.expect("validate() must accept invalid session backend so custom store can override");
}
#[test]
fn autumn_config_validate_database_err() {
let mut config = AutumnConfig::default();
config.database.url = Some("mysql://localhost/test".to_string());
assert!(config.validate().is_err());
}
#[test]
fn log_defaults() {
let config = LogConfig::default();
assert_eq!(config.level, "info");
assert_eq!(config.format, LogFormat::Auto);
}
#[test]
fn telemetry_defaults() {
let config = TelemetryConfig::default();
assert!(!config.enabled);
assert_eq!(config.service_name, "autumn-app");
assert!(config.service_namespace.is_none());
assert_eq!(config.service_version, "unknown");
assert_eq!(config.environment, "development");
assert!(config.otlp_endpoint.is_none());
assert_eq!(config.protocol, TelemetryProtocol::Grpc);
assert!(!config.strict);
}
#[test]
fn health_defaults() {
let config = HealthConfig::default();
assert_eq!(config.path, "/health");
assert_eq!(config.live_path, "/live");
assert_eq!(config.ready_path, "/ready");
assert_eq!(config.startup_path, "/startup");
assert!(!config.detailed);
}
#[test]
fn top_level_default_populates_all_sections() {
let config = AutumnConfig::default();
assert_eq!(config.server.port, 3000);
assert!(config.database.url.is_none());
assert_eq!(config.log.level, "info");
assert_eq!(config.health.path, "/health");
}
#[test]
fn deserialize_empty_object_uses_all_defaults() {
let config: AutumnConfig = serde_json::from_str("{}").expect("empty object should parse");
assert_eq!(config.server.port, 3000);
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.shutdown_timeout_secs, 30);
assert!(config.database.url.is_none());
assert_eq!(config.database.pool_size, 10);
assert_eq!(config.database.connect_timeout_secs, 5);
assert!(!config.database.auto_migrate_in_production);
assert_eq!(config.log.level, "info");
assert_eq!(config.log.format, LogFormat::Auto);
assert_eq!(config.health.path, "/health");
}
#[test]
fn deserialize_partial_config_merges_with_defaults() {
let json = r#"{"server": {"port": 8080}}"#;
let config: AutumnConfig = serde_json::from_str(json).expect("partial config should parse");
assert_eq!(config.server.port, 8080);
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.database.pool_size, 10);
assert_eq!(config.log.level, "info");
}
#[test]
fn log_format_variants_deserialize() {
let auto: LogFormat = serde_json::from_str(r#""Auto""#).expect("Auto");
let pretty: LogFormat = serde_json::from_str(r#""Pretty""#).expect("Pretty");
let json: LogFormat = serde_json::from_str(r#""Json""#).expect("Json");
assert_eq!(auto, LogFormat::Auto);
assert_eq!(pretty, LogFormat::Pretty);
assert_eq!(json, LogFormat::Json);
}
#[test]
fn load_missing_file_returns_defaults() {
let config = AutumnConfig::load_from(Path::new("this_file_does_not_exist.toml")).unwrap();
assert_eq!(config.server.port, 3000);
assert!(config.database.url.is_none());
}
#[test]
fn load_valid_full_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(
&path,
r#"
[server]
port = 8080
host = "0.0.0.0"
shutdown_timeout_secs = 60
[database]
url = "postgres://user:pass@db:5432/myapp"
pool_size = 20
connect_timeout_secs = 10
auto_migrate_in_production = true
[log]
level = "debug"
format = "Json"
[health]
path = "/healthz"
"#,
)
.unwrap();
let config = AutumnConfig::load_from(&path).unwrap();
assert_eq!(config.server.port, 8080);
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.shutdown_timeout_secs, 60);
assert_eq!(
config.database.url.as_deref(),
Some("postgres://user:pass@db:5432/myapp")
);
assert_eq!(config.database.pool_size, 20);
assert_eq!(config.database.connect_timeout_secs, 10);
assert!(config.database.auto_migrate_in_production);
assert_eq!(config.log.level, "debug");
assert_eq!(config.log.format, LogFormat::Json);
assert_eq!(config.health.path, "/healthz");
}
#[test]
fn load_partial_config_merges_with_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(&path, "[server]\nport = 9090\n").unwrap();
let config = AutumnConfig::load_from(&path).unwrap();
assert_eq!(config.server.port, 9090);
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.database.pool_size, 10);
assert_eq!(config.log.level, "info");
}
#[test]
fn access_log_defaults_on_with_probe_and_asset_exclusions() {
let log = LogConfig::default();
assert!(log.access_log);
assert_eq!(
log.access_log_exclude,
vec![
"/health",
"/live",
"/ready",
"/startup",
"/actuator",
"/static"
]
);
}
#[test]
fn env_override_access_log_off() {
let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG", "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(!config.log.access_log);
}
#[test]
fn env_override_access_log_exclude_csv() {
let env = MockEnv::new().with("AUTUMN_LOG__ACCESS_LOG_EXCLUDE", "/internal, /probes");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.access_log_exclude, vec!["/internal", "/probes"]);
}
#[test]
fn access_log_is_configurable_from_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(
&path,
"[log]\naccess_log = false\naccess_log_exclude = [\"/internal\"]\n",
)
.unwrap();
let config = AutumnConfig::load_from(&path).unwrap();
assert!(!config.log.access_log);
assert_eq!(config.log.access_log_exclude, vec!["/internal"]);
}
#[test]
fn load_invalid_toml_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(&path, "not valid [[[toml").unwrap();
let result = AutumnConfig::load_from(&path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("invalid autumn.toml"));
}
#[test]
fn load_empty_file_returns_defaults() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(&path, "").unwrap();
let config = AutumnConfig::load_from(&path).unwrap();
assert_eq!(config.server.port, 3000);
}
#[test]
fn env_override_database_url() {
let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://override:5432/test");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.database.url.as_deref(),
Some("postgres://override:5432/test")
);
}
#[test]
fn metrics_section_defaults_to_the_shipped_caps() {
let config = AutumnConfig::default();
assert_eq!(config.metrics.limits(), crate::metrics::Limits::default());
let parsed: AutumnConfig =
toml::from_str("[server]\nport = 3000\n").expect("config without [metrics] parses");
assert_eq!(parsed.metrics.limits(), crate::metrics::Limits::default());
}
#[test]
fn metrics_section_parses_a_raised_cap() {
let parsed: AutumnConfig =
toml::from_str("[metrics]\nmax_series_per_metric = 500\nmax_labels_per_series = 12\n")
.expect("[metrics] parses");
assert_eq!(parsed.metrics.max_series_per_metric, 500);
assert_eq!(parsed.metrics.max_labels_per_series, 12);
assert_eq!(
parsed.metrics.max_instruments,
crate::metrics::DEFAULT_MAX_INSTRUMENTS,
"an unset key keeps its default rather than zeroing"
);
parsed.metrics.validate().expect("in-range caps validate");
}
#[test]
fn env_override_metrics_caps() {
let env = MockEnv::new()
.with("AUTUMN_METRICS__MAX_SERIES_PER_METRIC", "500")
.with("AUTUMN_METRICS__MAX_INSTRUMENTS", "512")
.with("AUTUMN_METRICS__MAX_LABELS_PER_SERIES", "12");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.metrics.limits(),
crate::metrics::Limits {
max_series_per_metric: 500,
max_instruments: 512,
max_labels_per_series: 12,
}
);
}
#[test]
fn validate_rejects_an_out_of_range_metrics_cap() {
let mut config = AutumnConfig::default();
config.metrics.max_series_per_metric = 0;
let error = config
.validate()
.expect_err("a zero cap must not validate")
.to_string();
assert!(
error.contains("max_series_per_metric"),
"the error must name the key at fault; got {error}"
);
let mut config = AutumnConfig::default();
config.metrics.max_labels_per_series = crate::metrics::MAX_LABELS_PER_SERIES_CEILING + 1;
assert!(
config.validate().is_err(),
"a cap above its ceiling must not validate"
);
AutumnConfig::default()
.validate()
.expect("the default [metrics] section validates");
}
#[test]
fn env_override_actuator_prometheus_disables() {
let env = MockEnv::new().with("AUTUMN_ACTUATOR__PROMETHEUS", "false");
let mut config = AutumnConfig::default();
assert!(config.actuator.prometheus, "default should be enabled");
config.apply_env_overrides_with_env(&env);
assert!(
!config.actuator.prometheus,
"AUTUMN_ACTUATOR__PROMETHEUS=false must disable the scrape endpoint"
);
}
#[test]
fn env_override_actuator_sensitive() {
let env = MockEnv::new().with("AUTUMN_ACTUATOR__SENSITIVE", "true");
let mut config = AutumnConfig::default();
assert!(!config.actuator.sensitive);
config.apply_env_overrides_with_env(&env);
assert!(config.actuator.sensitive);
}
#[test]
fn env_override_upload_reject_on_content_type_mismatch() {
let env = MockEnv::new().with(
"AUTUMN_SECURITY__UPLOAD__REJECT_ON_CONTENT_TYPE_MISMATCH",
"true",
);
let mut config = AutumnConfig::default();
assert!(!config.security.upload.reject_on_content_type_mismatch);
config.apply_env_overrides_with_env(&env);
assert!(config.security.upload.reject_on_content_type_mismatch);
}
#[test]
fn env_override_actuator_prefix() {
let env = MockEnv::new().with("AUTUMN_ACTUATOR__PREFIX", "/ops");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.actuator.prefix, "/ops");
}
#[test]
fn env_override_database_url_wins_over_file_primary_url() {
let env = MockEnv::new().with("AUTUMN_DATABASE__URL", "postgres://env.example/app");
let mut config = AutumnConfig::default();
config.database.primary_url = Some("postgres://file.example/app".to_owned());
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.database.effective_primary_url(),
Some("postgres://env.example/app")
);
assert!(config.database.primary_url.is_none());
}
#[test]
fn env_override_database_primary_url_wins_over_legacy_database_url() {
let env = MockEnv::new()
.with("AUTUMN_DATABASE__URL", "postgres://legacy.env/app")
.with("AUTUMN_DATABASE__PRIMARY_URL", "postgres://primary.env/app");
let mut config = AutumnConfig::default();
config.database.primary_url = Some("postgres://file.example/app".to_owned());
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.database.effective_primary_url(),
Some("postgres://primary.env/app")
);
assert_eq!(
config.database.url.as_deref(),
Some("postgres://legacy.env/app")
);
}
#[test]
fn env_override_pool_size() {
let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "25");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.pool_size, 25);
}
#[cfg(feature = "reporting")]
#[test]
fn env_override_reporting() {
let env = MockEnv::new()
.with("AUTUMN_REPORTING__ENABLED", "false")
.with("AUTUMN_REPORTING__SAMPLE_RATE", "0.1");
let mut config = AutumnConfig::default();
assert!(config.reporting.enabled);
assert!((config.reporting.sample_rate - 1.0).abs() < f64::EPSILON);
config.apply_env_overrides_with_env(&env);
assert!(!config.reporting.enabled);
assert!((config.reporting.sample_rate - 0.1).abs() < f64::EPSILON);
}
#[cfg(feature = "reporting")]
#[test]
fn failure_capture_defaults_are_off() {
let config = AutumnConfig::default();
assert!(
!config.failure_capture.enabled,
"capture writes production request data to disk; it must be opt-in"
);
assert_eq!(config.failure_capture.dir, "tmp/autumn-capsules");
assert_eq!(config.failure_capture.max_body_bytes, 65_536);
assert_eq!(config.failure_capture.max_capsule_bytes, 1_048_576);
assert_eq!(config.failure_capture.max_capsules, 50);
}
#[cfg(feature = "reporting")]
#[test]
fn failure_capture_env_overrides_apply() {
let env = MockEnv::new()
.with("AUTUMN_FAILURE_CAPTURE__ENABLED", "true")
.with("AUTUMN_FAILURE_CAPTURE__DIR", "/var/tmp/capsules")
.with("AUTUMN_FAILURE_CAPTURE__MAX_BODY_BYTES", "1024")
.with("AUTUMN_FAILURE_CAPTURE__MAX_CAPSULE_BYTES", "2048")
.with("AUTUMN_FAILURE_CAPTURE__MAX_CAPSULES", "5");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.failure_capture.enabled);
assert_eq!(config.failure_capture.dir, "/var/tmp/capsules");
assert_eq!(config.failure_capture.max_body_bytes, 1024);
assert_eq!(config.failure_capture.max_capsule_bytes, 2048);
assert_eq!(config.failure_capture.max_capsules, 5);
}
#[cfg(feature = "reporting")]
#[test]
fn failure_capture_child_keys_are_strictly_validated() {
let leaves = AutumnConfig::schema_leaf_paths();
for key in [
"failure_capture.enabled",
"failure_capture.dir",
"failure_capture.max_body_bytes",
"failure_capture.max_capsule_bytes",
"failure_capture.max_capsules",
] {
assert!(
leaves.contains(key),
"{key} must be a schema leaf so strict validation descends into \
[failure_capture]; if this fails, the section was likely moved below \
`database` in AutumnConfig"
);
}
let schema = AutumnConfig::get_schema_keys();
let errors = AutumnConfig::validate_toml("[failure_capture]\nenabledd = true\n", &schema);
assert!(
errors
.iter()
.any(|(path, _)| path == "failure_capture.enabledd"),
"a bogus [failure_capture] child key must be rejected by strict validation, \
got: {errors:?}"
);
}
#[test]
fn cluster_enabled_requires_secret() {
let mut config = AutumnConfig::default();
config.cluster.enabled = true;
config.cluster.secret = None;
let result = config.cluster.validate();
assert!(
result.is_err(),
"[cluster] enabled = true with no secret must fail validation, got {result:?}"
);
let message = result.err().map(|e| e.to_string()).unwrap_or_default();
assert!(
message.contains("secret"),
"the error must name the missing key so an operator can fix it; got {message:?}"
);
assert!(
AutumnConfig::default().cluster.validate().is_ok(),
"a disabled [cluster] section must validate with no secret at all"
);
}
#[test]
fn cluster_rejects_short_secret() {
let mut config = AutumnConfig::default();
config.cluster.enabled = true;
config.cluster.secret = Some(secrecy::SecretString::from("too-short".to_owned()));
let result = config.cluster.validate();
assert!(
result.is_err(),
"a secret shorter than {MIN_CLUSTER_SECRET_LEN} bytes must be refused, got {result:?}"
);
config.cluster.secret = Some(secrecy::SecretString::from(
"a-perfectly-adequate-cluster-secret".to_owned(),
));
assert!(
config.cluster.validate().is_ok(),
"a secret of at least {MIN_CLUSTER_SECRET_LEN} bytes must be accepted"
);
}
#[test]
fn cluster_rejects_suspicion_below_3x_push_interval() {
let mut config = AutumnConfig::default();
config.cluster.enabled = true;
config.cluster.secret = Some(secrecy::SecretString::from(
"a-perfectly-adequate-cluster-secret".to_owned(),
));
config.cluster.push_interval_ms = 500;
config.cluster.suspicion_timeout_ms = 1_000;
let result = config.cluster.validate();
assert!(
result.is_err(),
"suspicion_timeout_ms must be at least {MIN_CLUSTER_SUSPICION_MULTIPLE}x \
push_interval_ms, got {result:?}"
);
config.cluster.suspicion_timeout_ms = 1_500;
assert!(
config.cluster.validate().is_ok(),
"exactly {MIN_CLUSTER_SUSPICION_MULTIPLE}x the push interval must be accepted"
);
config.cluster.push_interval_ms = 1;
config.cluster.suspicion_timeout_ms = 3;
assert!(
config.cluster.validate().is_err(),
"push_interval_ms below {MIN_CLUSTER_PUSH_INTERVAL_MS}ms must be refused"
);
}
#[test]
fn cluster_rejects_node_id_with_cell_separator() {
let mut config = AutumnConfig::default();
config.cluster.enabled = true;
config.cluster.secret = Some(secrecy::SecretString::from(
"a-perfectly-adequate-cluster-secret".to_owned(),
));
config.cluster.node_id = Some("node#1".to_owned());
assert!(
config.cluster.validate().is_err(),
"a node_id containing '#' must be refused: it is the counter cell-key separator"
);
config.cluster.node_id = Some("node-1".to_owned());
config.cluster.cluster_name = "orch#ard".to_owned();
assert!(
config.cluster.validate().is_err(),
"a cluster_name containing '#' must be refused for the same reason"
);
config.cluster.cluster_name = "orchard".to_owned();
assert!(
config.cluster.validate().is_ok(),
"…and separator-free names must be accepted, or the rule is vacuous"
);
}
#[test]
fn cluster_rejects_ephemeral_port_where_a_peer_must_dial() {
let mut config = AutumnConfig::default();
config.cluster.enabled = true;
config.cluster.secret = Some(secrecy::SecretString::from(
"a-perfectly-adequate-cluster-secret".to_owned(),
));
config.cluster.bind_addr = "127.0.0.1:0".to_owned();
assert!(
config.cluster.validate().is_ok(),
"bind_addr with port 0 is the ephemeral-bind spelling and must be accepted"
);
config.cluster.advertise_addr = Some("10.0.0.4:0".to_owned());
let result = config.cluster.validate();
assert!(
result.is_err(),
"an explicit advertise_addr on port 0 must be refused: peers dial it verbatim, \
got {result:?}"
);
let message = result.err().map(|e| e.to_string()).unwrap_or_default();
assert!(
message.contains("cluster.advertise_addr"),
"the error must name the offending key; got {message:?}"
);
config.cluster.advertise_addr = Some("10.0.0.4:7946".to_owned());
assert!(
config.cluster.validate().is_ok(),
"…and a real advertised port must be accepted, or the rule is vacuous"
);
config.cluster.seed_peers = vec!["10.0.0.5:7946".to_owned(), "10.0.0.6:0".to_owned()];
let result = config.cluster.validate();
assert!(
result.is_err(),
"a seed peer on port 0 is equally undialable and must be refused, got {result:?}"
);
let message = result.err().map(|e| e.to_string()).unwrap_or_default();
assert!(
message.contains("cluster.seed_peers[1]"),
"the error must name which seed is wrong; got {message:?}"
);
config.cluster.seed_peers = vec!["10.0.0.5:7946".to_owned()];
assert!(
config.cluster.validate().is_ok(),
"…and dialable seeds must be accepted"
);
}
#[test]
fn cluster_rejects_a_wildcard_advertised_address() {
let mut config = AutumnConfig::default();
config.cluster.enabled = true;
config.cluster.secret = Some(secrecy::SecretString::from(
"a-perfectly-adequate-cluster-secret".to_owned(),
));
config.cluster.bind_addr = "0.0.0.0:7946".to_owned();
let result = config.cluster.validate();
assert!(
result.is_err(),
"binding a wildcard with no advertise_addr must be refused: the node \
would gossip 0.0.0.0 as its dial address, got {result:?}"
);
let message = result.err().map(|e| e.to_string()).unwrap_or_default();
assert!(
message.contains("cluster.bind_addr"),
"the error must name the key the operator has to fix; got {message:?}"
);
config.cluster.advertise_addr = Some("0.0.0.0:7946".to_owned());
let result = config.cluster.validate();
assert!(
result.is_err(),
"an explicit wildcard advertise_addr must be refused too, got {result:?}"
);
let message = result.err().map(|e| e.to_string()).unwrap_or_default();
assert!(
message.contains("cluster.advertise_addr"),
"…and must blame advertise_addr, not the (legal) wildcard bind; \
got {message:?}"
);
config.cluster.advertise_addr = Some("10.0.1.7:7946".to_owned());
assert!(
config.cluster.validate().is_ok(),
"0.0.0.0 bind + explicit advertise_addr is the documented spelling \
and must be accepted"
);
config.cluster.enabled = false;
config.cluster.advertise_addr = None;
assert!(
config.cluster.validate().is_ok(),
"a disabled section advertises nothing, so the wildcard rule must \
not fire on it"
);
}
#[test]
fn cluster_rejects_over_long_idents() {
let mut config = AutumnConfig::default();
config.cluster.enabled = true;
config.cluster.secret = Some(secrecy::SecretString::from(
"a-perfectly-adequate-cluster-secret".to_owned(),
));
let at_cap = "n".repeat(MAX_CLUSTER_IDENT_LEN);
let over_cap = "n".repeat(MAX_CLUSTER_IDENT_LEN.saturating_add(1));
config.cluster.node_id = Some(at_cap.clone());
assert!(
config.cluster.validate().is_ok(),
"exactly {MAX_CLUSTER_IDENT_LEN} bytes must be accepted, or the cap \
is off by one"
);
config.cluster.node_id = Some(over_cap.clone());
let result = config.cluster.validate();
assert!(
result.is_err(),
"a node_id one byte over {MAX_CLUSTER_IDENT_LEN} must be refused, \
got {result:?}"
);
let message = result.err().map(|e| e.to_string()).unwrap_or_default();
assert!(
message.contains("cluster.node_id"),
"the error must name the offending key; got {message:?}"
);
config.cluster.node_id = Some("node-a".to_owned());
config.cluster.cluster_name = over_cap;
let result = config.cluster.validate();
assert!(
result.is_err(),
"an over-long cluster_name must be refused for the same reason, \
got {result:?}"
);
let message = result.err().map(|e| e.to_string()).unwrap_or_default();
assert!(
message.contains("cluster.cluster_name"),
"…naming cluster_name this time; got {message:?}"
);
config.cluster.cluster_name = at_cap;
assert!(
config.cluster.validate().is_ok(),
"…and a cluster_name at the cap must be accepted, or the rule is vacuous"
);
}
#[test]
fn shadow_env_overrides_apply() {
let env = MockEnv::new()
.with("AUTUMN_SHADOW__ENABLED", "true")
.with("AUTUMN_SHADOW__TARGET", "http://127.0.0.1:9091")
.with("AUTUMN_SHADOW__SAMPLE_RATE", "0.25")
.with("AUTUMN_SHADOW__ROUTES", "/api/*, /status")
.with("AUTUMN_SHADOW__TIMEOUT_MS", "750")
.with("AUTUMN_SHADOW__MAX_IN_FLIGHT", "16")
.with("AUTUMN_SHADOW__MAX_BODY_BYTES", "4096")
.with("AUTUMN_SHADOW__MAX_RECORDS", "12")
.with("AUTUMN_SHADOW__MAX_SAMPLE_BYTES", "256");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.shadow.enabled);
assert_eq!(
config.shadow.target.as_deref(),
Some("http://127.0.0.1:9091")
);
assert!((config.shadow.sample_rate - 0.25).abs() < f64::EPSILON);
assert_eq!(config.shadow.routes, vec!["/api/*", "/status"]);
assert_eq!(config.shadow.timeout_ms, 750);
assert_eq!(config.shadow.max_in_flight, 16);
assert_eq!(config.shadow.max_body_bytes, 4096);
assert_eq!(config.shadow.max_records, 12);
assert_eq!(config.shadow.max_sample_bytes, 256);
assert!(config.validate().is_ok());
}
#[test]
fn an_empty_shadow_routes_override_does_not_disable_mirroring() {
let env = MockEnv::new().with("AUTUMN_SHADOW__ROUTES", "");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.shadow.routes.is_empty());
}
#[test]
fn top_level_validation_rejects_an_unusable_shadow_section() {
let mut config = AutumnConfig::default();
config.shadow.enabled = true;
let error = config.validate().expect_err("must reject");
assert!(
error.to_string().contains("shadow.target"),
"the boot failure must name the missing key, got: {error}"
);
}
#[test]
fn cluster_env_overrides_apply() {
let env = MockEnv::new()
.with("AUTUMN_CLUSTER__ENABLED", "true")
.with("AUTUMN_CLUSTER__SECRET", "a-shared-cluster-secret-value")
.with("AUTUMN_CLUSTER__CLUSTER_NAME", "orchard")
.with("AUTUMN_CLUSTER__BIND_ADDR", "0.0.0.0:7946")
.with("AUTUMN_CLUSTER__ADVERTISE_ADDR", "10.0.0.4:7946")
.with("AUTUMN_CLUSTER__SEED_PEERS", "10.0.0.5:7946, 10.0.0.6:7946")
.with("AUTUMN_CLUSTER__NODE_ID", "node-a")
.with("AUTUMN_CLUSTER__PUSH_INTERVAL_MS", "250")
.with("AUTUMN_CLUSTER__SUSPICION_TIMEOUT_MS", "1500");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.cluster.enabled);
assert_eq!(
config
.cluster
.secret
.as_ref()
.map(|s| secrecy::ExposeSecret::expose_secret(s).to_owned()),
Some("a-shared-cluster-secret-value".to_owned())
);
assert_eq!(config.cluster.cluster_name, "orchard");
assert_eq!(config.cluster.bind_addr, "0.0.0.0:7946");
assert_eq!(
config.cluster.advertise_addr.as_deref(),
Some("10.0.0.4:7946")
);
assert_eq!(
config.cluster.seed_peers,
vec!["10.0.0.5:7946".to_owned(), "10.0.0.6:7946".to_owned()],
"AUTUMN_CLUSTER__SEED_PEERS must split on commas and trim"
);
assert_eq!(config.cluster.node_id.as_deref(), Some("node-a"));
assert_eq!(config.cluster.push_interval_ms, 250);
assert_eq!(config.cluster.suspicion_timeout_ms, 1_500);
}
#[test]
fn env_override_connect_timeout() {
let env = MockEnv::new().with("AUTUMN_DATABASE__CONNECT_TIMEOUT_SECS", "15");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.connect_timeout_secs, 15);
}
#[test]
fn env_override_read_your_writes() {
let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "request");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.read_your_writes, ReadYourWrites::Request);
}
#[test]
fn env_override_read_your_writes_session() {
let env = MockEnv::new().with("AUTUMN_DATABASE__READ_YOUR_WRITES", "session");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.read_your_writes, ReadYourWrites::Session);
}
#[test]
fn env_override_pin_after_write_secs() {
let env = MockEnv::new().with("AUTUMN_DATABASE__PIN_AFTER_WRITE_SECS", "10");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.pin_after_write_secs, 10);
}
#[test]
fn replica_max_lag_and_pooler_warning_config() {
let config = AutumnConfig::default();
assert_eq!(config.database.replica_max_lag_ms, None);
assert!(config.database.warn_on_pooler);
let parsed: AutumnConfig =
toml::from_str("[database]\nreplica_max_lag_ms = 2500\nwarn_on_pooler = false\n")
.unwrap();
assert_eq!(parsed.database.replica_max_lag_ms, Some(2500));
assert!(!parsed.database.warn_on_pooler);
let env = MockEnv::new()
.with("AUTUMN_DATABASE__REPLICA_MAX_LAG_MS", "750")
.with("AUTUMN_DATABASE__WARN_ON_POOLER", "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.replica_max_lag_ms, Some(750));
assert!(!config.database.warn_on_pooler);
}
#[test]
fn env_override_invalid_pool_size_ignored() {
let env = MockEnv::new().with("AUTUMN_DATABASE__POOL_SIZE", "not_a_number");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.pool_size, 10);
}
#[test]
fn env_override_magic_link_ttl_minutes() {
let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.ttl_minutes, 45);
}
#[test]
fn env_override_magic_link_email_cooldown_secs() {
let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
}
#[test]
fn env_override_magic_link_overrides_toml_value() {
let env = MockEnv::new()
.with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "45")
.with("AUTUMN_AUTH__MAGIC_LINK__EMAIL_COOLDOWN_SECS", "120");
let mut config = AutumnConfig::default();
config.auth.magic_link.ttl_minutes = 30;
config.auth.magic_link.email_cooldown_secs = 200;
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.ttl_minutes, 45);
assert_eq!(config.auth.magic_link.email_cooldown_secs, 120);
}
#[test]
fn env_unset_leaves_magic_link_toml_value_intact() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.auth.magic_link.ttl_minutes = 30;
config.auth.magic_link.email_cooldown_secs = 200;
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.ttl_minutes, 30);
assert_eq!(config.auth.magic_link.email_cooldown_secs, 200);
}
#[test]
fn env_override_invalid_magic_link_ttl_minutes_ignored() {
let env = MockEnv::new().with("AUTUMN_AUTH__MAGIC_LINK__TTL_MINUTES", "not_a_number");
let mut config = AutumnConfig::default();
config.auth.magic_link.ttl_minutes = 30;
config.apply_env_overrides_with_env(&env);
assert_eq!(config.auth.magic_link.ttl_minutes, 30);
}
#[test]
fn startup_wait_secs_default_is_zero() {
assert_eq!(DatabaseConfig::default().startup_wait_secs, 0);
}
#[test]
fn env_override_startup_wait_secs() {
let env = MockEnv::new().with("AUTUMN_DATABASE__STARTUP_WAIT_SECS", "60");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.startup_wait_secs, 60);
}
#[test]
fn startup_wait_secs_parses_from_toml() {
let config: AutumnConfig = toml::from_str("[database]\nstartup_wait_secs = 30").unwrap();
assert_eq!(config.database.startup_wait_secs, 30);
}
#[cfg(feature = "storage")]
#[test]
fn env_override_storage_fields() {
let env = MockEnv::new()
.with("AUTUMN_STORAGE__BACKEND", "s3")
.with("AUTUMN_STORAGE__DEFAULT_PROVIDER", "media")
.with("AUTUMN_STORAGE__ALLOW_LOCAL_IN_PRODUCTION", "true")
.with("AUTUMN_STORAGE__LOCAL__ROOT", "var/blobs")
.with("AUTUMN_STORAGE__LOCAL__MOUNT_PATH", "/files")
.with("AUTUMN_STORAGE__LOCAL__DEFAULT_URL_EXPIRY_SECS", "42")
.with("AUTUMN_STORAGE__LOCAL__SIGNING_KEY", "secret")
.with("AUTUMN_STORAGE__S3__BUCKET", "uploads")
.with("AUTUMN_STORAGE__S3__REGION", "us-east-1")
.with("AUTUMN_STORAGE__S3__ENDPOINT", "https://s3.example.test")
.with(
"AUTUMN_STORAGE__S3__PUBLIC_BASE_URL",
"https://cdn.example.test",
)
.with("AUTUMN_STORAGE__S3__ACCESS_KEY_ID_ENV", "AWS_ACCESS_KEY_ID")
.with(
"AUTUMN_STORAGE__S3__SECRET_ACCESS_KEY_ENV",
"AWS_SECRET_ACCESS_KEY",
)
.with("AUTUMN_STORAGE__S3__FORCE_PATH_STYLE", "true")
.with("AUTUMN_STORAGE__S3__DEFAULT_URL_EXPIRY_SECS", "99")
.with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_BYTES", "5242880")
.with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_WIDTH", "2000")
.with("AUTUMN_STORAGE__VARIANTS__MAX_SOURCE_HEIGHT", "1500");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.storage.backend, crate::storage::StorageBackend::S3);
assert_eq!(config.storage.default_provider, "media");
assert!(config.storage.allow_local_in_production);
assert_eq!(config.storage.local.root, PathBuf::from("var/blobs"));
assert_eq!(config.storage.local.mount_path, "/files");
assert_eq!(config.storage.local.default_url_expiry_secs, 42);
assert_eq!(config.storage.local.signing_key.as_deref(), Some("secret"));
assert_eq!(config.storage.s3.bucket.as_deref(), Some("uploads"));
assert_eq!(config.storage.s3.region.as_deref(), Some("us-east-1"));
assert_eq!(
config.storage.s3.endpoint.as_deref(),
Some("https://s3.example.test")
);
assert_eq!(
config.storage.s3.public_base_url.as_deref(),
Some("https://cdn.example.test")
);
assert_eq!(
config.storage.s3.access_key_id_env.as_deref(),
Some("AWS_ACCESS_KEY_ID")
);
assert_eq!(
config.storage.s3.secret_access_key_env.as_deref(),
Some("AWS_SECRET_ACCESS_KEY")
);
assert!(config.storage.s3.force_path_style);
assert_eq!(config.storage.s3.default_url_expiry_secs, 99);
assert_eq!(config.storage.variants.max_source_bytes, 5_242_880);
assert_eq!(config.storage.variants.max_source_width, 2_000);
assert_eq!(config.storage.variants.max_source_height, 1_500);
}
#[test]
fn replication_parses_from_toml() {
let toml = r#"
[replication]
enabled = true
rpo_secs = 5
sync_interval_secs = 2
snapshot_interval_secs = 900
max_wal_bytes = 8388608
retention_hours = 48
verify_interval_secs = 3600
prefix = "db"
allow_shared_bucket = true
[replication.s3]
bucket = "myapp-replicas"
region = "auto"
endpoint = "https://acct.r2.cloudflarestorage.com"
access_key_id_env = "AUTUMN_REPLICA_ACCESS_KEY_ID"
secret_access_key_env = "AUTUMN_REPLICA_SECRET_ACCESS_KEY"
force_path_style = true
"#;
let config: AutumnConfig = toml::from_str(toml).expect("parse");
let replication = *config.replication.expect("section present");
assert!(replication.enabled);
assert_eq!(replication.rpo_secs, 5);
assert_eq!(
replication.sync_interval(),
std::time::Duration::from_secs(2)
);
assert_eq!(replication.snapshot_interval_secs, 900);
assert_eq!(replication.max_wal_bytes, 8_388_608);
assert_eq!(replication.retention_hours, 48);
assert_eq!(
replication.verify_interval(),
Some(std::time::Duration::from_secs(3600))
);
assert_eq!(replication.prefix.as_deref(), Some("db"));
assert!(replication.allow_shared_bucket);
let s3 = replication.s3.expect("s3 destination");
assert_eq!(s3.bucket.as_deref(), Some("myapp-replicas"));
assert_eq!(s3.region.as_deref(), Some("auto"));
assert_eq!(
s3.endpoint.as_deref(),
Some("https://acct.r2.cloudflarestorage.com")
);
assert_eq!(
s3.access_key_id_env.as_deref(),
Some("AUTUMN_REPLICA_ACCESS_KEY_ID")
);
assert_eq!(
s3.secret_access_key_env.as_deref(),
Some("AUTUMN_REPLICA_SECRET_ACCESS_KEY")
);
assert!(s3.force_path_style);
}
#[test]
fn replication_defaults_to_absent_and_carries_the_documented_rpo() {
let config = AutumnConfig::default();
assert!(config.replication.is_none());
let defaults = ReplicationConfig::default();
assert!(!defaults.enabled, "replication is opt-in");
assert_eq!(defaults.rpo_secs, 10, "AC #2: at most 10s of data loss");
assert_eq!(defaults.retention_hours, 168);
assert_eq!(defaults.max_wal_bytes, 16 * 1024 * 1024);
assert!(
!defaults.allow_shared_bucket,
"#1619's distinct-bucket posture"
);
assert!(defaults.validation_errors().is_empty(), "disabled is valid");
}
#[test]
fn replication_validation_names_every_problem() {
let no_destination = ReplicationConfig {
enabled: true,
..ReplicationConfig::default()
};
assert!(
no_destination
.validation_errors()
.iter()
.any(|e| e.contains("no destination is configured")),
"{:?}",
no_destination.validation_errors()
);
let both = ReplicationConfig {
enabled: true,
path: Some("/mnt/replica".to_owned()),
s3: Some(ReplicationS3Config {
bucket: Some("b".to_owned()),
..ReplicationS3Config::default()
}),
..ReplicationConfig::default()
};
assert!(
both.validation_errors()
.iter()
.any(|e| e.contains("pick exactly one destination"))
);
let no_credentials = ReplicationConfig {
enabled: true,
s3: Some(ReplicationS3Config {
bucket: Some("b".to_owned()),
..ReplicationS3Config::default()
}),
rpo_secs: 0,
retention_hours: 0,
..ReplicationConfig::default()
};
let errors = no_credentials.validation_errors();
assert!(
errors.iter().any(|e| e.contains("access_key_id_env")),
"{errors:?}"
);
assert!(errors.iter().any(|e| e.contains("rpo_secs")), "{errors:?}");
assert!(
errors.iter().any(|e| e.contains("retention_hours")),
"{errors:?}"
);
}
#[test]
fn env_override_replication_fields() {
let env = MockEnv::new()
.with("AUTUMN_REPLICATION__ENABLED", "true")
.with("AUTUMN_REPLICATION__RPO_SECS", "20")
.with("AUTUMN_REPLICATION__SYNC_INTERVAL_SECS", "7")
.with("AUTUMN_REPLICATION__SNAPSHOT_INTERVAL_SECS", "600")
.with("AUTUMN_REPLICATION__MAX_WAL_BYTES", "1048576")
.with("AUTUMN_REPLICATION__RETENTION_HOURS", "12")
.with("AUTUMN_REPLICATION__VERIFY_INTERVAL_SECS", "0")
.with("AUTUMN_REPLICATION__PREFIX", "replicas")
.with("AUTUMN_REPLICATION__ALLOW_SHARED_BUCKET", "true")
.with("AUTUMN_REPLICATION__S3__BUCKET", "from-env")
.with("AUTUMN_REPLICATION__S3__REGION", "auto")
.with(
"AUTUMN_REPLICATION__S3__ENDPOINT",
"https://minio.test:9000",
)
.with("AUTUMN_REPLICATION__S3__ACCESS_KEY_ID_ENV", "KEY")
.with("AUTUMN_REPLICATION__S3__SECRET_ACCESS_KEY_ENV", "SECRET")
.with("AUTUMN_REPLICATION__S3__FORCE_PATH_STYLE", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let replication = *config.replication.expect("materialized from env alone");
assert!(replication.enabled);
assert_eq!(replication.rpo_secs, 20);
assert_eq!(replication.sync_interval_secs, Some(7));
assert_eq!(replication.snapshot_interval_secs, 600);
assert_eq!(replication.max_wal_bytes, 1_048_576);
assert_eq!(replication.retention_hours, 12);
assert_eq!(replication.verify_interval(), None);
assert_eq!(replication.prefix.as_deref(), Some("replicas"));
assert!(replication.allow_shared_bucket);
let s3 = replication.s3.expect("s3 from env");
assert_eq!(s3.bucket.as_deref(), Some("from-env"));
assert_eq!(s3.endpoint.as_deref(), Some("https://minio.test:9000"));
assert!(s3.force_path_style);
}
#[test]
fn replication_env_does_not_materialize_a_section_from_optional_keys_alone() {
let env = MockEnv::new()
.with("AUTUMN_REPLICATION__S3__REGION", "auto")
.with("AUTUMN_REPLICATION__PREFIX", "db")
.with("AUTUMN_REPLICATION__ENABLED", "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.replication.is_none());
}
#[test]
fn a_path_destination_from_env_never_grows_an_empty_s3_section() {
let env = MockEnv::new()
.with("AUTUMN_REPLICATION__ENABLED", "true")
.with("AUTUMN_REPLICATION__PATH", "/mnt/replica");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let replication = *config.replication.expect("materialized");
assert_eq!(replication.path.as_deref(), Some("/mnt/replica"));
assert!(replication.s3.is_none(), "no S3 key was set");
assert!(replication.validation_errors().is_empty());
}
#[test]
fn replication_env_overlays_a_toml_section() {
let mut config: AutumnConfig = toml::from_str(
"[replication]\nenabled = false\nretention_hours = 24\npath = \"/from-toml\"",
)
.expect("parse");
let env = MockEnv::new().with("AUTUMN_REPLICATION__ENABLED", "true");
config.apply_env_overrides_with_env(&env);
let replication = *config.replication.expect("section");
assert!(replication.enabled, "env flips the TOML toggle");
assert_eq!(replication.retention_hours, 24, "TOML value survives");
assert_eq!(replication.path.as_deref(), Some("/from-toml"));
}
#[test]
fn backup_offsite_parses_from_toml() {
let toml = r#"
[backup.offsite]
prefix = "db"
keep = 5
auto_upload = true
allow_shared_bucket = true
[backup.offsite.s3]
bucket = "offsite-backups"
region = "auto"
endpoint = "https://minio.example.test"
access_key_id_env = "OFFSITE_KEY_ID"
secret_access_key_env = "OFFSITE_SECRET"
force_path_style = true
"#;
let config: AutumnConfig = toml::from_str(toml).unwrap();
let offsite = config.backup.offsite.expect("offsite section present");
assert_eq!(offsite.prefix.as_deref(), Some("db"));
assert_eq!(offsite.keep, Some(5));
assert!(offsite.auto_upload);
assert!(offsite.allow_shared_bucket);
assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite-backups"));
assert_eq!(offsite.s3.region.as_deref(), Some("auto"));
assert_eq!(
offsite.s3.endpoint.as_deref(),
Some("https://minio.example.test")
);
assert_eq!(
offsite.s3.access_key_id_env.as_deref(),
Some("OFFSITE_KEY_ID")
);
assert_eq!(
offsite.s3.secret_access_key_env.as_deref(),
Some("OFFSITE_SECRET")
);
assert!(offsite.s3.force_path_style);
}
#[test]
fn backup_offsite_defaults_to_none() {
let config = AutumnConfig::default();
assert!(config.backup.offsite.is_none());
}
#[test]
fn env_override_backup_offsite_fields() {
let env = MockEnv::new()
.with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
.with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
.with(
"AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT",
"https://s3.offsite.test",
)
.with("AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV", "OFF_KEY")
.with(
"AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
"OFF_SECRET",
)
.with("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true")
.with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
.with("AUTUMN_BACKUP__OFFSITE__KEEP", "3")
.with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true")
.with("AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config.backup.offsite.expect("materialized from env");
assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
assert_eq!(
offsite.s3.endpoint.as_deref(),
Some("https://s3.offsite.test")
);
assert_eq!(offsite.s3.access_key_id_env.as_deref(), Some("OFF_KEY"));
assert_eq!(
offsite.s3.secret_access_key_env.as_deref(),
Some("OFF_SECRET")
);
assert!(offsite.s3.force_path_style);
assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
assert_eq!(offsite.keep, Some(3));
assert!(offsite.auto_upload);
assert!(offsite.allow_shared_bucket);
}
#[test]
fn env_override_backup_offsite_absent_stays_none() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.backup.offsite.is_none());
}
#[test]
fn env_override_backup_offsite_lone_opt_out_toggle_stays_none() {
for key in [
"AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD",
"AUTUMN_BACKUP__OFFSITE__ALLOW_SHARED_BUCKET",
] {
let env = MockEnv::new().with(key, "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(
config.backup.offsite.is_none(),
"{key}=false must not materialize an offsite section",
);
}
}
#[test]
fn env_override_backup_offsite_truthy_auto_upload_materializes() {
let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config
.backup
.offsite
.expect("auto_upload=true materializes offsite");
assert!(offsite.auto_upload);
}
#[test]
fn env_override_backup_offsite_destination_key_materializes() {
let env = MockEnv::new()
.with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
.with("AUTUMN_BACKUP__OFFSITE__AUTO_UPLOAD", "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config
.backup
.offsite
.expect("a bucket key materializes offsite");
assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
assert!(!offsite.auto_upload);
}
#[test]
fn env_override_backup_offsite_lone_optional_key_stays_none() {
for (key, val) in [
("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-east-1"),
("AUTUMN_BACKUP__OFFSITE__S3__ENDPOINT", "https://s3.test"),
("AUTUMN_BACKUP__OFFSITE__S3__FORCE_PATH_STYLE", "true"),
("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly"),
("AUTUMN_BACKUP__OFFSITE__KEEP", "3"),
] {
let env = MockEnv::new().with(key, val);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(
config.backup.offsite.is_none(),
"{key} is optional-only and must not materialize an offsite section",
);
}
}
#[test]
fn env_override_backup_offsite_credential_key_materializes() {
for key in [
"AUTUMN_BACKUP__OFFSITE__S3__ACCESS_KEY_ID_ENV",
"AUTUMN_BACKUP__OFFSITE__S3__SECRET_ACCESS_KEY_ENV",
] {
let env = MockEnv::new().with(key, "SOME_ENV_NAME");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(
config.backup.offsite.is_some(),
"{key} is a required credential signal and must materialize offsite",
);
}
}
#[test]
fn env_override_backup_offsite_bucket_only_materializes() {
let env = MockEnv::new().with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config
.backup
.offsite
.expect("a bucket key materializes offsite");
assert_eq!(offsite.s3.bucket.as_deref(), Some("offsite"));
}
#[test]
fn env_override_backup_offsite_region_only_applied_when_materialized() {
let env = MockEnv::new()
.with("AUTUMN_BACKUP__OFFSITE__S3__BUCKET", "offsite")
.with("AUTUMN_BACKUP__OFFSITE__S3__REGION", "us-west-2")
.with("AUTUMN_BACKUP__OFFSITE__PREFIX", "nightly")
.with("AUTUMN_BACKUP__OFFSITE__KEEP", "5");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let offsite = config.backup.offsite.expect("bucket materializes offsite");
assert_eq!(offsite.s3.region.as_deref(), Some("us-west-2"));
assert_eq!(offsite.prefix.as_deref(), Some("nightly"));
assert_eq!(offsite.keep, Some(5));
}
#[test]
fn env_override_database_auto_migrate_in_production() {
let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE_IN_PRODUCTION", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.database.auto_migrate_in_production);
}
#[test]
fn database_auto_migrate_defaults_to_none() {
assert_eq!(DatabaseConfig::default().auto_migrate, None);
}
#[test]
fn env_override_database_auto_migrate() {
let env = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE", "true");
let mut config = AutumnConfig::default();
assert_eq!(config.database.auto_migrate, None);
config.apply_env_overrides_with_env(&env);
assert_eq!(config.database.auto_migrate, Some(true));
let env_false = MockEnv::new().with("AUTUMN_DATABASE__AUTO_MIGRATE", "false");
let mut config_false = AutumnConfig::default();
config_false.apply_env_overrides_with_env(&env_false);
assert_eq!(config_false.database.auto_migrate, Some(false));
}
#[test]
fn env_override_jobs_fields() {
let env = MockEnv::new()
.with("AUTUMN_JOBS__BACKEND", "redis")
.with("AUTUMN_JOBS__WORKERS", "8")
.with("AUTUMN_JOBS__MAX_ATTEMPTS", "12")
.with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "750")
.with("AUTUMN_JOBS__MAX_BACKOFF_MS", "90000")
.with("AUTUMN_JOBS__REDIS__URL", "redis://jobs:6379/2")
.with("AUTUMN_JOBS__REDIS__KEY_PREFIX", "myapp:jobs")
.with("AUTUMN_JOBS__REDIS__VISIBILITY_TIMEOUT_MS", "45000");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.jobs.backend, "redis");
assert_eq!(config.jobs.workers, 8);
assert_eq!(config.jobs.max_attempts, 12);
assert_eq!(config.jobs.initial_backoff_ms, 750);
assert_eq!(config.jobs.max_backoff_ms, 90_000);
assert_eq!(
config.jobs.redis.url.as_deref(),
Some("redis://jobs:6379/2")
);
assert_eq!(config.jobs.redis.key_prefix, "myapp:jobs");
assert_eq!(config.jobs.redis.visibility_timeout_ms, 45_000);
}
#[test]
fn jobs_default_timeout_ms_defaults_to_zero_and_overrides() {
assert_eq!(AutumnConfig::default().jobs.default_timeout_ms, 0);
let config: AutumnConfig = toml::from_str("[jobs]\ndefault_timeout_ms = 30000\n").unwrap();
assert_eq!(config.jobs.default_timeout_ms, 30_000);
let env = MockEnv::new().with("AUTUMN_JOBS__DEFAULT_TIMEOUT_MS", "1500");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.jobs.default_timeout_ms, 1_500);
}
#[test]
fn retention_config_defaults_leave_every_dataset_unset() {
let config = AutumnConfig::default();
assert!(
config.retention.job_history.is_none(),
"job_history must default to unset"
);
assert!(config.retention.job_tracking.is_none());
assert!(config.retention.idempotency.is_none());
assert!(config.retention.experiment_assignments.is_none());
assert!(config.retention.webhook_replay.is_none());
assert!(config.retention.sessions.is_none());
assert!(config.retention.audit_archives.is_none());
assert!(
!config.retention.any_window_configured(),
"a default config must register no retention sweep at all"
);
assert_eq!(
config.retention.sweep_interval, "1h",
"the sweep cadence has a documented default even when no window is set"
);
}
#[test]
fn retention_toml_deserializes_every_dataset_window() {
let config: AutumnConfig = toml::from_str(
r#"
[retention]
sweep_interval = "30m"
job_history = "90d"
job_tracking = "7d"
idempotency = "2d"
experiment_assignments = "365d"
webhook_replay = "3d"
sessions = "30d"
audit_archives = "400d"
"#,
)
.unwrap();
assert_eq!(config.retention.sweep_interval, "30m");
assert_eq!(config.retention.job_history.as_deref(), Some("90d"));
assert_eq!(config.retention.job_tracking.as_deref(), Some("7d"));
assert_eq!(config.retention.idempotency.as_deref(), Some("2d"));
assert_eq!(
config.retention.experiment_assignments.as_deref(),
Some("365d")
);
assert_eq!(config.retention.webhook_replay.as_deref(), Some("3d"));
assert_eq!(config.retention.sessions.as_deref(), Some("30d"));
assert_eq!(config.retention.audit_archives.as_deref(), Some("400d"));
assert!(config.retention.any_window_configured());
config.validate().expect("a well-formed section validates");
}
#[test]
fn env_override_retention_windows() {
let env = MockEnv::new()
.with("AUTUMN_RETENTION__SWEEP_INTERVAL", "15m")
.with("AUTUMN_RETENTION__JOB_HISTORY", "45d")
.with("AUTUMN_RETENTION__JOB_TRACKING", "2d")
.with("AUTUMN_RETENTION__IDEMPOTENCY", "12h")
.with("AUTUMN_RETENTION__EXPERIMENT_ASSIGNMENTS", "180d")
.with("AUTUMN_RETENTION__WEBHOOK_REPLAY", "1d")
.with("AUTUMN_RETENTION__SESSIONS", "14d")
.with("AUTUMN_RETENTION__AUDIT_ARCHIVES", "365d")
.with("AUTUMN_RETENTION__CUSTOM_DOMAINS", "30d");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.retention.sweep_interval, "15m");
assert_eq!(config.retention.job_history.as_deref(), Some("45d"));
assert_eq!(config.retention.job_tracking.as_deref(), Some("2d"));
assert_eq!(config.retention.idempotency.as_deref(), Some("12h"));
assert_eq!(
config.retention.experiment_assignments.as_deref(),
Some("180d")
);
assert_eq!(config.retention.webhook_replay.as_deref(), Some("1d"));
assert_eq!(config.retention.sessions.as_deref(), Some("14d"));
assert_eq!(config.retention.audit_archives.as_deref(), Some("365d"));
assert_eq!(config.retention.custom_domains.as_deref(), Some("30d"));
}
#[test]
fn env_override_retention_empty_value_clears_a_window() {
let env = MockEnv::new().with("AUTUMN_RETENTION__JOB_HISTORY", "");
let mut config = AutumnConfig::default();
config.retention.job_history = Some("90d".to_owned());
config.apply_env_overrides_with_env(&env);
assert!(config.retention.job_history.is_none());
}
#[test]
fn retention_validate_rejects_an_unparseable_window() {
let mut config = AutumnConfig::default();
config.retention.job_history = Some("ninety days".to_owned());
let error = config
.validate()
.expect_err("an unparseable duration must fail boot, not be ignored");
let message = error.to_string();
assert!(
message.contains("retention.job_history"),
"the error must name the offending key: {message}"
);
}
#[test]
fn retention_validate_rejects_a_zero_window() {
let mut config = AutumnConfig::default();
config.retention.sessions = Some("0s".to_owned());
let error = config.validate().expect_err("a zero window must fail boot");
assert!(error.to_string().contains("retention.sessions"), "{error}");
}
#[test]
fn retention_validate_rejects_an_unparseable_sweep_interval() {
let mut config = AutumnConfig::default();
config.retention.job_history = Some("30d".to_owned());
config.retention.sweep_interval = "whenever".to_owned();
let error = config.validate().expect_err("bad cadence must fail boot");
assert!(
error.to_string().contains("retention.sweep_interval"),
"{error}"
);
}
#[test]
fn retention_validate_accepts_a_default_config() {
AutumnConfig::default()
.validate()
.expect("an unconfigured [retention] section must validate");
}
#[test]
fn job_tracking_config_defaults_ttl_86400_and_route_enabled() {
let config = AutumnConfig::default();
assert_eq!(config.jobs.tracking.ttl_secs, 86_400);
assert!(config.jobs.tracking.route_enabled);
}
#[test]
fn env_override_jobs_tracking_fields() {
let env = MockEnv::new()
.with("AUTUMN_JOBS__TRACKING__TTL_SECS", "3600")
.with("AUTUMN_JOBS__TRACKING__ROUTE_ENABLED", "false");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.jobs.tracking.ttl_secs, 3_600);
assert!(!config.jobs.tracking.route_enabled);
}
#[test]
fn jobs_toml_deserializes_tracking_fields() {
let config: AutumnConfig = toml::from_str(
r"
[jobs.tracking]
ttl_secs = 7200
route_enabled = false
",
)
.unwrap();
assert_eq!(config.jobs.tracking.ttl_secs, 7_200);
assert!(!config.jobs.tracking.route_enabled);
}
#[test]
fn jobs_toml_deserializes_redis_visibility_timeout() {
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
backend = "redis"
[jobs.redis]
url = "redis://localhost:6379/5"
key_prefix = "demo:jobs"
visibility_timeout_ms = 15000
"#,
)
.unwrap();
assert_eq!(config.jobs.backend, "redis");
assert_eq!(
config.jobs.redis.url.as_deref(),
Some("redis://localhost:6379/5")
);
assert_eq!(config.jobs.redis.key_prefix, "demo:jobs");
assert_eq!(config.jobs.redis.visibility_timeout_ms, 15_000);
}
#[test]
fn jobs_redis_dead_letter_limit_defaults_parses_and_overrides() {
let defaults = JobRedisConfig::default();
assert_eq!(defaults.dead_letter_limit, 10_000);
assert_eq!(AutumnConfig::default().jobs.redis.dead_letter_limit, 10_000);
let config: AutumnConfig =
toml::from_str("[jobs.redis]\ndead_letter_limit = 0\n").expect("dead_letter_limit");
assert_eq!(config.jobs.redis.dead_letter_limit, 0);
let config: AutumnConfig = toml::from_str("[jobs.redis]\nkey_prefix = \"x\"\n").unwrap();
assert_eq!(config.jobs.redis.dead_letter_limit, 10_000);
let env = MockEnv::new().with("AUTUMN_JOBS__REDIS__DEAD_LETTER_LIMIT", "250");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.jobs.redis.dead_letter_limit, 250);
}
#[test]
fn outbox_validate_rejects_zero_values() {
assert!(OutboxConfig::default().validate().is_ok());
for config in [
OutboxConfig {
lease_ms: 0,
..OutboxConfig::default()
},
OutboxConfig {
max_attempts: 0,
..OutboxConfig::default()
},
OutboxConfig {
batch_size: 0,
..OutboxConfig::default()
},
] {
let error = config.validate().unwrap_err().to_string();
assert!(error.contains("must be greater than zero"), "{error}");
}
let error = OutboxConfig {
initial_backoff_ms: 300_000,
max_backoff_ms: 1_000,
..OutboxConfig::default()
}
.validate()
.unwrap_err()
.to_string();
assert!(error.contains("max_backoff_ms"), "{error}");
}
#[test]
fn outbox_defaults_parse_and_env_overrides() {
let defaults = AutumnConfig::default().outbox;
assert!(!defaults.enabled, "the outbox is off by default");
assert_eq!(defaults, OutboxConfig::default());
assert_eq!(defaults.max_attempts, 10);
let config: AutumnConfig =
toml::from_str("[outbox]\nenabled = true\nlease_ms = 5000\n").expect("outbox");
assert!(config.outbox.enabled);
assert_eq!(config.outbox.lease_ms, 5_000);
assert_eq!(config.outbox.batch_size, 100, "unset keys keep defaults");
let env = MockEnv::new()
.with("AUTUMN_OUTBOX__ENABLED", "true")
.with("AUTUMN_OUTBOX__POLL_INTERVAL_MS", "50")
.with("AUTUMN_OUTBOX__BATCH_SIZE", "7")
.with("AUTUMN_OUTBOX__MAX_ATTEMPTS", "3")
.with("AUTUMN_OUTBOX__INITIAL_BACKOFF_MS", "20")
.with("AUTUMN_OUTBOX__MAX_BACKOFF_MS", "200")
.with("AUTUMN_OUTBOX__LEASE_MS", "900")
.with("AUTUMN_OUTBOX__RETENTION_MS", "1000");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.outbox,
OutboxConfig {
enabled: true,
poll_interval_ms: 50,
batch_size: 7,
max_attempts: 3,
initial_backoff_ms: 20,
max_backoff_ms: 200,
lease_ms: 900,
retention_ms: 1_000,
}
);
}
#[test]
fn job_queues_defaults_to_single_default_queue() {
let config = AutumnConfig::default();
assert!(config.jobs.queues.strict);
assert_eq!(config.jobs.queues.queues.len(), 1);
assert_eq!(config.jobs.queues.queues[0].name, "default");
assert_eq!(config.jobs.queues.queues[0].weight, 1);
}
#[test]
fn jobs_without_queues_key_keeps_single_default_queue() {
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
backend = "local"
workers = 4
"#,
)
.unwrap();
assert!(config.jobs.queues.strict);
assert_eq!(config.jobs.queues.queues.len(), 1);
assert_eq!(config.jobs.queues.queues[0].name, "default");
}
#[test]
fn job_queues_parse_ordered_list_as_strict_priority() {
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
backend = "local"
queues = ["critical", "default", "low"]
"#,
)
.unwrap();
assert!(config.jobs.queues.strict, "list form is strict priority");
let names: Vec<&str> = config
.jobs
.queues
.queues
.iter()
.map(|q| q.name.as_str())
.collect();
assert_eq!(names, ["critical", "default", "low"]);
assert!(config.jobs.queues.queues.iter().all(|q| q.weight == 1));
}
#[test]
fn job_queues_parse_weight_map_as_weighted() {
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
backend = "local"
[jobs.queues]
critical = 4
default = 2
low = 1
"#,
)
.unwrap();
assert!(!config.jobs.queues.strict, "map form is weighted");
let weight = |name: &str| {
config
.jobs
.queues
.queues
.iter()
.find(|q| q.name == name)
.map(|q| q.weight)
};
assert_eq!(weight("critical"), Some(4));
assert_eq!(weight("default"), Some(2));
assert_eq!(weight("low"), Some(1));
}
#[test]
fn job_queues_strict_list_rejects_duplicate_names() {
let err = toml::from_str::<AutumnConfig>(
r#"
[jobs]
queues = ["critical", "default", "critical"]
"#,
)
.unwrap_err()
.to_string();
assert!(
err.contains("duplicate queue name") && err.contains("critical"),
"unexpected error: {err}"
);
}
#[test]
fn job_queues_table_form_parses_caps_and_reserved_slots() {
let config: AutumnConfig = toml::from_str(
r"
[jobs.queues]
critical = { weight = 3, reserved = 2 }
bulk = { weight = 1, concurrency = 4 }
default = 2
",
)
.unwrap();
assert!(!config.jobs.queues.strict, "table form is weighted");
let find = |name: &str| {
config
.jobs
.queues
.queues
.iter()
.find(|q| q.name == name)
.cloned()
.unwrap()
};
let critical = find("critical");
assert_eq!(critical.weight, 3);
assert_eq!(critical.reserved, Some(2));
assert_eq!(critical.concurrency, None);
let bulk = find("bulk");
assert_eq!(bulk.weight, 1);
assert_eq!(bulk.concurrency, Some(4));
assert_eq!(bulk.reserved, None);
let default = find("default");
assert_eq!(default.weight, 2);
assert_eq!(default.concurrency, None);
assert_eq!(default.reserved, None);
}
#[test]
fn job_queues_table_form_defaults_weight_to_one() {
let config: AutumnConfig = toml::from_str(
r"
[jobs.queues]
critical = { reserved = 1 }
",
)
.unwrap();
let critical = &config.jobs.queues.queues[0];
assert_eq!(critical.weight, 1, "omitted weight defaults to 1");
assert_eq!(critical.reserved, Some(1));
}
#[test]
fn job_queues_table_form_rejects_zero_weight() {
let err = toml::from_str::<AutumnConfig>(
r"
[jobs.queues]
critical = { weight = 0, reserved = 1 }
",
)
.unwrap_err()
.to_string();
assert!(
err.contains("weight must be at least 1") && err.contains("critical"),
"unexpected error: {err}"
);
}
#[test]
fn job_queues_table_form_rejects_unknown_setting() {
let err = toml::from_str::<AutumnConfig>(
r"
[jobs.queues]
critical = { weight = 1, bogus = 3 }
",
)
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "unexpected error: {err}");
}
#[test]
fn jobs_pin_defaults_empty_and_parses_from_toml() {
let default = AutumnConfig::default();
assert!(default.jobs.pin.is_empty(), "pin is empty by default (AC4)");
let config: AutumnConfig = toml::from_str(
r#"
[jobs]
pin = ["critical", "default"]
"#,
)
.unwrap();
assert_eq!(config.jobs.pin, vec!["critical", "default"]);
}
#[test]
fn jobs_fleet_tiers_parse_from_toml() {
let default = AutumnConfig::default();
assert!(
default.jobs.fleet.tiers.is_empty(),
"no fleet topology is declared by default (AC4)"
);
let config: AutumnConfig = toml::from_str(
r#"
[jobs.fleet]
tiers = [["critical"], ["bulk", "default"], []]
"#,
)
.unwrap();
assert_eq!(
config.jobs.fleet.tiers,
vec![
vec!["critical".to_owned()],
vec!["bulk".to_owned(), "default".to_owned()],
Vec::<String>::new(),
],
);
}
#[test]
fn declared_fleet_topology_boots_under_enforce_all() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(
&config_path,
"[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n\
[jobs]\npin = [\"critical\"]\n\n\
[jobs.fleet]\ntiers = [[\"critical\"], [\"bulk\", \"default\"]]\n",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_ok(),
"a declared [jobs.fleet] topology must not be an unknown config key: {res:?}"
);
let config = res.unwrap();
assert_eq!(config.jobs.fleet.tiers.len(), 2);
assert_eq!(config.jobs.pin, vec!["critical".to_owned()]);
}
#[test]
fn jobs_fleet_typo_is_caught_by_strict_config() {
let temp = tempfile::tempdir().unwrap();
let config_path = temp.path().join("autumn.toml");
std::fs::write(
&config_path,
"[server]\nstrict_config = true\nstrict_config_enforce_all = true\n\n\
[jobs.fleet]\nteirs = [[\"critical\"]]\n",
)
.unwrap();
let env = FakeEnv(
[
("AUTUMN_ENV".to_owned(), "prod".to_owned()),
(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
),
]
.into(),
);
let res = AutumnConfig::load_with_env(&env);
assert!(
res.is_err(),
"a typo inside [jobs.fleet] must not be silently accepted"
);
let err_str = format!("{:?}", res.err().unwrap());
assert!(
err_str.contains("teirs"),
"error should name the typo: {err_str}"
);
}
#[test]
fn jobs_pin_env_override_is_comma_separated() {
let env = MockEnv::new().with("AUTUMN_JOBS__PIN", "critical, bulk ,");
let mut config = AutumnConfig::default();
config.apply_jobs_env_overrides_with_env(&env);
assert_eq!(
config.jobs.pin,
vec!["critical".to_string(), "bulk".to_string()],
"trims whitespace and drops empty entries"
);
}
#[test]
fn job_queues_weighted_rejects_zero_weight() {
let err = toml::from_str::<AutumnConfig>(
r"
[jobs.queues]
critical = 4
default = 0
",
)
.unwrap_err()
.to_string();
assert!(
err.contains("weight must be at least 1") && err.contains("default"),
"unexpected error: {err}"
);
}
#[test]
fn channels_defaults_to_in_process_backend() {
let config = AutumnConfig::default();
assert_eq!(config.channels.backend, ChannelBackend::InProcess);
assert_eq!(config.channels.capacity, 32);
assert_eq!(config.channels.replay_buffer, 256);
assert_eq!(config.channels.redis.key_prefix, "autumn:channels");
assert!(config.channels.redis.url.is_none());
}
#[test]
fn channels_env_overrides_fields() {
let env = MockEnv::new()
.with("AUTUMN_CHANNELS__BACKEND", "redis")
.with("AUTUMN_CHANNELS__CAPACITY", "128")
.with("AUTUMN_CHANNELS__REPLAY_BUFFER", "512")
.with("AUTUMN_CHANNELS__REDIS__URL", "redis://channels:6379/4")
.with("AUTUMN_CHANNELS__REDIS__KEY_PREFIX", "myapp:channels");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.channels.backend, ChannelBackend::Redis);
assert_eq!(config.channels.capacity, 128);
assert_eq!(config.channels.replay_buffer, 512);
assert_eq!(
config.channels.redis.url.as_deref(),
Some("redis://channels:6379/4")
);
assert_eq!(config.channels.redis.key_prefix, "myapp:channels");
}
#[test]
fn channels_toml_deserializes_redis_backend() {
let config: AutumnConfig = toml::from_str(
r#"
[channels]
backend = "redis"
capacity = 64
[channels.redis]
url = "redis://localhost:6379/5"
key_prefix = "demo:channels"
"#,
)
.unwrap();
assert_eq!(config.channels.backend, ChannelBackend::Redis);
assert_eq!(config.channels.capacity, 64);
assert_eq!(
config.channels.redis.url.as_deref(),
Some("redis://localhost:6379/5")
);
assert_eq!(config.channels.redis.key_prefix, "demo:channels");
}
#[test]
fn env_override_invalid_jobs_numeric_values_ignored() {
let env = MockEnv::new()
.with("AUTUMN_JOBS__WORKERS", "many")
.with("AUTUMN_JOBS__MAX_ATTEMPTS", "a_lot")
.with("AUTUMN_JOBS__INITIAL_BACKOFF_MS", "soon");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.jobs.workers, 1);
assert_eq!(config.jobs.max_attempts, 5);
assert_eq!(config.jobs.initial_backoff_ms, 250);
}
#[test]
fn env_override_server_port() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.port, 8080);
}
#[test]
fn parse_env_works() {
let env = MockEnv::new().with("SOME_NUM", "123");
let mut target: u32 = 0;
parse_env(&env, "SOME_NUM", &mut target);
assert_eq!(target, 123);
let env_err = MockEnv::new().with("SOME_NUM", "abc");
let mut target_err: u32 = 0;
parse_env(&env_err, "SOME_NUM", &mut target_err);
assert_eq!(target_err, 0); }
#[test]
fn parse_env_option_string_works() {
let env = MockEnv::new().with("SOME_OPT", "val");
let mut target = None;
parse_env_option_string(&env, "SOME_OPT", &mut target);
assert_eq!(target, Some("val".to_string()));
let env_empty = MockEnv::new().with("SOME_OPT", "");
let mut target_empty = Some("old".to_string());
parse_env_option_string(&env_empty, "SOME_OPT", &mut target_empty);
assert_eq!(target_empty, None);
}
#[test]
fn parse_env_string_works() {
let env = MockEnv::new().with("SOME_STR", "val");
let mut target = "old".to_string();
parse_env_string(&env, "SOME_STR", &mut target);
assert_eq!(target, "val");
}
fn cfg_with_profile(profile: Option<&str>) -> AutumnConfig {
AutumnConfig {
profile: profile.map(str::to_owned),
..Default::default()
}
}
#[test]
fn server_timing_defaults_on_in_dev_profile() {
let cfg = cfg_with_profile(Some("dev"));
assert!(server_timing_enabled(&cfg));
let cfg = cfg_with_profile(Some("development"));
assert!(server_timing_enabled(&cfg));
}
#[test]
fn server_timing_defaults_off_in_prod_and_test_profiles() {
let cfg = cfg_with_profile(Some("prod"));
assert!(!server_timing_enabled(&cfg));
let cfg = cfg_with_profile(Some("production"));
assert!(!server_timing_enabled(&cfg));
let cfg = cfg_with_profile(Some("test"));
assert!(!server_timing_enabled(&cfg));
let cfg = cfg_with_profile(None);
assert!(!server_timing_enabled(&cfg));
}
#[test]
fn server_timing_explicit_config_overrides_profile_default() {
let mut cfg = cfg_with_profile(Some("prod"));
cfg.observability.server_timing = Some(true);
assert!(server_timing_enabled(&cfg));
let mut cfg = cfg_with_profile(Some("dev"));
cfg.observability.server_timing = Some(false);
assert!(!server_timing_enabled(&cfg));
}
#[test]
fn server_timing_env_override_wires_into_dispatcher() {
let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "true");
let mut config = cfg_with_profile(Some("prod"));
config.apply_env_overrides_with_env(&env);
assert_eq!(config.observability.server_timing, Some(true));
assert!(server_timing_enabled(&config));
let env = MockEnv::new().with("AUTUMN_OBSERVABILITY__SERVER_TIMING", "false");
let mut config = cfg_with_profile(Some("dev"));
config.apply_env_overrides_with_env(&env);
assert_eq!(config.observability.server_timing, Some(false));
assert!(!server_timing_enabled(&config));
}
#[test]
fn cost_section_defaults_and_toml() {
let config = AutumnConfig::default();
assert!(!config.cost.enabled);
assert_eq!(config.cost.defer_threshold, None);
assert_eq!(config.cost.defer_recheck_secs, 30);
assert_eq!(config.cost.max_tenants, 1000);
assert_eq!(config.cost.signal_refresh_secs, 5);
let config: AutumnConfig =
toml::from_str("[cost]\nenabled = true\ndefer_threshold = 400.5\n").expect("parse");
assert!(config.cost.enabled);
assert_eq!(config.cost.defer_threshold, Some(400.5));
assert_eq!(
config.cost.defer_recheck_secs, 30,
"unset keys keep defaults"
);
}
#[test]
fn cost_validation_names_the_bad_key() {
assert!(CostConfig::default().validate().is_ok());
let cost = CostConfig {
defer_recheck_secs: 0,
..CostConfig::default()
};
assert!(cost.validate().unwrap_err().contains("defer_recheck_secs"));
let cost = CostConfig {
signal_refresh_secs: 0,
..CostConfig::default()
};
assert!(cost.validate().unwrap_err().contains("signal_refresh_secs"));
for bad in [-1.0, f64::NAN, f64::INFINITY] {
let cost = CostConfig {
defer_threshold: Some(bad),
..CostConfig::default()
};
assert!(cost.validate().unwrap_err().contains("defer_threshold"));
}
let mut config = AutumnConfig::default();
config.cost.defer_recheck_secs = 0;
assert!(config.validate().is_err(), "AutumnConfig::validate runs it");
}
#[test]
fn cost_env_overrides_wire_into_dispatcher() {
let env = MockEnv::new()
.with("AUTUMN_COST__ENABLED", "true")
.with("AUTUMN_COST__DEFER_THRESHOLD", "250")
.with("AUTUMN_COST__DEFER_RECHECK_SECS", "10")
.with("AUTUMN_COST__MAX_TENANTS", "7")
.with("AUTUMN_COST__SIGNAL_REFRESH_SECS", "2");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.cost.enabled);
assert_eq!(config.cost.defer_threshold, Some(250.0));
assert_eq!(config.cost.defer_recheck_secs, 10);
assert_eq!(config.cost.max_tenants, 7);
assert_eq!(config.cost.signal_refresh_secs, 2);
}
#[test]
fn parse_env_bool_works() {
let env = MockEnv::new().with("SOME_BOOL", "true");
let mut target = false;
parse_env_bool(&env, "SOME_BOOL", &mut target);
assert!(target);
let env2 = MockEnv::new().with("SOME_BOOL", "1");
let mut target2 = false;
parse_env_bool(&env2, "SOME_BOOL", &mut target2);
assert!(target2);
let env3 = MockEnv::new().with("SOME_BOOL", "0");
let mut target3 = true;
parse_env_bool(&env3, "SOME_BOOL", &mut target3);
assert!(!target3);
let env_err = MockEnv::new().with("SOME_BOOL", "invalid");
let mut target_err = true;
parse_env_bool(&env_err, "SOME_BOOL", &mut target_err);
assert!(target_err); }
#[test]
fn parse_env_csv_works() {
let env = MockEnv::new().with("SOME_CSV", "a, b,c");
let mut target = vec![];
parse_env_csv(&env, "SOME_CSV", &mut target);
assert_eq!(target, vec!["a", "b", "c"]);
}
#[test]
fn env_override_tenancy_quota_bytes() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.quota_bytes, 0);
let env = MockEnv::new().with("AUTUMN_TENANCY__QUOTA_BYTES", "1048576");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.quota_bytes, 1_048_576);
}
#[test]
fn env_override_tenancy_enabled() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(!config.tenancy.enabled);
let env = MockEnv::new().with("AUTUMN_TENANCY__ENABLED", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.tenancy.enabled);
}
#[test]
fn env_override_tenancy_string_fields() {
let env = MockEnv::new()
.with("AUTUMN_TENANCY__SOURCE", "jwt")
.with("AUTUMN_TENANCY__HEADER_NAME", "x-org")
.with("AUTUMN_TENANCY__SESSION_KEY", "org_id")
.with("AUTUMN_TENANCY__JWT_CLAIM", "org")
.with("AUTUMN_TENANCY__JWT_ISSUER", "https://issuer.example")
.with("AUTUMN_TENANCY__JWT_AUDIENCE", "autumn-api")
.with("AUTUMN_TENANCY__BASE_DOMAIN", "apps.example.com")
.with("AUTUMN_TENANCY__LOGIN_REDIRECT", "/login")
.with("AUTUMN_TENANCY__PUBLIC_PATHS", "/login, /signup ,/assets");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.source, "jwt");
assert_eq!(config.tenancy.header_name, "x-org");
assert_eq!(config.tenancy.session_key, "org_id");
assert_eq!(config.tenancy.jwt_claim, "org");
assert_eq!(
config.tenancy.jwt_issuer.as_deref(),
Some("https://issuer.example")
);
assert_eq!(config.tenancy.jwt_audience.as_deref(), Some("autumn-api"));
assert_eq!(
config.tenancy.base_domain.as_deref(),
Some("apps.example.com")
);
assert_eq!(config.tenancy.login_redirect.as_deref(), Some("/login"));
assert_eq!(
config.tenancy.public_paths,
vec!["/login", "/signup", "/assets"]
);
}
#[test]
fn env_override_isolation_knobs() {
let env = MockEnv::new()
.with("AUTUMN_TENANCY__MAX_CONCURRENT_REQUESTS", "32")
.with("AUTUMN_TENANCY__MAX_DB_CONNECTIONS", "4")
.with("AUTUMN_JOBS__TENANTS__MAX_CONCURRENT", "2")
.with("AUTUMN_JOBS__TENANTS__LANES", "8")
.with("AUTUMN_JOBS__TENANTS__LANES_PER_TENANT", "3")
.with("AUTUMN_JOBS__POSTGRES__SHARD_LOCAL", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.max_concurrent_requests, 32);
assert_eq!(config.tenancy.max_db_connections, 4);
assert_eq!(config.jobs.tenants.max_concurrent, 2);
assert_eq!(config.jobs.tenants.lanes, 8);
assert_eq!(config.jobs.tenants.lanes_per_tenant, 3);
assert!(config.jobs.postgres.shard_local);
}
#[test]
fn env_override_tenancy_eviction_knobs() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.max_cells, 0);
assert_eq!(config.tenancy.idle_ttl_secs, 0);
let env = MockEnv::new()
.with("AUTUMN_TENANCY__MAX_CELLS", "512")
.with("AUTUMN_TENANCY__IDLE_TTL_SECS", "900");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.tenancy.max_cells, 512);
assert_eq!(config.tenancy.idle_ttl_secs, 900);
}
#[test]
fn env_override_tenancy_secret() {
use secrecy::ExposeSecret;
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.tenancy.jwt_secret.is_none());
let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", " s3cr3t-signing-key ");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config
.tenancy
.jwt_secret
.as_ref()
.map(|s| s.expose_secret().to_owned()),
Some("s3cr3t-signing-key".to_string())
);
let env = MockEnv::new().with("AUTUMN_TENANCY__JWT_SECRET", " ");
let mut config = AutumnConfig::default();
config.tenancy.jwt_secret = Some(secrecy::SecretString::from("preexisting".to_string()));
config.apply_env_overrides_with_env(&env);
assert!(config.tenancy.jwt_secret.is_none());
}
#[test]
fn env_override_rate_limit_trusted_proxies() {
let env = MockEnv::new().with(
"AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES",
"10.0.0.10, 203.0.113.0/24",
);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.rate_limit.trusted_proxies,
vec!["10.0.0.10", "203.0.113.0/24"]
);
}
#[test]
fn env_override_rate_limit_backend_redis() {
use crate::security::config::RateLimitBackend;
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "redis");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Redis);
}
#[test]
fn env_override_rate_limit_backend_memory() {
use crate::security::config::RateLimitBackend;
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "memory");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
}
#[test]
fn env_override_rate_limit_backend_invalid_ignored() {
use crate::security::config::RateLimitBackend;
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__BACKEND", "postgres");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.rate_limit.backend, RateLimitBackend::Memory);
}
#[cfg(feature = "redis")]
#[test]
fn env_override_rate_limit_on_backend_failure_fail_closed() {
use crate::security::config::RateLimitBackendFailure;
let env = MockEnv::new().with(
"AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE",
"fail_closed",
);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.rate_limit.on_backend_failure,
RateLimitBackendFailure::FailClosed
);
}
#[cfg(feature = "redis")]
#[test]
fn env_override_rate_limit_on_backend_failure_invalid_ignored() {
use crate::security::config::RateLimitBackendFailure;
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__ON_BACKEND_FAILURE", "explode");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.rate_limit.on_backend_failure,
RateLimitBackendFailure::FailOpen
);
}
#[cfg(feature = "redis")]
#[test]
fn env_override_rate_limit_redis_url() {
let env = MockEnv::new().with(
"AUTUMN_SECURITY__RATE_LIMIT__REDIS__URL",
"redis://myhost:6379",
);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.rate_limit.redis.url.as_deref(),
Some("redis://myhost:6379")
);
}
#[cfg(feature = "redis")]
#[test]
fn env_override_rate_limit_redis_key_prefix() {
let env = MockEnv::new().with("AUTUMN_SECURITY__RATE_LIMIT__REDIS__KEY_PREFIX", "prod:rl");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.rate_limit.redis.key_prefix, "prod:rl");
}
#[test]
fn env_override_server_host() {
let env = MockEnv::new().with("AUTUMN_SERVER__HOST", "0.0.0.0");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.host, "0.0.0.0");
}
#[test]
fn env_override_server_shutdown_timeout() {
let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "60");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.shutdown_timeout_secs, 60);
}
#[test]
fn env_override_invalid_server_port_ignored() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "not_a_port");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.port, 3000);
}
#[test]
fn env_override_invalid_shutdown_timeout_ignored() {
let env = MockEnv::new().with("AUTUMN_SERVER__SHUTDOWN_TIMEOUT_SECS", "forever");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.shutdown_timeout_secs, 30);
}
#[test]
fn server_config_defaults_unix_socket_none() {
let config = AutumnConfig::default();
assert!(config.server.unix_socket.is_none());
}
#[test]
fn env_override_server_unix_socket() {
let env = MockEnv::new().with("AUTUMN_SERVER__UNIX_SOCKET", "/run/autumn/app.sock");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.server.unix_socket.as_deref(),
Some("/run/autumn/app.sock")
);
}
#[test]
fn unix_socket_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server]
unix_socket = "/tmp/autumn.sock"
"#,
)
.expect("config with server.unix_socket should parse");
assert_eq!(
config.server.unix_socket.as_deref(),
Some("/tmp/autumn.sock")
);
}
#[test]
fn server_upgrade_config_defaults_enabled_with_a_30s_readiness_budget() {
let config = AutumnConfig::default();
assert!(config.server.upgrade.enabled);
assert_eq!(config.server.upgrade.ready_timeout_secs, 30);
}
#[test]
fn server_upgrade_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r"
[server.upgrade]
enabled = false
ready_timeout_secs = 5
",
)
.expect("config with [server.upgrade] should parse");
assert!(!config.server.upgrade.enabled);
assert_eq!(config.server.upgrade.ready_timeout_secs, 5);
}
#[test]
fn env_overrides_server_upgrade() {
let env = MockEnv::new()
.with("AUTUMN_SERVER__UPGRADE__ENABLED", "false")
.with("AUTUMN_SERVER__UPGRADE__READY_TIMEOUT_SECS", "90");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(!config.server.upgrade.enabled);
assert_eq!(config.server.upgrade.ready_timeout_secs, 90);
}
#[test]
fn server_config_defaults_tls_none() {
let config = AutumnConfig::default();
assert!(config.server.tls.is_none());
}
#[test]
fn server_tls_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "/etc/autumn/tls/fullchain.pem"
key_path = "/etc/autumn/tls/privkey.pem"
"#,
)
.expect("config with [server.tls] should parse");
let tls = config.server.tls.expect("tls configured");
assert_eq!(
tls.cert_path,
Some(std::path::PathBuf::from("/etc/autumn/tls/fullchain.pem"))
);
assert_eq!(
tls.key_path,
Some(std::path::PathBuf::from("/etc/autumn/tls/privkey.pem"))
);
assert_eq!(tls.reload_interval_secs, 60);
assert_eq!(tls.handshake_timeout_secs, 10);
assert!(tls.acme.is_none());
assert!(tls.validate().is_ok());
}
#[test]
fn server_tls_handshake_timeout_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "cert.pem"
key_path = "key.pem"
handshake_timeout_secs = 25
"#,
)
.expect("config with [server.tls] handshake_timeout_secs should parse");
assert_eq!(config.server.tls.unwrap().handshake_timeout_secs, 25);
}
#[test]
fn server_tls_reload_interval_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "cert.pem"
key_path = "key.pem"
reload_interval_secs = 120
"#,
)
.expect("config with [server.tls] reload_interval_secs should parse");
assert_eq!(config.server.tls.unwrap().reload_interval_secs, 120);
}
#[test]
fn env_override_materializes_server_tls() {
let env = MockEnv::new()
.with("AUTUMN_SERVER__TLS__CERT_PATH", "/env/cert.pem")
.with("AUTUMN_SERVER__TLS__KEY_PATH", "/env/key.pem")
.with("AUTUMN_SERVER__TLS__RELOAD_INTERVAL_SECS", "90")
.with("AUTUMN_SERVER__TLS__HANDSHAKE_TIMEOUT_SECS", "5");
let mut config = AutumnConfig::default();
assert!(config.server.tls.is_none());
config.apply_env_overrides_with_env(&env);
let tls = config.server.tls.expect("env should materialize tls");
assert_eq!(
tls.cert_path,
Some(std::path::PathBuf::from("/env/cert.pem"))
);
assert_eq!(tls.key_path, Some(std::path::PathBuf::from("/env/key.pem")));
assert_eq!(tls.reload_interval_secs, 90);
assert_eq!(tls.handshake_timeout_secs, 5);
}
#[test]
fn env_override_updates_existing_server_tls_cert() {
let mut config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "toml-cert.pem"
key_path = "toml-key.pem"
"#,
)
.unwrap();
let env = MockEnv::new().with("AUTUMN_SERVER__TLS__CERT_PATH", "override-cert.pem");
config.apply_env_overrides_with_env(&env);
let tls = config.server.tls.expect("tls configured");
assert_eq!(
tls.cert_path,
Some(std::path::PathBuf::from("override-cert.pem"))
);
assert_eq!(tls.key_path, Some(std::path::PathBuf::from("toml-key.pem")));
}
#[test]
fn no_tls_env_leaves_tls_none() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.server.tls.is_none());
}
#[test]
fn deploy_absent_is_none() {
let config = AutumnConfig::default();
assert!(config.deploy.is_none());
let parsed: AutumnConfig = toml::from_str("[server]\nport = 3000\n")
.expect("config without [deploy] should parse");
assert!(parsed.deploy.is_none());
}
#[test]
fn deploy_defaults_from_bare_table() {
let config: AutumnConfig =
toml::from_str("[deploy]\n").expect("bare [deploy] table should parse");
let deploy = config.deploy.expect("deploy configured");
assert_eq!(deploy.host, None);
assert_eq!(deploy.user, "root");
assert_eq!(deploy.ssh_port, 22);
assert_eq!(deploy.app_name, None);
assert_eq!(deploy.app_dir, None);
assert_eq!(deploy.service_name, None);
assert_eq!(deploy.readiness_timeout_secs, 60);
assert_eq!(deploy.keep_releases, 3);
assert!(deploy.install_proxy);
}
#[test]
fn deploy_full_table_parses() {
let config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "203.0.113.10"
user = "deploy"
ssh_port = 2222
app_name = "myapp"
app_dir = "/srv/myapp"
service_name = "myapp-web"
readiness_timeout_secs = 90
keep_releases = 5
install_proxy = false
"#,
)
.expect("full [deploy] table should parse");
let deploy = config.deploy.expect("deploy configured");
assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
assert_eq!(deploy.user, "deploy");
assert_eq!(deploy.ssh_port, 2222);
assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
assert_eq!(deploy.readiness_timeout_secs, 90);
assert_eq!(deploy.keep_releases, 5);
assert!(
!deploy.install_proxy,
"the host-prep opt-out parses (#1607)"
);
assert!(deploy.validate().is_ok());
}
#[test]
fn deploy_validate_rejects_missing_host() {
let missing = DeployConfig::default();
let err = missing
.validate()
.expect_err("missing host must be rejected");
assert!(
err.contains("host"),
"error should name the missing key: {err}"
);
let blank = DeployConfig {
host: Some(" ".to_owned()),
..DeployConfig::default()
};
assert!(blank.validate().is_err());
let ok = DeployConfig {
host: Some("example.com".to_owned()),
..DeployConfig::default()
};
assert!(ok.validate().is_ok());
}
#[test]
fn env_override_materializes_deploy() {
let env = MockEnv::new()
.with("AUTUMN_DEPLOY__HOST", "203.0.113.10")
.with("AUTUMN_DEPLOY__USER", "deploy")
.with("AUTUMN_DEPLOY__SSH_PORT", "2222")
.with("AUTUMN_DEPLOY__APP_NAME", "myapp")
.with("AUTUMN_DEPLOY__APP_DIR", "/srv/myapp")
.with("AUTUMN_DEPLOY__SERVICE_NAME", "myapp-web")
.with("AUTUMN_DEPLOY__READINESS_TIMEOUT_SECS", "90")
.with("AUTUMN_DEPLOY__KEEP_RELEASES", "5")
.with("AUTUMN_DEPLOY__PROFILE", "staging");
let mut config = AutumnConfig::default();
assert!(config.deploy.is_none());
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("env should materialize deploy");
assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
assert_eq!(deploy.user, "deploy");
assert_eq!(deploy.ssh_port, 2222);
assert_eq!(deploy.app_name.as_deref(), Some("myapp"));
assert_eq!(deploy.app_dir.as_deref(), Some("/srv/myapp"));
assert_eq!(deploy.service_name.as_deref(), Some("myapp-web"));
assert_eq!(deploy.readiness_timeout_secs, 90);
assert_eq!(deploy.keep_releases, 5);
assert_eq!(deploy.profile, "staging");
assert!(deploy.validate().is_ok());
}
#[test]
fn env_override_declines_deploy_host_preparation() {
let env = MockEnv::new().with("AUTUMN_DEPLOY__INSTALL_PROXY", "false");
let mut config = AutumnConfig::default();
assert!(config.deploy.is_none());
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("env should materialize deploy");
assert!(!deploy.install_proxy);
let mut from_toml: AutumnConfig =
toml::from_str("[deploy]\nhost = \"203.0.113.10\"\ninstall_proxy = true\n").unwrap();
from_toml.apply_env_overrides_with_env(&env);
assert!(!from_toml.deploy.expect("deploy configured").install_proxy);
}
#[test]
fn deploy_bake_defaults_to_off() {
let config: AutumnConfig = toml::from_str("[deploy]\nhost = \"h\"\n").unwrap();
let bake = config.deploy.expect("deploy").bake;
assert_eq!(bake.duration_secs, 0);
assert_eq!(bake.interval_secs, 10);
assert_eq!(bake.min_requests, 20);
assert!(bake.max_error_rate.is_none());
assert!(bake.max_p99_ms.is_none());
}
#[test]
fn deploy_bake_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
"[deploy]\nhost = \"h\"\n\n[deploy.bake]\nduration_secs = 300\n\
interval_secs = 15\nmin_requests = 50\nmax_error_rate = 0.01\nmax_p99_ms = 400\n",
)
.unwrap();
let bake = config.deploy.expect("deploy").bake;
assert_eq!(bake.duration_secs, 300);
assert_eq!(bake.interval_secs, 15);
assert_eq!(bake.min_requests, 50);
assert_eq!(bake.max_error_rate, Some(0.01));
assert_eq!(bake.max_p99_ms, Some(400));
}
#[test]
fn env_override_wins_over_toml_deploy_bake_and_ignores_a_bad_value() {
let mut config: AutumnConfig = toml::from_str(
"[deploy]\nhost = \"h\"\n\n[deploy.bake]\nduration_secs = 300\nmax_p99_ms = 400\n",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_DEPLOY__BAKE__DURATION_SECS", "60")
.with("AUTUMN_DEPLOY__BAKE__MAX_P99_MS", "5m");
config.apply_env_overrides_with_env(&env);
let bake = config.deploy.expect("deploy").bake;
assert_eq!(bake.duration_secs, 60);
assert_eq!(bake.max_p99_ms, Some(400));
}
#[test]
fn env_override_sets_every_deploy_bake_key() {
let env = MockEnv::new()
.with("AUTUMN_DEPLOY__BAKE__DURATION_SECS", "120")
.with("AUTUMN_DEPLOY__BAKE__INTERVAL_SECS", "5")
.with("AUTUMN_DEPLOY__BAKE__MIN_REQUESTS", "7")
.with("AUTUMN_DEPLOY__BAKE__MAX_ERROR_RATE", "0.02")
.with("AUTUMN_DEPLOY__BAKE__MAX_P99_MS", "300");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let bake = config.deploy.expect("env should materialize deploy").bake;
assert_eq!(bake.duration_secs, 120);
assert_eq!(bake.interval_secs, 5);
assert_eq!(bake.min_requests, 7);
assert_eq!(bake.max_error_rate, Some(0.02));
assert_eq!(bake.max_p99_ms, Some(300));
let only = MockEnv::new().with("AUTUMN_DEPLOY__BAKE__MAX_P99_MS", "250");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&only);
assert_eq!(
config.deploy.expect("materialized").bake.max_p99_ms,
Some(250)
);
}
#[test]
fn env_override_sets_deploy_tls_enabled_and_host() {
let env = MockEnv::new()
.with("AUTUMN_DEPLOY__TLS__ENABLED", "true")
.with("AUTUMN_DEPLOY__TLS__HOST", "app.example.com");
let mut config = AutumnConfig::default();
assert!(config.deploy.is_none());
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("env should materialize deploy");
assert!(deploy.tls.enabled);
assert_eq!(deploy.tls.host.as_deref(), Some("app.example.com"));
}
#[test]
fn env_override_wins_over_toml_deploy_tls_host() {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "203.0.113.10"
[deploy.tls]
enabled = true
host = "toml.example.com"
"#,
)
.unwrap();
assert_eq!(
config.deploy.as_ref().unwrap().tls.host.as_deref(),
Some("toml.example.com"),
);
let env = MockEnv::new().with("AUTUMN_DEPLOY__TLS__HOST", "env.example.com");
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.unwrap();
assert!(deploy.tls.enabled);
assert_eq!(deploy.tls.host.as_deref(), Some("env.example.com"));
}
#[test]
fn deploy_profile_defaults_to_production() {
let config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "203.0.113.10"
"#,
)
.unwrap();
let deploy = config.deploy.expect("deploy configured");
assert_eq!(deploy.profile, "prod");
assert_eq!(DeployConfig::default().profile, "prod");
}
#[test]
fn deploy_profile_honors_toml_and_env_override() {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "toml-host"
profile = "staging"
"#,
)
.unwrap();
assert_eq!(config.deploy.as_ref().unwrap().profile, "staging");
let env = MockEnv::new().with("AUTUMN_DEPLOY__PROFILE", "prod");
config.apply_env_overrides_with_env(&env);
assert_eq!(config.deploy.unwrap().profile, "prod");
}
#[test]
fn env_override_materializes_deploy_from_single_host() {
let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "198.51.100.7");
let mut config = AutumnConfig::default();
assert!(config.deploy.is_none());
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("env should materialize deploy");
assert_eq!(deploy.host.as_deref(), Some("198.51.100.7"));
assert_eq!(deploy.user, "root");
assert_eq!(deploy.ssh_port, 22);
assert_eq!(deploy.readiness_timeout_secs, 60);
assert_eq!(deploy.keep_releases, 3);
}
#[test]
fn env_override_updates_existing_deploy_host() {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "toml-host"
user = "deploy"
ssh_port = 2200
"#,
)
.unwrap();
let env = MockEnv::new().with("AUTUMN_DEPLOY__HOST", "env-host");
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("deploy configured");
assert_eq!(deploy.host.as_deref(), Some("env-host"));
assert_eq!(deploy.user, "deploy");
assert_eq!(deploy.ssh_port, 2200);
}
#[test]
fn env_override_parses_deploy_ssh_port_u16() {
let env = MockEnv::new()
.with("AUTUMN_DEPLOY__HOST", "example.com")
.with("AUTUMN_DEPLOY__SSH_PORT", "65535");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("env should materialize deploy");
assert_eq!(deploy.ssh_port, 65_535_u16);
}
#[test]
fn no_deploy_env_leaves_deploy_none() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "8080");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.deploy.is_none());
}
#[test]
fn deploy_hosts_list_parses_in_declaration_order_and_defaults_to_empty() {
let config: AutumnConfig = toml::from_str(
r#"
[deploy]
hosts = ["web-1.example.com", "web-2.example.com", "web-3.example.com"]
"#,
)
.expect("[deploy] hosts list should parse");
let deploy = config.deploy.expect("deploy configured");
assert_eq!(
deploy.hosts,
vec![
"web-1.example.com".to_owned(),
"web-2.example.com".to_owned(),
"web-3.example.com".to_owned(),
],
"hosts must parse in declaration order (the documented rollout order), got: {:?}",
deploy.hosts
);
assert_eq!(
deploy.host, None,
"the fleet spelling must not populate the legacy scalar, got: {:?}",
deploy.host
);
let bare: AutumnConfig =
toml::from_str("[deploy]\nhost = \"203.0.113.10\"\n").expect("legacy table parses");
let bare_deploy = bare.deploy.expect("deploy configured");
assert!(
bare_deploy.hosts.is_empty(),
"a pre-#1621 [deploy] table must resolve to an empty hosts list, got: {:?}",
bare_deploy.hosts
);
assert!(
DeployConfig::default().hosts.is_empty(),
"the hand-written Default impl must seed an empty hosts list, got: {:?}",
DeployConfig::default().hosts
);
}
#[test]
fn deploy_validate_rejects_host_and_hosts_set_together() {
let cfg = DeployConfig {
host: Some("203.0.113.10".to_owned()),
hosts: vec!["web-1.example.com".to_owned()],
..DeployConfig::default()
};
let err = cfg
.validate()
.expect_err("host + hosts together must be rejected");
assert!(
err.contains("[deploy] host") && err.contains("[deploy] hosts"),
"the mutual-exclusion error must name BOTH keys so the operator knows \
which one to delete, got: {err}"
);
}
#[test]
fn deploy_validate_rejects_blank_and_duplicate_hosts_entries() {
let blank = DeployConfig {
hosts: vec!["web-1.example.com".to_owned(), " ".to_owned()],
..DeployConfig::default()
};
let blank_err = blank
.validate()
.expect_err("a blank hosts entry must be rejected");
assert!(
blank_err.contains("hosts") && blank_err.contains('1'),
"the blank-entry error must name `hosts` and the 0-based index, got: {blank_err}"
);
let duplicate = DeployConfig {
hosts: vec![
"web-1.example.com".to_owned(),
" web-1.example.com ".to_owned(),
],
..DeployConfig::default()
};
let duplicate_err = duplicate
.validate()
.expect_err("a duplicate hosts entry must be rejected");
assert!(
duplicate_err.contains("web-1.example.com"),
"the duplicate error must name the repeated value, got: {duplicate_err}"
);
let ok = DeployConfig {
hosts: vec![
"web-1.example.com".to_owned(),
"web-2.example.com".to_owned(),
],
..DeployConfig::default()
};
assert!(
ok.validate().is_ok(),
"a well-formed fleet list must validate, got: {:?}",
ok.validate()
);
}
#[test]
fn env_override_materializes_deploy_from_hosts_only() {
let env = MockEnv::new().with(
"AUTUMN_DEPLOY__HOSTS",
"web-1.example.com,web-2.example.com",
);
let mut config = AutumnConfig::default();
assert!(config.deploy.is_none());
config.apply_env_overrides_with_env(&env);
let deploy = config
.deploy
.expect("AUTUMN_DEPLOY__HOSTS alone must materialize the [deploy] table");
assert_eq!(
deploy.hosts,
vec![
"web-1.example.com".to_owned(),
"web-2.example.com".to_owned(),
],
"the CSV env value must parse into the ordered fleet list, got: {:?}",
deploy.hosts
);
assert_eq!(deploy.host, None, "got: {:?}", deploy.host);
assert_eq!(deploy.user, "root");
assert_eq!(deploy.ssh_port, 22);
}
#[test]
fn env_override_hosts_replaces_the_whole_toml_list() {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
hosts = ["toml-1", "toml-2", "toml-3"]
"#,
)
.expect("[deploy] hosts list should parse");
let env = MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", "env-1, env-2");
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("deploy configured");
assert_eq!(
deploy.hosts,
vec!["env-1".to_owned(), "env-2".to_owned()],
"env must REPLACE the TOML fleet list (and trim each entry), got: {:?}",
deploy.hosts
);
}
#[test]
fn an_empty_deploy_hosts_env_override_behaves_as_unset() {
for blank in ["", " ", ",", " , "] {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "203.0.113.10"
"#,
)
.expect("[deploy] host should parse");
let env = MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", blank);
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("deploy configured");
assert!(
deploy.hosts.is_empty(),
"AUTUMN_DEPLOY__HOSTS={blank:?} must behave as unset, got: {:?}",
deploy.hosts
);
assert_eq!(deploy.host.as_deref(), Some("203.0.113.10"));
assert!(
deploy.validate().is_ok(),
"a blank fleet override must not trip the mutual-exclusion refusal, got: {:?}",
deploy.validate()
);
}
}
#[test]
fn a_trailing_comma_in_the_deploy_hosts_env_override_is_tolerated() {
for (value, expected) in [
("10.0.0.1,10.0.0.2,", vec!["10.0.0.1", "10.0.0.2"]),
("10.0.0.1,,10.0.0.2", vec!["10.0.0.1", "10.0.0.2"]),
(" 10.0.0.1 , 10.0.0.2 , ", vec!["10.0.0.1", "10.0.0.2"]),
] {
let mut config = AutumnConfig::default();
let env = MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", value);
config.apply_env_overrides_with_env(&env);
let deploy = config.deploy.expect("deploy configured");
assert_eq!(
deploy.hosts,
expected
.iter()
.map(|h| (*h).to_owned())
.collect::<Vec<String>>(),
"AUTUMN_DEPLOY__HOSTS={value:?} must drop blank segments, got: {:?}",
deploy.hosts
);
assert!(
deploy.validate().is_ok(),
"a tolerated trailing comma must not refuse the fleet, got: {:?}",
deploy.validate()
);
}
}
#[test]
fn a_non_empty_deploy_host_env_override_clears_the_toml_alternate_spelling() {
let mut fleet_over_scalar: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "203.0.113.10"
"#,
)
.expect("[deploy] host should parse");
fleet_over_scalar.apply_env_overrides_with_env(
&MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", "web-1,web-2"),
);
let deploy = fleet_over_scalar.deploy.expect("deploy configured");
assert_eq!(
deploy.hosts,
vec!["web-1".to_owned(), "web-2".to_owned()],
"the env fleet list must win, got: {:?}",
deploy.hosts
);
assert_eq!(
deploy.host, None,
"a non-empty AUTUMN_DEPLOY__HOSTS must CLEAR the TOML `host`, got: {:?}",
deploy.host
);
assert!(
deploy.validate().is_ok(),
"env-over-TOML retarget must not trip mutual exclusion, got: {:?}",
deploy.validate()
);
let mut scalar_over_fleet: AutumnConfig = toml::from_str(
r#"
[deploy]
hosts = ["toml-1", "toml-2"]
"#,
)
.expect("[deploy] hosts should parse");
scalar_over_fleet
.apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOST", "single-1"));
let deploy = scalar_over_fleet.deploy.expect("deploy configured");
assert_eq!(deploy.host.as_deref(), Some("single-1"));
assert!(
deploy.hosts.is_empty(),
"a non-empty AUTUMN_DEPLOY__HOST must CLEAR the TOML `hosts`, got: {:?}",
deploy.hosts
);
assert!(
deploy.validate().is_ok(),
"env-over-TOML narrowing must not trip mutual exclusion, got: {:?}",
deploy.validate()
);
}
#[test]
fn two_conflicting_non_empty_deploy_host_env_overrides_are_still_refused() {
for toml_src in [
"[deploy]\n",
"[deploy]\nhost = \"toml-host\"\n",
"[deploy]\nhosts = [\"toml-1\"]\n",
] {
let mut config: AutumnConfig =
toml::from_str(toml_src).expect("[deploy] table should parse");
config.apply_env_overrides_with_env(
&MockEnv::new()
.with("AUTUMN_DEPLOY__HOST", "env-single")
.with("AUTUMN_DEPLOY__HOSTS", "env-1,env-2"),
);
let deploy = config.deploy.expect("deploy configured");
assert_eq!(
deploy.host.as_deref(),
Some("env-single"),
"both env spellings must survive so the conflict is visible ({toml_src:?})"
);
assert_eq!(
deploy.hosts,
vec!["env-1".to_owned(), "env-2".to_owned()],
"both env spellings must survive so the conflict is visible ({toml_src:?})"
);
assert!(
deploy.validate().is_err(),
"two conflicting NON-EMPTY env spellings must still be refused ({toml_src:?})",
);
}
}
#[test]
fn an_empty_deploy_host_env_override_never_clears_the_toml_alternate_spelling() {
for blank in ["", " ", ",", " , "] {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
host = "203.0.113.10"
"#,
)
.expect("[deploy] host should parse");
config
.apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOSTS", blank));
let deploy = config.deploy.expect("deploy configured");
assert_eq!(
deploy.host.as_deref(),
Some("203.0.113.10"),
"AUTUMN_DEPLOY__HOSTS={blank:?} must not clear the TOML `host`"
);
assert!(deploy.hosts.is_empty());
}
for blank in ["", " "] {
let mut config: AutumnConfig = toml::from_str(
r#"
[deploy]
hosts = ["toml-1", "toml-2"]
"#,
)
.expect("[deploy] hosts should parse");
config.apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOST", blank));
let deploy = config.deploy.expect("deploy configured");
assert_eq!(
deploy.hosts,
vec!["toml-1".to_owned(), "toml-2".to_owned()],
"AUTUMN_DEPLOY__HOST={blank:?} must not clear the TOML `hosts`"
);
}
let mut config = AutumnConfig {
deploy: Some(DeployConfig {
host: Some("toml-host".to_owned()),
..DeployConfig::default()
}),
..AutumnConfig::default()
};
config.apply_env_overrides_with_env(&MockEnv::new().with("AUTUMN_DEPLOY__HOST", ""));
assert_eq!(config.deploy.expect("deploy configured").host, None);
}
#[test]
fn client_auth_absent_is_none_and_off() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "cert.pem"
key_path = "key.pem"
"#,
)
.expect("server-only TLS should parse");
let tls = config.server.tls.expect("tls configured");
assert!(tls.client_auth.is_none());
assert!(!tls.client_auth_active());
assert!(tls.validate().is_ok());
}
#[test]
fn client_auth_parses_modes_and_paths() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "cert.pem"
key_path = "key.pem"
[server.tls.client_auth]
mode = "required"
ca_bundle_path = "/etc/autumn/tls/client-ca.pem"
crl_path = "/etc/autumn/tls/client-ca.crl.pem"
required_paths = ["/internal/"]
"#,
)
.expect("[server.tls.client_auth] should parse");
let tls = config.server.tls.expect("tls configured");
let ca = tls.client_auth.as_ref().expect("client_auth configured");
assert_eq!(ca.mode, ClientAuthMode::Required);
assert_eq!(
ca.ca_bundle_path,
Some(PathBuf::from("/etc/autumn/tls/client-ca.pem"))
);
assert_eq!(
ca.crl_path,
Some(PathBuf::from("/etc/autumn/tls/client-ca.crl.pem"))
);
assert_eq!(ca.required_paths, vec!["/internal/".to_owned()]);
assert_eq!(ca.reload_interval_secs, 60);
assert!(tls.client_auth_active());
assert!(tls.validate().is_ok());
}
#[test]
fn client_auth_mode_defaults_to_off() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "cert.pem"
key_path = "key.pem"
[server.tls.client_auth]
"#,
)
.expect("an empty [server.tls.client_auth] should parse");
let tls = config.server.tls.expect("tls configured");
let ca = tls.client_auth.as_ref().expect("client_auth present");
assert_eq!(ca.mode, ClientAuthMode::Off);
assert!(!tls.client_auth_active());
assert!(tls.validate().is_ok());
}
#[test]
fn client_auth_requires_a_ca_bundle_when_enabled() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls]
cert_path = "cert.pem"
key_path = "key.pem"
[server.tls.client_auth]
mode = "optional"
"#,
)
.expect("parse");
let err = config
.server
.tls
.expect("tls configured")
.validate()
.expect_err("client auth without a trust store is invalid");
assert!(err.contains("ca_bundle_path"), "unhelpful message: {err}");
}
#[test]
fn client_auth_rejects_blank_paths() {
let ca = ClientAuthConfig {
mode: ClientAuthMode::Required,
ca_bundle_path: Some(PathBuf::new()),
..ClientAuthConfig::default()
};
let err = ca
.validate()
.expect_err("a blank ca_bundle_path is invalid");
assert!(err.contains("ca_bundle_path"), "unhelpful message: {err}");
let ca = ClientAuthConfig {
mode: ClientAuthMode::Required,
ca_bundle_path: Some(PathBuf::from("ca.pem")),
crl_path: Some(PathBuf::new()),
..ClientAuthConfig::default()
};
let err = ca.validate().expect_err("a blank crl_path is invalid");
assert!(err.contains("crl_path"), "unhelpful message: {err}");
}
#[test]
fn client_auth_rejects_required_paths_when_mode_is_off() {
let ca = ClientAuthConfig {
required_paths: vec!["/internal/".to_owned()],
..ClientAuthConfig::default()
};
let err = ca
.validate()
.expect_err("required_paths with mode = off is invalid");
assert!(err.contains("required_paths"), "unhelpful message: {err}");
}
#[test]
fn client_auth_rejects_relative_required_paths() {
let ca = ClientAuthConfig {
mode: ClientAuthMode::Optional,
ca_bundle_path: Some(PathBuf::from("ca.pem")),
required_paths: vec!["internal/".to_owned()],
..ClientAuthConfig::default()
};
let err = ca.validate().expect_err("a required_path must be rooted");
assert!(err.contains("required_paths"), "unhelpful message: {err}");
}
#[test]
fn client_auth_rejects_noncanonical_required_paths() {
for bad in [
"/internal//",
"//internal",
"/internal/./keys",
"/internal/../admin",
] {
let ca = ClientAuthConfig {
mode: ClientAuthMode::Required,
ca_bundle_path: Some(PathBuf::from("ca.pem")),
required_paths: vec![bad.to_owned()],
..ClientAuthConfig::default()
};
let Err(err) = ca.validate() else {
panic!("`{bad}` should be rejected");
};
assert!(err.contains("required_paths"), "unhelpful message: {err}");
}
for good in ["/internal", "/internal/", "/internal/keys", "/"] {
let ca = ClientAuthConfig {
mode: ClientAuthMode::Required,
ca_bundle_path: Some(PathBuf::from("ca.pem")),
required_paths: vec![good.to_owned()],
..ClientAuthConfig::default()
};
assert!(ca.validate().is_ok(), "`{good}` should be accepted");
}
}
#[test]
fn client_auth_mode_requires_a_certificate_only_when_required() {
assert!(!ClientAuthMode::Off.requests_certificate());
assert!(ClientAuthMode::Optional.requests_certificate());
assert!(ClientAuthMode::Required.requests_certificate());
assert!(!ClientAuthMode::Optional.mandates_certificate());
assert!(ClientAuthMode::Required.mandates_certificate());
}
fn tls_static(cert: Option<&str>, key: Option<&str>) -> TlsConfig {
TlsConfig {
cert_path: cert.map(PathBuf::from),
key_path: key.map(PathBuf::from),
reload_interval_secs: default_tls_reload_interval_secs(),
handshake_timeout_secs: default_tls_handshake_timeout_secs(),
acme: None,
client_auth: None,
}
}
fn acme_cfg(domains: &[&str], email: &str) -> AcmeConfig {
AcmeConfig {
domains: domains.iter().map(|d| (*d).to_owned()).collect(),
contact_email: email.to_owned(),
directory: AcmeDirectory::Staging,
cache_dir: default_acme_cache_dir(),
http_challenge_port: default_acme_http_challenge_port(),
renew_before_days: default_acme_renew_before_days(),
ca_root_path: None,
dns: None,
custom_domains: None,
}
}
#[test]
fn acme_parses_from_toml_with_defaults() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls.acme]
domains = ["app.example.com"]
contact_email = "ops@example.com"
"#,
)
.expect("config with [server.tls.acme] should parse");
let tls = config.server.tls.expect("tls configured");
let acme = tls.acme.as_ref().expect("acme configured");
assert_eq!(acme.domains, vec!["app.example.com".to_owned()]);
assert_eq!(acme.contact_email, "ops@example.com");
assert_eq!(acme.directory, AcmeDirectory::Staging);
assert_eq!(acme.cache_dir, PathBuf::from("config/acme"));
assert_eq!(acme.http_challenge_port, 80);
assert_eq!(acme.renew_before_days, 30);
assert!(tls.validate().is_ok());
}
#[test]
fn acme_ca_root_path_defaults_to_unset_and_round_trips() {
let acme = acme_cfg(&["app.example.com"], "ops@example.com");
assert_eq!(acme.ca_root_path, None);
assert!(acme.validate().is_ok());
let config: AutumnConfig = toml::from_str(
r#"
[server.tls.acme]
domains = ["app.example.com"]
contact_email = "ops@example.com"
directory = { custom = { url = "https://pebble.test/dir" } }
ca_root_path = "config/pebble-root.pem"
"#,
)
.expect("ca_root_path should parse");
let acme = config.server.tls.unwrap().acme.unwrap();
assert_eq!(
acme.ca_root_path,
Some(PathBuf::from("config/pebble-root.pem"))
);
assert!(acme.validate().is_ok());
}
#[test]
fn acme_rejects_a_blank_ca_root_path() {
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.ca_root_path = Some(PathBuf::new());
let err = acme
.validate()
.expect_err("a blank ca_root_path is invalid");
assert!(err.contains("ca_root_path"), "unhelpful message: {err}");
}
#[test]
fn acme_directory_custom_parses() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls.acme]
domains = ["a.example.com"]
contact_email = "ops@example.com"
directory = { custom = { url = "https://pebble.test/dir" } }
"#,
)
.expect("custom directory should parse");
let acme = config.server.tls.unwrap().acme.unwrap();
assert_eq!(
acme.directory,
AcmeDirectory::Custom {
url: "https://pebble.test/dir".to_owned()
}
);
}
#[test]
fn validate_static_only_ok() {
assert!(tls_static(Some("c.pem"), Some("k.pem")).validate().is_ok());
}
#[test]
fn validate_acme_only_ok() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
assert!(cfg.validate().is_ok());
}
#[test]
fn validate_both_static_and_acme_rejected() {
let mut cfg = tls_static(Some("c.pem"), Some("k.pem"));
cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("choose exactly one"), "got: {err}");
}
#[test]
fn validate_neither_static_nor_acme_rejected() {
let err = tls_static(None, None).validate().unwrap_err();
assert!(err.contains("exactly one of"), "got: {err}");
}
#[test]
fn validate_cert_without_key_rejected() {
let err = tls_static(Some("c.pem"), None).validate().unwrap_err();
assert!(err.contains("set together"), "got: {err}");
}
#[test]
fn validate_acme_empty_domains_rejected() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&[], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("at least one domain"), "got: {err}");
}
#[test]
fn validate_acme_empty_email_rejected() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&["app.example.com"], " "));
let err = cfg.validate().unwrap_err();
assert!(err.contains("contact_email"), "got: {err}");
}
#[test]
fn validate_acme_wildcard_domain_without_dns_provider_rejected() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&["*.example.com"], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("[server.tls.acme.dns]"), "got: {err}");
assert!(err.contains("wildcard"), "got: {err}");
}
#[test]
fn validate_acme_blank_domain_entry_rejected() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&[""], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("blank entries"), "got: {err}");
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&[" "], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("blank entries"), "got: {err}");
}
#[test]
fn acme_numeric_fields_reject_non_integer_toml_values() {
for key in ["http_challenge_port", "renew_before_days"] {
for bad in ["\"30\"", "30.5", "true", "-1", "4294967296"] {
let src = format!(
"[server.tls.acme]\ndomains = [\"app.example.com\"]\n\
contact_email = \"ops@example.com\"\n{key} = {bad}\n"
);
assert!(
toml::from_str::<AutumnConfig>(&src).is_err(),
"{key} = {bad} must fail to deserialize"
);
}
let src = format!(
"[server.tls.acme]\ndomains = [\"app.example.com\"]\n\
contact_email = \"ops@example.com\"\n{key} = 45\n"
);
assert!(
toml::from_str::<AutumnConfig>(&src).is_ok(),
"{key} = 45 must deserialize"
);
}
}
#[test]
fn validate_acme_whitespace_padded_domain_rejected() {
for padded in [
" app.example.com",
"app.example.com ",
"\tapp.example.com\n",
] {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&[padded], "ops@example.com"));
let err = cfg
.validate()
.expect_err("a whitespace-padded domain must be rejected");
assert!(
err.contains("whitespace"),
"message must name the problem: {err}"
);
assert!(
err.contains(&format!("`{padded}`")),
"message must echo the RAW padded entry, not only the trimmed \
spelling it suggests: {err}"
);
assert!(
err.contains("`app.example.com`"),
"message must name the trimmed spelling to use: {err}"
);
}
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(
&["app.example.com", " www.example.com "],
"ops@example.com",
));
let err = cfg.validate().unwrap_err();
assert!(
err.contains("index 1"),
"message must name the index: {err}"
);
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&["app.example.com"], "ops@example.com"));
assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
}
#[test]
fn validate_acme_padded_domain_rule_does_not_shadow_blank_or_wildcard() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&[" "], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("blank entries"), "got: {err}");
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&[" *.example.com "], "ops@example.com"));
let err = cfg.validate().unwrap_err();
assert!(err.contains("wildcard"), "got: {err}");
assert!(err.contains("[server.tls.acme.dns]"), "got: {err}");
}
#[test]
fn validate_acme_zero_http_challenge_port_rejected() {
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.http_challenge_port = 0;
cfg.acme = Some(acme);
let err = cfg.validate().unwrap_err();
assert!(err.contains("http_challenge_port"), "got: {err}");
}
#[test]
fn validate_acme_renew_before_days_at_or_above_cert_lifetime_rejected() {
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.renew_before_days = 100;
cfg.acme = Some(acme);
let err = cfg.validate().unwrap_err();
assert!(err.contains("renew_before_days"), "got: {err}");
assert!(err.contains("rate limits"), "got: {err}");
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.renew_before_days = 90;
cfg.acme = Some(acme);
assert!(
cfg.validate().is_err(),
"renew_before_days == 90 must be rejected"
);
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.renew_before_days = 30;
cfg.acme = Some(acme);
assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["app.example.com"], "ops@example.com");
acme.renew_before_days = 89;
cfg.acme = Some(acme);
assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
}
fn acme_dns_cfg(provider: AcmeDnsProvider) -> AcmeDnsConfig {
AcmeDnsConfig {
provider,
credential: default_acme_dns_credential(),
propagation_timeout_secs: default_acme_dns_propagation_timeout_secs(),
poll_interval_secs: default_acme_dns_poll_interval_secs(),
resolvers: default_acme_dns_resolvers(),
command: Vec::new(),
}
}
#[test]
fn acme_dns_section_parses_with_defaults() {
let config: AutumnConfig = toml::from_str(
r#"
[server.tls.acme]
domains = ["myapp.com", "*.myapp.com"]
contact_email = "ops@myapp.com"
[server.tls.acme.dns]
provider = "cloudflare"
"#,
)
.expect("[server.tls.acme.dns] should parse");
let tls = config.server.tls.expect("tls configured");
let acme = tls.acme.as_ref().expect("acme configured");
let dns = acme.dns.as_ref().expect("dns configured");
assert_eq!(dns.provider, AcmeDnsProvider::Cloudflare);
assert_eq!(dns.credential, "acme_dns");
assert_eq!(dns.propagation_timeout_secs, 300);
assert_eq!(dns.poll_interval_secs, 5);
assert!(!dns.resolvers.is_empty());
assert!(tls.validate().is_ok(), "got: {:?}", tls.validate());
}
#[test]
fn acme_dns_provider_names_round_trip() {
for (spelling, expected) in [
("cloudflare", AcmeDnsProvider::Cloudflare),
("route53", AcmeDnsProvider::Route53),
("exec", AcmeDnsProvider::Exec),
] {
let extra = if expected == AcmeDnsProvider::Exec {
"\ncommand = [\"/usr/local/bin/dns-hook\"]"
} else {
""
};
let src = format!(
"[server.tls.acme]\ndomains = [\"*.myapp.com\"]\n\
contact_email = \"ops@myapp.com\"\n\
[server.tls.acme.dns]\nprovider = \"{spelling}\"{extra}\n"
);
let config: AutumnConfig =
toml::from_str(&src).unwrap_or_else(|e| panic!("{spelling} should parse: {e}"));
let acme = config.server.tls.unwrap().acme.unwrap();
assert_eq!(acme.dns.as_ref().unwrap().provider, expected);
assert!(acme.validate().is_ok(), "{spelling}: {:?}", acme.validate());
}
}
#[test]
fn acme_dns_section_rejects_an_inline_secret() {
for secret_key in [
"api_token",
"token",
"secret_access_key",
"access_key_id",
"api_key",
] {
let src = format!(
"[server.tls.acme]\ndomains = [\"*.myapp.com\"]\n\
contact_email = \"ops@myapp.com\"\n\
[server.tls.acme.dns]\nprovider = \"cloudflare\"\n{secret_key} = \"s3cret\"\n"
);
let err = toml::from_str::<AutumnConfig>(&src)
.err()
.unwrap_or_else(|| panic!("{secret_key} in autumn.toml must be rejected"));
let msg = err.to_string();
assert!(
msg.contains(secret_key),
"the rejection must name the offending key: {msg}"
);
}
}
#[test]
fn validate_acme_wildcard_requires_a_dns_provider() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(&["*.myapp.com"], "ops@myapp.com"));
let err = cfg
.validate()
.expect_err("a wildcard without [server.tls.acme.dns] must be rejected");
assert!(err.contains("wildcard"), "got: {err}");
assert!(
err.contains("[server.tls.acme.dns]"),
"the message must name the section that fixes it: {err}"
);
assert!(err.contains("DNS-01"), "got: {err}");
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["myapp.com", "*.myapp.com"], "ops@myapp.com");
acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Cloudflare));
cfg.acme = Some(acme);
assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
}
#[test]
fn validate_acme_rejects_malformed_wildcards() {
for bad in ["*", "*.", "*myapp.com", "app.*.myapp.com", "*.*.myapp.com"] {
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&[bad], "ops@myapp.com");
acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Cloudflare));
cfg.acme = Some(acme);
let err = cfg.validate().expect_err(&format!(
"`{bad}` is not a usable wildcard and must be rejected"
));
assert!(
err.contains(bad),
"the message must echo the offending entry `{bad}`: {err}"
);
}
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Cloudflare));
cfg.acme = Some(acme);
assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
}
#[test]
fn validate_acme_dns_exec_requires_a_command() {
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
acme.dns = Some(acme_dns_cfg(AcmeDnsProvider::Exec));
cfg.acme = Some(acme);
let err = cfg
.validate()
.expect_err("provider = \"exec\" with no command must be rejected");
assert!(err.contains("command"), "got: {err}");
let mut cfg = tls_static(None, None);
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.command = vec!["/usr/local/bin/dns-hook".to_owned()];
acme.dns = Some(dns);
cfg.acme = Some(acme);
let err = cfg
.validate()
.expect_err("command on cloudflare is invalid");
assert!(err.contains("command"), "got: {err}");
}
#[test]
fn validate_acme_dns_rejects_unusable_propagation_bounds() {
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.propagation_timeout_secs = 0;
acme.dns = Some(dns);
let err = acme.validate().expect_err("zero timeout is invalid");
assert!(err.contains("propagation_timeout_secs"), "got: {err}");
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.poll_interval_secs = 0;
acme.dns = Some(dns);
let err = acme.validate().expect_err("zero poll interval is invalid");
assert!(err.contains("poll_interval_secs"), "got: {err}");
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.poll_interval_secs = 600;
dns.propagation_timeout_secs = 60;
acme.dns = Some(dns);
let err = acme.validate().expect_err("interval > timeout is invalid");
assert!(err.contains("poll_interval_secs"), "got: {err}");
}
#[test]
fn validate_acme_dns_rejects_an_unbounded_propagation_timeout() {
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.propagation_timeout_secs = u64::MAX;
acme.dns = Some(dns);
let err = acme
.validate()
.expect_err("an unbounded propagation budget is invalid");
assert!(err.contains("propagation_timeout_secs"), "got: {err}");
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.propagation_timeout_secs = MAX_ACME_DNS_PROPAGATION_TIMEOUT_SECS;
acme.dns = Some(dns);
assert!(acme.validate().is_ok(), "got: {:?}", acme.validate());
}
#[test]
fn validate_acme_dns_rejects_an_unusable_exec_program() {
for bad in [
vec![String::new()],
vec![" ".to_owned(), "arg".to_owned()],
] {
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Exec);
dns.command = bad.clone();
acme.dns = Some(dns);
let err = acme
.validate()
.expect_err(&format!("`{bad:?}` is not a usable exec command"));
assert!(err.contains("command"), "got: {err}");
}
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Exec);
dns.command = vec![" /usr/local/bin/hook ".to_owned()];
acme.dns = Some(dns);
let err = acme.validate().expect_err("a padded program is invalid");
assert!(err.contains("whitespace"), "got: {err}");
}
#[test]
fn validate_acme_dns_rejects_a_blank_or_unparseable_resolver() {
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.resolvers = Vec::new();
acme.dns = Some(dns);
let err = acme.validate().expect_err("no resolvers is invalid");
assert!(err.contains("resolvers"), "got: {err}");
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.resolvers = vec!["not a resolver".to_owned()];
acme.dns = Some(dns);
let err = acme.validate().expect_err("garbage resolver is invalid");
assert!(err.contains("resolvers"), "got: {err}");
}
#[test]
fn acme_dns_resolver_addresses_accept_bare_ips_and_explicit_ports() {
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.resolvers = vec!["1.1.1.1".to_owned(), "9.9.9.9:5353".to_owned()];
let addrs = dns.resolver_addrs().expect("resolvers parse");
assert_eq!(addrs[0].port(), 53, "a bare IP defaults to port 53");
assert_eq!(addrs[1].port(), 5353);
}
#[test]
fn validate_acme_dns_rejects_a_blank_credential_reference() {
for blank in ["", " "] {
let mut acme = acme_cfg(&["*.myapp.com"], "ops@myapp.com");
let mut dns = acme_dns_cfg(AcmeDnsProvider::Cloudflare);
dns.credential = blank.to_owned();
acme.dns = Some(dns);
let err = acme.validate().expect_err("a blank credential is invalid");
assert!(err.contains("credential"), "got: {err}");
}
}
#[test]
fn san_covers_host_matches_rfc6125_wildcard_rules() {
assert!(san_covers_host("myapp.com", "myapp.com"));
assert!(
san_covers_host("MyApp.COM", "myapp.com"),
"case-insensitive"
);
assert!(!san_covers_host("myapp.com", "tenant1.myapp.com"));
assert!(san_covers_host("*.myapp.com", "tenant1.myapp.com"));
assert!(
!san_covers_host("*.myapp.com", "a.b.myapp.com"),
"a wildcard must not span a dot"
);
assert!(
!san_covers_host("*.myapp.com", "myapp.com"),
"a wildcard does not cover the apex"
);
assert!(!san_covers_host("*.myapp.com", "myapp.com.evil.com"));
assert!(!san_covers_host("*.myapp.com", ""));
assert!(san_covers_host("*.myapp.com", "tenant1.myapp.com."));
}
#[test]
fn acme_config_covers_host_uses_every_san() {
let acme = acme_cfg(&["myapp.com", "*.myapp.com"], "ops@myapp.com");
assert!(acme.covers_host("myapp.com"));
assert!(acme.covers_host("tenant42.myapp.com"));
assert!(!acme.covers_host("other.example.com"));
assert!(!acme.covers_host("deep.tenant42.myapp.com"));
}
#[test]
fn validate_acme_valid_domains_and_port_ok() {
let mut cfg = tls_static(None, None);
cfg.acme = Some(acme_cfg(
&["app.example.com", "www.example.com"],
"ops@example.com",
));
assert!(cfg.validate().is_ok(), "got: {:?}", cfg.validate());
}
#[test]
fn server_config_defaults_capacity_contract_none() {
let config = AutumnConfig::default();
assert!(config.server.capacity_contract.is_none());
}
#[test]
fn capacity_contract_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server]
capacity_contract = "capacity.lock"
"#,
)
.expect("config with server.capacity_contract should parse");
assert_eq!(
config.server.capacity_contract.as_deref(),
Some("capacity.lock")
);
}
#[test]
fn env_override_server_capacity_contract() {
let env = MockEnv::new().with("AUTUMN_SERVER__CAPACITY_CONTRACT", "deploy/capacity.lock");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.server.capacity_contract.as_deref(),
Some("deploy/capacity.lock")
);
}
#[test]
fn admission_defaults_change_nothing() {
let a = AutumnConfig::default().server.admission;
assert_eq!(a.mode, AdmissionMode::Static);
assert_eq!(a.algorithm, AdmissionAlgorithm::Gradient2);
assert!(!a.trust_criticality_header);
assert_eq!(
a.partition_shares().unwrap(),
crate::admission::PartitionShares::default()
);
}
#[test]
fn admission_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r#"
[server.admission]
mode = "adaptive"
algorithm = "vegas"
min_limit = 4
max_limit = 400
initial_limit = 40
latency_threshold_ms = 250
trust_criticality_header = true
[server.admission.partitions]
default = 0.9
sheddable = 0.4
"#,
)
.expect("server.admission should parse");
let a = &config.server.admission;
assert_eq!(a.mode, AdmissionMode::Adaptive);
assert_eq!(a.algorithm, AdmissionAlgorithm::Vegas);
assert_eq!(
(a.min_limit, a.max_limit, a.initial_limit),
(4, Some(400), 40)
);
assert_eq!(a.latency_threshold_ms, 250);
assert!(a.trust_criticality_header);
let shares = a.partition_shares().unwrap();
assert_eq!(
shares.threshold(crate::admission::Criticality::Default, 100),
90
);
assert_eq!(
shares.threshold(crate::admission::Criticality::Sheddable, 100),
40
);
config.validate().expect("valid admission config");
}
#[test]
fn admission_rejects_an_unknown_algorithm() {
let err = toml::from_str::<AutumnConfig>(
r#"
[server.admission]
algorithm = "hystrix"
"#,
);
assert!(err.is_err());
}
#[test]
fn admission_env_overrides() {
let env = MockEnv::new()
.with("AUTUMN_SERVER__ADMISSION__MODE", "adaptive")
.with("AUTUMN_SERVER__ADMISSION__ALGORITHM", "aimd")
.with("AUTUMN_SERVER__ADMISSION__MIN_LIMIT", "2")
.with("AUTUMN_SERVER__ADMISSION__MAX_LIMIT", "50")
.with("AUTUMN_SERVER__ADMISSION__INITIAL_LIMIT", "10")
.with("AUTUMN_SERVER__ADMISSION__LATENCY_THRESHOLD_MS", "99")
.with("AUTUMN_SERVER__ADMISSION__TRUST_CRITICALITY_HEADER", "true")
.with("AUTUMN_SERVER__ADMISSION__PARTITIONS__DEFAULT", "0.8")
.with("AUTUMN_SERVER__ADMISSION__PARTITIONS__SHEDDABLE", "0.2");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
let a = &config.server.admission;
assert_eq!(a.mode, AdmissionMode::Adaptive);
assert_eq!(a.algorithm, AdmissionAlgorithm::Aimd);
assert_eq!(
(a.min_limit, a.max_limit, a.initial_limit),
(2, Some(50), 10)
);
assert_eq!(a.latency_threshold_ms, 99);
assert!(a.trust_criticality_header);
assert!((a.partitions.default - 0.8).abs() < f64::EPSILON);
assert!((a.partitions.sheddable - 0.2).abs() < f64::EPSILON);
}
#[cfg(feature = "http-client")]
#[test]
fn adaptive_throttle_parses_and_validates() {
let config: AutumnConfig = toml::from_str(
r"
[http.client.adaptive_throttle]
enabled = true
k = 1.5
window_secs = 60
",
)
.expect("adaptive_throttle should parse");
let t = config.http.client.adaptive_throttle;
assert!(t.enabled);
assert!((t.k - 1.5).abs() < f64::EPSILON);
assert_eq!(t.window_secs, 60);
config.validate().expect("valid");
assert!(
!AutumnConfig::default()
.http
.client
.adaptive_throttle
.enabled
);
let mut bad = AutumnConfig::default();
bad.http.client.adaptive_throttle.k = 0.5;
assert!(bad.validate().is_err(), "k < 1");
let mut bad = AutumnConfig::default();
bad.http.client.adaptive_throttle.window_secs = 0;
assert!(bad.validate().is_err(), "window 0");
}
#[test]
fn admission_validate_rejects_bad_bounds_and_shares() {
let mut config = AutumnConfig::default();
config.server.admission.min_limit = 0;
assert!(config.validate().is_err(), "min_limit = 0");
let mut config = AutumnConfig::default();
config.server.admission.min_limit = 10;
config.server.admission.max_limit = Some(5);
assert!(config.validate().is_err(), "min_limit > max_limit");
let mut config = AutumnConfig::default();
config.server.admission.partitions.sheddable = 0.9;
config.server.admission.partitions.default = 0.5;
assert!(config.validate().is_err(), "sheddable > default");
}
#[test]
fn server_config_defaults_max_concurrent_requests_none() {
let config = AutumnConfig::default();
assert!(config.server.max_concurrent_requests.is_none());
}
#[test]
fn max_concurrent_requests_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r"
[server]
max_concurrent_requests = 64
",
)
.expect("config with server.max_concurrent_requests should parse");
assert_eq!(config.server.max_concurrent_requests, Some(64));
}
#[test]
fn env_override_server_max_concurrent_requests() {
let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "128");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.max_concurrent_requests, Some(128));
}
#[test]
fn env_override_invalid_max_concurrent_requests_ignored() {
let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "not_a_number");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.server.max_concurrent_requests.is_none());
}
#[test]
fn env_override_empty_max_concurrent_requests_clears_to_none() {
let env = MockEnv::new().with("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "");
let mut config = AutumnConfig::default();
config.server.max_concurrent_requests = Some(64);
config.apply_env_overrides_with_env(&env);
assert!(config.server.max_concurrent_requests.is_none());
}
#[test]
fn env_override_log_level() {
let env = MockEnv::new().with("AUTUMN_LOG__LEVEL", "debug");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.level, "debug");
}
#[test]
fn env_override_log_format_json() {
let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Json");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.format, LogFormat::Json);
}
#[test]
fn env_override_log_format_pretty() {
let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Pretty");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.format, LogFormat::Pretty);
}
#[test]
fn env_override_invalid_log_format_ignored() {
let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "yaml");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.format, LogFormat::Auto);
}
#[test]
fn env_override_telemetry_fields() {
let env = MockEnv::new()
.with("AUTUMN_TELEMETRY__ENABLED", "true")
.with("AUTUMN_TELEMETRY__SERVICE_NAME", "orders-api")
.with("AUTUMN_TELEMETRY__SERVICE_NAMESPACE", "acme")
.with("AUTUMN_TELEMETRY__SERVICE_VERSION", "1.2.3")
.with("AUTUMN_TELEMETRY__ENVIRONMENT", "production")
.with(
"AUTUMN_TELEMETRY__OTLP_ENDPOINT",
"http://otel-collector:4317",
)
.with("AUTUMN_TELEMETRY__PROTOCOL", "HTTP_PROTOBUF")
.with("AUTUMN_TELEMETRY__STRICT", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.telemetry.enabled);
assert_eq!(config.telemetry.service_name, "orders-api");
assert_eq!(config.telemetry.service_namespace.as_deref(), Some("acme"));
assert_eq!(config.telemetry.service_version, "1.2.3");
assert_eq!(config.telemetry.environment, "production");
assert_eq!(
config.telemetry.otlp_endpoint.as_deref(),
Some("http://otel-collector:4317")
);
assert_eq!(config.telemetry.protocol, TelemetryProtocol::HttpProtobuf);
assert!(config.telemetry.strict);
}
#[test]
fn env_override_telemetry_sample_ratio() {
let env = MockEnv::new().with("AUTUMN_TELEMETRY__SAMPLE_RATIO", "0.25");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!((config.telemetry.sample_ratio - 0.25).abs() < f64::EPSILON);
let env = MockEnv::new().with("AUTUMN_TELEMETRY__SAMPLE_RATIO", "half");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!((config.telemetry.sample_ratio - 1.0).abs() < f64::EPSILON);
}
#[test]
fn env_override_invalid_telemetry_protocol_ignored() {
let env = MockEnv::new().with("AUTUMN_TELEMETRY__PROTOCOL", "zipkin");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.telemetry.protocol, TelemetryProtocol::Grpc);
}
#[test]
fn env_override_health_path() {
let env = MockEnv::new().with("AUTUMN_HEALTH__PATH", "/healthz");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.health.path, "/healthz");
}
#[test]
fn env_override_probe_paths() {
let env = MockEnv::new()
.with("AUTUMN_HEALTH__LIVE_PATH", "/livez")
.with("AUTUMN_HEALTH__READY_PATH", "/readyz")
.with("AUTUMN_HEALTH__STARTUP_PATH", "/startupz");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.health.live_path, "/livez");
assert_eq!(config.health.ready_path, "/readyz");
assert_eq!(config.health.startup_path, "/startupz");
}
#[test]
fn env_overrides_toml_values() {
let env = MockEnv::new().with("AUTUMN_SERVER__PORT", "9999");
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("autumn.toml");
std::fs::write(&path, "[server]\nport = 4000\n").unwrap();
let mut config = AutumnConfig::load_from(&path).unwrap();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.server.port, 9999); }
#[test]
fn validate_rejects_invalid_url_scheme() {
let config = DatabaseConfig {
url: Some("mysql://localhost/test".to_owned()),
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("must start with postgres://")
);
}
#[test]
fn validate_accepts_postgres_url() {
let config = DatabaseConfig {
url: Some("postgres://localhost/test".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn validate_accepts_postgresql_url() {
let config = DatabaseConfig {
url: Some("postgresql://localhost/test".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn validate_accepts_no_url() {
let config = DatabaseConfig::default();
assert!(config.validate().is_ok());
}
#[test]
fn validate_accepts_keyword_value_connection_strings() {
for url in [
"host=db user=app dbname=app",
"host=db user=app sslmode=require",
"host=db sslmode = require",
"host=db password='p w' sslmode='verify-full'",
"host=db password=https://looks-like-a-url sslmode=require",
] {
let config = DatabaseConfig {
url: Some(url.to_owned()),
..Default::default()
};
assert!(
config.validate().is_ok(),
"keyword/value string must validate: {url}"
);
}
let config = DatabaseConfig {
primary_url: Some("host=db user=app sslmode=require".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok());
let config = DatabaseConfig {
primary_url: Some("postgres://db-control/app".to_owned()),
shards: vec![ShardConfig {
name: "s0".to_owned(),
primary_url: "host=db-shard0 user=app dbname=app".to_owned(),
replica_url: None,
slots: None,
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: None,
}],
..Default::default()
};
assert!(
config.validate().is_ok(),
"shard URLs accept the keyword form too: {:?}",
config.validate()
);
}
#[test]
fn validate_still_rejects_garbage_connection_strings() {
for url in [
"mysql://localhost/test",
"mysql://localhost/test?a=b",
"not a connection string",
"localhost",
"host=",
"host='unterminated",
] {
let config = DatabaseConfig {
url: Some(url.to_owned()),
..Default::default()
};
let err = config
.validate()
.expect_err(&format!("garbage must be rejected: {url:?}"))
.to_string();
assert!(
err.contains("must start with postgres:// or postgresql://"),
"the error must stay clear about accepted forms, got: {err}"
);
}
}
#[test]
fn detect_backend_postgres_urls() {
for url in [
"postgres://localhost/app",
"postgresql://user:pass@db:5432/app",
] {
assert_eq!(
DatabaseBackend::detect(url),
Some(DatabaseBackend::Postgres),
"{url} should detect as postgres"
);
}
}
#[test]
fn detect_backend_postgres_keyword_value() {
assert_eq!(
DatabaseBackend::detect("host=db user=app sslmode=require"),
Some(DatabaseBackend::Postgres)
);
}
#[test]
fn detect_backend_sqlite_schemes() {
for url in [
"sqlite:///var/lib/app.db", "sqlite://./relative.db",
"sqlite::memory:",
"sqlite:app.db", "file:app.db", ] {
assert_eq!(
DatabaseBackend::detect(url),
Some(DatabaseBackend::Sqlite),
"{url} should detect as sqlite"
);
}
}
#[test]
fn detect_backend_bare_path_is_unrecognized() {
for target in ["/var/lib/app.db", "./app.db", "app.db", "C:\\db\\app.db"] {
assert_eq!(
DatabaseBackend::detect(target),
None,
"{target} must not be auto-detected as a backend"
);
}
}
#[test]
fn detect_backend_garbage_is_unrecognized() {
for target in ["mysql://localhost/app", "not a connection string", "host="] {
assert_eq!(DatabaseBackend::detect(target), None, "{target}");
}
}
#[test]
fn validate_accepts_sqlite_url() {
let config = DatabaseConfig {
url: Some("sqlite:///var/lib/app.db".to_owned()),
..Default::default()
};
assert!(
config.validate().is_ok(),
"a sqlite:// target must be accepted as valid config: {:?}",
config.validate()
);
}
#[test]
fn validate_accepts_sqlite_primary_url() {
let config = DatabaseConfig {
primary_url: Some("sqlite::memory:".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok(), "{:?}", config.validate());
}
#[test]
fn validate_rejects_replica_url_on_sqlite() {
let config = DatabaseConfig {
primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
..Default::default()
};
let err = config
.validate()
.expect_err("read replicas must be refused on sqlite")
.to_string();
assert!(
err.contains("read replicas require the postgres backend"),
"message must name the postgres requirement, got: {err}"
);
}
#[test]
fn validate_rejects_shards_on_sqlite() {
let config = DatabaseConfig {
primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
shards: vec![ShardConfig {
name: "s0".to_owned(),
primary_url: "postgres://db-shard0/app".to_owned(),
replica_url: None,
slots: None,
primary_pool_size: None,
replica_pool_size: None,
replica_fallback: None,
}],
..Default::default()
};
let err = config
.validate()
.expect_err("shards must be refused on sqlite")
.to_string();
assert!(
err.contains("database shards require the postgres backend"),
"message must name the postgres requirement, got: {err}"
);
}
fn fleet(mode: crate::fleet_layout::FleetMode) -> DatabaseFleetConfig {
DatabaseFleetConfig {
mode,
root: "/var/lib/app/fleet".to_owned(),
path: None,
max_open: 16,
pool_size: 2,
create_on_demand: None,
idle_close_secs: 300,
restore_missing: false,
ownership: None,
}
}
fn fleet_db(fleet_config: DatabaseFleetConfig) -> DatabaseConfig {
DatabaseConfig {
url: Some("sqlite:///var/lib/app/control.db".to_owned()),
fleet: Some(fleet_config),
..Default::default()
}
}
#[test]
#[allow(
clippy::literal_string_with_formatting_args,
reason = "fleet path templates use {placeholders}"
)]
fn validate_accepts_a_fleet_on_a_sqlite_control_database() {
use crate::fleet_layout::FleetMode;
fleet_db(fleet(FleetMode::Tenant)).validate().unwrap();
fleet_db(fleet(FleetMode::Slot)).validate().unwrap();
let tenant = fleet(FleetMode::Tenant);
assert_eq!(tenant.path_template(), "{bucket}/{tenant}.db");
assert!(!tenant.effective_create_on_demand());
let slot = fleet(FleetMode::Slot);
assert_eq!(slot.path_template(), "{bucket}/slot-{slot}.db");
assert!(slot.effective_create_on_demand());
}
#[test]
fn validate_rejects_a_fleet_without_a_sqlite_control_database() {
use crate::fleet_layout::FleetMode;
for url in [
None,
Some("postgres://db/app"),
Some("sqlite::memory:"),
Some("sqlite:"),
Some("sqlite://"),
Some("file::memory:?cache=shared"),
Some("sqlite:file:app?mode=memory&cache=shared"),
] {
let config = DatabaseConfig {
url: url.map(str::to_owned),
fleet: Some(fleet(FleetMode::Tenant)),
..Default::default()
};
let err = config
.validate()
.expect_err("needs a file-backed sqlite control database")
.to_string();
assert!(err.contains("sqlite: control database"), "{url:?}: {err}");
}
}
#[test]
fn percent_decoding_matches_sqlite_uri_paths() {
assert_eq!(percent_decode_uri_path("/a/%66leet/b"), "/a/fleet/b");
assert_eq!(percent_decode_uri_path("/a%2Fb"), "/a/b");
assert_eq!(percent_decode_uri_path("/50%"), "/50%", "trailing % stays");
assert_eq!(percent_decode_uri_path("/x%zz"), "/x%zz", "non-hex stays");
assert_eq!(
percent_decode_uri_path("/x%+1"),
"/x%+1",
"a sign is not hex"
);
assert_eq!(
percent_decode_uri_path("/db%00tail"),
"/db",
"NUL ends the path"
);
}
#[test]
fn validate_rejects_a_control_database_inside_the_fleet_root() {
use crate::fleet_layout::FleetMode;
for url in [
"sqlite:///var/lib/app/control.db",
"sqlite:///var/lib/app/./sub/../control.db",
"sqlite:/var/lib/app/control.db?mode=rwc",
"sqlite:file:/var/lib/app/control.db?cache=private",
"sqlite:file:///var/lib/app/control.db",
] {
let mut config = fleet_db(DatabaseFleetConfig {
root: "/var/lib/app".to_owned(),
path: Some("{tenant}.db".to_owned()),
..fleet(FleetMode::Tenant)
});
config.url = Some(url.to_owned());
let err = config
.validate()
.expect_err("control database inside the fleet root")
.to_string();
assert!(err.contains("inside database.fleet.root"), "{url}: {err}");
}
for url in [
"file:/var/lib/app/%66leet/control.db",
"sqlite:file:/var/lib/app/%66leet/control.db?mode=rwc",
"sqlite:file:///var/lib/app/fleet/control.db#frag",
"file://localhost/var/lib/app/fleet/control.db",
"sqlite:file://LOCALHOST/var/lib/app/fleet/control.db",
] {
let mut config = fleet_db(fleet(FleetMode::Tenant));
config.url = Some(url.to_owned());
let err = config
.validate()
.expect_err("decoded control path is inside the fleet root")
.to_string();
assert!(err.contains("inside database.fleet.root"), "{url}: {err}");
}
let mut literal = fleet_db(DatabaseFleetConfig {
root: "/srv/fleet?data".to_owned(),
..fleet(FleetMode::Tenant)
});
literal.url = Some("sqlite:///srv/fleet?data/control.db".to_owned());
let err = literal
.validate()
.expect_err("a literal `?` path inside the root")
.to_string();
assert!(err.contains("inside database.fleet.root"), "{err}");
fleet_db(fleet(FleetMode::Tenant)).validate().unwrap();
let mut sibling = fleet_db(fleet(FleetMode::Tenant));
sibling.url = Some("sqlite:///var/lib/app/fleet-control.db".to_owned());
sibling.validate().unwrap();
}
#[test]
fn validate_rejects_a_fleet_alongside_shards_or_the_directory_router() {
use crate::fleet_layout::FleetMode;
let mut config = fleet_db(fleet(FleetMode::Slot));
config.directory_shard_router = true;
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("directory_shard_router"), "{err}");
let mut config = fleet_db(fleet(FleetMode::Slot));
config.shards = vec![shard("s0", "postgres://db-shard0/app")];
assert!(config.validate().is_err());
let err = config.validate_fleet().unwrap_err().to_string();
assert!(err.contains("declare one or the other"), "{err}");
}
#[test]
fn validate_rejects_malformed_fleet_sections() {
use crate::fleet_layout::FleetMode;
type Mutate = fn(&mut DatabaseFleetConfig);
let cases: [(&str, Mutate); 6] = [
("root", |f| f.root = " ".to_owned()),
("not a database URL", |f| f.root = "sqlite:///x".to_owned()),
("{tenant}", |f| f.path = Some("{bucket}/all.db".to_owned())),
("..", |f| f.path = Some("../{tenant}.db".to_owned())),
("max_open", |f| f.max_open = 0),
("pool_size", |f| f.pool_size = 0),
];
for (needle, mutate) in cases {
let mut section = fleet(FleetMode::Tenant);
mutate(&mut section);
let err = fleet_db(section).validate().unwrap_err().to_string();
assert!(err.contains(needle), "expected {needle:?} in {err}");
}
}
#[test]
fn fleet_env_overrides_create_and_adjust_the_section() {
use crate::fleet_layout::FleetMode;
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(
&MockEnv::new().with("AUTUMN_DATABASE__FLEET__MODE", "slot"),
);
assert!(config.database.fleet.is_none());
let env = MockEnv::new()
.with("AUTUMN_DATABASE__FLEET__MODE", "tenant")
.with("AUTUMN_DATABASE__FLEET__ROOT", "/data/fleet")
.with("AUTUMN_DATABASE__FLEET__PATH", "{tenant}/main.db")
.with("AUTUMN_DATABASE__FLEET__MAX_OPEN", "9")
.with("AUTUMN_DATABASE__FLEET__POOL_SIZE", "1")
.with("AUTUMN_DATABASE__FLEET__CREATE_ON_DEMAND", "true")
.with("AUTUMN_DATABASE__FLEET__IDLE_CLOSE_SECS", "0")
.with("AUTUMN_DATABASE__FLEET__RESTORE_MISSING", "true");
config.apply_env_overrides_with_env(&env);
let fleet = config.database.fleet.expect("env creates the fleet");
assert_eq!(fleet.mode, FleetMode::Tenant);
assert_eq!(fleet.root, "/data/fleet");
assert_eq!(fleet.path_template(), "{tenant}/main.db");
assert_eq!(fleet.max_open, 9);
assert_eq!(fleet.pool_size, 1);
assert!(fleet.effective_create_on_demand());
assert_eq!(fleet.idle_close_secs, 0);
assert!(fleet.restore_missing);
}
#[test]
fn fleet_ownership_deserializes_and_validates() {
let config: AutumnConfig = toml::from_str(
r#"
[database]
url = "sqlite:///var/lib/app/control.db"
[database.fleet]
mode = "slot"
root = "/var/lib/app/fleet"
[database.fleet.ownership]
owner = "host-a"
slots = ["0-8191", 9000]
"#,
)
.unwrap();
let ownership = config
.database
.fleet
.as_ref()
.and_then(|f| f.ownership.clone())
.unwrap();
assert_eq!(ownership.lease_secs, 30);
assert_eq!(ownership.ranges().unwrap(), vec![(0, 8191), (9000, 9000)]);
config.database.validate().unwrap();
let every = FleetOwnershipConfig {
owner: "a".to_owned(),
slots: Vec::new(),
lease_secs: 30,
};
assert_eq!(every.ranges().unwrap(), vec![(0, SLOT_COUNT - 1)]);
let bad = |owner: &str, slots: &[&str], lease_secs| {
let mut fleet = fleet(crate::fleet_layout::FleetMode::Slot);
fleet.ownership = Some(FleetOwnershipConfig {
owner: owner.to_owned(),
slots: slots
.iter()
.map(|s| SlotSpec::Range((*s).to_owned()))
.collect(),
lease_secs,
});
fleet_db(fleet).validate().unwrap_err().to_string()
};
assert!(bad("", &[], 30).contains("owner"));
assert!(bad("a", &["0-10", "10-20"], 30).contains("overlap"));
assert!(bad("a", &["0-16384"], 30).contains("out of range"));
assert!(bad("a", &["9-1"], 30).contains("inverted"));
assert!(bad("a", &[], 2).contains("lease_secs"));
assert!(bad("a", &[], 3601).contains("lease_secs"));
assert!(bad(" a", &[], 30).contains("white space"));
}
#[test]
fn fleet_ownership_env_overrides_create_and_adjust_the_section() {
let mut config = AutumnConfig::default();
config.database.fleet = Some(fleet(crate::fleet_layout::FleetMode::Slot));
config.apply_env_overrides_with_env(
&MockEnv::new()
.with("AUTUMN_DATABASE__FLEET__OWNERSHIP__OWNER", "host-b")
.with("AUTUMN_DATABASE__FLEET__OWNERSHIP__SLOTS", "0-99, 200,")
.with("AUTUMN_DATABASE__FLEET__OWNERSHIP__LEASE_SECS", "12"),
);
let ownership = config.database.fleet.unwrap().ownership.unwrap();
assert_eq!(ownership.owner, "host-b");
assert_eq!(ownership.ranges().unwrap(), vec![(0, 99), (200, 200)]);
assert_eq!(ownership.lease_secs, 12);
}
#[test]
fn fleet_section_deserializes_from_toml() {
use crate::fleet_layout::FleetMode;
let config: AutumnConfig = toml::from_str(
r#"
[database]
url = "sqlite:///var/lib/app/control.db"
[database.fleet]
mode = "slot"
root = "/var/lib/app/fleet"
"#,
)
.unwrap();
let fleet = config.database.fleet.as_ref().unwrap();
assert_eq!(fleet.mode, FleetMode::Slot);
assert_eq!(fleet.max_open, 256);
assert_eq!(fleet.pool_size, 2);
assert_eq!(fleet.idle_close_secs, 300);
config.database.validate().unwrap();
}
#[test]
fn validate_rejects_backend_mismatch_across_roles() {
let config = DatabaseConfig {
primary_url: Some("postgres://db-primary/app".to_owned()),
replica_url: Some("sqlite:///var/lib/replica.db".to_owned()),
..Default::default()
};
let err = config
.validate()
.expect_err("mixed backends must be refused")
.to_string();
assert!(
err.contains("database.replica_url")
&& err.contains("does not match the primary database backend"),
"message must name the offending field and the mismatch, got: {err}"
);
}
#[test]
fn validate_rejects_sqlite_primary_with_postgres_url() {
let config = DatabaseConfig {
primary_url: Some("sqlite:///var/lib/app.db".to_owned()),
url: Some("postgres://db-primary/app".to_owned()),
..Default::default()
};
let err = config
.validate()
.expect_err("mixed backends must be refused")
.to_string();
assert!(
err.contains("database.url")
&& err.contains("does not match the primary database backend"),
"got: {err}"
);
}
#[test]
fn validate_postgres_app_with_replica_still_valid() {
let config = DatabaseConfig {
primary_url: Some("postgres://db-primary/app".to_owned()),
replica_url: Some("postgres://db-replica/app".to_owned()),
..Default::default()
};
assert!(config.validate().is_ok(), "{:?}", config.validate());
}
#[test]
fn resolve_profile_from_autumn_env() {
let env = MockEnv::new().with("AUTUMN_ENV", "prod");
let profile = resolve_profile(&env);
assert_eq!(profile, "prod");
}
#[test]
fn resolve_profile_from_legacy_env() {
let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
let profile = resolve_profile(&env);
assert_eq!(profile, "staging");
}
#[test]
fn resolve_profile_prefers_autumn_env_over_legacy_alias() {
let env = MockEnv::new()
.with("AUTUMN_ENV", "dev")
.with("AUTUMN_PROFILE", "prod");
let profile = resolve_profile(&env);
assert_eq!(profile, "dev");
}
#[test]
fn resolve_profile_normalizes_production_alias() {
let env = MockEnv::new().with("AUTUMN_ENV", "production");
let profile = resolve_profile(&env);
assert_eq!(profile, "prod");
}
#[test]
fn resolve_profile_normalizes_development_alias_with_whitespace() {
let env = MockEnv::new().with("AUTUMN_ENV", " development ");
let profile = resolve_profile(&env);
assert_eq!(profile, "dev");
}
#[test]
fn resolve_profile_normalizes_uppercase_dev_and_prod() {
let prod_env = MockEnv::new().with("AUTUMN_ENV", "PROD");
let prod = resolve_profile(&prod_env);
assert_eq!(prod, "prod");
let dev_env = MockEnv::new().with("AUTUMN_ENV", "DEV");
let dev = resolve_profile(&dev_env);
assert_eq!(dev, "dev");
}
#[test]
fn resolve_profile_preserves_case_for_custom_profiles() {
let env = MockEnv::new().with("AUTUMN_ENV", "QA");
let profile = resolve_profile(&env);
assert_eq!(profile, "QA");
}
#[test]
fn resolve_profile_auto_detect_debug() {
let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "1");
let profile = resolve_profile(&env);
assert_eq!(profile, "dev");
}
#[test]
fn resolve_profile_auto_detect_release() {
let env = MockEnv::new().with("AUTUMN_IS_DEBUG", "0");
let profile = resolve_profile(&env);
assert_eq!(profile, "prod");
}
#[test]
fn resolve_profile_defaults_to_dev_when_no_signal_present() {
let env = MockEnv::new();
let profile = resolve_profile(&env);
assert_eq!(profile, "dev");
}
#[test]
fn dev_profile_smart_defaults() {
let defaults = profile_defaults_as_toml("dev");
let toml_str = toml::to_string(&defaults).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.log.level, "debug");
assert_eq!(config.log.format, LogFormat::Pretty);
assert_eq!(config.server.host, "127.0.0.1");
assert_eq!(config.server.shutdown_timeout_secs, 1);
assert_eq!(
config.server.prestop_grace_secs, 0,
"dev profile must set prestop_grace_secs = 0 so Ctrl-C is instant"
);
assert_eq!(config.telemetry.environment, "development");
assert!(config.health.detailed);
assert_eq!(config.cors.allowed_origins, vec!["*"]);
assert!(
config.security.trusted_proxies.trust_forwarded_headers,
"dev profile must trust forwarded headers from loopback"
);
assert!(
config
.security
.trusted_proxies
.ranges
.contains(&"127.0.0.0/8".to_owned()),
"dev profile must include 127.0.0.0/8 as trusted proxy range"
);
assert!(
config
.security
.trusted_proxies
.ranges
.contains(&"::1/128".to_owned()),
"dev profile must include ::1/128 as trusted proxy range"
);
}
#[test]
fn prod_profile_smart_defaults() {
let defaults = profile_defaults_as_toml("prod");
let toml_str = toml::to_string(&defaults).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.log.level, "info");
assert_eq!(config.log.format, LogFormat::Json);
assert_eq!(config.server.host, "0.0.0.0");
assert_eq!(config.server.shutdown_timeout_secs, 35);
assert_eq!(config.telemetry.environment, "production");
assert!(!config.health.detailed);
assert!(
config.security.headers.strict_transport_security,
"prod profile must auto-enable Strict-Transport-Security"
);
assert_eq!(config.security.headers.x_frame_options, "DENY");
assert!(config.security.headers.x_content_type_options);
assert!(!config.security.headers.content_security_policy.is_empty());
}
#[test]
fn profile_shutdown_defaults_match_the_smart_defaults() {
assert_eq!(profile_shutdown_defaults("dev"), (0, 1));
assert_eq!(profile_shutdown_defaults("development"), (0, 1));
assert_eq!(profile_shutdown_defaults(" dev "), (0, 1));
assert_eq!(profile_shutdown_defaults("prod"), (5, 35));
assert_eq!(profile_shutdown_defaults("production"), (5, 35));
let server = ServerConfig::default();
assert_eq!(
profile_shutdown_defaults("staging"),
(server.prestop_grace_secs, server.shutdown_timeout_secs)
);
}
#[test]
fn dev_profile_does_not_auto_enable_hsts() {
let defaults = profile_defaults_as_toml("dev");
let toml_str = toml::to_string(&defaults).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert!(
!config.security.headers.strict_transport_security,
"dev profile must not force HSTS on (local http development)"
);
}
#[test]
fn custom_profile_no_smart_defaults() {
let defaults = profile_defaults_as_toml("staging");
assert_eq!(defaults, toml::Value::Table(toml::map::Map::new()));
}
#[test]
fn deep_merge_tables() {
let mut base: toml::Value = toml::from_str(
r#"
[server]
port = 3000
host = "127.0.0.1"
[database]
pool_size = 10
"#,
)
.unwrap();
let overlay: toml::Value = toml::from_str(
r#"
[server]
port = 8080
[database]
url = "postgres://localhost/test"
"#,
)
.unwrap();
deep_merge(&mut base, overlay);
assert_eq!(base["server"]["port"], toml::Value::Integer(8080));
assert_eq!(
base["server"]["host"],
toml::Value::String("127.0.0.1".into())
);
assert_eq!(
base["database"]["url"],
toml::Value::String("postgres://localhost/test".into())
);
assert_eq!(base["database"]["pool_size"], toml::Value::Integer(10));
}
#[test]
fn profile_toml_overrides_base_toml() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("autumn.toml");
let dev_path = dir.path().join("autumn-dev.toml");
std::fs::write(
&base_path,
r"
[server]
port = 3000
[database]
pool_size = 10
",
)
.unwrap();
std::fs::write(
&dev_path,
r#"
[database]
url = "postgres://localhost/myapp_dev"
"#,
)
.unwrap();
let mut merged = toml::Value::Table(toml::map::Map::new());
let base = load_raw_toml(&base_path).unwrap().unwrap();
deep_merge(&mut merged, base);
let profile = load_raw_toml(&dev_path).unwrap().unwrap();
deep_merge(&mut merged, profile);
let toml_str = toml::to_string(&merged).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.server.port, 3000); assert_eq!(config.database.pool_size, 10); assert_eq!(
config.database.url.as_deref(),
Some("postgres://localhost/myapp_dev")
); }
#[test]
fn inline_profile_section_overrides_base_toml() {
let mut merged = toml::Value::Table(toml::map::Map::new());
let base: toml::Value = toml::from_str(
r#"
[server]
port = 3000
[log]
level = "info"
[profile.dev.log]
level = "debug"
"#,
)
.unwrap();
deep_merge(&mut merged, base.clone());
let inline = profile_section_from_base_toml(&base, "dev").unwrap();
deep_merge(&mut merged, inline);
let toml_str = toml::to_string(&merged).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(config.server.port, 3000);
assert_eq!(config.log.level, "debug");
}
#[test]
fn levenshtein_basic() {
assert_eq!(levenshtein("dev", "dev"), 0);
assert_eq!(levenshtein("dev", "dve"), 2); assert_eq!(levenshtein("prod", "prodd"), 1);
assert_eq!(levenshtein("prod", "prd"), 1);
assert_eq!(levenshtein("staging", "dev"), 7);
}
#[test]
fn env_override_health_detailed() {
let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.health.detailed);
}
#[test]
fn env_overrides_health_dependency_checks() {
let env = MockEnv::new()
.with("AUTUMN_HEALTH__CACHE_TTL_MS", "250")
.with("AUTUMN_HEALTH__PING_TIMEOUT_MS", "750")
.with("AUTUMN_HEALTH__DB_READINESS", "false")
.with("AUTUMN_HEALTH__REDIS_READINESS", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.health.cache_ttl(),
std::time::Duration::from_millis(250)
);
assert_eq!(
config.health.ping_timeout(),
std::time::Duration::from_millis(750)
);
assert!(!config.health.db_readiness);
assert!(config.health.redis_readiness);
}
#[test]
fn health_dependency_checks_parse_from_toml() {
let config: AutumnConfig = toml::from_str(
"[health]\ncache_ttl_ms = 0\nping_timeout_ms = 300\ndb_readiness = false\n",
)
.expect("valid toml");
assert_eq!(config.health.cache_ttl_ms, 0);
assert_eq!(config.health.ping_timeout_ms, 300);
assert!(!config.health.db_readiness);
assert!(!config.health.redis_readiness);
}
#[test]
fn zero_ping_timeout_is_rejected() {
let mut config = AutumnConfig::default();
config.health.ping_timeout_ms = 0;
let error = config.validate().expect_err("zero ping timeout");
assert!(
error.to_string().contains("health.ping_timeout_ms"),
"{error}"
);
}
#[test]
fn profile_name_accessor() {
let mut config = AutumnConfig::default();
assert!(config.profile_name().is_none());
config.profile = Some("dev".to_owned());
assert_eq!(config.profile_name(), Some("dev"));
}
#[test]
fn find_config_file_falls_back_to_cwd() {
let env = MockEnv::new();
let path = find_config_file_named("autumn.toml", &env);
assert_eq!(path, PathBuf::from("autumn.toml"));
}
#[test]
fn find_config_file_uses_manifest_dir_when_file_exists() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("autumn.toml");
std::fs::write(&config_path, "").unwrap();
let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let path = find_config_file_named("autumn.toml", &env);
assert_eq!(path, config_path);
}
#[test]
fn find_config_file_falls_back_when_manifest_dir_missing_file() {
let dir = tempfile::tempdir().unwrap();
let env = MockEnv::new().with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let path = find_config_file_named("nonexistent.toml", &env);
assert_eq!(path, PathBuf::from("nonexistent.toml"));
}
#[test]
fn resolve_profile_cli_flag_exact_match() {
let env = MockEnv::new();
let profile = resolve_profile(&env);
drop(profile);
}
#[test]
fn deep_merge_non_table_overlay_replaces_base() {
let mut base: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
let overlay = toml::Value::String("not_a_table".into());
deep_merge(&mut base, overlay);
assert!(base.is_table());
assert_eq!(base["server"]["port"], toml::Value::Integer(3000));
}
#[test]
fn deep_merge_when_base_not_table() {
let mut base = toml::Value::String("original".into());
let overlay: toml::Value = toml::from_str("[server]\nport = 3000\n").unwrap();
deep_merge(&mut base, overlay);
assert_eq!(base, toml::Value::String("original".into()));
}
#[test]
fn suggest_profile_close_match() {
assert_eq!(suggest_profile("dve"), Some("dev"));
}
#[test]
fn suggest_profile_no_match_when_distant() {
assert_eq!(suggest_profile("xyz"), None);
}
#[test]
fn suggest_profile_exact_known_profile() {
assert_eq!(suggest_profile("dev"), Some("dev"));
assert_eq!(suggest_profile("prod"), Some("prod"));
}
#[test]
fn suggest_profile_prd() {
assert_eq!(suggest_profile("prd"), Some("prod"));
}
#[test]
fn warn_profile_typo_runs_without_panic() {
warn_profile_typo("dve");
warn_profile_typo("xyz");
}
#[test]
fn should_warn_missing_profile_file_custom_without_inline() {
assert!(should_warn_missing_profile_file("staging", false));
}
#[test]
fn should_not_warn_missing_profile_file_custom_with_inline() {
assert!(!should_warn_missing_profile_file("staging", true));
}
#[test]
fn should_not_warn_missing_profile_file_dev_or_prod() {
assert!(!should_warn_missing_profile_file("dev", false));
assert!(!should_warn_missing_profile_file("prod", false));
}
#[test]
fn levenshtein_threshold_in_warn_profile_typo() {
assert!(levenshtein("dve", "dev") <= 2);
assert!(levenshtein("xyz", "dev") > 2);
assert!(levenshtein("xyz", "prod") > 2);
}
#[test]
fn env_override_cors_allowed_origins() {
let env = MockEnv::new().with(
"AUTUMN_CORS__ALLOWED_ORIGINS",
"https://a.com, https://b.com",
);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.cors.allowed_origins,
vec!["https://a.com", "https://b.com"]
);
}
#[test]
fn env_override_cors_allow_credentials() {
let env = MockEnv::new().with("AUTUMN_CORS__ALLOW_CREDENTIALS", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.cors.allow_credentials);
}
#[test]
fn env_override_cors_max_age() {
let env = MockEnv::new().with("AUTUMN_CORS__MAX_AGE_SECS", "3600");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.cors.max_age_secs, 3600);
}
#[test]
fn cors_validate_rejects_wildcard_with_credentials() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["*".to_owned()];
config.cors.allow_credentials = true;
let result = config.validate();
match result {
Err(ConfigError::Validation(msg)) => {
assert!(
msg.contains("allow_credentials") && msg.contains('*'),
"message should mention credentials and wildcard, got: {msg}"
);
}
other => panic!("expected ConfigError::Validation, got {other:?}"),
}
}
#[test]
fn cors_validate_accepts_wildcard_without_credentials() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["*".to_owned()];
config.cors.allow_credentials = false;
assert!(config.validate().is_ok());
}
#[test]
fn cors_validate_accepts_explicit_origins_with_credentials() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
config.cors.allow_credentials = true;
assert!(config.validate().is_ok());
}
#[test]
fn load_uses_profile_layering() {
let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("dev"));
assert_eq!(config.log.level, "debug"); assert_eq!(config.log.format, LogFormat::Pretty); assert!(config.health.detailed); }
#[test]
fn load_custom_profile_without_toml_warns() {
let env = MockEnv::new().with("AUTUMN_PROFILE", "staging");
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("staging"));
assert_eq!(config.server.port, 3000);
assert_eq!(config.log.level, "info");
}
#[test]
fn load_dev_profile_no_profile_toml_no_warn() {
let env = MockEnv::new().with("AUTUMN_PROFILE", "dev");
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("dev"));
}
#[test]
fn load_custom_profile_uses_inline_profile_without_legacy_file() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("autumn.toml");
std::fs::write(
&base_path,
r"
[server]
port = 3000
[profile.staging.server]
port = 4100
",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "staging")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("staging"));
assert_eq!(config.server.port, 4100);
}
#[test]
fn load_production_profile_reads_inline_profile_production_section() {
let dir = tempfile::tempdir().unwrap();
let base_path = dir.path().join("autumn.toml");
std::fs::write(
&base_path,
r"
[profile.production.server]
port = 4200
",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "production")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("prod"));
assert_eq!(config.server.port, 4200);
}
#[test]
fn load_production_profile_reads_legacy_autumn_production_toml() {
let dir = tempfile::tempdir().unwrap();
let production_path = dir.path().join("autumn-production.toml");
std::fs::write(
&production_path,
r"
[server]
port = 4300
",
)
.unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "production")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("prod"));
assert_eq!(config.server.port, 4300);
}
#[test]
fn load_prod_prefers_autumn_prod_toml_before_production_alias() {
let dir = tempfile::tempdir().unwrap();
let prod_path = dir.path().join("autumn-prod.toml");
let production_path = dir.path().join("autumn-production.toml");
std::fs::write(
&prod_path,
r"
[server]
port = 4400
",
)
.unwrap();
std::fs::write(&production_path, "[server\nport = 4500").unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "prod")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("prod"));
assert_eq!(config.server.port, 4400);
}
#[test]
fn load_production_prefers_autumn_production_toml_before_prod_alias() {
let dir = tempfile::tempdir().unwrap();
let prod_path = dir.path().join("autumn-prod.toml");
let production_path = dir.path().join("autumn-production.toml");
std::fs::write(
&production_path,
r"
[server]
port = 4500
",
)
.unwrap();
std::fs::write(&prod_path, "[server\nport = 4400").unwrap();
let env = MockEnv::new()
.with("AUTUMN_ENV", "production")
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
assert_eq!(config.profile.as_deref(), Some("prod"));
assert_eq!(config.server.port, 4500);
}
#[test]
fn load_from_io_error_is_not_swallowed() {
let dir = tempfile::tempdir().unwrap();
let result = AutumnConfig::load_from(dir.path());
assert!(result.is_err());
}
#[test]
fn load_raw_toml_missing_file_returns_none() {
let result = load_raw_toml(Path::new("this_file_does_not_exist_12345.toml")).unwrap();
assert!(result.is_none());
}
#[test]
fn load_raw_toml_directory_returns_io_error() {
let dir = tempfile::tempdir().unwrap();
let result = load_raw_toml(dir.path());
assert!(result.is_err());
}
#[test]
fn load_raw_toml_valid_file_returns_some() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.toml");
std::fs::write(&path, "[server]\nport = 3000\n").unwrap();
let result = load_raw_toml(&path).unwrap();
assert!(result.is_some());
assert_eq!(
result.unwrap()["server"]["port"],
toml::Value::Integer(3000)
);
}
#[test]
fn env_override_log_format_auto() {
let env = MockEnv::new().with("AUTUMN_LOG__FORMAT", "Auto");
let mut config = AutumnConfig::default();
config.log.format = LogFormat::Json;
config.apply_env_overrides_with_env(&env);
assert_eq!(config.log.format, LogFormat::Auto);
}
#[test]
fn env_override_health_detailed_false() {
let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "false");
let mut config = AutumnConfig::default();
config.health.detailed = true; config.apply_env_overrides_with_env(&env);
assert!(!config.health.detailed);
}
#[test]
fn env_override_health_detailed_zero() {
let env = MockEnv::new().with("AUTUMN_HEALTH__DETAILED", "0");
let mut config = AutumnConfig::default();
config.health.detailed = true;
config.apply_env_overrides_with_env(&env);
assert!(!config.health.detailed);
}
#[test]
fn health_enabled_defaults_true() {
assert!(HealthConfig::default().enabled);
}
#[test]
fn env_override_health_enabled_false() {
let env = MockEnv::new().with("AUTUMN_HEALTH__ENABLED", "false");
let mut config = AutumnConfig::default();
assert!(config.health.enabled); config.apply_env_overrides_with_env(&env);
assert!(!config.health.enabled);
}
#[test]
fn cors_defaults() {
let cors = CorsConfig::default();
assert!(cors.allowed_origins.is_empty());
assert_eq!(cors.allowed_methods.len(), 6);
assert!(cors.allowed_methods.contains(&"GET".to_owned()));
assert!(cors.allowed_headers.contains(&"Content-Type".to_owned()));
assert!(!cors.allow_credentials);
assert_eq!(cors.max_age_secs, 86400);
}
#[test]
fn cors_in_full_config_defaults() {
let config = AutumnConfig::default();
assert!(config.cors.allowed_origins.is_empty());
}
#[test]
fn actuator_defaults() {
let config = ActuatorConfig::default();
assert_eq!(config.prefix, "/actuator");
assert!(!config.sensitive);
assert!(config.prometheus);
}
#[test]
fn actuator_prometheus_can_be_disabled_via_toml() {
let toml = r"
sensitive = false
prometheus = false
";
let config: ActuatorConfig = toml::from_str(toml).unwrap();
assert!(!config.sensitive);
assert!(!config.prometheus);
}
#[test]
fn actuator_prefix_in_full_config() {
let config = AutumnConfig::default();
assert_eq!(config.actuator.prefix, "/actuator");
}
#[test]
fn deep_merge_handles_deep_nesting() {
let mut base = toml::Value::Table(toml::map::Map::new());
let mut overlay = toml::Value::Table(toml::map::Map::new());
let mut current_base = &mut base;
let mut current_overlay = &mut overlay;
for _ in 0..10_000 {
if let toml::Value::Table(t) = current_base {
t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
current_base = t.get_mut("x").unwrap();
}
if let toml::Value::Table(t) = current_overlay {
t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
current_overlay = t.get_mut("x").unwrap();
}
}
if let toml::Value::Table(t) = current_overlay {
t.insert("y".to_owned(), toml::Value::Integer(42));
}
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(move || {
deep_merge(&mut base, overlay);
std::mem::forget(base);
})
.unwrap()
.join()
.unwrap();
}
#[test]
fn deep_merge_stops_at_max_depth() {
let mut base = toml::Value::Table(toml::map::Map::new());
let mut overlay = toml::Value::Table(toml::map::Map::new());
let mut current_base = &mut base;
let mut current_overlay = &mut overlay;
for _ in 0..=MAX_MERGE_DEPTH {
if let toml::Value::Table(t) = current_base {
t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
current_base = t.get_mut("x").unwrap();
}
if let toml::Value::Table(t) = current_overlay {
t.insert("x".to_owned(), toml::Value::Table(toml::map::Map::new()));
current_overlay = t.get_mut("x").unwrap();
}
}
if let toml::Value::Table(t) = current_overlay {
t.insert("deep_value".to_owned(), toml::Value::Integer(123));
}
deep_merge(&mut base, overlay);
let mut current_base_check = &base;
for _ in 0..=MAX_MERGE_DEPTH {
if let toml::Value::Table(t) = current_base_check {
current_base_check = t.get("x").unwrap();
}
}
if let toml::Value::Table(t) = current_base_check {
assert!(
!t.contains_key("deep_value"),
"Value beyond MAX_MERGE_DEPTH should not be merged"
);
} else {
panic!("Expected a table");
}
}
#[test]
fn env_override_forbidden_response_403() {
let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "403");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.forbidden_response,
crate::authorization::ForbiddenResponse::Forbidden403
);
}
#[test]
fn env_override_forbidden_response_404() {
let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "404");
let mut config = AutumnConfig::default();
config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.forbidden_response,
crate::authorization::ForbiddenResponse::NotFound404
);
}
#[test]
fn env_override_forbidden_response_invalid_keeps_existing() {
let env = MockEnv::new().with("AUTUMN_SECURITY__FORBIDDEN_RESPONSE", "418");
let mut config = AutumnConfig::default();
config.security.forbidden_response = crate::authorization::ForbiddenResponse::Forbidden403;
config.apply_env_overrides_with_env(&env);
assert_eq!(
config.security.forbidden_response,
crate::authorization::ForbiddenResponse::Forbidden403
);
}
#[test]
fn env_override_allow_unauthorized_repository_api() {
let env = MockEnv::new().with("AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API", "true");
let mut config = AutumnConfig::default();
assert!(!config.security.allow_unauthorized_repository_api);
config.apply_env_overrides_with_env(&env);
assert!(config.security.allow_unauthorized_repository_api);
}
#[test]
fn env_override_allow_unauthorized_repository_api_false_overrides_toml_true() {
let env = MockEnv::new().with(
"AUTUMN_SECURITY__ALLOW_UNAUTHORIZED_REPOSITORY_API",
"false",
);
let mut config = AutumnConfig::default();
config.security.allow_unauthorized_repository_api = true;
config.apply_env_overrides_with_env(&env);
assert!(!config.security.allow_unauthorized_repository_api);
}
#[test]
fn env_override_csrf_token_scan_bytes() {
let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "8388608");
let mut config = AutumnConfig::default();
assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.csrf.token_scan_bytes, 8_388_608);
}
#[test]
fn env_override_csrf_token_scan_bytes_invalid_is_ignored() {
let env = MockEnv::new().with("AUTUMN_SECURITY__CSRF__TOKEN_SCAN_BYTES", "not-a-number");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.security.csrf.token_scan_bytes, 2 * 1024 * 1024);
}
#[test]
fn openapi_runtime_config_defaults_enabled() {
let config = AutumnConfig::default();
assert!(
config.openapi_runtime.enabled,
"[openapi] must default to enabled = true"
);
assert_eq!(
config.openapi_runtime.path, "/openapi.json",
"[openapi] must default to path = \"/openapi.json\""
);
}
#[test]
fn openapi_runtime_config_can_be_disabled_via_toml() {
let toml_str = "
[openapi]
enabled = false
";
let config: AutumnConfig = toml::from_str(toml_str).unwrap();
assert!(
!config.openapi_runtime.enabled,
"[openapi] enabled = false must deserialize correctly"
);
}
#[test]
fn openapi_runtime_config_path_can_be_customized() {
let toml_str = r#"
[openapi]
path = "/api-spec.json"
"#;
let config: AutumnConfig = toml::from_str(toml_str).unwrap();
assert_eq!(
config.openapi_runtime.path, "/api-spec.json",
"[openapi] path must deserialize correctly"
);
}
#[test]
fn cache_env_overrides_fields() {
let env = MockEnv::new()
.with("AUTUMN_CACHE__BACKEND", "redis")
.with("AUTUMN_CACHE__REDIS__URL", "redis://cache:6379/1")
.with("AUTUMN_CACHE__REDIS__KEY_PREFIX", "myapp:cache");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.cache.is_redis(), "backend should be redis");
assert_eq!(
config.cache.redis.url.as_deref(),
Some("redis://cache:6379/1")
);
assert_eq!(config.cache.redis.key_prefix, "myapp:cache");
}
#[test]
fn cache_backend_from_env_value_invalid_is_none() {
assert!(CacheBackend::from_env_value("postgres").is_none());
assert!(CacheBackend::from_env_value("").is_none());
}
#[test]
fn scheduler_validate_rejects_zero_lease_ttl() {
let cfg = SchedulerConfig {
lease_ttl_secs: 0,
..SchedulerConfig::default()
};
assert!(cfg.validate().is_err(), "zero lease_ttl_secs must fail");
}
#[test]
fn scheduler_validate_rejects_empty_key_prefix() {
let cfg = SchedulerConfig {
key_prefix: " ".to_owned(),
..SchedulerConfig::default()
};
assert!(cfg.validate().is_err(), "blank key_prefix must fail");
}
#[test]
fn scheduler_validate_ok_with_defaults() {
assert!(SchedulerConfig::default().validate().is_ok());
}
#[test]
fn scheduler_resolved_replica_id_uses_explicit_value() {
let cfg = SchedulerConfig {
replica_id: Some("my-pod".to_owned()),
..SchedulerConfig::default()
};
assert_eq!(cfg.resolved_replica_id(), "my-pod");
}
#[test]
fn scheduler_resolved_replica_id_falls_back_to_pid() {
let cfg = SchedulerConfig {
replica_id: None,
..SchedulerConfig::default()
};
assert!(!cfg.resolved_replica_id().is_empty());
}
#[cfg(feature = "mail")]
#[test]
fn mail_allow_in_process_deliver_later_in_production_is_overridable_via_env() {
let env = MockEnv::new()
.with(
"AUTUMN_MAIL__ALLOW_IN_PROCESS_DELIVER_LATER_IN_PRODUCTION",
"true",
)
.with("AUTUMN_MAIL__TRANSPORT", "smtp")
.with("AUTUMN_MAIL__SMTP__HOST", "smtp.example.com");
let mut config = AutumnConfig::default();
config.apply_mail_env_overrides_with_env(&env);
assert!(
config.mail.allow_in_process_deliver_later_in_production,
"env var should set allow_in_process_deliver_later_in_production"
);
}
#[cfg(feature = "mail")]
#[test]
fn mail_allow_in_process_deliver_later_in_production_defaults_false() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_mail_env_overrides_with_env(&env);
assert!(
!config.mail.allow_in_process_deliver_later_in_production,
"flag should default to false when env var is not set"
);
}
#[cfg(feature = "mail")]
#[test]
fn mail_inline_css_is_overridable_via_env() {
let env = MockEnv::new().with("AUTUMN_MAIL__INLINE_CSS", "true");
let mut config = AutumnConfig::default();
config.apply_mail_env_overrides_with_env(&env);
assert!(
config.mail.inline_css,
"AUTUMN_MAIL__INLINE_CSS=true should enable inline_css"
);
}
#[cfg(feature = "mail")]
#[test]
fn mail_inline_css_defaults_false() {
let env = MockEnv::new();
let mut config = AutumnConfig::default();
config.apply_mail_env_overrides_with_env(&env);
assert!(
!config.mail.inline_css,
"inline_css should default to false when env var is not set"
);
}
#[test]
fn config_credentials_empty_when_no_directory() {
let env = MockEnv::new();
let config = AutumnConfig::load_with_env(&env).unwrap();
assert!(
config.credentials().is_empty(),
"existing apps without config/credentials/ must boot with an empty credentials store"
);
}
#[test]
fn config_has_credentials_accessor() {
let config = AutumnConfig::default();
let _store = config.credentials();
}
#[test]
fn config_credentials_loaded_when_file_present() {
use crate::credentials::{MasterKey, encrypt};
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let key = MasterKey::generate();
let ct = encrypt(&key, b"stripe_key = \"sk_test_xyz\"\n");
std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
let env = MockEnv::new()
.with("AUTUMN_MASTER_KEY", &key.to_hex())
.with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
let val: Option<String> = config.credentials().get("stripe_key");
assert_eq!(val.as_deref(), Some("sk_test_xyz"));
}
#[cfg(feature = "oauth2")]
#[test]
fn config_resolves_oauth_credentials_by_convention() {
use crate::credentials::{MasterKey, encrypt};
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let key = MasterKey::generate();
let ct = encrypt(
&key,
b"oauth2_github_client_id = \"git-id-123\"\noauth2_github_client_secret = \"git-secret-456\"\n",
);
std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
std::fs::create_dir_all(tmp.path().join("config")).unwrap();
let config_toml = r#"
[auth.oauth2.github]
client_id = ""
client_secret = ""
authorize_url = "https://github.com/login/oauth/authorize"
token_url = "https://github.com/login/oauth/access_token"
redirect_uri = "http://localhost:3000/auth/github/callback"
"#;
std::fs::write(tmp.path().join("autumn.toml"), config_toml).unwrap();
let env = MockEnv::new()
.with("AUTUMN_MASTER_KEY", &key.to_hex())
.with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
let config = AutumnConfig::load_with_env(&env).unwrap();
let github = config.auth.oauth2.providers.get("github").unwrap();
assert_eq!(github.client_id, "git-id-123");
assert_eq!(github.client_secret, "git-secret-456");
}
#[test]
fn config_fails_with_credentials_error_when_key_is_invalid() {
use crate::credentials::encrypt;
use tempfile::TempDir;
let tmp = TempDir::new().unwrap();
let bogus_key = "zz".repeat(32); let ct = encrypt(&crate::credentials::MasterKey::generate(), b"x = \"y\"\n");
std::fs::create_dir_all(tmp.path().join("config/credentials")).unwrap();
std::fs::write(tmp.path().join("config/credentials/dev.toml.enc"), &ct).unwrap();
let env = MockEnv::new()
.with("AUTUMN_MASTER_KEY", &bogus_key)
.with("AUTUMN_MANIFEST_DIR", tmp.path().to_str().unwrap());
let err = AutumnConfig::load_with_env(&env).unwrap_err();
assert!(
matches!(err, ConfigError::Credentials(_)),
"bad master key should produce ConfigError::Credentials, got {err:?}"
);
}
#[test]
fn test_parse_duration_str() {
assert_eq!(
parse_duration_str("500ms").unwrap(),
std::time::Duration::from_millis(500)
);
assert_eq!(
parse_duration_str("5s").unwrap(),
std::time::Duration::from_secs(5)
);
assert_eq!(
parse_duration_str("2m").unwrap(),
std::time::Duration::from_secs(120)
);
assert_eq!(
parse_duration_str("1h").unwrap(),
std::time::Duration::from_secs(3600)
);
assert_eq!(
parse_duration_str("1000").unwrap(),
std::time::Duration::from_secs(1)
);
assert!(parse_duration_str("abc").is_err());
assert!(parse_duration_str("").is_err());
}
#[test]
fn test_database_config_duration_deserialization() {
#[derive(Debug, Deserialize)]
struct TestConfig {
#[serde(deserialize_with = "deserialize_option_duration", default)]
timeout: Option<std::time::Duration>,
#[serde(deserialize_with = "deserialize_duration")]
threshold: std::time::Duration,
}
let toml_str = r#"
timeout = "2s"
threshold = "100ms"
"#;
let parsed: TestConfig = toml::from_str(toml_str).unwrap();
assert_eq!(parsed.timeout, Some(std::time::Duration::from_secs(2)));
assert_eq!(parsed.threshold, std::time::Duration::from_millis(100));
let toml_str_null = r#"
threshold = "500"
"#;
let parsed_null: TestConfig = toml::from_str(toml_str_null).unwrap();
assert_eq!(parsed_null.timeout, None);
assert_eq!(parsed_null.threshold, std::time::Duration::from_millis(500));
}
#[test]
fn request_timeouts_config_defaults_to_none() {
let config = RequestTimeoutsConfig::default();
assert!(config.request_timeout_ms.is_none());
}
#[test]
fn server_config_timeouts_defaults_to_disabled() {
let config = ServerConfig::default();
assert!(config.timeouts.request_timeout_ms.is_none());
}
#[test]
fn request_timeouts_config_can_be_set_via_toml() {
let toml_str = "request_timeout_ms = 5000";
let config: RequestTimeoutsConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.request_timeout_ms, Some(5000));
}
#[test]
fn server_config_timeouts_deserialize_nested() {
let toml_str = r#"
port = 3000
host = "127.0.0.1"
shutdown_timeout_secs = 30
prestop_grace_secs = 5
[timeouts]
request_timeout_ms = 15000
"#;
let config: ServerConfig = toml::from_str(toml_str).unwrap();
assert_eq!(config.timeouts.request_timeout_ms, Some(15_000));
}
#[test]
fn prod_drain_window_is_request_timeout_plus_margin() {
let defaults = profile_defaults_as_toml("prod");
let config: AutumnConfig = toml::from_str(&toml::to_string(&defaults).unwrap()).unwrap();
let timeout_secs = config.server.timeouts.request_timeout_ms.unwrap() / 1000;
assert_eq!(
config.server.shutdown_timeout_secs,
timeout_secs + DRAIN_MARGIN_SECS
);
assert!(config.server.drain_window_warning().is_none());
}
#[test]
fn drain_window_shorter_than_request_timeout_is_flagged() {
let mut server = ServerConfig::default();
server.timeouts.request_timeout_ms = Some(30_000);
server.shutdown_timeout_secs = 30;
let warning = server
.drain_window_warning()
.expect("30 s drain, 30 s timeout");
assert!(warning.contains("35"), "names the safe value: {warning}");
server.shutdown_timeout_secs = 35;
assert!(server.drain_window_warning().is_none());
server.timeouts.request_timeout_ms = Some(30_001);
assert!(server.drain_window_warning().is_some(), "rounds up to 36 s");
server.shutdown_timeout_secs = 36;
assert!(server.drain_window_warning().is_none());
server.timeouts.request_timeout_ms = None;
server.shutdown_timeout_secs = 1;
assert!(
server.drain_window_warning().is_none(),
"no timeout, no risk"
);
}
#[test]
fn accept_deadline_header_is_off_by_default_and_configurable() {
assert!(!RequestTimeoutsConfig::default().accept_deadline_header);
let config: ServerConfig =
toml::from_str("[timeouts]\naccept_deadline_header = true\n").unwrap();
assert!(config.timeouts.accept_deadline_header);
let env = MockEnv::new().with("AUTUMN_SERVER__TIMEOUTS__ACCEPT_DEADLINE_HEADER", "true");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert!(config.server.timeouts.accept_deadline_header);
}
#[cfg(feature = "http-client")]
#[test]
fn http_client_deadline_header_and_retry_budget_defaults() {
let config = HttpClientConfig::default();
assert!(config.send_deadline_header);
let budget = &config.retry_budget;
assert!(budget.enabled);
assert_eq!(budget.capacity, 500);
assert_eq!(budget.transient_cost, 14);
assert_eq!(budget.throttling_cost, 5);
assert!((budget.retry_ratio - 0.1).abs() < f64::EPSILON);
}
#[cfg(feature = "http-client")]
#[test]
fn http_client_retry_budget_rejects_bad_values() {
let mut config = AutumnConfig::default();
assert!(config.validate().is_ok());
config.http.client.retry_budget.retry_ratio = 1.5;
assert!(config.validate().is_err());
config.http.client.retry_budget.retry_ratio = f64::NAN;
assert!(config.validate().is_err());
config.http.client.retry_budget.retry_ratio = 0.1;
config.http.client.retry_budget.transient_cost = 0;
assert!(config.validate().is_err());
}
#[cfg(feature = "http-client")]
#[test]
fn a_disabled_retry_budget_is_not_validated() {
let mut config = AutumnConfig::default();
config.http.client.retry_budget.enabled = false;
config.http.client.retry_budget.transient_cost = 0;
config.http.client.retry_budget.throttling_cost = 0;
config.http.client.retry_budget.retry_ratio = 2.0;
assert!(config.validate().is_ok(), "{:?}", config.validate());
}
#[cfg(feature = "http-client")]
#[test]
fn http_client_retry_budget_parses_from_toml() {
let config: HttpConfig = toml::from_str(
"[client]\nsend_deadline_header = false\n\
[client.retry_budget]\nenabled = false\ncapacity = 50\nretry_ratio = 0.2\n",
)
.unwrap();
assert!(!config.client.send_deadline_header);
assert!(!config.client.retry_budget.enabled);
assert_eq!(config.client.retry_budget.capacity, 50);
assert_eq!(config.client.retry_budget.transient_cost, 14);
assert!((config.client.retry_budget.retry_ratio - 0.2).abs() < f64::EPSILON);
}
#[test]
fn autumn_config_server_timeouts_roundtrip() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(20_000);
assert_eq!(config.server.timeouts.request_timeout_ms, Some(20_000));
}
#[test]
fn server_timeouts_env_var_override() {
struct FakeEnv(std::collections::HashMap<String, String>);
impl Env for FakeEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.0
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
}
}
let mut config = AutumnConfig::default();
let env = FakeEnv(
[(
"AUTUMN_SERVER__TIMEOUTS__REQUEST_TIMEOUT_MS".to_owned(),
"8000".to_owned(),
)]
.into(),
);
config.apply_server_env_overrides_with_env(&env);
assert_eq!(config.server.timeouts.request_timeout_ms, Some(8000));
}
#[test]
fn prod_profile_sets_request_timeout_30s() {
let defaults = profile_defaults_as_toml("prod");
let toml_str = toml::to_string(&defaults).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(
config.server.timeouts.request_timeout_ms,
Some(30_000),
"prod profile must enable the 30-second request timeout by default"
);
}
#[test]
fn dev_profile_leaves_request_timeout_disabled() {
let defaults = profile_defaults_as_toml("dev");
let toml_str = toml::to_string(&defaults).unwrap();
let config: AutumnConfig = toml::from_str(&toml_str).unwrap();
assert!(
config.server.timeouts.request_timeout_ms.is_none(),
"dev profile must not enable a request timeout by default"
);
}
#[test]
fn server_http_defaults_are_unset() {
assert_eq!(
AutumnConfig::default().server.http,
HttpServerConfig::default()
);
let dev: AutumnConfig =
toml::from_str(&toml::to_string(&profile_defaults_as_toml("dev")).unwrap()).unwrap();
assert_eq!(dev.server.http, HttpServerConfig::default());
}
#[test]
fn prod_profile_sets_safe_server_http_limits() {
let prod: AutumnConfig =
toml::from_str(&toml::to_string(&profile_defaults_as_toml("prod")).unwrap()).unwrap();
let http = prod.server.http;
assert_eq!(http.header_read_timeout_ms, Some(10_000));
assert_eq!(http.keep_alive_timeout_ms, Some(75_000));
assert_eq!(http.max_header_bytes, Some(65_536));
assert_eq!(http.http2_max_concurrent_streams, Some(100));
assert_eq!(http.max_connections, Some(10_000));
}
#[test]
fn server_http_parses_from_toml() {
let config: AutumnConfig = toml::from_str(
r"
[server.http]
header_read_timeout_ms = 500
keep_alive_timeout_ms = 900
max_header_bytes = 16384
http2_max_concurrent_streams = 8
max_connections = 3
",
)
.unwrap();
assert_eq!(
config.server.http,
HttpServerConfig {
header_read_timeout_ms: Some(500),
keep_alive_timeout_ms: Some(900),
max_header_bytes: Some(16_384),
http2_max_concurrent_streams: Some(8),
max_connections: Some(3),
}
);
}
fn load_3057(profile: &str, toml: &str, vars: &[(&str, &str)]) -> AutumnConfig {
try_load_3057(profile, toml, vars).expect("config loads")
}
fn try_load_3057(
profile: &str,
toml: &str,
vars: &[(&str, &str)],
) -> Result<AutumnConfig, ConfigError> {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("autumn.toml"), toml).unwrap();
let mut env: HashMap<String, String> = vars
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
env.insert("AUTUMN_ENV".to_owned(), profile.to_owned());
env.insert(
"AUTUMN_MANIFEST_DIR".to_owned(),
temp.path().to_str().unwrap().to_owned(),
);
AutumnConfig::load_with_env(&FakeEnv(env))
}
#[test]
fn prod_profile_enables_strict_config() {
assert!(load_3057("prod", "", &[]).server.strict_config);
assert!(!load_3057("dev", "", &[]).server.strict_config);
}
#[test]
fn prod_profile_rejects_misspelled_timeout_key() {
let toml = "[database]\nstatment_timeout = \"5s\"\n";
let err = try_load_3057("prod", toml, &[]).expect_err("typo must fail in prod");
assert!(format!("{err:?}").contains("statment_timeout"), "{err:?}");
assert!(try_load_3057("dev", toml, &[]).is_ok());
}
#[test]
fn prod_strict_config_opt_out() {
let toml = "[server]\nstrict_config = false\n[database]\nstatment_timeout = \"5s\"\n";
assert!(!load_3057("prod", toml, &[]).server.strict_config);
let toml = "[database]\nstatment_timeout = \"5s\"\n";
let env = [("AUTUMN_SERVER__STRICT_CONFIG", "false")];
assert!(!load_3057("prod", toml, &env).server.strict_config);
}
#[cfg(not(feature = "sqlite"))]
#[test]
fn prod_profile_sets_database_timeouts() {
let config = load_3057("prod", "", &[]);
assert_eq!(
config.database.statement_timeout,
Some(std::time::Duration::from_secs(30))
);
assert_eq!(
config.database.idle_in_transaction_timeout,
Some(std::time::Duration::from_secs(60))
);
}
#[cfg(feature = "sqlite")]
#[test]
fn prod_profile_leaves_database_timeouts_off_on_sqlite() {
let config = load_3057("prod", "", &[]);
assert_eq!(config.database.statement_timeout, None);
assert_eq!(config.database.idle_in_transaction_timeout, None);
}
#[test]
fn dev_profile_leaves_database_timeouts_off() {
let config = load_3057("dev", "", &[]);
assert_eq!(config.database.statement_timeout, None);
assert_eq!(config.database.idle_in_transaction_timeout, None);
}
#[test]
fn prod_database_timeouts_opt_out_via_toml() {
let toml = "[database]\nstatement_timeout = \"0s\"\nidle_in_transaction_timeout = \"0s\"\n";
let config = load_3057("prod", toml, &[]);
assert_eq!(
config.database.statement_timeout,
Some(std::time::Duration::ZERO)
);
assert_eq!(
config.database.idle_in_transaction_timeout,
Some(std::time::Duration::ZERO)
);
}
#[test]
fn server_http_env_overrides() {
let env = MockEnv::new()
.with("AUTUMN_SERVER__HTTP__HEADER_READ_TIMEOUT_MS", "1500")
.with("AUTUMN_SERVER__HTTP__KEEP_ALIVE_TIMEOUT_MS", "2500")
.with("AUTUMN_SERVER__HTTP__MAX_HEADER_BYTES", "9000")
.with("AUTUMN_SERVER__HTTP__HTTP2_MAX_CONCURRENT_STREAMS", "7")
.with("AUTUMN_SERVER__HTTP__MAX_CONNECTIONS", "42");
let mut config = AutumnConfig::default();
config.apply_server_env_overrides_with_env(&env);
assert_eq!(config.server.http.header_read_timeout_ms, Some(1500));
assert_eq!(config.server.http.keep_alive_timeout_ms, Some(2500));
assert_eq!(config.server.http.max_header_bytes, Some(9000));
assert_eq!(config.server.http.http2_max_concurrent_streams, Some(7));
assert_eq!(config.server.http.max_connections, Some(42));
}
#[test]
fn server_http_rejects_unusable_values() {
let mut config = AutumnConfig::default();
config.server.http.max_header_bytes = Some(1024);
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("server.http.max_header_bytes"), "{err}");
let mut config = AutumnConfig::default();
config.server.http.http2_max_concurrent_streams = Some(0);
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("http2_max_concurrent_streams"), "{err}");
let mut config = AutumnConfig::default();
config.server.http.max_connections = Some(usize::MAX);
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("server.http.max_connections"), "{err}");
let mut config = AutumnConfig::default();
config.server.http.max_header_bytes = Some(HttpServerConfig::MIN_HEADER_BYTES);
config.server.http.max_connections = Some(HttpServerConfig::MAX_CONNECTIONS);
config.validate().unwrap();
}
#[test]
fn realtime_defaults_are_unset_outside_prod() {
assert_eq!(AutumnConfig::default().realtime, RealtimeConfig::default());
let dev: AutumnConfig =
toml::from_str(&toml::to_string(&profile_defaults_as_toml("dev")).unwrap()).unwrap();
assert_eq!(dev.realtime, RealtimeConfig::default());
}
#[test]
fn prod_profile_sets_safe_realtime_limits() {
let prod: AutumnConfig =
toml::from_str(&toml::to_string(&profile_defaults_as_toml("prod")).unwrap()).unwrap();
assert_eq!(
prod.realtime,
RealtimeConfig {
max_connections: None,
max_message_bytes: Some(1_048_576),
ping_interval_ms: Some(30_000),
idle_timeout_ms: Some(120_000),
}
);
}
#[test]
fn database_timeouts_env_overrides() {
let env = [
("AUTUMN_DATABASE__STATEMENT_TIMEOUT", "0"),
("AUTUMN_DATABASE__IDLE_IN_TRANSACTION_TIMEOUT", "15s"),
];
let config = load_3057("prod", "", &env);
assert_eq!(
config.database.statement_timeout,
Some(std::time::Duration::ZERO)
);
assert_eq!(
config.database.idle_in_transaction_timeout,
Some(std::time::Duration::from_secs(15))
);
let env = [("AUTUMN_DATABASE__STATEMENT_TIMEOUT", "")];
assert_eq!(load_3057("prod", "", &env).database.statement_timeout, None);
}
#[test]
fn migration_lock_defaults_apply_to_every_profile() {
for profile in ["dev", "prod", "staging"] {
let db = load_3057(profile, "", &[]).database;
assert_eq!(
db.migration_lock_timeout,
std::time::Duration::from_secs(5),
"{profile}"
);
assert_eq!(db.migration_lock_retries, 5, "{profile}");
}
}
#[test]
fn migration_lock_opt_out() {
let toml = "[database]\nmigration_lock_timeout = \"0s\"\nmigration_lock_retries = 0\n";
let db = load_3057("prod", toml, &[]).database;
assert_eq!(db.migration_lock_timeout, std::time::Duration::ZERO);
assert_eq!(db.migration_lock_retries, 0);
let env = [
("AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT", "2s"),
("AUTUMN_DATABASE__MIGRATION_LOCK_RETRIES", "1"),
];
let db = load_3057("prod", "", &env).database;
assert_eq!(db.migration_lock_timeout, std::time::Duration::from_secs(2));
assert_eq!(db.migration_lock_retries, 1);
let toml = "[database]\nmigration_lock_timeout = \"0s\"\n";
let env = [("AUTUMN_DATABASE__MIGRATION_LOCK_TIMEOUT", "")];
let db = load_3057("prod", toml, &env).database;
assert_eq!(db.migration_lock_timeout, std::time::Duration::ZERO);
}
#[test]
fn prod_profile_admission_default_is_pool_size_times_k() {
let config = load_3057("prod", "[database]\npool_size = 20\n", &[]);
assert_eq!(
config.profile_admission_default(),
Some(20 * PROD_REQUESTS_PER_POOL_CONNECTION)
);
let config = load_3057(
"prod",
"[database]\npool_size = 20\nprimary_pool_size = 9\n",
&[],
);
assert_eq!(
config.profile_admission_default(),
Some(9 * PROD_REQUESTS_PER_POOL_CONNECTION)
);
let config = load_3057("prod", "[database]\nprimary_pool_size = 2\n", &[]);
assert_eq!(
config.profile_admission_default(),
Some(PROD_MIN_ADMISSION_LIMIT)
);
assert_eq!(load_3057("dev", "", &[]).profile_admission_default(), None);
assert_eq!(
load_3057("staging", "", &[]).profile_admission_default(),
None
);
}
#[test]
fn realtime_env_overrides_and_validation() {
let env = MockEnv::new()
.with("AUTUMN_REALTIME__MAX_CONNECTIONS", "9")
.with("AUTUMN_REALTIME__MAX_MESSAGE_BYTES", "4096")
.with("AUTUMN_REALTIME__PING_INTERVAL_MS", "1000")
.with("AUTUMN_REALTIME__IDLE_TIMEOUT_MS", "5000");
let mut config = AutumnConfig::default();
config.apply_realtime_env_overrides_with_env(&env);
assert_eq!(
config.realtime,
RealtimeConfig {
max_connections: Some(9),
max_message_bytes: Some(4096),
ping_interval_ms: Some(1000),
idle_timeout_ms: Some(5000),
}
);
config.realtime.max_message_bytes = Some(0);
let err = config.validate().unwrap_err().to_string();
assert!(err.contains("realtime.max_message_bytes"), "{err}");
}
#[test]
fn prod_profile_admission_default_resolves_to_a_ceiling() {
let config = load_3057("prod", "", &[]);
let limit = crate::capacity::resolve_configured_admission_limit_with_default(
config.server.max_concurrent_requests,
config.server.capacity_contract.as_deref(),
config.profile_admission_default(),
);
assert_eq!(
limit,
crate::capacity::AdmissionLimit::ProfileDefault(
default_pool_size() * PROD_REQUESTS_PER_POOL_CONNECTION
)
);
}
#[test]
fn prod_admission_default_opt_out() {
for (toml, env) in [
("[server]\nmax_concurrent_requests = 0\n", vec![]),
("", vec![("AUTUMN_SERVER__MAX_CONCURRENT_REQUESTS", "0")]),
] {
let config = load_3057("prod", toml, &env);
let limit = crate::capacity::resolve_configured_admission_limit_with_default(
config.server.max_concurrent_requests,
config.server.capacity_contract.as_deref(),
config.profile_admission_default(),
);
assert_eq!(limit, crate::capacity::AdmissionLimit::Unlimited);
}
}
#[test]
fn test_resilience_config_defaults() {
let config = AutumnConfig::default();
assert!(
config
.resilience
.circuit_breaker
.defaults
.failure_ratio_threshold
.is_none()
);
}
#[test]
fn test_resilience_config_parsing() {
let toml_str = r#"
[resilience.circuit_breaker.defaults]
failure_ratio_threshold = 0.6
sample_window_secs = 20
minimum_sample_count = 15
open_duration_secs = 30
half_open_trial_count = 5
[resilience.circuit_breaker.hosts."api.github.com"]
failure_ratio_threshold = 0.3
open_duration_secs = 10
"#;
let config: AutumnConfig = toml::from_str(toml_str).unwrap();
let cb = &config.resilience.circuit_breaker;
assert_eq!(cb.defaults.failure_ratio_threshold, Some(0.6));
assert_eq!(cb.defaults.sample_window_secs, Some(20));
assert_eq!(cb.defaults.minimum_sample_count, Some(15));
assert_eq!(cb.defaults.open_duration_secs, Some(30));
assert_eq!(cb.defaults.half_open_trial_count, Some(5));
let host_cb = cb.hosts.get("api.github.com").unwrap();
assert_eq!(host_cb.failure_ratio_threshold, Some(0.3));
assert_eq!(host_cb.open_duration_secs, Some(10));
assert!(host_cb.sample_window_secs.is_none());
}
#[test]
fn test_resilience_config_env_overrides() {
struct FakeEnv(std::collections::HashMap<String, String>);
impl Env for FakeEnv {
fn var(&self, key: &str) -> Result<String, std::env::VarError> {
self.0
.get(key)
.cloned()
.ok_or(std::env::VarError::NotPresent)
}
}
let mut config = AutumnConfig::default();
let env = FakeEnv(
[(
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__FAILURE_RATIO_THRESHOLD".to_owned(),
"0.7".to_owned(),
)]
.into(),
);
config.apply_resilience_env_overrides_with_env(&env);
assert_eq!(
config
.resilience
.circuit_breaker
.defaults
.failure_ratio_threshold,
Some(0.7)
);
}
#[test]
fn test_resilience_config_parses_slow_call_keys() {
let toml_str = r#"
[resilience.circuit_breaker.defaults]
slow_call_duration_threshold_ms = 2500
slow_call_rate_threshold = 0.4
cancelled_call_outcome = "failure"
[resilience.circuit_breaker.hosts."api.github.com"]
slow_call_duration_threshold_ms = 0
"#;
let config: AutumnConfig = toml::from_str(toml_str).unwrap();
let cb = &config.resilience.circuit_breaker;
assert_eq!(cb.defaults.slow_call_duration_threshold_ms, Some(2500));
assert_eq!(cb.defaults.slow_call_rate_threshold, Some(0.4));
assert_eq!(
cb.defaults.cancelled_call_outcome,
Some(crate::circuit_breaker::CancelledCallOutcome::Failure)
);
let host_cb = cb.hosts.get("api.github.com").unwrap();
assert_eq!(host_cb.slow_call_duration_threshold_ms, Some(0));
assert!(host_cb.slow_call_rate_threshold.is_none());
}
#[test]
fn test_resilience_config_slow_call_env_overrides() {
let env = MockEnv::new()
.with(
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_DURATION_THRESHOLD_MS",
"1500",
)
.with(
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__SLOW_CALL_RATE_THRESHOLD",
"0.25",
)
.with(
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__CANCELLED_CALL_OUTCOME",
"failure",
);
let mut config = AutumnConfig::default();
config.apply_resilience_env_overrides_with_env(&env);
let defaults = &config.resilience.circuit_breaker.defaults;
assert_eq!(defaults.slow_call_duration_threshold_ms, Some(1500));
assert_eq!(defaults.slow_call_rate_threshold, Some(0.25));
assert_eq!(
defaults.cancelled_call_outcome,
Some(crate::circuit_breaker::CancelledCallOutcome::Failure)
);
}
#[test]
fn test_resilience_config_bad_cancelled_call_outcome_env_is_ignored() {
let env = MockEnv::new().with(
"AUTUMN_RESILIENCE__CIRCUIT_BREAKER__DEFAULTS__CANCELLED_CALL_OUTCOME",
"drop",
);
let mut config = AutumnConfig::default();
config.apply_resilience_env_overrides_with_env(&env);
assert!(
config
.resilience
.circuit_breaker
.defaults
.cancelled_call_outcome
.is_none()
);
}
const TEST_REGISTRY: &[DeprecatedKey] = &[DeprecatedKey {
path: "a.b.c",
replacement: Some("a.b.d"),
since: "0.1.0",
remove_in: "1.0.0",
}];
fn merged_with_abc(value: toml::Value) -> toml::Table {
let mut root = toml::Table::new();
let mut b = toml::Table::new();
b.insert("c".to_owned(), value);
let mut a = toml::Table::new();
a.insert("b".to_owned(), toml::Value::Table(b));
root.insert("a".to_owned(), toml::Value::Table(a));
root
}
#[test]
fn red_detect_from_toml_present_emits_finding() {
let merged = merged_with_abc(toml::Value::Integer(1));
let env = MockEnv::new(); let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert_eq!(findings.len(), 1);
let f = &findings[0];
assert_eq!(f.path, "a.b.c");
assert_eq!(f.replacement.as_deref(), Some("a.b.d"));
assert_eq!(f.since, "0.1.0");
assert_eq!(f.remove_in, "1.0.0");
assert_eq!(f.source, DeprecationSource::Toml);
}
#[test]
fn red_detect_from_env_present_emits_finding() {
let merged = toml::Table::new(); let env = MockEnv::new().with("AUTUMN_A__B__C", "val");
let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].source, DeprecationSource::Env);
}
#[test]
fn red_detect_dedupe_toml_and_env_single_finding() {
let merged = merged_with_abc(toml::Value::Boolean(true));
let env = MockEnv::new().with("AUTUMN_A__B__C", "true");
let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert_eq!(findings.len(), 1, "TOML+env should collapse to one finding");
assert_eq!(findings[0].source, DeprecationSource::Both);
}
#[test]
fn red_detect_replacement_only_no_finding() {
let mut merged = toml::Table::new();
let mut b = toml::Table::new();
b.insert("d".to_owned(), toml::Value::Integer(1)); let mut a = toml::Table::new();
a.insert("b".to_owned(), toml::Value::Table(b));
merged.insert("a".to_owned(), toml::Value::Table(a));
let env = MockEnv::new();
let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert!(
findings.is_empty(),
"only replacement key set — no deprecation warning"
);
}
#[test]
fn red_detect_absent_everywhere_no_finding() {
let merged = toml::Table::new();
let env = MockEnv::new();
let findings = detect_deprecated_keys(&merged, &env, TEST_REGISTRY);
assert!(findings.is_empty());
}
#[test]
fn red_env_var_name_mapping() {
assert_eq!(
deprecated_env_var_name("security.rate_limit.trusted_proxies"),
"AUTUMN_SECURITY__RATE_LIMIT__TRUSTED_PROXIES"
);
assert_eq!(deprecated_env_var_name("a.b.c"), "AUTUMN_A__B__C");
}
#[test]
fn red_toml_path_non_table_mid_segment_not_present() {
let mut root = toml::Table::new();
root.insert("a".to_owned(), toml::Value::Integer(42)); assert!(!toml_path_present(&root, "a.b.c"));
}
#[test]
fn red_schema_leaf_paths_includes_known_paths() {
let leaves = AutumnConfig::schema_leaf_paths();
assert!(
leaves.contains("server.port"),
"server.port must be a schema leaf"
);
assert!(
leaves.contains("server.host"),
"server.host must be a schema leaf"
);
assert!(
leaves.contains("database.url"),
"database.url must be a schema leaf"
);
assert!(
leaves.contains("security"),
"security must appear as a root-level leaf"
);
assert!(
leaves.contains("session"),
"session must appear as a root-level leaf"
);
}
#[test]
fn shards_auto_split_true_when_all_slots_none() {
let config = DatabaseConfig {
shards: vec![
shard("a", "postgres://a/app"),
shard("b", "postgres://b/app"),
],
..Default::default()
};
assert!(config.shards_auto_split());
}
#[test]
fn shards_auto_split_false_when_no_shards() {
assert!(!DatabaseConfig::default().shards_auto_split());
}
#[test]
fn shards_auto_split_false_when_any_shard_declares_slots() {
let config = DatabaseConfig {
shards: vec![
shard_with_slots("a", "postgres://a/app", &["0-8191"]),
shard_with_slots("b", "postgres://b/app", &["8192-16383"]),
],
..Default::default()
};
assert!(!config.shards_auto_split());
}
#[test]
fn resolved_shard_assignments_two_shards() {
let config = DatabaseConfig {
shards: vec![
shard("s0", "postgres://s0/app"),
shard("s1", "postgres://s1/app"),
],
..Default::default()
};
let assignments = config
.resolved_shard_assignments()
.expect("two-shard auto-split should resolve");
assert_eq!(assignments.len(), 2);
assert_eq!(assignments[0].name, "s0");
assert_eq!(assignments[0].ranges, "0-8191");
assert_eq!(assignments[1].name, "s1");
assert_eq!(assignments[1].ranges, "8192-16383");
}
#[test]
fn resolved_shard_assignments_three_shards() {
let config = DatabaseConfig {
shards: vec![
shard("s0", "postgres://s0/app"),
shard("s1", "postgres://s1/app"),
shard("s2", "postgres://s2/app"),
],
..Default::default()
};
let assignments = config
.resolved_shard_assignments()
.expect("three-shard auto-split should resolve");
assert_eq!(assignments.len(), 3);
assert_eq!(assignments[0].ranges, "0-5461");
assert_eq!(assignments[1].ranges, "5462-10922");
assert_eq!(assignments[2].ranges, "10923-16383");
}
fn assignment(name: &str, ranges: &str) -> ShardSlotAssignment {
ShardSlotAssignment {
name: name.to_owned(),
ranges: ranges.to_owned(),
}
}
#[test]
fn check_stored_slot_map_explicit_mode_always_ok() {
let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
let stored = vec![
assignment("s0", "0-5460"),
assignment("s1", "5461-10922"),
assignment("s2", "10923-16383"),
];
assert!(check_stored_slot_map(false, &computed, Some(&stored)).is_ok());
}
#[test]
fn check_stored_slot_map_first_boot_no_stored_ok() {
let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
assert!(check_stored_slot_map(true, &computed, None).is_ok());
}
#[test]
fn check_stored_slot_map_matching_map_ok() {
let computed = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
let stored = vec![assignment("s1", "8192-16383"), assignment("s0", "0-8191")];
assert!(check_stored_slot_map(true, &computed, Some(&stored)).is_ok());
}
#[test]
fn check_stored_slot_map_mismatch_two_to_three_shards_returns_err() {
let computed = vec![
assignment("s0", "0-5460"),
assignment("s1", "5461-10922"),
assignment("s2", "10923-16383"),
];
let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
let err = check_stored_slot_map(true, &computed, Some(&stored))
.expect_err("3-shard auto-split vs 2-shard stored map must fail");
assert!(err.contains("shard slot map mismatch"), "message: {err}");
assert!(err.contains("3 shards"), "message: {err}");
assert!(err.contains("2 shards"), "message: {err}");
}
#[test]
fn check_stored_slot_map_mismatch_shard_rename_returns_err() {
let computed = vec![
assignment("alpha", "0-8191"),
assignment("beta", "8192-16383"),
];
let stored = vec![assignment("s0", "0-8191"), assignment("s1", "8192-16383")];
let err = check_stored_slot_map(true, &computed, Some(&stored))
.expect_err("renamed shards must be detected as mismatch");
assert!(err.contains("shard slot map mismatch"), "message: {err}");
assert!(
err.contains("alpha"),
"message must name computed shards: {err}"
);
assert!(err.contains("s0"), "message must name stored shards: {err}");
}
#[test]
fn process_role_default_is_combined() {
assert_eq!(ProcessRole::default(), ProcessRole::Combined);
assert_eq!(AutumnConfig::default().role, ProcessRole::Combined);
}
#[test]
fn process_role_from_env_value_accepts_aliases_case_insensitively() {
for v in [
"combined",
"COMBINED",
" all ",
"web_and_worker",
"server_and_worker",
] {
assert_eq!(
ProcessRole::from_env_value(v),
Some(ProcessRole::Combined),
"{v}"
);
}
for v in ["web", "Web", " SERVER ", "http"] {
assert_eq!(
ProcessRole::from_env_value(v),
Some(ProcessRole::Web),
"{v}"
);
}
for v in ["worker", "WORKER", " jobs ", "worker_only"] {
assert_eq!(
ProcessRole::from_env_value(v),
Some(ProcessRole::Worker),
"{v}"
);
}
for v in ["", "webby", "workers", "scheduler", "both"] {
assert_eq!(ProcessRole::from_env_value(v), None, "{v}");
}
}
#[test]
fn process_role_as_str_round_trips_through_from_env_value() {
for role in [ProcessRole::Combined, ProcessRole::Web, ProcessRole::Worker] {
assert_eq!(ProcessRole::from_env_value(role.as_str()), Some(role));
}
}
#[test]
fn scheduler_backend_is_fleet_distributed_only_for_postgres() {
assert!(!SchedulerBackend::InProcess.is_fleet_distributed());
assert!(SchedulerBackend::Postgres.is_fleet_distributed());
}
#[test]
fn process_role_serves_http_and_runs_workers_truth_table() {
assert!(ProcessRole::Combined.serves_http());
assert!(ProcessRole::Combined.runs_workers());
assert!(ProcessRole::Web.serves_http());
assert!(!ProcessRole::Web.runs_workers());
assert!(!ProcessRole::Worker.serves_http());
assert!(ProcessRole::Worker.runs_workers());
}
#[test]
fn process_role_deserializes_from_toml() {
let web: AutumnConfig = toml::from_str("role = \"web\"\n").expect("web role");
assert_eq!(web.role, ProcessRole::Web);
let worker: AutumnConfig = toml::from_str("role = \"worker\"\n").expect("worker role");
assert_eq!(worker.role, ProcessRole::Worker);
let combined: AutumnConfig =
toml::from_str("role = \"combined\"\n").expect("combined role");
assert_eq!(combined.role, ProcessRole::Combined);
let aliased: AutumnConfig = toml::from_str("role = \"all\"\n").expect("all alias");
assert_eq!(aliased.role, ProcessRole::Combined);
let absent: AutumnConfig = toml::from_str("").expect("empty config");
assert_eq!(absent.role, ProcessRole::Combined);
}
#[test]
fn split_role_requires_durable_backend_truth_table() {
assert!(!split_role_requires_durable_backend(
ProcessRole::Combined,
"local"
));
assert!(!split_role_requires_durable_backend(
ProcessRole::Combined,
"postgres"
));
assert!(split_role_requires_durable_backend(
ProcessRole::Web,
"local"
));
assert!(split_role_requires_durable_backend(
ProcessRole::Worker,
"local"
));
assert!(split_role_requires_durable_backend(
ProcessRole::Web,
"postgresql"
));
assert!(split_role_requires_durable_backend(ProcessRole::Web, ""));
assert!(split_role_requires_durable_backend(
ProcessRole::Web,
"unknown"
));
assert!(split_role_requires_durable_backend(
ProcessRole::Web,
"LOCAL"
));
assert!(!split_role_requires_durable_backend(
ProcessRole::Web,
"postgres"
));
assert!(!split_role_requires_durable_backend(
ProcessRole::Worker,
"redis"
));
assert!(!split_role_requires_durable_backend(
ProcessRole::Web,
"sqlite"
));
assert!(!split_role_requires_durable_backend(
ProcessRole::Worker,
"sqlite"
));
}
#[test]
fn split_role_on_sqlite_requires_a_file_backed_database() {
for url in [
"sqlite::memory:",
"sqlite://:memory:",
":memory:",
"file::memory:",
"file::memory:?cache=shared",
"sqlite:file::memory:?cache=shared",
"sqlite://app.db?mode=memory",
"sqlite://",
"sqlite:",
] {
assert!(
is_in_memory_sqlite_target(url),
"{url} must read as in-memory"
);
assert!(
split_role_requires_file_backed_sqlite(ProcessRole::Web, "sqlite", Some(url)),
"a split role on {url} must be refused"
);
}
for url in ["sqlite:///var/lib/app.db", "sqlite://./app.db", "app.db"] {
assert!(
!is_in_memory_sqlite_target(url),
"{url} must read as file-backed"
);
assert!(
!split_role_requires_file_backed_sqlite(ProcessRole::Web, "sqlite", Some(url)),
"a split role on {url} is valid"
);
}
assert!(!split_role_requires_file_backed_sqlite(
ProcessRole::Combined,
"sqlite",
Some("sqlite::memory:")
));
assert!(!split_role_requires_file_backed_sqlite(
ProcessRole::Worker,
"postgres",
Some("sqlite::memory:")
));
assert!(!split_role_requires_file_backed_sqlite(
ProcessRole::Worker,
"sqlite",
None
));
}
#[test]
fn scheduler_backend_sqlite_is_selectable_and_fleet_distributed() {
assert_eq!(
SchedulerBackend::from_env_value("sqlite"),
Some(SchedulerBackend::Sqlite)
);
assert_eq!(
SchedulerBackend::from_env_value(" SINGLE_HOST "),
Some(SchedulerBackend::Sqlite)
);
assert_eq!(SchedulerBackend::from_env_value("nonsense"), None);
assert!(SchedulerBackend::Sqlite.is_fleet_distributed());
assert!(!SchedulerBackend::InProcess.is_fleet_distributed());
let config: AutumnConfig =
toml::from_str("[scheduler]\nbackend = \"sqlite\"\n").expect("sqlite backend");
assert_eq!(config.scheduler.backend, SchedulerBackend::Sqlite);
let aliased: AutumnConfig =
toml::from_str("[scheduler]\nbackend = \"single_host\"\n").expect("single_host alias");
assert_eq!(aliased.scheduler.backend, SchedulerBackend::Sqlite);
let env = MockEnv::new().with("AUTUMN_SCHEDULER__BACKEND", "sqlite");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.scheduler.backend, SchedulerBackend::Sqlite);
}
#[test]
fn jobs_sqlite_section_parses_with_defaults() {
let defaults = JobSqliteConfig::default();
assert_eq!(defaults.visibility_timeout_ms, 30_000);
assert_eq!(defaults.poll_interval_ms, 250);
let config: AutumnConfig = toml::from_str(
"[jobs]\nbackend = \"sqlite\"\n\n[jobs.sqlite]\nvisibility_timeout_ms = 5000\n\
poll_interval_ms = 50\n",
)
.expect("sqlite job section");
assert_eq!(config.jobs.backend, "sqlite");
assert_eq!(config.jobs.sqlite.visibility_timeout_ms, 5_000);
assert_eq!(config.jobs.sqlite.poll_interval_ms, 50);
let bare: AutumnConfig =
toml::from_str("[jobs]\nbackend = \"sqlite\"\n").expect("bare jobs section");
assert_eq!(bare.jobs.sqlite.visibility_timeout_ms, 30_000);
}
#[test]
fn autumn_role_env_override_sets_role() {
let env = MockEnv::new().with("AUTUMN_ROLE", "worker");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.role, ProcessRole::Worker);
let env = MockEnv::new().with("AUTUMN_ROLE", " WEB ");
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.role, ProcessRole::Web);
}
#[test]
fn autumn_role_env_override_ignores_invalid_value_keeping_default() {
let env = MockEnv::new().with("AUTUMN_ROLE", "nonsense");
let mut config = AutumnConfig {
role: ProcessRole::Worker,
..Default::default()
};
config.apply_env_overrides_with_env(&env);
assert_eq!(config.role, ProcessRole::Worker);
let mut config = AutumnConfig::default();
config.apply_env_overrides_with_env(&env);
assert_eq!(config.role, ProcessRole::Combined);
}
}