From e7e0e5a8863dd4dac614da99e21f212a3b05a59f Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Fri, 11 Apr 2025 22:51:34 +0000 Subject: [PATCH 1/2] feat: add support for account ID in IMDS credentials --- .../525a1637-f0f1-4cbd-97dd-5e9c6bcd182e.json | 5 + aws-runtime/aws-config/api/aws-config.api | 10 +- aws-runtime/aws-config/build.gradle.kts | 2 + .../DefaultChainCredentialsProvider.kt | 10 +- .../credentials/ImdsCredentialsProvider.kt | 230 +++--- .../credentials/ProfileCredentialsProvider.kt | 10 +- .../credentials/internal/CredentialsExt.kt | 3 + .../kotlin/runtime/config/AwsSdkSetting.kt | 8 + .../kotlin/runtime/config/imds/ImdsClient.kt | 10 +- .../runtime/config/imds/ImdsRetryPolicy.kt | 4 +- .../runtime/config/imds/TokenMiddleware.kt | 9 +- .../imds_default_chain_error/test-case.json | 2 +- .../imds_no_iam_role/test-case.json | 2 +- .../ImdsCredentialsProviderTest.kt | 660 ++++-------------- .../ImdsCredentialsProviderTestResources.kt | 658 +++++++++++++++++ .../runtime/config/imds/ImdsClientTest.kt | 7 +- .../util/VerifyingInstanceMetadataProvider.kt | 33 + .../DefaultChainCredentialsProviderTest.kt | 8 +- .../ImdsCredentialsProviderTest.kt | 153 ---- gradle/libs.versions.toml | 1 + 20 files changed, 1015 insertions(+), 810 deletions(-) create mode 100644 .changes/525a1637-f0f1-4cbd-97dd-5e9c6bcd182e.json create mode 100644 aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTestResources.kt create mode 100644 aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt delete mode 100644 aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt diff --git a/.changes/525a1637-f0f1-4cbd-97dd-5e9c6bcd182e.json b/.changes/525a1637-f0f1-4cbd-97dd-5e9c6bcd182e.json new file mode 100644 index 00000000000..ddf14b6196f --- /dev/null +++ b/.changes/525a1637-f0f1-4cbd-97dd-5e9c6bcd182e.json @@ -0,0 +1,5 @@ +{ + "id": "525a1637-f0f1-4cbd-97dd-5e9c6bcd182e", + "type": "feature", + "description": "Add support for fetching account ID from IMDS credentials on EC2" +} \ No newline at end of file diff --git a/aws-runtime/aws-config/api/aws-config.api b/aws-runtime/aws-config/api/aws-config.api index e0eae000ee6..a682d2c856c 100644 --- a/aws-runtime/aws-config/api/aws-config.api +++ b/aws-runtime/aws-config/api/aws-config.api @@ -77,12 +77,11 @@ public final class aws/sdk/kotlin/runtime/auth/credentials/EnvironmentCredential public final class aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider : aws/smithy/kotlin/runtime/auth/awscredentials/CloseableCredentialsProvider { public fun ()V + public fun (Ljava/lang/String;Laws/sdk/kotlin/runtime/config/imds/InstanceMetadataProvider;Laws/smithy/kotlin/runtime/util/PlatformProvider;)V + public synthetic fun (Ljava/lang/String;Laws/sdk/kotlin/runtime/config/imds/InstanceMetadataProvider;Laws/smithy/kotlin/runtime/util/PlatformProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/String;Lkotlin/Lazy;Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider;Laws/smithy/kotlin/runtime/time/Clock;)V public synthetic fun (Ljava/lang/String;Lkotlin/Lazy;Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider;Laws/smithy/kotlin/runtime/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V - public final fun getClient ()Lkotlin/Lazy; - public final fun getPlatformProvider ()Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider; - public final fun getProfileOverride ()Ljava/lang/String; public fun resolve (Laws/smithy/kotlin/runtime/collections/Attributes;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun toString ()Ljava/lang/String; } @@ -277,6 +276,7 @@ public final class aws/sdk/kotlin/runtime/config/AwsSdkSetting { public final fun getAwsContainerCredentialsFullUri ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsContainerCredentialsRelativeUri ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsDisableRequestCompression ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; + public final fun getAwsEc2InstanceProfileName ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsEc2MetadataDisabled ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsEc2MetadataServiceEndpoint ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsEc2MetadataServiceEndpointMode ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; @@ -348,8 +348,8 @@ public final class aws/sdk/kotlin/runtime/config/endpoints/ResolversKt { } public final class aws/sdk/kotlin/runtime/config/imds/EC2MetadataError : aws/sdk/kotlin/runtime/AwsServiceException { - public fun (ILjava/lang/String;)V - public final fun getStatusCode ()I + public fun (Laws/smithy/kotlin/runtime/http/HttpStatusCode;Ljava/lang/String;)V + public final fun getStatusCode ()Laws/smithy/kotlin/runtime/http/HttpStatusCode; } public abstract class aws/sdk/kotlin/runtime/config/imds/EndpointConfiguration { diff --git a/aws-runtime/aws-config/build.gradle.kts b/aws-runtime/aws-config/build.gradle.kts index 7a7f1938e1e..765b207a598 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -9,6 +9,7 @@ import org.jetbrains.dokka.gradle.DokkaTaskPartial plugins { alias(libs.plugins.aws.kotlin.repo.tools.smithybuild) + alias(libs.plugins.kotlinx.serialization) } description = "Support for AWS configuration" @@ -53,6 +54,7 @@ kotlin { implementation(libs.kotlinx.coroutines.test) implementation(libs.smithy.kotlin.http.test) implementation(libs.kotlinx.serialization.json) + implementation(libs.kotest.framework.datatest) } } jvmTest { diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt index 5b849ec0a35..f249491dbcc 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt @@ -41,7 +41,7 @@ import aws.smithy.kotlin.runtime.util.PlatformProvider * @param region the region to make credentials requests to. * @return the newly-constructed credentials provider */ -public class DefaultChainCredentialsProvider constructor( +public class DefaultChainCredentialsProvider( public val profileName: String? = null, public val platformProvider: PlatformProvider = PlatformProvider.System, httpClient: HttpClientEngine? = null, @@ -59,11 +59,9 @@ public class DefaultChainCredentialsProvider constructor( ProfileCredentialsProvider(profileName = profileName, platformProvider = platformProvider, httpClient = engine, region = region), EcsCredentialsProvider(platformProvider, engine), ImdsCredentialsProvider( - client = lazy { - ImdsClient { - platformProvider = this@DefaultChainCredentialsProvider.platformProvider - engine = this@DefaultChainCredentialsProvider.engine - } + client = ImdsClient { + platformProvider = this@DefaultChainCredentialsProvider.platformProvider + engine = this@DefaultChainCredentialsProvider.engine }, platformProvider = platformProvider, ), diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt index 495e3d810d6..dad190bcdc2 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt @@ -5,6 +5,7 @@ package aws.sdk.kotlin.runtime.auth.credentials +import aws.sdk.kotlin.runtime.auth.credentials.internal.credentials import aws.sdk.kotlin.runtime.config.AwsSdkSetting import aws.sdk.kotlin.runtime.config.imds.EC2MetadataError import aws.sdk.kotlin.runtime.config.imds.ImdsClient @@ -18,137 +19,208 @@ import aws.smithy.kotlin.runtime.http.HttpStatusCode import aws.smithy.kotlin.runtime.io.IOException import aws.smithy.kotlin.runtime.serde.json.JsonDeserializer import aws.smithy.kotlin.runtime.telemetry.logging.info -import aws.smithy.kotlin.runtime.telemetry.logging.warn import aws.smithy.kotlin.runtime.time.Clock -import aws.smithy.kotlin.runtime.time.Instant import aws.smithy.kotlin.runtime.util.PlatformEnvironProvider import aws.smithy.kotlin.runtime.util.PlatformProvider -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock +import aws.smithy.kotlin.runtime.util.SingleFlightGroup import kotlin.coroutines.coroutineContext -import kotlin.time.Duration.Companion.seconds -private const val CREDENTIALS_BASE_PATH: String = "/latest/meta-data/iam/security-credentials/" private const val CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS: String = "AssumeRoleUnauthorizedAccess" private const val PROVIDER_NAME = "IMDSv2" /** * [CredentialsProvider] that uses EC2 instance metadata service (IMDS) to provide credentials information. - * This provider requires that the EC2 instance has an [instance profile](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile) + * This provider requires that the EC2 instance has an + * [instance profile](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile) * configured. - * - * See [EC2 IAM Roles](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html) for more - * information. - * - * @param profileOverride override the instance profile name. When retrieving credentials, a call must first be made to - * `/latest/meta-data/iam/security-credentials/`. This returns the instance profile used. If - * [profileOverride] is set, the initial call to retrieve the profile is skipped and the provided value is used instead. - * @param client the IMDS client to use to resolve credentials information with. This provider takes ownership over - * the lifetime of the given [ImdsClient] and will close it when the provider is closed. - * @param platformProvider the [PlatformEnvironProvider] instance + * @param instanceProfileName overrides the instance profile name. When set, this provider skips querying IMDS for the + * name of the active profile. + * @param client a preconfigured IMDS client with which to retrieve instance metadata. If an instance is passed, the + * caller is responsible for closing it. If no instance is passed, a default instance is created and will be closed when + * this credentials provider is closed. + * @param platformProvider a platform provider used for env vars and system properties */ public class ImdsCredentialsProvider( - public val profileOverride: String? = null, - public val client: Lazy = lazy { ImdsClient() }, - public val platformProvider: PlatformEnvironProvider = PlatformProvider.System, - private val clock: Clock = Clock.System, + instanceProfileName: String? = null, + client: InstanceMetadataProvider? = null, + private val platformProvider: PlatformProvider = PlatformProvider.System, ) : CloseableCredentialsProvider { + + @Deprecated("This constructor supports parameters which are no longer used in the implementation. It will be removed in version 1.5.") + public constructor( + profileOverride: String? = null, + client: Lazy = lazy { ImdsClient() }, + platformProvider: PlatformEnvironProvider = PlatformProvider.System, + @Suppress("UNUSED_PARAMETER") clock: Clock = Clock.System, + ) : this(profileOverride, client.value, platformProvider = platformProvider as? PlatformProvider ?: PlatformProvider.System) + + private val manageClient: Boolean = client == null + + private val client: InstanceMetadataProvider = client ?: ImdsClient { + this.platformProvider = this@ImdsCredentialsProvider.platformProvider + } + + // FIXME This only resolves from env vars and sys props but we need to resolve from profiles too + private val instanceProfileName = instanceProfileName + ?: AwsSdkSetting.AwsEc2InstanceProfileName.resolve(platformProvider) + + // FIXME This only resolves from env vars and sys props but we need to resolve from profiles too + private val providerDisabled = AwsSdkSetting.AwsEc2MetadataDisabled.resolve(platformProvider) == true + + /** + * Tracks the known-good version of IMDS APIs available in the local environment. This starts as `null` and will be + * updated after the first successful API call. + */ + private var apiVersion: ApiVersion? = null + + private val urlBase: String + get() = (apiVersion ?: ApiVersion.EXTENDED).urlBase + private var previousCredentials: Credentials? = null - // the time to refresh the Credentials. If set, it will take precedence over the Credentials' expiration time - private var nextRefresh: Instant? = null + /** + * Tracks the instance profile name resolved from IMDS. This starts as `null` and will be updated after a + * successful API call. Note that if [instanceProfileName] is set, profile name resolution will be skipped. + */ + private var resolvedProfileName: String? = null + + /** + * A deduplicator for resolving credentials and tracking mutable state about IMDS + */ + private val sfg = SingleFlightGroup() - // protects previousCredentials and nextRefresh - private val mu = Mutex() + override suspend fun resolve(attributes: Attributes): Credentials = sfg.singleFlight { resolveUnderLock() } - override suspend fun resolve(attributes: Attributes): Credentials { - if (AwsSdkSetting.AwsEc2MetadataDisabled.resolve(platformProvider) == true) { + private suspend fun resolveUnderLock(): Credentials { + println("**** Resolving creds (instanceProfileName=$instanceProfileName; apiVersion=$apiVersion; urlBase=$urlBase)") + + if (providerDisabled) { + println("**** Explicitly disabled") throw CredentialsNotLoadedException("AWS EC2 metadata is explicitly disabled; credentials not loaded") } - // if we have previously served IMDS credentials and it's not time for a refresh, just return the previous credentials - mu.withLock { - previousCredentials?.run { - nextRefresh?.takeIf { clock.now() < it }?.run { - return previousCredentials!! + val profileName = instanceProfileName ?: resolvedProfileName ?: try { + println("**** Resolving profile") + client.get(urlBase).also { + if (apiVersion == null) { + // Tried EXTENDED and it worked; remember that for the future + apiVersion = ApiVersion.EXTENDED } } - } + } catch (ex: EC2MetadataError) { + when { + apiVersion == null && ex.statusCode == HttpStatusCode.NotFound -> { + // Tried EXTENDED and that didn't work; fallback to LEGACY + apiVersion = ApiVersion.LEGACY + return resolveUnderLock() + } + + ex.statusCode == HttpStatusCode.NotFound -> { + coroutineContext.info { + "Received 404 when loading profile name. This instance may not have an associated profile." + } + throw ex + } - val profileName = try { - profileOverride ?: loadProfile() + else -> return usePreviousCredentials() + ?: throw ImdsProfileException(ex).wrapAsCredentialsProviderException() + } + } catch (ex: IOException) { + return usePreviousCredentials() ?: throw ImdsProfileException(ex).wrapAsCredentialsProviderException() } catch (ex: Exception) { - return useCachedCredentials(ex) ?: throw CredentialsProviderException("failed to load instance profile", ex) + throw ImdsProfileException(ex).wrapAsCredentialsProviderException() } - val payload = try { - client.value.get("$CREDENTIALS_BASE_PATH$profileName") + val credsPayload = try { + client.get("$urlBase$profileName") + } catch (ex: EC2MetadataError) { + when { + apiVersion == null && ex.statusCode == HttpStatusCode.NotFound -> { + // Tried EXTENDED and that didn't work; fallback to LEGACY + apiVersion = ApiVersion.LEGACY + return resolveUnderLock() + } + + instanceProfileName == null && ex.statusCode == HttpStatusCode.NotFound -> { + // A previously-resolved profile is now invalid; forget the resolved name and re-resolve + resolvedProfileName = null + return resolveUnderLock() + } + + else -> return usePreviousCredentials() + ?: throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() + } + } catch (ex: IOException) { + return usePreviousCredentials() + ?: throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() } catch (ex: Exception) { - return useCachedCredentials(ex) ?: throw CredentialsProviderException("failed to load credentials", ex) + throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() } - val deserializer = JsonDeserializer(payload.encodeToByteArray()) + if (instanceProfileName == null) { + // No profile name was provided at construction time; cache the resolved name + resolvedProfileName = profileName + } + + val deserializer = JsonDeserializer(credsPayload.encodeToByteArray()) return when (val resp = deserializeJsonCredentials(deserializer)) { is JsonCredentialsResponse.SessionCredentials -> { - nextRefresh = if (resp.expiration != null && resp.expiration < clock.now()) { - coroutineContext.warn { - "Attempting credential expiration extension due to a credential service availability issue. " + - "A refresh of these credentials will be attempted again in " + - "${ DEFAULT_CREDENTIALS_REFRESH_SECONDS / 60 } minutes." - } - clock.now() + DEFAULT_CREDENTIALS_REFRESH_SECONDS.seconds - } else { - null - } - - val creds = Credentials( + val creds = credentials( resp.accessKeyId, resp.secretAccessKey, resp.sessionToken, resp.expiration, PROVIDER_NAME, + resp.accountId, ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) - creds.also { - mu.withLock { previousCredentials = it } - } + creds.also { previousCredentials = it } } - is JsonCredentialsResponse.Error -> { - when (resp.code) { - CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS -> throw ProviderConfigurationException("Incorrect IMDS/IAM configuration: [${resp.code}] ${resp.message}. Hint: Does this role have a trust relationship with EC2?") - else -> throw CredentialsProviderException("Error retrieving credentials from IMDS: code=${resp.code}; ${resp.message}") - } + is JsonCredentialsResponse.Error -> when (resp.code) { + CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS -> throw ProviderConfigurationException("Incorrect IMDS/IAM configuration: [${resp.code}] ${resp.message}. Hint: Does this role have a trust relationship with EC2?") + else -> throw CredentialsProviderException("Error retrieving credentials from IMDS: code=${resp.code}; ${resp.message}") } } } override fun close() { - if (client.isInitialized()) { - client.value.close() + if (manageClient) { + client.close() } } - private suspend fun loadProfile() = try { - client.value.get(CREDENTIALS_BASE_PATH) - } catch (ex: EC2MetadataError) { - if (ex.statusCode == HttpStatusCode.NotFound.value) { + private suspend fun usePreviousCredentials(): Credentials? = + previousCredentials?.apply { coroutineContext.info { - "Received 404 from IMDS when loading profile information. Hint: This instance may not have an " + - "IAM role associated." + "Attempting to reuse previously-fetched credentials (expiration = $expiration)" } } - throw ex - } - - private suspend fun useCachedCredentials(ex: Exception): Credentials? = when { - ex is IOException || ex is EC2MetadataError && ex.statusCode == HttpStatusCode.InternalServerError.value -> { - mu.withLock { - previousCredentials?.apply { nextRefresh = clock.now() + DEFAULT_CREDENTIALS_REFRESH_SECONDS.seconds } - } - } - else -> null - } override fun toString(): String = this.simpleClassName + + /** + * Identifies different versions of IMDS APIs for fetching credentials + */ + private enum class ApiVersion(val urlBase: String) { + /** + * The original, now-deprecated API + */ + LEGACY("/latest/meta-data/iam/security-credentials/"), + + /** + * The new API which provides `AccountId` and potentially other fields in the future + */ + EXTENDED("/latest/meta-data/iam/security-credentials-extended/"), + } } + +internal class ImdsCredentialsException( + profileName: String, + cause: Throwable, +) : RuntimeException("Failed to load credentials for EC2 instance profile \"$profileName\"", cause) + +internal class ImdsProfileException(cause: Throwable) : RuntimeException("Failed to load instance profile name", cause) + +private fun Throwable.wrapAsCredentialsProviderException() = + CredentialsProviderException(message.orEmpty(), this) diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt index 81935c0be9e..0b21677ee5e 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt @@ -106,12 +106,10 @@ public class ProfileCredentialsProvider @InternalSdkApi constructor( private val namedProviders = mapOf( "Environment" to EnvironmentCredentialsProvider(platformProvider::getenv), "Ec2InstanceMetadata" to ImdsCredentialsProvider( - profileOverride = profileName, - client = lazy { - ImdsClient { - platformProvider = this@ProfileCredentialsProvider.platformProvider - engine = httpClient - } + instanceProfileName = profileName, + client = ImdsClient { + platformProvider = this@ProfileCredentialsProvider.platformProvider + engine = httpClient }, platformProvider = platformProvider, ), diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/internal/CredentialsExt.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/internal/CredentialsExt.kt index 3f40a0e584d..48bf1fb443e 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/internal/CredentialsExt.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/internal/CredentialsExt.kt @@ -29,3 +29,6 @@ internal fun credentials( } return Credentials(accessKeyId, secretAccessKey, sessionToken, expiration, attributes = attributes) } + +internal val Credentials.accountId: String? + get() = attributes.getOrNull(AwsClientOption.AccountId) diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/AwsSdkSetting.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/AwsSdkSetting.kt index d96c76da25b..2292e400553 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/AwsSdkSetting.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/AwsSdkSetting.kt @@ -105,6 +105,14 @@ public object AwsSdkSetting { public val AwsEc2MetadataServiceEndpointMode: EnvironmentSetting = strEnvSetting("aws.ec2MetadataServiceEndpointMode", "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE") + /** + * The name of the EC2 + * [instance profile](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html) + * to use for resolving credentials on an EC2 instance. Specifying this name disables profile name discovery. + */ + public val AwsEc2InstanceProfileName: EnvironmentSetting = + strEnvSetting("aws.ec2InstanceProfileName", "AWS_EC2_INSTANCE_PROFILE_NAME") + // TODO Currently env/system properties around role ARN, role session name, etc are restricted to the STS web // identity provider. They should be applied more broadly but this needs fleshed out across AWS SDKs before we can // do so. diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt index 61de7692d3b..6927da71bfa 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt @@ -13,7 +13,6 @@ import aws.smithy.kotlin.runtime.client.LogMode import aws.smithy.kotlin.runtime.client.SdkClientOption import aws.smithy.kotlin.runtime.client.endpoints.Endpoint import aws.smithy.kotlin.runtime.http.* -import aws.smithy.kotlin.runtime.http.HttpCall import aws.smithy.kotlin.runtime.http.engine.DefaultHttpEngine import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine import aws.smithy.kotlin.runtime.http.engine.ProxySelector @@ -115,10 +114,10 @@ public class ImdsClient private constructor(builder: Builder) : InstanceMetadata override suspend fun deserialize(context: ExecutionContext, call: HttpCall): String { val response = call.response if (response.status.isSuccess()) { - val payload = response.body.readAll() ?: throw EC2MetadataError(response.status.value, "no metadata payload") + val payload = response.body.readAll() ?: throw EC2MetadataError(response.status, "no metadata payload") return payload.decodeToString() } else { - throw EC2MetadataError(response.status.value, "error retrieving instance metadata: ${response.status.description}") + throw EC2MetadataError(response.status, "error retrieving instance metadata: ${response.status.description}") } } } @@ -229,8 +228,7 @@ public enum class EndpointMode(internal val defaultEndpoint: Endpoint) { /** * Exception thrown when an error occurs retrieving metadata from IMDS - * - * @param statusCode The raw HTTP status code of the response + * @param statusCode The HTTP status code of the response * @param message The error message */ -public class EC2MetadataError(public val statusCode: Int, message: String) : AwsServiceException(message) +public class EC2MetadataError(public val statusCode: HttpStatusCode, message: String) : AwsServiceException(message) diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsRetryPolicy.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsRetryPolicy.kt index 4f2284f97f3..a70abfc5f58 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsRetryPolicy.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsRetryPolicy.kt @@ -23,14 +23,14 @@ internal class ImdsRetryPolicy( private fun evaluate(throwable: Throwable): RetryDirective = when (throwable) { is EC2MetadataError -> { - val status = HttpStatusCode.fromValue(throwable.statusCode) + val status = throwable.statusCode when { status.category() == HttpStatusCode.Category.SERVER_ERROR -> RetryDirective.RetryError(RetryErrorType.ServerSide) // 401 indicates the token has expired, this is retryable status == HttpStatusCode.Unauthorized -> RetryDirective.RetryError(RetryErrorType.ServerSide) else -> { val logger = callContext.logger() - logger.debug { "Non retryable IMDS error: statusCode=${throwable.statusCode}; ${throwable.message}" } + logger.debug { "Non retryable IMDS error: statusCode=$status; ${throwable.message}" } RetryDirective.TerminateAndFail } } diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/TokenMiddleware.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/TokenMiddleware.kt index 68a862b0661..0851eeb1718 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/TokenMiddleware.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/TokenMiddleware.kt @@ -6,7 +6,6 @@ package aws.sdk.kotlin.runtime.config.imds import aws.smithy.kotlin.runtime.http.* -import aws.smithy.kotlin.runtime.http.complete import aws.smithy.kotlin.runtime.http.operation.ModifyRequestMiddleware import aws.smithy.kotlin.runtime.http.operation.SdkHttpOperation import aws.smithy.kotlin.runtime.http.operation.SdkHttpRequest @@ -64,10 +63,10 @@ internal class TokenMiddleware( val call = httpClient.call(tokenReq) return try { - when (call.response.status) { + when (val status = call.response.status) { HttpStatusCode.OK -> { - val ttl = call.response.headers[X_AWS_EC2_METADATA_TOKEN_TTL_SECONDS]?.toLong() ?: throw EC2MetadataError(200, "No TTL provided in IMDS response") - val token = call.response.body.readAll() ?: throw EC2MetadataError(200, "No token provided in IMDS response") + val ttl = call.response.headers[X_AWS_EC2_METADATA_TOKEN_TTL_SECONDS]?.toLong() ?: throw EC2MetadataError(status, "No TTL provided in IMDS response") + val token = call.response.body.readAll() ?: throw EC2MetadataError(status, "No token provided in IMDS response") val expires = clock.now() + ttl.seconds Token(token, expires) } @@ -76,7 +75,7 @@ internal class TokenMiddleware( HttpStatusCode.Forbidden -> "Request forbidden: IMDS is disabled or the caller has insufficient permissions." else -> "Failed to retrieve IMDS token" } - throw EC2MetadataError(call.response.status.value, message) + throw EC2MetadataError(status, message) } } } finally { diff --git a/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_default_chain_error/test-case.json b/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_default_chain_error/test-case.json index c182d0bbbaf..035482c750a 100644 --- a/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_default_chain_error/test-case.json +++ b/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_default_chain_error/test-case.json @@ -2,6 +2,6 @@ "name": "imds-default-chain", "docs": "IMDS isn't specifically configured but is loaded as part of the default chain. This has the exact same HTTP traffic as imds_no_iam_role, they are equivalent.", "result": { - "ErrorContains": "failed to load instance profile" + "ErrorContains": "Failed to load instance profile name" } } diff --git a/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_no_iam_role/test-case.json b/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_no_iam_role/test-case.json index 0df36927015..d89927ffaa7 100644 --- a/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_no_iam_role/test-case.json +++ b/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_no_iam_role/test-case.json @@ -2,6 +2,6 @@ "name": "imds-token-fail", "docs": "attempts to acquire an IMDS token, but the instance doesn't have a role configured", "result": { - "ErrorContains": "failed to load instance profile" + "ErrorContains": "Failed to load instance profile name" } } diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt index 90a30e9e9c8..99e9274c7da 100644 --- a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt @@ -4,580 +4,154 @@ */ package aws.sdk.kotlin.runtime.auth.credentials -import aws.sdk.kotlin.runtime.config.AwsSdkSetting -import aws.sdk.kotlin.runtime.config.imds.* -import aws.sdk.kotlin.runtime.config.imds.DEFAULT_TOKEN_TTL_SECONDS -import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.AwsBusinessMetric -import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.withBusinessMetric -import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.sdk.kotlin.runtime.auth.credentials.internal.accountId +import aws.sdk.kotlin.runtime.config.imds.EC2MetadataError +import aws.sdk.kotlin.runtime.util.VerifyingInstanceMetadataProvider import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProviderException -import aws.smithy.kotlin.runtime.http.* -import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineBase -import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineConfig -import aws.smithy.kotlin.runtime.http.request.HttpRequest -import aws.smithy.kotlin.runtime.http.response.HttpResponse -import aws.smithy.kotlin.runtime.httptest.TestEngine -import aws.smithy.kotlin.runtime.httptest.buildTestConnection -import aws.smithy.kotlin.runtime.io.IOException -import aws.smithy.kotlin.runtime.net.Host -import aws.smithy.kotlin.runtime.net.Scheme -import aws.smithy.kotlin.runtime.net.url.Url -import aws.smithy.kotlin.runtime.operation.ExecutionContext -import aws.smithy.kotlin.runtime.time.Instant -import aws.smithy.kotlin.runtime.time.ManualClock -import aws.smithy.kotlin.runtime.time.epochMilliseconds -import aws.smithy.kotlin.runtime.time.fromEpochMilliseconds +import aws.smithy.kotlin.runtime.http.HttpStatusCode import aws.smithy.kotlin.runtime.util.TestPlatformProvider -import io.kotest.matchers.string.shouldContain -import kotlinx.coroutines.test.runTest -import kotlin.test.* -import kotlin.time.Duration.Companion.seconds - -class ImdsCredentialsProviderTest { - - private val ec2MetadataDisabledPlatform = TestPlatformProvider( - env = mapOf(AwsSdkSetting.AwsEc2MetadataDisabled.envVar to "true"), - ) - private val ec2MetadataEnabledPlatform = TestPlatformProvider() - - @Test - fun testImdsDisabled() = runTest { - val platform = ec2MetadataDisabledPlatform - val provider = ImdsCredentialsProvider(platformProvider = platform) - assertFailsWith { - provider.resolve() - }.message.shouldContain("AWS EC2 metadata is explicitly disabled; credentials not loaded") - } - - @Test - fun testSuccess() = runTest { - val testClock = ManualClock(Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds)) - val expiration0 = Instant.fromEpochMilliseconds(testClock.now().epochMilliseconds) - val expiration1 = expiration0 + 2.seconds - - val connection = buildTestConnection { - expect( - tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), - tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), - ) - expect( - imdsRequest("http://169.254.169.254/latest/meta-data/iam/security-credentials/", "TOKEN_A"), - imdsResponse("imds-test-role"), - ) - expect( - imdsRequest( - "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", - "TOKEN_A", - ), - imdsResponse( - """ - { - "Code" : "Success", - "LastUpdated" : "2021-09-17T20:57:08Z", - "Type" : "AWS-HMAC", - "AccessKeyId" : "ASIARTEST0", - "SecretAccessKey" : "xjtest0", - "Token" : "IQote///test0", - "Expiration" : "$expiration0" - } - """, - ), - ) - - // verify that profile is re-retrieved after credentials expiration - expect( - imdsRequest("http://169.254.169.254/latest/meta-data/iam/security-credentials/", "TOKEN_A"), - imdsResponse("imds-test-role-2"), - ) - expect( - imdsRequest( - "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role-2", - "TOKEN_A", - ), - imdsResponse( - """ - { - "Code" : "Success", - "LastUpdated" : "2021-09-17T20:57:08Z", - "Type" : "AWS-HMAC", - "AccessKeyId" : "ASIARTEST1", - "SecretAccessKey" : "xjtest1", - "Token" : "IQote///test1", - "Expiration" : "$expiration1" - } - """, - ), - ) - } - - val client = ImdsClient { - engine = connection - clock = testClock - } - - val provider = ImdsCredentialsProvider( - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) - - val actual0 = provider.resolve() - val expected0 = Credentials( - "ASIARTEST0", - "xjtest0", - "IQote///test0", - expiration0, - "IMDSv2", - ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) - assertEquals(expected0, actual0) - - testClock.advance(1.seconds) - - val actual1 = provider.resolve() - val expected1 = Credentials( - "ASIARTEST1", - "xjtest1", - "IQote///test1", - expiration1, - "IMDSv2", - ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) - assertEquals(expected1, actual1) - - connection.assertRequests() - } - - @Test - fun testSuccessProfileOverride() = runTest { - val testClock = ManualClock() - val expiration = Instant.fromEpochMilliseconds(testClock.now().epochMilliseconds) - - val connection = buildTestConnection { - expect( - tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), - tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), - ) - // no request for profile, go directly to retrieving role credentials - expect( - imdsRequest( - "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", - "TOKEN_A", - ), - imdsResponse( - """ - { - "Code" : "Success", - "LastUpdated" : "2021-09-17T20:57:08Z", - "Type" : "AWS-HMAC", - "AccessKeyId" : "ASIARTEST", - "SecretAccessKey" : "xjtest", - "Token" : "IQote///test", - "Expiration" : "$expiration" - } - """, - ), - ) - } - - val client = ImdsClient { - engine = connection - clock = testClock - } - - val provider = ImdsCredentialsProvider( - profileOverride = "imds-test-role", - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) - - val actual = provider.resolve() - val expected = Credentials( - "ASIARTEST", - "xjtest", - "IQote///test", - expiration, - "IMDSv2", - ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) - assertEquals(expected, actual) - - connection.assertRequests() - } - - @Test - fun testTokenFailure() = runTest { - // when attempting to retrieve initial token, IMDS replied with 403, indicating IMDS is disabled or not allowed through permissions - val connection = buildTestConnection { - expect( - tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), - HttpResponse(HttpStatusCode.Forbidden, Headers.Empty, HttpBody.Empty), - ) - } - - val testClock = ManualClock() - val client = ImdsClient { - engine = connection - clock = testClock - } - - val provider = ImdsCredentialsProvider( - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) - - val ex = assertFailsWith { - provider.resolve() - } - ex.message.shouldContain("failed to load instance profile") - assertIs(ex.cause) - ex.cause!!.message.shouldContain("Request forbidden") - - connection.assertRequests() - } +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.WithDataTestName +import io.kotest.datatest.withData +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.fail + +class ImdsCredentialsProviderTest : + FunSpec({ + context("ImdsCredentialsProviderTest") { + withData(testCases) { testCase -> + println("**** TC: ${testCases.indexOf(testCase)}=${testCase.summary}") + val imds = imds(testCase.expectations) + + val provider = ImdsCredentialsProvider( + instanceProfileName = testCase.config.profileName, + client = imds, + platformProvider = TestPlatformProvider(env = testCase.config.envVars), + ) + + testCase.outcomes.forEachIndexed { index, outcome -> + println("**** Outcome: $index=$outcome") + assertCredentials(provider, index, outcome) + } - @Test - fun testNoInstanceProfileConfigured() = runTest { - val connection = buildTestConnection { - expect( - tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), - tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), - ) - expect( - imdsRequest("http://169.254.169.254/latest/meta-data/iam/security-credentials/", "TOKEN_A"), - HttpResponse( - HttpStatusCode.NotFound, - Headers.Empty, - HttpBody.fromBytes( - """ - - - - 404 - Not Found - - -

404 - Not Found

- - - """.trimIndent().encodeToByteArray(), - ), - ), - ) + imds.verifyComplete() + } } - - val testClock = ManualClock() - val client = ImdsClient { - engine = connection - clock = testClock + }) { + private companion object { + val json = Json { + @OptIn(ExperimentalSerializationApi::class) + decodeEnumsCaseInsensitive = true } - val provider = ImdsCredentialsProvider( - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) + val testCases = json.decodeFromString>(imdsCredentialsTestSpec) - assertFailsWith { - provider.resolve() - }.message.shouldContain("failed to load instance profile") + fun imds(expectations: List) = + VerifyingInstanceMetadataProvider(expectations.map { it.get to it.response.asStringProvider() }) - connection.assertRequests() - } - - // SDK can send a request if expired credentials are available. - // If the credentials provider can return expired credentials, that means the SDK can use them, - // because no other checks are done before using the credentials. - @Test - fun testCanReturnExpiredCredentials() = runTest { - val connection = buildTestConnection { - expect( - tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), - tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), - ) - expect( - imdsRequest( - "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", - "TOKEN_A", - ), - imdsResponse( - """ - { - "Code" : "Success", - "LastUpdated" : "2021-09-17T20:57:08Z", - "Type" : "AWS-HMAC", - "AccessKeyId" : "ASIARTEST", - "SecretAccessKey" : "xjtest", - "Token" : "IQote///test", - "Expiration" : "2021-09-18T03:31:56Z" - } - """, - ), - ) + fun Response.asStringProvider(): () -> String = { + if (status == 200) requireNotNull(body) else throw EC2MetadataError(HttpStatusCode.fromValue(status), "err") } - val testClock = ManualClock() - val client = ImdsClient { - engine = connection - clock = testClock - } + suspend fun assertCredentials(provider: ImdsCredentialsProvider, index: Int, outcome: Outcome) { + val result = runCatching { provider.resolve() }.also { println("**** Got $it") } - val provider = ImdsCredentialsProvider( - profileOverride = "imds-test-role", - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) + (result.exceptionOrNull() as? AssertionError)?.let { throw it } // Rethrow any failed assertions - val actual = provider.resolve() - - val expected = Credentials( - accessKeyId = "ASIARTEST", - secretAccessKey = "xjtest", - sessionToken = "IQote///test", - expiration = Instant.fromEpochSeconds(1631935916), - providerName = "IMDSv2", - ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) - - assertEquals(expected, actual) - - connection.assertRequests() - } - - @Test - fun testUsesPreviousCredentialsOnReadTimeout() = runTest { - val testClock = ManualClock() - - // this engine throws read timeout exceptions for any requests after the initial one - // (i.e allow 1 TTL token and 1 credentials request) - val readTimeoutEngine = object : HttpClientEngineBase("readTimeout") { - var successfulCallCount = 0 - - override val config: HttpClientEngineConfig = HttpClientEngineConfig.Default - - override suspend fun roundTrip(context: ExecutionContext, request: HttpRequest): HttpCall { - if (successfulCallCount >= 2) { - throw IOException() - } else { - successfulCallCount += 1 + when (outcome.result) { + Result.CREDENTIALS -> { + val creds = result.getOrNull() ?: fail("Test index $index: expected credentials but got $result") + assertEquals(creds.accountId, outcome.accountId, "Test index $index: Unexpected account ID value") + } - return when (successfulCallCount) { - 1 -> HttpCall( - tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), - tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), - testClock.now(), - testClock.now(), - ) + Result.NO_CREDENTIALS -> { + val ex = result.exceptionOrNull() ?: fail("Test index $index: Expected exception but got $result") + assertIs(ex, "Test index $index: Unexpected exception type $ex") + } - else -> HttpCall( - imdsRequest( - "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", - "TOKEN_A", - ), - imdsResponse( - """ - { - "Code" : "Success", - "LastUpdated" : "2021-09-17T20:57:08Z", - "Type" : "AWS-HMAC", - "AccessKeyId" : "ASIARTEST", - "SecretAccessKey" : "xjtest", - "Token" : "IQote///test", - "Expiration" : "2021-09-18T03:31:56Z" - }""", - ), - testClock.now(), - testClock.now(), - ) - } + Result.INVALID_PROFILE -> { + val ex = result.exceptionOrNull() ?: fail("Test index $index: Expected exception but got $result") + assertIs(ex, "Test index $index: Unexpected exception $ex") + val cause = assertNotNull(ex.cause, "Test index $index: Expected non-null exception cause") + assertIs(cause, "Test index $index: Unexpected cause $cause") } } } - - val client = ImdsClient { - engine = readTimeoutEngine - clock = testClock - } - - val previousCredentials = Credentials( - accessKeyId = "ASIARTEST", - secretAccessKey = "xjtest", - sessionToken = "IQote///test", - expiration = Instant.fromEpochSeconds(1631935916), - providerName = "IMDSv2", - ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) - - val provider = ImdsCredentialsProvider( - profileOverride = "imds-test-role", - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) - - // call the engine the first time to get a proper credentials response from IMDS - val credentials = provider.resolve() - assertEquals(credentials, previousCredentials) - - // call it again and get a read timeout exception from the engine - val newCredentials = provider.resolve() - - // should cause provider to return the previously-served credentials - assertEquals(newCredentials, previousCredentials) - } - - @Test - fun testThrowsExceptionOnReadTimeoutWhenMissingPreviousCredentials() = runTest { - val readTimeoutEngine = TestEngine { _, _ -> throw IOException() } - val testClock = ManualClock() - - val client = ImdsClient { - engine = readTimeoutEngine - clock = testClock - } - - val provider = ImdsCredentialsProvider( - profileOverride = "imds-test-role", - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) - - // a read timeout should cause an exception to be thrown, because we have no previous credentials to re-serve - assertFailsWith { - provider.resolve() - } } +} - @Test - fun testUsesPreviousCredentialsOnServerError() = runTest { - val testClock = ManualClock() +@Serializable +data class TestCase( + val summary: String, + val config: Config, + val expectations: List, + val outcomes: List, +) : WithDataTestName { + override fun dataTestName() = summary +} - // this engine returns 500 errors for any requests after the initial one (i.e allow 1 TTL token and 1 credentials request) - val internalServerErrorEngine = object : HttpClientEngineBase("internalServerError") { - var successfulCallCount = 0 +@Serializable +data class Config( + @SerialName("ec2InstanceProfileName") + val profileName: String? = null, - override val config: HttpClientEngineConfig = HttpClientEngineConfig.Default + val envVars: Map = mapOf(), +) - override suspend fun roundTrip(context: ExecutionContext, request: HttpRequest): HttpCall { - if (successfulCallCount >= 2) { - return HttpCall( - HttpRequest( - HttpMethod.GET, - Url { - scheme = Scheme.HTTP - host = Host.parse("test") - path.encoded = "/path/foo/bar" - }, - Headers.Empty, - HttpBody.Empty, - ), - HttpResponse(HttpStatusCode.InternalServerError, Headers.Empty, HttpBody.Empty), - testClock.now(), - testClock.now(), - ) - } else { - successfulCallCount += 1 +@Serializable +data class Expectation( + val get: String, + val response: Response, +) - return when (successfulCallCount) { - 1 -> HttpCall( - tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), - tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), - testClock.now(), - testClock.now(), - ) +@Serializable +data class Response( + val status: Int, - else -> HttpCall( - imdsRequest( - "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", - "TOKEN_A", - ), - imdsResponse( - """ - { - "Code" : "Success", - "LastUpdated" : "2021-09-17T20:57:08Z", - "Type" : "AWS-HMAC", - "AccessKeyId" : "ASIARTEST", - "SecretAccessKey" : "xjtest", - "Token" : "IQote///test", - "Expiration" : "2021-09-18T03:31:56Z" - }""", - ), - testClock.now(), - testClock.now(), - ) - } - } - } - } + @Serializable(with = StringOrObjectSerializer::class) + val body: String? = null, +) - val client = ImdsClient { - engine = internalServerErrorEngine - clock = testClock - } +@Serializable +data class Outcome( + val result: Result, + val accountId: String? = null, +) - val previousCredentials = Credentials( - accessKeyId = "ASIARTEST", - secretAccessKey = "xjtest", - sessionToken = "IQote///test", - expiration = Instant.fromEpochSeconds(1631935916), - providerName = "IMDSv2", - ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) +enum class Result { + CREDENTIALS, - val provider = ImdsCredentialsProvider( - profileOverride = "imds-test-role", - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) + @SerialName("no credentials") + NO_CREDENTIALS, - // call the engine the first time to get a proper credentials response from IMDS - val credentials = provider.resolve() - assertEquals(previousCredentials, credentials) - - // call it again and get a 500 error from the engine - val newCredentials = provider.resolve() - - // should cause provider to return the previously-served credentials - assertEquals(newCredentials, previousCredentials) - } + @SerialName("invalid profile") + INVALID_PROFILE, +} - @Test - fun testThrowsExceptionOnServerErrorWhenMissingPreviousCredentials() = runTest { - val testClock = ManualClock() +object StringOrObjectSerializer : KSerializer { + override val descriptor: SerialDescriptor = + SerialDescriptor("string-or-object", JsonElement.serializer().descriptor) - // this engine just returns 500 errors - val internalServerErrorEngine = TestEngine { _, _ -> - HttpCall( - HttpRequest( - HttpMethod.GET, - Url { - scheme = Scheme.HTTP - host = Host.parse("test") - path.encoded = "/path/foo/bar" - }, - Headers.Empty, - HttpBody.Empty, - ), - HttpResponse(HttpStatusCode.InternalServerError, Headers.Empty, HttpBody.Empty), - testClock.now(), - testClock.now(), - ) + override fun deserialize(decoder: Decoder): String { + val jsonDecoder = decoder as? JsonDecoder ?: error("This serializer only supports JSON") + return when (val element = jsonDecoder.decodeJsonElement()) { + is JsonPrimitive -> element.content + is JsonObject -> element.toString() + else -> error("Unsupported JSON type ${element::class}") } + } - val client = ImdsClient { - engine = internalServerErrorEngine - clock = testClock - } - - val provider = ImdsCredentialsProvider( - profileOverride = "imds-test-role", - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) - - assertFailsWith { - provider.resolve() - } + override fun serialize(encoder: Encoder, value: String) { + encoder.encodeString(value) } } diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTestResources.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTestResources.kt new file mode 100644 index 00000000000..e5ddb3b191d --- /dev/null +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTestResources.kt @@ -0,0 +1,658 @@ +package aws.sdk.kotlin.runtime.auth.credentials + +// language=JSON +val imdsCredentialsTestSpec = """ + [ + { + "summary": "Test IMDS credentials provider with env vars { AWS_EC2_METADATA_DISABLED=true } returns no credentials", + "config": { + "ec2InstanceProfileName": null, + "envVars": { + "AWS_EC2_METADATA_DISABLED": "true" + } + }, + "expectations": [], + "outcomes": [ + { + "result": "no credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0001" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "123456789101" + }, + { + "result": "credentials", + "accountId": "123456789101" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": "my-profile-0002" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "234567891011" + }, + { + "result": "credentials", + "accountId": "234567891011" + } + ] + }, + { + "summary": "Test IMDS credentials provider when profile is unstable returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "345678910112" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "314253647589" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "345678910112" + }, + { + "result": "credentials", + "accountId": "314253647589" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0004" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0004", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0004", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0005" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0006" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0008" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0008", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0008", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0009" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-20T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-20T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0010" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0010", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-21T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-21T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-22T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-22T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0012" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0012", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0012", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + } + ] +""".trimIndent() diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt index 4d492a000e5..a0448f02f38 100644 --- a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt @@ -16,7 +16,10 @@ import aws.smithy.kotlin.runtime.util.TestPlatformProvider import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.* -import kotlin.test.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.fail import kotlin.time.Duration.Companion.seconds class ImdsClientTest { @@ -200,7 +203,7 @@ class ImdsClientTest { client.get("/latest/metadata") } - assertEquals(HttpStatusCode.Forbidden.value, ex.statusCode) + assertEquals(HttpStatusCode.Forbidden, ex.statusCode) connection.assertRequests() } diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt new file mode 100644 index 00000000000..6c2ab05a596 --- /dev/null +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt @@ -0,0 +1,33 @@ +package aws.sdk.kotlin.runtime.util + +import aws.sdk.kotlin.runtime.config.imds.InstanceMetadataProvider +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.asserter + +class VerifyingInstanceMetadataProvider(expectations: List String>>) : InstanceMetadataProvider { + private val expectations = expectations.toMutableList() + + override fun close() = Unit + + override suspend fun get(path: String): String { + val trimmedPath = path.trimEnd('/') // remove trailing slashes to simplify testing + println("**** IMDS: $trimmedPath") + val next = assertNotNull(expectations.removeFirstOrNull(), "Call to \"$trimmedPath\" was unexpected!") + val (expectedPath, result) = next + assertEquals(trimmedPath, expectedPath, "Expected call to \"$expectedPath\" but got \"$trimmedPath\" instead!") + return result() + } + + fun verifyComplete() { + asserter.assertTrue( + lazyMessage = { + buildString { + appendLine("Not all expectations were met! Remaining paths which were not called:") + expectations.map { it.first }.forEach { appendLine("- $it") } + } + }, + actual = expectations.isEmpty(), + ) + } +} diff --git a/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProviderTest.kt b/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProviderTest.kt index d56b63cd225..b8489fc8f04 100644 --- a/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProviderTest.kt +++ b/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProviderTest.kt @@ -198,7 +198,7 @@ class DefaultChainCredentialsProviderTest { // In particular a test case only looks to verify a specific behavior and even though it // may fail at the correct spot, later providers may still be tried and also fail. val needle = expected.message - val haystack = listOf(ex.message!!) + ex.suppressed.map { it.message!! } + ex.suppressed.mapNotNull { it.cause?.message } + val haystack = ex.causesAndSuppressions().mapNotNull { it.message } val expectedErrorFound = haystack.any { it.contains(needle) } assertTrue(expectedErrorFound, "`$needle` not found in any of the chain exception messages: $haystack") } @@ -280,3 +280,9 @@ class DefaultChainCredentialsProviderTest { @Test fun testStsRetryOnError() = executeTest("retry_on_error") } + +private fun Throwable.causesAndSuppressions(): List = buildList { + add(this@causesAndSuppressions) + addAll(cause?.causesAndSuppressions().orEmpty()) + addAll(suppressedExceptions.flatMap { it.causesAndSuppressions() }) +} diff --git a/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt b/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt deleted file mode 100644 index 6065431eeb6..00000000000 --- a/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -package aws.sdk.kotlin.runtime.auth.credentials - -import aws.sdk.kotlin.runtime.config.imds.* -import aws.sdk.kotlin.runtime.config.imds.DEFAULT_TOKEN_TTL_SECONDS -import aws.smithy.kotlin.runtime.httptest.buildTestConnection -import aws.smithy.kotlin.runtime.time.ManualClock -import aws.smithy.kotlin.runtime.util.TestPlatformProvider -import io.mockk.coVerify -import io.mockk.spyk -import kotlinx.coroutines.test.runTest -import kotlin.test.* -import kotlin.time.Duration.Companion.minutes - -class ImdsCredentialsProviderTestJvm { - private val ec2MetadataEnabledPlatform = TestPlatformProvider() - - // FIXME Refactor mocking for KMP - // SDK can perform 3 successive requests with expired credentials. IMDS must only be called once. - @Test - fun testSuccessiveRequestsOnlyCallIMDSOnce() = runTest { - val connection = buildTestConnection { - expect( - tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), - tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), - ) - expect( - imdsRequest( - "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", - "TOKEN_A", - ), - imdsResponse( - """ - { - "Code" : "Success", - "LastUpdated" : "2021-09-17T20:57:08Z", - "Type" : "AWS-HMAC", - "AccessKeyId" : "ASIARTEST", - "SecretAccessKey" : "xjtest", - "Token" : "IQote///test", - "Expiration" : "2021-09-18T03:31:56Z" - } - """, - ), - ) - } - - val testClock = ManualClock() - - val client = spyk( - ImdsClient { - engine = connection - clock = testClock - }, - ) - - val provider = ImdsCredentialsProvider( - profileOverride = "imds-test-role", - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) - - // call resolve 3 times - repeat(3) { - provider.resolve() - } - - // make sure ImdsClient only gets called once - coVerify(exactly = 1) { - client.get(any()) - } - } - - // FIXME Refactor mocking for KMP - @Test - fun testDontRefreshUntilNextRefreshTimeHasPassed() = runTest { - val connection = buildTestConnection { - expect( - tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), - tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), - ) - expect( - imdsRequest( - "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", - "TOKEN_A", - ), - imdsResponse( - """ - { - "Code" : "Success", - "LastUpdated" : "2021-09-17T20:57:08Z", - "Type" : "AWS-HMAC", - "AccessKeyId" : "ASIARTEST", - "SecretAccessKey" : "xjtest", - "Token" : "IQote///test", - "Expiration" : "2021-09-18T03:31:56Z" - } - """, - ), - ) - expect( - imdsRequest( - "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", - "TOKEN_A", - ), - imdsResponse( - """ - { - "Code" : "Success", - "LastUpdated" : "2021-09-17T20:57:08Z", - "Type" : "AWS-HMAC", - "AccessKeyId" : "NEWCREDENTIALS", - "SecretAccessKey" : "shhh", - "Token" : "IQote///test", - "Expiration" : "2022-10-05T03:31:56Z" - } - """, - ), - ) - } - - val testClock = ManualClock() - - val client = spyk( - ImdsClient { - engine = connection - clock = testClock - }, - ) - - val provider = ImdsCredentialsProvider( - profileOverride = "imds-test-role", - client = lazyOf(client), - clock = testClock, - platformProvider = ec2MetadataEnabledPlatform, - ) - - val first = provider.resolve() - testClock.advance(20.minutes) // 20 minutes later, we should try to refresh the expired credentials - val second = provider.resolve() - - coVerify(exactly = 2) { - client.get(any()) - } - - // make sure we did not just serve the previous credentials - assertNotEquals(first, second) - } -} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8cf2f83af40..da25ea8f00c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -113,6 +113,7 @@ junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", vers kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest-version" } kotest-assertions-core-jvm = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest-version" } +kotest-framework-datatest = { module = "io.kotest:kotest-framework-datatest", version.ref = "kotest-version" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest-version" } kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinx-benchmark-version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-version" } From 44d2d5c2c75e96fdb2b78a06bdba62bdbc2a7a97 Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Mon, 14 Apr 2025 21:41:28 +0000 Subject: [PATCH 2/2] addressing PR feedback: * close auto-created ImdsClient in default creds chain * adding support for profile settings * removing println calls leftover from debug * rename resolveUnderLock to resolveSingleFlight * add FIXME for static stability messaging/caching in CachedCredentialsProvider * re-adding removed/broken members for API compatibility --- aws-runtime/aws-config/api/aws-config.api | 16 +++- .../DefaultChainCredentialsProvider.kt | 11 ++- .../credentials/ImdsCredentialsProvider.kt | 76 +++++++++++-------- .../kotlin/runtime/config/imds/ImdsClient.kt | 10 ++- .../runtime/config/imds/ImdsResolvers.kt | 29 +++++++ .../runtime/config/imds/ImdsRetryPolicy.kt | 2 +- .../runtime/config/profile/AwsProfile.kt | 14 ++++ .../runtime/config/imds/ImdsClientTest.kt | 2 +- .../util/VerifyingInstanceMetadataProvider.kt | 1 - 9 files changed, 118 insertions(+), 43 deletions(-) create mode 100644 aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsResolvers.kt diff --git a/aws-runtime/aws-config/api/aws-config.api b/aws-runtime/aws-config/api/aws-config.api index a682d2c856c..f4a15401515 100644 --- a/aws-runtime/aws-config/api/aws-config.api +++ b/aws-runtime/aws-config/api/aws-config.api @@ -82,6 +82,9 @@ public final class aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvid public fun (Ljava/lang/String;Lkotlin/Lazy;Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider;Laws/smithy/kotlin/runtime/time/Clock;)V public synthetic fun (Ljava/lang/String;Lkotlin/Lazy;Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider;Laws/smithy/kotlin/runtime/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V + public final fun getClient ()Lkotlin/Lazy; + public final fun getPlatformProvider ()Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider; + public final fun getProfileOverride ()Ljava/lang/String; public fun resolve (Laws/smithy/kotlin/runtime/collections/Attributes;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun toString ()Ljava/lang/String; } @@ -348,8 +351,10 @@ public final class aws/sdk/kotlin/runtime/config/endpoints/ResolversKt { } public final class aws/sdk/kotlin/runtime/config/imds/EC2MetadataError : aws/sdk/kotlin/runtime/AwsServiceException { + public fun (ILjava/lang/String;)V public fun (Laws/smithy/kotlin/runtime/http/HttpStatusCode;Ljava/lang/String;)V - public final fun getStatusCode ()Laws/smithy/kotlin/runtime/http/HttpStatusCode; + public final fun getStatus ()Laws/smithy/kotlin/runtime/http/HttpStatusCode; + public final fun getStatusCode ()I } public abstract class aws/sdk/kotlin/runtime/config/imds/EndpointConfiguration { @@ -418,6 +423,13 @@ public final class aws/sdk/kotlin/runtime/config/imds/ImdsClient$Companion { public final fun invoke (Lkotlin/jvm/functions/Function1;)Laws/sdk/kotlin/runtime/config/imds/ImdsClient; } +public final class aws/sdk/kotlin/runtime/config/imds/ImdsResolversKt { + public static final fun resolveDisableEc2Metadata (Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun resolveDisableEc2Metadata$default (Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun resolveEc2InstanceProfileName (Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun resolveEc2InstanceProfileName$default (Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; +} + public abstract interface class aws/sdk/kotlin/runtime/config/imds/InstanceMetadataProvider : java/io/Closeable { public abstract fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -518,6 +530,8 @@ public final class aws/sdk/kotlin/runtime/config/profile/AwsProfileKt { public static synthetic fun getBooleanOrNull$default (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/Boolean; public static final fun getCredentialProcess (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/String; public static final fun getDisableRequestCompression (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/Boolean; + public static final fun getEc2InstanceProfileName (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/String; + public static final fun getEc2MetadataDisabled (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/Boolean; public static final fun getEndpointDiscoveryEnabled (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/Boolean; public static final fun getEndpointUrl (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Laws/smithy/kotlin/runtime/net/url/Url; public static final fun getIgnoreEndpointUrls (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/Boolean; diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt index f249491dbcc..0b98da26e86 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt @@ -51,6 +51,11 @@ public class DefaultChainCredentialsProvider( private val manageEngine = httpClient == null private val engine = httpClient ?: DefaultHttpEngine() + private val imdsClient = ImdsClient { + platformProvider = this@DefaultChainCredentialsProvider.platformProvider + engine = this@DefaultChainCredentialsProvider.engine + } + private val chain = CredentialsProviderChain( SystemPropertyCredentialsProvider(platformProvider::getProperty), EnvironmentCredentialsProvider(platformProvider::getenv), @@ -59,10 +64,7 @@ public class DefaultChainCredentialsProvider( ProfileCredentialsProvider(profileName = profileName, platformProvider = platformProvider, httpClient = engine, region = region), EcsCredentialsProvider(platformProvider, engine), ImdsCredentialsProvider( - client = ImdsClient { - platformProvider = this@DefaultChainCredentialsProvider.platformProvider - engine = this@DefaultChainCredentialsProvider.engine - }, + client = imdsClient, platformProvider = platformProvider, ), ) @@ -73,6 +75,7 @@ public class DefaultChainCredentialsProvider( override fun close() { provider.close() + imdsClient.close() if (manageEngine) { engine.closeIfCloseable() } diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt index dad190bcdc2..f4794ba130a 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt @@ -6,15 +6,11 @@ package aws.sdk.kotlin.runtime.auth.credentials import aws.sdk.kotlin.runtime.auth.credentials.internal.credentials -import aws.sdk.kotlin.runtime.config.AwsSdkSetting -import aws.sdk.kotlin.runtime.config.imds.EC2MetadataError -import aws.sdk.kotlin.runtime.config.imds.ImdsClient -import aws.sdk.kotlin.runtime.config.imds.InstanceMetadataProvider +import aws.sdk.kotlin.runtime.config.imds.* import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.AwsBusinessMetric import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.withBusinessMetric import aws.smithy.kotlin.runtime.auth.awscredentials.* import aws.smithy.kotlin.runtime.collections.Attributes -import aws.smithy.kotlin.runtime.config.resolve import aws.smithy.kotlin.runtime.http.HttpStatusCode import aws.smithy.kotlin.runtime.io.IOException import aws.smithy.kotlin.runtime.serde.json.JsonDeserializer @@ -23,6 +19,7 @@ import aws.smithy.kotlin.runtime.time.Clock import aws.smithy.kotlin.runtime.util.PlatformEnvironProvider import aws.smithy.kotlin.runtime.util.PlatformProvider import aws.smithy.kotlin.runtime.util.SingleFlightGroup +import aws.smithy.kotlin.runtime.util.asyncLazy import kotlin.coroutines.coroutineContext private const val CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS: String = "AssumeRoleUnauthorizedAccess" @@ -43,7 +40,7 @@ private const val PROVIDER_NAME = "IMDSv2" public class ImdsCredentialsProvider( instanceProfileName: String? = null, client: InstanceMetadataProvider? = null, - private val platformProvider: PlatformProvider = PlatformProvider.System, + platformProvider: PlatformProvider = PlatformProvider.System, ) : CloseableCredentialsProvider { @Deprecated("This constructor supports parameters which are no longer used in the implementation. It will be removed in version 1.5.") @@ -52,20 +49,37 @@ public class ImdsCredentialsProvider( client: Lazy = lazy { ImdsClient() }, platformProvider: PlatformEnvironProvider = PlatformProvider.System, @Suppress("UNUSED_PARAMETER") clock: Clock = Clock.System, - ) : this(profileOverride, client.value, platformProvider = platformProvider as? PlatformProvider ?: PlatformProvider.System) + ) : this( + profileOverride, + client.value, + platformProvider = platformProvider as? PlatformProvider ?: PlatformProvider.System, + ) + + private val actualPlatformProvider = platformProvider + + @Deprecated("This property is retained for backwards compatibility but no longer needs to be public and will be removed in version 1.5.") + public val platformProvider: PlatformEnvironProvider = actualPlatformProvider private val manageClient: Boolean = client == null - private val client: InstanceMetadataProvider = client ?: ImdsClient { - this.platformProvider = this@ImdsCredentialsProvider.platformProvider + private val actualClient = client ?: ImdsClient { + this.platformProvider = actualPlatformProvider } - // FIXME This only resolves from env vars and sys props but we need to resolve from profiles too - private val instanceProfileName = instanceProfileName - ?: AwsSdkSetting.AwsEc2InstanceProfileName.resolve(platformProvider) + @Deprecated("This property is retained for backwards compatibility but no longer needs to be public and will be removed in version 1.5.") + public val client: Lazy + get() = lazyOf(actualClient) + + private val instanceProfileName = asyncLazy { + instanceProfileName ?: resolveEc2InstanceProfileName(platformProvider) + } - // FIXME This only resolves from env vars and sys props but we need to resolve from profiles too - private val providerDisabled = AwsSdkSetting.AwsEc2MetadataDisabled.resolve(platformProvider) == true + @Deprecated("This property is retained for backwards compatibility but no longer needs to be public and will be removed in version 1.5.") + public val profileOverride: String? = instanceProfileName + + private val providerDisabled = asyncLazy { + resolveDisableEc2Metadata(platformProvider) ?: false + } /** * Tracks the known-good version of IMDS APIs available in the local environment. This starts as `null` and will be @@ -89,19 +103,15 @@ public class ImdsCredentialsProvider( */ private val sfg = SingleFlightGroup() - override suspend fun resolve(attributes: Attributes): Credentials = sfg.singleFlight { resolveUnderLock() } - - private suspend fun resolveUnderLock(): Credentials { - println("**** Resolving creds (instanceProfileName=$instanceProfileName; apiVersion=$apiVersion; urlBase=$urlBase)") + override suspend fun resolve(attributes: Attributes): Credentials = sfg.singleFlight(::resolveSingleFlight) - if (providerDisabled) { - println("**** Explicitly disabled") + private suspend fun resolveSingleFlight(): Credentials { + if (providerDisabled.get()) { throw CredentialsNotLoadedException("AWS EC2 metadata is explicitly disabled; credentials not loaded") } - val profileName = instanceProfileName ?: resolvedProfileName ?: try { - println("**** Resolving profile") - client.get(urlBase).also { + val profileName = instanceProfileName.get() ?: resolvedProfileName ?: try { + actualClient.get(urlBase).also { if (apiVersion == null) { // Tried EXTENDED and it worked; remember that for the future apiVersion = ApiVersion.EXTENDED @@ -109,13 +119,13 @@ public class ImdsCredentialsProvider( } } catch (ex: EC2MetadataError) { when { - apiVersion == null && ex.statusCode == HttpStatusCode.NotFound -> { + apiVersion == null && ex.status == HttpStatusCode.NotFound -> { // Tried EXTENDED and that didn't work; fallback to LEGACY apiVersion = ApiVersion.LEGACY - return resolveUnderLock() + return resolveSingleFlight() } - ex.statusCode == HttpStatusCode.NotFound -> { + ex.status == HttpStatusCode.NotFound -> { coroutineContext.info { "Received 404 when loading profile name. This instance may not have an associated profile." } @@ -132,19 +142,19 @@ public class ImdsCredentialsProvider( } val credsPayload = try { - client.get("$urlBase$profileName") + actualClient.get("$urlBase$profileName") } catch (ex: EC2MetadataError) { when { - apiVersion == null && ex.statusCode == HttpStatusCode.NotFound -> { + apiVersion == null && ex.status == HttpStatusCode.NotFound -> { // Tried EXTENDED and that didn't work; fallback to LEGACY apiVersion = ApiVersion.LEGACY - return resolveUnderLock() + return resolveSingleFlight() } - instanceProfileName == null && ex.statusCode == HttpStatusCode.NotFound -> { + instanceProfileName.get() == null && ex.status == HttpStatusCode.NotFound -> { // A previously-resolved profile is now invalid; forget the resolved name and re-resolve resolvedProfileName = null - return resolveUnderLock() + return resolveSingleFlight() } else -> return usePreviousCredentials() @@ -157,7 +167,7 @@ public class ImdsCredentialsProvider( throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() } - if (instanceProfileName == null) { + if (instanceProfileName.get() == null) { // No profile name was provided at construction time; cache the resolved name resolvedProfileName = profileName } @@ -186,7 +196,7 @@ public class ImdsCredentialsProvider( override fun close() { if (manageClient) { - client.close() + actualClient.close() } } diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt index 6927da71bfa..58b9126930e 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt @@ -228,7 +228,13 @@ public enum class EndpointMode(internal val defaultEndpoint: Endpoint) { /** * Exception thrown when an error occurs retrieving metadata from IMDS - * @param statusCode The HTTP status code of the response + * @param status The HTTP status code of the response * @param message The error message */ -public class EC2MetadataError(public val statusCode: HttpStatusCode, message: String) : AwsServiceException(message) +public class EC2MetadataError(public val status: HttpStatusCode, message: String) : AwsServiceException(message) { + @Deprecated("This constructor passes HTTP status as an Int instead of as HttpStatusCode") + public constructor(statusCode: Int, message: String) : this(HttpStatusCode.fromValue(statusCode), message) + + @Deprecated("This property is now deprecated and should be fetched from status.value") + public val statusCode: Int = status.value +} diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsResolvers.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsResolvers.kt new file mode 100644 index 00000000000..52a30fbad09 --- /dev/null +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsResolvers.kt @@ -0,0 +1,29 @@ +package aws.sdk.kotlin.runtime.config.imds + +import aws.sdk.kotlin.runtime.InternalSdkApi +import aws.sdk.kotlin.runtime.config.AwsSdkSetting +import aws.sdk.kotlin.runtime.config.profile.AwsProfile +import aws.sdk.kotlin.runtime.config.profile.ec2InstanceProfileName +import aws.sdk.kotlin.runtime.config.profile.ec2MetadataDisabled +import aws.sdk.kotlin.runtime.config.profile.loadAwsSharedConfig +import aws.smithy.kotlin.runtime.config.resolve +import aws.smithy.kotlin.runtime.util.LazyAsyncValue +import aws.smithy.kotlin.runtime.util.PlatformProvider +import aws.smithy.kotlin.runtime.util.asyncLazy + +/** + * Attempts to resolve a named EC2 instance profile to use which allows bypassing auto-discovery + */ +@InternalSdkApi +public suspend fun resolveEc2InstanceProfileName( + provider: PlatformProvider = PlatformProvider.System, + profile: LazyAsyncValue = asyncLazy { loadAwsSharedConfig(provider).activeProfile }, +): String? = AwsSdkSetting.AwsEc2InstanceProfileName.resolve(provider) ?: profile.get().ec2InstanceProfileName + +/** + * Attempts to resolve the flag which disables the use of IMDS for credentials + */ +public suspend fun resolveDisableEc2Metadata( + provider: PlatformProvider = PlatformProvider.System, + profile: LazyAsyncValue = asyncLazy { loadAwsSharedConfig(provider).activeProfile }, +): Boolean? = AwsSdkSetting.AwsEc2MetadataDisabled.resolve(provider) ?: profile.get().ec2MetadataDisabled diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsRetryPolicy.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsRetryPolicy.kt index a70abfc5f58..aaca35a59c9 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsRetryPolicy.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsRetryPolicy.kt @@ -23,7 +23,7 @@ internal class ImdsRetryPolicy( private fun evaluate(throwable: Throwable): RetryDirective = when (throwable) { is EC2MetadataError -> { - val status = throwable.statusCode + val status = throwable.status when { status.category() == HttpStatusCode.Category.SERVER_ERROR -> RetryDirective.RetryError(RetryErrorType.ServerSide) // 401 indicates the token has expired, this is retryable diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/profile/AwsProfile.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/profile/AwsProfile.kt index fc6168c9dd7..4b3c73f26b8 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/profile/AwsProfile.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/profile/AwsProfile.kt @@ -177,6 +177,20 @@ public val AwsProfile.requestChecksumCalculation: RequestHttpChecksumConfig? public val AwsProfile.responseChecksumValidation: ResponseHttpChecksumConfig? get() = getEnumOrNull("response_checksum_validation") +/** + * Specifies a named EC2 instance profile to use which allows bypassing auto-discovery + */ +@InternalSdkApi +public val AwsProfile.ec2InstanceProfileName: String? + get() = getOrNull("ec2_instance_profile_name") + +/** + * The flag which disables the use of IMDS for credentials + */ +@InternalSdkApi +public val AwsProfile.ec2MetadataDisabled: Boolean? + get() = getBooleanOrNull("disable_ec2_metadata") + /** * Parse a config value as a boolean, ignoring case. */ diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt index a0448f02f38..71b34dd4425 100644 --- a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt @@ -203,7 +203,7 @@ class ImdsClientTest { client.get("/latest/metadata") } - assertEquals(HttpStatusCode.Forbidden, ex.statusCode) + assertEquals(HttpStatusCode.Forbidden, ex.status) connection.assertRequests() } diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt index 6c2ab05a596..0adf75e08a3 100644 --- a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt @@ -12,7 +12,6 @@ class VerifyingInstanceMetadataProvider(expectations: List St override suspend fun get(path: String): String { val trimmedPath = path.trimEnd('/') // remove trailing slashes to simplify testing - println("**** IMDS: $trimmedPath") val next = assertNotNull(expectations.removeFirstOrNull(), "Call to \"$trimmedPath\" was unexpected!") val (expectedPath, result) = next assertEquals(trimmedPath, expectedPath, "Expected call to \"$expectedPath\" but got \"$trimmedPath\" instead!")