From 68804bcb7336afb7a121ddfcf6b893b11eedcda0 Mon Sep 17 00:00:00 2001 From: Didier Villevalois Date: Tue, 16 Apr 2024 16:50:21 +0200 Subject: [PATCH] Fix exposed ports and port bindings - Stick to 1.41 API version - Fix exposed ports serialization - Fix port bindings serialization - Enhance exposed ports and port bindings DSL - Fix various missing nullables in models --- README.md | 36 ++++++++ .../me/devnatan/yoki/models/ExposedPort.kt | 84 +++++++++++++++++-- .../me/devnatan/yoki/models/HostConfig.kt | 21 ++++- .../me/devnatan/yoki/models/PortBinding.kt | 43 +++++++++- .../yoki/models/container/Container.kt | 7 +- .../yoki/models/container/ContainerConfig.kt | 12 +-- .../container/ContainerCreateOptions.kt | 12 +++ .../kotlin/me/devnatan/yoki/TestUtils.kt | 2 +- .../resource/container/StartContainerIT.kt | 45 ++++++++++ 9 files changed, 245 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 8eb75286..f5f3fadc 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,42 @@ but there are extensions for Kotlin that are `suspend` and for streaming returns val version: SystemVersion = client.system.version() ``` +##### Create and start a Container with explicit port bindings + +```kotlin +val containerId = client.containers.create("busybox:latest") { + // Only if your container doesn't already expose this port + exposedPort(80u) + + hostConfig { + portBindings(80u) { + add(PortBinding("0.0.0.0", 8080u)) + } + } +} + +client.containers.start(containerId) +``` + +##### Create and start a Container with auto-assigned port bindings + +```kotlin +val containerId = client.containers.create("busybox:latest") { + // Only if your container doesn't already expose this port + exposedPort(80u) + + hostConfig { + portBindings(80u) + } +} + +client.containers.start(containerId) + +// Inspect the container to retrieve the auto-assigned ports +val container = testClient.containers.inspect(id) +val ports = container.networkSettings.ports +``` + ##### List All Containers ```kotlin diff --git a/src/commonMain/kotlin/me/devnatan/yoki/models/ExposedPort.kt b/src/commonMain/kotlin/me/devnatan/yoki/models/ExposedPort.kt index e226be67..660a8f21 100644 --- a/src/commonMain/kotlin/me/devnatan/yoki/models/ExposedPort.kt +++ b/src/commonMain/kotlin/me/devnatan/yoki/models/ExposedPort.kt @@ -1,20 +1,90 @@ package me.devnatan.yoki.models +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive @Serializable public data class ExposedPort internal constructor( - public val protocol: String, - public val port: Short, + public val port: UShort, + public val protocol: ExposedPortProtocol = ExposedPortProtocol.TCP, ) { + override fun toString(): String { + return "$port/${protocol.toString().lowercase()}" + } public companion object { + public fun fromString(exposedPort: String): ExposedPort { + val portAndProtocol = exposedPort.split('/') + if (portAndProtocol.size != 2) error("Invalid exposed port") + + val port = portAndProtocol.getOrNull(0)?.toUShortOrNull() ?: error("Invalid exposed port") + + val protocolString = portAndProtocol.getOrNull(1) ?: error("Invalid exposed port") + val protocol = runCatching { ExposedPortProtocol.valueOf(protocolString.uppercase()) } + .getOrElse { error("Invalid exposed port") } + + return ExposedPort(port, protocol) + } + } +} + +@Serializable +public enum class ExposedPortProtocol { + @SerialName("tcp") + TCP, + + @SerialName("udp") + UDP, + + @SerialName("sctp") + SCTP, +} + +internal object ExposedPortSerializer : KSerializer { + + override val descriptor: SerialDescriptor + get() = buildClassSerialDescriptor("ExposedPort") { + element("port", UShort.serializer().descriptor) + element("protocol", ExposedPortProtocol.serializer().descriptor) + } + + override fun deserialize(decoder: Decoder): ExposedPort { + try { + return ExposedPort.fromString(decoder.decodeString()) + } catch (e: Throwable) { + throw SerializationException("Cannot parse exposed port", e) + } + } - public const val TCP: String = "tcp" - public const val UDP: String = "udp" - public const val SCTP: String = "stcp" + override fun serialize(encoder: Encoder, value: ExposedPort) { + encoder.encodeString(value.toString()) } } -public fun exposedPort(port: Short): ExposedPort = ExposedPort(ExposedPort.TCP, port) -public fun exposedPort(port: Short, protocol: String): ExposedPort = ExposedPort(protocol, port) +internal object ExposedPortsSerializer : + JsonTransformingSerializer>(ListSerializer(ExposedPortSerializer)) { + + override fun transformDeserialize(element: JsonElement): JsonElement { + return JsonArray(element.jsonObject.entries.map { JsonPrimitive(it.key) }) + } + + override fun transformSerialize(element: JsonElement): JsonElement { + return JsonObject(element.jsonArray.associate { it.jsonPrimitive.content to JsonObject(mapOf()) }) + } +} diff --git a/src/commonMain/kotlin/me/devnatan/yoki/models/HostConfig.kt b/src/commonMain/kotlin/me/devnatan/yoki/models/HostConfig.kt index d4c96a84..7422743a 100644 --- a/src/commonMain/kotlin/me/devnatan/yoki/models/HostConfig.kt +++ b/src/commonMain/kotlin/me/devnatan/yoki/models/HostConfig.kt @@ -43,7 +43,7 @@ public data class HostConfig @JvmOverloads public constructor( @SerialName("ContainerIDFile") public var containerIDFile: String? = null, @SerialName("LogConfig") public var logConfig: LogConfig? = null, @SerialName("NetworkMode") public var networkMode: String? = null, - @SerialName("PortBindings") public var portBindings: Map?>? = null, + @SerialName("PortBindings") public var portBindings: @Serializable(with = PortBindingsSerializer::class) Map?>? = null, @SerialName("RestartPolicy") public var restartPolicy: RestartPolicy? = null, @SerialName("AutoRemove") public var autoRemove: Boolean? = null, @SerialName("VolumeDriver") public var volumeDriver: String? = null, @@ -84,3 +84,22 @@ public data class HostConfig @JvmOverloads public constructor( @SerialName("MaskedPaths") public var maskedPaths: List? = null, @SerialName("ReadonlyPaths") public var readonlyPaths: List? = null, ) + +public fun HostConfig.portBindings(exposedPort: ExposedPort, portBindings: List) { + this.portBindings = this.portBindings.orEmpty() + mapOf(exposedPort to portBindings) +} + +public fun HostConfig.portBindings(exposedPort: UShort, portBindings: List) { + this.portBindings(ExposedPort(exposedPort), portBindings) +} + +public fun HostConfig.portBindings( + exposedPort: ExposedPort, + portBindingBuilder: MutableList.() -> Unit = {}, +) { + this.portBindings(exposedPort, buildList(portBindingBuilder)) +} + +public fun HostConfig.portBindings(exposedPort: UShort, portBindingBuilder: MutableList.() -> Unit = {}) { + this.portBindings(exposedPort, buildList(portBindingBuilder)) +} diff --git a/src/commonMain/kotlin/me/devnatan/yoki/models/PortBinding.kt b/src/commonMain/kotlin/me/devnatan/yoki/models/PortBinding.kt index e6a66e8b..4a4cd8ec 100644 --- a/src/commonMain/kotlin/me/devnatan/yoki/models/PortBinding.kt +++ b/src/commonMain/kotlin/me/devnatan/yoki/models/PortBinding.kt @@ -1,10 +1,51 @@ package me.devnatan.yoki.models +import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.nullable +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder @Serializable public data class PortBinding( @SerialName("HostIp") public var ip: String? = null, - @SerialName("HostPort") public var port: String? = null, + @SerialName("HostPort") public var port: @Serializable(with = UShortAsStringSerializer::class) UShort? = null, ) + +internal object UShortAsStringSerializer : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("UShortAsString", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: UShort) { + encoder.encodeString(value.toString()) + } + + override fun deserialize(decoder: Decoder): UShort { + val stringValue = decoder.decodeString() + return stringValue.toUShortOrNull() ?: throw SerializationException("Cannot parse ushort from string") + } +} + +internal object PortBindingsSerializer : KSerializer?>> { + private val mapSerializer = MapSerializer(String.serializer(), ListSerializer(PortBinding.serializer()).nullable) + + override val descriptor: SerialDescriptor + get() = mapSerializer.descriptor + + override fun deserialize(decoder: Decoder): Map?> { + val map = mapSerializer.deserialize(decoder) + return map.mapKeys { ExposedPort.fromString(it.key) } + } + + override fun serialize(encoder: Encoder, value: Map?>) { + mapSerializer.serialize(encoder, value.mapKeys { it.key.toString() }) + } +} diff --git a/src/commonMain/kotlin/me/devnatan/yoki/models/container/Container.kt b/src/commonMain/kotlin/me/devnatan/yoki/models/container/Container.kt index 96d2d1f2..7c901226 100644 --- a/src/commonMain/kotlin/me/devnatan/yoki/models/container/Container.kt +++ b/src/commonMain/kotlin/me/devnatan/yoki/models/container/Container.kt @@ -2,8 +2,11 @@ package me.devnatan.yoki.models.container import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import me.devnatan.yoki.models.ExposedPort import me.devnatan.yoki.models.Mount import me.devnatan.yoki.models.MountBindOptions +import me.devnatan.yoki.models.PortBinding +import me.devnatan.yoki.models.PortBindingsSerializer import me.devnatan.yoki.models.network.EndpointSettings @Serializable @@ -46,11 +49,11 @@ public data class NetworkSettings internal constructor( @SerialName("IPPrefixLen") public val ipAddressPrefixLength: Int? = null, @SerialName("IPv6Gateway") public val ipv6Gateway: String? = null, @SerialName("MacAddress") public val macAddress: String? = null, - @SerialName("Ports") public val ports: Map> = emptyMap(), + @SerialName("Ports") public val ports: @Serializable(with = PortBindingsSerializer::class) Map?> = emptyMap(), @SerialName("SandboxKey") public val sandboxKey: String, @SerialName("EndpointID") public val endpointId: String, @SerialName("Gateway") public val gateway: String, - @SerialName("Networks") public val networks: List = emptyList(), + @SerialName("Networks") public val networks: Map = emptyMap(), ) @Serializable diff --git a/src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerConfig.kt b/src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerConfig.kt index 27f27596..6be68a1f 100644 --- a/src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerConfig.kt +++ b/src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerConfig.kt @@ -2,6 +2,8 @@ package me.devnatan.yoki.models.container import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import me.devnatan.yoki.models.ExposedPort +import me.devnatan.yoki.models.ExposedPortsSerializer import me.devnatan.yoki.models.HealthConfig @Serializable @@ -12,21 +14,21 @@ public data class ContainerConfig( @SerialName("AttachStdin") public val attachStdin: Boolean? = null, @SerialName("AttachStdout") public val attachStdout: Boolean? = null, @SerialName("AttachStderr") public val attachStderr: Boolean? = null, - @SerialName("ExposedPorts") public val exposedPorts: Map = emptyMap(), + @SerialName("ExposedPorts") public val exposedPorts: @Serializable(with = ExposedPortsSerializer::class) List? = emptyList(), @SerialName("Tty") public val tty: Boolean? = null, @SerialName("OpenStdin") public val openStdin: Boolean? = null, @SerialName("StdinOnce") public val stdinOnce: Boolean? = null, - @SerialName("Env") public val env: List = emptyList(), + @SerialName("Env") public val env: List? = emptyList(), @SerialName("Cmd") public val command: List = emptyList(), @SerialName("Healthcheck") public val healthcheck: HealthConfig? = null, @SerialName("ArgsEscaped") public val argsEscaped: Boolean? = null, @SerialName("Image") public val image: String? = null, - @SerialName("Volumes") public val volumes: Map = emptyMap(), + @SerialName("Volumes") public val volumes: Map? = emptyMap(), @SerialName("WorkingDir") public val workingDir: String? = null, - @SerialName("Entrypoint") public val entrypoint: List = emptyList(), + @SerialName("Entrypoint") public val entrypoint: List? = emptyList(), @SerialName("NetworkDisabled") public val networkDisabled: Boolean? = null, @SerialName("MacAddress") public val macAddress: String? = null, - @SerialName("OnBuild") public val onBuild: List = emptyList(), + @SerialName("OnBuild") public val onBuild: List? = emptyList(), @SerialName("Labels") public val labels: Map = emptyMap(), @SerialName("StopSignal") public val stopSignal: String? = null, @SerialName("StopTimeout") public val stopTimeout: Int? = null, diff --git a/src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerCreateOptions.kt b/src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerCreateOptions.kt index b5a19e3b..2a69d4c6 100644 --- a/src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerCreateOptions.kt +++ b/src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerCreateOptions.kt @@ -3,6 +3,9 @@ package me.devnatan.yoki.models.container import kotlinx.serialization.Contextual import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import me.devnatan.yoki.models.ExposedPort +import me.devnatan.yoki.models.ExposedPortProtocol +import me.devnatan.yoki.models.ExposedPortsSerializer import me.devnatan.yoki.models.HealthConfig import me.devnatan.yoki.models.HostConfig import me.devnatan.yoki.models.network.NetworkingConfig @@ -17,6 +20,7 @@ public data class ContainerCreateOptions( @SerialName("Domainname") public var domainName: String? = null, @SerialName("User") public var user: String? = null, @SerialName("AttachStdin") public var attachStdin: Boolean? = null, + @SerialName("ExposedPorts") public var exposedPorts: @Serializable(with = ExposedPortsSerializer::class) List? = null, @SerialName("Cmd") public var command: List? = null, @SerialName("Healthcheck") public var healthcheck: HealthConfig? = null, @SerialName("ArgsEscaped") public var escapedArgs: Boolean? = null, @@ -36,6 +40,14 @@ public data class ContainerCreateOptions( @SerialName("Tty") public var tty: Boolean? = null, ) +public fun ContainerCreateOptions.exposedPort(port: UShort) { + this.exposedPort(port, ExposedPortProtocol.TCP) +} + +public fun ContainerCreateOptions.exposedPort(port: UShort, protocol: ExposedPortProtocol) { + this.exposedPorts = exposedPorts.orEmpty() + listOf(ExposedPort(port, protocol)) +} + public fun ContainerCreateOptions.healthcheck(block: HealthConfig.() -> Unit) { this.healthcheck = HealthConfig().apply(block) } diff --git a/src/commonTest/kotlin/me/devnatan/yoki/TestUtils.kt b/src/commonTest/kotlin/me/devnatan/yoki/TestUtils.kt index 227b4002..0c3e0d0f 100644 --- a/src/commonTest/kotlin/me/devnatan/yoki/TestUtils.kt +++ b/src/commonTest/kotlin/me/devnatan/yoki/TestUtils.kt @@ -63,6 +63,6 @@ suspend fun Yoki.withVolume( * Make a container started forever. */ fun ContainerCreateOptions.keepStartedForever() { - openStdin = true + attachStdin = true tty = true } diff --git a/src/commonTest/kotlin/me/devnatan/yoki/resource/container/StartContainerIT.kt b/src/commonTest/kotlin/me/devnatan/yoki/resource/container/StartContainerIT.kt index ac608bee..8a25c755 100644 --- a/src/commonTest/kotlin/me/devnatan/yoki/resource/container/StartContainerIT.kt +++ b/src/commonTest/kotlin/me/devnatan/yoki/resource/container/StartContainerIT.kt @@ -5,10 +5,19 @@ package me.devnatan.yoki.resource.container import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import me.devnatan.yoki.keepStartedForever +import me.devnatan.yoki.models.ExposedPort +import me.devnatan.yoki.models.ExposedPortProtocol +import me.devnatan.yoki.models.container.exposedPort +import me.devnatan.yoki.models.container.hostConfig +import me.devnatan.yoki.models.portBindings import me.devnatan.yoki.resource.ResourceIT import me.devnatan.yoki.withContainer import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue class StartContainerIT : ResourceIT() { @@ -19,6 +28,42 @@ class StartContainerIT : ResourceIT() { } } + @Test + fun `start a container with auto-assigned port bindings`() = runTest { + testClient.withContainer( + "busybox:latest", + { + exposedPort(80u) + hostConfig { + portBindings(80u) + } + }, + ) { id -> + testClient.containers.start(id) + val container = testClient.containers.inspect(id) + + val ports = container.networkSettings.ports + + assertTrue { ports.isNotEmpty() } + val exposedPort = ExposedPort(80u, ExposedPortProtocol.TCP) + assertContains(ports, exposedPort) + + val port80Bindings = container.networkSettings.ports[exposedPort] + assertNotNull(port80Bindings) + assertTrue { port80Bindings.size == 2 } + + val ipv4Binding = port80Bindings[0] + assertEquals(ipv4Binding.ip, "0.0.0.0") + assertNotNull(ipv4Binding.port) + assertTrue { ipv4Binding.port!!.toInt() > 0 } + + val ipv6Binding = port80Bindings[1] + assertEquals(ipv6Binding.ip, "::") + assertNotNull(ipv6Binding.port) + assertTrue { ipv6Binding.port!!.toInt() > 0 } + } + } + @Test fun `throws ContainerNotFoundException on start unknown container`() = runTest { assertFailsWith {