diff --git a/.changes/next-release/feature-AWSSDKforJavav2-994f3a1.json b/.changes/next-release/feature-AWSSDKforJavav2-994f3a1.json new file mode 100644 index 000000000000..efa94b5b5be6 --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-994f3a1.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "AWS SDK for Java v2", + "contributor": "", + "description": "Adding ec2InstanceProfileName configuration to specify IMDS instance profile for retrieving credentials." +} diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java index 9751477b9f14..0b2724f16edc 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java @@ -18,6 +18,7 @@ import static java.time.temporal.ChronoUnit.MINUTES; import static software.amazon.awssdk.utils.ComparableUtils.maximum; import static software.amazon.awssdk.utils.FunctionalUtils.invokeSafely; +import static software.amazon.awssdk.utils.StringUtils.isBlank; import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; import java.net.URI; @@ -103,6 +104,8 @@ private enum ApiVersion { private final Supplier profileFile; private final String profileName; + + private final String ec2InstanceProfileName; private final Duration staleTime; @@ -118,6 +121,13 @@ private InstanceProfileCredentialsProvider(BuilderImpl builder) { .orElseGet(() -> ProfileFileSupplier.fixedProfileFile(ProfileFile.defaultProfileFile())); this.profileName = Optional.ofNullable(builder.profileName) .orElseGet(ProfileFileSystemSetting.AWS_PROFILE::getStringValueOrThrow); + this.ec2InstanceProfileName = builder.ec2InstanceProfileName; + + if (isBlank(ec2InstanceProfileName) && ec2InstanceProfileName != null) { + throw SdkClientException.builder() + .message("ec2InstanceProfileName cannot be blank") + .build(); + } this.httpCredentialsLoader = HttpCredentialsLoader.create(PROVIDER_NAME); this.configProvider = @@ -173,6 +183,11 @@ private RefreshResult refreshCredentials() { try { LoadedCredentials credentials = httpCredentialsLoader.loadCredentials(createEndpointProvider()); + + if (apiVersion == ApiVersion.UNKNOWN) { + apiVersion = ApiVersion.EXTENDED; + } + Instant expiration = credentials.getExpiration().orElse(null); log.debug(() -> "Loaded credentials from IMDS with expiration time of " + expiration); @@ -186,19 +201,29 @@ private RefreshResult refreshCredentials() { } catch (Ec2MetadataClientException e) { if (e.statusCode() == 404) { log.debug(() -> "Resolved profile is no longer available. Resetting it and trying again."); - resolvedProfile = null; if (apiVersion == ApiVersion.UNKNOWN) { apiVersion = ApiVersion.LEGACY; return refreshCredentials(); + } else if (resolveProfileName() == null) { + // Resolved profile name is invalid, reset it and try again + resolvedProfile = null; + + profileRetryCount++; + if (profileRetryCount <= MAX_PROFILE_RETRIES) { + log.debug(() -> "Profile name not found, retrying fetching the profile name again."); + return refreshCredentials(); + } + } else { + String profileName = resolveProfileName(); + throw SdkClientException.builder() + .message(String.format("Invalid EC2 instance profile name: '%s'. " + + "Verify that the profile exists and that your instance " + + "has permission to access it. ", + profileName)) + .cause(e) + .build(); } - - profileRetryCount++; - if (profileRetryCount <= MAX_PROFILE_RETRIES) { - log.debug(() -> "Profile name not found, retrying fetching the profile name again."); - return refreshCredentials(); - } - throw SdkClientException.create(FAILED_TO_LOAD_CREDENTIALS_ERROR, e); } throw SdkClientException.create(FAILED_TO_LOAD_CREDENTIALS_ERROR, e); } catch (RuntimeException e) { @@ -327,7 +352,18 @@ private boolean isInsecureFallbackDisabled() { return configProvider.isMetadataV1Disabled(); } + private String resolveProfileName() { + return ec2InstanceProfileName != null ? + ec2InstanceProfileName : + configProvider.ec2InstanceProfileName(); + } + private String[] getSecurityCredentials(String imdsHostname, String metadataToken) { + String profileName = resolveProfileName(); + if (profileName != null) { + return new String[]{profileName}; + } + if (resolvedProfile != null) { return new String[]{resolvedProfile}; } @@ -383,6 +419,16 @@ public Builder toBuilder() { */ public interface Builder extends HttpCredentialsProvider.Builder, CopyableBuilder { + /** + * Configure the EC2 instance profile name to use for retrieving credentials. + * + *

When this is set, the provider will skip fetching the list of available instance profiles + * and use this name directly. + * + * @param ec2InstanceProfileName The EC2 instance profile name to use + */ + Builder ec2InstanceProfileName(String ec2InstanceProfileName); + /** * Configure the profile file used for loading IMDS-related configuration, like the endpoint mode (IPv4 vs IPv6). * @@ -434,6 +480,7 @@ static final class BuilderImpl implements Builder { private String asyncThreadName; private Supplier profileFile; private String profileName; + private String ec2InstanceProfileName; private Duration staleTime; private BuilderImpl() { @@ -447,6 +494,7 @@ private BuilderImpl(InstanceProfileCredentialsProvider provider) { this.asyncThreadName = provider.asyncThreadName; this.profileFile = provider.profileFile; this.profileName = provider.profileName; + this.ec2InstanceProfileName = provider.ec2InstanceProfileName; this.staleTime = provider.staleTime; } @@ -515,6 +563,17 @@ public Builder profileName(String profileName) { public void setProfileName(String profileName) { profileName(profileName); } + + @Override + public Builder ec2InstanceProfileName(String ec2InstanceProfileName) { + this.ec2InstanceProfileName = ec2InstanceProfileName; + return this; + } + + public void setEc2InstanceProfileName(String ec2InstanceProfileName) { + ec2InstanceProfileName(ec2InstanceProfileName); + } + @Override public Builder staleTime(Duration duration) { diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/Ec2MetadataConfigProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/Ec2MetadataConfigProvider.java index fb73540e75b4..d662a08b5fa8 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/Ec2MetadataConfigProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/Ec2MetadataConfigProvider.java @@ -46,12 +46,14 @@ public final class Ec2MetadataConfigProvider { private final Lazy metadataV1Disabled; private final Lazy serviceTimeout; + private final Lazy ec2InstanceProfileName; private Ec2MetadataConfigProvider(Builder builder) { this.profileFile = builder.profileFile; this.profileName = builder.profileName; this.metadataV1Disabled = new Lazy<>(this::resolveMetadataV1Disabled); this.serviceTimeout = new Lazy<>(this::resolveServiceTimeout); + this.ec2InstanceProfileName = new Lazy<>(this::resolveEc2InstanceProfileName); } public enum EndpointMode { @@ -127,6 +129,14 @@ public boolean isMetadataV1Disabled() { public long serviceTimeout() { return serviceTimeout.getValue(); } + + /** + * Resolves the EC2 Instance Profile Name to use. + * @return the EC2 Instance Profile Name or null if not specified. + */ + public String ec2InstanceProfileName() { + return ec2InstanceProfileName.getValue(); + } // Internal resolution logic for Metadata V1 disabled private boolean resolveMetadataV1Disabled() { @@ -146,6 +156,14 @@ private long resolveServiceTimeout() { .orElseGet(() -> parseTimeoutValue(SdkSystemSetting.AWS_METADATA_SERVICE_TIMEOUT.defaultValue())); } + private String resolveEc2InstanceProfileName() { + return OptionalUtils.firstPresent( + fromSystemSettingsEc2InstanceProfileName(), + () -> fromProfileFileEc2InstanceProfileName(profileFile, profileName) + ) + .orElse(null); + } + // System settings resolution for Metadata V1 disabled private static Optional fromSystemSettingsMetadataV1Disabled() { return SdkSystemSetting.AWS_EC2_METADATA_V1_DISABLED.getBooleanValue(); @@ -171,6 +189,22 @@ private static Optional fromProfileFileServiceTimeout(Supplier p.property(ProfileProperty.METADATA_SERVICE_TIMEOUT)) .map(Ec2MetadataConfigProvider::parseTimeoutValue); } + + // System settings resolution for EC2 Instance Profile Name + private static Optional fromSystemSettingsEc2InstanceProfileName() { + return SdkSystemSetting.AWS_EC2_INSTANCE_PROFILE_NAME.getNonDefaultStringValue(); + } + + // Profile file resolution for EC2 Instance Profile Name + private static Optional fromProfileFileEc2InstanceProfileName(Supplier profileFile, String profileName) { + try { + return profileFile.get() + .profile(profileName) + .flatMap(p -> p.property(ProfileProperty.EC2_INSTANCE_PROFILE_NAME)); + } catch (Exception e) { + return Optional.empty(); + } + } // Parses a timeout value from a string to milliseconds private static long parseTimeoutValue(String timeoutValue) { diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderExtendedApiTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderExtendedApiTest.java index a8b3aa5bf603..533c494a2c95 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderExtendedApiTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderExtendedApiTest.java @@ -212,6 +212,188 @@ void resolveCredentials_withNon404ErrorOnProfileDiscovery_doesNotFallbackToLegac verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH))); verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + PROFILE_NAME))); } + + @Test + void resolveCredentials_withExplicitInstanceProfileName_usesExtendedEndpoint() { + String explicitProfileName = "explicit-profile-name"; + String credentialsWithAccountId = String.format( + "{\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Token\":\"SESSION_TOKEN\"," + + "\"Expiration\":\"%s\"," + + "\"AccountId\":\"%s\"}", + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofDays(1))), + ACCOUNT_ID + ); + + wireMockServer.stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .willReturn(aResponse().withBody(TOKEN_STUB))); + + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + explicitProfileName)) + .willReturn(aResponse().withStatus(200).withBody(credentialsWithAccountId))); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder() + .ec2InstanceProfileName(explicitProfileName) + .build(); + AwsCredentials credentials = provider.resolveCredentials(); + + assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID"); + assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); + assertThat(((AwsSessionCredentials)credentials).sessionToken()).isEqualTo("SESSION_TOKEN"); + assertThat(credentials.accountId()).hasValue(ACCOUNT_ID); + + verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + explicitProfileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH))); + verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH))); + } + + @Test + void resolveCredentials_withInvalidProfileName_throwsError() { + String invalidProfileName = "my-profile-0004"; + + wireMockServer.stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .willReturn(aResponse().withBody(TOKEN_STUB))); + + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + invalidProfileName)) + .willReturn(aResponse().withStatus(404))); + + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + invalidProfileName)) + .willReturn(aResponse().withStatus(404))); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder() + .ec2InstanceProfileName(invalidProfileName) + .build(); + + assertThatThrownBy(() -> provider.resolveCredentials()) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Invalid EC2 instance profile name"); + + verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + invalidProfileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + invalidProfileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + } + + @Test + void resolveCredentials_withValidProfileNameNoAccountId_returnsCredentials() { + String profileName = "my-profile-0006"; + String credentialsWithoutAccountId = String.format( + "{\"Code\":\"Success\"," + + "\"LastUpdated\":\"2025-03-17T20:53:17.832308Z\"," + + "\"Type\":\"AWS-HMAC\"," + + "\"AccessKeyId\":\"ASIAIOSFODNN7EXAMPLE\"," + + "\"SecretAccessKey\":\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\"," + + "\"Token\":\"AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...\"," + + "\"Expiration\":\"2025-03-17T21:53:17.832308Z\"," + + "\"UnexpectedElement6\":{\"Name\":\"ignore-me-6\"}}"); + + wireMockServer.stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .willReturn(aResponse().withBody(TOKEN_STUB))); + + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + profileName)) + .willReturn(aResponse().withStatus(200).withBody(credentialsWithoutAccountId))); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder() + .ec2InstanceProfileName(profileName) + .build(); + AwsCredentials credentials = provider.resolveCredentials(); + + assertThat(credentials.accessKeyId()).isEqualTo("ASIAIOSFODNN7EXAMPLE"); + assertThat(credentials.secretAccessKey()).isEqualTo("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); + assertThat(((AwsSessionCredentials)credentials).sessionToken()).isEqualTo("AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw..."); + assertThat(credentials.accountId()).isEmpty(); + + verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + profileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH))); + } + + @Test + void resolveCredentials_withValidProfileNameAgainstLegacyApi_returnsCredentials() { + String profileName = "my-profile-0010"; + String credentialsJson = String.format( + "{\"Code\":\"Success\"," + + "\"LastUpdated\":\"2025-03-21T20:53:17.832308Z\"," + + "\"Type\":\"AWS-HMAC\"," + + "\"AccessKeyId\":\"ASIAIOSFODNN7EXAMPLE\"," + + "\"SecretAccessKey\":\"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\"," + + "\"Token\":\"AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...\"," + + "\"Expiration\":\"2025-03-21T21:53:17.832308Z\"}"); + + wireMockServer.stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .willReturn(aResponse().withBody(TOKEN_STUB))); + + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + profileName)) + .willReturn(aResponse().withStatus(404))); + + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + profileName)) + .willReturn(aResponse().withStatus(200).withBody(credentialsJson))); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder() + .ec2InstanceProfileName(profileName) + .build(); + AwsCredentials credentials = provider.resolveCredentials(); + + assertThat(credentials.accessKeyId()).isEqualTo("ASIAIOSFODNN7EXAMPLE"); + assertThat(credentials.secretAccessKey()).isEqualTo("wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); + assertThat(((AwsSessionCredentials)credentials).sessionToken()).isEqualTo("AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw..."); + + verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + profileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + profileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH))); + verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH))); + } + + @Test + void resolveCredentials_withInvalidProfileNameAgainstLegacyApi_throwsError() { + String invalidProfileName = "my-profile-0012"; + + wireMockServer.stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .willReturn(aResponse().withBody(TOKEN_STUB))); + + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + invalidProfileName)) + .willReturn(aResponse().withStatus(404))); + + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + invalidProfileName)) + .willReturn(aResponse().withStatus(404))); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder() + .ec2InstanceProfileName(invalidProfileName) + .build(); + + assertThatThrownBy(() -> provider.resolveCredentials()) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("Invalid EC2 instance profile name"); + + verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_EXTENDED_RESOURCE_PATH + invalidProfileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + invalidProfileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + } @Test void resolveCredentials_withUnstableProfile_ReturnsCredentials() { diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java index 671e591b17b5..a273a19cc594 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java @@ -325,6 +325,138 @@ void resolveCredentials_metadataLookupDisabled_throws() { } } + @Test + void resolveCredentials_withExplicitInstanceProfileName_skipsProfileDiscovery() { + String explicitProfileName = "explicit-profile-name"; + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody(TOKEN_STUB))); + stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + explicitProfileName)).willReturn(aResponse().withBody(STUB_CREDENTIALS))); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder() + .ec2InstanceProfileName(explicitProfileName) + .build(); + AwsCredentials credentials = provider.resolveCredentials(); + + assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID"); + assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); + + // Verify token was requested + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + // Verify credentials were requested with the explicit profile name + WireMock.verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + explicitProfileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + // Verify profile discovery was NOT called + WireMock.verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH))); + } + + @Test + void resolveCredentials_withSystemPropertyInstanceProfileName_skipsProfileDiscovery() { + String systemPropertyProfileName = "system-property-profile"; + System.setProperty(SdkSystemSetting.AWS_EC2_INSTANCE_PROFILE_NAME.property(), systemPropertyProfileName); + + try { + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody(TOKEN_STUB))); + stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + systemPropertyProfileName)).willReturn(aResponse().withBody(STUB_CREDENTIALS))); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); + AwsCredentials credentials = provider.resolveCredentials(); + + assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID"); + assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); + + // Verify token was requested + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + // Verify credentials were requested with the system property profile name + WireMock.verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + systemPropertyProfileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + // Verify profile discovery was NOT called + WireMock.verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH))); + } finally { + System.clearProperty(SdkSystemSetting.AWS_EC2_INSTANCE_PROFILE_NAME.property()); + } + } + + @Test + void resolveCredentials_withConfigFileInstanceProfileName_skipsProfileDiscovery() { + String configFileProfileName = "config-file-profile"; + + ProfileFile config = configFile("profile test", + Pair.of(ProfileProperty.EC2_INSTANCE_PROFILE_NAME, configFileProfileName)); + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody(TOKEN_STUB))); + stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + configFileProfileName)).willReturn(aResponse().withBody(STUB_CREDENTIALS))); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder() + .profileFile(config) + .profileName("test") + .build(); + AwsCredentials credentials = provider.resolveCredentials(); + + assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID"); + assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); + + // Verify token was requested + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + // Verify credentials were requested with the config file profile name + WireMock.verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + configFileProfileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + // Verify profile discovery was NOT called + WireMock.verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH))); + } + + @Test + void resolveCredentials_withEnvironmentVariableInstanceProfileName_skipsProfileDiscovery() { + String envVarProfileName = "env-var-profile"; + + try { + environmentVariableHelper.set(SdkSystemSetting.AWS_EC2_INSTANCE_PROFILE_NAME.environmentVariable(), envVarProfileName); + + stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)).willReturn(aResponse().withBody(TOKEN_STUB))); + stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + envVarProfileName)).willReturn(aResponse().withBody(STUB_CREDENTIALS))); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); + AwsCredentials credentials = provider.resolveCredentials(); + + assertThat(credentials.accessKeyId()).isEqualTo("ACCESS_KEY_ID"); + assertThat(credentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); + + WireMock.verify(putRequestedFor(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .withHeader(EC2_METADATA_TOKEN_TTL_HEADER, equalTo("21600"))); + + WireMock.verify(getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + envVarProfileName)) + .withHeader(TOKEN_HEADER, equalTo(TOKEN_STUB))); + + WireMock.verify(0, getRequestedFor(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH))); + } finally { + environmentVariableHelper.reset(); + } + } + + @Test + void resolveCredentials_withBlankInstanceProfileName_throwsException() { + assertThatThrownBy(() -> InstanceProfileCredentialsProvider.builder() + .ec2InstanceProfileName("") + .build()) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("ec2InstanceProfileName cannot be blank"); + } + @Test + void resolveCredentials_withBlankInstanceProfileName1_throwsException() { + assertThatThrownBy(() -> InstanceProfileCredentialsProvider.builder() + .ec2InstanceProfileName(" ") + .build()) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("ec2InstanceProfileName cannot be blank"); + } + @Test void resolveCredentials_customProfileFileAndName_usesCorrectEndpoint() { WireMockServer mockMetadataEndpoint_2 = new WireMockServer(WireMockConfiguration.options().dynamicPort()); diff --git a/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileProperty.java b/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileProperty.java index cd97c6047a55..dc618fc44514 100644 --- a/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileProperty.java +++ b/core/profiles/src/main/java/software/amazon/awssdk/profiles/ProfileProperty.java @@ -162,6 +162,12 @@ public final class ProfileProperty { public static final String EC2_METADATA_V1_DISABLED = "ec2_metadata_v1_disabled"; public static final String METADATA_SERVICE_TIMEOUT = "metadata_service_timeout"; + + /** + * Property name for specifying an IAM instance profile by name. + * When this is set, the provider will skip fetching a list of available instance profiles. + */ + public static final String EC2_INSTANCE_PROFILE_NAME = "ec2_instance_profile_name"; /** * Whether request compression is disabled for operations marked with the RequestCompression trait. The default value is diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java index 65889c2d08fd..63465b423c93 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SdkSystemSetting.java @@ -99,6 +99,14 @@ public enum SdkSystemSetting implements SystemSetting { * metadata service in environments with varying network conditions. */ AWS_METADATA_SERVICE_TIMEOUT("aws.ec2MetadataServiceTimeout", "1"), + + /** + * The EC2 instance profile name to use for retrieving credentials. + * + * When this is set, the provider will skip fetching the list of available instance profiles + * and use this name directly. + */ + AWS_EC2_INSTANCE_PROFILE_NAME("aws.ec2InstanceProfileName", null), /** * The elastic container metadata service endpoint that should be called by the ContainerCredentialsProvider