This is an automated email from the ASF dual-hosted git repository.

hubcio pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/iggy.git


The following commit(s) were added to refs/heads/master by this push:
     new d1cb5aee7 fix(configs): check server env vars once and refuse boot 
only in debug (#4200)
d1cb5aee7 is described below

commit d1cb5aee7b00df9ae7f152757cd23bec45087968
Author: Hubert Gruszecki <[email protected]>
AuthorDate: Mon Sep 21 13:15:52 2026 +0200

    fix(configs): check server env vars once and refuse boot only in debug 
(#4200)
---
 core/configs/src/configs_impl/file_provider.rs     | 101 ++++++++++++++++-----
 .../configs/src/configs_impl/typed_env_provider.rs | 100 +++++++++++++-------
 core/configs/src/server_config/server.rs           |  36 +++++++-
 gateways/kafka/src/bridge/config.rs                |   4 +-
 gateways/kafka/src/main.rs                         |  10 +-
 5 files changed, 185 insertions(+), 66 deletions(-)

diff --git a/core/configs/src/configs_impl/file_provider.rs 
b/core/configs/src/configs_impl/file_provider.rs
index f182f2cd2..851000303 100644
--- a/core/configs/src/configs_impl/file_provider.rs
+++ b/core/configs/src/configs_impl/file_provider.rs
@@ -127,7 +127,9 @@ impl<P: Provider> FileConfigProvider<P> {
         self
     }
 
-    fn reject_unknown_env_names(&self) -> Result<(), ConfigurationError> {
+    /// Debug builds refuse to boot on an unknown name, so CI and local runs
+    /// catch a stray or misspelled variable. Release builds warn and ignore 
it.
+    fn check_unknown_env_names(&self) -> Result<(), ConfigurationError> {
         let Some(known) = &self.known_env_names else {
             return Ok(());
         };
@@ -137,15 +139,24 @@ impl<P: Provider> FileConfigProvider<P> {
             known,
             self.allowed_env_prefixes,
         );
-        for name in &unknown {
-            eprintln!("Unknown configuration environment variable '{name}'. 
Unset it to boot.");
+        if unknown.is_empty() {
+            return Ok(());
         }
-        let rejected = !unknown.is_empty();
-        if rejected {
-            Err(ConfigurationError::InvalidConfigurationValue)
+        // Config load runs before the logger is configured, so a `warn!` 
record
+        // can be filtered by `RUST_LOG` or lost when boot fails before 
`late_init`.
+        let refuse = cfg!(debug_assertions);
+        let remedy = if refuse {
+            "Unset it to boot."
         } else {
-            Ok(())
+            "It will be ignored."
+        };
+        for name in &unknown {
+            eprintln!("Unknown configuration environment variable '{name}'. 
{remedy}");
         }
+        if refuse {
+            return Err(ConfigurationError::InvalidConfigurationValue);
+        }
+        Ok(())
     }
 
     fn reject_relocated_keys(&self) -> Result<(), ConfigurationError> {
@@ -194,7 +205,7 @@ impl<P: Provider + Clone> ConfigProvider for 
FileConfigProvider<P> {
         // below is just as silent about a key no field reads, and the
         // pure-env container never touches the file branch at all.
         self.reject_relocated_keys()?;
-        self.reject_unknown_env_names()?;
+        self.check_unknown_env_names()?;
 
         // Start with the default configuration if provided
         let mut config_builder = Figment::new();
@@ -429,6 +440,7 @@ mod tests {
             "IGGY_CONNECTORS_STATE_PATH",
             "IGGY_MCP_CONFIG_PATH",
             "IGGY_MCP_TRANSPORT",
+            "IGGY_KAFKA_BIND_ADDR",
             "IGGY_HOME",
             "IGGY_USERNAME",
             "IGGY_PASSWORD",
@@ -457,17 +469,60 @@ mod tests {
     }
 
     /// `main.rs` loads a `.env` through `dotenvy` before `load_config` runs, 
and
-    /// `dotenvy` injects into the process environment that 
`reject_unknown_env_names`
+    /// `dotenvy` injects into the process environment that 
`check_unknown_env_names`
     /// scans with `env::vars_os()`. So the fence does not need a shared 
container
     /// or a shared `env_file`: a `.env` in the working directory is enough.
-    ///
-    /// Mutates the process environment, so it must not run beside another test
-    /// that reads it.
     #[test]
-    #[serial_test::serial]
     fn 
given_a_dotenv_with_a_connectors_variable_when_loading_then_the_server_should_boot()
 {
-        // SAFETY: single-threaded assertion over a variable no other test 
reads.
-        unsafe { std::env::set_var("IGGY_CONNECTORS_CONFIG_PATH", 
"/etc/iggy/connectors.toml") };
+        let unknown = 
server_unknown_env_names(&["IGGY_CONNECTORS_CONFIG_PATH"]);
+
+        assert!(
+            unknown.is_empty(),
+            "a .env naming the connectors runtime's own config path refuses 
server boot, with no opt-out and a message that names no remedy"
+        );
+    }
+
+    /// The server reads these variables outside its config, so the boot check
+    /// must accept them. Without the `SERVER_PROCESS_ENV_VARS` chain in
+    /// `ServerConfig::config_provider`, a debug build refuses to boot.
+    #[test]
+    fn 
given_the_server_process_variables_when_checking_then_the_server_should_boot() {
+        let unknown =
+            
server_unknown_env_names(crate::server_config::server::SERVER_PROCESS_ENV_VARS);
+
+        assert!(
+            unknown.is_empty(),
+            "the boot check must accept every variable the server reads 
outside its config, got: {unknown:?}"
+        );
+    }
+
+    /// Runs the boot check's filter over `candidates` with the real server
+    /// wiring, and without reading the ambient environment.
+    fn server_unknown_env_names(candidates: &[&str]) -> Vec<String> {
+        let provider =
+            
crate::server_config::server::ServerConfig::config_provider("nonexistent-config.toml");
+        let known = provider
+            .known_env_names
+            .as_ref()
+            .expect("the server provider declares its known env names");
+
+        unknown_env_names(
+            names(candidates).into_iter(),
+            provider.env_prefix,
+            known,
+            provider.allowed_env_prefixes,
+        )
+    }
+
+    #[test]
+    #[serial_test::serial]
+    fn 
given_an_unknown_env_var_when_checking_then_only_debug_builds_should_refuse() {
+        const PREFIX: &str = "IGGY_FILE_PROVIDER_TEST_";
+        const UNKNOWN: &str = "IGGY_FILE_PROVIDER_TEST_UNKNOWN";
+        // SAFETY: the race is process-wide, not per key: `set_var` is unsound
+        // against any concurrent environment access. `serial_test::serial` on
+        // this test is what prevents that.
+        unsafe { std::env::set_var(UNKNOWN, "1") };
 
         let provider = FileConfigProvider::new(
             "nonexistent-config.toml".to_string(),
@@ -475,17 +530,17 @@ mod tests {
             false,
             None,
         )
-        .with_relocated_keys("IGGY_", &[])
-        
.with_known_env_names(crate::server_config::server::SERVER_PROCESS_ENV_VARS.to_vec())
-        
.with_allowed_env_prefixes(crate::server_config::server::SERVER_ALLOWED_ENV_PREFIXES);
-        let rejected = provider.reject_unknown_env_names();
+        .with_relocated_keys(PREFIX, &[])
+        .with_known_env_names(Vec::new());
+        let checked = provider.check_unknown_env_names();
 
         // SAFETY: paired with the set above.
-        unsafe { std::env::remove_var("IGGY_CONNECTORS_CONFIG_PATH") };
+        unsafe { std::env::remove_var(UNKNOWN) };
 
-        assert!(
-            rejected.is_ok(),
-            "a .env naming the connectors runtime's own config path refuses 
server boot, with no opt-out and a message that names no remedy"
+        assert_eq!(
+            checked.is_err(),
+            cfg!(debug_assertions),
+            "an unknown variable must refuse boot in debug builds and only 
warn in release builds"
         );
     }
 
diff --git a/core/configs/src/configs_impl/typed_env_provider.rs 
b/core/configs/src/configs_impl/typed_env_provider.rs
index 5225f6a8a..b6be617da 100644
--- a/core/configs/src/configs_impl/typed_env_provider.rs
+++ b/core/configs/src/configs_impl/typed_env_provider.rs
@@ -43,39 +43,19 @@ enum WarningContext<'a> {
     ConnectorConfig(&'a str),
 }
 
-/// Environment variables starting with IGGY_ that are NOT config values.
-/// These are used for test control, CI, CLI behavior, config file paths, etc.
+/// `IGGY_` variables that are NOT config values: the config file and dotenv
+/// paths the connectors runtime and the MCP server read before their config
+/// loads.
 const IGNORED_ENV_VARS: &[&str] = &[
-    "IGGY_CI_BUILD",
-    // Test-harness knob: overrides the default cluster size the integration
-    // harness builds; leaks to spawned servers via the IGGY_ env forwarding.
-    "IGGY_TEST_CLUSTER_NODES",
-    "IGGY_CONFIG_PATH",
     "IGGY_CONNECTORS_CONFIG_PATH",
+    "IGGY_CONNECTORS_ENV_PATH",
     "IGGY_MCP_CONFIG_PATH",
-    "IGGY_ROOT_PASSWORD",
-    "IGGY_ROOT_USERNAME",
-    // Tunes per-shard io_uring SQ/CQ capacity; read directly by
-    // `server_common::executor::create_shard_executor` (see that fn for 
rationale).
-    "IGGY_SHARD_RUNTIME_CAPACITY",
-    "IGGY_TEST_CLEANUP_DISABLED",
-    "IGGY_TEST_VERBOSE",
+    "IGGY_MCP_ENV_PATH",
 ];
 
 /// Prefixes for env vars handled by separate providers with runtime prefixes.
 /// The main config provider skips these; each sub-provider validates its own 
vars.
-///
-/// `IGGY_KAFKA_` (`gateways/kafka/src/main.rs`) parses its own nine vars by 
hand rather than via
-/// `#[derive(ConfigEnv)]`, so it needs an entry here the same way the 
connector prefixes do -
-/// without it, `iggy-server` (and `cargo test -p integration`, which forwards 
`IGGY_*` to spawned
-/// servers) `debug_assert!`s on the first `IGGY_KAFKA_*` var it sees. This 
trades away the
-/// typo-detection this provider gives derived configs: an `IGGY_KAFKA_` typo 
now silently no-ops
-/// instead of surfacing here.
-const DELEGATED_ENV_VAR_PREFIXES: &[&str] = &[
-    "IGGY_CONNECTORS_SINK_",
-    "IGGY_CONNECTORS_SOURCE_",
-    "IGGY_KAFKA_",
-];
+const DELEGATED_ENV_VAR_PREFIXES: &[&str] = &["IGGY_CONNECTORS_SINK_", 
"IGGY_CONNECTORS_SOURCE_"];
 
 type ProfileMap = FigmentMap<Profile, Dict>;
 
@@ -92,6 +72,7 @@ type ProfileMap = FigmentMap<Profile, Dict>;
 pub struct TypedEnvProvider<T: ConfigEnvMappings> {
     prefix: String,
     secret_keys: Vec<String>,
+    check_unknown_env_vars: bool,
     _phantom: PhantomData<T>,
 }
 
@@ -105,6 +86,7 @@ impl<T: ConfigEnvMappings> TypedEnvProvider<T> {
         Self {
             prefix: prefix.to_string(),
             secret_keys: secret_keys.iter().map(|s| s.to_string()).collect(),
+            check_unknown_env_vars: true,
             _phantom: PhantomData,
         }
     }
@@ -141,26 +123,42 @@ impl<T: ConfigEnvMappings> TypedEnvProvider<T> {
         Self {
             prefix: prefix.to_string(),
             secret_keys,
+            check_unknown_env_vars: true,
             _phantom: PhantomData,
         }
     }
 
+    /// Skip the unknown-variable scan in [`Self::deserialize`] and
+    /// [`Self::deserialize_with_runtime_prefix`].
+    ///
+    /// For a loader that checks every name itself: a second check with its
+    /// own list would flag names that loader accepts.
+    pub fn without_unknown_env_var_check(mut self) -> Self {
+        self.check_unknown_env_vars = false;
+        self
+    }
+
     /// Deserialize with runtime prefix prepended to each mapping's env_name.
     ///
     /// Unlike `deserialize()`, this method prepends `self.prefix` to each 
mapping's
     /// env_name, allowing for dynamic prefix construction at runtime.
     pub fn deserialize_with_runtime_prefix(&self) -> Result<ProfileMap, 
ConfigurationError> {
-        
self.warn_unknown_env_vars_inner(WarningContext::ConnectorConfig(&self.prefix));
+        if self.check_unknown_env_vars {
+            
self.warn_unknown_env_vars_inner(WarningContext::ConnectorConfig(&self.prefix));
+        }
         self.deserialize_inner(EnvNameResolution::PrependPrefix(&self.prefix))
     }
 
     /// Deserialize environment variables into a configuration profile map.
     ///
     /// This method:
-    /// 1. Validates that all env vars with the prefix are known (warns on 
unknown)
+    /// 1. Validates that all env vars with the prefix are known (warns on 
unknown),
+    ///    unless [`Self::without_unknown_env_var_check`] turned that off
     /// 2. Iterates over compile-time generated mappings and applies set values
     pub fn deserialize(&self) -> Result<ProfileMap, ConfigurationError> {
-        self.warn_unknown_env_vars_inner(WarningContext::MainConfig);
+        if self.check_unknown_env_vars {
+            self.warn_unknown_env_vars_inner(WarningContext::MainConfig);
+        }
         self.deserialize_inner(EnvNameResolution::Direct)
     }
 
@@ -333,11 +331,7 @@ impl<T: ConfigEnvMappings> TypedEnvProvider<T> {
 
     fn warn_unknown_var(unknown_var: &str, suggestions: &[String]) {
         if suggestions.is_empty() {
-            warn!(
-                "Unknown environment variable '{}' will be ignored. \
-                 Use --list-env-vars to see all valid environment variables.",
-                unknown_var
-            );
+            warn!("Unknown environment variable '{unknown_var}' will be 
ignored.");
         } else {
             warn!(
                 "Unknown environment variable '{}' will be ignored. Similar 
variables: {}?",
@@ -622,4 +616,42 @@ mod tests {
             panic!("count should be u64");
         }
     }
+
+    /// A sibling binary's path arrives here through a shared environment, or
+    /// through one `.env` that every binary loads. Neither path is a config
+    /// value, and a debug build refuses to boot on an unknown name, so the 
scan
+    /// has to skip all four. The list check holds in both build profiles, and
+    /// the scan adds the `debug_assert!` path in a debug build.
+    #[test]
+    #[serial_test::serial]
+    fn ignored_env_vars_are_skipped_by_the_unknown_variable_scan() {
+        for name in [
+            "IGGY_CONNECTORS_CONFIG_PATH",
+            "IGGY_CONNECTORS_ENV_PATH",
+            "IGGY_MCP_CONFIG_PATH",
+            "IGGY_MCP_ENV_PATH",
+        ] {
+            assert!(
+                IGNORED_ENV_VARS.contains(&name),
+                "{name} is read by a sibling binary before its config loads, 
so the scan must skip it"
+            );
+        }
+
+        for name in IGNORED_ENV_VARS {
+            // SAFETY: the race is process-wide, not per key: `set_var` is 
unsound
+            // against any concurrent environment access. 
`serial_test::serial` on
+            // this test is what prevents that.
+            unsafe { env::set_var(name, "/etc/iggy/ignored") };
+        }
+
+        for prefix in ["IGGY_CONNECTORS_", "IGGY_MCP_"] {
+            TypedEnvProvider::<TestConfig>::new(prefix, &[])
+                .warn_unknown_env_vars_inner(WarningContext::MainConfig);
+        }
+
+        for name in IGNORED_ENV_VARS {
+            // SAFETY: paired with the set above.
+            unsafe { env::remove_var(name) };
+        }
+    }
 }
diff --git a/core/configs/src/server_config/server.rs 
b/core/configs/src/server_config/server.rs
index 20879d993..69985923f 100644
--- a/core/configs/src/server_config/server.rs
+++ b/core/configs/src/server_config/server.rs
@@ -66,7 +66,8 @@ pub const SERVER_PROCESS_ENV_VARS: &[&str] = &[
     "IGGY_PASSWORD",
 ];
 
-pub(crate) const SERVER_ALLOWED_ENV_PREFIXES: &[&str] = &["IGGY_CONNECTORS_", 
"IGGY_MCP_"];
+pub(crate) const SERVER_ALLOWED_ENV_PREFIXES: &[&str] =
+    &["IGGY_CONNECTORS_", "IGGY_KAFKA_", "IGGY_MCP_"];
 
 const DEFAULT_CONFIG_PATH: &str = "core/server/config.toml";
 
@@ -287,7 +288,10 @@ pub struct ServerConfigEnvProvider {
 impl Default for ServerConfigEnvProvider {
     fn default() -> Self {
         Self {
-            provider: TypedEnvProvider::from_config(ServerConfig::ENV_PREFIX),
+            // `ServerConfig::config_provider` checks every `IGGY_` name before
+            // this provider runs.
+            provider: TypedEnvProvider::from_config(ServerConfig::ENV_PREFIX)
+                .without_unknown_env_var_check(),
         }
     }
 }
@@ -513,4 +517,32 @@ mod tests {
             "expected at least one IGGY_MESSAGE_BUS_* env var, got: {names:?}"
         );
     }
+
+    #[test]
+    #[serial_test::serial]
+    fn env_provider_accepts_server_process_env_vars() {
+        for name in SERVER_PROCESS_ENV_VARS {
+            // SAFETY: the race is process-wide, not per key: `set_var` is 
unsound
+            // against any concurrent environment access. 
`serial_test::serial` on
+            // this test is what prevents that.
+            unsafe { env::set_var(name, "1") };
+        }
+
+        let data = ServerConfigEnvProvider::default().data();
+
+        for name in SERVER_PROCESS_ENV_VARS {
+            // SAFETY: paired with the set above.
+            unsafe { env::remove_var(name) };
+        }
+
+        // The provider holds no scan of its own, so the typed provider's
+        // debug_assert! stays quiet. A panic above is one failure this test
+        // guards, and one of these names reaching the map is the other.
+        let data = data.expect("the server env provider must accept every 
process variable");
+        let profile = data.get(&Profile::default()).expect("no default 
profile");
+        assert!(
+            profile.is_empty(),
+            "none of these variables is a config value, so none of them may 
reach the map: {profile:?}"
+        );
+    }
 }
diff --git a/gateways/kafka/src/bridge/config.rs 
b/gateways/kafka/src/bridge/config.rs
index 41de1fcc3..63266f480 100644
--- a/gateways/kafka/src/bridge/config.rs
+++ b/gateways/kafka/src/bridge/config.rs
@@ -49,8 +49,8 @@ impl IggyBridgeConfig {
     /// `KNOWN_KAFKA_ENV_VARS`, not one merged copy - a var added only here is 
already
     /// recognized there with no corresponding edit needed, and vice versa. A 
var this module
     /// reads still has to be listed *somewhere* the guard checks, or a typo 
silently no-ops
-    /// instead of surfacing (`IGGY_KAFKA_` is a `DELEGATED_ENV_VAR_PREFIXES` 
entry in
-    /// `core/configs`, so the central provider's own typo-detection doesn't 
cover this namespace
+    /// instead of surfacing (`IGGY_KAFKA_` is a `SERVER_ALLOWED_ENV_PREFIXES` 
entry in
+    /// `core/configs`, so the server's own unknown-variable check doesn't 
cover this namespace
     /// either).
     pub const KNOWN_ENV_VARS: &'static [&'static str] = &[
         "IGGY_KAFKA_IGGY_ADDR",
diff --git a/gateways/kafka/src/main.rs b/gateways/kafka/src/main.rs
index 140619531..7e01391fe 100644
--- a/gateways/kafka/src/main.rs
+++ b/gateways/kafka/src/main.rs
@@ -59,10 +59,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
 }
 
 /// The `IGGY_KAFKA_*` vars `load_config` itself reads. `IGGY_KAFKA_` is a
-/// `DELEGATED_ENV_VAR_PREFIXES` entry in `core/configs` (see that file's 
comment), which trades
-/// away the central provider's typo-detection for this whole namespace - a 
misspelled key here
-/// would otherwise silently no-op instead of surfacing anywhere. 
`reject_unknown_kafka_env_vars`
-/// is this crate's own replacement for that lost check.
+/// `SERVER_ALLOWED_ENV_PREFIXES` entry in `core/configs`, which trades away 
the server's
+/// unknown-variable check for this whole namespace - a misspelled key here 
would otherwise
+/// silently no-op instead of surfacing anywhere. 
`reject_unknown_kafka_env_vars` is this crate's
+/// own replacement for that lost check.
 ///
 /// Deliberately excludes `IggyBridgeConfig::KNOWN_ENV_VARS`: 
`reject_unknown_kafka_env_vars`
 /// checks both lists rather than one merged copy, so a bridge var rename 
can't silently desync
@@ -82,7 +82,7 @@ const KNOWN_KAFKA_ENV_VARS: &[&str] = &[
 
 /// Rejects any `IGGY_KAFKA_*` env var not in [`KNOWN_KAFKA_ENV_VARS`] or
 /// [`IggyBridgeConfig::KNOWN_ENV_VARS`] - a typo (e.g. `IGGY_KAFKA_BIN_ADDR`) 
would otherwise be
-/// silently ignored: `core/configs`' central provider skips the whole 
`IGGY_KAFKA_` prefix, and
+/// silently ignored: the server's check in `core/configs` skips the whole 
`IGGY_KAFKA_` prefix, and
 /// this crate's own `env_var()` only ever looks up exact known names, so 
nothing reads the
 /// misspelled var and nothing warns either. Checking both lists is 
deliberate: a user who
 /// exports a bridge var while `IGGY_KAFKA_BRIDGE_ENABLED` is off must not see 
a spurious

Reply via email to