Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions aws-runtime/aws-config/build.gradle.kts
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.
Comment on lines +24 to +26
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.

* @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
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.

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

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

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 😉

* 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,
)
}
Loading