diff --git a/.changes/next-release/feature-AWSSDKforJavav2-688d30e.json b/.changes/next-release/feature-AWSSDKforJavav2-688d30e.json new file mode 100644 index 000000000000..7c53e085c72c --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-688d30e.json @@ -0,0 +1,6 @@ +{ + "category": "AWS SDK for Java v2", + "contributor": "", + "type": "feature", + "description": "Created ReloadingProfileCredentialsProvider to reload credentials due to disk changes" +} diff --git a/core/auth/pom.xml b/core/auth/pom.xml index 8608947cf30f..c57432ed735e 100644 --- a/core/auth/pom.xml +++ b/core/auth/pom.xml @@ -152,6 +152,12 @@ commons-lang3 test + + com.google.jimfs + jimfs + ${jimfs.version} + test + 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); + } + } +}