Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix exposed ports and port bindings #151

Merged
merged 1 commit into from
Apr 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 77 additions & 7 deletions src/commonMain/kotlin/me/devnatan/yoki/models/ExposedPort.kt
Original file line number Diff line number Diff line change
@@ -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<ExposedPort> {

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<List<ExposedPort>>(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()) })
}
}
21 changes: 20 additions & 1 deletion src/commonMain/kotlin/me/devnatan/yoki/models/HostConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, List<PortBinding>?>? = null,
@SerialName("PortBindings") public var portBindings: @Serializable(with = PortBindingsSerializer::class) Map<ExposedPort, List<PortBinding>?>? = null,
@SerialName("RestartPolicy") public var restartPolicy: RestartPolicy? = null,
@SerialName("AutoRemove") public var autoRemove: Boolean? = null,
@SerialName("VolumeDriver") public var volumeDriver: String? = null,
Expand Down Expand Up @@ -84,3 +84,22 @@ public data class HostConfig @JvmOverloads public constructor(
@SerialName("MaskedPaths") public var maskedPaths: List<String>? = null,
@SerialName("ReadonlyPaths") public var readonlyPaths: List<String>? = null,
)

public fun HostConfig.portBindings(exposedPort: ExposedPort, portBindings: List<PortBinding>) {
this.portBindings = this.portBindings.orEmpty() + mapOf(exposedPort to portBindings)
}

public fun HostConfig.portBindings(exposedPort: UShort, portBindings: List<PortBinding>) {
this.portBindings(ExposedPort(exposedPort), portBindings)
}

public fun HostConfig.portBindings(
exposedPort: ExposedPort,
portBindingBuilder: MutableList<PortBinding>.() -> Unit = {},
) {
this.portBindings(exposedPort, buildList(portBindingBuilder))
}

public fun HostConfig.portBindings(exposedPort: UShort, portBindingBuilder: MutableList<PortBinding>.() -> Unit = {}) {
this.portBindings(exposedPort, buildList(portBindingBuilder))
}
43 changes: 42 additions & 1 deletion src/commonMain/kotlin/me/devnatan/yoki/models/PortBinding.kt
Original file line number Diff line number Diff line change
@@ -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<UShort> {
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<Map<ExposedPort, List<PortBinding>?>> {
private val mapSerializer = MapSerializer(String.serializer(), ListSerializer(PortBinding.serializer()).nullable)

override val descriptor: SerialDescriptor
get() = mapSerializer.descriptor

override fun deserialize(decoder: Decoder): Map<ExposedPort, List<PortBinding>?> {
val map = mapSerializer.deserialize(decoder)
return map.mapKeys { ExposedPort.fromString(it.key) }
}

override fun serialize(encoder: Encoder, value: Map<ExposedPort, List<PortBinding>?>) {
mapSerializer.serialize(encoder, value.mapKeys { it.key.toString() })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, Map<String, String>> = emptyMap(),
@SerialName("Ports") public val ports: @Serializable(with = PortBindingsSerializer::class) Map<ExposedPort, List<PortBinding>?> = 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<EndpointSettings> = emptyList(),
@SerialName("Networks") public val networks: Map<String, EndpointSettings> = emptyMap(),
)

@Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, String> = emptyMap(),
@SerialName("ExposedPorts") public val exposedPorts: @Serializable(with = ExposedPortsSerializer::class) List<ExposedPort>? = 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<String> = emptyList(),
@SerialName("Env") public val env: List<String>? = emptyList(),
@SerialName("Cmd") public val command: List<String> = 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<String, String> = emptyMap(),
@SerialName("Volumes") public val volumes: Map<String, String>? = emptyMap(),
@SerialName("WorkingDir") public val workingDir: String? = null,
@SerialName("Entrypoint") public val entrypoint: List<String> = emptyList(),
@SerialName("Entrypoint") public val entrypoint: List<String>? = emptyList(),
@SerialName("NetworkDisabled") public val networkDisabled: Boolean? = null,
@SerialName("MacAddress") public val macAddress: String? = null,
@SerialName("OnBuild") public val onBuild: List<String> = emptyList(),
@SerialName("OnBuild") public val onBuild: List<String>? = emptyList(),
@SerialName("Labels") public val labels: Map<String, String> = emptyMap(),
@SerialName("StopSignal") public val stopSignal: String? = null,
@SerialName("StopTimeout") public val stopTimeout: Int? = null,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ExposedPort>? = null,
@SerialName("Cmd") public var command: List<String>? = null,
@SerialName("Healthcheck") public var healthcheck: HealthConfig? = null,
@SerialName("ArgsEscaped") public var escapedArgs: Boolean? = null,
Expand All @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion src/commonTest/kotlin/me/devnatan/yoki/TestUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@ suspend fun <R> Yoki.withVolume(
* Make a container started forever.
*/
fun ContainerCreateOptions.keepStartedForever() {
openStdin = true
attachStdin = true
tty = true
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {

Expand All @@ -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<ContainerNotFoundException> {
Expand Down