diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProvider.java
index 0e8ddb4aad3b..3c797cf9905c 100644
--- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProvider.java
+++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProvider.java
@@ -30,7 +30,7 @@
public interface AwsCredentialsProvider {
/**
* Returns {@link AwsCredentials} that can be used to authorize an AWS request. Each implementation of AWSCredentialsProvider
- * can chose its own strategy for loading credentials. For example, an implementation might load credentials from an existing
+ * can choose its own strategy for loading credentials. For example, an implementation might load credentials from an existing
* key management system, or load new credentials when credentials are rotated.
*
* If an error occurs during the loading of credentials or credentials could not be found, a runtime exception will be
diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java
index 37ccb8a636bf..28c4b74ce3ee 100644
--- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java
+++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java
@@ -15,6 +15,7 @@
package software.amazon.awssdk.auth.credentials;
+import java.util.Objects;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Supplier;
@@ -23,6 +24,7 @@
import software.amazon.awssdk.auth.credentials.internal.ProfileCredentialsUtils;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.profiles.ProfileFile;
+import software.amazon.awssdk.profiles.ProfileFileSupplier;
import software.amazon.awssdk.profiles.ProfileFileSystemSetting;
import software.amazon.awssdk.utils.IoUtils;
import software.amazon.awssdk.utils.SdkAutoCloseable;
@@ -46,55 +48,42 @@ public final class ProfileCredentialsProvider
implements AwsCredentialsProvider,
SdkAutoCloseable,
ToCopyableBuilder {
- private final AwsCredentialsProvider credentialsProvider;
- private final RuntimeException loadException;
- private final ProfileFile profileFile;
+ private AwsCredentialsProvider credentialsProvider;
+ private final RuntimeException loadException;
+ private final ProfileFileSupplier profileFileSupplier;
+ private volatile ProfileFile currentProfileFile;
private final String profileName;
-
private final Supplier defaultProfileFileLoader;
/**
* @see #builder()
*/
private ProfileCredentialsProvider(BuilderImpl builder) {
- AwsCredentialsProvider credentialsProvider = null;
- RuntimeException loadException = null;
- ProfileFile profileFile = null;
- String profileName = null;
+ this.defaultProfileFileLoader = builder.defaultProfileFileLoader;
+
+ RuntimeException thrownException = null;
+ String selectedProfileName = null;
+ ProfileFileSupplier selectedProfileSupplier = null;
try {
- profileName = builder.profileName != null ? builder.profileName
- : ProfileFileSystemSetting.AWS_PROFILE.getStringValueOrThrow();
-
- // Load the profiles file
- profileFile = Optional.ofNullable(builder.profileFile)
- .orElseGet(builder.defaultProfileFileLoader);
-
- // Load the profile and credentials provider
- String finalProfileName = profileName;
- ProfileFile finalProfileFile = profileFile;
- credentialsProvider =
- profileFile.profile(profileName)
- .flatMap(p -> new ProfileCredentialsUtils(finalProfileFile, p, finalProfileFile::profile)
- .credentialsProvider())
- .orElseThrow(() -> {
- String errorMessage = String.format("Profile file contained no credentials for " +
- "profile '%s': %s", finalProfileName, finalProfileFile);
- return SdkClientException.builder().message(errorMessage).build();
- });
+ selectedProfileName = Optional.ofNullable(builder.profileName)
+ .orElseGet(ProfileFileSystemSetting.AWS_PROFILE::getStringValueOrThrow);
+
+ selectedProfileSupplier = Optional.ofNullable(builder.profileFileSupplier)
+ .orElseGet(() -> ProfileFileSupplier
+ .fixedProfileFile(builder.defaultProfileFileLoader.get()));
+
} catch (RuntimeException e) {
// If we couldn't load the credentials provider for some reason, save an exception describing why. This exception
- // will only be raised on calls to getCredentials. We don't want to raise an exception here because it may be
+ // will only be raised on calls to resolveCredentials. We don't want to raise an exception here because it may be
// expected (eg. in the default credential chain).
- loadException = e;
+ thrownException = e;
}
- this.loadException = loadException;
- this.credentialsProvider = credentialsProvider;
- this.profileFile = profileFile;
- this.profileName = profileName;
- this.defaultProfileFileLoader = builder.defaultProfileFileLoader;
+ this.loadException = thrownException;
+ this.profileName = selectedProfileName;
+ this.profileFileSupplier = selectedProfileSupplier;
}
/**
@@ -127,19 +116,39 @@ public AwsCredentials resolveCredentials() {
if (loadException != null) {
throw loadException;
}
+
+ ProfileFile cachedOrRefreshedProfileFile = refreshProfileFile();
+ if (isNewProfileFile(cachedOrRefreshedProfileFile)) {
+ currentProfileFile = cachedOrRefreshedProfileFile;
+ handleProfileFileReload(cachedOrRefreshedProfileFile);
+ }
+
return credentialsProvider.resolveCredentials();
}
+ private void handleProfileFileReload(ProfileFile profileFile) {
+ credentialsProvider = createCredentialsProvider(profileFile, profileName);
+ }
+
+ private ProfileFile refreshProfileFile() {
+ return profileFileSupplier.get();
+ }
+
+ private boolean isNewProfileFile(ProfileFile profileFile) {
+ return !Objects.equals(currentProfileFile, profileFile);
+ }
+
@Override
public String toString() {
return ToString.builder("ProfileCredentialsProvider")
.add("profileName", profileName)
- .add("profileFile", profileFile)
+ .add("profileFile", currentProfileFile)
.build();
}
@Override
public void close() {
+ profileFileSupplier.close();
// The delegate credentials provider may be closeable (eg. if it's an STS credentials provider). In this case, we should
// clean it up when this credentials provider is closed.
IoUtils.closeIfCloseable(credentialsProvider, null);
@@ -150,6 +159,17 @@ public Builder toBuilder() {
return new BuilderImpl(this);
}
+ private AwsCredentialsProvider createCredentialsProvider(ProfileFile profileFile, String profileName) {
+ // Load the profile and credentials provider
+ return profileFile.profile(profileName)
+ .flatMap(p -> new ProfileCredentialsUtils(profileFile, p, profileFile::profile).credentialsProvider())
+ .orElseThrow(() -> {
+ String errorMessage = String.format("Profile file contained no credentials for " +
+ "profile '%s': %s", profileName, profileFile);
+ return SdkClientException.builder().message(errorMessage).build();
+ });
+ }
+
/**
* A builder for creating a custom {@link ProfileCredentialsProvider}.
*/
@@ -158,6 +178,7 @@ public interface Builder extends CopyableBuilder profileFile);
+ /**
+ * Define the mechanism for loading profile files.
+ *
+ * @param profileFileSupplier Supplier interface for generating a ProfileFile instance.
+ * @see #profileFile(ProfileFile)
+ */
+ Builder profileFile(ProfileFileSupplier profileFileSupplier);
+
/**
* Define the name of the profile that should be used by this credentials provider. By default, the value in
* {@link ProfileFileSystemSetting#AWS_PROFILE} is used.
@@ -176,11 +205,12 @@ public interface Builder extends CopyableBuilder defaultProfileFileLoader = ProfileFile::defaultProfileFile;
@@ -188,15 +218,14 @@ static final class BuilderImpl implements Builder {
}
BuilderImpl(ProfileCredentialsProvider provider) {
- this.profileFile = provider.profileFile;
this.profileName = provider.profileName;
this.defaultProfileFileLoader = provider.defaultProfileFileLoader;
+ this.profileFileSupplier = provider.profileFileSupplier;
}
@Override
public Builder profileFile(ProfileFile profileFile) {
- this.profileFile = profileFile;
- return this;
+ return profileFile(ProfileFileSupplier.wrapIntoNullableSupplier(profileFile));
}
public void setProfileFile(ProfileFile profileFile) {
@@ -208,6 +237,16 @@ public Builder profileFile(Consumer profileFile) {
return profileFile(ProfileFile.builder().applyMutation(profileFile).build());
}
+ @Override
+ public Builder profileFile(ProfileFileSupplier profileFileSupplier) {
+ this.profileFileSupplier = profileFileSupplier;
+ return this;
+ }
+
+ public void setProfileFile(ProfileFileSupplier supplier) {
+ profileFile(supplier);
+ }
+
@Override
public Builder profileName(String profileName) {
this.profileName = profileName;
@@ -225,8 +264,9 @@ public ProfileCredentialsProvider build() {
/**
* Override the default configuration file to be used when the customer does not explicitly set
- * profileName(profileName);
- * {@link #profileFile(ProfileFile)}. Use of this method is only useful for testing the default behavior.
+ * profileFile(ProfileFile) or profileFileSupplier(supplier);
+ * {@link #profileFile(ProfileFile)}. Use of this method is
+ * only useful for testing the default behavior.
*/
@SdkTestInternalApi
Builder defaultProfileFileLoader(Supplier defaultProfileFileLoader) {
@@ -234,4 +274,5 @@ Builder defaultProfileFileLoader(Supplier defaultProfileFileLoader)
return this;
}
}
+
}
diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProviderTest.java
index d6f261779206..7895e520f02e 100644
--- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProviderTest.java
+++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProviderTest.java
@@ -18,9 +18,18 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import com.google.common.jimfs.Jimfs;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import software.amazon.awssdk.core.exception.SdkClientException;
import software.amazon.awssdk.profiles.ProfileFile;
+import software.amazon.awssdk.profiles.ProfileFileSupplier;
import software.amazon.awssdk.profiles.ProfileProperty;
import software.amazon.awssdk.utils.StringInputStream;
@@ -28,18 +37,37 @@
* Verify functionality of {@link ProfileCredentialsProvider}.
*/
public class ProfileCredentialsProviderTest {
+
+ private static FileSystem jimfs;
+ private static Path testDirectory;
+
+ @BeforeAll
+ public static void setup() {
+ jimfs = Jimfs.newFileSystem();
+ testDirectory = jimfs.getPath("test");
+ }
+
+ @AfterAll
+ public static void tearDown() {
+ try {
+ jimfs.close();
+ } catch (IOException e) {
+ // no-op
+ }
+ }
+
@Test
- public void missingCredentialsFileThrowsExceptionInGetCredentials() {
+ void missingCredentialsFileThrowsExceptionInResolveCredentials() {
ProfileCredentialsProvider provider =
- new ProfileCredentialsProvider.BuilderImpl()
- .defaultProfileFileLoader(() -> { throw new IllegalStateException(); })
- .build();
+ new ProfileCredentialsProvider.BuilderImpl()
+ .defaultProfileFileLoader(() -> { throw new IllegalStateException(); })
+ .build();
assertThatThrownBy(provider::resolveCredentials).isInstanceOf(IllegalStateException.class);
}
@Test
- public void missingProfileFileThrowsExceptionInGetCredentials() {
+ void missingProfileFileThrowsExceptionInResolveCredentials() {
ProfileCredentialsProvider provider =
new ProfileCredentialsProvider.BuilderImpl()
.defaultProfileFileLoader(() -> ProfileFile.builder()
@@ -52,35 +80,35 @@ public void missingProfileFileThrowsExceptionInGetCredentials() {
}
@Test
- public void missingProfileThrowsExceptionInGetCredentials() {
+ void missingProfileThrowsExceptionInResolveCredentials() {
ProfileFile file = profileFile("[default]\n"
- + "aws_access_key_id = defaultAccessKey\n"
- + "aws_secret_access_key = defaultSecretAccessKey");
+ + "aws_access_key_id = defaultAccessKey\n"
+ + "aws_secret_access_key = defaultSecretAccessKey");
ProfileCredentialsProvider provider =
- ProfileCredentialsProvider.builder().profileFile(file).profileName("foo").build();
+ ProfileCredentialsProvider.builder().profileFile(file).profileName("foo").build();
assertThatThrownBy(provider::resolveCredentials).isInstanceOf(SdkClientException.class);
}
@Test
- public void profileWithoutCredentialsThrowsExceptionInGetCredentials() {
+ void profileWithoutCredentialsThrowsExceptionInResolveCredentials() {
ProfileFile file = profileFile("[default]");
ProfileCredentialsProvider provider =
- ProfileCredentialsProvider.builder().profileFile(file).profileName("default").build();
+ ProfileCredentialsProvider.builder().profileFile(file).profileName("default").build();
assertThatThrownBy(provider::resolveCredentials).isInstanceOf(SdkClientException.class);
}
@Test
- public void presentProfileReturnsCredentials() {
+ void presentProfileReturnsCredentials() {
ProfileFile file = profileFile("[default]\n"
+ "aws_access_key_id = defaultAccessKey\n"
+ "aws_secret_access_key = defaultSecretAccessKey");
ProfileCredentialsProvider provider =
- ProfileCredentialsProvider.builder().profileFile(file).profileName("default").build();
+ ProfileCredentialsProvider.builder().profileFile(file).profileName("default").build();
assertThat(provider.resolveCredentials()).satisfies(credentials -> {
assertThat(credentials.accessKeyId()).isEqualTo("defaultAccessKey");
@@ -89,7 +117,7 @@ public void presentProfileReturnsCredentials() {
}
@Test
- public void profileWithWebIdentityToken() {
+ void profileWithWebIdentityToken() {
String token = "/User/home/test";
ProfileFile file = profileFile("[default]\n"
@@ -100,7 +128,168 @@ public void profileWithWebIdentityToken() {
assertThat(file.profile("default").get().property(ProfileProperty.WEB_IDENTITY_TOKEN_FILE).get()).isEqualTo(token);
}
+ @Test
+ void resolveCredentials_missingProfileFileCausesExceptionInMethod_throwsException() {
+ ProfileCredentialsProvider.BuilderImpl builder = new ProfileCredentialsProvider.BuilderImpl();
+ builder.defaultProfileFileLoader(() -> ProfileFile.builder()
+ .content(new StringInputStream(""))
+ .type(ProfileFile.Type.CONFIGURATION)
+ .build());
+ ProfileCredentialsProvider provider = builder.build();
+
+ assertThatThrownBy(provider::resolveCredentials).isInstanceOf(SdkClientException.class);
+ }
+
+ @Test
+ void resolveCredentials_missingProfile_throwsException() {
+ ProfileFile file = profileFile("[default]\n"
+ + "aws_access_key_id = defaultAccessKey\n"
+ + "aws_secret_access_key = defaultSecretAccessKey");
+
+ try (ProfileCredentialsProvider provider =
+ ProfileCredentialsProvider.builder()
+ .profileFile(() -> file)
+ .profileName("foo")
+ .build()) {
+
+ assertThatThrownBy(provider::resolveCredentials).isInstanceOf(SdkClientException.class);
+ }
+ }
+
+ @Test
+ void resolveCredentials_profileWithoutCredentials_throwsException() {
+ ProfileFile file = profileFile("[default]");
+
+ ProfileCredentialsProvider provider =
+ ProfileCredentialsProvider.builder()
+ .profileFile(() -> file)
+ .profileName("default")
+ .build();
+
+ assertThatThrownBy(provider::resolveCredentials).isInstanceOf(SdkClientException.class);
+ }
+
+ @Test
+ void resolveCredentials_presentProfile_returnsCredentials() {
+ ProfileFile file = profileFile("[default]\n"
+ + "aws_access_key_id = defaultAccessKey\n"
+ + "aws_secret_access_key = defaultSecretAccessKey");
+
+ ProfileCredentialsProvider provider =
+ ProfileCredentialsProvider.builder()
+ .profileFile(() -> file)
+ .profileName("default")
+ .build();
+
+ assertThat(provider.resolveCredentials()).satisfies(credentials -> {
+ assertThat(credentials.accessKeyId()).isEqualTo("defaultAccessKey");
+ assertThat(credentials.secretAccessKey()).isEqualTo("defaultSecretAccessKey");
+ });
+ }
+
+ @Test
+ void resolveCredentials_presentProfileFileSupplier_returnsCredentials() {
+ Path path = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ ProfileCredentialsProvider provider =
+ ProfileCredentialsProvider.builder()
+ .profileFile(ProfileFileSupplier.reloadWhenModified(path))
+ .profileName("default")
+ .build();
+
+ assertThat(provider.resolveCredentials()).satisfies(credentials -> {
+ assertThat(credentials.accessKeyId()).isEqualTo("defaultAccessKey");
+ assertThat(credentials.secretAccessKey()).isEqualTo("defaultSecretAccessKey");
+ });
+ }
+
+ @Test
+ void create_noProfileName_returnsProfileCredentialsProviderToResolveWithDefaults() {
+ ProfileCredentialsProvider provider = ProfileCredentialsProvider.create();
+ String toString = provider.toString();
+
+ assertThat(toString).satisfies(s -> assertThat(s).contains("profileName=default"));
+ }
+
+ @Test
+ void create_givenProfileName_returnsProfileCredentialsProviderToResolveForGivenName() {
+ ProfileCredentialsProvider provider = ProfileCredentialsProvider.create("override");
+ String toString = provider.toString();
+
+ assertThat(toString).satisfies(s -> assertThat(s).contains("profileName=override"));
+ }
+
+ @Test
+ void toString_anyProfileCredentialsProviderAfterResolvingCredentialsFileDoesExists_returnsProfileFile() {
+ ProfileCredentialsProvider provider = new ProfileCredentialsProvider.BuilderImpl()
+ .defaultProfileFileLoader(() -> profileFile("[default]\naws_access_key_id = %s\naws_secret_access_key = %s\n"))
+ .build();
+ provider.resolveCredentials();
+ String toString = provider.toString();
+
+ assertThat(toString).satisfies(s -> {
+ assertThat(s).contains("profileName=default");
+ assertThat(s).contains("profileFile=");
+ });
+ }
+
+ @Test
+ void toString_anyProfileCredentialsProviderAfterResolvingCredentialsFileDoesNotExist_throwsException() {
+ ProfileCredentialsProvider provider = new ProfileCredentialsProvider.BuilderImpl()
+ .defaultProfileFileLoader(() -> ProfileFile.builder()
+ .content(new StringInputStream(""))
+ .type(ProfileFile.Type.CONFIGURATION)
+ .build())
+ .build();
+
+ assertThatThrownBy(provider::resolveCredentials).isInstanceOf(SdkClientException.class);
+ }
+
+ @Test
+ void toString_anyProfileCredentialsProviderBeforeResolvingCredentials_doesNotReturnProfileFile() {
+ ProfileCredentialsProvider provider =
+ new ProfileCredentialsProvider.BuilderImpl()
+ .defaultProfileFileLoader(() -> ProfileFile.builder()
+ .content(new StringInputStream(""))
+ .type(ProfileFile.Type.CONFIGURATION)
+ .build())
+ .build();
+
+ String toString = provider.toString();
+
+ assertThat(toString).satisfies(s -> {
+ assertThat(s).contains("profileName");
+ assertThat(s).doesNotContain("profileFile");
+ });
+ }
+
+ @Test
+ void toBuilder_fromCredentialsProvider_returnsBuilderCapableOfProducingSimilarProvider() {
+ ProfileCredentialsProvider provider1 = ProfileCredentialsProvider.create("override");
+ ProfileCredentialsProvider provider2 = provider1.toBuilder().build();
+
+ String provider1ToString = provider1.toString();
+ String provider2ToString = provider2.toString();
+ assertThat(provider1ToString).isEqualTo(provider2ToString);
+ }
+
private ProfileFile profileFile(String string) {
return ProfileFile.builder().content(new StringInputStream(string)).type(ProfileFile.Type.CONFIGURATION).build();
}
+
+ private Path generateTestFile(String contents, String filename) {
+ try {
+ Files.createDirectories(testDirectory);
+ return Files.write(testDirectory.resolve(filename), contents.getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private Path generateTestCredentialsFile(String accessKeyId, String secretAccessKey) {
+ String contents = String.format("[default]\naws_access_key_id = %s\naws_secret_access_key = %s\n",
+ accessKeyId, secretAccessKey);
+ return generateTestFile(contents, "credentials.txt");
+ }
+
}
diff --git a/core/profiles/pom.xml b/core/profiles/pom.xml
index 8ec4ba040297..f5f4e2962816 100644
--- a/core/profiles/pom.xml
+++ b/core/profiles/pom.xml
@@ -55,6 +55,12 @@
assertj-core
test
+
+ com.google.jimfs
+ jimfs
+ ${jimfs.version}
+ test
+
diff --git a/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFile.java b/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFile.java
index 064397c90c6c..b12370b1cebd 100644
--- a/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFile.java
+++ b/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFile.java
@@ -273,7 +273,7 @@ public void setContent(InputStream contentStream) {
@Override
public Builder content(Path contentLocation) {
Validate.paramNotNull(contentLocation, "profileLocation");
- Validate.validState(contentLocation.toFile().exists(), "Profile file '%s' does not exist.", contentLocation);
+ Validate.validState(Files.exists(contentLocation), "Profile file '%s' does not exist.", contentLocation);
this.content = null;
this.contentLocation = contentLocation;
diff --git a/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFileSupplier.java b/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFileSupplier.java
new file mode 100644
index 000000000000..ea8e2ffbd8de
--- /dev/null
+++ b/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFileSupplier.java
@@ -0,0 +1,108 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.profiles;
+
+import java.nio.file.Path;
+import java.util.Objects;
+import java.util.function.Supplier;
+import software.amazon.awssdk.annotations.SdkPublicApi;
+import software.amazon.awssdk.profiles.internal.ProfileFileRefresher;
+import software.amazon.awssdk.utils.SdkAutoCloseable;
+
+/**
+ * Encapsulates the logic for supplying either a single or multiple ProfileFile instances.
+ *
+ * Each call to the {@link #get()} method will result in either a new or previously supplied profile based on the
+ * implementation's rules.
+ */
+@SdkPublicApi
+@FunctionalInterface
+public interface ProfileFileSupplier extends Supplier, SdkAutoCloseable {
+
+ @Override
+ default void close() {
+ }
+
+ /**
+ * Creates a {@link ProfileFileSupplier} capable of producing multiple profile objects from a file. This supplier will
+ * return a new ProfileFile instance only once the disk file has been modified. Multiple calls to the supplier while the
+ * disk file is unchanged will return the same object.
+ *
+ * @param path Path to the file to read from.
+ * @return Implementation of {@link ProfileFileSupplier} that is capable of supplying a new profile when the file
+ * has been modified.
+ */
+ static ProfileFileSupplier reloadWhenModified(Path path) {
+ return new ProfileFileSupplier() {
+
+ final ProfileFile.Builder builder = ProfileFile.builder()
+ .content(path)
+ .type(ProfileFile.Type.CREDENTIALS);
+
+ final ProfileFileRefresher refresher = ProfileFileRefresher.builder()
+ .profileFile(builder::build)
+ .profileFilePath(path)
+ .build();
+
+ @Override
+ public ProfileFile get() {
+ return refresher.refreshIfStale();
+ }
+
+ @Override
+ public void close() {
+ refresher.close();
+ }
+ };
+ }
+
+ /**
+ * Creates a {@link ProfileFileSupplier} capable of producing a single profile object from a file.
+ *
+ * @param path Path to the file to read from.
+ * @return Implementation of {@link ProfileFileSupplier} that is capable of supplying a single profile.
+ */
+ static ProfileFileSupplier fixedProfileFile(Path path) {
+ ProfileFile profileFile = ProfileFile.builder()
+ .content(path)
+ .type(ProfileFile.Type.CREDENTIALS)
+ .build();
+
+ return () -> profileFile;
+ }
+
+ /**
+ * Creates a {@link ProfileFileSupplier} that produces an existing profile.
+ *
+ * @param profileFile Profile object to supply.
+ * @return Implementation of {@link ProfileFileSupplier} that is capable of supplying a single profile.
+ */
+ static ProfileFileSupplier fixedProfileFile(ProfileFile profileFile) {
+ return () -> profileFile;
+ }
+
+ /**
+ * creates a {@link ProfileFileSupplier} that produces an existing non-null profile. If the given profile
+ * is null, then the created supplier will also be null.
+ *
+ * @param profileFile Profile object to supply.
+ * @return Implementation of {@link ProfileFileSupplier} that is capable of supplying a single profile.
+ */
+ static ProfileFileSupplier wrapIntoNullableSupplier(ProfileFile profileFile) {
+ return Objects.nonNull(profileFile) ? () -> profileFile : null;
+ }
+
+}
diff --git a/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFileSupplierBuilder.java b/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFileSupplierBuilder.java
new file mode 100644
index 000000000000..6bc55cc33130
--- /dev/null
+++ b/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileFileSupplierBuilder.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.profiles;
+
+import java.nio.file.Path;
+import java.time.Clock;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Supplier;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.profiles.internal.ProfileFileRefresher;
+
+@SdkInternalApi
+final class ProfileFileSupplierBuilder {
+
+ private boolean reloadingSupplier = false;
+ private Supplier profileFile;
+ private Path profileFilePath;
+ private Clock clock;
+ private Consumer onProfileFileLoad;
+
+ public ProfileFileSupplierBuilder reloadWhenModified(Path path) {
+ ProfileFile.Builder builder = ProfileFile.builder()
+ .content(path)
+ .type(ProfileFile.Type.CREDENTIALS);
+ this.profileFile = builder::build;
+ this.profileFilePath = path;
+ this.reloadingSupplier = true;
+ return this;
+ }
+
+ public ProfileFileSupplierBuilder fixedProfileFile(Path path) {
+ return fixedProfileFile(ProfileFile.builder()
+ .content(path)
+ .type(ProfileFile.Type.CREDENTIALS)
+ .build());
+ }
+
+ public ProfileFileSupplierBuilder fixedProfileFile(ProfileFile profileFile) {
+ this.profileFile = () -> profileFile;
+ this.profileFilePath = null;
+ this.reloadingSupplier = false;
+ return this;
+ }
+
+ public ProfileFileSupplierBuilder onProfileFileLoad(Consumer action) {
+ this.onProfileFileLoad = action;
+ return this;
+ }
+
+ public ProfileFileSupplierBuilder clock(Clock clock) {
+ this.clock = clock;
+ return this;
+ }
+
+ public ProfileFileSupplier build() {
+ return fromBuilder(this);
+ }
+
+ /**
+ * Completes {@link ProfileFileSupplier} build.
+ * @param builder Object to complete build.
+ * @return Implementation of {@link ProfileFileSupplier}.
+ */
+ static ProfileFileSupplier fromBuilder(ProfileFileSupplierBuilder builder) {
+ if (builder.reloadingSupplier) {
+
+ ProfileFileRefresher.Builder refresherBuilder = ProfileFileRefresher.builder()
+ .profileFile(builder.profileFile)
+ .profileFilePath(builder.profileFilePath);
+
+ if (Objects.nonNull(builder.clock)) {
+ refresherBuilder.clock(builder.clock);
+ }
+ if (Objects.nonNull(builder.onProfileFileLoad)) {
+ refresherBuilder.onProfileFileReload(builder.onProfileFileLoad);
+ }
+
+ ProfileFileRefresher refresher = refresherBuilder.build();
+
+ return new ProfileFileSupplier() {
+ @Override
+ public ProfileFile get() {
+ return refresher.refreshIfStale();
+ }
+
+ @Override
+ public void close() {
+ refresher.close();
+ }
+ };
+ }
+
+ return builder.profileFile::get;
+ }
+}
diff --git a/core/profiles/src/main/java/software/amazon/awssdk/profiles/internal/ProfileFileRefresher.java b/core/profiles/src/main/java/software/amazon/awssdk/profiles/internal/ProfileFileRefresher.java
new file mode 100644
index 000000000000..4740a36577c3
--- /dev/null
+++ b/core/profiles/src/main/java/software/amazon/awssdk/profiles/internal/ProfileFileRefresher.java
@@ -0,0 +1,267 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.profiles.internal;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import software.amazon.awssdk.annotations.SdkInternalApi;
+import software.amazon.awssdk.annotations.SdkTestInternalApi;
+import software.amazon.awssdk.profiles.ProfileFile;
+import software.amazon.awssdk.utils.SdkAutoCloseable;
+import software.amazon.awssdk.utils.cache.CachedSupplier;
+import software.amazon.awssdk.utils.cache.RefreshResult;
+
+/**
+ * Class used for caching and reloading ProfileFile objects from a Supplier.
+ */
+@SdkInternalApi
+public final class ProfileFileRefresher implements SdkAutoCloseable {
+
+ private static final ProfileFileRefreshRecord EMPTY_REFRESH_RECORD = ProfileFileRefreshRecord.builder()
+ .refreshTime(Instant.MIN)
+ .build();
+ private final CachedSupplier profileFileCache;
+ private volatile ProfileFileRefreshRecord currentRefreshRecord;
+ private final Supplier profileFile;
+ private final Path profileFilePath;
+ private final Function exceptionHandler;
+ private final Consumer onProfileFileReload;
+ private final Clock clock;
+
+ private ProfileFileRefresher(Builder builder) {
+ this.exceptionHandler = builder.exceptionHandler;
+ this.clock = builder.clock;
+ this.profileFile = builder.profileFile;
+ this.profileFilePath = builder.profileFilePath;
+ this.onProfileFileReload = builder.onProfileFileReload;
+ this.profileFileCache = CachedSupplier.builder(this::refreshResult)
+ .clock(this.clock)
+ .build();
+ this.currentRefreshRecord = EMPTY_REFRESH_RECORD;
+ }
+
+ /**
+ * Builder method to construct instance of ProfileFileRefresher.
+ */
+ public static ProfileFileRefresher.Builder builder() {
+ return new ProfileFileRefresher.Builder();
+ }
+
+ /**
+ * Retrieves the cache value or refreshes it if stale.
+ */
+ public ProfileFile refreshIfStale() {
+ ProfileFileRefreshRecord cachedOrRefreshedRecord = profileFileCache.get();
+ ProfileFile cachedOrRefreshedProfileFile = cachedOrRefreshedRecord.profileFile;
+ if (isNewProfileFile(cachedOrRefreshedProfileFile)) {
+ currentRefreshRecord = cachedOrRefreshedRecord;
+ }
+
+ return cachedOrRefreshedProfileFile;
+ }
+
+ @Override
+ public void close() {
+ profileFileCache.close();
+ }
+
+ private RefreshResult refreshResult() {
+ try {
+ return reloadAsRefreshResultIfStale();
+ } catch (RuntimeException exception) {
+ Instant now = Instant.now();
+ Instant staleTime = now;
+ ProfileFile exceptionProfileFile = exceptionHandler.apply(exception);
+ ProfileFileRefreshRecord refreshRecord = ProfileFileRefreshRecord.builder()
+ .profileFile(exceptionProfileFile)
+ .refreshTime(now)
+ .build();
+
+ return wrapIntoRefreshResult(refreshRecord, staleTime);
+ }
+ }
+
+ private RefreshResult reloadAsRefreshResultIfStale() {
+ Instant now = clock.instant();
+ Instant staleTime = now;
+ ProfileFileRefreshRecord refreshRecord;
+
+ if (canReloadProfileFile() || hasNotBeenPreviouslyLoaded()) {
+ ProfileFile reloadedProfileFile = reload(profileFile, onProfileFileReload);
+ refreshRecord = ProfileFileRefreshRecord.builder()
+ .profileFile(reloadedProfileFile)
+ .refreshTime(now)
+ .build();
+ } else {
+ refreshRecord = currentRefreshRecord;
+ }
+
+ return wrapIntoRefreshResult(refreshRecord, staleTime);
+ }
+
+ private RefreshResult wrapIntoRefreshResult(T value, Instant staleTime) {
+ return RefreshResult.builder(value)
+ .staleTime(staleTime)
+ .build();
+ }
+
+ private static ProfileFile reload(Supplier supplier) {
+ return supplier.get();
+ }
+
+ private static ProfileFile reload(Supplier supplier, Consumer consumer) {
+ ProfileFile reloadedProfileFile = reload(supplier);
+ consumer.accept(reloadedProfileFile);
+
+ return reloadedProfileFile;
+ }
+
+ private boolean isNewProfileFile(ProfileFile profileFile) {
+ return !Objects.equals(currentRefreshRecord.profileFile, profileFile);
+ }
+
+ private boolean canReloadProfileFile() {
+ if (Objects.isNull(profileFilePath)) {
+ return false;
+ }
+
+ try {
+ Instant lastModifiedInstant = Files.getLastModifiedTime(profileFilePath).toInstant();
+ return currentRefreshRecord.refreshTime.isBefore(lastModifiedInstant);
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ private boolean hasNotBeenPreviouslyLoaded() {
+ return currentRefreshRecord == EMPTY_REFRESH_RECORD;
+ }
+
+
+ public static final class Builder {
+
+ private Supplier profileFile;
+ private Path profileFilePath;
+ private Consumer onProfileFileReload = p -> { };
+ private Function exceptionHandler;
+ private Clock clock = Clock.systemUTC();
+
+ private Builder() {
+ }
+
+ public Builder profileFile(Supplier profileFile) {
+ this.profileFile = profileFile;
+ return this;
+ }
+
+ public Builder profileFilePath(Path profileFilePath) {
+ this.profileFilePath = profileFilePath;
+ return this;
+ }
+
+ /**
+ * Sets a clock for managing stale and prefetch durations.
+ */
+ @SdkTestInternalApi
+ public Builder clock(Clock clock) {
+ this.clock = clock;
+ return this;
+ }
+
+ /**
+ * @param exceptionHandler Handler which takes action when a Runtime exception occurs while loading a profile file.
+ * Handler can return a previously stored profile file or throw back the exception.
+ */
+ public Builder exceptionHandler(Function exceptionHandler) {
+ this.exceptionHandler = exceptionHandler;
+ return this;
+ }
+
+ /**
+ * Sets a custom action to perform when a profile file is reloaded. This action is executed when both the cache is stale
+ * and the disk file associated with the profile file has been modified since the last load.
+ *
+ * @param consumer The action to perform.
+ */
+ public Builder onProfileFileReload(Consumer consumer) {
+ this.onProfileFileReload = consumer;
+ return this;
+ }
+
+ public ProfileFileRefresher build() {
+ return new ProfileFileRefresher(this);
+ }
+ }
+
+ /**
+ * Class used to encapsulate additional refresh information.
+ */
+ public static final class ProfileFileRefreshRecord {
+ private final Instant refreshTime;
+ private final ProfileFile profileFile;
+
+ private ProfileFileRefreshRecord(Builder builder) {
+ this.profileFile = builder.profileFile;
+ this.refreshTime = builder.refreshTime;
+ }
+
+ /**
+ * The refreshed ProfileFile instance.
+ */
+ public ProfileFile profileFile() {
+ return profileFile;
+ }
+
+ /**
+ * The time at which the RefreshResult was created.
+ */
+ public Instant refreshTime() {
+ return refreshTime;
+ }
+
+ static Builder builder() {
+ return new Builder();
+ }
+
+ private static final class Builder {
+ private Instant refreshTime;
+ private ProfileFile profileFile;
+
+ Builder refreshTime(Instant refreshTime) {
+ this.refreshTime = refreshTime;
+ return this;
+ }
+
+ Builder profileFile(ProfileFile profileFile) {
+ this.profileFile = profileFile;
+ return this;
+ }
+
+ ProfileFileRefreshRecord build() {
+ return new ProfileFileRefreshRecord(this);
+ }
+ }
+ }
+
+}
diff --git a/core/profiles/src/test/java/software/amazon/awssdk/profiles/ProfileFileSupplierTest.java b/core/profiles/src/test/java/software/amazon/awssdk/profiles/ProfileFileSupplierTest.java
new file mode 100644
index 000000000000..370ddb955a80
--- /dev/null
+++ b/core/profiles/src/test/java/software/amazon/awssdk/profiles/ProfileFileSupplierTest.java
@@ -0,0 +1,389 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.profiles;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.google.common.jimfs.Jimfs;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.temporal.TemporalAmount;
+import java.util.Optional;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.profiles.ProfileFileSupplierBuilder;
+
+class ProfileFileSupplierTest {
+
+ private static FileSystem jimfs;
+ private static Path testDirectory;
+
+ @BeforeAll
+ public static void setup() {
+ jimfs = Jimfs.newFileSystem();
+ testDirectory = jimfs.getPath("test");
+ }
+
+ @AfterAll
+ public static void tearDown() {
+ try {
+ jimfs.close();
+ } catch (IOException e) {
+ // no-op
+ }
+ }
+
+ @Test
+ void get_profileFileFixed_doesNotReloadProfileFile() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ ProfileFileSupplier supplier = builder()
+ .fixedProfileFile(credentialsFilePath)
+ .build();
+
+ ProfileFile file1 = supplier.get();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+
+ ProfileFile file2 = supplier.get();
+
+ assertThat(file2).isSameAs(file1);
+ }
+
+ @Test
+ void get_profileModifiedWithinJitterPeriod_doesNotReloadCredentials() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ Duration durationWithinJitter = Duration.ofMillis(10);
+ ProfileFileSupplier supplier = builderWithClock(clock)
+ .reloadWhenModified(credentialsFilePath)
+ .build();
+
+ ProfileFile file1 = supplier.get();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plus(durationWithinJitter));
+
+ clock.tickForward(durationWithinJitter);
+ ProfileFile file2 = supplier.get();
+
+ assertThat(file2).isSameAs(file1);
+ }
+
+ @Test
+ void get_profileModifiedOutsideJitterPeriod_reloadsCredentials() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+
+ ProfileFileSupplier supplier = builderWithClock(clock)
+ .reloadWhenModified(credentialsFilePath)
+ .build();
+
+ Duration durationOutsideJitter = Duration.ofSeconds(1);
+
+ supplier.get();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plus(durationOutsideJitter));
+
+ clock.tickForward(durationOutsideJitter);
+
+ Optional fileOptional = supplier.get().profile("default");
+ assertThat(fileOptional).isPresent();
+
+ assertThat(fileOptional.get()).satisfies(profile -> {
+ Optional awsAccessKeyIdOptional = profile.property("aws_access_key_id");
+ assertThat(awsAccessKeyIdOptional).isPresent();
+ String awsAccessKeyId = awsAccessKeyIdOptional.get();
+ assertThat(awsAccessKeyId).isEqualTo("modifiedAccessKey");
+
+ Optional awsSecretAccessKeyOptional = profile.property("aws_secret_access_key");
+ assertThat(awsSecretAccessKeyOptional).isPresent();
+ String awsSecretAccessKey = awsSecretAccessKeyOptional.get();
+ assertThat(awsSecretAccessKey).isEqualTo("modifiedSecretAccessKey");
+ });
+ }
+
+ @Test
+ void get_profileModified_reloadsProfileFile() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ ProfileFileSupplier supplier = builderWithClock(clock)
+ .reloadWhenModified(credentialsFilePath)
+ .build();
+
+ Duration duration = Duration.ofSeconds(10);
+ ProfileFile file1 = supplier.get();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(duration);
+ ProfileFile file2 = supplier.get();
+
+ assertThat(file2).isNotSameAs(file1);
+ }
+
+ @Test
+ void get_profileModifiedOnceButRefreshedMultipleTimes_reloadsProfileFileOnce() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ ProfileFileSupplier supplier = builderWithClock(clock)
+ .reloadWhenModified(credentialsFilePath)
+ .build();
+ ProfileFile file1 = supplier.get();
+
+ clock.tickForward(Duration.ofSeconds(5));
+ ProfileFile file2 = supplier.get();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(Duration.ofSeconds(5));
+ ProfileFile file3 = supplier.get();
+
+ assertThat(file2).isSameAs(file1);
+ assertThat(file3).isNotSameAs(file2);
+ }
+
+ @Test
+ void get_profileModifiedMultipleTimes_reloadsProfileFileOncePerChange() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ ProfileFileSupplier supplier = builderWithClock(clock)
+ .reloadWhenModified(credentialsFilePath)
+ .build();
+ Duration duration = Duration.ofSeconds(5);
+
+ ProfileFile file1 = supplier.get();
+
+ clock.tickForward(duration);
+ ProfileFile file2 = supplier.get();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(duration);
+ ProfileFile file3 = supplier.get();
+
+ generateTestCredentialsFile("updatedAccessKey", "updatedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(duration);
+ ProfileFile file4 = supplier.get();
+
+ clock.tickForward(duration);
+ ProfileFile file5 = supplier.get();
+
+ assertThat(file2).isSameAs(file1);
+ assertThat(file3).isNotSameAs(file2);
+ assertThat(file4).isNotSameAs(file3);
+ assertThat(file5).isSameAs(file4);
+ }
+
+ @Test
+ void get_supplierBuiltByReloadWhenModified_loadsProfileFile() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ ProfileFileSupplier supplier = ProfileFileSupplier.reloadWhenModified(credentialsFilePath);
+ ProfileFile file = supplier.get();
+
+ Optional profileOptional = file.profile("default");
+ assertThat(profileOptional).isPresent();
+
+ assertThat(profileOptional.get()).satisfies(profile -> {
+ Optional awsAccessKeyIdOptional = profile.property("aws_access_key_id");
+ assertThat(awsAccessKeyIdOptional).isPresent();
+ String awsAccessKeyId = awsAccessKeyIdOptional.get();
+ assertThat(awsAccessKeyId).isEqualTo("defaultAccessKey");
+
+ Optional awsSecretAccessKeyOptional = profile.property("aws_secret_access_key");
+ assertThat(awsSecretAccessKeyOptional).isPresent();
+ String awsSecretAccessKey = awsSecretAccessKeyOptional.get();
+ assertThat(awsSecretAccessKey).isEqualTo("defaultSecretAccessKey");
+ });
+ }
+
+ @Test
+ void get_supplierBuiltByFixedProfileFilePath_loadsProfileFile() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ ProfileFileSupplier supplier = ProfileFileSupplier.fixedProfileFile(credentialsFilePath);
+ ProfileFile file = supplier.get();
+
+ Optional profileOptional = file.profile("default");
+ assertThat(profileOptional).isPresent();
+
+ assertThat(profileOptional.get()).satisfies(profile -> {
+ Optional awsAccessKeyIdOptional = profile.property("aws_access_key_id");
+ assertThat(awsAccessKeyIdOptional).isPresent();
+ String awsAccessKeyId = awsAccessKeyIdOptional.get();
+ assertThat(awsAccessKeyId).isEqualTo("defaultAccessKey");
+
+ Optional awsSecretAccessKeyOptional = profile.property("aws_secret_access_key");
+ assertThat(awsSecretAccessKeyOptional).isPresent();
+ String awsSecretAccessKey = awsSecretAccessKeyOptional.get();
+ assertThat(awsSecretAccessKey).isEqualTo("defaultSecretAccessKey");
+ });
+ }
+
+ @Test
+ void get_supplierBuiltByFixedProfileFileObject_returnsProfileFileInstance() {
+ ProfileFile file = ProfileFile.defaultProfileFile();
+ ProfileFileSupplier supplier = ProfileFileSupplier.fixedProfileFile(file);
+
+ assertThat(supplier.get()).isSameAs(file);
+ }
+
+ @Test
+ void wrapIntoNullableSupplier_nonNullProfileFile_returnsNonNullSupplier() {
+ ProfileFile file = ProfileFile.defaultProfileFile();
+ ProfileFileSupplier supplier = ProfileFileSupplier.wrapIntoNullableSupplier(file);
+
+ assertThat(supplier).isNotNull();
+ }
+
+ @Test
+ void wrapIntoNullableSupplier_nullProfileFile_returnsNullSupplier() {
+ ProfileFile file = null;
+ ProfileFileSupplier supplier = ProfileFileSupplier.wrapIntoNullableSupplier(file);
+
+ assertThat(supplier).isNull();
+ }
+
+ @Test
+ void fixedProfileFile_nullProfileFile_returnsNonNullSupplier() {
+ ProfileFile file = null;
+ ProfileFileSupplier supplier = ProfileFileSupplier.fixedProfileFile(file);
+
+ assertThat(supplier).isNotNull();
+ }
+
+ @Test
+ void get_givenOnLoadAction_callsActionOncePerNewProfileFile() {
+ int actualProfilesCount = 3;
+ AtomicInteger blockCount = new AtomicInteger();
+
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ ProfileFileSupplier supplier = builderWithClock(clock)
+ .reloadWhenModified(credentialsFilePath)
+ .onProfileFileLoad(f -> blockCount.incrementAndGet())
+ .build();
+ Duration duration = Duration.ofSeconds(5);
+
+ supplier.get();
+
+ clock.tickForward(duration);
+ supplier.get();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(duration);
+ supplier.get();
+
+ generateTestCredentialsFile("updatedAccessKey", "updatedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(duration);
+ supplier.get();
+
+ clock.tickForward(duration);
+ supplier.get();
+
+ assertThat(blockCount.get()).isEqualTo(actualProfilesCount);
+ }
+
+ private Path generateTestFile(String contents, String filename) {
+ try {
+ Files.createDirectories(testDirectory);
+ return Files.write(testDirectory.resolve(filename), contents.getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private Path generateTestCredentialsFile(String accessKeyId, String secretAccessKey) {
+ String contents = String.format("[default]\naws_access_key_id = %s\naws_secret_access_key = %s\n",
+ accessKeyId, secretAccessKey);
+ return generateTestFile(contents, "credentials.txt");
+ }
+
+ private void updateModificationTime(Path path, Instant instant) {
+ try {
+ Files.setLastModifiedTime(path, FileTime.from(instant));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private ProfileFileSupplierBuilder builder() {
+ return new ProfileFileSupplierBuilder();
+ }
+
+ private ProfileFileSupplierBuilder builderWithClock(Clock clock) {
+ return new ProfileFileSupplierBuilder().clock(clock);
+ }
+
+ private static final class AdjustableClock extends Clock {
+ private Instant time;
+
+ private AdjustableClock() {
+ this.time = Instant.now();
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instant instant() {
+ return time;
+ }
+
+ public void tickForward(TemporalAmount amount) {
+ time = time.plus(amount);
+ }
+
+ public void tickBackward(TemporalAmount amount) {
+ time = time.minus(amount);
+ }
+ }
+}
\ No newline at end of file
diff --git a/core/profiles/src/test/java/software/amazon/awssdk/profiles/internal/ProfileFileRefresherTest.java b/core/profiles/src/test/java/software/amazon/awssdk/profiles/internal/ProfileFileRefresherTest.java
new file mode 100644
index 000000000000..eda74dfc3bb2
--- /dev/null
+++ b/core/profiles/src/test/java/software/amazon/awssdk/profiles/internal/ProfileFileRefresherTest.java
@@ -0,0 +1,348 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License").
+ * You may not use this file except in compliance with the License.
+ * A copy of the License is located at
+ *
+ * http://aws.amazon.com/apache2.0
+ *
+ * or in the "license" file accompanying this file. This file is distributed
+ * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
+ * express or implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package software.amazon.awssdk.profiles.internal;
+
+import com.google.common.jimfs.Jimfs;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.temporal.TemporalAmount;
+import java.util.concurrent.atomic.AtomicInteger;
+import org.assertj.core.api.Assertions;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import software.amazon.awssdk.profiles.ProfileFile;
+import software.amazon.awssdk.utils.StringInputStream;
+
+public class ProfileFileRefresherTest {
+
+ private static FileSystem jimfs;
+ private static Path testDirectory;
+
+ @BeforeAll
+ public static void setup() {
+ jimfs = Jimfs.newFileSystem();
+ testDirectory = jimfs.getPath("test");
+ }
+
+ @AfterAll
+ public static void tearDown() {
+ try {
+ jimfs.close();
+ } catch (IOException e) {
+ // no-op
+ }
+ }
+
+ @Test
+ void refreshIfStale_profileModifiedNoPathSpecified_doesNotReloadProfileFile() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ try (ProfileFileRefresher refresher = refresherWithClock(clock)
+ .profileFile(() -> profileFile(credentialsFilePath))
+ .build()) {
+ Duration intervalWithinJitter = Duration.ofMillis(100);
+
+ ProfileFile file1 = refresher.refreshIfStale();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(intervalWithinJitter);
+ ProfileFile file2 = refresher.refreshIfStale();
+
+ Assertions.assertThat(file2).isSameAs(file1);
+ }
+ }
+
+ @Test
+ void refreshIfStale_profileModifiedWithinJitterPeriod_doesNotReloadProfileFile() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ try (ProfileFileRefresher refresher = refresherWithClock(clock)
+ .profileFile(() -> profileFile(credentialsFilePath))
+ .profileFilePath(credentialsFilePath)
+ .build()) {
+ Duration intervalWithinJitter = Duration.ofMillis(100);
+
+ ProfileFile file1 = refresher.refreshIfStale();
+
+ clock.tickForward(intervalWithinJitter);
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant());
+
+ ProfileFile file2 = refresher.refreshIfStale();
+
+ Assertions.assertThat(file2).isSameAs(file1);
+ }
+ }
+
+ @Test
+ void refreshIfStale_profileModifiedOutsideJitterPeriod_reloadsProfileFile() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ try (ProfileFileRefresher refresher = refresherWithClock(clock)
+ .profileFile(() -> profileFile(credentialsFilePath))
+ .profileFilePath(credentialsFilePath)
+ .build()) {
+ Duration intervalOutsideJitter = Duration.ofMillis(1_000);
+
+ ProfileFile file1 = refresher.refreshIfStale();
+
+ clock.tickForward(intervalOutsideJitter);
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant());
+
+ ProfileFile file2 = refresher.refreshIfStale();
+
+ Assertions.assertThat(file2).isNotSameAs(file1);
+ }
+ }
+
+ @Test
+ void refreshIfStale_profileModified_reloadsProfileFile() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ try (ProfileFileRefresher refresher = refresherWithClock(clock)
+ .profileFile(() -> profileFile(credentialsFilePath))
+ .profileFilePath(credentialsFilePath)
+ .build()) {
+
+ Duration refreshInterval = Duration.ofSeconds(15);
+
+ ProfileFile file1 = refresher.refreshIfStale();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(refreshInterval.plusSeconds(10));
+ ProfileFile file2 = refresher.refreshIfStale();
+
+ Assertions.assertThat(file2).isNotSameAs(file1);
+ }
+ }
+
+ @Test
+ void refreshIfStale_profileModifiedOnceButRefreshedMultipleTimes_reloadsProfileFileOnce() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ try (ProfileFileRefresher refresher = refresherWithClock(clock)
+ .profileFile(() -> profileFile(credentialsFilePath))
+ .profileFilePath(credentialsFilePath)
+ .build()) {
+ ProfileFile file1 = refresher.refreshIfStale();
+
+ clock.tickForward(Duration.ofSeconds(5));
+ ProfileFile file2 = refresher.refreshIfStale();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(Duration.ofSeconds(5));
+ ProfileFile file3 = refresher.refreshIfStale();
+
+ Assertions.assertThat(file2).isSameAs(file1);
+ Assertions.assertThat(file3).isNotSameAs(file2);
+ }
+ }
+
+ @Test
+ void refreshIfStale_profileModifiedMultipleTimes_reloadsProfileFileOncePerChange() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ try (ProfileFileRefresher refresher = refresherWithClock(clock)
+ .profileFile(() -> profileFile(credentialsFilePath))
+ .profileFilePath(credentialsFilePath)
+ .build()) {
+ Duration duration = Duration.ofSeconds(5);
+
+ ProfileFile file1 = refresher.refreshIfStale();
+
+ clock.tickForward(duration);
+ ProfileFile file2 = refresher.refreshIfStale();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(duration);
+ ProfileFile file3 = refresher.refreshIfStale();
+
+ generateTestCredentialsFile("updatedAccessKey", "updatedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(duration);
+ ProfileFile file4 = refresher.refreshIfStale();
+
+ clock.tickForward(duration);
+ ProfileFile file5 = refresher.refreshIfStale();
+
+ Assertions.assertThat(file2).isSameAs(file1);
+ Assertions.assertThat(file3).isNotSameAs(file2);
+ Assertions.assertThat(file4).isNotSameAs(file3);
+ Assertions.assertThat(file5).isSameAs(file4);
+ }
+ }
+
+ @Test
+ void refreshIfStale_givenOnReloadConsumer_callsConsumerOncePerChange() {
+ int actualRefreshOperations = 3;
+ AtomicInteger refreshOperationsCounter = new AtomicInteger();
+
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+
+ AdjustableClock clock = new AdjustableClock();
+ try (ProfileFileRefresher refresher = refresherWithClock(clock)
+ .profileFile(() -> profileFile(credentialsFilePath))
+ .profileFilePath(credentialsFilePath)
+ .onProfileFileReload(f -> refreshOperationsCounter.incrementAndGet())
+ .build()) {
+ Duration duration = Duration.ofSeconds(5);
+
+ ProfileFile file1 = refresher.refreshIfStale();
+
+ clock.tickForward(duration);
+ ProfileFile file2 = refresher.refreshIfStale();
+
+ generateTestCredentialsFile("modifiedAccessKey", "modifiedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(duration);
+ ProfileFile file3 = refresher.refreshIfStale();
+
+ generateTestCredentialsFile("updatedAccessKey", "updatedSecretAccessKey");
+ updateModificationTime(credentialsFilePath, clock.instant().plusMillis(1));
+
+ clock.tickForward(duration);
+ ProfileFile file4 = refresher.refreshIfStale();
+
+ clock.tickForward(duration);
+ ProfileFile file5 = refresher.refreshIfStale();
+
+ Assertions.assertThat(file2).isSameAs(file1);
+ Assertions.assertThat(file3).isNotSameAs(file2);
+ Assertions.assertThat(file4).isNotSameAs(file3);
+ Assertions.assertThat(file5).isSameAs(file4);
+ }
+
+ Assertions.assertThat(refreshOperationsCounter.get()).isEqualTo(actualRefreshOperations);
+ }
+
+ @Test
+ void refreshIfStale_profileDeleted_returnsProfileFileFromExceptionHandler() {
+ Path credentialsFilePath = generateTestCredentialsFile("defaultAccessKey", "defaultSecretAccessKey");
+ ProfileFile fallbackProfile = credentialFile("[test]\nx = y");
+
+ AdjustableClock clock = new AdjustableClock();
+ try (ProfileFileRefresher refresher = refresherWithClock(clock)
+ .profileFile(() -> profileFile(credentialsFilePath))
+ .profileFilePath(credentialsFilePath)
+ .exceptionHandler(e -> fallbackProfile)
+ .build()) {
+
+ Files.deleteIfExists(credentialsFilePath);
+ ProfileFile file1 = refresher.refreshIfStale();
+
+ Assertions.assertThat(file1).isSameAs(fallbackProfile);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private ProfileFile credentialFile(String credentialFile) {
+ return ProfileFile.builder()
+ .content(new StringInputStream(credentialFile))
+ .type(ProfileFile.Type.CREDENTIALS)
+ .build();
+ }
+
+ private Path generateTestFile(String contents, String filename) {
+ try {
+ Files.createDirectories(testDirectory);
+ return Files.write(testDirectory.resolve(filename), contents.getBytes(StandardCharsets.UTF_8));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private Path generateTestCredentialsFile(String accessKeyId, String secretAccessKey) {
+ String contents = String.format("[default]\naws_access_key_id = %s\naws_secret_access_key = %s\n",
+ accessKeyId, secretAccessKey);
+ return generateTestFile(contents, "credentials.txt");
+ }
+
+ private void updateModificationTime(Path path, Instant instant) {
+ try {
+ Files.setLastModifiedTime(path, FileTime.from(instant));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private ProfileFile profileFile(Path path) {
+ return ProfileFile.builder().content(path).type(ProfileFile.Type.CREDENTIALS).build();
+ }
+
+ private ProfileFileRefresher.Builder refresherWithClock(Clock clock) {
+ return ProfileFileRefresher.builder()
+ .clock(clock);
+ }
+
+ private static final class AdjustableClock extends Clock {
+ private Instant time;
+
+ private AdjustableClock() {
+ this.time = Instant.now();
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Instant instant() {
+ return time;
+ }
+
+ public void tickForward(TemporalAmount amount) {
+ time = time.plus(amount);
+ }
+
+ public void tickBackward(TemporalAmount amount) {
+ time = time.minus(amount);
+ }
+ }
+}