Skip to content

Commit 4c86d31

Browse files
authored
feat: IMDSv2 client (#330)
1 parent 4ae0349 commit 4c86d31

File tree

36 files changed

+1254
-392
lines changed

36 files changed

+1254
-392
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
description = "Support for AWS configuration"
7+
extra["moduleName"] = "aws.sdk.kotlin.runtime.config"
8+
9+
val smithyKotlinVersion: String by project
10+
11+
kotlin {
12+
sourceSets {
13+
commonMain {
14+
dependencies {
15+
api(project(":aws-runtime:aws-core"))
16+
implementation("aws.smithy.kotlin:logging:$smithyKotlinVersion")
17+
implementation("aws.smithy.kotlin:http:$smithyKotlinVersion")
18+
implementation("aws.smithy.kotlin:utils:$smithyKotlinVersion")
19+
implementation(project(":aws-runtime:http-client-engine-crt"))
20+
implementation(project(":aws-runtime:protocols:http"))
21+
}
22+
}
23+
commonTest {
24+
dependencies {
25+
implementation(project(":aws-runtime:testing"))
26+
implementation("aws.smithy.kotlin:http-test:$smithyKotlinVersion")
27+
val kotlinxSerializationVersion: String by project
28+
val mockkVersion: String by project
29+
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationVersion")
30+
implementation("io.mockk:mockk:$mockkVersion")
31+
}
32+
}
33+
}
34+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.sdk.kotlin.runtime.config
7+
8+
import aws.smithy.kotlin.runtime.time.Clock
9+
import aws.smithy.kotlin.runtime.time.Instant
10+
import kotlinx.coroutines.sync.Mutex
11+
import kotlinx.coroutines.sync.withLock
12+
import kotlin.time.Duration
13+
import kotlin.time.ExperimentalTime
14+
15+
/**
16+
* A value with an expiration
17+
*/
18+
internal data class ExpiringValue<T> (val value: T, val expiresAt: Instant)
19+
20+
/**
21+
* Expiry aware value
22+
*
23+
* @param value The value that expires
24+
* @param bufferTime The amount of time before the actual expiration time when the value is considered expired. By default
25+
* the buffer time is zero meaning the value expires at the expiration time. A non-zero buffer time means the value will
26+
* expire BEFORE the actual expiration.
27+
* @param clock The clock to use for system time
28+
*/
29+
@OptIn(ExperimentalTime::class)
30+
internal class CachedValue<T> (
31+
private var value: ExpiringValue<T>? = null,
32+
private val bufferTime: Duration = Duration.seconds(0),
33+
private val clock: Clock = Clock.System
34+
) {
35+
constructor(value: T, expiresAt: Instant, bufferTime: Duration = Duration.seconds(0), clock: Clock = Clock.System) : this(ExpiringValue(value, expiresAt), bufferTime, clock)
36+
private val mu = Mutex()
37+
38+
/**
39+
* Check if the value is expired or not as compared to the time [now]
40+
*/
41+
suspend fun isExpired(): Boolean = mu.withLock { isExpiredUnlocked() }
42+
43+
private fun isExpiredUnlocked(): Boolean {
44+
val curr = value ?: return true
45+
return clock.now() >= (curr.expiresAt - bufferTime)
46+
}
47+
48+
/**
49+
* Get the value if it has not expired yet. Returns null if the value has expired
50+
*/
51+
suspend fun get(): T? = mu.withLock {
52+
if (!isExpiredUnlocked()) return value!!.value else null
53+
}
54+
55+
/**
56+
* Attempt to get the value or refresh it with [initializer] if it is expired
57+
*/
58+
suspend fun getOrLoad(initializer: suspend () -> ExpiringValue<T>): T = mu.withLock {
59+
if (!isExpiredUnlocked()) return@withLock value!!.value
60+
61+
val refreshed = initializer().also { value = it }
62+
return refreshed.value
63+
}
64+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.sdk.kotlin.runtime.config.imds
7+
8+
import aws.sdk.kotlin.runtime.AwsServiceException
9+
import aws.sdk.kotlin.runtime.ConfigurationException
10+
import aws.sdk.kotlin.runtime.client.AwsClientOption
11+
import aws.sdk.kotlin.runtime.endpoint.Endpoint
12+
import aws.sdk.kotlin.runtime.http.ApiMetadata
13+
import aws.sdk.kotlin.runtime.http.AwsUserAgentMetadata
14+
import aws.sdk.kotlin.runtime.http.engine.crt.CrtHttpEngine
15+
import aws.sdk.kotlin.runtime.http.middleware.ServiceEndpointResolver
16+
import aws.sdk.kotlin.runtime.http.middleware.UserAgent
17+
import aws.smithy.kotlin.runtime.client.ExecutionContext
18+
import aws.smithy.kotlin.runtime.http.*
19+
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine
20+
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineConfig
21+
import aws.smithy.kotlin.runtime.http.operation.*
22+
import aws.smithy.kotlin.runtime.http.response.HttpResponse
23+
import aws.smithy.kotlin.runtime.io.Closeable
24+
import aws.smithy.kotlin.runtime.io.middleware.Phase
25+
import aws.smithy.kotlin.runtime.logging.Logger
26+
import aws.smithy.kotlin.runtime.time.Clock
27+
import aws.smithy.kotlin.runtime.util.Platform
28+
import aws.smithy.kotlin.runtime.util.PlatformProvider
29+
import kotlin.time.Duration
30+
import kotlin.time.ExperimentalTime
31+
32+
/**
33+
* Maximum time allowed by default (6 hours)
34+
*/
35+
internal const val DEFAULT_TOKEN_TTL_SECONDS: Int = 21_600
36+
internal const val DEFAULT_MAX_RETRIES: UInt = 3u
37+
38+
private const val SERVICE = "imds"
39+
40+
/**
41+
* IMDSv2 Client
42+
*
43+
* This client supports fetching tokens, retrying failures, and token caching according to the specified TTL.
44+
*
45+
* NOTE: This client ONLY supports IMDSv2. It will not fallback to IMDSv1.
46+
* See [transitioning to IMDSv2](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html#instance-metadata-transition-to-version-2)
47+
* for more information.
48+
*/
49+
@OptIn(ExperimentalTime::class)
50+
public class ImdsClient private constructor(builder: Builder) : Closeable {
51+
public constructor() : this(Builder())
52+
53+
private val logger = Logger.getLogger<ImdsClient>()
54+
55+
private val maxRetries: UInt = builder.maxRetries
56+
private val endpointConfiguration: EndpointConfiguration = builder.endpointConfiguration
57+
private val tokenTtl: Duration = builder.tokenTTL
58+
private val clock: Clock = builder.clock
59+
private val platformProvider: PlatformProvider = builder.platformProvider
60+
61+
init {
62+
// validate the override at construction time
63+
if (endpointConfiguration is EndpointConfiguration.Custom) {
64+
val url = endpointConfiguration.endpoint.toUrl()
65+
try {
66+
Url.parse(url.toString())
67+
} catch (ex: Exception) {
68+
throw ConfigurationException("Invalid endpoint configuration: `$url` is not a valid URI", ex)
69+
}
70+
}
71+
}
72+
73+
// TODO connect/socket timeouts
74+
private val httpClient = sdkHttpClient(builder.engine ?: CrtHttpEngine(HttpClientEngineConfig()))
75+
76+
// cached middleware instances
77+
private val middleware: List<Feature> = listOf(
78+
ServiceEndpointResolver.create {
79+
serviceId = SERVICE
80+
resolver = ImdsEndpointResolver(platformProvider, endpointConfiguration)
81+
},
82+
UserAgent.create {
83+
metadata = AwsUserAgentMetadata.fromEnvironment(ApiMetadata(SERVICE, "unknown"))
84+
},
85+
TokenMiddleware.create {
86+
httpClient = this@ImdsClient.httpClient
87+
ttl = tokenTtl
88+
clock = this@ImdsClient.clock
89+
}
90+
)
91+
92+
public companion object {
93+
public operator fun invoke(block: Builder.() -> Unit): ImdsClient = ImdsClient(Builder().apply(block))
94+
}
95+
96+
/**
97+
* Retrieve information from instance metadata service (IMDS).
98+
*
99+
* This method will combine [path] with the configured endpoint and return the response as a string.
100+
*
101+
* For more information about IMDSv2 methods and functionality, see
102+
* [Instance metadata and user data](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-instance-metadata.html)
103+
*
104+
* Example:
105+
*
106+
* ```kotlin
107+
* val client = EC2Metadata()
108+
* val amiId = client.get("/latest/meta-data/ami-id")
109+
* ```
110+
*/
111+
public suspend fun get(path: String): String {
112+
val op = SdkHttpOperation.build<Unit, String> {
113+
serializer = UnitSerializer
114+
deserializer = object : HttpDeserialize<String> {
115+
override suspend fun deserialize(context: ExecutionContext, response: HttpResponse): String {
116+
if (response.status.isSuccess()) {
117+
val payload = response.body.readAll() ?: throw EC2MetadataError(response.status.value, "no metadata payload")
118+
return payload.decodeToString()
119+
} else {
120+
throw EC2MetadataError(response.status.value, "error retrieving instance metadata")
121+
}
122+
}
123+
}
124+
context {
125+
operationName = path
126+
service = SERVICE
127+
// artifact of re-using ServiceEndpointResolver middleware
128+
set(AwsClientOption.Region, "not-used")
129+
}
130+
}
131+
middleware.forEach { it.install(op) }
132+
op.execution.mutate.intercept(Phase.Order.Before) { req, next ->
133+
req.subject.url.path = path
134+
next.call(req)
135+
}
136+
137+
// TODO - retries
138+
return op.roundTrip(httpClient, Unit)
139+
}
140+
141+
override fun close() {
142+
httpClient.close()
143+
}
144+
145+
public class Builder {
146+
/**
147+
* The maximum number of retries for fetching tokens and metadata
148+
*/
149+
public var maxRetries: UInt = DEFAULT_MAX_RETRIES
150+
151+
/**
152+
* The endpoint configuration to use when making requests
153+
*/
154+
public var endpointConfiguration: EndpointConfiguration = EndpointConfiguration.Default
155+
156+
/**
157+
* Override the time-to-live for the session token
158+
*/
159+
public var tokenTTL: Duration = Duration.seconds(DEFAULT_TOKEN_TTL_SECONDS)
160+
161+
/**
162+
* The HTTP engine to use to make requests with. This is here to facilitate testing and can otherwise be ignored
163+
*/
164+
internal var engine: HttpClientEngine? = null
165+
166+
/**
167+
* The source of time for token refreshes. This is here to facilitate testing and can otherwise be ignored
168+
*/
169+
internal var clock: Clock = Clock.System
170+
171+
/**
172+
* The platform provider. This is here to facilitate testing and can otherwise be ignored
173+
*/
174+
internal var platformProvider: PlatformProvider = Platform
175+
}
176+
}
177+
178+
public sealed class EndpointConfiguration {
179+
/**
180+
* Detected from the execution environment
181+
*/
182+
public object Default : EndpointConfiguration()
183+
184+
/**
185+
* Override the endpoint to make requests to
186+
*/
187+
public data class Custom(val endpoint: Endpoint) : EndpointConfiguration()
188+
189+
/**
190+
* Override the [EndpointMode] to use
191+
*/
192+
public data class ModeOverride(val mode: EndpointMode) : EndpointConfiguration()
193+
}
194+
195+
public enum class EndpointMode(internal val defaultEndpoint: Endpoint) {
196+
/**
197+
* IPv4 mode. This is the default unless otherwise specified
198+
* e.g. `http://169.254.169.254'
199+
*/
200+
IPv4(Endpoint("169.254.169.254", "http")),
201+
202+
/**
203+
* IPv6 mode
204+
* e.g. `http://[fd00:ec2::254]`
205+
*/
206+
IPv6(Endpoint("[fd00:ec2::254]", "http"));
207+
208+
public companion object {
209+
public fun fromValue(value: String): EndpointMode = when (value.lowercase()) {
210+
"ipv4" -> IPv4
211+
"ipv6" -> IPv6
212+
else -> throw IllegalArgumentException("invalid EndpointMode: `$value`")
213+
}
214+
}
215+
}
216+
217+
/**
218+
* Exception thrown when an error occurs retrieving metadata from IMDS
219+
*
220+
* @param statusCode The raw HTTP status code of the response
221+
* @param message The error message
222+
*/
223+
public class EC2MetadataError(public val statusCode: Int, message: String) : AwsServiceException(message)
224+
225+
private fun Endpoint.toUrl(): Url {
226+
val endpoint = this
227+
val protocol = Protocol.parse(endpoint.protocol)
228+
return Url(
229+
scheme = protocol,
230+
host = endpoint.hostname,
231+
port = endpoint.port ?: protocol.defaultPort,
232+
)
233+
}

0 commit comments

Comments
 (0)