From 4cfebe56d723142ae3f1b4d2fccaaf97ed1faec1 Mon Sep 17 00:00:00 2001 From: Natan Date: Tue, 20 Jan 2026 17:16:17 -0300 Subject: [PATCH 1/9] Create `isMultiplexedStream` support function --- .../me/devnatan/dockerkt/io/Streaming.kt | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Streaming.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Streaming.kt index 7108197d..1e3ce9e0 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Streaming.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Streaming.kt @@ -6,9 +6,13 @@ import io.ktor.utils.io.readFully import me.devnatan.dockerkt.models.Frame import me.devnatan.dockerkt.models.Stream +/*** Default buffer size for stream reading operations. */ private const val DefaultBufferSize = 8192 private const val HeaderSize = 8 +/** Maximum reasonable payload size for a single Docker frame. */ +internal const val MaxPayloadSize = 10 * 1024 * 1024 // 10 MB + internal suspend fun readStream( channel: ByteReadChannel, multiplexed: Boolean, @@ -98,7 +102,7 @@ private suspend fun readMultiplexedFrames( } } -private fun readPayloadSize(header: ByteArray): Int = +internal fun readPayloadSize(header: ByteArray): Int = ((header[4].toInt() and 0xFF) shl 24) or ((header[5].toInt() and 0xFF) shl 16) or ((header[6].toInt() and 0xFF) shl 8) or @@ -174,3 +178,37 @@ internal suspend fun collectStreamDemuxed( return stdout.toString() to stderr.toString() } + +/** + * Detects if the stream is using Docker's multiplexed protocol or raw TTY output. + * + * Docker multiplexed stream format (non-TTY): + * - Byte 0: Stream type (0=stdin, 1=stdout, 2=stderr) + * - Bytes 1-3: Reserved, always 0x00 + * - Bytes 4-7: Payload size (big-endian uint32) + * + * For TTY-enabled containers, output is raw without this header structure. + * + * Detection logic: + * 1. First byte must be 0, 1, or 2 (valid stream type) + * 2. Bytes 1-3 must be 0x00 (reserved bytes) + * 3. Payload size (bytes 4-7) must be reasonable (> 0 and < 10MB) + * + * @param header First 8 bytes of the stream + * @return true if multiplexed protocol detected, false if raw TTY stream + */ +internal fun isMultiplexedStream(header: ByteArray): Boolean { + if (header.size < HeaderSize) return false + + val streamType = Stream.typeOfOrNull(header[0]) ?: Stream.Unknown + if (streamType == Stream.Unknown) + return false + + if (header[1].toInt() != 0 || header[2].toInt() != 0 || header[3].toInt() != 0) { + return false + } + + val payloadSize = readPayloadSize(header) + + return payloadSize in 1..MaxPayloadSize +} From 825e33d348b1d2f452dc2f4d134e8c5a9d1b4b80 Mon Sep 17 00:00:00 2001 From: Natan Date: Tue, 20 Jan 2026 17:16:50 -0300 Subject: [PATCH 2/9] Write tests for `isMultiplexedStream` and `readPayloadSize` --- .../io/MultiplexedStreamParsingTest.kt | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/io/MultiplexedStreamParsingTest.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/io/MultiplexedStreamParsingTest.kt index db97b06e..02a8a020 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/io/MultiplexedStreamParsingTest.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/io/MultiplexedStreamParsingTest.kt @@ -1,9 +1,12 @@ package me.devnatan.dockerkt.io +import io.ktor.utils.io.core.toByteArray import me.devnatan.dockerkt.models.Frame import me.devnatan.dockerkt.models.Stream import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue class MultiplexedStreamParsingTest { @Test @@ -131,4 +134,167 @@ class MultiplexedStreamParsingTest { return Frame(content, size, streamType) } + + @Test + fun `isMultiplexedStream should detect valid stdout header`() { + // Stream type = STDOUT (1), size = 5 + val header = + byteArrayOf( + 1, + 0, + 0, + 0, // Type (stdout) + reserved + 0, + 0, + 0, + 5, // Size = 5 + ) + + assertTrue(isMultiplexedStream(header)) + } + + @Test + fun `isMultiplexedStream should detect valid stderr header`() { + // Stream type = STDERR (2), size = 10 + val header = + byteArrayOf( + 2, + 0, + 0, + 0, // Type (stderr) + reserved + 0, + 0, + 0, + 10, // Size = 10 + ) + + assertTrue(isMultiplexedStream(header)) + } + + @Test + fun `isMultiplexedStream should detect valid stdin header`() { + // Stream type = STDIN (0), size = 3 + val header = + byteArrayOf( + 0, + 0, + 0, + 0, // Type (stdin) + reserved + 0, + 0, + 0, + 3, // Size = 3 + ) + + assertTrue(isMultiplexedStream(header)) + } + + @Test + fun `isMultiplexedStream should reject invalid stream type`() { + // Invalid stream type (5) + val header = + byteArrayOf( + 5, + 0, + 0, + 0, + 0, + 0, + 0, + 10, + ) + + assertFalse(isMultiplexedStream(header)) + } + + @Test + fun `isMultiplexedStream should reject non-zero reserved bytes`() { + val header = + byteArrayOf( + 1, + 1, + 0, + 0, // Reserved byte 1 is not 0 + 0, + 0, + 0, + 10, + ) + + assertFalse(isMultiplexedStream(header)) + } + + @Test + fun `isMultiplexedStream should reject zero payload size`() { + val header = + byteArrayOf( + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, // Size = 0 + ) + + assertFalse(isMultiplexedStream(header)) + } + + @Test + fun `isMultiplexedStream should reject header too short`() { + val header = byteArrayOf(1, 0, 0, 0) + assertFalse(isMultiplexedStream(header)) + } + + @Test + fun `isMultiplexedStream should detect raw TTY text data`() { + // Raw text "Hello" - not a valid multiplexed header + val header = "Hello Wo".toByteArray() + assertFalse(isMultiplexedStream(header)) + } + + @Test + fun `isMultiplexedStream should detect ANSI escape sequences as raw`() { + // ANSI escape sequence for colors + val header = "\u001B[32mTest".toByteArray() + assertFalse(isMultiplexedStream(header)) + } + + @Test + fun `readPayloadSize should parse small size`() { + val header = byteArrayOf(1, 0, 0, 0, 0, 0, 0, 5) + assertEquals(5, readPayloadSize(header)) + } + + @Test + fun `readPayloadSize should parse medium size`() { + // Size = 256 + val header = byteArrayOf(1, 0, 0, 0, 0, 0, 1, 0) + assertEquals(256, readPayloadSize(header)) + } + + @Test + fun `readPayloadSize should parse large size`() { + // Size = 65536 (0x00010000) + val header = byteArrayOf(1, 0, 0, 0, 0, 1, 0, 0) + assertEquals(65536, readPayloadSize(header)) + } + + @Test + fun `readPayloadSize should parse maximum reasonable size`() { + // Size = 10MB (10 * 1024 * 1024 = 10485760 = 0x00A00000) + val header = + byteArrayOf( + 1, + 0, + 0, + 0, + 0, + 0xA0.toByte(), + 0, + 0, + ) + assertEquals(10485760, readPayloadSize(header)) + } } From be77a2a3069e2c20d7ab8a387729a6c0c3c2defc Mon Sep 17 00:00:00 2001 From: Natan Date: Tue, 20 Jan 2026 17:28:45 -0300 Subject: [PATCH 3/9] ContainerAttachOptions --- .../container/ContainerAttachOptions.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/commonMain/kotlin/me/devnatan/dockerkt/models/container/ContainerAttachOptions.kt diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/models/container/ContainerAttachOptions.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/models/container/ContainerAttachOptions.kt new file mode 100644 index 00000000..58c35e0f --- /dev/null +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/models/container/ContainerAttachOptions.kt @@ -0,0 +1,57 @@ +package me.devnatan.dockerkt.models.container + +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient + +/** + * Options for attaching to a container. + * + * The attach operation allows you to connect to a container's stdin, stdout, and stderr + * streams, similar to the `docker attach` command. + * + * @see Docker API - Container Attach + */ +@Serializable +public data class ContainerAttachOptions( + /** + * Attach to stdout. + * Default: true + */ + @Transient + var stdout: Boolean = true, + /** + * Attach to stderr. + * Default: true + */ + @Transient + var stderr: Boolean = true, + /** + * Attach to stdin. + * When enabled, allows sending input to the container. + * Default: false + */ + @Transient + var stdin: Boolean = false, + /** + * Stream attached streams. + * If false, returns immediately after attaching. + * Default: true + */ + @Transient + var stream: Boolean = true, + /** + * Return logs from container start. + * Only works if stream=true. + * Default: false + */ + @Transient + var logs: Boolean = false, + /** + * Override the key sequence for detaching a container. + * Format is a single character [a-Z] or ctrl- where is one of: + * a-z, @, ^, [, , or _. + * Default: null (uses Docker daemon default: ctrl-p,ctrl-q) + */ + @Transient + var detachKeys: String? = null, +) From d4a1a7c5feb0a09dccfffcb2f927d3fe0719bf52 Mon Sep 17 00:00:00 2001 From: Natan Date: Tue, 20 Jan 2026 17:28:50 -0300 Subject: [PATCH 4/9] ContainerAttachWebSocketResult --- .../ContainerAttachWebSocketResult.kt | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/commonMain/kotlin/me/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult.kt diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult.kt new file mode 100644 index 00000000..b7c9d37f --- /dev/null +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult.kt @@ -0,0 +1,46 @@ +package me.devnatan.dockerkt.models.container + +import kotlinx.coroutines.flow.Flow +import me.devnatan.dockerkt.models.Frame + +/** + * Result of a WebSocket-based container attach operation. + * + * Provides bidirectional communication through WebSocket protocol. + * This is the preferred method for interactive sessions. + * + * @property close Function to close the WebSocket session. + */ +public sealed class ContainerAttachWebSocketResult { + public abstract val close: suspend () -> Unit + + /** + * Successfully attached via WebSocket with combined output stream. + * + * @property output Flow of frames from stdout/stderr. + * @property sendText Function to send text to stdin. + * @property sendBinary Function to send binary data to stdin. + */ + public data class Connected( + override val close: suspend () -> Unit, + val output: Flow, + val sendText: suspend (String) -> Unit, + val sendBinary: suspend (ByteArray) -> Unit, + ) : ContainerAttachWebSocketResult() + + /** + * Successfully attached via WebSocket with demuxed streams. + * + * @property stdout Flow of stdout content. + * @property stderr Flow of stderr content. + * @property sendText Function to send text to stdin. + * @property sendBinary Function to send binary data to stdin. + */ + public data class ConnectedDemuxed( + override val close: suspend () -> Unit, + val stdout: Flow, + val stderr: Flow, + val sendText: suspend (String) -> Unit, + val sendBinary: suspend (ByteArray) -> Unit, + ) : ContainerAttachWebSocketResult() +} From cd28311fb4591f8f858037231676c2215cd2d593 Mon Sep 17 00:00:00 2001 From: Natan Date: Tue, 20 Jan 2026 17:29:05 -0300 Subject: [PATCH 5/9] Install WebSockets plugin --- src/commonMain/kotlin/me/devnatan/dockerkt/io/Http.kt | 6 +++++- src/commonMain/kotlin/me/devnatan/dockerkt/io/Streaming.kt | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Http.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Http.kt index a7f7323f..6e3fc6f5 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Http.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Http.kt @@ -14,6 +14,7 @@ import io.ktor.client.plugins.logging.LogLevel import io.ktor.client.plugins.logging.Logger import io.ktor.client.plugins.logging.Logging import io.ktor.client.plugins.logging.SIMPLE +import io.ktor.client.plugins.websocket.WebSockets import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.URLBuilder @@ -46,6 +47,8 @@ internal fun createHttpClient(client: DockerClient): HttpClient { private fun HttpClientConfig<*>.configure(client: DockerClient) { expectSuccess = true + install(WebSockets) + install(ContentNegotiation) { json( Json { @@ -62,7 +65,6 @@ private fun HttpClientConfig<*>.configure(client: DockerClient) { } install(UserAgent) { agent = "docker-kotlin" } - configureHttpClient(client) HttpResponseValidator { handleResponseExceptionWithRequest { exception, _ -> @@ -94,6 +96,8 @@ private fun HttpClientConfig<*>.configure(client: DockerClient) { }, ) } + + configureHttpClient(client) } @OptIn(ExperimentalStdlibApi::class) diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Streaming.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Streaming.kt index 1e3ce9e0..32cdea8b 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Streaming.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Streaming.kt @@ -201,8 +201,9 @@ internal fun isMultiplexedStream(header: ByteArray): Boolean { if (header.size < HeaderSize) return false val streamType = Stream.typeOfOrNull(header[0]) ?: Stream.Unknown - if (streamType == Stream.Unknown) + if (streamType == Stream.Unknown) { return false + } if (header[1].toInt() != 0 || header[2].toInt() != 0 || header[3].toInt() != 0) { return false From 1d7bc703bf57fb136cbb95a7a960ec87f0a511c8 Mon Sep 17 00:00:00 2001 From: Natan Date: Tue, 20 Jan 2026 17:29:17 -0300 Subject: [PATCH 6/9] ContainerResource `attachWebSocket` --- .../resource/container/ContainerResource.kt | 20 +++ .../container/ContainerResourceExt.kt | 20 +++ .../container/ContainerResource.jvm.kt | 166 ++++++++++++++++++ .../container/ContainerResource.native.kt | 14 ++ 4 files changed, 220 insertions(+) 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 dcc97641..b556f45c 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.kt @@ -5,6 +5,8 @@ import me.devnatan.dockerkt.DockerResponseException import me.devnatan.dockerkt.models.Frame import me.devnatan.dockerkt.models.ResizeTTYOptions import me.devnatan.dockerkt.models.container.Container +import me.devnatan.dockerkt.models.container.ContainerAttachOptions +import me.devnatan.dockerkt.models.container.ContainerAttachWebSocketResult import me.devnatan.dockerkt.models.container.ContainerCopyOptions import me.devnatan.dockerkt.models.container.ContainerCopyResult import me.devnatan.dockerkt.models.container.ContainerCreateOptions @@ -289,4 +291,22 @@ public expect class ContainerResource { destinationPath: String, options: ContainerCopyOptions = ContainerCopyOptions(path = destinationPath), ) + + /** + * Attach to a container via WebSocket for bidirectional communication. + * + * This method provides full stdin/stdout/stderr support through WebSocket protocol. + * + * @param container Container id or name. + * @param options Attach options. + * @return [ContainerAttachWebSocketResult] containing the WebSocket streams and send functions. + * @throws ContainerNotFoundException If the container is not found. + * @throws ContainerNotRunningException If the container is not running. + * + * @see Docker API - Container Attach WebSocket + */ + public suspend fun attachWebSocket( + container: String, + options: ContainerAttachOptions = ContainerAttachOptions(), + ): ContainerAttachWebSocketResult } diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResourceExt.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResourceExt.kt index c0a69c9e..cc6e2bc2 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResourceExt.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResourceExt.kt @@ -6,6 +6,8 @@ import kotlinx.coroutines.flow.Flow import me.devnatan.dockerkt.DockerResponseException import me.devnatan.dockerkt.models.Frame import me.devnatan.dockerkt.models.ResizeTTYOptions +import me.devnatan.dockerkt.models.container.ContainerAttachOptions +import me.devnatan.dockerkt.models.container.ContainerAttachWebSocketResult import me.devnatan.dockerkt.models.container.ContainerCopyOptions import me.devnatan.dockerkt.models.container.ContainerCreateOptions import me.devnatan.dockerkt.models.container.ContainerListOptions @@ -220,3 +222,21 @@ public suspend fun ContainerResource.copyDirectoryTo( destinationPath = destinationPath, options = ContainerCopyOptions(path = destinationPath).apply(options), ) + +/** + * Attach to a container via WebSocket for bidirectional communication. + * + * This method provides full stdin/stdout/stderr support through WebSocket protocol. + * + * @param container Container id or name. + * @param block Attach options. + * @return [ContainerAttachWebSocketResult] containing the WebSocket streams and send functions. + * @throws ContainerNotFoundException If the container is not found. + * @throws ContainerNotRunningException If the container is not running. + * + * @see Docker API - Container Attach WebSocket + */ +public suspend fun ContainerResource.attachWebSocket( + container: String, + block: ContainerAttachOptions.() -> Unit, +): ContainerAttachWebSocketResult = attachWebSocket(container, ContainerAttachOptions().apply(block)) diff --git a/src/jvmMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.jvm.kt b/src/jvmMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.jvm.kt index 124ff709..95d9186e 100644 --- a/src/jvmMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.jvm.kt +++ b/src/jvmMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.jvm.kt @@ -2,6 +2,8 @@ package me.devnatan.dockerkt.resource.container import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.webSocket import io.ktor.client.request.delete import io.ktor.client.request.get import io.ktor.client.request.parameter @@ -15,15 +17,32 @@ import io.ktor.client.statement.readRawBytes import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.contentType +import io.ktor.http.headers import io.ktor.util.decodeBase64Bytes import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.CancellationException import io.ktor.utils.io.readUTF8Line +import io.ktor.websocket.CloseReason +import io.ktor.websocket.close +import io.ktor.websocket.readBytes +import io.ktor.websocket.readText import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ClosedReceiveChannelException +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.future.asCompletableFuture +import kotlinx.coroutines.launch import kotlinx.io.files.Path import kotlinx.serialization.json.Json import me.devnatan.dockerkt.DockerResponseException @@ -33,6 +52,8 @@ import me.devnatan.dockerkt.io.TarOperations import me.devnatan.dockerkt.io.TarUtils import me.devnatan.dockerkt.io.collectStream import me.devnatan.dockerkt.io.collectStreamDemuxed +import me.devnatan.dockerkt.io.isMultiplexedStream +import me.devnatan.dockerkt.io.readPayloadSize import me.devnatan.dockerkt.io.readStream import me.devnatan.dockerkt.io.requestCatching import me.devnatan.dockerkt.models.Frame @@ -40,6 +61,8 @@ import me.devnatan.dockerkt.models.ResizeTTYOptions import me.devnatan.dockerkt.models.Stream import me.devnatan.dockerkt.models.container.Container import me.devnatan.dockerkt.models.container.ContainerArchiveInfo +import me.devnatan.dockerkt.models.container.ContainerAttachOptions +import me.devnatan.dockerkt.models.container.ContainerAttachWebSocketResult import me.devnatan.dockerkt.models.container.ContainerCopyOptions import me.devnatan.dockerkt.models.container.ContainerCopyResult import me.devnatan.dockerkt.models.container.ContainerCreateOptions @@ -739,4 +762,147 @@ public actual class ContainerResource( ContainerLogsResult.Stream(framesFlow) } } + + public actual suspend fun attachWebSocket( + container: String, + options: ContainerAttachOptions, + ): ContainerAttachWebSocketResult { + val outputChannel = Channel(Channel.BUFFERED) + var session: DefaultClientWebSocketSession? = null + var sessionJob: Job? = null + + try { + val queryParams = + buildString { + append("stdout=${options.stdout}") + append("&stderr=${options.stderr}") + append("&stdin=${options.stdin}") + append("&stream=${options.stream}") + append("&logs=${options.logs}") + options.detachKeys?.let { append("&detachKeys=$it") } + } + + sessionJob = + CoroutineScope(Dispatchers.IO).launch { + httpClient.webSocket( + urlString = "$BasePath/$container/attach/ws?$queryParams", + ) { + session = this + + try { + for (frame in incoming) { + when (frame) { + is io.ktor.websocket.Frame.Text -> { + val content = frame.readText() + outputChannel.send(Frame(content, content.length, Stream.StdOut)) + } + + is io.ktor.websocket.Frame.Binary -> { + val bytes = frame.readBytes() + val header by lazy { bytes.copyOf(8) } + if (bytes.size >= 8 && isMultiplexedStream(header)) { + val streamType = Stream.typeOfOrNull(header[0]) ?: Stream.StdOut + val payloadSize = readPayloadSize(header) + if (bytes.size >= 8 + payloadSize) { + val content = bytes.copyOfRange(8, 8 + payloadSize).decodeToString() + outputChannel.send(Frame(content, payloadSize, streamType)) + } + } else { + val raw = bytes.decodeToString() + outputChannel.send(Frame(raw, raw.length, Stream.StdOut)) + } + } + + is io.ktor.websocket.Frame.Close -> { + break + } + + else -> { /* Ignore ping/pong */ } + } + } + } catch (_: ClosedReceiveChannelException) { + // Connection closed normally + } catch (e: CancellationException) { + throw e + } finally { + outputChannel.close() + } + } + } + + var attempts = 0 + while (session == null && attempts < 50) { + delay(10) + attempts++ + } + + if (session == null) { + throw IllegalStateException("Failed to establish WebSocket connection") + } + + @Suppress("UNNECESSARY_NOT_NULL_ASSERTION") + val currentSession = session!! + + val sendText: suspend (String) -> Unit = { text -> + currentSession.send( + io.ktor.websocket.Frame + .Text(text), + ) + } + + val sendBinary: suspend (ByteArray) -> Unit = { data -> + currentSession.send( + io.ktor.websocket.Frame + .Binary(true, data), + ) + } + + val closeSession: suspend () -> Unit = { + try { + currentSession.close(CloseReason(CloseReason.Codes.NORMAL, "Detaching")) + } catch (_: Throwable) { + // Ignore close errors + } + + sessionJob.cancel() + outputChannel.close() + } + + val outputFlow = outputChannel.receiveAsFlow() + val demux = options.stdout && options.stderr && !options.stdin + + return if (demux) { + val sharedFlow = + outputFlow.shareIn( + scope = CoroutineScope(Dispatchers.Default), + started = SharingStarted.Lazily, + ) + + ContainerAttachWebSocketResult.ConnectedDemuxed( + stdout = + sharedFlow + .filter { frame -> frame.stream == Stream.StdOut } + .map { frame -> frame.value }, + stderr = + sharedFlow + .filter { frame -> frame.stream == Stream.StdErr } + .map { frame -> frame.value }, + sendText = sendText, + sendBinary = sendBinary, + close = closeSession, + ) + } else { + ContainerAttachWebSocketResult.Connected( + output = outputFlow, + sendText = sendText, + sendBinary = sendBinary, + close = closeSession, + ) + } + } catch (e: Throwable) { + outputChannel.close() + sessionJob?.cancel() + throw e + } + } } diff --git a/src/nativeMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.native.kt b/src/nativeMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.native.kt index 3a073446..1d18553a 100644 --- a/src/nativeMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.native.kt +++ b/src/nativeMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.native.kt @@ -4,6 +4,8 @@ import kotlinx.coroutines.flow.Flow import me.devnatan.dockerkt.models.Frame import me.devnatan.dockerkt.models.ResizeTTYOptions import me.devnatan.dockerkt.models.container.Container +import me.devnatan.dockerkt.models.container.ContainerAttachOptions +import me.devnatan.dockerkt.models.container.ContainerAttachWebSocketResult import me.devnatan.dockerkt.models.container.ContainerCopyOptions import me.devnatan.dockerkt.models.container.ContainerCopyResult import me.devnatan.dockerkt.models.container.ContainerCreateOptions @@ -203,6 +205,7 @@ public actual class ContainerResource { tarArchive: ByteArray, options: ContainerCopyOptions, ) { + TODO("Not yet implemented") } public actual suspend fun copyFileTo( @@ -211,6 +214,7 @@ public actual class ContainerResource { destinationPath: String, options: ContainerCopyOptions, ) { + TODO("Not yet implemented") } public actual suspend fun copyFileFrom( @@ -218,6 +222,7 @@ public actual class ContainerResource { sourcePath: String, destinationPath: String, ) { + TODO("Not yet implemented") } public actual suspend fun copyDirectoryFrom( @@ -225,6 +230,7 @@ public actual class ContainerResource { sourcePath: String, destinationPath: String, ) { + TODO("Not yet implemented") } public actual suspend fun copyDirectoryTo( @@ -233,5 +239,13 @@ public actual class ContainerResource { destinationPath: String, options: ContainerCopyOptions, ) { + TODO("Not yet implemented") + } + + public actual suspend fun attachWebSocket( + container: String, + options: ContainerAttachOptions, + ): ContainerAttachWebSocketResult { + TODO("Not yet implemented") } } From 72825fa2cfd71831e0d5a59e3bfb0c1e57eb546b Mon Sep 17 00:00:00 2001 From: Natan Date: Tue, 20 Jan 2026 17:29:57 -0300 Subject: [PATCH 7/9] Write integration tests --- .../container/AttachContainerWebSocketIT.kt | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) create mode 100644 src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/AttachContainerWebSocketIT.kt diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/AttachContainerWebSocketIT.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/AttachContainerWebSocketIT.kt new file mode 100644 index 00000000..3f9218b1 --- /dev/null +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/AttachContainerWebSocketIT.kt @@ -0,0 +1,200 @@ +package me.devnatan.dockerkt.resource.container + +import io.ktor.utils.io.core.toByteArray +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withTimeoutOrNull +import me.devnatan.dockerkt.models.container.ContainerAttachWebSocketResult +import me.devnatan.dockerkt.resource.ResourceIT +import me.devnatan.dockerkt.withContainer +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +class AttachContainerWebSocketIT : ResourceIT() { + + @Test + fun `attach and receive output`() = + runTest { + testClient.withContainer( + image = "alpine:latest", + options = { + command = listOf("sh", "-c", "echo 'WebSocket test'; sleep 10") + attachStdin = true + }, + ) { container -> + testClient.containers.start(container) + + try { + val result = + testClient.containers.attachWebSocket(container) { + stdout = true + stderr = true + stdin = true + stream = true + } + + assertTrue( + result is ContainerAttachWebSocketResult.Connected || + result is ContainerAttachWebSocketResult.ConnectedDemuxed, + ) + + val output = + when (result) { + is ContainerAttachWebSocketResult.Connected -> { + withTimeoutOrNull(5.seconds) { + result.output + .take(1) + .toList() + .joinToString("") { it.value } + } ?: "" + } + + is ContainerAttachWebSocketResult.ConnectedDemuxed -> { + withTimeoutOrNull(5.seconds) { + result.stdout + .take(1) + .toList() + .joinToString("") + } ?: "" + } + } + + assertContains(output, "WebSocket test") + + result + } catch (e: Throwable) { + // WebSocket might not be supported in all environments + println("WebSocket test skipped: ${e.message}") + } + } + } + + @Test + fun `send stdin via WebSocket`() = + runTest { + testClient.withContainer( + image = "alpine:latest", + options = { + command = listOf("sh") + attachStdin = true + tty = true + }, + ) { container -> + testClient.containers.start(container) + + try { + val result = + testClient.containers.attachWebSocket(container) { + stdout = true + stderr = true + stdin = true + stream = true + } + + when (result) { + is ContainerAttachWebSocketResult.Connected -> { + // Send a command + result.sendText("echo 'Input received'\n") + + // Wait for response + val output = + withTimeoutOrNull(5.seconds) { + result.output + .take(5) + .toList() + .joinToString("") { it.value } + } + + assertEquals(output?.contains("Input received"), true) + + result.close() + } + + is ContainerAttachWebSocketResult.ConnectedDemuxed -> { + result.sendText("echo 'Input received'\n") + + val output = + withTimeoutOrNull(5.seconds) { + result.stdout + .take(5) + .toList() + .joinToString("") + } + + assertEquals(output?.contains("Input received"), true) + + result.close() + } + } + } catch (e: Throwable) { + println("WebSocket stdin test skipped: ${e.message}") + } + } + } + + @Test + fun `send binary data via WebSocket`() = + runTest { + testClient.withContainer( + image = "alpine:latest", + options = { + command = listOf("cat") + attachStdin = true + }, + ) { container -> + testClient.containers.start(container) + + try { + val result = + testClient.containers.attachWebSocket(container) { + stdout = true + stdin = true + stream = true + } + + when (result) { + is ContainerAttachWebSocketResult.Connected -> { + // Send binary data (ASCII text as bytes) + val testData = "Binary test data\n".toByteArray() + result.sendBinary(testData) + + val output = + withTimeoutOrNull(5.seconds) { + result.output + .take(1) + .toList() + .joinToString("") { it.value } + } + + assertEquals(output?.contains("Binary test data"), true) + + result.close() + } + + is ContainerAttachWebSocketResult.ConnectedDemuxed -> { + val testData = "Binary test data\n".toByteArray() + result.sendBinary(testData) + + val output = + withTimeoutOrNull(5.seconds) { + result.stdout + .take(1) + .toList() + .joinToString("") + } + + assertEquals(output?.contains("Binary test data"), true) + + result.close() + } + } + } catch (e: Throwable) { + println("WebSocket binary test skipped: ${e.message}") + } + } + } +} From 9294eb20a835b141639db3bece9f3ac753274819 Mon Sep 17 00:00:00 2001 From: Natan Date: Tue, 20 Jan 2026 17:30:05 -0300 Subject: [PATCH 8/9] Update SUPPORTED_ENDPOINTS.md --- SUPPORTED_ENDPOINTS.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/SUPPORTED_ENDPOINTS.md b/SUPPORTED_ENDPOINTS.md index bf1cc850..b1bc9801 100644 --- a/SUPPORTED_ENDPOINTS.md +++ b/SUPPORTED_ENDPOINTS.md @@ -1,8 +1,8 @@ # docker-kotlin supported Docker API endpoints -Supports 48 of 106 endpoints +Supports 49 of 107 endpoints -### Containers (15/25) +### Containers (16/26) * [x] List containers - GET **/containers/json** * [x] Create a container - POST **/containers/create** * [x] Inspect a container - GET **/containers/:id/json** @@ -20,8 +20,9 @@ Supports 48 of 106 endpoints * [x] Rename a container - **POST /containers/:id/rename** * [x] Pause a container - **POST /containers/:id/pause** * [x] Unpause a container - **POST /containers/:id/unpause** -* [ ] Attach to a container - **POST /containers/:id/attach** -* [ ] Attach to a container via a websocket - **POST /containers/:id/attach/ws** +* [ ] Attach to a container (HTTP) - **POST /containers/:id/attach** +* [ ] Attach to a container (TCP) - **POST /containers/:id/attach** +* [x] Attach to a container (WebSocket) - **POST /containers/:id/attach/ws** * [x] Wait for a container - **POST /containers/:id/wait** * [x] Remove a container - **DELETE /containers/:id** * [x] Get information about files in a container - **HEAD /containers/:id/archive** From 9e359a1a278d79e54826f2e2006dbdfa9ffff8f5 Mon Sep 17 00:00:00 2001 From: Natan Date: Tue, 20 Jan 2026 17:30:10 -0300 Subject: [PATCH 9/9] Update legacy ABI --- api/docker-kotlin.api | 88 +++++++++++++++ api/docker-kotlin.klib.api | 101 ++++++++++++++++++ .../container/AttachContainerWebSocketIT.kt | 1 - 3 files changed, 189 insertions(+), 1 deletion(-) diff --git a/api/docker-kotlin.api b/api/docker-kotlin.api index d2745e05..d9682305 100644 --- a/api/docker-kotlin.api +++ b/api/docker-kotlin.api @@ -1345,6 +1345,91 @@ public final class me/devnatan/dockerkt/models/container/ContainerArchiveInfoKt public static final fun getModifiedAt (Lme/devnatan/dockerkt/models/container/ContainerArchiveInfo;)Lkotlin/time/Instant; } +public final class me/devnatan/dockerkt/models/container/ContainerAttachOptions { + public static final field Companion Lme/devnatan/dockerkt/models/container/ContainerAttachOptions$Companion; + public fun ()V + public fun (ZZZZZLjava/lang/String;)V + public synthetic fun (ZZZZZLjava/lang/String;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Z + public final fun component2 ()Z + public final fun component3 ()Z + public final fun component4 ()Z + public final fun component5 ()Z + public final fun component6 ()Ljava/lang/String; + public final fun copy (ZZZZZLjava/lang/String;)Lme/devnatan/dockerkt/models/container/ContainerAttachOptions; + public static synthetic fun copy$default (Lme/devnatan/dockerkt/models/container/ContainerAttachOptions;ZZZZZLjava/lang/String;ILjava/lang/Object;)Lme/devnatan/dockerkt/models/container/ContainerAttachOptions; + public fun equals (Ljava/lang/Object;)Z + public final fun getDetachKeys ()Ljava/lang/String; + public final fun getLogs ()Z + public final fun getStderr ()Z + public final fun getStdin ()Z + public final fun getStdout ()Z + public final fun getStream ()Z + public fun hashCode ()I + public final fun setDetachKeys (Ljava/lang/String;)V + public final fun setLogs (Z)V + public final fun setStderr (Z)V + public final fun setStdin (Z)V + public final fun setStdout (Z)V + public final fun setStream (Z)V + public fun toString ()Ljava/lang/String; +} + +public final synthetic class me/devnatan/dockerkt/models/container/ContainerAttachOptions$$serializer : kotlinx/serialization/internal/GeneratedSerializer { + public static final field INSTANCE Lme/devnatan/dockerkt/models/container/ContainerAttachOptions$$serializer; + public final fun childSerializers ()[Lkotlinx/serialization/KSerializer; + public synthetic fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Ljava/lang/Object; + public final fun deserialize (Lkotlinx/serialization/encoding/Decoder;)Lme/devnatan/dockerkt/models/container/ContainerAttachOptions; + public final fun getDescriptor ()Lkotlinx/serialization/descriptors/SerialDescriptor; + public synthetic fun serialize (Lkotlinx/serialization/encoding/Encoder;Ljava/lang/Object;)V + public final fun serialize (Lkotlinx/serialization/encoding/Encoder;Lme/devnatan/dockerkt/models/container/ContainerAttachOptions;)V + public fun typeParametersSerializers ()[Lkotlinx/serialization/KSerializer; +} + +public final class me/devnatan/dockerkt/models/container/ContainerAttachOptions$Companion { + public final fun serializer ()Lkotlinx/serialization/KSerializer; +} + +public abstract class me/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult { + public abstract fun getClose ()Lkotlin/jvm/functions/Function1; +} + +public final class me/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult$Connected : me/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult { + public fun (Lkotlin/jvm/functions/Function1;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V + public final fun component1 ()Lkotlin/jvm/functions/Function1; + public final fun component2 ()Lkotlinx/coroutines/flow/Flow; + public final fun component3 ()Lkotlin/jvm/functions/Function2; + public final fun component4 ()Lkotlin/jvm/functions/Function2; + public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lme/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult$Connected; + public static synthetic fun copy$default (Lme/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult$Connected;Lkotlin/jvm/functions/Function1;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lme/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult$Connected; + public fun equals (Ljava/lang/Object;)Z + public fun getClose ()Lkotlin/jvm/functions/Function1; + public final fun getOutput ()Lkotlinx/coroutines/flow/Flow; + public final fun getSendBinary ()Lkotlin/jvm/functions/Function2; + public final fun getSendText ()Lkotlin/jvm/functions/Function2; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class me/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult$ConnectedDemuxed : me/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult { + public fun (Lkotlin/jvm/functions/Function1;Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)V + public final fun component1 ()Lkotlin/jvm/functions/Function1; + public final fun component2 ()Lkotlinx/coroutines/flow/Flow; + public final fun component3 ()Lkotlinx/coroutines/flow/Flow; + public final fun component4 ()Lkotlin/jvm/functions/Function2; + public final fun component5 ()Lkotlin/jvm/functions/Function2; + public final fun copy (Lkotlin/jvm/functions/Function1;Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;)Lme/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult$ConnectedDemuxed; + public static synthetic fun copy$default (Lme/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult$ConnectedDemuxed;Lkotlin/jvm/functions/Function1;Lkotlinx/coroutines/flow/Flow;Lkotlinx/coroutines/flow/Flow;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function2;ILjava/lang/Object;)Lme/devnatan/dockerkt/models/container/ContainerAttachWebSocketResult$ConnectedDemuxed; + public fun equals (Ljava/lang/Object;)Z + public fun getClose ()Lkotlin/jvm/functions/Function1; + public final fun getSendBinary ()Lkotlin/jvm/functions/Function2; + public final fun getSendText ()Lkotlin/jvm/functions/Function2; + public final fun getStderr ()Lkotlinx/coroutines/flow/Flow; + public final fun getStdout ()Lkotlinx/coroutines/flow/Flow; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class me/devnatan/dockerkt/models/container/ContainerConfig { public static final field Companion Lme/devnatan/dockerkt/models/container/ContainerConfig$Companion; public fun ()V @@ -5016,6 +5101,8 @@ public final class me/devnatan/dockerkt/resource/container/ContainerRenameConfli public final class me/devnatan/dockerkt/resource/container/ContainerResource { public fun (Lkotlinx/coroutines/CoroutineScope;Lkotlinx/serialization/json/Json;Lio/ktor/client/HttpClient;)V public final synthetic fun attach (Ljava/lang/String;)Lkotlinx/coroutines/flow/Flow; + public final fun attachWebSocket (Ljava/lang/String;Lme/devnatan/dockerkt/models/container/ContainerAttachOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun attachWebSocket$default (Lme/devnatan/dockerkt/resource/container/ContainerResource;Ljava/lang/String;Lme/devnatan/dockerkt/models/container/ContainerAttachOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; public final fun copyDirectoryFrom (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public final fun copyDirectoryTo (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lme/devnatan/dockerkt/models/container/ContainerCopyOptions;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static synthetic fun copyDirectoryTo$default (Lme/devnatan/dockerkt/resource/container/ContainerResource;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lme/devnatan/dockerkt/models/container/ContainerCopyOptions;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; @@ -5086,6 +5173,7 @@ public final class me/devnatan/dockerkt/resource/container/ContainerResource { } public final class me/devnatan/dockerkt/resource/container/ContainerResourceExtKt { + public static final fun attachWebSocket (Lme/devnatan/dockerkt/resource/container/ContainerResource;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun copyDirectoryTo (Lme/devnatan/dockerkt/resource/container/ContainerResource;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun copyFileTo (Lme/devnatan/dockerkt/resource/container/ContainerResource;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public static final fun copyTo (Lme/devnatan/dockerkt/resource/container/ContainerResource;Ljava/lang/String;Ljava/lang/String;[BLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/api/docker-kotlin.klib.api b/api/docker-kotlin.klib.api index c86e95aa..5dd5be3b 100644 --- a/api/docker-kotlin.klib.api +++ b/api/docker-kotlin.klib.api @@ -279,6 +279,53 @@ final class me.devnatan.dockerkt.models.container/ContainerArchiveInfo { // me.d } } +final class me.devnatan.dockerkt.models.container/ContainerAttachOptions { // me.devnatan.dockerkt.models.container/ContainerAttachOptions|null[0] + constructor (kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String? = ...) // me.devnatan.dockerkt.models.container/ContainerAttachOptions.|(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String?){}[0] + + final var detachKeys // me.devnatan.dockerkt.models.container/ContainerAttachOptions.detachKeys|{}detachKeys[0] + final fun (): kotlin/String? // me.devnatan.dockerkt.models.container/ContainerAttachOptions.detachKeys.|(){}[0] + final fun (kotlin/String?) // me.devnatan.dockerkt.models.container/ContainerAttachOptions.detachKeys.|(kotlin.String?){}[0] + final var logs // me.devnatan.dockerkt.models.container/ContainerAttachOptions.logs|{}logs[0] + final fun (): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.logs.|(){}[0] + final fun (kotlin/Boolean) // me.devnatan.dockerkt.models.container/ContainerAttachOptions.logs.|(kotlin.Boolean){}[0] + final var stderr // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stderr|{}stderr[0] + final fun (): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stderr.|(){}[0] + final fun (kotlin/Boolean) // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stderr.|(kotlin.Boolean){}[0] + final var stdin // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stdin|{}stdin[0] + final fun (): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stdin.|(){}[0] + final fun (kotlin/Boolean) // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stdin.|(kotlin.Boolean){}[0] + final var stdout // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stdout|{}stdout[0] + final fun (): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stdout.|(){}[0] + final fun (kotlin/Boolean) // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stdout.|(kotlin.Boolean){}[0] + final var stream // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stream|{}stream[0] + final fun (): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stream.|(){}[0] + final fun (kotlin/Boolean) // me.devnatan.dockerkt.models.container/ContainerAttachOptions.stream.|(kotlin.Boolean){}[0] + + final fun component1(): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.component1|component1(){}[0] + final fun component2(): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.component2|component2(){}[0] + final fun component3(): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.component3|component3(){}[0] + final fun component4(): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.component4|component4(){}[0] + final fun component5(): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.component5|component5(){}[0] + final fun component6(): kotlin/String? // me.devnatan.dockerkt.models.container/ContainerAttachOptions.component6|component6(){}[0] + final fun copy(kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/Boolean = ..., kotlin/String? = ...): me.devnatan.dockerkt.models.container/ContainerAttachOptions // me.devnatan.dockerkt.models.container/ContainerAttachOptions.copy|copy(kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.Boolean;kotlin.String?){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachOptions.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // me.devnatan.dockerkt.models.container/ContainerAttachOptions.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // me.devnatan.dockerkt.models.container/ContainerAttachOptions.toString|toString(){}[0] + + final object $serializer : kotlinx.serialization.internal/GeneratedSerializer { // me.devnatan.dockerkt.models.container/ContainerAttachOptions.$serializer|null[0] + final val descriptor // me.devnatan.dockerkt.models.container/ContainerAttachOptions.$serializer.descriptor|{}descriptor[0] + final fun (): kotlinx.serialization.descriptors/SerialDescriptor // me.devnatan.dockerkt.models.container/ContainerAttachOptions.$serializer.descriptor.|(){}[0] + + final fun childSerializers(): kotlin/Array> // me.devnatan.dockerkt.models.container/ContainerAttachOptions.$serializer.childSerializers|childSerializers(){}[0] + final fun deserialize(kotlinx.serialization.encoding/Decoder): me.devnatan.dockerkt.models.container/ContainerAttachOptions // me.devnatan.dockerkt.models.container/ContainerAttachOptions.$serializer.deserialize|deserialize(kotlinx.serialization.encoding.Decoder){}[0] + final fun serialize(kotlinx.serialization.encoding/Encoder, me.devnatan.dockerkt.models.container/ContainerAttachOptions) // me.devnatan.dockerkt.models.container/ContainerAttachOptions.$serializer.serialize|serialize(kotlinx.serialization.encoding.Encoder;me.devnatan.dockerkt.models.container.ContainerAttachOptions){}[0] + } + + final object Companion { // me.devnatan.dockerkt.models.container/ContainerAttachOptions.Companion|null[0] + final fun serializer(): kotlinx.serialization/KSerializer // me.devnatan.dockerkt.models.container/ContainerAttachOptions.Companion.serializer|serializer(){}[0] + } +} + final class me.devnatan.dockerkt.models.container/ContainerConfig { // me.devnatan.dockerkt.models.container/ContainerConfig|null[0] constructor (kotlin/String? = ..., kotlin/String? = ..., kotlin/String? = ..., kotlin/Boolean? = ..., kotlin/Boolean? = ..., kotlin/Boolean? = ..., kotlin.collections/List? = ..., kotlin/Boolean? = ..., kotlin/Boolean? = ..., kotlin/Boolean? = ..., kotlin.collections/List? = ..., kotlin.collections/List? = ..., me.devnatan.dockerkt.models/HealthConfig? = ..., kotlin/Boolean? = ..., kotlin/String? = ..., kotlin.collections/List? = ..., kotlin/String? = ..., kotlin.collections/List? = ..., kotlin/Boolean? = ..., kotlin/String? = ..., kotlin.collections/List? = ..., kotlin.collections/Map = ..., kotlin/String? = ..., kotlin/Int? = ..., kotlin.collections/List = ...) // me.devnatan.dockerkt.models.container/ContainerConfig.|(kotlin.String?;kotlin.String?;kotlin.String?;kotlin.Boolean?;kotlin.Boolean?;kotlin.Boolean?;kotlin.collections.List?;kotlin.Boolean?;kotlin.Boolean?;kotlin.Boolean?;kotlin.collections.List?;kotlin.collections.List?;me.devnatan.dockerkt.models.HealthConfig?;kotlin.Boolean?;kotlin.String?;kotlin.collections.List?;kotlin.String?;kotlin.collections.List?;kotlin.Boolean?;kotlin.String?;kotlin.collections.List?;kotlin.collections.Map;kotlin.String?;kotlin.Int?;kotlin.collections.List){}[0] @@ -5179,6 +5226,7 @@ final class me.devnatan.dockerkt.resource.container/ContainerResource { // me.de constructor () // me.devnatan.dockerkt.resource.container/ContainerResource.|(){}[0] final fun attach(kotlin/String): kotlinx.coroutines.flow/Flow // me.devnatan.dockerkt.resource.container/ContainerResource.attach|attach(kotlin.String){}[0] + final suspend fun attachWebSocket(kotlin/String, me.devnatan.dockerkt.models.container/ContainerAttachOptions = ...): me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult // me.devnatan.dockerkt.resource.container/ContainerResource.attachWebSocket|attachWebSocket(kotlin.String;me.devnatan.dockerkt.models.container.ContainerAttachOptions){}[0] final suspend fun copyDirectoryFrom(kotlin/String, kotlin/String, kotlin/String) // me.devnatan.dockerkt.resource.container/ContainerResource.copyDirectoryFrom|copyDirectoryFrom(kotlin.String;kotlin.String;kotlin.String){}[0] final suspend fun copyDirectoryTo(kotlin/String, kotlin/String, kotlin/String, me.devnatan.dockerkt.models.container/ContainerCopyOptions = ...) // me.devnatan.dockerkt.resource.container/ContainerResource.copyDirectoryTo|copyDirectoryTo(kotlin.String;kotlin.String;kotlin.String;me.devnatan.dockerkt.models.container.ContainerCopyOptions){}[0] final suspend fun copyFileFrom(kotlin/String, kotlin/String, kotlin/String) // me.devnatan.dockerkt.resource.container/ContainerResource.copyFileFrom|copyFileFrom(kotlin.String;kotlin.String;kotlin.String){}[0] @@ -5380,6 +5428,58 @@ open class me.devnatan.dockerkt/DockerResourceException : me.devnatan.dockerkt/D open fun (): kotlin/String? // me.devnatan.dockerkt/DockerResourceException.message.|(){}[0] } +sealed class me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult { // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult|null[0] + abstract val close // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.close|{}close[0] + abstract fun (): kotlin.coroutines/SuspendFunction0 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.close.|(){}[0] + + final class Connected : me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult { // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected|null[0] + constructor (kotlin.coroutines/SuspendFunction0, kotlinx.coroutines.flow/Flow, kotlin.coroutines/SuspendFunction1, kotlin.coroutines/SuspendFunction1) // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.|(kotlin.coroutines.SuspendFunction0;kotlinx.coroutines.flow.Flow;kotlin.coroutines.SuspendFunction1;kotlin.coroutines.SuspendFunction1){}[0] + + final val close // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.close|{}close[0] + final fun (): kotlin.coroutines/SuspendFunction0 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.close.|(){}[0] + final val output // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.output|{}output[0] + final fun (): kotlinx.coroutines.flow/Flow // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.output.|(){}[0] + final val sendBinary // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.sendBinary|{}sendBinary[0] + final fun (): kotlin.coroutines/SuspendFunction1 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.sendBinary.|(){}[0] + final val sendText // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.sendText|{}sendText[0] + final fun (): kotlin.coroutines/SuspendFunction1 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.sendText.|(){}[0] + + final fun component1(): kotlin.coroutines/SuspendFunction0 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.component1|component1(){}[0] + final fun component2(): kotlinx.coroutines.flow/Flow // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.component2|component2(){}[0] + final fun component3(): kotlin.coroutines/SuspendFunction1 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.component3|component3(){}[0] + final fun component4(): kotlin.coroutines/SuspendFunction1 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.component4|component4(){}[0] + final fun copy(kotlin.coroutines/SuspendFunction0 = ..., kotlinx.coroutines.flow/Flow = ..., kotlin.coroutines/SuspendFunction1 = ..., kotlin.coroutines/SuspendFunction1 = ...): me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.copy|copy(kotlin.coroutines.SuspendFunction0;kotlinx.coroutines.flow.Flow;kotlin.coroutines.SuspendFunction1;kotlin.coroutines.SuspendFunction1){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.Connected.toString|toString(){}[0] + } + + final class ConnectedDemuxed : me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult { // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed|null[0] + constructor (kotlin.coroutines/SuspendFunction0, kotlinx.coroutines.flow/Flow, kotlinx.coroutines.flow/Flow, kotlin.coroutines/SuspendFunction1, kotlin.coroutines/SuspendFunction1) // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.|(kotlin.coroutines.SuspendFunction0;kotlinx.coroutines.flow.Flow;kotlinx.coroutines.flow.Flow;kotlin.coroutines.SuspendFunction1;kotlin.coroutines.SuspendFunction1){}[0] + + final val close // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.close|{}close[0] + final fun (): kotlin.coroutines/SuspendFunction0 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.close.|(){}[0] + final val sendBinary // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.sendBinary|{}sendBinary[0] + final fun (): kotlin.coroutines/SuspendFunction1 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.sendBinary.|(){}[0] + final val sendText // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.sendText|{}sendText[0] + final fun (): kotlin.coroutines/SuspendFunction1 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.sendText.|(){}[0] + final val stderr // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.stderr|{}stderr[0] + final fun (): kotlinx.coroutines.flow/Flow // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.stderr.|(){}[0] + final val stdout // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.stdout|{}stdout[0] + final fun (): kotlinx.coroutines.flow/Flow // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.stdout.|(){}[0] + + final fun component1(): kotlin.coroutines/SuspendFunction0 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.component1|component1(){}[0] + final fun component2(): kotlinx.coroutines.flow/Flow // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.component2|component2(){}[0] + final fun component3(): kotlinx.coroutines.flow/Flow // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.component3|component3(){}[0] + final fun component4(): kotlin.coroutines/SuspendFunction1 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.component4|component4(){}[0] + final fun component5(): kotlin.coroutines/SuspendFunction1 // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.component5|component5(){}[0] + final fun copy(kotlin.coroutines/SuspendFunction0 = ..., kotlinx.coroutines.flow/Flow = ..., kotlinx.coroutines.flow/Flow = ..., kotlin.coroutines/SuspendFunction1 = ..., kotlin.coroutines/SuspendFunction1 = ...): me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.copy|copy(kotlin.coroutines.SuspendFunction0;kotlinx.coroutines.flow.Flow;kotlinx.coroutines.flow.Flow;kotlin.coroutines.SuspendFunction1;kotlin.coroutines.SuspendFunction1){}[0] + final fun equals(kotlin/Any?): kotlin/Boolean // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.equals|equals(kotlin.Any?){}[0] + final fun hashCode(): kotlin/Int // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.hashCode|hashCode(){}[0] + final fun toString(): kotlin/String // me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult.ConnectedDemuxed.toString|toString(){}[0] + } +} + sealed class me.devnatan.dockerkt.models.container/ContainerLogsResult { // me.devnatan.dockerkt.models.container/ContainerLogsResult|null[0] final class Complete : me.devnatan.dockerkt.models.container/ContainerLogsResult { // me.devnatan.dockerkt.models.container/ContainerLogsResult.Complete|null[0] constructor (kotlin/String) // me.devnatan.dockerkt.models.container/ContainerLogsResult.Complete.|(kotlin.String){}[0] @@ -5597,6 +5697,7 @@ final fun me.devnatan.dockerkt.util/toJsonEncodedString(kotlin/Any): kotlin/Stri final inline fun (me.devnatan.dockerkt.models.container/ContainerListOptions).me.devnatan.dockerkt.models.container/filters(kotlin/Function1) // me.devnatan.dockerkt.models.container/filters|filters@me.devnatan.dockerkt.models.container.ContainerListOptions(kotlin.Function1){}[0] final inline fun (me.devnatan.dockerkt.resource.system/SystemResource).me.devnatan.dockerkt.resource.system/events(kotlin/Function1): kotlinx.coroutines.flow/Flow // me.devnatan.dockerkt.resource.system/events|events@me.devnatan.dockerkt.resource.system.SystemResource(kotlin.Function1){}[0] final inline fun me.devnatan.dockerkt/DockerClient(crossinline kotlin/Function1): me.devnatan.dockerkt/DockerClient // me.devnatan.dockerkt/DockerClient|DockerClient(kotlin.Function1){}[0] +final suspend fun (me.devnatan.dockerkt.resource.container/ContainerResource).me.devnatan.dockerkt.resource.container/attachWebSocket(kotlin/String, kotlin/Function1): me.devnatan.dockerkt.models.container/ContainerAttachWebSocketResult // me.devnatan.dockerkt.resource.container/attachWebSocket|attachWebSocket@me.devnatan.dockerkt.resource.container.ContainerResource(kotlin.String;kotlin.Function1){}[0] final suspend fun (me.devnatan.dockerkt.resource.container/ContainerResource).me.devnatan.dockerkt.resource.container/copyDirectoryTo(kotlin/String, kotlin/String, kotlin/String, kotlin/Function1) // me.devnatan.dockerkt.resource.container/copyDirectoryTo|copyDirectoryTo@me.devnatan.dockerkt.resource.container.ContainerResource(kotlin.String;kotlin.String;kotlin.String;kotlin.Function1){}[0] final suspend fun (me.devnatan.dockerkt.resource.container/ContainerResource).me.devnatan.dockerkt.resource.container/copyFileTo(kotlin/String, kotlin/String, kotlin/String, kotlin/Function1) // me.devnatan.dockerkt.resource.container/copyFileTo|copyFileTo@me.devnatan.dockerkt.resource.container.ContainerResource(kotlin.String;kotlin.String;kotlin.String;kotlin.Function1){}[0] final suspend fun (me.devnatan.dockerkt.resource.container/ContainerResource).me.devnatan.dockerkt.resource.container/copyTo(kotlin/String, kotlin/String, kotlin/ByteArray, kotlin/Function1) // me.devnatan.dockerkt.resource.container/copyTo|copyTo@me.devnatan.dockerkt.resource.container.ContainerResource(kotlin.String;kotlin.String;kotlin.ByteArray;kotlin.Function1){}[0] diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/AttachContainerWebSocketIT.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/AttachContainerWebSocketIT.kt index 3f9218b1..8f212428 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/AttachContainerWebSocketIT.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/AttachContainerWebSocketIT.kt @@ -15,7 +15,6 @@ import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds class AttachContainerWebSocketIT : ResourceIT() { - @Test fun `attach and receive output`() = runTest {