Skip to main content

redisctl_core/
clients.rs

1//! Shared profile resolution and API client construction.
2//!
3//! This module contains the product-level connection behavior shared by the
4//! redisctl CLI and MCP server. Frontends choose the profile and environment
5//! policy, while this layer resolves credentials and consistently configures
6//! the underlying API clients.
7
8use 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/// Errors produced while resolving connection settings or building API clients.
15#[derive(Debug, Error)]
16pub enum ClientResolutionError {
17    /// The redisctl configuration could not be resolved.
18    #[error(transparent)]
19    Config(#[from] ConfigError),
20
21    /// A selected profile has the wrong deployment type.
22    #[error("Profile '{name}' is type '{actual_type}' but a {expected_type} profile is required")]
23    ProfileTypeMismatch {
24        /// Selected profile name.
25        name: String,
26        /// Deployment type stored on the selected profile.
27        actual_type: DeploymentType,
28        /// Deployment type required by the requested client.
29        expected_type: DeploymentType,
30        /// Other configured profiles with the required type.
31        available_profiles: Vec<String>,
32    },
33
34    /// Required credential fields were empty or absent.
35    #[error("Missing credentials for {deployment_type} profile '{profile_name}': {missing_fields}")]
36    MissingCredentials {
37        /// Profile name, or `environment` when a complete environment-only
38        /// configuration was selected.
39        profile_name: String,
40        /// Deployment type being resolved.
41        deployment_type: DeploymentType,
42        /// Comma-separated credential field names.
43        missing_fields: String,
44    },
45
46    /// Redis Cloud client construction failed.
47    #[error("Failed to build Redis Cloud client: {0}")]
48    CloudClient(#[source] redis_cloud::CloudError),
49
50    /// Redis Enterprise client construction failed.
51    #[error("Failed to build Redis Enterprise client: {0}")]
52    EnterpriseClient(#[source] redis_enterprise::RestError),
53}
54
55/// Fully resolved Redis Cloud connection settings.
56#[derive(Clone)]
57pub struct ResolvedCloudConnection {
58    /// Cloud API base URL.
59    pub base_url: String,
60    /// Cloud API key.
61    pub api_key: String,
62    /// Cloud API secret.
63    pub api_secret: String,
64    /// HTTP user-agent value.
65    pub user_agent: String,
66}
67
68impl ResolvedCloudConnection {
69    /// Build a Redis Cloud client from these resolved settings.
70    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/// Fully resolved Redis Enterprise connection settings.
82#[derive(Clone)]
83pub struct ResolvedEnterpriseConnection {
84    /// Enterprise REST API base URL.
85    pub base_url: String,
86    /// Enterprise API username.
87    pub username: String,
88    /// Enterprise API password.
89    pub password: Option<String>,
90    /// Whether TLS certificate verification is disabled.
91    pub insecure: bool,
92    /// Optional custom CA certificate path.
93    pub ca_cert: Option<String>,
94    /// HTTP user-agent value.
95    pub user_agent: String,
96}
97
98impl ResolvedEnterpriseConnection {
99    /// Build a Redis Enterprise client from these resolved settings.
100    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
123/// Resolves configured profiles into typed connection settings and API clients.
124pub struct ClientResolver<'a> {
125    config: &'a Config,
126    environment_overrides: EnvironmentOverrides,
127    user_agent: String,
128}
129
130impl<'a> ClientResolver<'a> {
131    /// Create a resolver that allows supported environment variables to
132    /// override stored profile values.
133    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    /// Set whether process environment variables may override profile values.
142    #[must_use]
143    pub fn environment_overrides(mut self, environment_overrides: EnvironmentOverrides) -> Self {
144        self.environment_overrides = environment_overrides;
145        self
146    }
147
148    /// Set the product-specific HTTP user agent applied to built clients.
149    #[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    /// Resolve Redis Cloud connection settings.
156    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    /// Resolve Redis Enterprise connection settings.
229    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    /// Resolve settings and build a Redis Cloud client.
322    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    /// Resolve settings and build a Redis Enterprise client.
330    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}