Skip to content

Commit abe6620

Browse files
feat: make --config-file take precedence over environment variables (#438)
When --config-file is explicitly specified, environment variables are now ignored to provide true configuration isolation. This follows CLI best practices where explicit arguments override implicit environment. Changes: - ConnectionManager tracks whether config_path was explicitly provided - create_cloud_client() and create_enterprise_client() skip env var checks when config_path is Some() - ApiCommandParams now includes config_path to preserve it through API command execution - Added debug logging to show when env vars are being ignored Tests: - test_config_file_overrides_env_vars: verifies env vars are ignored when --config-file is specified - test_env_vars_work_without_config_file: verifies env vars still work when --config-file is not specified This provides true configuration isolation for testing and follows the principle of "explicit wins" (CLI args > env vars > defaults). Fixes #436
1 parent 24b9995 commit abe6620

File tree

4 files changed

+147
-10
lines changed

4 files changed

+147
-10
lines changed

crates/redisctl/src/commands/api.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use serde_json::Value;
1212
#[allow(dead_code)] // Used by binary target
1313
pub struct ApiCommandParams {
1414
pub config: Config,
15+
pub config_path: Option<std::path::PathBuf>,
1516
pub profile_name: Option<String>,
1617
pub deployment: DeploymentType,
1718
pub method: HttpMethod,
@@ -24,7 +25,7 @@ pub struct ApiCommandParams {
2425
/// Handle raw API commands
2526
#[allow(dead_code)] // Used by binary target
2627
pub async fn handle_api_command(params: ApiCommandParams) -> CliResult<()> {
27-
let connection_manager = ConnectionManager::new(params.config);
28+
let connection_manager = ConnectionManager::with_config_path(params.config, params.config_path);
2829

2930
match params.deployment {
3031
DeploymentType::Cloud => {

crates/redisctl/src/connection.rs

Lines changed: 71 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ impl ConnectionManager {
4646
}
4747

4848
/// Create a Cloud client from profile credentials with environment variable override support
49+
///
50+
/// When --config-file is explicitly specified, environment variables are ignored to provide
51+
/// true configuration isolation. This allows testing with isolated configs and follows the
52+
/// principle of "explicit wins" (CLI args > env vars > defaults).
4953
#[allow(dead_code)] // Used by binary target
5054
pub async fn create_cloud_client(
5155
&self,
@@ -54,10 +58,35 @@ impl ConnectionManager {
5458
debug!("Creating Redis Cloud client");
5559
trace!("Profile name: {:?}", profile_name);
5660

57-
// Check if all required environment variables are present
58-
let env_api_key = std::env::var("REDIS_CLOUD_API_KEY").ok();
59-
let env_api_secret = std::env::var("REDIS_CLOUD_SECRET_KEY").ok();
60-
let env_api_url = std::env::var("REDIS_CLOUD_API_URL").ok();
61+
// When --config-file is explicitly specified, ignore environment variables
62+
// This provides true configuration isolation for testing and follows CLI best practices
63+
let use_env_vars = self.config_path.is_none();
64+
65+
debug!(
66+
"Config path: {:?}, use_env_vars: {}",
67+
self.config_path, use_env_vars
68+
);
69+
70+
if !use_env_vars {
71+
info!("--config-file specified explicitly, ignoring environment variables");
72+
}
73+
74+
// Check if all required environment variables are present (only if we're using them)
75+
let env_api_key = if use_env_vars {
76+
std::env::var("REDIS_CLOUD_API_KEY").ok()
77+
} else {
78+
None
79+
};
80+
let env_api_secret = if use_env_vars {
81+
std::env::var("REDIS_CLOUD_SECRET_KEY").ok()
82+
} else {
83+
None
84+
};
85+
let env_api_url = if use_env_vars {
86+
std::env::var("REDIS_CLOUD_API_URL").ok()
87+
} else {
88+
None
89+
};
6190

6291
if env_api_key.is_some() {
6392
debug!("Found REDIS_CLOUD_API_KEY environment variable");
@@ -140,6 +169,10 @@ impl ConnectionManager {
140169
}
141170

142171
/// Create an Enterprise client from profile credentials with environment variable override support
172+
///
173+
/// When --config-file is explicitly specified, environment variables are ignored to provide
174+
/// true configuration isolation. This allows testing with isolated configs and follows the
175+
/// principle of "explicit wins" (CLI args > env vars > defaults).
143176
#[allow(dead_code)] // Used by binary target
144177
pub async fn create_enterprise_client(
145178
&self,
@@ -148,11 +181,40 @@ impl ConnectionManager {
148181
debug!("Creating Redis Enterprise client");
149182
trace!("Profile name: {:?}", profile_name);
150183

151-
// Check if all required environment variables are present
152-
let env_url = std::env::var("REDIS_ENTERPRISE_URL").ok();
153-
let env_user = std::env::var("REDIS_ENTERPRISE_USER").ok();
154-
let env_password = std::env::var("REDIS_ENTERPRISE_PASSWORD").ok();
155-
let env_insecure = std::env::var("REDIS_ENTERPRISE_INSECURE").ok();
184+
// When --config-file is explicitly specified, ignore environment variables
185+
// This provides true configuration isolation for testing and follows CLI best practices
186+
let use_env_vars = self.config_path.is_none();
187+
188+
debug!(
189+
"Config path: {:?}, use_env_vars: {}",
190+
self.config_path, use_env_vars
191+
);
192+
193+
if !use_env_vars {
194+
info!("--config-file specified explicitly, ignoring environment variables");
195+
}
196+
197+
// Check if all required environment variables are present (only if we're using them)
198+
let env_url = if use_env_vars {
199+
std::env::var("REDIS_ENTERPRISE_URL").ok()
200+
} else {
201+
None
202+
};
203+
let env_user = if use_env_vars {
204+
std::env::var("REDIS_ENTERPRISE_USER").ok()
205+
} else {
206+
None
207+
};
208+
let env_password = if use_env_vars {
209+
std::env::var("REDIS_ENTERPRISE_PASSWORD").ok()
210+
} else {
211+
None
212+
};
213+
let env_insecure = if use_env_vars {
214+
std::env::var("REDIS_ENTERPRISE_INSECURE").ok()
215+
} else {
216+
None
217+
};
156218

157219
if env_url.is_some() {
158220
debug!("Found REDIS_ENTERPRISE_URL environment variable");

crates/redisctl/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,17 @@ async fn main() -> Result<()> {
2626
// Load configuration from specified path or default location
2727
let (config, config_path) = if let Some(config_file) = &cli.config_file {
2828
let path = std::path::PathBuf::from(config_file);
29+
debug!("Loading config from explicit path: {:?}", path);
2930
let config = Config::load_from_path(&path)?;
3031
(config, Some(path))
3132
} else {
33+
debug!("Loading config from default location");
3234
(Config::load()?, None)
3335
};
36+
debug!(
37+
"Creating ConnectionManager with config_path: {:?}",
38+
config_path
39+
);
3440
let conn_mgr = ConnectionManager::with_config_path(config, config_path);
3541

3642
// Execute command
@@ -821,6 +827,7 @@ async fn execute_api_command(
821827
) -> Result<(), RedisCtlError> {
822828
commands::api::handle_api_command(commands::api::ApiCommandParams {
823829
config: conn_mgr.config.clone(),
830+
config_path: conn_mgr.config_path.clone(),
824831
profile_name: cli.profile.clone(),
825832
deployment: *deployment,
826833
method: method.clone(),

crates/redisctl/tests/cli_integration_mocked_tests.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,3 +997,70 @@ async fn test_cloud_maintenance_windows() {
997997
.stdout(predicate::str::contains("maintenanceWindows"))
998998
.stdout(predicate::str::contains("weekly"));
999999
}
1000+
1001+
#[tokio::test]
1002+
async fn test_config_file_overrides_env_vars() {
1003+
let temp_dir = TempDir::new().unwrap();
1004+
let mock_server = MockServer::start().await;
1005+
1006+
create_cloud_profile(&temp_dir, &mock_server.uri()).unwrap();
1007+
1008+
// Mock endpoint expecting credentials from config file
1009+
// Note: Matches any path to debug what request is being made
1010+
Mock::given(method("GET"))
1011+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
1012+
"message": "config file credentials used"
1013+
})))
1014+
.mount(&mock_server)
1015+
.await;
1016+
1017+
let mut cmd = Command::cargo_bin("redisctl").unwrap();
1018+
let config_file = temp_dir.path().join("config.toml");
1019+
1020+
// Set environment variables that should be IGNORED when --config-file is specified
1021+
cmd.env("REDIS_CLOUD_API_KEY", "wrong-env-key");
1022+
cmd.env("REDIS_CLOUD_SECRET_KEY", "wrong-env-secret");
1023+
1024+
// The --config-file flag should take precedence over env vars
1025+
cmd.arg("--config-file")
1026+
.arg(config_file)
1027+
.arg("api")
1028+
.arg("cloud")
1029+
.arg("get")
1030+
.arg("/test")
1031+
.assert()
1032+
.success()
1033+
.stdout(predicate::str::contains("config file credentials used"));
1034+
}
1035+
1036+
#[tokio::test]
1037+
async fn test_env_vars_work_without_config_file() {
1038+
let mock_server = MockServer::start().await;
1039+
1040+
// Mock endpoint expecting credentials from environment variables
1041+
Mock::given(method("GET"))
1042+
.and(path("/test"))
1043+
.and(header("x-api-key", "env-api-key"))
1044+
.and(header("x-api-secret-key", "env-api-secret"))
1045+
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
1046+
"message": "env credentials used"
1047+
})))
1048+
.expect(1)
1049+
.mount(&mock_server)
1050+
.await;
1051+
1052+
let mut cmd = Command::cargo_bin("redisctl").unwrap();
1053+
1054+
// Without --config-file, env vars should work
1055+
cmd.env("REDIS_CLOUD_API_KEY", "env-api-key");
1056+
cmd.env("REDIS_CLOUD_SECRET_KEY", "env-api-secret");
1057+
cmd.env("REDIS_CLOUD_API_URL", mock_server.uri());
1058+
1059+
cmd.arg("api")
1060+
.arg("cloud")
1061+
.arg("get")
1062+
.arg("/test")
1063+
.assert()
1064+
.success()
1065+
.stdout(predicate::str::contains("env credentials used"));
1066+
}

0 commit comments

Comments
 (0)