-
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
Conversation
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.
Looks good, minor concerns only.
| private val maxRetries: UInt = builder.maxRetries | ||
| private val endpointOverride: Endpoint? = builder.endpoint | ||
| private val endpointModeOverride: EndpointMode? = builder.endpointMode | ||
| private val tokenTTL: Duration = builder.tokenTTL |
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.
Style: tokenTTL → tokenTtl
| if (endpointOverride != null && endpointModeOverride != null) { | ||
| logger.warn { | ||
| "EndpointMode was set in combination with an explicit endpoint. " + | ||
| "The mode override will be ignored: endpointMode=$endpointModeOverride, endpoint=$endpointOverride" | ||
| } | ||
| } |
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: If only one of these should be set, is a union type a better fit?
sealed class EndpointConfiguration {
object Default : EndpointConfiguration()
data class Override(endpoint: Endpoint) : EndpointConfiguration()
data class ModeOverride(mode: EndpointMode): EndpointConfiguration()
}I see these parameters are also reused in ImdsEndpointResolver which seems like further argument for type encapsulation.
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.
yup that works for me, updated
| * Example: | ||
| * | ||
| * ```kotlin | ||
| * val client = EC2Metadata() | ||
| * val amiId = client.get("/latest/meta-data/ami-id") | ||
| * ``` |
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.
Comment: I like all the examples you're including in recent PRs. At some point, we should decide whether using the @sample KDoc tag is worthwhile. On the surface it seems like an excellent way to keep examples fresh/compiling as the code ages.
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.
TIL.
Do you have an example of using the tag? Happy to update.
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.
I don't necessarily think we should do it as part of this PR as it may be difficult to bootstrap initially but:
Here's an example of KDoc usage on String.replaceFirstChar and here's the sample to which it points.
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.
(Note that this doesn't actually work in the IDE for stdlib right now because of KTIJ-8414.)
| internal class ImdsEndpointResolver( | ||
| private val platformProvider: PlatformProvider, | ||
| private val endpointModeOverride: EndpointMode? = null, | ||
| private val endpointOverride: Endpoint? = null, | ||
| ) : EndpointResolver { | ||
| // cached endpoint and profile | ||
| private var resolvedEndpoint: Endpoint? = null | ||
| private var cachedProfile: AwsProfile? = null | ||
|
|
||
| override suspend fun resolve(service: String, region: String): Endpoint = resolvedEndpoint ?: doResolveEndpoint() |
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 does this class implement EndpointResolver if it ignores the input arguments to resolve? What values could be passed in? Would any of them represent a fundamental misalignment that means the resolution should fail?
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.
It implements EndpointResolver so that we can take advantage of ServiceEndpointResolver middleware (which will set the host, etc for us from a resolved endpoint).
We could forgo this and just duplicate the functionality if you find that preferable.
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.
Hmm, actually I'm noticing that the StaticEnpointResolver ignores service and region and the EndpointResolverGenerator ignores service. My concern here is no worse than those examples so we can leave it as-is for now. But this does makes me question the interface of EndpointResolver.
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.
Sure we can discuss that but IIRC the reason for the parameters is for the user to make a determination based on service and region. Not all resolvers need that information (e.g. the default resolver is generated per service so there is no need to use the service argument). A static resolver is well static so it's going to always return the same endpoint. A custom resolver may need that info is the primary driver.
| private suspend fun doResolveEndpoint(): Endpoint { | ||
| val resolved = endpointOverride ?: resolveEndpointFromConfig() | ||
| return resolved.also { resolvedEndpoint = it } | ||
| } |
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: This field-level caching strategy is not thread-safe (applies also to cachedProfile and TokenMiddlware.cachedToken). Is concurrent access a concern? Would by lazy be a better fit here?
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.
lazy doesn't work with suspend. Good call out though. Will fix.
|
|
||
| private suspend fun getToken(clock: Clock, req: SdkHttpRequest): Token { | ||
| val logger = req.context.getLogger("TokenMiddleware") | ||
| logger.trace { "refreshing IMDS token" } |
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.
Nit: This log message might be more useful if it included the values that led to us refreshing the token. For example: "Refreshing IMDS token because prior token expired at [timestamp] ([duration] ago)". (That might mean moving it elsewhere or refactoring...)
| package aws.sdk.kotlin.runtime.config | ||
| /* | ||
| * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. | ||
| * SPDX-License-Identifier: Apache-2.0. | ||
| */ | ||
|
|
||
| package aws.sdk.kotlin.runtime.config.profile |
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: Curious...how did ktlint not catch this?
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 don't use the same set of ktlint rules (the custom rules are only in smithy-kotlin)
| @@ -1,4 +1,4 @@ | |||
| package aws.sdk.kotlin.runtime.config | |||
| package aws.sdk.kotlin.runtime.config.profile | |||
|
|
|||
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.
Comment: Missing file header.
| Boolean::class -> strValue.toBoolean() | ||
| else -> error("conversion to ${T::class} not implemented for AwsSdkSetting") | ||
| } | ||
| return typed as? T |
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 as? (nullable)? Isn't typed guaranteed to be castable to T at this point?
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.
No, the resulting type is Any because all of the branches of when produce a different type. So we can also do as instead of as? if that is what you are asking. It won't matter in practice I think.
| public inline fun <reified T> AwsSdkSetting<T>.resolve(platform: PlatformEnvironProvider): T? { | ||
| val strValue = platform.getProperty(jvmProperty) ?: platform.getenv(environmentVariable) | ||
| if (strValue != null) { | ||
| val typed: Any = when (T::class) { | ||
| String::class -> strValue | ||
| Int::class -> strValue.toInt() | ||
| Long::class -> strValue.toLong() | ||
| Boolean::class -> strValue.toBoolean() | ||
| else -> error("conversion to ${T::class} not implemented for AwsSdkSetting") | ||
| } | ||
| return typed as? T | ||
| } | ||
| return defaultValue | ||
| } |
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: Should checking for an incorrect type happen before getting strValue?
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.
Not sure I follow...
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.
Nevermind, I didn't fully read this function.
| * @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. |
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 expiresAt to 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.
| /** | ||
| * 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 | ||
| } |
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 provider a supplier at getOrLoad time vs in the constructor?
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.
Hmm I suppose I can see it going either way
| @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 | ||
| ) { |
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.
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?
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.
Delegates don't support suspend
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.
(unless I'm missing something, because otherwise yes I would love for asyncLazy and this to work as a property delegate)
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.
No you're probably right. I often expect suspend to be more baked into the language/stdlib than it actually is.
| private val resolvedEndpoint = asyncLazy(::doResolveEndpoint) | ||
| private val activeProfile = asyncLazy { loadActiveAwsProfile(platformProvider) } |
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: Where is asyncLazy defined? I don't see it in this PR or in main.
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.
It was added in the companion smithy-kotlin PR
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.
Nice work, I especially like re-using the middleware facility for imds.
| coroutinesVersion=1.5.1 | ||
| atomicFuVersion=0.16.1 | ||
| kotlinxSerializationVersion=0.20.0 | ||
| kotlinxSerializationVersion=1.3.0 |
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
big jump! was this necessary to support something in the PR or just general maintenance?
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.
general maintenance. It looked odd that we were so far behind so I just bumped it
| private val props: Map<String, String> = emptyMap(), | ||
| private val fs: Map<String, String> = emptyMap(), | ||
| private val os: OperatingSystem = OperatingSystem(OsFamily.Linux, "test") | ||
| ) : PlatformProvider, Filesystem by Filesystem.fromMap(fs.mapValues { it.value.encodeToByteArray() }) { |
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.
nice
| * for more information. | ||
| */ | ||
| @OptIn(ExperimentalTime::class) | ||
| public class EC2Metadata private constructor(builder: Builder) : Closeable { |
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.
style/suggestion
I would put a "Client" suffix on this since it's a client and not a metadata.
| * Example: | ||
| * | ||
| * ```kotlin | ||
| * val client = EC2Metadata() |
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.
shouldnt this be val client = ImdsClient() ?
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.
sigh yes...little late on the review though 😉
Issue #
closes #224
Description of changes
Ec2Metadata)aws-configmodule and changed the package fromaws.sdk.kotlin.runtime.config->aws.sdk.kotlin.runtime.config.profilesmithy-kotlinsuch that they can be re-used in either runtime.Scope
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.