Skip to content

Conversation

@aajtodd
Copy link
Contributor

@aajtodd aajtodd commented Oct 5, 2021

Issue #

closes #224

Description of changes

  • Adds a new IMDSv2 client (Ec2Metadata)
    • Still left to do is handling retries and http timeouts. These will come in future PRs when the knobs are available
  • Relocated profile parsing and types to a new aws-config module and changed the package from aws.sdk.kotlin.runtime.config -> aws.sdk.kotlin.runtime.config.profile
  • Relocated several common test classes/types to smithy-kotlin such that they can be re-used in either runtime.

Scope

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

Copy link
Contributor

@ianbotsf ianbotsf left a 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: tokenTTLtokenTtl

Comment on lines 64 to 69
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"
}
}
Copy link
Contributor

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.

Copy link
Contributor Author

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

Comment on lines 111 to 116
* Example:
*
* ```kotlin
* val client = EC2Metadata()
* val amiId = client.get("/latest/meta-data/ami-id")
* ```
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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.

Copy link
Contributor

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.)

Comment on lines 20 to 29
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()
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Copy link
Contributor

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.

Copy link
Contributor Author

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.

Comment on lines 31 to 34
private suspend fun doResolveEndpoint(): Endpoint {
val resolved = endpointOverride ?: resolveEndpointFromConfig()
return resolved.also { resolvedEndpoint = it }
}
Copy link
Contributor

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?

Copy link
Contributor Author

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" }
Copy link
Contributor

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...)

Comment on lines -1 to +6
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
Copy link
Contributor

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?

Copy link
Contributor Author

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)

Comment on lines 1 to 2
@@ -1,4 +1,4 @@
package aws.sdk.kotlin.runtime.config
package aws.sdk.kotlin.runtime.config.profile

Copy link
Contributor

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
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Comment on lines +107 to +120
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
}
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure I follow...

Copy link
Contributor

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.

Comment on lines +24 to +26
* @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.
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Comment on lines +55 to +63
/**
* 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
}
Copy link
Contributor

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?

Copy link
Contributor Author

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

Comment on lines +29 to +34
@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
) {
Copy link
Contributor

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?

Copy link
Contributor Author

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

Copy link
Contributor Author

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)

Copy link
Contributor

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.

Comment on lines +25 to +26
private val resolvedEndpoint = asyncLazy(::doResolveEndpoint)
private val activeProfile = asyncLazy { loadActiveAwsProfile(platformProvider) }
Copy link
Contributor

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.

Copy link
Contributor Author

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

Copy link
Contributor

@kggilmer kggilmer left a 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
Copy link
Contributor

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?

Copy link
Contributor Author

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() }) {
Copy link
Contributor

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 {
Copy link
Contributor

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.

@aajtodd aajtodd merged commit 4c86d31 into main Oct 7, 2021
@aajtodd aajtodd deleted the feat-imds-client branch October 7, 2021 20:40
* Example:
*
* ```kotlin
* val client = EC2Metadata()

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() ?

Copy link
Contributor Author

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 😉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EC2 IMDS Client

4 participants