-
Notifications
You must be signed in to change notification settings - Fork 55
refactor(rt)!: implement kmp ecs provider #475
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
Changes from all commits
f424b71
369f51c
d310117
e4ad4d5
52fb7a7
21c4b22
e47070d
8c44de8
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 |
|---|---|---|
|
|
@@ -5,27 +5,198 @@ | |
|
|
||
| package aws.sdk.kotlin.runtime.auth.credentials | ||
|
|
||
| import aws.sdk.kotlin.crt.auth.credentials.build | ||
| import aws.sdk.kotlin.runtime.crt.SdkDefaultIO | ||
| import aws.sdk.kotlin.crt.auth.credentials.EcsCredentialsProvider as EcsCredentialsProviderCrt | ||
| import aws.sdk.kotlin.runtime.config.AwsSdkSetting | ||
| import aws.sdk.kotlin.runtime.config.AwsSdkSetting.AwsContainerCredentialsRelativeUri | ||
| import aws.sdk.kotlin.runtime.config.resolve | ||
| import aws.sdk.kotlin.runtime.http.engine.crt.CrtHttpEngine | ||
| import aws.smithy.kotlin.runtime.ServiceException | ||
| 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.middleware.ResolveEndpoint | ||
| import aws.smithy.kotlin.runtime.http.middleware.Retry | ||
| import aws.smithy.kotlin.runtime.http.operation.* | ||
| import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder | ||
| import aws.smithy.kotlin.runtime.http.request.header | ||
| import aws.smithy.kotlin.runtime.http.response.HttpResponse | ||
| import aws.smithy.kotlin.runtime.io.Closeable | ||
| import aws.smithy.kotlin.runtime.logging.Logger | ||
| import aws.smithy.kotlin.runtime.retries.RetryDirective | ||
| import aws.smithy.kotlin.runtime.retries.RetryErrorType | ||
| import aws.smithy.kotlin.runtime.retries.RetryPolicy | ||
| import aws.smithy.kotlin.runtime.retries.impl.* | ||
| import aws.smithy.kotlin.runtime.serde.json.JsonDeserializer | ||
| import aws.smithy.kotlin.runtime.time.TimestampFormat | ||
| import aws.smithy.kotlin.runtime.util.Platform | ||
| import aws.smithy.kotlin.runtime.util.PlatformEnvironProvider | ||
|
|
||
| /** | ||
| * A provider that gets credentials from an ECS environment | ||
| * The elastic container metadata service endpoint that should be called by the [aws.sdk.kotlin.runtime.auth.credentials.EcsCredentialsProvider] | ||
| * when loading data from the container metadata service. | ||
| * | ||
| * @param host The host component of the URL to query credentials from | ||
| * @param pathAndQuery The path and query components of the URI, concatenated, to query credentials from | ||
| * @param authToken The token to pass to ECS credential service | ||
| * This is not used if the [AwsContainerCredentialsRelativeUri] is not specified. | ||
| */ | ||
| public class EcsCredentialsProvider public constructor( | ||
| host: String? = null, | ||
| pathAndQuery: String? = null, | ||
| authToken: String? = null, | ||
| ) : CrtCredentialsProvider { | ||
| override val crtProvider: EcsCredentialsProviderCrt = EcsCredentialsProviderCrt.build { | ||
| clientBootstrap = SdkDefaultIO.ClientBootstrap | ||
| tlsContext = SdkDefaultIO.TlsContext | ||
| this.host = host | ||
| this.pathAndQuery = pathAndQuery | ||
| this.authToken = authToken | ||
| private const val AWS_CONTAINER_SERVICE_ENDPOINT = "http://169.254.170.2" | ||
|
|
||
| /** | ||
| * A [CredentialsProvider] that sources credentials from a local metadata service. | ||
| * | ||
| * This provider is frequently used with an AWS-provided credentials service such as Amazon Container Service (ECS). | ||
| * However, it is possible to use environment variables to configure this provider to use any local metadata service. | ||
| * | ||
| * For more information on configuring ECS credentials see [IAM Roles for tasks](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html) | ||
| * | ||
| */ | ||
| public class EcsCredentialsProvider internal constructor( | ||
| private val platform: PlatformEnvironProvider, | ||
| private val httpClientEngine: HttpClientEngine | ||
| ) : CredentialsProvider, Closeable { | ||
|
|
||
| public constructor() : this(Platform, CrtHttpEngine()) | ||
|
|
||
| private val retryMiddleware = run { | ||
| val tokenBucket = StandardRetryTokenBucket(StandardRetryTokenBucketOptions.Default) | ||
| val delayProvider = ExponentialBackoffWithJitter(ExponentialBackoffWithJitterOptions.Default) | ||
| val strategy = StandardRetryStrategy(StandardRetryStrategyOptions.Default, tokenBucket, delayProvider) | ||
| val policy = EcsCredentialsRetryPolicy() | ||
| Retry<Credentials>(strategy, policy) | ||
| } | ||
|
|
||
| override suspend fun getCredentials(): Credentials { | ||
| val logger = Logger.getLogger<EcsCredentialsProvider>() | ||
| val authToken = AwsSdkSetting.AwsContainerAuthorizationToken.resolve(platform) | ||
| val relativeUri = AwsSdkSetting.AwsContainerCredentialsRelativeUri.resolve(platform) | ||
| val fullUri = AwsSdkSetting.AwsContainerCredentialsFullUri.resolve(platform) | ||
|
|
||
| val url = when { | ||
| relativeUri?.isBlank() == false -> validateRelativeUri(relativeUri) | ||
| fullUri?.isBlank() == false -> validateFullUri(fullUri) | ||
| else -> throw ProviderConfigurationException("Container credentials URI not set") | ||
| } | ||
|
|
||
| val op = SdkHttpOperation.build<Unit, Credentials> { | ||
| serializer = EcsCredentialsSerializer(authToken) | ||
| deserializer = EcsCredentialsDeserializer() | ||
| context { | ||
| operationName = "EcsCredentialsProvider" | ||
| service = "n/a" | ||
| } | ||
| } | ||
|
|
||
| op.install(ResolveEndpoint(resolver = { Endpoint(url) })) | ||
| op.install(retryMiddleware) | ||
|
|
||
| logger.debug { "retrieving container credentials" } | ||
| val client = sdkHttpClient(httpClientEngine, manageEngine = false) | ||
| val creds = try { | ||
| op.roundTrip(client, Unit) | ||
| } catch (ex: Exception) { | ||
| logger.debug { "failed to obtain credentials from container metadata service" } | ||
| throw when (ex) { | ||
| is CredentialsProviderException -> ex | ||
| else -> CredentialsProviderException("Failed to get credentials from container metadata service", ex) | ||
| } | ||
| } finally { | ||
| client.close() | ||
| } | ||
|
|
||
| logger.debug { "obtained credentials from container metadata service; expiration=${creds.expiration?.format(TimestampFormat.ISO_8601)}" } | ||
|
|
||
| return creds | ||
| } | ||
|
|
||
| /** | ||
| * Validate that the [relativeUri] can be combined with the static ECS endpoint to form a valid URL | ||
| */ | ||
| private fun validateRelativeUri(relativeUri: String): Url = try { | ||
| Url.parse("${AWS_CONTAINER_SERVICE_ENDPOINT}$relativeUri") | ||
| } catch (ex: Exception) { | ||
| throw ProviderConfigurationException("Invalid relativeUri `$relativeUri`", ex) | ||
| } | ||
|
|
||
| /** | ||
| * Validate that [uri] is valid to be used as a full provider URI | ||
| * | ||
| * Either: | ||
| * 1. The URL uses `https | ||
| * 2. The URL refers to a loopback device. If a URL contains a domain name instead of an IP address a DNS lookup | ||
| * will be performed. ALL resolved IP addresses MUST refer to a loopback interface. | ||
| * | ||
| * @return the validated URL | ||
| */ | ||
| private suspend fun validateFullUri(uri: String): Url { | ||
| // full URI requires verification either https OR that the host resolves to loopback device | ||
| val url = try { | ||
| Url.parse(uri) | ||
| } catch (ex: Exception) { | ||
| throw ProviderConfigurationException("Invalid fullUri `$uri`", ex) | ||
| } | ||
|
|
||
| if (url.scheme == Protocol.HTTPS) return url | ||
|
|
||
| // TODO - validate loopback via DNS resolution instead of fixed set. Custom host names (including localhost) that | ||
|
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 will this require some KMP work?
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. Yeah that's why it isn't implemented. We need the ability to actually resolve a host to something like ECS itself only uses the relative URI, this only comes into play when the full URI is set (and not using |
||
| // resolve to loopback won't work until then. ALL resolved addresses MUST resolve to the loopback device | ||
| val allowedHosts = setOf("127.0.0.1", "[::1]") | ||
|
|
||
| if (url.host !in allowedHosts) { | ||
| throw ProviderConfigurationException( | ||
| "The container credentials full URI ($uri) has an invalid host. Host can only be one of [${allowedHosts.joinToString()}]." | ||
| ) | ||
| } | ||
| return url | ||
| } | ||
|
|
||
| override fun close() { | ||
| httpClientEngine.close() | ||
| } | ||
| } | ||
| private class EcsCredentialsDeserializer : HttpDeserialize<Credentials> { | ||
| override suspend fun deserialize(context: ExecutionContext, response: HttpResponse): Credentials { | ||
| val payload = response.body.readAll() ?: throw CredentialsProviderException("HTTP credentials response did not contain a payload") | ||
| val deserializer = JsonDeserializer(payload) | ||
| return when (val resp = deserializeJsonCredentials(deserializer)) { | ||
| is JsonCredentialsResponse.SessionCredentials -> Credentials( | ||
| resp.accessKeyId, | ||
| resp.secretAccessKey, | ||
| resp.sessionToken, | ||
| resp.expiration | ||
| ) | ||
| is JsonCredentialsResponse.Error -> throw CredentialsProviderException("Error retrieving credentials from container service: code=${resp.code}; message=${resp.message}") | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private class EcsCredentialsSerializer( | ||
| private val authToken: String? = null | ||
| ) : HttpSerialize<Unit> { | ||
| override suspend fun serialize(context: ExecutionContext, input: Unit): HttpRequestBuilder { | ||
| val builder = HttpRequestBuilder() | ||
| builder.url.path | ||
| builder.header("Accept", "application/json") | ||
| builder.header("Accept-Encoding", "identity") | ||
| if (authToken != null) { | ||
| builder.header("Authorization", authToken) | ||
| } | ||
| return builder | ||
| } | ||
| } | ||
|
|
||
| internal class EcsCredentialsRetryPolicy : RetryPolicy<Any?> { | ||
| override fun evaluate(result: Result<Any?>): RetryDirective = when { | ||
| result.isSuccess -> RetryDirective.TerminateAndSucceed | ||
| else -> evaluate(result.exceptionOrNull()!!) | ||
|
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. nit it seems a little weird to call an
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. Ya I agree it's a little weird but there is no function on Seems like something is missing from |
||
| } | ||
|
|
||
| private fun evaluate(throwable: Throwable): RetryDirective = when (throwable) { | ||
| is ServiceException -> { | ||
| val httpResp = throwable.sdkErrorMetadata.protocolResponse as? HttpResponse | ||
| val status = httpResp?.status | ||
| if (status?.category() == HttpStatusCode.Category.SERVER_ERROR) { | ||
| RetryDirective.RetryError(RetryErrorType.ServerSide) | ||
| } else { | ||
| RetryDirective.TerminateAndFail | ||
| } | ||
| } | ||
| else -> RetryDirective.TerminateAndFail | ||
| } | ||
| } | ||
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