1use crate::{Config, ConfigError, CredentialStore, DeploymentType, EnvironmentOverrides, Profile};
9use thiserror::Error;
10
11const DEFAULT_CLOUD_API_URL: &str = "https://api.redislabs.com/v1";
12const DEFAULT_USER_AGENT: &str = concat!("redisctl-core/", env!("CARGO_PKG_VERSION"));
13
14#[derive(Debug, Error)]
16pub enum ClientResolutionError {
17 #[error(transparent)]
19 Config(#[from] ConfigError),
20
21 #[error("Profile '{name}' is type '{actual_type}' but a {expected_type} profile is required")]
23 ProfileTypeMismatch {
24 name: String,
26 actual_type: DeploymentType,
28 expected_type: DeploymentType,
30 available_profiles: Vec<String>,
32 },
33
34 #[error("Missing credentials for {deployment_type} profile '{profile_name}': {missing_fields}")]
36 MissingCredentials {
37 profile_name: String,
40 deployment_type: DeploymentType,
42 missing_fields: String,
44 },
45
46 #[error("Failed to build Redis Cloud client: {0}")]
48 CloudClient(#[source] redis_cloud::CloudError),
49
50 #[error("Failed to build Redis Enterprise client: {0}")]
52 EnterpriseClient(#[source] redis_enterprise::RestError),
53}
54
55#[derive(Clone)]
57pub struct ResolvedCloudConnection {
58 pub base_url: String,
60 pub api_key: String,
62 pub api_secret: String,
64 pub user_agent: String,
66}
67
68impl ResolvedCloudConnection {
69 pub fn build_client(&self) -> Result<redis_cloud::CloudClient, ClientResolutionError> {
71 redis_cloud::CloudClient::builder()
72 .api_key(&self.api_key)
73 .api_secret(&self.api_secret)
74 .base_url(&self.base_url)
75 .user_agent(&self.user_agent)
76 .build()
77 .map_err(ClientResolutionError::CloudClient)
78 }
79}
80
81#[derive(Clone)]
83pub struct ResolvedEnterpriseConnection {
84 pub base_url: String,
86 pub username: String,
88 pub password: Option<String>,
90 pub insecure: bool,
92 pub ca_cert: Option<String>,
94 pub user_agent: String,
96}
97
98impl ResolvedEnterpriseConnection {
99 pub fn build_client(
101 &self,
102 ) -> Result<redis_enterprise::EnterpriseClient, ClientResolutionError> {
103 let mut builder = redis_enterprise::EnterpriseClient::builder()
104 .base_url(&self.base_url)
105 .username(&self.username)
106 .insecure(self.insecure)
107 .user_agent(&self.user_agent);
108
109 if let Some(password) = &self.password {
110 builder = builder.password(password);
111 }
112
113 if let Some(ca_cert) = &self.ca_cert {
114 builder = builder.ca_cert(ca_cert);
115 }
116
117 builder
118 .build()
119 .map_err(ClientResolutionError::EnterpriseClient)
120 }
121}
122
123pub struct ClientResolver<'a> {
125 config: &'a Config,
126 environment_overrides: EnvironmentOverrides,
127 user_agent: String,
128}
129
130impl<'a> ClientResolver<'a> {
131 pub fn new(config: &'a Config) -> Self {
134 Self {
135 config,
136 environment_overrides: EnvironmentOverrides::Enabled,
137 user_agent: DEFAULT_USER_AGENT.to_string(),
138 }
139 }
140
141 #[must_use]
143 pub fn environment_overrides(mut self, environment_overrides: EnvironmentOverrides) -> Self {
144 self.environment_overrides = environment_overrides;
145 self
146 }
147
148 #[must_use]
150 pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
151 self.user_agent = user_agent.into();
152 self
153 }
154
155 pub fn resolve_cloud(
157 &self,
158 explicit_profile: Option<&str>,
159 ) -> Result<ResolvedCloudConnection, ClientResolutionError> {
160 let env_api_key = self.environment_value("REDIS_CLOUD_API_KEY");
161 let env_api_secret = self
162 .environment_value("REDIS_CLOUD_SECRET_KEY")
163 .or_else(|| self.environment_value("REDIS_CLOUD_API_SECRET"));
164 let env_api_url = self.environment_value("REDIS_CLOUD_API_URL");
165
166 let (profile_name, api_key, api_secret, base_url) = if let (
167 None,
168 Some(api_key),
169 Some(api_secret),
170 ) =
171 (explicit_profile, &env_api_key, &env_api_secret)
172 {
173 (
174 "environment".to_string(),
175 api_key.clone(),
176 api_secret.clone(),
177 env_api_url.unwrap_or_else(|| DEFAULT_CLOUD_API_URL.to_string()),
178 )
179 } else {
180 let (profile_name, profile) =
181 self.resolve_profile(explicit_profile, DeploymentType::Cloud)?;
182 let (stored_api_key, stored_api_secret, stored_api_url) = profile
183 .cloud_credentials()
184 .ok_or_else(|| {
185 ConfigError::CredentialError(format!(
186 "Profile '{profile_name}' declares type cloud but contains different credentials"
187 ))
188 })?;
189 let store = CredentialStore::new();
190
191 let api_key = store.get_credential_with_environment(
192 stored_api_key,
193 &["REDIS_CLOUD_API_KEY"],
194 self.environment_overrides,
195 )?;
196 let api_secret = store.get_credential_with_environment(
197 stored_api_secret,
198 &["REDIS_CLOUD_SECRET_KEY", "REDIS_CLOUD_API_SECRET"],
199 self.environment_overrides,
200 )?;
201 let base_url = store.get_credential_with_environment(
202 stored_api_url,
203 &["REDIS_CLOUD_API_URL"],
204 self.environment_overrides,
205 )?;
206
207 (profile_name, api_key, api_secret, base_url)
208 };
209
210 self.ensure_credentials(
211 &profile_name,
212 DeploymentType::Cloud,
213 [
214 ("api_key", api_key.as_str()),
215 ("api_secret", api_secret.as_str()),
216 ("api_url", base_url.as_str()),
217 ],
218 )?;
219
220 Ok(ResolvedCloudConnection {
221 base_url,
222 api_key,
223 api_secret,
224 user_agent: self.user_agent.clone(),
225 })
226 }
227
228 pub fn resolve_enterprise(
230 &self,
231 explicit_profile: Option<&str>,
232 ) -> Result<ResolvedEnterpriseConnection, ClientResolutionError> {
233 let env_url = self.environment_value("REDIS_ENTERPRISE_URL");
234 let env_username = self.environment_value("REDIS_ENTERPRISE_USER");
235 let env_password = self.environment_value("REDIS_ENTERPRISE_PASSWORD");
236 let env_insecure = self.environment_value("REDIS_ENTERPRISE_INSECURE");
237 let env_ca_cert = self.environment_value("REDIS_ENTERPRISE_CA_CERT");
238
239 let (profile_name, base_url, username, password, insecure, ca_cert) = if let (
240 None,
241 Some(url),
242 Some(username),
243 ) =
244 (explicit_profile, &env_url, &env_username)
245 {
246 (
247 "environment".to_string(),
248 url.clone(),
249 username.clone(),
250 env_password,
251 env_insecure.as_deref().map(parse_bool).unwrap_or_default(),
252 env_ca_cert,
253 )
254 } else {
255 let (profile_name, profile) =
256 self.resolve_profile(explicit_profile, DeploymentType::Enterprise)?;
257 let (stored_url, stored_username, stored_password, stored_insecure, stored_ca_cert) =
258 profile
259 .enterprise_credentials()
260 .ok_or_else(|| {
261 ConfigError::CredentialError(format!(
262 "Profile '{profile_name}' declares type enterprise but contains different credentials"
263 ))
264 })?;
265 let store = CredentialStore::new();
266
267 let base_url = store.get_credential_with_environment(
268 stored_url,
269 &["REDIS_ENTERPRISE_URL"],
270 self.environment_overrides,
271 )?;
272 let username = store.get_credential_with_environment(
273 stored_username,
274 &["REDIS_ENTERPRISE_USER"],
275 self.environment_overrides,
276 )?;
277 let password = match stored_password {
278 Some(stored_password) => Some(store.get_credential_with_environment(
279 stored_password,
280 &["REDIS_ENTERPRISE_PASSWORD"],
281 self.environment_overrides,
282 )?),
283 None => env_password,
284 };
285 let insecure = env_insecure
286 .as_deref()
287 .map(parse_bool)
288 .unwrap_or(stored_insecure);
289 let ca_cert = env_ca_cert.or_else(|| stored_ca_cert.map(ToString::to_string));
290
291 (
292 profile_name,
293 base_url,
294 username,
295 password,
296 insecure,
297 ca_cert,
298 )
299 };
300
301 self.ensure_credentials(
302 &profile_name,
303 DeploymentType::Enterprise,
304 [
305 ("url", base_url.as_str()),
306 ("username", username.as_str()),
307 ("password", password.as_deref().unwrap_or_default()),
308 ],
309 )?;
310
311 Ok(ResolvedEnterpriseConnection {
312 base_url,
313 username,
314 password,
315 insecure,
316 ca_cert,
317 user_agent: self.user_agent.clone(),
318 })
319 }
320
321 pub fn build_cloud_client(
323 &self,
324 explicit_profile: Option<&str>,
325 ) -> Result<redis_cloud::CloudClient, ClientResolutionError> {
326 self.resolve_cloud(explicit_profile)?.build_client()
327 }
328
329 pub fn build_enterprise_client(
331 &self,
332 explicit_profile: Option<&str>,
333 ) -> Result<redis_enterprise::EnterpriseClient, ClientResolutionError> {
334 self.resolve_enterprise(explicit_profile)?.build_client()
335 }
336
337 fn environment_value(&self, name: &str) -> Option<String> {
338 if self.environment_overrides == EnvironmentOverrides::Enabled {
339 std::env::var(name).ok()
340 } else {
341 None
342 }
343 }
344
345 fn resolve_profile(
346 &self,
347 explicit_profile: Option<&str>,
348 expected_type: DeploymentType,
349 ) -> Result<(String, &Profile), ClientResolutionError> {
350 let profile_name = match expected_type {
351 DeploymentType::Cloud => self.config.resolve_cloud_profile(explicit_profile)?,
352 DeploymentType::Enterprise => {
353 self.config.resolve_enterprise_profile(explicit_profile)?
354 }
355 DeploymentType::Database => unreachable!("database clients are resolved elsewhere"),
356 };
357 let profile = self.config.profiles.get(&profile_name).ok_or_else(|| {
358 ConfigError::ProfileNotFound {
359 name: profile_name.clone(),
360 }
361 })?;
362
363 if profile.deployment_type != expected_type {
364 return Err(ClientResolutionError::ProfileTypeMismatch {
365 name: profile_name,
366 actual_type: profile.deployment_type,
367 expected_type,
368 available_profiles: self
369 .config
370 .get_profiles_of_type(expected_type)
371 .into_iter()
372 .map(ToString::to_string)
373 .collect(),
374 });
375 }
376
377 Ok((profile_name, profile))
378 }
379
380 fn ensure_credentials<const N: usize>(
381 &self,
382 profile_name: &str,
383 deployment_type: DeploymentType,
384 fields: [(&str, &str); N],
385 ) -> Result<(), ClientResolutionError> {
386 let missing_fields = fields
387 .into_iter()
388 .filter_map(|(name, value)| value.trim().is_empty().then_some(name))
389 .collect::<Vec<_>>();
390
391 if missing_fields.is_empty() {
392 Ok(())
393 } else {
394 Err(ClientResolutionError::MissingCredentials {
395 profile_name: profile_name.to_string(),
396 deployment_type,
397 missing_fields: missing_fields.join(", "),
398 })
399 }
400 }
401}
402
403fn parse_bool(value: &str) -> bool {
404 value.eq_ignore_ascii_case("true") || value == "1"
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::ProfileCredentials;
411 use std::collections::HashMap;
412
413 fn cloud_profile(api_key: &str, api_secret: &str, api_url: &str) -> Profile {
414 Profile {
415 deployment_type: DeploymentType::Cloud,
416 credentials: ProfileCredentials::Cloud {
417 api_key: api_key.to_string(),
418 api_secret: api_secret.to_string(),
419 api_url: api_url.to_string(),
420 },
421 files_api_key: None,
422 tags: Vec::new(),
423 }
424 }
425
426 fn enterprise_profile(
427 url: &str,
428 username: &str,
429 password: Option<&str>,
430 insecure: bool,
431 ca_cert: Option<&str>,
432 ) -> Profile {
433 Profile {
434 deployment_type: DeploymentType::Enterprise,
435 credentials: ProfileCredentials::Enterprise {
436 url: url.to_string(),
437 username: username.to_string(),
438 password: password.map(ToString::to_string),
439 insecure,
440 ca_cert: ca_cert.map(ToString::to_string),
441 },
442 files_api_key: None,
443 tags: Vec::new(),
444 }
445 }
446
447 fn isolated_resolver(config: &Config) -> ClientResolver<'_> {
448 ClientResolver::new(config).environment_overrides(EnvironmentOverrides::Disabled)
449 }
450
451 #[test]
452 fn resolves_default_cloud_profile() {
453 let mut profiles = HashMap::new();
454 profiles.insert(
455 "first".to_string(),
456 cloud_profile("first-key", "first-secret", "https://first.example/v1"),
457 );
458 profiles.insert(
459 "default".to_string(),
460 cloud_profile(
461 "default-key",
462 "default-secret",
463 "https://default.example/v1",
464 ),
465 );
466 let config = Config {
467 default_cloud: Some("default".to_string()),
468 profiles,
469 ..Config::default()
470 };
471
472 let resolved = isolated_resolver(&config).resolve_cloud(None).unwrap();
473
474 assert_eq!(resolved.api_key, "default-key");
475 assert_eq!(resolved.base_url, "https://default.example/v1");
476 }
477
478 #[test]
479 fn resolves_default_enterprise_profile() {
480 let mut profiles = HashMap::new();
481 profiles.insert(
482 "first".to_string(),
483 enterprise_profile(
484 "https://first.example:9443",
485 "first-user",
486 Some("first-password"),
487 false,
488 None,
489 ),
490 );
491 profiles.insert(
492 "default".to_string(),
493 enterprise_profile(
494 "https://default.example:9443",
495 "default-user",
496 Some("default-password"),
497 true,
498 None,
499 ),
500 );
501 let config = Config {
502 default_enterprise: Some("default".to_string()),
503 profiles,
504 ..Config::default()
505 };
506
507 let resolved = isolated_resolver(&config).resolve_enterprise(None).unwrap();
508
509 assert_eq!(resolved.username, "default-user");
510 assert_eq!(resolved.base_url, "https://default.example:9443");
511 assert!(resolved.insecure);
512 }
513
514 #[test]
515 fn resolves_explicit_profile_and_custom_endpoints() {
516 let mut profiles = HashMap::new();
517 profiles.insert(
518 "cloud-a".to_string(),
519 cloud_profile("key-a", "secret-a", "https://cloud-a.example/v1"),
520 );
521 profiles.insert(
522 "cloud-b".to_string(),
523 cloud_profile("key-b", "secret-b", "https://cloud-b.example/v1"),
524 );
525 profiles.insert(
526 "enterprise".to_string(),
527 enterprise_profile(
528 "https://enterprise.example:9443",
529 "admin",
530 Some("secret"),
531 false,
532 Some("/etc/redis/ca.pem"),
533 ),
534 );
535 let config = Config {
536 profiles,
537 ..Config::default()
538 };
539
540 let resolver = isolated_resolver(&config).user_agent("redisctl-test/1");
541 let cloud = resolver.resolve_cloud(Some("cloud-b")).unwrap();
542 let enterprise = resolver.resolve_enterprise(Some("enterprise")).unwrap();
543
544 assert_eq!(cloud.api_key, "key-b");
545 assert_eq!(cloud.base_url, "https://cloud-b.example/v1");
546 assert_eq!(cloud.user_agent, "redisctl-test/1");
547 assert_eq!(enterprise.base_url, "https://enterprise.example:9443");
548 assert_eq!(enterprise.ca_cert.as_deref(), Some("/etc/redis/ca.pem"));
549 assert_eq!(enterprise.user_agent, "redisctl-test/1");
550 }
551
552 #[test]
553 fn reports_missing_credentials() {
554 let mut profiles = HashMap::new();
555 profiles.insert(
556 "cloud".to_string(),
557 cloud_profile("key", "", "https://api.example/v1"),
558 );
559 profiles.insert(
560 "enterprise".to_string(),
561 enterprise_profile("https://cluster:9443", "admin", None, false, None),
562 );
563 let config = Config {
564 profiles,
565 ..Config::default()
566 };
567
568 let cloud_error = isolated_resolver(&config)
569 .resolve_cloud(Some("cloud"))
570 .err()
571 .expect("missing Cloud secret must fail");
572 let enterprise_error = isolated_resolver(&config)
573 .resolve_enterprise(Some("enterprise"))
574 .err()
575 .expect("missing Enterprise password must fail");
576
577 assert!(matches!(
578 cloud_error,
579 ClientResolutionError::MissingCredentials {
580 missing_fields,
581 ..
582 } if missing_fields == "api_secret"
583 ));
584 assert!(matches!(
585 enterprise_error,
586 ClientResolutionError::MissingCredentials {
587 missing_fields,
588 ..
589 } if missing_fields == "password"
590 ));
591 }
592
593 #[test]
594 fn rejects_an_explicit_profile_of_the_wrong_type() {
595 let mut profiles = HashMap::new();
596 profiles.insert(
597 "enterprise".to_string(),
598 enterprise_profile("https://cluster:9443", "admin", Some("secret"), false, None),
599 );
600 let config = Config {
601 profiles,
602 ..Config::default()
603 };
604
605 let error = isolated_resolver(&config)
606 .resolve_cloud(Some("enterprise"))
607 .err()
608 .expect("wrong profile type must fail");
609
610 assert!(matches!(
611 error,
612 ClientResolutionError::ProfileTypeMismatch {
613 actual_type: DeploymentType::Enterprise,
614 expected_type: DeploymentType::Cloud,
615 ..
616 }
617 ));
618 }
619
620 #[test]
621 #[serial_test::serial(client_resolution_env)]
622 fn environment_overrides_and_explicit_config_isolation_are_distinct() {
623 unsafe {
624 std::env::set_var("REDIS_CLOUD_API_KEY", "environment-key");
625 std::env::set_var("REDIS_CLOUD_SECRET_KEY", "environment-secret");
626 std::env::set_var("REDIS_CLOUD_API_URL", "https://environment.example/v1");
627 }
628 let mut profiles = HashMap::new();
629 profiles.insert(
630 "cloud".to_string(),
631 cloud_profile(
632 "profile-key",
633 "profile-secret",
634 "https://profile.example/v1",
635 ),
636 );
637 let config = Config {
638 profiles,
639 ..Config::default()
640 };
641
642 let overridden = ClientResolver::new(&config)
643 .resolve_cloud(Some("cloud"))
644 .unwrap();
645 let isolated = isolated_resolver(&config)
646 .resolve_cloud(Some("cloud"))
647 .unwrap();
648
649 unsafe {
650 std::env::remove_var("REDIS_CLOUD_API_KEY");
651 std::env::remove_var("REDIS_CLOUD_SECRET_KEY");
652 std::env::remove_var("REDIS_CLOUD_API_URL");
653 }
654
655 assert_eq!(overridden.api_key, "environment-key");
656 assert_eq!(overridden.base_url, "https://environment.example/v1");
657 assert_eq!(isolated.api_key, "profile-key");
658 assert_eq!(isolated.base_url, "https://profile.example/v1");
659 }
660
661 #[test]
662 #[serial_test::serial(client_resolution_env)]
663 fn environment_can_override_keyring_references() {
664 unsafe {
665 std::env::set_var("REDIS_CLOUD_API_KEY", "environment-key");
666 std::env::set_var("REDIS_CLOUD_SECRET_KEY", "environment-secret");
667 }
668 let mut profiles = HashMap::new();
669 profiles.insert(
670 "cloud".to_string(),
671 cloud_profile(
672 "keyring:cloud-key",
673 "keyring:cloud-secret",
674 "https://api.example/v1",
675 ),
676 );
677 let config = Config {
678 profiles,
679 ..Config::default()
680 };
681
682 let resolved = ClientResolver::new(&config)
683 .resolve_cloud(Some("cloud"))
684 .unwrap();
685
686 unsafe {
687 std::env::remove_var("REDIS_CLOUD_API_KEY");
688 std::env::remove_var("REDIS_CLOUD_SECRET_KEY");
689 }
690
691 assert_eq!(resolved.api_key, "environment-key");
692 assert_eq!(resolved.api_secret, "environment-secret");
693 }
694
695 #[test]
696 fn builds_clients_from_resolved_settings() {
697 let cloud = ResolvedCloudConnection {
698 base_url: "https://api.example/v1".to_string(),
699 api_key: "key".to_string(),
700 api_secret: "secret".to_string(),
701 user_agent: "redisctl-test/1".to_string(),
702 };
703 let enterprise = ResolvedEnterpriseConnection {
704 base_url: "https://cluster.example:9443".to_string(),
705 username: "admin".to_string(),
706 password: Some("secret".to_string()),
707 insecure: true,
708 ca_cert: None,
709 user_agent: "redisctl-test/1".to_string(),
710 };
711
712 cloud.build_client().unwrap();
713 enterprise.build_client().unwrap();
714 }
715}