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;
+}