diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml index d18c9681..179a1367 100644 --- a/.github/workflows/integration-tests.yml +++ b/.github/workflows/integration-tests.yml @@ -7,6 +7,7 @@ on: env: DOCKER_API_VERSION: 1.48 + DOCKER_CE_VERSION: 28.3.3 concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -17,12 +18,7 @@ permissions: jobs: jvm: - strategy: - fail-fast: false - matrix: - docker_version: - - "29.2.1" - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 name: JVM steps: - name: Checkout @@ -30,11 +26,6 @@ jobs: with: fetch-depth: 0 - - name: Setup Docker CE v${{ matrix.docker_version }} - uses: docker/setup-docker-action@v4.6.0 - with: - version: v${{ matrix.docker_version }} - - name: Set up JDK 17 uses: actions/setup-java@v5 with: @@ -49,53 +40,44 @@ jobs: - name: Run Tests run: ./gradlew jvmTest + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report-jvm + path: build/reports/tests/ native: if: ${{ vars.NATIVE_TESTS_ENABLED == 'true' }} strategy: fail-fast: false matrix: - docker_version: - - "29.2.1" os: - name: Linux - runner: ubuntu-22.04 + runner: ubuntu-24.04 sourceset: linuxX64 - - name: macOS (Intel) + - name: macOS runner: macos-15-intel sourceset: macosX64 - - name: macOS (Apple Silicon) - runner: macos-15 - sourceset: macosArm64 - - name: Windows runner: windows-2025 sourceset: mingwX64 runs-on: ${{ matrix.os.runner }} - name: Native - ${{ matrix.os.name }} + name: ${{ matrix.os.name }} steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - - name: Setup Docker CE v${{ matrix.docker_version }} + - name: Setup Docker CE uses: docker/setup-docker-action@v4.6.0 - if: ${{ matrix.os.sourceset != 'macosArm64' }} + if: ${{ matrix.os.sourceset == 'macosX64' }} with: - version: v${{ matrix.docker_version }} - - - name: Setup Homebrew - uses: Homebrew/actions/setup-homebrew@master - if: ${{ matrix.os.sourceset == 'macosArm64' }} - - - name: Setup Docker - if: ${{ matrix.os.sourceset == 'macosArm64' }} - run: | - brew install colima docker - colima start + version: v${{ env.DOCKER_CE_VERSION }} - name: Set up JDK 17 uses: actions/setup-java@v5 @@ -111,3 +93,10 @@ jobs: - name: Run Tests run: ./gradlew ${{ matrix.os.sourceset }}Test + + - name: Upload test reports + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report-${{ matrix.os.sourceset }} + path: build/reports/tests/ diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.kt index da1d5bd6..a11cf842 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.kt @@ -14,6 +14,7 @@ import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.readRawBytes import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode +import io.ktor.http.content.ByteArrayContent import io.ktor.http.contentType import io.ktor.util.decodeBase64Bytes import io.ktor.utils.io.ByteReadChannel @@ -513,8 +514,10 @@ public class ContainerResource internal constructor( parameter("noOverwriteDirNonDir", options.noOverwriteDirNonDir.toString()) parameter("copyUIDGID", options.copyUIDGID.toString()) - setBody(tarArchive) - contentType(ContentType.Application.OctetStream) + // ByteArrayContent guarantees Content-Length is set; plain setBody(ByteArray) can fall + // back to chunked transfer on Ktor CIO native, which Docker's archive endpoint rejects + // with "request body length should be specified". + setBody(ByteArrayContent(tarArchive, ContentType.Application.OctetStream)) } } diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/ResourceIT.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/ResourceIT.kt index f8767051..d81bd674 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/ResourceIT.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/ResourceIT.kt @@ -2,13 +2,22 @@ package me.devnatan.dockerkt.resource import me.devnatan.dockerkt.DockerClient import me.devnatan.dockerkt.createTestDockerClient +import kotlin.jvm.JvmOverloads +import kotlin.test.AfterTest open class ResourceIT( - private val debugHttpCalls: Boolean = true, + private val debugHttpCalls: Boolean, ) { + constructor() : this(debugHttpCalls = true) + val testClient: DockerClient by lazy { createTestDockerClient { debugHttpCalls(this@ResourceIT.debugHttpCalls) } } + + @AfterTest + fun closeTestClient() { + runCatching { testClient.close() } + } } diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/CopyContainerArchivesIT.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/CopyContainerArchivesIT.kt index 3fea58bd..f22f8eff 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/CopyContainerArchivesIT.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/CopyContainerArchivesIT.kt @@ -1,13 +1,9 @@ package me.devnatan.dockerkt.resource.container -import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest import kotlinx.io.files.Path import me.devnatan.dockerkt.io.FileSystemUtils -import me.devnatan.dockerkt.models.exec.ExecStartOptions -import me.devnatan.dockerkt.models.exec.ExecStartResult import me.devnatan.dockerkt.resource.ResourceIT -import me.devnatan.dockerkt.resource.exec.create import me.devnatan.dockerkt.sleepForever import me.devnatan.dockerkt.withContainer import kotlin.test.Test @@ -24,13 +20,11 @@ class CopyContainerArchivesIT : ResourceIT() { testClient.withContainer( testImage, { - command = listOf("sh", "-c", "echo 'test content' > /tmp/test.txt && sleep infinity") + command = listOf("sh", "-c", "echo 'test content' > /tmp/test.txt") }, ) { id -> testClient.containers.start(id) - - // Wait for file to be created - delay(500) + testClient.containers.wait(id) val tempDir = FileSystemUtils.createTempDirectory() try { @@ -50,7 +44,6 @@ class CopyContainerArchivesIT : ResourceIT() { ) } finally { FileSystemUtils.deleteRecursively(tempDir) - testClient.containers.stop(id) } } } @@ -76,15 +69,22 @@ class CopyContainerArchivesIT : ResourceIT() { "/tmp/", ) - val execId = - testClient.exec.create(id) { - command = listOf("cat", "/tmp/${tempFile.name}") - attachStdout = true - } - - val result = testClient.exec.start(execId, ExecStartOptions()) - assertTrue(result is ExecStartResult.Complete) - assertTrue(result.output.contains("hello from host")) + val verifyDir = FileSystemUtils.createTempDirectory() + try { + testClient.containers.copyFileFrom( + id, + "/tmp/${tempFile.name}", + verifyDir.toString(), + ) + val copiedBack = Path(verifyDir, tempFile.name) + assertTrue(FileSystemUtils.exists(copiedBack)) + assertEquals( + expected = "hello from host", + actual = FileSystemUtils.readFile(copiedBack).decodeToString(), + ) + } finally { + FileSystemUtils.deleteRecursively(verifyDir) + } } finally { FileSystemUtils.delete(tempFile) testClient.containers.stop(id) @@ -102,13 +102,12 @@ class CopyContainerArchivesIT : ResourceIT() { listOf( "sh", "-c", - "mkdir -p /tmp/testdir && echo 'file1' > /tmp/testdir/file1.txt && echo 'file2' > /tmp/testdir/file2.txt && sleep infinity", + "mkdir -p /tmp/testdir && echo 'file1' > /tmp/testdir/file1.txt && echo 'file2' > /tmp/testdir/file2.txt", ) }, ) { id -> testClient.containers.start(id) - - delay(500) + testClient.containers.wait(id) val tempDir = FileSystemUtils.createTempDirectory() try { @@ -134,7 +133,6 @@ class CopyContainerArchivesIT : ResourceIT() { ) } finally { FileSystemUtils.deleteRecursively(tempDir) - testClient.containers.stop(id) } } } @@ -165,24 +163,19 @@ class CopyContainerArchivesIT : ResourceIT() { "/tmp/", ) - val execId = - testClient.exec.create(id) { - command = listOf("sh", "-c", "cat /tmp/file1.txt && cat /tmp/file2.txt") - attachStdout = true - } - - val result = testClient.exec.start(execId, ExecStartOptions()) - assertTrue(result is ExecStartResult.Complete) - - val output = result.output - assertTrue( - actual = output.contains("content1"), - message = "Expected 'content1' in output, but got: $output", - ) - assertTrue( - actual = output.contains("content2"), - message = "Expected 'content2' in output, but got: $output", - ) + val verifyDir = FileSystemUtils.createTempDirectory() + try { + testClient.containers.copyFileFrom(id, "/tmp/file1.txt", verifyDir.toString()) + testClient.containers.copyFileFrom(id, "/tmp/file2.txt", verifyDir.toString()) + val copiedFile1 = Path(verifyDir, "file1.txt") + val copiedFile2 = Path(verifyDir, "file2.txt") + assertTrue(FileSystemUtils.exists(copiedFile1)) + assertTrue(FileSystemUtils.exists(copiedFile2)) + assertEquals("content1", FileSystemUtils.readFile(copiedFile1).decodeToString()) + assertEquals("content2", FileSystemUtils.readFile(copiedFile2).decodeToString()) + } finally { + FileSystemUtils.deleteRecursively(verifyDir) + } } finally { FileSystemUtils.deleteRecursively(tempDir) testClient.containers.stop(id) diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/LogContainerIT.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/LogContainerIT.kt index 2c7ab51b..cb760d7c 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/LogContainerIT.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/LogContainerIT.kt @@ -257,8 +257,7 @@ class LogContainerIT : ResourceIT() { }, ) { container -> testClient.containers.start(container) - - delay(3000) + testClient.containers.wait(container) val result = testClient.containers.logs(container) { diff --git a/src/nativeMain/kotlin/me/devnatan/dockerkt/io/Http.native.kt b/src/nativeMain/kotlin/me/devnatan/dockerkt/io/Http.native.kt index bef573e0..eea2c92c 100644 --- a/src/nativeMain/kotlin/me/devnatan/dockerkt/io/Http.native.kt +++ b/src/nativeMain/kotlin/me/devnatan/dockerkt/io/Http.native.kt @@ -5,15 +5,27 @@ import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.cio.CIO import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.header +import io.ktor.http.HttpHeaders import me.devnatan.dockerkt.DockerClient internal actual val defaultHttpClientEngine: HttpClientEngineFactory<*>? get() = CIO internal actual fun HttpClientConfig.configureHttpClient(client: DockerClient) { + engine { + require(this is io.ktor.client.engine.cio.CIOEngineConfig) { "Only CIO engine is supported for now" } + // disable request timeout so long-running calls (image pulls, log streams) aren't killed + requestTimeout = 0 + } defaultRequest { val socketPath = client.config.socketPath if (isUnixSocket(socketPath)) { - unixSocket(socketPath) + unixSocket(socketPath.removePrefix(UnixSocketPrefix)) } + // Force Connection: close. Docker often returns bodies framed by connection-close (no + // Content-Length, no Transfer-Encoding), and Ktor CIO over unix sockets cannot otherwise + // determine where the response ends — throwing "request body length should be specified, + // chunked transfer encoding should be used or keep-alive should be disabled (connection: close)". + header(HttpHeaders.Connection, "close") } }