Skip to content

Commit d142ab5

Browse files
authored
Fix exposed ports and port bindings (#151)
* 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
1 parent 78e5cc9 commit d142ab5

File tree

9 files changed

+245
-17
lines changed

9 files changed

+245
-17
lines changed

README.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,42 @@ but there are extensions for Kotlin that are `suspend` and for streaming returns
6565
val version: SystemVersion = client.system.version()
6666
```
6767

68+
##### Create and start a Container with explicit port bindings
69+
70+
```kotlin
71+
val containerId = client.containers.create("busybox:latest") {
72+
// Only if your container doesn't already expose this port
73+
exposedPort(80u)
74+
75+
hostConfig {
76+
portBindings(80u) {
77+
add(PortBinding("0.0.0.0", 8080u))
78+
}
79+
}
80+
}
81+
82+
client.containers.start(containerId)
83+
```
84+
85+
##### Create and start a Container with auto-assigned port bindings
86+
87+
```kotlin
88+
val containerId = client.containers.create("busybox:latest") {
89+
// Only if your container doesn't already expose this port
90+
exposedPort(80u)
91+
92+
hostConfig {
93+
portBindings(80u)
94+
}
95+
}
96+
97+
client.containers.start(containerId)
98+
99+
// Inspect the container to retrieve the auto-assigned ports
100+
val container = testClient.containers.inspect(id)
101+
val ports = container.networkSettings.ports
102+
```
103+
68104
##### List All Containers
69105

70106
```kotlin
Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,90 @@
11
package me.devnatan.yoki.models
22

3+
import kotlinx.serialization.KSerializer
4+
import kotlinx.serialization.SerialName
35
import kotlinx.serialization.Serializable
6+
import kotlinx.serialization.SerializationException
7+
import kotlinx.serialization.builtins.ListSerializer
8+
import kotlinx.serialization.builtins.serializer
9+
import kotlinx.serialization.descriptors.SerialDescriptor
10+
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
11+
import kotlinx.serialization.encoding.Decoder
12+
import kotlinx.serialization.encoding.Encoder
13+
import kotlinx.serialization.json.JsonArray
14+
import kotlinx.serialization.json.JsonElement
15+
import kotlinx.serialization.json.JsonObject
16+
import kotlinx.serialization.json.JsonPrimitive
17+
import kotlinx.serialization.json.JsonTransformingSerializer
18+
import kotlinx.serialization.json.jsonArray
19+
import kotlinx.serialization.json.jsonObject
20+
import kotlinx.serialization.json.jsonPrimitive
421

522
@Serializable
623
public data class ExposedPort internal constructor(
7-
public val protocol: String,
8-
public val port: Short,
24+
public val port: UShort,
25+
public val protocol: ExposedPortProtocol = ExposedPortProtocol.TCP,
926
) {
27+
override fun toString(): String {
28+
return "$port/${protocol.toString().lowercase()}"
29+
}
1030

1131
public companion object {
32+
public fun fromString(exposedPort: String): ExposedPort {
33+
val portAndProtocol = exposedPort.split('/')
34+
if (portAndProtocol.size != 2) error("Invalid exposed port")
35+
36+
val port = portAndProtocol.getOrNull(0)?.toUShortOrNull() ?: error("Invalid exposed port")
37+
38+
val protocolString = portAndProtocol.getOrNull(1) ?: error("Invalid exposed port")
39+
val protocol = runCatching { ExposedPortProtocol.valueOf(protocolString.uppercase()) }
40+
.getOrElse { error("Invalid exposed port") }
41+
42+
return ExposedPort(port, protocol)
43+
}
44+
}
45+
}
46+
47+
@Serializable
48+
public enum class ExposedPortProtocol {
49+
@SerialName("tcp")
50+
TCP,
51+
52+
@SerialName("udp")
53+
UDP,
54+
55+
@SerialName("sctp")
56+
SCTP,
57+
}
58+
59+
internal object ExposedPortSerializer : KSerializer<ExposedPort> {
60+
61+
override val descriptor: SerialDescriptor
62+
get() = buildClassSerialDescriptor("ExposedPort") {
63+
element("port", UShort.serializer().descriptor)
64+
element("protocol", ExposedPortProtocol.serializer().descriptor)
65+
}
66+
67+
override fun deserialize(decoder: Decoder): ExposedPort {
68+
try {
69+
return ExposedPort.fromString(decoder.decodeString())
70+
} catch (e: Throwable) {
71+
throw SerializationException("Cannot parse exposed port", e)
72+
}
73+
}
1274

13-
public const val TCP: String = "tcp"
14-
public const val UDP: String = "udp"
15-
public const val SCTP: String = "stcp"
75+
override fun serialize(encoder: Encoder, value: ExposedPort) {
76+
encoder.encodeString(value.toString())
1677
}
1778
}
1879

19-
public fun exposedPort(port: Short): ExposedPort = ExposedPort(ExposedPort.TCP, port)
20-
public fun exposedPort(port: Short, protocol: String): ExposedPort = ExposedPort(protocol, port)
80+
internal object ExposedPortsSerializer :
81+
JsonTransformingSerializer<List<ExposedPort>>(ListSerializer(ExposedPortSerializer)) {
82+
83+
override fun transformDeserialize(element: JsonElement): JsonElement {
84+
return JsonArray(element.jsonObject.entries.map { JsonPrimitive(it.key) })
85+
}
86+
87+
override fun transformSerialize(element: JsonElement): JsonElement {
88+
return JsonObject(element.jsonArray.associate { it.jsonPrimitive.content to JsonObject(mapOf()) })
89+
}
90+
}

src/commonMain/kotlin/me/devnatan/yoki/models/HostConfig.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public data class HostConfig @JvmOverloads public constructor(
4343
@SerialName("ContainerIDFile") public var containerIDFile: String? = null,
4444
@SerialName("LogConfig") public var logConfig: LogConfig? = null,
4545
@SerialName("NetworkMode") public var networkMode: String? = null,
46-
@SerialName("PortBindings") public var portBindings: Map<String, List<PortBinding>?>? = null,
46+
@SerialName("PortBindings") public var portBindings: @Serializable(with = PortBindingsSerializer::class) Map<ExposedPort, List<PortBinding>?>? = null,
4747
@SerialName("RestartPolicy") public var restartPolicy: RestartPolicy? = null,
4848
@SerialName("AutoRemove") public var autoRemove: Boolean? = null,
4949
@SerialName("VolumeDriver") public var volumeDriver: String? = null,
@@ -84,3 +84,22 @@ public data class HostConfig @JvmOverloads public constructor(
8484
@SerialName("MaskedPaths") public var maskedPaths: List<String>? = null,
8585
@SerialName("ReadonlyPaths") public var readonlyPaths: List<String>? = null,
8686
)
87+
88+
public fun HostConfig.portBindings(exposedPort: ExposedPort, portBindings: List<PortBinding>) {
89+
this.portBindings = this.portBindings.orEmpty() + mapOf(exposedPort to portBindings)
90+
}
91+
92+
public fun HostConfig.portBindings(exposedPort: UShort, portBindings: List<PortBinding>) {
93+
this.portBindings(ExposedPort(exposedPort), portBindings)
94+
}
95+
96+
public fun HostConfig.portBindings(
97+
exposedPort: ExposedPort,
98+
portBindingBuilder: MutableList<PortBinding>.() -> Unit = {},
99+
) {
100+
this.portBindings(exposedPort, buildList(portBindingBuilder))
101+
}
102+
103+
public fun HostConfig.portBindings(exposedPort: UShort, portBindingBuilder: MutableList<PortBinding>.() -> Unit = {}) {
104+
this.portBindings(exposedPort, buildList(portBindingBuilder))
105+
}
Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,51 @@
11
package me.devnatan.yoki.models
22

3+
import kotlinx.serialization.KSerializer
34
import kotlinx.serialization.SerialName
45
import kotlinx.serialization.Serializable
6+
import kotlinx.serialization.SerializationException
7+
import kotlinx.serialization.builtins.ListSerializer
8+
import kotlinx.serialization.builtins.MapSerializer
9+
import kotlinx.serialization.builtins.nullable
10+
import kotlinx.serialization.builtins.serializer
11+
import kotlinx.serialization.descriptors.PrimitiveKind
12+
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
13+
import kotlinx.serialization.descriptors.SerialDescriptor
14+
import kotlinx.serialization.encoding.Decoder
15+
import kotlinx.serialization.encoding.Encoder
516

617
@Serializable
718
public data class PortBinding(
819
@SerialName("HostIp") public var ip: String? = null,
9-
@SerialName("HostPort") public var port: String? = null,
20+
@SerialName("HostPort") public var port: @Serializable(with = UShortAsStringSerializer::class) UShort? = null,
1021
)
22+
23+
internal object UShortAsStringSerializer : KSerializer<UShort> {
24+
override val descriptor: SerialDescriptor =
25+
PrimitiveSerialDescriptor("UShortAsString", PrimitiveKind.STRING)
26+
27+
override fun serialize(encoder: Encoder, value: UShort) {
28+
encoder.encodeString(value.toString())
29+
}
30+
31+
override fun deserialize(decoder: Decoder): UShort {
32+
val stringValue = decoder.decodeString()
33+
return stringValue.toUShortOrNull() ?: throw SerializationException("Cannot parse ushort from string")
34+
}
35+
}
36+
37+
internal object PortBindingsSerializer : KSerializer<Map<ExposedPort, List<PortBinding>?>> {
38+
private val mapSerializer = MapSerializer(String.serializer(), ListSerializer(PortBinding.serializer()).nullable)
39+
40+
override val descriptor: SerialDescriptor
41+
get() = mapSerializer.descriptor
42+
43+
override fun deserialize(decoder: Decoder): Map<ExposedPort, List<PortBinding>?> {
44+
val map = mapSerializer.deserialize(decoder)
45+
return map.mapKeys { ExposedPort.fromString(it.key) }
46+
}
47+
48+
override fun serialize(encoder: Encoder, value: Map<ExposedPort, List<PortBinding>?>) {
49+
mapSerializer.serialize(encoder, value.mapKeys { it.key.toString() })
50+
}
51+
}

src/commonMain/kotlin/me/devnatan/yoki/models/container/Container.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@ package me.devnatan.yoki.models.container
22

33
import kotlinx.serialization.SerialName
44
import kotlinx.serialization.Serializable
5+
import me.devnatan.yoki.models.ExposedPort
56
import me.devnatan.yoki.models.Mount
67
import me.devnatan.yoki.models.MountBindOptions
8+
import me.devnatan.yoki.models.PortBinding
9+
import me.devnatan.yoki.models.PortBindingsSerializer
710
import me.devnatan.yoki.models.network.EndpointSettings
811

912
@Serializable
@@ -46,11 +49,11 @@ public data class NetworkSettings internal constructor(
4649
@SerialName("IPPrefixLen") public val ipAddressPrefixLength: Int? = null,
4750
@SerialName("IPv6Gateway") public val ipv6Gateway: String? = null,
4851
@SerialName("MacAddress") public val macAddress: String? = null,
49-
@SerialName("Ports") public val ports: Map<String, Map<String, String>> = emptyMap(),
52+
@SerialName("Ports") public val ports: @Serializable(with = PortBindingsSerializer::class) Map<ExposedPort, List<PortBinding>?> = emptyMap(),
5053
@SerialName("SandboxKey") public val sandboxKey: String,
5154
@SerialName("EndpointID") public val endpointId: String,
5255
@SerialName("Gateway") public val gateway: String,
53-
@SerialName("Networks") public val networks: List<EndpointSettings> = emptyList(),
56+
@SerialName("Networks") public val networks: Map<String, EndpointSettings> = emptyMap(),
5457
)
5558

5659
@Serializable

src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerConfig.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package me.devnatan.yoki.models.container
22

33
import kotlinx.serialization.SerialName
44
import kotlinx.serialization.Serializable
5+
import me.devnatan.yoki.models.ExposedPort
6+
import me.devnatan.yoki.models.ExposedPortsSerializer
57
import me.devnatan.yoki.models.HealthConfig
68

79
@Serializable
@@ -12,21 +14,21 @@ public data class ContainerConfig(
1214
@SerialName("AttachStdin") public val attachStdin: Boolean? = null,
1315
@SerialName("AttachStdout") public val attachStdout: Boolean? = null,
1416
@SerialName("AttachStderr") public val attachStderr: Boolean? = null,
15-
@SerialName("ExposedPorts") public val exposedPorts: Map<String, String> = emptyMap(),
17+
@SerialName("ExposedPorts") public val exposedPorts: @Serializable(with = ExposedPortsSerializer::class) List<ExposedPort>? = emptyList(),
1618
@SerialName("Tty") public val tty: Boolean? = null,
1719
@SerialName("OpenStdin") public val openStdin: Boolean? = null,
1820
@SerialName("StdinOnce") public val stdinOnce: Boolean? = null,
19-
@SerialName("Env") public val env: List<String> = emptyList(),
21+
@SerialName("Env") public val env: List<String>? = emptyList(),
2022
@SerialName("Cmd") public val command: List<String> = emptyList(),
2123
@SerialName("Healthcheck") public val healthcheck: HealthConfig? = null,
2224
@SerialName("ArgsEscaped") public val argsEscaped: Boolean? = null,
2325
@SerialName("Image") public val image: String? = null,
24-
@SerialName("Volumes") public val volumes: Map<String, String> = emptyMap(),
26+
@SerialName("Volumes") public val volumes: Map<String, String>? = emptyMap(),
2527
@SerialName("WorkingDir") public val workingDir: String? = null,
26-
@SerialName("Entrypoint") public val entrypoint: List<String> = emptyList(),
28+
@SerialName("Entrypoint") public val entrypoint: List<String>? = emptyList(),
2729
@SerialName("NetworkDisabled") public val networkDisabled: Boolean? = null,
2830
@SerialName("MacAddress") public val macAddress: String? = null,
29-
@SerialName("OnBuild") public val onBuild: List<String> = emptyList(),
31+
@SerialName("OnBuild") public val onBuild: List<String>? = emptyList(),
3032
@SerialName("Labels") public val labels: Map<String, String> = emptyMap(),
3133
@SerialName("StopSignal") public val stopSignal: String? = null,
3234
@SerialName("StopTimeout") public val stopTimeout: Int? = null,

src/commonMain/kotlin/me/devnatan/yoki/models/container/ContainerCreateOptions.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ package me.devnatan.yoki.models.container
33
import kotlinx.serialization.Contextual
44
import kotlinx.serialization.SerialName
55
import kotlinx.serialization.Serializable
6+
import me.devnatan.yoki.models.ExposedPort
7+
import me.devnatan.yoki.models.ExposedPortProtocol
8+
import me.devnatan.yoki.models.ExposedPortsSerializer
69
import me.devnatan.yoki.models.HealthConfig
710
import me.devnatan.yoki.models.HostConfig
811
import me.devnatan.yoki.models.network.NetworkingConfig
@@ -17,6 +20,7 @@ public data class ContainerCreateOptions(
1720
@SerialName("Domainname") public var domainName: String? = null,
1821
@SerialName("User") public var user: String? = null,
1922
@SerialName("AttachStdin") public var attachStdin: Boolean? = null,
23+
@SerialName("ExposedPorts") public var exposedPorts: @Serializable(with = ExposedPortsSerializer::class) List<ExposedPort>? = null,
2024
@SerialName("Cmd") public var command: List<String>? = null,
2125
@SerialName("Healthcheck") public var healthcheck: HealthConfig? = null,
2226
@SerialName("ArgsEscaped") public var escapedArgs: Boolean? = null,
@@ -36,6 +40,14 @@ public data class ContainerCreateOptions(
3640
@SerialName("Tty") public var tty: Boolean? = null,
3741
)
3842

43+
public fun ContainerCreateOptions.exposedPort(port: UShort) {
44+
this.exposedPort(port, ExposedPortProtocol.TCP)
45+
}
46+
47+
public fun ContainerCreateOptions.exposedPort(port: UShort, protocol: ExposedPortProtocol) {
48+
this.exposedPorts = exposedPorts.orEmpty() + listOf(ExposedPort(port, protocol))
49+
}
50+
3951
public fun ContainerCreateOptions.healthcheck(block: HealthConfig.() -> Unit) {
4052
this.healthcheck = HealthConfig().apply(block)
4153
}

src/commonTest/kotlin/me/devnatan/yoki/TestUtils.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,6 @@ suspend fun <R> Yoki.withVolume(
6363
* Make a container started forever.
6464
*/
6565
fun ContainerCreateOptions.keepStartedForever() {
66-
openStdin = true
66+
attachStdin = true
6767
tty = true
6868
}

src/commonTest/kotlin/me/devnatan/yoki/resource/container/StartContainerIT.kt

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,19 @@ package me.devnatan.yoki.resource.container
55
import kotlinx.coroutines.ExperimentalCoroutinesApi
66
import kotlinx.coroutines.test.runTest
77
import me.devnatan.yoki.keepStartedForever
8+
import me.devnatan.yoki.models.ExposedPort
9+
import me.devnatan.yoki.models.ExposedPortProtocol
10+
import me.devnatan.yoki.models.container.exposedPort
11+
import me.devnatan.yoki.models.container.hostConfig
12+
import me.devnatan.yoki.models.portBindings
813
import me.devnatan.yoki.resource.ResourceIT
914
import me.devnatan.yoki.withContainer
1015
import kotlin.test.Test
16+
import kotlin.test.assertContains
17+
import kotlin.test.assertEquals
1118
import kotlin.test.assertFailsWith
19+
import kotlin.test.assertNotNull
20+
import kotlin.test.assertTrue
1221

1322
class StartContainerIT : ResourceIT() {
1423

@@ -19,6 +28,42 @@ class StartContainerIT : ResourceIT() {
1928
}
2029
}
2130

31+
@Test
32+
fun `start a container with auto-assigned port bindings`() = runTest {
33+
testClient.withContainer(
34+
"busybox:latest",
35+
{
36+
exposedPort(80u)
37+
hostConfig {
38+
portBindings(80u)
39+
}
40+
},
41+
) { id ->
42+
testClient.containers.start(id)
43+
val container = testClient.containers.inspect(id)
44+
45+
val ports = container.networkSettings.ports
46+
47+
assertTrue { ports.isNotEmpty() }
48+
val exposedPort = ExposedPort(80u, ExposedPortProtocol.TCP)
49+
assertContains(ports, exposedPort)
50+
51+
val port80Bindings = container.networkSettings.ports[exposedPort]
52+
assertNotNull(port80Bindings)
53+
assertTrue { port80Bindings.size == 2 }
54+
55+
val ipv4Binding = port80Bindings[0]
56+
assertEquals(ipv4Binding.ip, "0.0.0.0")
57+
assertNotNull(ipv4Binding.port)
58+
assertTrue { ipv4Binding.port!!.toInt() > 0 }
59+
60+
val ipv6Binding = port80Bindings[1]
61+
assertEquals(ipv6Binding.ip, "::")
62+
assertNotNull(ipv6Binding.port)
63+
assertTrue { ipv6Binding.port!!.toInt() > 0 }
64+
}
65+
}
66+
2267
@Test
2368
fun `throws ContainerNotFoundException on start unknown container`() = runTest {
2469
assertFailsWith<ContainerNotFoundException> {

0 commit comments

Comments
 (0)