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
55 changes: 22 additions & 33 deletions .github/workflows/integration-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ on:

env:
DOCKER_API_VERSION: 1.48
DOCKER_CE_VERSION: 28.3.3

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand All @@ -17,24 +18,14 @@ 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
uses: actions/checkout@v6
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:
Expand All @@ -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
Expand All @@ -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/
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 {
Expand All @@ -50,7 +44,6 @@ class CopyContainerArchivesIT : ResourceIT() {
)
} finally {
FileSystemUtils.deleteRecursively(tempDir)
testClient.containers.stop(id)
}
}
}
Expand All @@ -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)
Expand All @@ -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 {
Expand All @@ -134,7 +133,6 @@ class CopyContainerArchivesIT : ResourceIT() {
)
} finally {
FileSystemUtils.deleteRecursively(tempDir)
testClient.containers.stop(id)
}
}
}
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,7 @@ class LogContainerIT : ResourceIT() {
},
) { container ->
testClient.containers.start(container)

delay(3000)
testClient.containers.wait(container)

val result =
testClient.containers.logs(container) {
Expand Down
14 changes: 13 additions & 1 deletion src/nativeMain/kotlin/me/devnatan/dockerkt/io/Http.native.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 <T : HttpClientEngineConfig> HttpClientConfig<out T>.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")
}
}
Loading