You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/07/24 13:00:34 UTC

(opendal-reqsign) 01/01: feat(aws): add profile selector to default provider

This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a commit to branch xuanwo/issue-791-profile-selector
in repository https://gitbox.apache.org/repos/asf/opendal-reqsign.git

commit 6dcb39036b699bd44eb1da6ede8ec44506115501
Author: Xuanwo <gi...@xuanwo.io>
AuthorDate: Fri Jul 24 21:00:19 2026 +0800

    feat(aws): add profile selector to default provider
---
 docs/default-credential-provider-api.md           |  15 ++
 services/aws-v4/README.md                         |  14 ++
 services/aws-v4/src/lib.rs                        |  13 ++
 services/aws-v4/src/provide_credential/default.rs | 177 +++++++++++++++++++++-
 4 files changed, 218 insertions(+), 1 deletion(-)

diff --git a/docs/default-credential-provider-api.md b/docs/default-credential-provider-api.md
index d46ee92..738223f 100644
--- a/docs/default-credential-provider-api.md
+++ b/docs/default-credential-provider-api.md
@@ -75,6 +75,21 @@ The same pattern applies to all other slots:
 - Slot names must match the actual provider concept, not an overloaded alias.
 - Builders expose only the slots that a service actually supports.
 
+### Cross-Slot Convenience Methods
+
+A service may expose a narrowly scoped convenience method when one logical
+input applies to multiple provider slots. Such a method must:
+
+- Update only slots that are currently present; it must not re-enable a slot
+  removed with `no_slot()`.
+- Preserve every unrelated setting on each provider.
+- Respect builder call order so that later slot-level replacement or removal
+  still wins.
+
+For AWS V4, `with_profile(profile)` applies the explicit profile to the
+`profile`, `sso`, and `process` slots. On `wasm32`, it applies only to the
+available `profile` slot.
+
 ## Internal State Model
 
 Each builder slot should be represented as:
diff --git a/services/aws-v4/README.md b/services/aws-v4/README.md
index 007f519..59f14ec 100644
--- a/services/aws-v4/README.md
+++ b/services/aws-v4/README.md
@@ -48,6 +48,20 @@ async fn main() -> anyhow::Result<()> {
 
 On `wasm32`, the non-portable `sso` and `process` slots are not available.
 
+## Select a Profile
+
+Use `with_profile(...)` to select one profile consistently for the shared
+profile, SSO, and process credential sources. The explicit selection takes
+precedence over `AWS_PROFILE`.
+
+```rust,no_run
+use reqsign_aws_v4::DefaultCredentialProvider;
+
+let provider = DefaultCredentialProvider::builder()
+    .with_profile("production")
+    .build();
+```
+
 ## Customize Slots
 
 Use `DefaultCredentialProvider::builder()` to replace or remove individual slots.
diff --git a/services/aws-v4/src/lib.rs b/services/aws-v4/src/lib.rs
index 4179ed9..a0b3ec0 100644
--- a/services/aws-v4/src/lib.rs
+++ b/services/aws-v4/src/lib.rs
@@ -112,6 +112,19 @@
 //!     .build();
 //! ```
 //!
+//! ### Selecting a Profile
+//!
+//! Use one explicit profile for the shared profile, SSO, and process credential
+//! sources:
+//!
+//! ```no_run
+//! use reqsign_aws_v4::DefaultCredentialProvider;
+//!
+//! let provider = DefaultCredentialProvider::builder()
+//!     .with_profile("production")
+//!     .build();
+//! ```
+//!
 //! ### Custom Credential Provider
 //!
 //! You can create custom credential providers by implementing the `ProvideCredential` trait:
diff --git a/services/aws-v4/src/provide_credential/default.rs b/services/aws-v4/src/provide_credential/default.rs
index 1bff7ff..eca6495 100644
--- a/services/aws-v4/src/provide_credential/default.rs
+++ b/services/aws-v4/src/provide_credential/default.rs
@@ -122,6 +122,34 @@ impl DefaultCredentialProviderBuilder {
         Self::default()
     }
 
+    /// Select the AWS profile used by all profile-aware provider slots.
+    ///
+    /// The explicit profile is applied to the shared profile, SSO, and process
+    /// providers that are still enabled. Other settings on those providers are
+    /// preserved, and slots removed with `no_profile()`, `no_sso()`, or
+    /// `no_process()` remain removed.
+    ///
+    /// An explicitly selected profile takes precedence over `AWS_PROFILE`.
+    /// Slot-level methods called after this method can still replace or remove
+    /// individual providers.
+    pub fn with_profile(mut self, profile: impl Into<String>) -> Self {
+        let profile = profile.into();
+
+        self.profile = self
+            .profile
+            .map(|provider| provider.with_profile(profile.clone()));
+
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            self.sso = self
+                .sso
+                .map(|provider| provider.with_profile(profile.clone()));
+            self.process = self.process.map(|provider| provider.with_profile(profile));
+        }
+
+        self
+    }
+
     /// Override the environment credential provider slot.
     pub fn env(mut self, provider: EnvCredentialProvider) -> Self {
         self.env = Some(provider);
@@ -263,15 +291,21 @@ impl ProvideCredential for DefaultCredentialProvider {
 mod tests {
     use super::*;
     use crate::constants::{
-        AWS_ACCESS_KEY_ID, AWS_CONFIG_FILE, AWS_SECRET_ACCESS_KEY, AWS_SHARED_CREDENTIALS_FILE,
+        AWS_ACCESS_KEY_ID, AWS_CONFIG_FILE, AWS_PROFILE, AWS_SECRET_ACCESS_KEY,
+        AWS_SHARED_CREDENTIALS_FILE,
     };
     #[cfg(not(target_arch = "wasm32"))]
     use reqsign_command_execute_tokio::TokioCommandExecute;
+    #[cfg(not(target_arch = "wasm32"))]
+    use reqsign_core::ErrorKind;
     use reqsign_core::{OsEnv, StaticEnv};
     use reqsign_file_read_tokio::TokioFileRead;
     use reqsign_http_send_reqwest::ReqwestHttpSend;
     use std::collections::HashMap;
     use std::env;
+    use std::fs::File;
+    use std::io::Write;
+    use tempfile::tempdir;
 
     #[tokio::test]
     async fn test_credential_env_loader_without_env() {
@@ -566,6 +600,147 @@ mod tests {
         assert_eq!("config_secret_access_key", cred.secret_access_key);
     }
 
+    #[tokio::test]
+    async fn test_with_profile_configures_profile_slot() -> anyhow::Result<()> {
+        let tmp_dir = tempdir()?;
+        let credentials_file = tmp_dir.path().join("credentials");
+        let mut file = File::create(&credentials_file)?;
+        writeln!(file, "[ambient]")?;
+        writeln!(file, "aws_access_key_id = AMBIENT")?;
+        writeln!(file, "aws_secret_access_key = ambient-secret")?;
+        writeln!(file)?;
+        writeln!(file, "[selected]")?;
+        writeln!(file, "aws_access_key_id = SELECTED")?;
+        writeln!(file, "aws_secret_access_key = selected-secret")?;
+
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([(AWS_PROFILE.to_string(), "ambient".to_string())]),
+            });
+
+        let builder = DefaultCredentialProvider::builder()
+            .profile(
+                ProfileCredentialProvider::new()
+                    .with_credentials_file(credentials_file.to_string_lossy()),
+            )
+            .with_profile("selected");
+        let provider = builder.profile.expect("profile slot must remain enabled");
+
+        let credential = provider
+            .provide_credential(&ctx)
+            .await?
+            .expect("selected profile must provide credentials");
+        assert_eq!("SELECTED", credential.access_key_id);
+        assert_eq!("selected-secret", credential.secret_access_key);
+
+        Ok(())
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    #[tokio::test]
+    async fn test_with_profile_configures_sso_slot() -> anyhow::Result<()> {
+        let tmp_dir = tempdir()?;
+        let config_file = tmp_dir.path().join("config");
+        let mut file = File::create(&config_file)?;
+        writeln!(file, "[profile selected]")?;
+        writeln!(file, "sso_account_id = 123456789012")?;
+        writeln!(file, "sso_region = us-east-1")?;
+        writeln!(file, "sso_role_name = Developer")?;
+        writeln!(file, "sso_start_url = https://example.awsapps.com/start")?;
+
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_env(StaticEnv {
+                home_dir: Some(tmp_dir.path().to_path_buf()),
+                envs: HashMap::from([
+                    (
+                        AWS_CONFIG_FILE.to_string(),
+                        config_file.to_string_lossy().into(),
+                    ),
+                    (AWS_PROFILE.to_string(), "ambient".to_string()),
+                ]),
+            });
+
+        let builder = DefaultCredentialProvider::builder().with_profile("selected");
+        let provider = builder.sso.expect("SSO slot must remain enabled");
+
+        let error = provider
+            .provide_credential(&ctx)
+            .await
+            .expect_err("selected SSO profile must be loaded before cache lookup");
+        assert_eq!(ErrorKind::ConfigInvalid, error.kind());
+        assert_eq!(
+            "No valid SSO token found. Please run 'aws sso login' first",
+            error.to_string()
+        );
+
+        Ok(())
+    }
+
+    #[cfg(not(target_arch = "wasm32"))]
+    #[tokio::test]
+    async fn test_with_profile_configures_process_slot() -> anyhow::Result<()> {
+        let tmp_dir = tempdir()?;
+        let config_file = tmp_dir.path().join("config");
+        let helper = format!(
+            "{}/tests/mocks/credential_process_helper.py",
+            env::current_dir()?.to_string_lossy()
+        );
+        let mut file = File::create(&config_file)?;
+        writeln!(file, "[profile ambient]")?;
+        writeln!(file, "credential_process = python3 {helper}")?;
+        writeln!(file)?;
+        writeln!(file, "[profile selected]")?;
+        writeln!(file, "credential_process = python3 {helper} --profile test")?;
+
+        let ctx = Context::new()
+            .with_file_read(TokioFileRead)
+            .with_command_execute(TokioCommandExecute)
+            .with_env(StaticEnv {
+                home_dir: None,
+                envs: HashMap::from([
+                    (
+                        AWS_CONFIG_FILE.to_string(),
+                        config_file.to_string_lossy().into(),
+                    ),
+                    (AWS_PROFILE.to_string(), "ambient".to_string()),
+                ]),
+            });
+
+        let builder = DefaultCredentialProvider::builder().with_profile("selected");
+        let provider = builder.process.expect("process slot must remain enabled");
+
+        let credential = provider
+            .provide_credential(&ctx)
+            .await?
+            .expect("selected process profile must provide credentials");
+        assert_eq!("ASIAPROCESSTEST", credential.access_key_id);
+        assert_eq!(
+            "process/test/secret/key/EXAMPLE",
+            credential.secret_access_key
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn test_with_profile_preserves_removed_slots() {
+        let builder = DefaultCredentialProvider::builder().no_profile();
+        #[cfg(not(target_arch = "wasm32"))]
+        let builder = builder.no_sso().no_process();
+
+        let builder = builder.with_profile("selected");
+
+        assert!(builder.profile.is_none());
+        #[cfg(not(target_arch = "wasm32"))]
+        {
+            assert!(builder.sso.is_none());
+            assert!(builder.process.is_none());
+        }
+    }
+
     #[cfg(not(target_arch = "wasm32"))]
     #[tokio::test]
     async fn test_default_credential_provider_custom_process_slot() {