From af4778a1f523e4d310ef5b4cb71eb8779956da12 Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Thu, 2 May 2024 18:48:09 +0000 Subject: [PATCH 1/9] chore: address testcontainers vulnerability by replacing with docker-java --- build.gradle.kts | 3 + gradle/libs.versions.toml | 6 +- .../test-suite/build.gradle.kts | 4 +- .../kotlin/runtime/http/test/MitmContainer.kt | 54 +++++++ .../kotlin/runtime/http/test/ProxyTest.kt | 73 ++++----- .../kotlin/runtime/http/test/util/Docker.kt | 153 ++++++++++++++++++ .../kotlin/runtime/http/test/util/Poller.kt | 52 ++++++ runtime/runtime-core/api/runtime-core.api | 1 + .../aws/smithy/kotlin/runtime/time/Instant.kt | 6 + .../smithy/kotlin/runtime/time/InstantJVM.kt | 5 + 10 files changed, 310 insertions(+), 47 deletions(-) create mode 100644 runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt create mode 100644 runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt create mode 100644 runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Poller.kt diff --git a/build.gradle.kts b/build.gradle.kts index 5ff5b26b5..da9ffca7c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -83,6 +83,9 @@ allprojects { ) } } + + // Enables running `./gradlew allDeps` to get a comprehensive list of dependencies for every subproject + tasks.register("allDeps") { } } // configure the root multimodule docs diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d75fd27c7..e4fab3f4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ kotest-version = "5.8.0" kotlin-compile-testing-version = "1.5.0" kotlinx-benchmark-version = "0.4.9" kotlinx-serialization-version = "1.6.0" -testcontainers-version = "1.19.1" +docker-client-version = "3.3.6" ktor-version = "2.3.6" kaml-version = "0.55.0" jsoup-version = "1.16.2" @@ -81,8 +81,8 @@ kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version. kotest-assertions-core-jvm = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest-version" } kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinx-benchmark-version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-version" } -testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers-version" } -testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers-version" } +docker-core = { module = "com.github.docker-java:docker-java-core", version.ref = "docker-client-version" } +docker-transport-zerodep = { module = "com.github.docker-java:docker-java-transport-zerodep", version.ref = "docker-client-version" } ktor-http-cio = { module = "io.ktor:ktor-http-cio", version.ref = "ktor-version" } ktor-utils = { module = "io.ktor:ktor-utils", version.ref = "ktor-version" } diff --git a/runtime/protocol/http-client-engines/test-suite/build.gradle.kts b/runtime/protocol/http-client-engines/test-suite/build.gradle.kts index 7fbc7afa5..22e774304 100644 --- a/runtime/protocol/http-client-engines/test-suite/build.gradle.kts +++ b/runtime/protocol/http-client-engines/test-suite/build.gradle.kts @@ -42,8 +42,8 @@ kotlin { jvmTest { dependencies { - implementation(libs.testcontainers) - implementation(libs.testcontainers.junit.jupiter) + implementation(libs.docker.core) + implementation(libs.docker.transport.zerodep) } } diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt new file mode 100644 index 000000000..3922aefc8 --- /dev/null +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt @@ -0,0 +1,54 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.http.test + +import aws.smithy.kotlin.runtime.http.test.util.Docker +import com.github.dockerjava.api.model.AccessMode +import com.github.dockerjava.api.model.Bind +import com.github.dockerjava.api.model.ExposedPort +import com.github.dockerjava.api.model.Volume +import java.io.Closeable + +private const val CONTAINER_MOUNT_POINT = "/home/mitmproxy/scripts" +private const val CONTAINER_PORT = 8080 +private const val IMAGE_NAME = "mitmproxy/mitmproxy:8.1.0" +private val PROXY_SCRIPT_ROOT = System.getProperty("MITM_PROXY_SCRIPTS_ROOT") // defined by gradle script + +// Port used for communication with container +private val exposedPort = ExposedPort.tcp(CONTAINER_PORT) + +class MitmContainer(vararg options: String) : Closeable { + private val delegate: Docker.Container + + init { + val cmd = listOf( + "mitmdump", + "--flow-detail", + "2", + "-s", + "$CONTAINER_MOUNT_POINT/fakeupstream.py", + *options, + ).also { println("Initializing container with command: $it") } + + // Make proxy scripts from host available to container + val binding = Bind(PROXY_SCRIPT_ROOT, Volume(CONTAINER_MOUNT_POINT), AccessMode.ro) + + try { + delegate = Docker.Instance.createContainer(IMAGE_NAME, cmd, binding, exposedPort).apply { + attachLogger(::println) + start() + waitUntilReady() + } + } catch (e: Throwable) { + close() + throw e + } + } + + val hostPort: Int + get() = delegate.hostPort + + override fun close() = delegate.close() +} diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/ProxyTest.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/ProxyTest.kt index 3ed4a6575..e5d55a4c3 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/ProxyTest.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/ProxyTest.kt @@ -2,7 +2,6 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ - package aws.smithy.kotlin.runtime.http.test import aws.smithy.kotlin.runtime.http.HttpStatusCode @@ -18,49 +17,34 @@ import aws.smithy.kotlin.runtime.http.test.util.AbstractEngineTest import aws.smithy.kotlin.runtime.http.test.util.engineConfig import aws.smithy.kotlin.runtime.http.test.util.test import aws.smithy.kotlin.runtime.net.url.Url +import org.junit.jupiter.api.AfterAll +import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance import org.junit.jupiter.api.condition.EnabledIfSystemProperty -import org.testcontainers.containers.BindMode -import org.testcontainers.containers.GenericContainer -import org.testcontainers.junit.jupiter.Container -import org.testcontainers.junit.jupiter.Testcontainers -import org.testcontainers.utility.DockerImageName import kotlin.test.assertEquals -// defined by gradle script -private val PROXY_SCRIPT_ROOT = System.getProperty("MITM_PROXY_SCRIPTS_ROOT") -private fun mitmProxyContainer( - vararg options: String, -) = GenericContainer(DockerImageName.parse("mitmproxy/mitmproxy:8.1.0")) - .withExposedPorts(8080) - .withFileSystemBind(PROXY_SCRIPT_ROOT, "/home/mitmproxy/scripts", BindMode.READ_ONLY) - .withLogConsumer { - print(it.utf8String) - }.apply { - val command = buildString { - // load the custom addon which by default does nothing without setting additional options - append("mitmdump --flow-detail 2 -s /home/mitmproxy/scripts/fakeupstream.py") - append(options.joinToString(separator = " ", prefix = " ")) - } - withCommand(command) - } - -@Testcontainers(disabledWithoutDocker = true) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) // enables non-static @BeforeAll/@AfterAll methods @EnabledIfSystemProperty(named = "aws.test.http.enableProxyTests", matches = "true") class ProxyTest : AbstractEngineTest() { + private lateinit var mitmProxy: MitmContainer + + @BeforeAll + fun setUp() { + mitmProxy = MitmContainer("--set", "fakeupstream=aws.amazon.com") + } - @Container - val mitmProxy = mitmProxyContainer("--set fakeupstream=aws.amazon.com") + @AfterAll + fun cleanUp() { + mitmProxy.close() + } @Test - fun testHttpProxy() = testEngines( - // we would expect a customer to configure proxy support on the underlying engine - skipEngines = setOf("KtorEngine"), - ) { + fun testHttpProxy() = testEngines { engineConfig { - val proxyPort = mitmProxy.getMappedPort(8080) + val hostPort = mitmProxy.hostPort proxySelector = ProxySelector { - ProxyConfig.Http("http://127.0.0.1:$proxyPort") + ProxyConfig.Http("http://127.0.0.1:$hostPort") } } @@ -70,22 +54,27 @@ class ProxyTest : AbstractEngineTest() { } } -@Testcontainers(disabledWithoutDocker = true) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) // enables non-static @BeforeAll/@AfterAll methods @EnabledIfSystemProperty(named = "aws.test.http.enableProxyTests", matches = "true") class ProxyAuthTest : AbstractEngineTest() { + private lateinit var mitmProxy: MitmContainer - @Container - val mitmProxy = mitmProxyContainer("--proxyauth testuser:testpass --set fakeupstream=aws.amazon.com") + @BeforeAll + fun setUp() { + mitmProxy = MitmContainer("--proxyauth", "testuser:testpass", "--set", "fakeupstream=aws.amazon.com") + } + + @AfterAll + fun cleanUp() { + mitmProxy.close() + } @Test - fun testHttpProxyAuth() = testEngines( - // we would expect a customer to configure proxy support on the underlying engine - skipEngines = setOf("KtorEngine"), - ) { + fun testHttpProxyAuth() = testEngines { engineConfig { - val proxyPort = mitmProxy.getMappedPort(8080) + val hostPort = mitmProxy.hostPort proxySelector = ProxySelector { - ProxyConfig.Http("http://testuser:testpass@127.0.0.1:$proxyPort") + ProxyConfig.Http("http://testuser:testpass@127.0.0.1:$hostPort") } } diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt new file mode 100644 index 000000000..321971eb3 --- /dev/null +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt @@ -0,0 +1,153 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.http.test.util + +import aws.smithy.kotlin.runtime.util.OsFamily +import aws.smithy.kotlin.runtime.util.PlatformProvider +import com.github.dockerjava.api.async.ResultCallback +import com.github.dockerjava.api.command.AsyncDockerCmd +import com.github.dockerjava.api.command.SyncDockerCmd +import com.github.dockerjava.api.model.* +import com.github.dockerjava.core.DefaultDockerClientConfig +import com.github.dockerjava.core.DockerClientImpl +import com.github.dockerjava.zerodep.ZerodepDockerHttpClient +import java.io.Closeable +import java.io.IOException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.URI +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.measureTimedValue + +private val MAX_POLL_TIME = 10.seconds +private const val POLL_CONNECT_TIMEOUT_MS = 100 +private val POLL_INTERVAL = 250.milliseconds + +/** + * Wrapper class for the Docker client + */ +class Docker { + companion object { + val Instance by lazy { Docker() } + } + + private val client = run { + val config = DefaultDockerClientConfig.createDefaultConfigBuilder().build() + + // Default Docker host locations according to Docker reference guide: + // https://docs.docker.com/reference/cli/dockerd/#bind-docker-to-another-hostport-or-a-unix-socket + val dockerHostString = when (PlatformProvider.System.osInfo().family) { + OsFamily.Windows -> "tcp://localhost:2376" + else -> "unix:///var/run/docker.sock" + } + + val httpClient = ZerodepDockerHttpClient.Builder() + .dockerHost(URI.create(dockerHostString)) + .build() + + DockerClientImpl.getInstance(config, httpClient) + } + + fun createContainer(imageName: String, cmd: List, bind: Bind, exposedPort: ExposedPort): Container { + val portBinding = PortBinding(Ports.Binding.empty(), exposedPort) + + val hostConfig = HostConfig + .newHostConfig() + .withBinds(bind) + .withPortBindings(portBinding) + + val id = client + .createContainerCmd(imageName) + .withHostConfig(hostConfig) + .withExposedPorts(exposedPort) + .withCmd(cmd) + .execAndMeasure { "Created container ${it.id}" } + .id + .substring(0..<12) // Short container IDs are 12 chars vs full container IDs at 64 chars + + return Container(id, exposedPort) + } + + inner class Container(val id: String, val exposedPort: ExposedPort) : Closeable { + private val poller = Poller(MAX_POLL_TIME, POLL_INTERVAL) + + fun attachLogger(handler: (String) -> Unit) { + val logger = object : ResultCallback.Adapter() { + override fun onNext(frame: Frame?) { + frame?.payload?.decodeToString()?.let(handler) + } + } + + client + .attachContainerCmd(id) + .withFollowStream(true) + .withStdOut(true) + .withStdErr(true) + .withLogs(true) + .execAndMeasure(logger) { "Attached logger to container $id" } + .awaitStarted() + } + + override fun close() { + client + .removeContainerCmd(id) + .withForce(true) + .exec() + .also { println("Container $id removed") } + } + + val hostPort: Int by lazy { + poller.pollNotNull("Port $exposedPort in container $id") { + client + .inspectContainerCmd(id) + .exec() + .networkSettings + .ports + .bindings[exposedPort] + ?.first() + ?.hostPortSpec + ?.toInt() + } + } + + private fun isReady() = + Socket().use { socket -> + val endpoint = InetSocketAddress(InetAddress.getLocalHost(), hostPort) + try { + socket.connect(endpoint, POLL_CONNECT_TIMEOUT_MS) + true + } catch (e: IOException) { + false + } + } + + fun start() { + client.startContainerCmd(id).execAndMeasure { "Container $id running" } + } + + fun waitUntilReady() = poller.pollTrue("Socket localHost:$hostPort → $exposedPort on container $id", ::isReady) + } +} + +private fun SyncDockerCmd.execAndMeasure(msg: (T) -> String): T { + val (value, duration) = measureTimedValue { + exec() + } + println("${msg(value)} in $duration") + return value +} + +private fun ?, T, I : ResultCallback> AsyncDockerCmd.execAndMeasure( + input: I, + msg: (I) -> String, +): I { + val (value, duration) = measureTimedValue { + exec(input) + } + println("${msg(value)} in $duration") + return value +} diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Poller.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Poller.kt new file mode 100644 index 000000000..6d15bd92c --- /dev/null +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Poller.kt @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.http.test.util + +import aws.smithy.kotlin.runtime.time.Instant +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlin.time.Duration + +/** + * A utility class for polling a resource until some specific condition is met + * @param maxWait The maximum amount of time the poller will wait for a condition to be met + * @param interval The amount of time to delay between polls + */ +class Poller(val maxWait: Duration, val interval: Duration) { + /** + * Poll [resource] by executing [action] until it returns true + */ + fun pollTrue(resource: String, action: () -> Boolean) { + poll(resource, action) { it } + } + + /** + * Poll [resource] by executing [action] until it returns non-null + */ + fun pollNotNull(resource: String, action: () -> T?): T = poll(resource, action) { it != null }!! + + /** + * Poll [resource] by executing [action] until [condition] is met on the result + */ + fun poll(resource: String, action: () -> T, condition: (T) -> Boolean): T = runBlocking { + val startTime = Instant.now() + val stopTime = startTime + maxWait + + var result = action() + var ready = condition(result) + while (!ready && (Instant.now() + interval < stopTime)) { + delay(interval) + result = action() + ready = condition(result) + } + + check(ready) { "$resource not ready within $maxWait" } + + val elapsed = Instant.now() - startTime + println("$resource is ready after $elapsed") + + result + } +} diff --git a/runtime/runtime-core/api/runtime-core.api b/runtime/runtime-core/api/runtime-core.api index 01b1c46db..a41a4aa47 100644 --- a/runtime/runtime-core/api/runtime-core.api +++ b/runtime/runtime-core/api/runtime-core.api @@ -2086,6 +2086,7 @@ public final class aws/smithy/kotlin/runtime/time/Instant : java/lang/Comparable public final fun getEpochSeconds ()J public final fun getNanosecondsOfSecond ()I public fun hashCode ()I + public final fun minus-5sfh64U (Laws/smithy/kotlin/runtime/time/Instant;)J public final fun minus-LRDsOJo (J)Laws/smithy/kotlin/runtime/time/Instant; public final fun plus-LRDsOJo (J)Laws/smithy/kotlin/runtime/time/Instant; public fun toString ()Ljava/lang/String; diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt index f9ffd6395..bd13ab6b7 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt @@ -44,6 +44,12 @@ public expect class Instant : Comparable { */ public operator fun minus(duration: Duration): Instant + /** + * Returns the duration between [other] and this instant. NOTE: The duration will be negative if [other] occurred + * after this instant. + */ + public operator fun minus(other: Instant): Duration + public companion object { /** * Parse an ISO-8601 formatted string into an [Instant] diff --git a/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt index 54f32fc30..7ed814ae3 100644 --- a/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt +++ b/runtime/runtime-core/jvm/src/aws/smithy/kotlin/runtime/time/InstantJVM.kt @@ -22,6 +22,8 @@ import java.time.format.SignStyle import java.time.temporal.ChronoField import java.time.temporal.ChronoUnit import kotlin.time.Duration +import kotlin.time.toKotlinDuration +import java.time.Duration as jtDuration import java.time.Instant as jtInstant public actual class Instant(internal val value: jtInstant) : Comparable { @@ -57,6 +59,9 @@ public actual class Instant(internal val value: jtInstant) : Comparable */ public actual operator fun minus(duration: Duration): Instant = plus(-duration) + public actual operator fun minus(other: Instant): Duration = + jtDuration.between(other.value, value).toKotlinDuration() + /** * Encode the [Instant] as a string into the format specified by [TimestampFormat] */ From 5b3fbcd83f3bd22d18432bb8dcfc4bd8a0a2ba33 Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Thu, 2 May 2024 19:23:07 +0000 Subject: [PATCH 2/9] clean up error handling for Docker containers --- .../test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt index 3922aefc8..85f24bbc4 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt @@ -35,8 +35,10 @@ class MitmContainer(vararg options: String) : Closeable { // Make proxy scripts from host available to container val binding = Bind(PROXY_SCRIPT_ROOT, Volume(CONTAINER_MOUNT_POINT), AccessMode.ro) + delegate = Docker.Instance.createContainer(IMAGE_NAME, cmd, binding, exposedPort) + try { - delegate = Docker.Instance.createContainer(IMAGE_NAME, cmd, binding, exposedPort).apply { + delegate.apply { attachLogger(::println) start() waitUntilReady() From 51dd9b1c0496ee31f547fbfdaf525a627a5d06ce Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Thu, 2 May 2024 21:45:57 +0000 Subject: [PATCH 3/9] add support for pulling Docker images which do not exist locally --- .../kotlin/runtime/http/test/MitmContainer.kt | 1 - .../kotlin/runtime/http/test/util/Docker.kt | 59 ++++++++++++++----- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt index 85f24bbc4..609cc94b0 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt @@ -39,7 +39,6 @@ class MitmContainer(vararg options: String) : Closeable { try { delegate.apply { - attachLogger(::println) start() waitUntilReady() } diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt index 321971eb3..50ad4c9f2 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt @@ -52,7 +52,15 @@ class Docker { DockerClientImpl.getInstance(config, httpClient) } - fun createContainer(imageName: String, cmd: List, bind: Bind, exposedPort: ExposedPort): Container { + fun createContainer( + imageName: String, + cmd: List, + bind: Bind, + exposedPort: ExposedPort, + loggingHandler: (String) -> Unit = ::println, + ): Container { + ensureImageExists(imageName, loggingHandler) + val portBinding = PortBinding(Ports.Binding.empty(), exposedPort) val hostConfig = HostConfig @@ -69,28 +77,38 @@ class Docker { .id .substring(0..<12) // Short container IDs are 12 chars vs full container IDs at 64 chars + val loggerAdapter = LoggerAdapter(loggingHandler) { it.payload.decodeToString() } + + client + .attachContainerCmd(id) + .withFollowStream(true) + .withStdOut(true) + .withStdErr(true) + .withLogs(true) + .execAndMeasure(loggerAdapter) { "Attached logger to container $id" } + .awaitStarted() + return Container(id, exposedPort) } - inner class Container(val id: String, val exposedPort: ExposedPort) : Closeable { - private val poller = Poller(MAX_POLL_TIME, POLL_INTERVAL) + private fun ensureImageExists(imageName: String, loggingHandler: (String) -> Unit) { + val exists = client + .listImagesCmd() + .withImageNameFilter(imageName) + .execAndMeasure { "Checking for $imageName locally (exists = ${it.any()})" } + .any() - fun attachLogger(handler: (String) -> Unit) { - val logger = object : ResultCallback.Adapter() { - override fun onNext(frame: Frame?) { - frame?.payload?.decodeToString()?.let(handler) - } - } + if (!exists) { + val loggerAdapter = LoggerAdapter(loggingHandler) { it.status } client - .attachContainerCmd(id) - .withFollowStream(true) - .withStdOut(true) - .withStdErr(true) - .withLogs(true) - .execAndMeasure(logger) { "Attached logger to container $id" } - .awaitStarted() + .pullImageCmd(imageName) + .execAndMeasure(loggerAdapter) { "Pulled missing image $imageName" } } + } + + inner class Container(val id: String, val exposedPort: ExposedPort) : Closeable { + private val poller = Poller(MAX_POLL_TIME, POLL_INTERVAL) override fun close() { client @@ -133,6 +151,15 @@ class Docker { } } +private class LoggerAdapter( + val handler: (String) -> Unit, + val converter: (I) -> String?, +) : ResultCallback.Adapter() { + override fun onNext(value: I?) { + value?.let(converter)?.let(handler) + } +} + private fun SyncDockerCmd.execAndMeasure(msg: (T) -> String): T { val (value, duration) = measureTimedValue { exec() From fd84e931f413a208bf03f47ffbc84d134ccb7711 Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Thu, 2 May 2024 22:00:35 +0000 Subject: [PATCH 4/9] use the right filter criteria --- .../jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt index 50ad4c9f2..7f3b57a8f 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt @@ -94,7 +94,7 @@ class Docker { private fun ensureImageExists(imageName: String, loggingHandler: (String) -> Unit) { val exists = client .listImagesCmd() - .withImageNameFilter(imageName) + .withReferenceFilter(imageName) .execAndMeasure { "Checking for $imageName locally (exists = ${it.any()})" } .any() From 51eed2a4c6779e5c0c46a93d7c92b89958bfbe72 Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Thu, 2 May 2024 22:10:16 +0000 Subject: [PATCH 5/9] wait for image pull completion --- .../test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt index 7f3b57a8f..4d7e79682 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt @@ -103,7 +103,8 @@ class Docker { client .pullImageCmd(imageName) - .execAndMeasure(loggerAdapter) { "Pulled missing image $imageName" } + .execAndMeasure(loggerAdapter) { "Started image pull for $imageName" } + .awaitCompletion() } } From 1319a2c1c2285fd99ec3ddcf2a856c301671ef71 Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Thu, 2 May 2024 22:18:58 +0000 Subject: [PATCH 6/9] add omitted `actual` minus function for `InstantNative` --- .../src/aws/smithy/kotlin/runtime/time/InstantNative.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt index 671cae6e3..00bc92f2f 100644 --- a/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt +++ b/runtime/runtime-core/native/src/aws/smithy/kotlin/runtime/time/InstantNative.kt @@ -40,6 +40,10 @@ public actual class Instant : Comparable { TODO("Not yet implemented") } + public actual operator fun minus(other: Instant): Duration { + TODO("Not yet implemented") + } + public actual companion object { /** * Parse an ISO-8601 formatted string into an [Instant] From df59afc94fe5814a92b12900df015dee758f27b0 Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Thu, 2 May 2024 22:49:21 +0000 Subject: [PATCH 7/9] only run Docker tests on Linux --- .../http-client-engines/test-suite/build.gradle.kts | 6 +++++- .../aws/smithy/kotlin/runtime/http/test/util/Docker.kt | 10 ++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/runtime/protocol/http-client-engines/test-suite/build.gradle.kts b/runtime/protocol/http-client-engines/test-suite/build.gradle.kts index 22e774304..06d011297 100644 --- a/runtime/protocol/http-client-engines/test-suite/build.gradle.kts +++ b/runtime/protocol/http-client-engines/test-suite/build.gradle.kts @@ -114,9 +114,13 @@ tasks.jvmTest { // set test environment for proxy tests systemProperty("MITM_PROXY_SCRIPTS_ROOT", projectDir.resolve("proxy-scripts").absolutePath) systemProperty("SSL_CONFIG_PATH", startTestServers.sslConfigPath) + val enableProxyTestsProp = "aws.test.http.enableProxyTests" val runningInCodeBuild = System.getenv().containsKey("CODEBUILD_BUILD_ID") - systemProperty(enableProxyTestsProp, System.getProperties().getOrDefault(enableProxyTestsProp, !runningInCodeBuild)) + val runningInLinux = System.getProperty("os.name").contains("Linux", ignoreCase = true) + val shouldRunProxyTests = !runningInCodeBuild && runningInLinux + + systemProperty(enableProxyTestsProp, System.getProperties().getOrDefault(enableProxyTestsProp, shouldRunProxyTests)) } gradle.buildFinished { diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt index 4d7e79682..106847c99 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt @@ -23,6 +23,7 @@ import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds import kotlin.time.measureTimedValue +private val DOCKER_HOST = URI.create("unix:///var/run/docker.sock") private val MAX_POLL_TIME = 10.seconds private const val POLL_CONNECT_TIMEOUT_MS = 100 private val POLL_INTERVAL = 250.milliseconds @@ -38,15 +39,8 @@ class Docker { private val client = run { val config = DefaultDockerClientConfig.createDefaultConfigBuilder().build() - // Default Docker host locations according to Docker reference guide: - // https://docs.docker.com/reference/cli/dockerd/#bind-docker-to-another-hostport-or-a-unix-socket - val dockerHostString = when (PlatformProvider.System.osInfo().family) { - OsFamily.Windows -> "tcp://localhost:2376" - else -> "unix:///var/run/docker.sock" - } - val httpClient = ZerodepDockerHttpClient.Builder() - .dockerHost(URI.create(dockerHostString)) + .dockerHost(DOCKER_HOST) .build() DockerClientImpl.getInstance(config, httpClient) From a3af2c292794d9e3fc8234fa27c6a0c4c14ac14a Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Thu, 2 May 2024 22:54:34 +0000 Subject: [PATCH 8/9] lint --- .../jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt index 106847c99..510a5c5b5 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/util/Docker.kt @@ -4,8 +4,6 @@ */ package aws.smithy.kotlin.runtime.http.test.util -import aws.smithy.kotlin.runtime.util.OsFamily -import aws.smithy.kotlin.runtime.util.PlatformProvider import com.github.dockerjava.api.async.ResultCallback import com.github.dockerjava.api.command.AsyncDockerCmd import com.github.dockerjava.api.command.SyncDockerCmd From c761b629e4ca8767ad1fa3f2f4d2931a085b38fc Mon Sep 17 00:00:00 2001 From: Ian Botsford <83236726+ianbotsf@users.noreply.github.com> Date: Fri, 3 May 2024 15:23:58 +0000 Subject: [PATCH 9/9] address PR feedback: clean up version name and add KDocs/comments --- gradle/libs.versions.toml | 6 +++--- .../smithy/kotlin/runtime/http/test/MitmContainer.kt | 12 ++++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e4fab3f4e..59ca097fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ kotest-version = "5.8.0" kotlin-compile-testing-version = "1.5.0" kotlinx-benchmark-version = "0.4.9" kotlinx-serialization-version = "1.6.0" -docker-client-version = "3.3.6" +docker-java-version = "3.3.6" ktor-version = "2.3.6" kaml-version = "0.55.0" jsoup-version = "1.16.2" @@ -81,8 +81,8 @@ kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version. kotest-assertions-core-jvm = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest-version" } kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinx-benchmark-version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-version" } -docker-core = { module = "com.github.docker-java:docker-java-core", version.ref = "docker-client-version" } -docker-transport-zerodep = { module = "com.github.docker-java:docker-java-transport-zerodep", version.ref = "docker-client-version" } +docker-core = { module = "com.github.docker-java:docker-java-core", version.ref = "docker-java-version" } +docker-transport-zerodep = { module = "com.github.docker-java:docker-java-transport-zerodep", version.ref = "docker-java-version" } ktor-http-cio = { module = "io.ktor:ktor-http-cio", version.ref = "ktor-version" } ktor-utils = { module = "io.ktor:ktor-utils", version.ref = "ktor-version" } diff --git a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt index 609cc94b0..9561fffac 100644 --- a/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt +++ b/runtime/protocol/http-client-engines/test-suite/jvm/test/aws/smithy/kotlin/runtime/http/test/MitmContainer.kt @@ -19,12 +19,17 @@ private val PROXY_SCRIPT_ROOT = System.getProperty("MITM_PROXY_SCRIPTS_ROOT") // // Port used for communication with container private val exposedPort = ExposedPort.tcp(CONTAINER_PORT) +/** + * A Docker container which runs the **mitmproxy** image. Upon instantiating this class, a docker container will be + * created and ran with a logger attached echoing logs out to **STDOUT**. The container will be stopped and removed when + * [close] is called. + */ class MitmContainer(vararg options: String) : Closeable { private val delegate: Docker.Container init { val cmd = listOf( - "mitmdump", + "mitmdump", // https://docs.mitmproxy.org/stable/#mitmdump "--flow-detail", "2", "-s", @@ -32,7 +37,7 @@ class MitmContainer(vararg options: String) : Closeable { *options, ).also { println("Initializing container with command: $it") } - // Make proxy scripts from host available to container + // Make proxy scripts from host filesystem available in container's filesystem val binding = Bind(PROXY_SCRIPT_ROOT, Volume(CONTAINER_MOUNT_POINT), AccessMode.ro) delegate = Docker.Instance.createContainer(IMAGE_NAME, cmd, binding, exposedPort) @@ -48,6 +53,9 @@ class MitmContainer(vararg options: String) : Closeable { } } + /** + * Gets the host port that can be used to communicate to the MITM proxy + */ val hostPort: Int get() = delegate.hostPort