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 fa86c2998 feat(cli): support IGGY_TOKEN environment variable (#4478)
fa86c2998 is described below

commit fa86c2998c21965eca2a2fc7c873182ee74b0902
Author: mola <[email protected]>
AuthorDate: Tue Oct 13 01:47:21 2026 -0700

    feat(cli): support IGGY_TOKEN environment variable (#4478)
---
 core/cli/src/args/mod.rs                           |  21 ++-
 core/cli/src/credentials.rs                        |  13 ++
 core/configs/src/configs_impl/file_provider.rs     |   7 +
 core/configs/src/server_config/server.rs           |   1 +
 core/integration/tests/cli/common/mod.rs           |  11 ++
 .../tests/cli/general/test_help_command.rs         |  17 +-
 .../tests/cli/user/test_login_options.rs           | 208 ++++++++++++++++++++-
 7 files changed, 269 insertions(+), 9 deletions(-)

diff --git a/core/cli/src/args/mod.rs b/core/cli/src/args/mod.rs
index 589e45d53..a758284e2 100644
--- a/core/cli/src/args/mod.rs
+++ b/core/cli/src/args/mod.rs
@@ -96,19 +96,30 @@ pub(crate) struct CliOptions {
     pub(crate) debug: Option<PathBuf>,
 
     /// Iggy server username
-    #[clap(short, long, group = "credentials")]
+    ///
+    /// When no credential flags are supplied, set both IGGY_USERNAME and
+    /// IGGY_PASSWORD. This environment pair does not fill omitted flags.
+    #[clap(short, long, group = "credentials", verbatim_doc_comment)]
     pub(crate) username: Option<String>,
 
     /// Iggy server password
     ///
-    /// An optional parameter to specify the password for authentication.
-    /// If not provided, user will be prompted interactively to enter the
-    /// password securely.
+    /// With --username and no --password, read the password from the terminal
+    /// or stdin. IGGY_PASSWORD does not supply a password for --username.
+    ///
+    /// When no credential flags are supplied, set both IGGY_USERNAME and
+    /// IGGY_PASSWORD. This environment pair does not fill omitted flags.
     #[clap(short, long, verbatim_doc_comment)]
     pub(crate) password: Option<String>,
 
     /// Iggy server personal access token
-    #[clap(short, long, group = "credentials")]
+    ///
+    /// Can also be provided with the IGGY_TOKEN environment variable.
+    /// An empty IGGY_TOKEN is treated as unset.
+    /// Command line credentials take precedence over the environment
+    /// variables, and IGGY_TOKEN takes precedence over the IGGY_USERNAME and
+    /// IGGY_PASSWORD pair.
+    #[clap(short, long, group = "credentials", verbatim_doc_comment)]
     pub(crate) token: Option<String>,
 
     #[cfg(feature = "login-session")]
diff --git a/core/cli/src/credentials.rs b/core/cli/src/credentials.rs
index f45067772..85f0fb221 100644
--- a/core/cli/src/credentials.rs
+++ b/core/cli/src/credentials.rs
@@ -37,6 +37,7 @@ use credentials_login_session::*;
 
 static ENV_IGGY_USERNAME: &str = "IGGY_USERNAME";
 static ENV_IGGY_PASSWORD: &str = "IGGY_PASSWORD";
+static ENV_IGGY_TOKEN: &str = "IGGY_TOKEN";
 
 struct IggyUserClient {
     username: String,
@@ -137,6 +138,18 @@ impl<'a> IggyCredentials<'a> {
                 iggy_client: None,
                 login_required,
             })
+        } else if let Some(token) = var(ENV_IGGY_TOKEN).ok().filter(|token| 
!token.is_empty()) {
+            // An environment token outranks the username and password pair
+            // below it, so a CI job can authenticate with IGGY_TOKEN alone.
+            // An empty value counts as unset. This is read here rather than
+            // as a clap `env` attribute: clap would treat the value like the
+            // --token flag and fail on the credentials group conflict rules
+            // whenever -u/-p are also present.
+            Ok(Self {
+                credentials: 
Some(Credentials::PersonalAccessToken(SecretString::from(token))),
+                iggy_client: None,
+                login_required,
+            })
         } else if var(ENV_IGGY_USERNAME).is_ok() && 
var(ENV_IGGY_PASSWORD).is_ok() {
             Ok(Self {
                 credentials: 
Some(Credentials::UserNameAndPassword(IggyUserClient {
diff --git a/core/configs/src/configs_impl/file_provider.rs 
b/core/configs/src/configs_impl/file_provider.rs
index 056c4390f..00ac8f744 100644
--- a/core/configs/src/configs_impl/file_provider.rs
+++ b/core/configs/src/configs_impl/file_provider.rs
@@ -457,6 +457,13 @@ mod tests {
         );
     }
 
+    #[test]
+    fn 
given_cli_token_when_checking_then_only_the_exact_name_should_be_allowed() {
+        let unknown = server_unknown_env_names(&["IGGY_TOKEN", 
"IGGY_TOKEN_TYPO"]);
+
+        assert_eq!(unknown, vec!["IGGY_TOKEN_TYPO"]);
+    }
+
     #[test]
     fn given_another_configs_prefix_when_matching_then_should_report_none() {
         let found = relocated_env_vars(
diff --git a/core/configs/src/server_config/server.rs 
b/core/configs/src/server_config/server.rs
index 90531bda6..21040b991 100644
--- a/core/configs/src/server_config/server.rs
+++ b/core/configs/src/server_config/server.rs
@@ -57,6 +57,7 @@ const SERVER_SCAN_ONLY_ENV_VARS: &[&str] = &[
     "IGGY_HOME",
     "IGGY_USERNAME",
     "IGGY_PASSWORD",
+    "IGGY_TOKEN",
 ];
 
 /// Non-config env vars supported by the server and advertised to operators.
diff --git a/core/integration/tests/cli/common/mod.rs 
b/core/integration/tests/cli/common/mod.rs
index 2c3c59325..790d348f7 100644
--- a/core/integration/tests/cli/common/mod.rs
+++ b/core/integration/tests/cli/common/mod.rs
@@ -202,6 +202,12 @@ impl IggyCmdTest {
         let mut command = Command::cargo_bin("iggy").unwrap();
         // Get command line arguments and environment variables from test case 
and execute command
         let command_args = test_case.get_command();
+        // Clear any IGGY_TOKEN inherited from the test process before the
+        // case's environment is applied: the token takes precedence over
+        // username/password, so an ambient value would override the
+        // credentials a case sets up. A case exercising token auth sets
+        // its own value in its environment.
+        command.env("IGGY_TOKEN", "");
         // Set environment variables for the command
         command.envs(command_args.get_env());
         // Set a fixed terminal width for consistent help output formatting
@@ -267,6 +273,11 @@ impl IggyCmdTest {
         let mut command = Command::cargo_bin("iggy").unwrap();
         // Get command line arguments and environment variables from test case 
and execute command
         let command_args = test_case.get_command();
+        // Clear any IGGY_TOKEN inherited from the test process before the
+        // case's environment is applied: the token takes precedence over
+        // username/password, so an ambient value would override the
+        // credentials a case sets up.
+        command.env("IGGY_TOKEN", "");
         // Set environment variables for the command
         command.envs(command_args.get_env());
         // Set COLUMNS environment variable to 200 to avoid truncation of 
output
diff --git a/core/integration/tests/cli/general/test_help_command.rs 
b/core/integration/tests/cli/general/test_help_command.rs
index 953af856b..24ca261ef 100644
--- a/core/integration/tests/cli/general/test_help_command.rs
+++ b/core/integration/tests/cli/general/test_help_command.rs
@@ -199,16 +199,27 @@ Options:
 
   -u, --username <USERNAME>
           Iggy server username
+{CLAP_INDENT}
+          When no credential flags are supplied, set both IGGY_USERNAME and
+          IGGY_PASSWORD. This environment pair does not fill omitted flags.
 
   -p, --password <PASSWORD>
           Iggy server password
 {CLAP_INDENT}
-          An optional parameter to specify the password for authentication.
-          If not provided, user will be prompted interactively to enter the
-          password securely.
+          With --username and no --password, read the password from the 
terminal
+          or stdin. IGGY_PASSWORD does not supply a password for --username.
+{CLAP_INDENT}
+          When no credential flags are supplied, set both IGGY_USERNAME and
+          IGGY_PASSWORD. This environment pair does not fill omitted flags.
 
   -t, --token <TOKEN>
           Iggy server personal access token
+{CLAP_INDENT}
+          Can also be provided with the IGGY_TOKEN environment variable.
+          An empty IGGY_TOKEN is treated as unset.
+          Command line credentials take precedence over the environment
+          variables, and IGGY_TOKEN takes precedence over the IGGY_USERNAME and
+          IGGY_PASSWORD pair.
 
   -n, --token-name <TOKEN_NAME>
           Iggy server personal access token name
diff --git a/core/integration/tests/cli/user/test_login_options.rs 
b/core/integration/tests/cli/user/test_login_options.rs
index 227b7ad65..04637fc2a 100644
--- a/core/integration/tests/cli/user/test_login_options.rs
+++ b/core/integration/tests/cli/user/test_login_options.rs
@@ -19,6 +19,7 @@ use crate::cli::common::{IggyCmdCommand, IggyCmdTest, 
IggyCmdTestCase};
 use assert_cmd::assert::Assert;
 use async_trait::async_trait;
 use iggy::prelude::Client;
+use iggy::prelude::PersonalAccessTokenExpiry;
 use iggy::prelude::defaults::{DEFAULT_ROOT_PASSWORD, DEFAULT_ROOT_USERNAME};
 use predicates::str::{contains, starts_with};
 use serial_test::parallel;
@@ -28,6 +29,9 @@ enum UseCredentials {
     #[default]
     CliOptions,
     StdinInput,
+    CliOptionsWithEnvToken,
+    StdinInputWithEnvCredentials,
+    EmptyEnvToken,
 }
 
 #[derive(Debug, Default)]
@@ -48,6 +52,21 @@ impl IggyCmdTestCase for TestLoginOptions {
     fn get_command(&self) -> IggyCmdCommand {
         match self.use_credentials {
             UseCredentials::CliOptions => 
IggyCmdCommand::new().with_cli_credentials().arg("me"),
+            UseCredentials::CliOptionsWithEnvToken => IggyCmdCommand::new()
+                .with_cli_credentials()
+                .env("IGGY_TOKEN", "invalid-token")
+                .arg("me"),
+            UseCredentials::StdinInputWithEnvCredentials => 
IggyCmdCommand::new()
+                .opt("--username")
+                .opt(DEFAULT_ROOT_USERNAME)
+                .env("IGGY_TOKEN", "invalid-token")
+                .env("IGGY_USERNAME", "wrong-username")
+                .env("IGGY_PASSWORD", "wrong-password")
+                .arg("me"),
+            UseCredentials::EmptyEnvToken => IggyCmdCommand::new()
+                .with_env_credentials()
+                .env("IGGY_TOKEN", "")
+                .arg("me"),
             UseCredentials::StdinInput => IggyCmdCommand::new()
                 .opt("--username")
                 .opt(DEFAULT_ROOT_USERNAME)
@@ -57,7 +76,9 @@ impl IggyCmdTestCase for TestLoginOptions {
 
     fn provide_stdin_input(&self) -> Option<Vec<String>> {
         match self.use_credentials {
-            UseCredentials::StdinInput => 
Some(vec![DEFAULT_ROOT_PASSWORD.to_string()]),
+            UseCredentials::StdinInput | 
UseCredentials::StdinInputWithEnvCredentials => {
+                Some(vec![DEFAULT_ROOT_PASSWORD.to_string()])
+            }
             _ => None,
         }
     }
@@ -85,3 +106,188 @@ pub async fn should_be_successful() {
         .execute_test(TestLoginOptions::new(UseCredentials::StdinInput))
         .await;
 }
+
+#[tokio::test]
+#[parallel]
+pub async fn should_use_stdin_password_despite_environment_credentials() {
+    let mut iggy_cmd_test = IggyCmdTest::default();
+
+    iggy_cmd_test.setup().await;
+    iggy_cmd_test
+        .execute_test(TestLoginOptions::new(
+            UseCredentials::StdinInputWithEnvCredentials,
+        ))
+        .await;
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_prefer_cli_credentials_over_environment_token() {
+    let mut iggy_cmd_test = IggyCmdTest::default();
+
+    iggy_cmd_test.setup().await;
+    iggy_cmd_test
+        .execute_test(TestLoginOptions::new(
+            UseCredentials::CliOptionsWithEnvToken,
+        ))
+        .await;
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_use_environment_pair_with_empty_token() {
+    let mut iggy_cmd_test = IggyCmdTest::default();
+
+    iggy_cmd_test.setup().await;
+    iggy_cmd_test
+        .execute_test(TestLoginOptions::new(UseCredentials::EmptyEnvToken))
+        .await;
+}
+
+#[derive(Debug)]
+enum EnvTokenScenario {
+    // Only IGGY_TOKEN is set, holding a valid personal access token.
+    TokenOnly,
+    TokenOnlyStreamList,
+    // IGGY_TOKEN holds an invalid token and --token a valid one: the flag 
wins.
+    FlagTokenOverridesEnvToken,
+    // IGGY_TOKEN holds a valid token and the IGGY_USERNAME/IGGY_PASSWORD pair
+    // a wrong password: the token wins.
+    EnvTokenOverridesEnvCredentials,
+    // IGGY_TOKEN holds an invalid token and the pair is valid: the token
+    // still wins, so the login fails.
+    InvalidEnvTokenOverridesEnvCredentials,
+}
+
+#[derive(Debug)]
+struct TestLoginOptionsWithEnvToken {
+    scenario: EnvTokenScenario,
+    token_name: String,
+    token_value: Option<String>,
+}
+
+impl TestLoginOptionsWithEnvToken {
+    fn new(scenario: EnvTokenScenario, token_name: &str) -> Self {
+        Self {
+            scenario,
+            token_name: token_name.to_string(),
+            token_value: None,
+        }
+    }
+
+    fn needs_valid_token(&self) -> bool {
+        !matches!(
+            self.scenario,
+            EnvTokenScenario::InvalidEnvTokenOverridesEnvCredentials
+        )
+    }
+}
+
+#[async_trait]
+impl IggyCmdTestCase for TestLoginOptionsWithEnvToken {
+    async fn prepare_server_state(&mut self, client: &dyn Client) {
+        if self.needs_valid_token() {
+            let token = client
+                .create_personal_access_token(
+                    &self.token_name,
+                    PersonalAccessTokenExpiry::NeverExpire,
+                )
+                .await;
+            assert!(token.is_ok());
+            self.token_value = Some(token.unwrap().token);
+        }
+    }
+
+    fn get_command(&self) -> IggyCmdCommand {
+        let valid_token = self.token_value.clone().unwrap_or_default();
+        match self.scenario {
+            EnvTokenScenario::TokenOnly => IggyCmdCommand::new()
+                .env("IGGY_TOKEN", valid_token)
+                .arg("me"),
+            EnvTokenScenario::TokenOnlyStreamList => IggyCmdCommand::new()
+                .env("IGGY_TOKEN", valid_token)
+                .arg("stream")
+                .arg("list"),
+            EnvTokenScenario::FlagTokenOverridesEnvToken => 
IggyCmdCommand::new()
+                .env("IGGY_TOKEN", "invalid-token")
+                .opts(vec!["--token".to_string(), valid_token])
+                .arg("me"),
+            EnvTokenScenario::EnvTokenOverridesEnvCredentials => 
IggyCmdCommand::new()
+                .env("IGGY_TOKEN", valid_token)
+                .env("IGGY_USERNAME", DEFAULT_ROOT_USERNAME)
+                .env("IGGY_PASSWORD", "wrong-password")
+                .arg("me"),
+            EnvTokenScenario::InvalidEnvTokenOverridesEnvCredentials => 
IggyCmdCommand::new()
+                .env("IGGY_TOKEN", "invalid-token")
+                .env("IGGY_USERNAME", DEFAULT_ROOT_USERNAME)
+                .env("IGGY_PASSWORD", DEFAULT_ROOT_PASSWORD)
+                .arg("me"),
+        }
+    }
+
+    fn verify_command(&self, command_state: Assert) {
+        match self.scenario {
+            EnvTokenScenario::InvalidEnvTokenOverridesEnvCredentials => {
+                command_state
+                    .failure()
+                    .stderr(contains("Problem with server login with token"));
+            }
+            EnvTokenScenario::TokenOnlyStreamList => {
+                command_state
+                    .success()
+                    .stdout(starts_with("Executing list streams in table 
mode"));
+            }
+            _ => {
+                command_state
+                    .success()
+                    .stdout(starts_with("Executing me command\n"))
+                    .stdout(contains(String::from("Transport | TCP")));
+            }
+        }
+    }
+
+    async fn verify_server_state(&self, client: &dyn Client) {
+        if self.needs_valid_token() {
+            let token = 
client.delete_personal_access_token(&self.token_name).await;
+            assert!(token.is_ok());
+        }
+    }
+}
+
+#[tokio::test]
+#[parallel]
+pub async fn should_resolve_iggy_token_with_documented_precedence() {
+    let mut iggy_cmd_test = IggyCmdTest::default();
+
+    iggy_cmd_test.setup().await;
+    iggy_cmd_test
+        .execute_test(TestLoginOptionsWithEnvToken::new(
+            EnvTokenScenario::TokenOnly,
+            "env-token-only",
+        ))
+        .await;
+    iggy_cmd_test
+        .execute_test(TestLoginOptionsWithEnvToken::new(
+            EnvTokenScenario::TokenOnlyStreamList,
+            "env-token-stream-list",
+        ))
+        .await;
+    iggy_cmd_test
+        .execute_test(TestLoginOptionsWithEnvToken::new(
+            EnvTokenScenario::FlagTokenOverridesEnvToken,
+            "env-token-flag-override",
+        ))
+        .await;
+    iggy_cmd_test
+        .execute_test(TestLoginOptionsWithEnvToken::new(
+            EnvTokenScenario::EnvTokenOverridesEnvCredentials,
+            "env-token-pair-override",
+        ))
+        .await;
+    iggy_cmd_test
+        .execute_test(TestLoginOptionsWithEnvToken::new(
+            EnvTokenScenario::InvalidEnvTokenOverridesEnvCredentials,
+            "env-token-invalid",
+        ))
+        .await;
+}

Reply via email to