-
Notifications
You must be signed in to change notification settings - Fork 55
feat: IMDSv2 client #330
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: IMDSv2 client #330
Changes from all commits
bc7e1ea
7d0d16a
2ec0efc
8dbe193
a816f87
a0647f1
61b3c01
59d033a
a34e6e2
7dc3ec5
a022160
e4a27e1
fd0c78c
1c7a96f
96a26bf
bec6740
d8e0cc9
4487bc2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| /* | ||
| * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| * SPDX-License-Identifier: Apache-2.0. | ||
| */ | ||
|
|
||
| description = "Support for AWS configuration" | ||
| extra["moduleName"] = "aws.sdk.kotlin.runtime.config" | ||
|
|
||
| val smithyKotlinVersion: String by project | ||
|
|
||
| kotlin { | ||
| sourceSets { | ||
| commonMain { | ||
| dependencies { | ||
| api(project(":aws-runtime:aws-core")) | ||
| implementation("aws.smithy.kotlin:logging:$smithyKotlinVersion") | ||
| implementation("aws.smithy.kotlin:http:$smithyKotlinVersion") | ||
| implementation("aws.smithy.kotlin:utils:$smithyKotlinVersion") | ||
| implementation(project(":aws-runtime:http-client-engine-crt")) | ||
| implementation(project(":aws-runtime:protocols:http")) | ||
| } | ||
| } | ||
| commonTest { | ||
| dependencies { | ||
| implementation(project(":aws-runtime:testing")) | ||
| implementation("aws.smithy.kotlin:http-test:$smithyKotlinVersion") | ||
| val kotlinxSerializationVersion: String by project | ||
| val mockkVersion: String by project | ||
| implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion") | ||
| implementation("io.mockk:mockk:$mockkVersion") | ||
| } | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| /* | ||
| * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| * SPDX-License-Identifier: Apache-2.0. | ||
| */ | ||
|
|
||
| package aws.sdk.kotlin.runtime.config | ||
|
|
||
| import aws.smithy.kotlin.runtime.time.Clock | ||
| import aws.smithy.kotlin.runtime.time.Instant | ||
| import kotlinx.coroutines.sync.Mutex | ||
| import kotlinx.coroutines.sync.withLock | ||
| import kotlin.time.Duration | ||
| import kotlin.time.ExperimentalTime | ||
|
|
||
| /** | ||
| * A value with an expiration | ||
| */ | ||
| internal data class ExpiringValue<T> (val value: T, val expiresAt: Instant) | ||
|
|
||
| /** | ||
| * Expiry aware value | ||
| * | ||
| * @param value The value that expires | ||
| * @param bufferTime The amount of time before the actual expiration time when the value is considered expired. By default | ||
| * the buffer time is zero meaning the value expires at the expiration time. A non-zero buffer time means the value will | ||
| * expire BEFORE the actual expiration. | ||
| * @param clock The clock to use for system time | ||
| */ | ||
| @OptIn(ExperimentalTime::class) | ||
| internal class CachedValue<T> ( | ||
| private var value: ExpiringValue<T>? = null, | ||
| private val bufferTime: Duration = Duration.seconds(0), | ||
| private val clock: Clock = Clock.System | ||
| ) { | ||
|
Comment on lines
+29
to
+34
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Comment: While I love that this is now encapsulated and I think it will have a lot of uses elsewhere, I wonder if the ergonomics can be improved. It would be delightful to be able to use a delegate such that callers could: val latestFoo: Foo by expiringCache(Duration.seconds(60)) {
Foo(…)
}Thoughts?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Delegates don't support
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (unless I'm missing something, because otherwise yes I would love for
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No you're probably right. I often expect |
||
| constructor(value: T, expiresAt: Instant, bufferTime: Duration = Duration.seconds(0), clock: Clock = Clock.System) : this(ExpiringValue(value, expiresAt), bufferTime, clock) | ||
| private val mu = Mutex() | ||
|
|
||
| /** | ||
| * Check if the value is expired or not as compared to the time [now] | ||
| */ | ||
| suspend fun isExpired(): Boolean = mu.withLock { isExpiredUnlocked() } | ||
|
|
||
| private fun isExpiredUnlocked(): Boolean { | ||
| val curr = value ?: return true | ||
| return clock.now() >= (curr.expiresAt - bufferTime) | ||
| } | ||
|
|
||
| /** | ||
| * Get the value if it has not expired yet. Returns null if the value has expired | ||
| */ | ||
| suspend fun get(): T? = mu.withLock { | ||
| if (!isExpiredUnlocked()) return value!!.value else null | ||
| } | ||
|
|
||
| /** | ||
| * Attempt to get the value or refresh it with [initializer] if it is expired | ||
| */ | ||
| suspend fun getOrLoad(initializer: suspend () -> ExpiringValue<T>): T = mu.withLock { | ||
| if (!isExpiredUnlocked()) return@withLock value!!.value | ||
|
|
||
| val refreshed = initializer().also { value = it } | ||
| return refreshed.value | ||
| } | ||
|
Comment on lines
+55
to
+63
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Question: Why provider a supplier at
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm I suppose I can see it going either way |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| /* | ||
| * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| * SPDX-License-Identifier: Apache-2.0. | ||
| */ | ||
|
|
||
| package aws.sdk.kotlin.runtime.config.imds | ||
|
|
||
| import aws.sdk.kotlin.runtime.AwsServiceException | ||
| import aws.sdk.kotlin.runtime.ConfigurationException | ||
| import aws.sdk.kotlin.runtime.client.AwsClientOption | ||
| import aws.sdk.kotlin.runtime.endpoint.Endpoint | ||
| import aws.sdk.kotlin.runtime.http.ApiMetadata | ||
| import aws.sdk.kotlin.runtime.http.AwsUserAgentMetadata | ||
| import aws.sdk.kotlin.runtime.http.engine.crt.CrtHttpEngine | ||
| import aws.sdk.kotlin.runtime.http.middleware.ServiceEndpointResolver | ||
| import aws.sdk.kotlin.runtime.http.middleware.UserAgent | ||
| import aws.smithy.kotlin.runtime.client.ExecutionContext | ||
| import aws.smithy.kotlin.runtime.http.* | ||
| import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine | ||
| import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineConfig | ||
| import aws.smithy.kotlin.runtime.http.operation.* | ||
| import aws.smithy.kotlin.runtime.http.response.HttpResponse | ||
| import aws.smithy.kotlin.runtime.io.Closeable | ||
| import aws.smithy.kotlin.runtime.io.middleware.Phase | ||
| import aws.smithy.kotlin.runtime.logging.Logger | ||
| import aws.smithy.kotlin.runtime.time.Clock | ||
| import aws.smithy.kotlin.runtime.util.Platform | ||
| import aws.smithy.kotlin.runtime.util.PlatformProvider | ||
| import kotlin.time.Duration | ||
| import kotlin.time.ExperimentalTime | ||
|
|
||
| /** | ||
| * Maximum time allowed by default (6 hours) | ||
| */ | ||
| internal const val DEFAULT_TOKEN_TTL_SECONDS: Int = 21_600 | ||
| internal const val DEFAULT_MAX_RETRIES: UInt = 3u | ||
|
|
||
| private const val SERVICE = "imds" | ||
|
|
||
| /** | ||
| * IMDSv2 Client | ||
| * | ||
| * This client supports fetching tokens, retrying failures, and token caching according to the specified TTL. | ||
| * | ||
| * NOTE: This client ONLY supports IMDSv2. It will not fallback to IMDSv1. | ||
| * See [transitioning to IMDSv2](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html#instance-metadata-transition-to-version-2) | ||
| * for more information. | ||
| */ | ||
| @OptIn(ExperimentalTime::class) | ||
| public class ImdsClient private constructor(builder: Builder) : Closeable { | ||
| public constructor() : this(Builder()) | ||
|
|
||
| private val logger = Logger.getLogger<ImdsClient>() | ||
|
|
||
| private val maxRetries: UInt = builder.maxRetries | ||
| private val endpointConfiguration: EndpointConfiguration = builder.endpointConfiguration | ||
| private val tokenTtl: Duration = builder.tokenTTL | ||
| private val clock: Clock = builder.clock | ||
| private val platformProvider: PlatformProvider = builder.platformProvider | ||
|
|
||
| init { | ||
| // validate the override at construction time | ||
| if (endpointConfiguration is EndpointConfiguration.Custom) { | ||
| val url = endpointConfiguration.endpoint.toUrl() | ||
| try { | ||
| Url.parse(url.toString()) | ||
| } catch (ex: Exception) { | ||
| throw ConfigurationException("Invalid endpoint configuration: `$url` is not a valid URI", ex) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // TODO connect/socket timeouts | ||
| private val httpClient = sdkHttpClient(builder.engine ?: CrtHttpEngine(HttpClientEngineConfig())) | ||
|
|
||
| // cached middleware instances | ||
| private val middleware: List<Feature> = listOf( | ||
| ServiceEndpointResolver.create { | ||
| serviceId = SERVICE | ||
| resolver = ImdsEndpointResolver(platformProvider, endpointConfiguration) | ||
| }, | ||
| UserAgent.create { | ||
| metadata = AwsUserAgentMetadata.fromEnvironment(ApiMetadata(SERVICE, "unknown")) | ||
| }, | ||
| TokenMiddleware.create { | ||
| httpClient = this@ImdsClient.httpClient | ||
| ttl = tokenTtl | ||
| clock = this@ImdsClient.clock | ||
| } | ||
| ) | ||
|
|
||
| public companion object { | ||
| public operator fun invoke(block: Builder.() -> Unit): ImdsClient = ImdsClient(Builder().apply(block)) | ||
| } | ||
|
|
||
| /** | ||
| * Retrieve information from instance metadata service (IMDS). | ||
| * | ||
| * This method will combine [path] with the configured endpoint and return the response as a string. | ||
| * | ||
| * For more information about IMDSv2 methods and functionality, see | ||
| * [Instance metadata and user data](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html) | ||
| * | ||
| * Example: | ||
| * | ||
| * ```kotlin | ||
| * val client = EC2Metadata() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldnt this be
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. sigh yes...little late on the review though 😉 |
||
| * val amiId = client.get("/latest/meta-data/ami-id") | ||
| * ``` | ||
| */ | ||
| public suspend fun get(path: String): String { | ||
| val op = SdkHttpOperation.build<Unit, String> { | ||
| serializer = UnitSerializer | ||
| deserializer = object : HttpDeserialize<String> { | ||
| override suspend fun deserialize(context: ExecutionContext, response: HttpResponse): String { | ||
| if (response.status.isSuccess()) { | ||
| val payload = response.body.readAll() ?: throw EC2MetadataError(response.status.value, "no metadata payload") | ||
| return payload.decodeToString() | ||
| } else { | ||
| throw EC2MetadataError(response.status.value, "error retrieving instance metadata") | ||
| } | ||
| } | ||
| } | ||
| context { | ||
| operationName = path | ||
| service = SERVICE | ||
| // artifact of re-using ServiceEndpointResolver middleware | ||
| set(AwsClientOption.Region, "not-used") | ||
| } | ||
| } | ||
| middleware.forEach { it.install(op) } | ||
| op.execution.mutate.intercept(Phase.Order.Before) { req, next -> | ||
| req.subject.url.path = path | ||
| next.call(req) | ||
| } | ||
|
|
||
| // TODO - retries | ||
| return op.roundTrip(httpClient, Unit) | ||
| } | ||
|
|
||
| override fun close() { | ||
| httpClient.close() | ||
| } | ||
|
|
||
| public class Builder { | ||
| /** | ||
| * The maximum number of retries for fetching tokens and metadata | ||
| */ | ||
| public var maxRetries: UInt = DEFAULT_MAX_RETRIES | ||
|
|
||
| /** | ||
| * The endpoint configuration to use when making requests | ||
| */ | ||
| public var endpointConfiguration: EndpointConfiguration = EndpointConfiguration.Default | ||
|
|
||
| /** | ||
| * Override the time-to-live for the session token | ||
| */ | ||
| public var tokenTTL: Duration = Duration.seconds(DEFAULT_TOKEN_TTL_SECONDS) | ||
|
|
||
| /** | ||
| * The HTTP engine to use to make requests with. This is here to facilitate testing and can otherwise be ignored | ||
| */ | ||
| internal var engine: HttpClientEngine? = null | ||
|
|
||
| /** | ||
| * The source of time for token refreshes. This is here to facilitate testing and can otherwise be ignored | ||
| */ | ||
| internal var clock: Clock = Clock.System | ||
|
|
||
| /** | ||
| * The platform provider. This is here to facilitate testing and can otherwise be ignored | ||
| */ | ||
| internal var platformProvider: PlatformProvider = Platform | ||
| } | ||
| } | ||
|
|
||
| public sealed class EndpointConfiguration { | ||
| /** | ||
| * Detected from the execution environment | ||
| */ | ||
| public object Default : EndpointConfiguration() | ||
|
|
||
| /** | ||
| * Override the endpoint to make requests to | ||
| */ | ||
| public data class Custom(val endpoint: Endpoint) : EndpointConfiguration() | ||
|
|
||
| /** | ||
| * Override the [EndpointMode] to use | ||
| */ | ||
| public data class ModeOverride(val mode: EndpointMode) : EndpointConfiguration() | ||
| } | ||
|
|
||
| public enum class EndpointMode(internal val defaultEndpoint: Endpoint) { | ||
| /** | ||
| * IPv4 mode. This is the default unless otherwise specified | ||
| * e.g. `http://169.254.169.254' | ||
| */ | ||
| IPv4(Endpoint("169.254.169.254", "http")), | ||
|
|
||
| /** | ||
| * IPv6 mode | ||
| * e.g. `http://[fd00:ec2::254]` | ||
| */ | ||
| IPv6(Endpoint("[fd00:ec2::254]", "http")); | ||
|
|
||
| public companion object { | ||
| public fun fromValue(value: String): EndpointMode = when (value.lowercase()) { | ||
| "ipv4" -> IPv4 | ||
| "ipv6" -> IPv6 | ||
| else -> throw IllegalArgumentException("invalid EndpointMode: `$value`") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Exception thrown when an error occurs retrieving metadata from IMDS | ||
| * | ||
| * @param statusCode The raw HTTP status code of the response | ||
| * @param message The error message | ||
| */ | ||
| public class EC2MetadataError(public val statusCode: Int, message: String) : AwsServiceException(message) | ||
|
|
||
| private fun Endpoint.toUrl(): Url { | ||
| val endpoint = this | ||
| val protocol = Protocol.parse(endpoint.protocol) | ||
| return Url( | ||
| scheme = protocol, | ||
| host = endpoint.hostname, | ||
| port = endpoint.port ?: protocol.defaultPort, | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Question: Why do we need a buffer time? If that modifies the expiration to be an earlier expiration then why not just set
expiresAtto something sooner?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could do that too, this was an idea from Rust really that seemed like a useful abstraction to just encapsulate vs having to manually modify expirations wherever you might want a buffer. It also helps in debugging such that you can see the original expiration time vs some manually modified one.