Skip to content
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
22 changes: 2 additions & 20 deletions src/commonMain/kotlin/me/devnatan/yoki/models/ExposedPort.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,12 @@ 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
import me.devnatan.yoki.util.ListAsMapToEmptyObjectsSerializer

@Serializable
public data class ExposedPort internal constructor(
Expand Down Expand Up @@ -77,14 +69,4 @@ internal object ExposedPortSerializer : KSerializer<ExposedPort> {
}
}

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()) })
}
}
internal object ExposedPortsSerializer : ListAsMapToEmptyObjectsSerializer<ExposedPort>(ExposedPortSerializer)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import kotlinx.serialization.Serializable
@Serializable
public data class ProcessConfig internal constructor(
val privileged: Boolean,
val user: String,
val user: String? = null,
val tty: Boolean,
val entrypoint: String,
val arguments: List<String>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ package me.devnatan.yoki.models.container

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.serializer
import me.devnatan.yoki.models.ExposedPort
import me.devnatan.yoki.models.ExposedPortsSerializer
import me.devnatan.yoki.models.HealthConfig
import me.devnatan.yoki.util.ListAsMapToEmptyObjectsSerializer

@Serializable
public data class ContainerConfig(
Expand All @@ -23,7 +25,7 @@ public data class ContainerConfig(
@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: @Serializable(with = VolumesSerializer::class) List<String>? = emptyList(),
@SerialName("WorkingDir") public val workingDir: String? = null,
@SerialName("Entrypoint") public val entrypoint: List<String>? = emptyList(),
@SerialName("NetworkDisabled") public val networkDisabled: Boolean? = null,
Expand All @@ -34,3 +36,5 @@ public data class ContainerConfig(
@SerialName("StopTimeout") public val stopTimeout: Int? = null,
@SerialName("Shell") public val shell: List<String> = emptyList(),
)

internal object VolumesSerializer : ListAsMapToEmptyObjectsSerializer<String>(String.serializer())
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package me.devnatan.yoki.models.container

import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import me.devnatan.yoki.models.ExposedPort
Expand All @@ -25,7 +24,7 @@ public data class ContainerCreateOptions(
@SerialName("Healthcheck") public var healthcheck: HealthConfig? = null,
@SerialName("ArgsEscaped") public var escapedArgs: Boolean? = null,
@SerialName("Image") public var image: String? = null,
@SerialName("Volumes") public var volumes: Map<String, @Contextual Any>? = null,
@SerialName("Volumes") public var volumes: @Serializable(with = VolumesSerializer::class) List<String>? = null,
@SerialName("WorkingDir") public var workingDirectory: String? = null,
@SerialName("Entrypoint") public var entrypoint: List<String>? = null,
@SerialName("NetworkDisabled") public var disabledNetwork: Boolean? = null,
Expand Down Expand Up @@ -57,7 +56,7 @@ public fun ContainerCreateOptions.hostConfig(block: HostConfig.() -> Unit) {
}

public fun ContainerCreateOptions.volume(string: String) {
this.volumes = mapOf(string to emptyMap<String, Any>()) + volumes.orEmpty()
this.volumes = this.volumes.orEmpty() + string
}

public fun ContainerCreateOptions.networkingConfig(block: NetworkingConfig.() -> Unit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import me.devnatan.yoki.models.container.ContainerPruneResult
import me.devnatan.yoki.models.container.ContainerRemoveOptions
import me.devnatan.yoki.models.container.ContainerSummary
import me.devnatan.yoki.models.container.ContainerWaitResult
import me.devnatan.yoki.models.exec.ExecCreateOptions
import me.devnatan.yoki.resource.image.ImageNotFoundException
import kotlin.jvm.JvmOverloads
import kotlin.time.Duration
Expand Down Expand Up @@ -125,14 +124,6 @@ public expect class ContainerResource {
*/
public suspend fun resizeTTY(container: String, options: ResizeTTYOptions = ResizeTTYOptions())

/**
* Runs a command inside a running container.
*
* @param container The container id to execute the command.
* @param options Exec instance command options.
*/
public suspend fun exec(container: String, options: ExecCreateOptions = ExecCreateOptions()): String

// TODO documentation
public fun attach(container: String): Flow<Frame>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,6 @@ public suspend inline fun ContainerResource.resizeTTY(container: String, options
resizeTTY(container, ResizeTTYOptions().apply(options))
}

/**
* Runs a command inside a running container.
*
* @param container The container id to execute the command.
* @param options Exec instance command options.
*/
public suspend inline fun ContainerResource.exec(container: String, options: ExecCreateOptions.() -> Unit) {
exec(container, ExecCreateOptions().apply(options))
}

// public inline fun ContainerResource.logs(
// id: String,
// block: ContainerLogsOptions.() -> Unit,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,15 @@ import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.HttpStatusCode
import kotlin.jvm.JvmSynthetic
import me.devnatan.yoki.io.requestCatching
import me.devnatan.yoki.models.IdOnlyResponse
import me.devnatan.yoki.models.ResizeTTYOptions
import me.devnatan.yoki.models.exec.ExecCreateOptions
import me.devnatan.yoki.models.exec.ExecInspectResponse
import me.devnatan.yoki.models.exec.ExecStartOptions
import me.devnatan.yoki.resource.ResourcePaths.CONTAINERS
import me.devnatan.yoki.resource.container.ContainerNotFoundException
import me.devnatan.yoki.resource.container.ContainerNotRunningException

/**
Expand All @@ -27,6 +32,35 @@ public class ExecResource internal constructor(
const val BASE_PATH = "/exec"
}

/**
* Runs a command inside a running container.
*
* @param id The container id to execute the command.
* @param options Exec instance command options.
* @throws ContainerNotFoundException If container instance is not found.
* @throws ContainerNotRunningException If the container is not running.
*/
@JvmSynthetic
public suspend fun create(id: String, options: ExecCreateOptions): String =
requestCatching(
HttpStatusCode.NotFound to { cause ->
ContainerNotFoundException(
cause,
id,
)
},
HttpStatusCode.Conflict to { cause ->
ContainerNotRunningException(
cause,
id,
)
},
) {
httpClient.post("$CONTAINERS/$id/exec") {
setBody(options)
}
}.body<IdOnlyResponse>().id

/**
* Inspects an exec instance and returns low-level information about it.
* `docker exec inspect`
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package me.devnatan.yoki.resource.exec

import me.devnatan.yoki.models.exec.ExecCreateOptions
import me.devnatan.yoki.models.exec.ExecStartOptions
import me.devnatan.yoki.resource.container.ContainerNotFoundException
import me.devnatan.yoki.resource.container.ContainerNotRunningException

/**
* Creates a new container.
*
* @param id The container id to execute the command.
* @param options Options to customize the container creation.
* @throws ContainerNotFoundException If container instance is not found.
* @throws ContainerNotRunningException If the container is not running.
*/
public suspend inline fun ExecResource.create(id: String, options: ExecCreateOptions.() -> Unit): String {
return create(id, ExecCreateOptions().apply(options))
}

/**
* Starts a previously set up exec instance.
*
* If detach is true, this endpoint returns immediately after starting the command.
* Otherwise, it sets up an interactive session with the command.
*
* @param id The exec instance id to be started.
* @param options Options to customize the exec start.
* @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 inline fun ExecResource.start(id: String, options: ExecStartOptions.() -> Unit = {}) {
start(id, ExecStartOptions().apply(options))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package me.devnatan.yoki.util

import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ListSerializer
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

internal open class ListAsMapToEmptyObjectsSerializer<T : Any>(
private val tSerializer: KSerializer<T>,
) : JsonTransformingSerializer<List<T>>(ListSerializer(tSerializer)) {

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()) })
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
@file:OptIn(ExperimentalCoroutinesApi::class)

package me.devnatan.yoki.resource.container

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import me.devnatan.yoki.models.container.volume
import me.devnatan.yoki.resource.ResourceIT
import me.devnatan.yoki.withContainer
import kotlin.test.Test
import kotlin.test.assertEquals

class InspectContainerIT : ResourceIT() {

@Test
fun `inspects container with volumes`() = runTest {
testClient.withContainer(
"busybox:latest",
{
volume("/opt")
command = listOf("sleep", "infinity")
},
) { id ->
testClient.containers.start(id)
try {
val container = testClient.containers.inspect(id)
val volumes = container.config.volumes
assertEquals(volumes, listOf("/opt"))
} finally {
testClient.containers.stop(id)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package me.devnatan.yoki.resource.exec

import kotlinx.coroutines.test.runTest
import me.devnatan.yoki.resource.ResourceIT
import me.devnatan.yoki.withContainer
import kotlin.test.Test
import kotlin.test.assertEquals

class ExecContainerIT : ResourceIT() {

@Test
fun `exec a command in a container`() = runTest {
testClient.withContainer(
"busybox:latest",
{
command = listOf("sleep", "infinity")
},
) { id ->
testClient.containers.start(id)

val execId = testClient.exec.create(id) {
command = listOf("true")
}

testClient.exec.start(execId)

val exec = testClient.exec.inspect(execId)
assertEquals(exec.exitCode, 0)

testClient.containers.stop(id)
}
}

@Test
fun `exec a failing command in a container`() = runTest {
testClient.withContainer(
"busybox:latest",
{
command = listOf("sleep", "infinity")
},
) { id ->
testClient.containers.start(id)

val execId = testClient.exec.create(id) {
command = listOf("false")
}

testClient.exec.start(execId)

val exec = testClient.exec.inspect(execId)
assertEquals(exec.exitCode, 1)

testClient.containers.stop(id)
}
}
}
Loading