diff --git a/.editorconfig b/.editorconfig index df473d6d..2906ddcf 100644 --- a/.editorconfig +++ b/.editorconfig @@ -10,4 +10,5 @@ ij_kotlin_keep_blank_lines_before_right_brace = 0 ij_kotlin_align_multiline_parameters = false ij_continuation_indent_size = 4 ij_kotlin_allow_trailing_comma_on_call_site = true -ij_kotlin_allow_trailing_comma = true \ No newline at end of file +ij_kotlin_allow_trailing_comma = true +ktlint_property_naming_constant_naming = pascal_case \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 2ce4a3e9..80150d7f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import org.jmailen.gradle.kotlinter.support.ReporterType + plugins { alias(libs.plugins.kotlin.multiplatform) alias(libs.plugins.kotlinx.serialization) @@ -124,3 +126,7 @@ publishing { } } } + +kotlinter { + reporters = arrayOf(ReporterType.html.name) +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3fd11fcc..512e086c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,92 +4,31 @@ ktx-coroutines = "1.10.2" ktx-serialization = "1.9.0" ktor = "3.3.2" junixsocket = "2.6.1" -junit = "5.12.2" slf4j = "2.0.13" apache-compress = "1.26.2" kotlinx-io = "0.8.0" plugin-kotlinter = "5.3.0" plugin-publish = "0.35.0" -[libraries.ktx-coroutines-core] -module = "org.jetbrains.kotlinx:kotlinx-coroutines-core" -version.ref = "ktx-coroutines" - -[libraries.ktx-coroutines-test] -module = "org.jetbrains.kotlinx:kotlinx-coroutines-test" -version.ref = "ktx-coroutines" - -[libraries.ktx-serialization-core] -module = "org.jetbrains.kotlinx:kotlinx-serialization-core" -version.ref = "ktx-serialization" - -[libraries.ktx-serialization-json] -module = "org.jetbrains.kotlinx:kotlinx-serialization-json" -version.ref = "ktx-serialization" - -[libraries.ktor-client-core] -module = "io.ktor:ktor-client-core" -version.ref = "ktor" - -[libraries.ktor-client-engine-okhttp] -module = "io.ktor:ktor-client-okhttp" -version.ref = "ktor" - -[libraries.ktor-client-engine-cio] -module = "io.ktor:ktor-client-cio" -version.ref = "ktor" - -[libraries.ktor-client-serialization] -module = "io.ktor:ktor-client-serialization" -version.ref = "ktor" - -[libraries.ktor-client-json] -module = "io.ktor:ktor-client-json" -version.ref = "ktor" - -[libraries.ktor-client-logging] -module = "io.ktor:ktor-client-logging" -version.ref = "ktor" - -[libraries.ktor-client-mock] -module = "io.ktor:ktor-client-mock" -version.ref = "ktor" - -[libraries.ktor-client-content-negotiation] -module = "io.ktor:ktor-client-content-negotiation" -version.ref = "ktor" - -[libraries.ktor-serialization-kotlinx-json] -module = "io.ktor:ktor-serialization-kotlinx-json" -version.ref = "ktor" - -[libraries.ktor-network] -module = "io.ktor:ktor-network" -version.ref = "ktor" - -[libraries.junixsocket-common] -module = "com.kohlschutter.junixsocket:junixsocket-common" -version.ref = "junixsocket" - -[libraries.junixsocket-native] -module = "com.kohlschutter.junixsocket:junixsocket-native-common" -version.ref = "junixsocket" - -[libraries.junit] -module = "org.junit.jupiter:junit-jupiter-engine" -version.ref = "junit" - -[libraries.slf4j-api] -module = "org.slf4j:slf4j-api" -version.ref = "slf4j" - -[libraries.apache-compress] -module = "org.apache.commons:commons-compress" -version.ref = "apache-compress" - -[libraries.kotlinx-io-core] -module = "org.jetbrains.kotlinx:kotlinx-io-core" -version.ref = "kotlinx-io" +[libraries] +ktx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "ktx-coroutines" } +ktx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "ktx-coroutines" } +ktx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "ktx-serialization" } +ktx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "ktx-serialization" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-engine-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-engine-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } +ktor-client-json = { module = "io.ktor:ktor-client-json", version.ref = "ktor" } +ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +ktor-network = { module = "io.ktor:ktor-network", version.ref = "ktor" } +junixsocket-common = { module = "com.kohlschutter.junixsocket:junixsocket-common", version.ref = "junixsocket" } +junixsocket-native = { module = "com.kohlschutter.junixsocket:junixsocket-native-common", version.ref = "junixsocket" } +slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } +apache-compress = { module = "org.apache.commons:commons-compress", version.ref = "apache-compress" } +kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" } [bundles] ktor = ["ktor-client-core", "ktor-client-serialization", "ktor-client-json", "ktor-client-logging", "ktor-client-content-negotiation", "ktor-serialization-kotlinx-json", "ktor-network"] diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/DockerClientConfig.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/DockerClientConfig.kt index 125028a3..d81552ac 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/DockerClientConfig.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/DockerClientConfig.kt @@ -1,18 +1,33 @@ package me.devnatan.dockerkt -import me.devnatan.dockerkt.io.DEFAULT_DOCKER_HTTP_SOCKET -import me.devnatan.dockerkt.io.DEFAULT_DOCKER_UNIX_SOCKET -import me.devnatan.dockerkt.io.HTTP_SOCKET_PREFIX -import me.devnatan.dockerkt.io.UNIX_SOCKET_PREFIX +import me.devnatan.dockerkt.io.DefaultDockerHttpSocket +import me.devnatan.dockerkt.io.DefaultDockerUnixSocket +import me.devnatan.dockerkt.io.HttpSocketPrefix +import me.devnatan.dockerkt.io.UnixSocketPrefix import kotlin.jvm.JvmStatic internal val DefaultDocketClientConfig = DocketClientConfig.builder().forCurrentPlatform().build() +/** + * Daemon socket to connect to. + */ +private const val DockerHostEnvKey = "DOCKER_HOST" + +/** + * Override the negotiated Docker Remote API version. + */ +private const val DockerApiVersionEnvKey = "DOCKER_API_VERSION" + +/** + * Minimum Docker Remote API version supported by docker-kotlin. + */ +public const val DefaultDockerApiVersion: String = "1.41" + /** * Class to store all Docker client configurations. * * @param socketPath Docker socket file used to communicate with the main Docker daemon. - * If not set, it will try to get from [DOCKER_HOST_ENV_KEY] environment variable, if it's found, + * If not set, it will try to get from [DockerHostEnvKey] environment variable, if it's found, * will try to select the socket path based on current operating system. * @param apiVersion The version of the Docker API that will be used during communication. * See more: [Versioned API and SDK](https://docs.docker.com/engine/api/#versioned-api-and-sdk). @@ -47,8 +62,8 @@ public class DockerClientConfigBuilder { */ private var apiVersion: String = envOrFallback( - key = DOCKER_API_VERSION_ENV_KEY, - fallback = DEFAULT_DOCKER_API_VERSION, + key = DockerApiVersionEnvKey, + fallback = DefaultDockerApiVersion, prefix = null, ) @@ -90,15 +105,15 @@ public class DockerClientConfigBuilder { /** * Configures to use a Unix socket defaults common to the standard Docker configuration. * - * The socket path is defined to [DEFAULT_DOCKER_UNIX_SOCKET] if `DOCKER_HOST` env var is not set, or it doesn't - * have the [UNIX_SOCKET_PREFIX] on its prefix. + * The socket path is defined to [DefaultDockerUnixSocket] if `DOCKER_HOST` env var is not set, or it doesn't + * have the [UnixSocketPrefix] on its prefix. */ public fun useUnixDefaults(): DockerClientConfigBuilder { socketPath = envOrFallback( - key = DOCKER_HOST_ENV_KEY, - fallback = DEFAULT_DOCKER_UNIX_SOCKET, - prefix = UNIX_SOCKET_PREFIX, + key = DockerHostEnvKey, + fallback = DefaultDockerUnixSocket, + prefix = UnixSocketPrefix, ) return this } @@ -106,15 +121,15 @@ public class DockerClientConfigBuilder { /** * Configures to use an HTTP socket defaults common to the standard Docker configuration. * - * The socket path is defined to [DEFAULT_DOCKER_HTTP_SOCKET] if `DOCKER_HOST` env var is not set, or it doesn't - * have the [HTTP_SOCKET_PREFIX] on its prefix. + * The socket path is defined to [DefaultDockerHttpSocket] if `DOCKER_HOST` env var is not set, or it doesn't + * have the [HttpSocketPrefix] on its prefix. */ public fun useHttpDefaults(): DockerClientConfigBuilder { socketPath = envOrFallback( - key = DOCKER_HOST_ENV_KEY, - fallback = DEFAULT_DOCKER_HTTP_SOCKET, - prefix = HTTP_SOCKET_PREFIX, + key = DockerHostEnvKey, + fallback = DefaultDockerHttpSocket, + prefix = HttpSocketPrefix, ) return this } @@ -126,7 +141,7 @@ public class DockerClientConfigBuilder { public fun forCurrentPlatform(): DockerClientConfigBuilder { socketPath = envOrFallback( - key = DOCKER_HOST_ENV_KEY, + key = DockerHostEnvKey, fallback = selectDockerSocketPath(), prefix = null, ) @@ -163,25 +178,8 @@ public class DockerClientConfigBuilder { */ private fun selectDockerSocketPath(): String = if (isUnixPlatform()) { - DEFAULT_DOCKER_UNIX_SOCKET + DefaultDockerUnixSocket } else { - DEFAULT_DOCKER_HTTP_SOCKET + DefaultDockerHttpSocket } - - public companion object { - /** - * Daemon socket to connect to. - */ - private const val DOCKER_HOST_ENV_KEY = "DOCKER_HOST" - - /** - * Override the negotiated Docker Remote API version. - */ - private const val DOCKER_API_VERSION_ENV_KEY = "DOCKER_API_VERSION" - - /** - * Minimum Docker Remote API version supported by docker-kotlin. - */ - public const val DEFAULT_DOCKER_API_VERSION: String = "1.41" - } } diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/DockerClientFactory.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/DockerClientFactory.kt index 7ce090ad..f2f6a93d 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/DockerClientFactory.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/DockerClientFactory.kt @@ -2,11 +2,10 @@ package me.devnatan.dockerkt -import me.devnatan.dockerkt.DockerClientConfigBuilder.Companion.DEFAULT_DOCKER_API_VERSION import kotlin.jvm.JvmSynthetic /** - * Creates a new Docker client instance with platform default socket path and [DEFAULT_DOCKER_API_VERSION] + * Creates a new Docker client instance with platform default socket path and [DefaultDockerApiVersion] * Docker API version that'll be merged with specified configuration. * * @param configure The client configuration. diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Http.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Http.kt index bd62729c..2eef12af 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Http.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Http.kt @@ -9,7 +9,10 @@ import io.ktor.client.plugins.ResponseException import io.ktor.client.plugins.UserAgent import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest -import io.ktor.client.plugins.logging.* +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.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.http.URLBuilder @@ -30,6 +33,7 @@ internal fun createHttpClient(client: DockerClient): HttpClient { check(client.config.socketPath.isNotBlank()) { "Socket path cannot be blank" } return HttpClient { expectSuccess = true + install(ContentNegotiation) { json( Json { @@ -86,8 +90,8 @@ private fun createUrlBuilder(socketPath: String): URLBuilder = if (isUnixSocket(socketPath)) { URLBuilder( protocol = URLProtocol.HTTP, - port = DOCKER_SOCKET_PORT, - host = socketPath.substringAfter(UNIX_SOCKET_PREFIX).encodeToByteArray().toHexString() + ENCODED_HOSTNAME_SUFFIX, + port = DockerSocketPort, + host = socketPath.substringAfter(UnixSocketPrefix).encodeToByteArray().toHexString() + EncodedHostnameSuffix, ) } else { val url = Url(socketPath) diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Sockets.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Sockets.kt index 17c532e7..f9e5ec2c 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/io/Sockets.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/io/Sockets.kt @@ -4,16 +4,16 @@ package me.devnatan.dockerkt.io import kotlin.jvm.JvmName -internal const val ENCODED_HOSTNAME_SUFFIX = ".socket" +internal const val EncodedHostnameSuffix = ".socket" -internal const val DOCKER_SOCKET_PORT = 2375 -internal const val UNIX_SOCKET_PREFIX = "unix://" -internal const val HTTP_SOCKET_PREFIX = "tcp://" +internal const val DockerSocketPort = 2375 +internal const val UnixSocketPrefix = "unix://" +internal const val HttpSocketPrefix = "tcp://" // unix:///var/run/docker.sock -public const val DEFAULT_DOCKER_UNIX_SOCKET: String = "$UNIX_SOCKET_PREFIX/var/run/docker.sock" +public const val DefaultDockerUnixSocket: String = "$UnixSocketPrefix/var/run/docker.sock" // tcp://localhost:2375 -public const val DEFAULT_DOCKER_HTTP_SOCKET: String = "${HTTP_SOCKET_PREFIX}localhost:$DOCKER_SOCKET_PORT" +public const val DefaultDockerHttpSocket: String = "${HttpSocketPrefix}localhost:$DockerSocketPort" -internal fun isUnixSocket(input: String): Boolean = input.startsWith(UNIX_SOCKET_PREFIX) +internal fun isUnixSocket(input: String): Boolean = input.startsWith(UnixSocketPrefix) diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/models/Resource.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/models/Resource.kt deleted file mode 100644 index 065783db..00000000 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/models/Resource.kt +++ /dev/null @@ -1,8 +0,0 @@ -package me.devnatan.dockerkt.models - -public open class Resource internal constructor() { - internal var _rawValues: Map = emptyMap() - - public val rawValues: Map - get() = _rawValues -} diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/models/exec/ExecStartOptions.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/models/exec/ExecStartOptions.kt index 852c2661..b51fbb7f 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/models/exec/ExecStartOptions.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/models/exec/ExecStartOptions.kt @@ -1,16 +1,94 @@ package me.devnatan.dockerkt.models.exec +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.flow.Flow import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient /** - * Options for start an exec instance. + * Options for starting an exec instance. * - * @property detach If it should detach from the command immediately after exec start. - * @property tty Allocate a pseudo-TTY. + * These options control how the exec command is executed and how its output is returned. + * + * @property detach If true, detach from the exec command and return immediately after starting. + * When enabled, no output will be captured. Default: false + * + * @property tty If true, allocate a pseudo-TTY for the exec instance. + * This should be enabled for interactive commands that require terminal capabilities. + * When enabled, stdout and stderr are combined into a single stream. Default: false + * + * @property stream If true, return response data progressively as a Flow of strings, + * rather than waiting for the command to complete and returning all output at once. + * Useful for long-running commands or when you need to process output as it arrives. + * Mutually exclusive with [socket]. Default: false + * + * @property socket If true, return the raw connection socket to allow custom read/write operations. + * The socket must be explicitly closed by the caller when done. + * Useful for bidirectional communication with the exec instance. + * Mutually exclusive with [stream]. Default: false + * + * @property demux If true, separate stdout and stderr in the returned output. + * Only works when [tty] is false (a TTY combines stdout and stderr). + * When enabled with [stream]=true, returns [ExecStartResult.StreamDemuxed]. + * When enabled with [stream]=false, returns [ExecStartResult.CompleteDemuxed]. + * Default: false + * + * @see ExecStartResult */ @Serializable public data class ExecStartOptions( @SerialName("Detach") var detach: Boolean? = null, @SerialName("Tty") val tty: Boolean? = null, + @Transient val stream: Boolean = false, + @Transient val socket: Boolean = false, + @Transient val demux: Boolean = false, +) { + init { + require(!(stream && socket)) { + "stream and socket options are mutually exclusive" + } + require(!demux || tty == false) { + "demux requires tty to be false (TTY combines stdout and stderr)" + } + } +} + +@Serializable +public sealed class ExecStartResult { + /** Detached execution - no output */ + public object Detached : ExecStartResult() + + /** Complete output as string */ + public data class Complete( + val output: String, + ) : ExecStartResult() + + /** Complete output with separated stdout/stderr */ + public data class CompleteDemuxed( + val output: DemuxedOutput, + ) : ExecStartResult() + + /** Streaming output */ + public data class Stream( + val output: Flow, + ) : ExecStartResult() + + /** Streaming output with separated stdout/stderr */ + public data class StreamDemuxed( + val output: Flow, + ) : ExecStartResult() + + /** Raw socket connection */ + public data class Socket( + val channel: ByteReadChannel, + ) : ExecStartResult() +} + +/** + * Demultiplexed output with separate stdout and stderr. + */ +public data class DemuxedOutput( + val stdout: String, + val stderr: String, ) 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 99b690cc..6ac78d4f 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.kt @@ -1,7 +1,6 @@ package me.devnatan.dockerkt.resource.container import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import kotlinx.io.RawSource import me.devnatan.dockerkt.DockerResponseException import me.devnatan.dockerkt.models.Frame @@ -20,7 +19,7 @@ import me.devnatan.dockerkt.resource.image.ImageNotFoundException import kotlin.jvm.JvmOverloads import kotlin.time.Duration -internal const val FS_ROOT = "/" +internal const val FileSystemRoot = "/" public expect class ContainerResource { /** @@ -170,7 +169,7 @@ public expect class ContainerResource { @JvmOverloads public suspend fun archive( container: String, - path: String = FS_ROOT, + path: String = FileSystemRoot, ): ContainerArchiveInfo /** diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/exec/ExecResource.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/exec/ExecResource.kt index 2be8f1d0..373280d1 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/exec/ExecResource.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/exec/ExecResource.kt @@ -5,18 +5,31 @@ import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.post import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpStatusCode +import io.ktor.utils.io.ByteReadChannel +import io.ktor.utils.io.cancel +import io.ktor.utils.io.readAvailable +import io.ktor.utils.io.readFully +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow import me.devnatan.dockerkt.io.requestCatching import me.devnatan.dockerkt.models.IdOnlyResponse import me.devnatan.dockerkt.models.ResizeTTYOptions +import me.devnatan.dockerkt.models.Stream +import me.devnatan.dockerkt.models.exec.DemuxedOutput import me.devnatan.dockerkt.models.exec.ExecCreateOptions import me.devnatan.dockerkt.models.exec.ExecInspectResponse import me.devnatan.dockerkt.models.exec.ExecStartOptions +import me.devnatan.dockerkt.models.exec.ExecStartResult import me.devnatan.dockerkt.resource.ResourcePaths.CONTAINERS import me.devnatan.dockerkt.resource.container.ContainerNotFoundException import me.devnatan.dockerkt.resource.container.ContainerNotRunningException import kotlin.jvm.JvmSynthetic +public const val DefaultBufferSize: Int = 8 * 1024 // 8192 bytes +private const val BasePath: String = "/exec" + /** * Exec runs new commands inside running containers. * @@ -27,10 +40,6 @@ import kotlin.jvm.JvmSynthetic public class ExecResource internal constructor( private val httpClient: HttpClient, ) { - private companion object { - const val BASE_PATH = "/exec" - } - /** * Runs a command inside a running container. * @@ -74,32 +83,189 @@ public class ExecResource internal constructor( requestCatching( HttpStatusCode.NotFound to { exception -> ExecNotFoundException(exception, id) }, ) { - httpClient.get("$BASE_PATH/$id/json") + httpClient.get("$BasePath/$id/json") }.body() /** * Starts a previously set up exec instance. * - * If [ExecStartOptions.detach] from [options] is set to `true`, it'll return immediately after starting the - * command. Otherwise, it will set up and interactive session with the command. + * This method provides different modes of execution based on the options provided: + * - **Detached mode** ([ExecStartOptions.detach] = true): Returns immediately after starting the command + * - **Socket mode** ([ExecStartOptions.socket] = true): Returns a raw connection socket for custom read/write operations + * - **Stream mode** ([ExecStartOptions.stream] = true): Returns output progressively as a Flow of chunks + * - **Standard mode**: Collects and returns all output as a single string + * + * When [ExecStartOptions.demux] is true and [ExecStartOptions.tty] is false, stdout and stderr are separated. * * @param id Exec instance unique identifier. + * @param options Configuration options for starting the exec instance. See [ExecStartOptions] for details. + * @return [ExecStartResult] containing the output based on the options provided: + * - [ExecStartResult.Detached] if detach mode is enabled + * - [ExecStartResult.Socket] if socket mode is enabled (must be closed by caller) + * - [ExecStartResult.Stream] if stream mode is enabled without demux + * - [ExecStartResult.StreamDemuxed] if stream mode is enabled with demux + * - [ExecStartResult.Complete] if standard mode without demux + * - [ExecStartResult.CompleteDemuxed] if standard mode with demux + * * @throws ExecNotFoundException If exec instance is not found. * @throws ContainerNotRunningException If the container in which the exec instance was created is not running. */ public suspend fun start( id: String, options: ExecStartOptions, - ) { + ): ExecStartResult = requestCatching( HttpStatusCode.NotFound to { exception -> ExecNotFoundException(exception, id) }, HttpStatusCode.Conflict to { exception -> ContainerNotRunningException(exception, null) }, ) { - httpClient.post("$BASE_PATH/$id/start") { - setBody(options) + val response = + httpClient.post("$BasePath/$id/start") { + if (!headers.contains("Connection", "upgrade")) { + setBody(options) + } + } + + when { + options.detach == true -> { + ExecStartResult.Detached + } + + options.socket -> { + ExecStartResult.Socket(response.bodyAsChannel()) + } + + options.stream -> { + val flow = + readFromSocket( + channel = response.bodyAsChannel(), + tty = options.tty == true, + demux = options.demux, + ) + + @Suppress("UNCHECKED_CAST") + if (options.demux) { + ExecStartResult.StreamDemuxed(flow as Flow) + } else { + ExecStartResult.Stream(flow as Flow) + } + } + + else -> { + val output = + collectFromSocket( + response.bodyAsChannel(), + tty = options.tty == true, + demux = options.demux, + ) + if (options.demux) { + ExecStartResult.CompleteDemuxed(output as DemuxedOutput) + } else { + ExecStartResult.Complete(output as String) + } + } } } - } + + private fun readFromSocket( + channel: ByteReadChannel, + tty: Boolean, + demux: Boolean, + ): Flow<*> = + flow { + try { + if (tty) { + val buffer = ByteArray(DefaultBufferSize) + while (true) { + val bytesRead = + try { + channel.readAvailable(buffer, 0, buffer.size) + } catch (e: Exception) { + break + } + + if (bytesRead == -1) { + break + } + + if (bytesRead > 0) { + emit(buffer.copyOf(bytesRead).decodeToString()) + } + } + } else { + while (true) { + val header = ByteArray(8) + val headerBytesRead = + try { + channel.readFully(header, 0, 8) + 8 + } catch (e: Exception) { + break + } + + if (headerBytesRead < 8) { + break + } + + val streamType = Stream.typeOfOrNull(header[0])!! + val size = + ((header[4].toInt() and 0xFF) shl 24) or + ((header[5].toInt() and 0xFF) shl 16) or + ((header[6].toInt() and 0xFF) shl 8) or + (header[7].toInt() and 0xFF) + + if (size > 0) { + val data = ByteArray(size) + try { + channel.readFully(data, 0, size) + } catch (_: Exception) { + break + } + + if (demux) { + emit( + DemuxedOutput( + stdout = if (streamType == Stream.StdOut) data.decodeToString() else "", + stderr = if (streamType == Stream.StdErr) data.decodeToString() else "", + ), + ) + } else { + emit(data.decodeToString()) + } + } + } + } + } finally { + channel.cancel() + } + } + + private suspend fun collectFromSocket( + channel: ByteReadChannel, + tty: Boolean, + demux: Boolean, + ): Any = + try { + if (demux && !tty) { + val stdout = StringBuilder() + val stderr = StringBuilder() + + readFromSocket(channel, tty, demux).collect { output -> + output as DemuxedOutput + stdout.append(output.stdout) + stderr.append(output.stderr) + } + + DemuxedOutput(stdout.toString(), stderr.toString()) + } else { + val output = StringBuilder() + readFromSocket(channel, tty, demux).collect { chunk -> + output.append(chunk as String) + } + output.toString() + } + } finally { + channel.cancel() + } /** * Resizes a TTY session used by an exec instance. @@ -117,7 +283,7 @@ public class ExecResource internal constructor( requestCatching( HttpStatusCode.NotFound to { exception -> ExecNotFoundException(exception, id) }, ) { - httpClient.post("$BASE_PATH/$id/resize") { + httpClient.post("$BasePath/$id/resize") { setBody(options) } } diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/image/ImageResource.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/image/ImageResource.kt index 3352ae31..46e57341 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/image/ImageResource.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/image/ImageResource.kt @@ -15,13 +15,12 @@ import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readUTF8Line import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import me.devnatan.dockerkt.models.image.ImageBuildOptions import me.devnatan.dockerkt.models.image.ImagePull import me.devnatan.dockerkt.models.image.ImageSummary -private const val BASE_PATH = "/images" +private const val BasePath = "/images" public class ImageResource internal constructor( private val httpClient: HttpClient, @@ -31,12 +30,12 @@ public class ImageResource internal constructor( private val TAR_CONTENT_TYPE = ContentType.parse("application/x-tar") } - public suspend fun list(): List = httpClient.get("$BASE_PATH/json").body() + public suspend fun list(): List = httpClient.get("$BasePath/json").body() public fun pull(image: String): Flow = flow { httpClient - .preparePost("$BASE_PATH/create") { + .preparePost("$BasePath/create") { parameter("fromImage", image) }.execute { response -> val channel = response.body() @@ -52,7 +51,7 @@ public class ImageResource internal constructor( force: Boolean? = false, noprune: Boolean? = false, ) { - httpClient.delete("$BASE_PATH/$name") { + httpClient.delete("$BasePath/$name") { parameter("force", force) parameter("noprune", noprune) } diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/network/NetworkResource.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/network/NetworkResource.kt index 725df5d7..e2c56c0d 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/network/NetworkResource.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/network/NetworkResource.kt @@ -8,7 +8,6 @@ import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.HttpStatusCode -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import me.devnatan.dockerkt.io.requestCatching import me.devnatan.dockerkt.models.network.Network @@ -18,6 +17,8 @@ import me.devnatan.dockerkt.models.network.NetworkListFilters import me.devnatan.dockerkt.models.network.NetworkPruneOptions import me.devnatan.dockerkt.resource.NetworkNotFoundException +private const val BasePath = "/networks" + /** * Networks are user-defined networks that containers can be attached to. * See the [networking documentation](https://docs.docker.com/network/) for more information. @@ -26,10 +27,6 @@ public class NetworkResource internal constructor( private val httpClient: HttpClient, private val json: Json, ) { - private companion object { - const val BASE_PATH = "/networks" - } - /** * Returns a list of networks. * @@ -38,7 +35,7 @@ public class NetworkResource internal constructor( */ public suspend fun list(filters: NetworkListFilters? = null): List = httpClient - .get(BASE_PATH) { + .get(BasePath) { parameter("filters", filters?.let(json::encodeToString)) }.body() @@ -56,7 +53,7 @@ public class NetworkResource internal constructor( requestCatching( HttpStatusCode.NotFound to { NetworkNotFoundException(it, id) }, ) { - httpClient.get("$BASE_PATH/$id") { + httpClient.get("$BasePath/$id") { parameter("verbose", options?.verbose) parameter("scope", options?.scope) } @@ -69,7 +66,7 @@ public class NetworkResource internal constructor( * @see NetworkDelete */ public suspend fun remove(id: String) { - httpClient.delete("$BASE_PATH/$id") + httpClient.delete("$BasePath/$id") } /** @@ -82,7 +79,7 @@ public class NetworkResource internal constructor( checkNotNull(config.name) { "Network name is required and cannot be null" } return httpClient - .post("$BASE_PATH/create") { + .post("$BasePath/create") { setBody(config) }.body() } @@ -95,7 +92,7 @@ public class NetworkResource internal constructor( * @see NetworkPrune */ public suspend fun prune(options: NetworkPruneOptions? = null) { - httpClient.post("$BASE_PATH/prune") { + httpClient.post("$BasePath/prune") { parameter("filters", options) } } @@ -111,7 +108,7 @@ public class NetworkResource internal constructor( id: String, container: String, ) { - httpClient.post("$BASE_PATH/$id/connect") { + httpClient.post("$BasePath/$id/connect") { setBody(mapOf("Container" to container)) } } @@ -127,7 +124,7 @@ public class NetworkResource internal constructor( id: String, container: String, ) { - httpClient.post("$BASE_PATH/$id/disconnect") { + httpClient.post("$BasePath/$id/disconnect") { setBody(mapOf("Container" to container)) } } diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/secret/SecretResource.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/secret/SecretResource.kt index 1af72632..3247b1fc 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/secret/SecretResource.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/secret/SecretResource.kt @@ -8,7 +8,6 @@ import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.HttpStatusCode -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import me.devnatan.dockerkt.io.requestCatching import me.devnatan.dockerkt.models.IdOnlyResponse @@ -17,6 +16,8 @@ import me.devnatan.dockerkt.models.secret.SecretListFilters import me.devnatan.dockerkt.models.secret.SecretSpec import me.devnatan.dockerkt.resource.swarm.NodeNotPartOfSwarmException +private const val BasePath = "/secrets" + /** * Secrets are sensitive data that can be used by Docker services. * Swarm mode must be enabled for these endpoints to work. @@ -25,10 +26,6 @@ public class SecretResource internal constructor( private val httpClient: HttpClient, private val json: Json, ) { - private companion object { - const val BASE_PATH = "/secrets" - } - /** * Lists all secrets. * @param filters Filters to process on the secrets list. @@ -37,7 +34,7 @@ public class SecretResource internal constructor( requestCatching( HttpStatusCode.ServiceUnavailable to ::NodeNotPartOfSwarmException, ) { - httpClient.get(BASE_PATH) { + httpClient.get(BasePath) { parameter("filters", filters?.let(json::encodeToString)) } }.body() @@ -47,7 +44,7 @@ public class SecretResource internal constructor( * * @param id The id of the secret. */ - public suspend fun inspect(id: String): Secret = httpClient.get("$BASE_PATH/$id").body() + public suspend fun inspect(id: String): Secret = httpClient.get("$BasePath/$id").body() /** * Deletes a secret. @@ -55,7 +52,7 @@ public class SecretResource internal constructor( * @param id The id of the secret. */ public suspend fun delete(id: String) { - httpClient.delete("$BASE_PATH/$id") + httpClient.delete("$BasePath/$id") } /** @@ -68,7 +65,7 @@ public class SecretResource internal constructor( HttpStatusCode.Conflict to { SecretNameConflictException(it, options.name) }, HttpStatusCode.ServiceUnavailable to ::NodeNotPartOfSwarmException, ) { - httpClient.post("$BASE_PATH/create") { + httpClient.post("$BasePath/create") { setBody(options) } }.body().id @@ -89,7 +86,7 @@ public class SecretResource internal constructor( HttpStatusCode.NotFound to { SecretNotFoundException(it, id, version) }, HttpStatusCode.ServiceUnavailable to ::NodeNotPartOfSwarmException, ) { - httpClient.post("$BASE_PATH/$id/update") { + httpClient.post("$BasePath/$id/update") { parameter("version", version) setBody(options) } diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/system/SystemResource.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/system/SystemResource.kt index 2297ca3e..34e438d5 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/system/SystemResource.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/system/SystemResource.kt @@ -10,7 +10,6 @@ import io.ktor.utils.io.ByteReadChannel import io.ktor.utils.io.readUTF8Line import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import me.devnatan.dockerkt.io.requestCatching import me.devnatan.dockerkt.models.system.Event @@ -26,10 +25,6 @@ public class SystemResource internal constructor( private val httpClient: HttpClient, private val json: Json, ) { - private companion object { - const val PING_ENDPOINT = "/_ping" - } - /** * Gets the version of Docker that is running and information about the system that Docker is running on. * @@ -54,9 +49,9 @@ public class SystemResource internal constructor( public suspend fun ping(head: Boolean = true): SystemPingData = requestCatching { if (head) { - httpClient.head(PING_ENDPOINT) + httpClient.head("/_ping") } else { - httpClient.get(PING_ENDPOINT) + httpClient.get("/_ping") } }.headers.let { headers -> SystemPingData( diff --git a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/volume/VolumeResource.kt b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/volume/VolumeResource.kt index 45fd3d48..87c27d19 100644 --- a/src/commonMain/kotlin/me/devnatan/dockerkt/resource/volume/VolumeResource.kt +++ b/src/commonMain/kotlin/me/devnatan/dockerkt/resource/volume/VolumeResource.kt @@ -7,7 +7,6 @@ import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import me.devnatan.dockerkt.models.volume.Volume import me.devnatan.dockerkt.models.volume.VolumeCreateOptions @@ -18,18 +17,16 @@ import me.devnatan.dockerkt.models.volume.VolumePruneResponse import me.devnatan.dockerkt.models.volume.VolumeRemoveOptions import kotlin.jvm.JvmOverloads +private const val BasePath = "/volumes" + public class VolumeResource internal constructor( private val httpClient: HttpClient, private val json: Json, ) { - private companion object { - const val BASE_PATH = "/volumes" - } - @JvmOverloads public suspend fun list(options: VolumeListOptions? = null): VolumeListResponse = httpClient - .get(BASE_PATH) { + .get(BasePath) { parameter("filters", options?.let(json::encodeToString)) }.body() @@ -43,7 +40,7 @@ public class VolumeResource internal constructor( */ public suspend fun create(config: VolumeCreateOptions): Volume = httpClient - .post("$BASE_PATH/create") { + .post("$BasePath/create") { setBody(config) }.body() @@ -55,7 +52,7 @@ public class VolumeResource internal constructor( * * @see VolumeInspect */ - public suspend fun inspect(id: String): Volume = httpClient.get("$BASE_PATH/$id").body() + public suspend fun inspect(id: String): Volume = httpClient.get("$BasePath/$id").body() /** * Remove a volume @@ -69,7 +66,7 @@ public class VolumeResource internal constructor( id: String, options: VolumeRemoveOptions? = null, ) { - httpClient.delete("$BASE_PATH/$id") { + httpClient.delete("$BasePath/$id") { parameter("force", options?.force) } } @@ -85,7 +82,7 @@ public class VolumeResource internal constructor( @JvmOverloads public suspend fun prune(options: VolumePruneOptions? = null): VolumePruneResponse = httpClient - .post("$BASE_PATH/prune") { + .post("$BasePath/prune") { parameter("filters", options?.let(json::encodeToString)) }.body() } diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/TestUtils.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/TestUtils.kt index 9ad860da..bbfb93e7 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/TestUtils.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/TestUtils.kt @@ -74,10 +74,13 @@ suspend fun DockerClient.withVolume( } } -/** - * Make a container started forever. - */ +/** Make a container started forever by attaching stdin */ fun ContainerCreateOptions.keepStartedForever() { attachStdin = true tty = true } + +/** Make a container started forever. */ +fun ContainerCreateOptions.sleepForever() { + command = listOf("sleep", "infinity") +} diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/InspectContainerIT.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/InspectContainerIT.kt index 2143f431..763b425a 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/InspectContainerIT.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/container/InspectContainerIT.kt @@ -4,6 +4,7 @@ package me.devnatan.dockerkt.resource.container import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest +import me.devnatan.dockerkt.keepStartedForever import me.devnatan.dockerkt.models.container.volume import me.devnatan.dockerkt.resource.ResourceIT import me.devnatan.dockerkt.withContainer @@ -18,7 +19,7 @@ class InspectContainerIT : ResourceIT() { "busybox:latest", { volume("/opt") - command = listOf("sleep", "infinity") + keepStartedForever() }, ) { id -> testClient.containers.start(id) diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/exec/ExecContainerIT.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/exec/ExecContainerIT.kt index edf19610..71b6c070 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/exec/ExecContainerIT.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/exec/ExecContainerIT.kt @@ -1,19 +1,30 @@ package me.devnatan.dockerkt.resource.exec +import io.ktor.utils.io.cancel +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest +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.container.ContainerNotRunningException +import me.devnatan.dockerkt.sleepForever import me.devnatan.dockerkt.withContainer import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue + +private const val TestImage = "alpine:latest" class ExecContainerIT : ResourceIT() { @Test - fun `exec a command in a container`() = + fun `exec a command in detached mode`() = runTest { testClient.withContainer( - "busybox:latest", - { - command = listOf("sleep", "infinity") + image = TestImage, + options = { + sleepForever() }, ) { id -> testClient.containers.start(id) @@ -23,22 +34,30 @@ class ExecContainerIT : ResourceIT() { command = listOf("true") } - testClient.exec.start(execId) + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(detach = true), + ) + + assertTrue(result is ExecStartResult.Detached) + + delay(100) val exec = testClient.exec.inspect(execId) - assertEquals(exec.exitCode, 0) + assertEquals(0, exec.exitCode) testClient.containers.stop(id) } } @Test - fun `exec a failing command in a container`() = + fun `exec a failing command in detached mode`() = runTest { testClient.withContainer( - "busybox:latest", - { - command = listOf("sleep", "infinity") + image = TestImage, + options = { + sleepForever() }, ) { id -> testClient.containers.start(id) @@ -48,12 +67,446 @@ class ExecContainerIT : ResourceIT() { command = listOf("false") } - testClient.exec.start(execId) + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(detach = true), + ) + + assertTrue(result is ExecStartResult.Detached) + + delay(100) + + val exec = testClient.exec.inspect(execId) + assertEquals(1, exec.exitCode) + + testClient.containers.stop(id) + } + } + + @Test + fun `exec a command and capture complete output`() = + runTest { + testClient.withContainer( + TestImage, + { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = listOf("sh", "-c", "echo 'hello world'") + attachStdout = true + attachStderr = true + } + + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(detach = false, stream = false), + ) + + assertTrue(result is ExecStartResult.Complete) + val output = result.output + assertTrue(output.contains("hello world")) val exec = testClient.exec.inspect(execId) - assertEquals(exec.exitCode, 1) + assertEquals(0, exec.exitCode) + + testClient.containers.stop(id) + } + } + + @Test + fun `exec a command with streaming output`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = listOf("sh", "-c", "echo line1 && echo line2 && echo line3") + attachStdout = true + attachStderr = true + } + + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(stream = true), + ) + + assertTrue(result is ExecStartResult.Stream) + + val output = + buildString { + result.output.collect { chunk -> + append(chunk) + } + } + + assertTrue(output.contains("line1"), "Output should contain 'line1', but was: $output") + assertTrue(output.contains("line2"), "Output should contain 'line2', but was: $output") + assertTrue(output.contains("line3"), "Output should contain 'line3', but was: $output") + + testClient.containers.stop(id) + } + } + + @Test + fun `exec a command with demuxed output`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = + listOf( + "sh", + "-c", + "echo 'stdout message' && echo 'stderr message' >&2", + ) + attachStdout = true + attachStderr = true + tty = false + } + + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(demux = true, tty = false), + ) + + assertTrue(result is ExecStartResult.CompleteDemuxed) + val output = result.output + + assertTrue(output.stdout.contains("stdout message"), "stdout was: ${output.stdout}") + assertTrue(output.stderr.contains("stderr message"), "stderr was: ${output.stderr}") testClient.containers.stop(id) } } + + @Test + fun `exec a command with demuxed streaming output`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = + listOf( + "sh", + "-c", + "echo out1 && echo err1 >&2 && echo out2 && echo err2 >&2", + ) + attachStdout = true + attachStderr = true + tty = false + } + + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(stream = true, demux = true, tty = false), + ) + + assertTrue(result is ExecStartResult.StreamDemuxed) + + val stdout = StringBuilder() + val stderr = StringBuilder() + + result.output.collect { output -> + stdout.append(output.stdout) + stderr.append(output.stderr) + } + + val stdoutStr = stdout.toString() + val stderrStr = stderr.toString() + + assertTrue(stdoutStr.contains("out1"), "stdout was: $stdoutStr") + assertTrue(stdoutStr.contains("out2"), "stdout was: $stdoutStr") + assertTrue(stderrStr.contains("err1"), "stderr was: $stderrStr") + assertTrue(stderrStr.contains("err2"), "stderr was: $stderrStr") + + testClient.containers.stop(id) + } + } + + @Test + fun `exec a command with TTY enabled`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = listOf("sh", "-c", "echo 'interactive output'") + attachStdout = true + attachStderr = true + tty = true + } + + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(tty = true, stream = false), + ) + + assertTrue(result is ExecStartResult.Complete) + assertTrue(result.output.contains("interactive output")) + + testClient.containers.stop(id) + } + } + + @Test + fun `exec a command with socket mode`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = listOf("sh", "-c", "echo 'socket test'") + attachStdout = true + attachStderr = true + } + + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(socket = true), + ) + + assertTrue(result is ExecStartResult.Socket) + + val channel = result.channel + try { + val buffer = ByteArray(1024) + val bytesRead = channel.readAvailable(buffer) + if (bytesRead > 0) { + val output = buffer.copyOf(bytesRead).decodeToString() + assertTrue(output.contains("socket test")) + } + } finally { + channel.cancel() + } + + testClient.containers.stop(id) + } + } + + @Test + fun `exec long running command with streaming`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = + listOf( + "sh", + "-c", + "for i in 1 2 3 4 5; do echo \"line \$i\"; sleep 0.1; done", + ) + attachStdout = true + attachStderr = true + } + + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(stream = true), + ) + + assertTrue(result is ExecStartResult.Stream) + + val chunks = mutableListOf() + result.output.collect { chunk -> + chunks.add(chunk) + } + + val fullOutput = chunks.joinToString("") + assertTrue(fullOutput.contains("line 1")) + assertTrue(fullOutput.contains("line 5")) + + testClient.containers.stop(id) + } + } + + @Test + fun `exec create fails when container is not running`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + command = listOf("true") + }, + ) { id -> + assertFailsWith { + testClient.exec.create(id) { + command = listOf("echo", "test") + } + } + } + } + + @Test + fun `exec start fails when container is not running`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = listOf("echo", "test") + } + + testClient.containers.stop(id) + + assertFailsWith { + testClient.exec.start(execId) + } + } + } + + @Test + fun `exec fails when exec instance not found`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + assertFailsWith { + testClient.exec.start( + "nonexistent_exec_id", + ExecStartOptions(detach = true), + ) + } + + testClient.containers.stop(id) + } + } + + @Test + fun `exec with environment variables`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = listOf("sh", "-c", "echo \"\$MY_VAR\"") + env = listOf("MY_VAR=hello from env") + attachStdout = true + } + + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(stream = false), + ) + + assertTrue(result is ExecStartResult.Complete) + assertTrue(result.output.contains("hello from env")) + + testClient.containers.stop(id) + } + } + + @Test + fun `exec with working directory`() = + runTest { + testClient.withContainer( + image = TestImage, + options = { + sleepForever() + }, + ) { id -> + testClient.containers.start(id) + + val execId = + testClient.exec.create(id) { + command = listOf("pwd") + workingDir = "/tmp" + attachStdout = true + } + + val result = + testClient.exec.start( + id = execId, + options = ExecStartOptions(stream = false), + ) + + assertTrue(result is ExecStartResult.Complete) + val output = result.output.trim() + assertEquals("/tmp", output) + + testClient.containers.stop(id) + } + } + + @Test + fun `validate mutually exclusive options`() { + assertFailsWith { + ExecStartOptions(stream = true, socket = true) + } + } + + @Test + fun `validate demux requires tty false`() { + assertFailsWith { + ExecStartOptions(demux = true, tty = true) + } + } } diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/network/NetworkResourceIT.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/network/NetworkResourceIT.kt index 44f463e7..0e5c5f76 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/network/NetworkResourceIT.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/network/NetworkResourceIT.kt @@ -11,14 +11,10 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class NetworkResourceIT : ResourceIT() { - companion object { - const val NETWORK_NAME = "docker-kotlin" - } - @Test fun `create network`() = runTest { - val createdNetwork = testClient.networks.create { name = NETWORK_NAME } + val createdNetwork = testClient.networks.create { name = "dockerkt" } val inspectedNetwork = testClient.networks.inspect(createdNetwork.id) assertEquals(createdNetwork.id, inspectedNetwork.id) @@ -29,7 +25,7 @@ class NetworkResourceIT : ResourceIT() { @Test fun `remove network`() = runTest { - val network = testClient.networks.create { name = NETWORK_NAME } + val network = testClient.networks.create { name = "dockerkt" } assertTrue(testClient.networks.list().any { it.id == network.id }) testClient.networks.remove(network.id) @@ -50,7 +46,7 @@ class NetworkResourceIT : ResourceIT() { val oldCount = testClient.networks.list().size val newCount = 5 repeat(newCount) { - testClient.networks.create { name = "$NETWORK_NAME-$it" } + testClient.networks.create { name = "dockerkt-$it" } } // check for >= because docker can have default networks defined diff --git a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/volume/VolumeResourceIT.kt b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/volume/VolumeResourceIT.kt index b24ad03f..e46aa27b 100644 --- a/src/commonTest/kotlin/me/devnatan/dockerkt/resource/volume/VolumeResourceIT.kt +++ b/src/commonTest/kotlin/me/devnatan/dockerkt/resource/volume/VolumeResourceIT.kt @@ -11,10 +11,6 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue class VolumeResourceIT : ResourceIT() { - companion object { - const val VOLUME_NAME = "docker-kotlin" - } - @Test fun `create volume`() = runTest { @@ -27,7 +23,7 @@ class VolumeResourceIT : ResourceIT() { @Test fun `remove volume`() = runTest { - val volumes = testClient.volumes.create { name = VOLUME_NAME } + val volumes = testClient.volumes.create { name = "dockerkt" } assertTrue( testClient.volumes .list() diff --git a/src/jvmMain/kotlin/me/devnatan/dockerkt/io/Http.jvm.kt b/src/jvmMain/kotlin/me/devnatan/dockerkt/io/Http.jvm.kt index 602a7ad1..25933535 100644 --- a/src/jvmMain/kotlin/me/devnatan/dockerkt/io/Http.jvm.kt +++ b/src/jvmMain/kotlin/me/devnatan/dockerkt/io/Http.jvm.kt @@ -4,8 +4,36 @@ import io.ktor.client.HttpClientConfig import io.ktor.client.engine.HttpClientEngineConfig import io.ktor.client.engine.okhttp.OkHttpConfig import me.devnatan.dockerkt.DockerClient +import okhttp3.Interceptor +import okhttp3.Response import java.util.concurrent.TimeUnit +// Ktor doesn't allow us to change "Upgrade" header so we set it directly in the engine +private class UpgradeHeaderInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + + if (request.url.encodedPath.matches(Regex(".*/exec/.*/start$"))) { + try { + val newRequest = + request + .newBuilder() + .header("Connection", "Upgrade") + .header("Upgrade", "tcp") + .build() + + return chain.proceed(newRequest) + } catch (e: IllegalArgumentException) { + if (e.message.equals("expected a null or empty request body with 'Connection: upgrade'")) { + return chain.proceed(request) + } + } + } + + return chain.proceed(request) + } +} + internal actual fun HttpClientConfig.configureHttpClient(client: DockerClient) { engine { // ensure that current engine is OkHttp, cannot use CIO due to a Ktor Client bug related to data streaming @@ -22,6 +50,7 @@ internal actual fun HttpClientConfig.configu connectTimeout(0, TimeUnit.MILLISECONDS) callTimeout(0, TimeUnit.MILLISECONDS) retryOnConnectionFailure(true) + addInterceptor(UpgradeHeaderInterceptor()) } } } diff --git a/src/jvmMain/kotlin/me/devnatan/dockerkt/io/Sockets.jvm.kt b/src/jvmMain/kotlin/me/devnatan/dockerkt/io/Sockets.jvm.kt index d3795efa..77658fa6 100644 --- a/src/jvmMain/kotlin/me/devnatan/dockerkt/io/Sockets.jvm.kt +++ b/src/jvmMain/kotlin/me/devnatan/dockerkt/io/Sockets.jvm.kt @@ -27,7 +27,7 @@ internal class UnixSocketFactory : AFUNIXSocketFactory() { @OptIn(ExperimentalStdlibApi::class) private fun decodeHostname(hostname: String): String = hostname - .substring(0, hostname.indexOf(ENCODED_HOSTNAME_SUFFIX)) + .substring(0, hostname.indexOf(EncodedHostnameSuffix)) .hexToByteArray() .decodeToString() 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 77f3216b..98eb6027 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 @@ -594,7 +594,7 @@ public actual class ContainerResource( val archive = writeTarFile(inputPath) httpClient.put("$CONTAINERS/$container/archive") { - parameter("path", remotePath.ifEmpty { FS_ROOT }) + parameter("path", remotePath.ifEmpty { FileSystemRoot }) parameter("noOverwriteDirNonDir", false) setBody(archive.buffered().asInputStream().toByteReadChannel()) }