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
2 changes: 1 addition & 1 deletion SUPPORTED_ENDPOINTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Supports 48 of 106 endpoints
* [x] Get container logs - **GET /containers/:id/logs**
* [ ] Get changes on container's filesystem - **GET /containers/:id/changes**
* [ ] Export a container - **GET /containers/:id/export**
* [ ] Get container stats based on resource usage - **GET /containers/:id/stats**
* [x] Get container stats based on resource usage - **GET /containers/:id/stats**
* [x] Resize a container TTY - **POST /containers/:id/resize**
* [x] Start a container - **POST /containers/:id/start**
* [x] Stop a container - **POST /containers/:id/stop**
Expand Down
385 changes: 385 additions & 0 deletions api/docker-kotlin.api

Large diffs are not rendered by default.

421 changes: 421 additions & 0 deletions api/docker-kotlin.klib.api

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package me.devnatan.dockerkt.models.container

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Sentinel value Docker uses in `uint64` fields (e.g. memory/pids limits) to
* indicate "unlimited". Equivalent to [ULong.MAX_VALUE].
*
* ```
* if (stats.memoryStats?.limit == Unlimited) { ... }
* ```
*/
public const val Unlimited: ULong = ULong.MAX_VALUE

/**
* Container resource usage statistics.
*
* Fields are nullable because Docker returns different subsets depending
* on the container platform (Linux vs Windows) and state (running vs stopped).
*
* Counter values are modeled as [ULong] to match Docker's `uint64` API types —
* some fields (e.g. memory/pids limits) use the `uint64` max value as a sentinel
* for "unlimited".
*/
@Serializable
public data class ContainerStats internal constructor(
@SerialName("read") public val read: String,
@SerialName("preread") public val preread: String? = null,
@SerialName("name") public val name: String? = null,
@SerialName("id") public val id: String? = null,
@SerialName("num_procs") public val numProcs: ULong? = null,
@SerialName("pids_stats") public val pidsStats: PidsStats? = null,
@SerialName("cpu_stats") public val cpuStats: CpuStats? = null,
@SerialName("precpu_stats") public val precpuStats: CpuStats? = null,
@SerialName("memory_stats") public val memoryStats: MemoryStats? = null,
@SerialName("blkio_stats") public val blkioStats: BlkioStats? = null,
@SerialName("networks") public val networks: Map<String, NetworkStats>? = null,
@SerialName("storage_stats") public val storageStats: StorageStats? = null,
)

@Serializable
public data class PidsStats internal constructor(
@SerialName("current") public val current: ULong? = null,
@SerialName("limit") public val limit: ULong? = null,
)

@Serializable
public data class CpuStats internal constructor(
@SerialName("cpu_usage") public val cpuUsage: CpuUsage? = null,
@SerialName("system_cpu_usage") public val systemCpuUsage: ULong? = null,
@SerialName("online_cpus") public val onlineCpus: ULong? = null,
@SerialName("throttling_data") public val throttlingData: ThrottlingData? = null,
)

@Serializable
public data class CpuUsage internal constructor(
@SerialName("total_usage") public val totalUsage: ULong? = null,
@SerialName("usage_in_kernelmode") public val usageInKernelmode: ULong? = null,
@SerialName("usage_in_usermode") public val usageInUsermode: ULong? = null,
@SerialName("percpu_usage") public val percpuUsage: List<ULong>? = null,
)

@Serializable
public data class ThrottlingData internal constructor(
@SerialName("periods") public val periods: ULong? = null,
@SerialName("throttled_periods") public val throttledPeriods: ULong? = null,
@SerialName("throttled_time") public val throttledTime: ULong? = null,
)

@Serializable
public data class MemoryStats internal constructor(
@SerialName("usage") public val usage: ULong? = null,
@SerialName("max_usage") public val maxUsage: ULong? = null,
@SerialName("limit") public val limit: ULong? = null,
@SerialName("failcnt") public val failcnt: ULong? = null,
@SerialName("stats") public val stats: Map<String, ULong>? = null,
@SerialName("commitbytes") public val commitBytes: ULong? = null,
@SerialName("commitpeakbytes") public val commitPeakBytes: ULong? = null,
@SerialName("privateworkingset") public val privateWorkingSet: ULong? = null,
)

@Serializable
public data class BlkioStats internal constructor(
@SerialName("io_service_bytes_recursive") public val ioServiceBytesRecursive: List<BlkioStatsEntry>? = null,
@SerialName("io_serviced_recursive") public val ioServicedRecursive: List<BlkioStatsEntry>? = null,
@SerialName("io_queue_recursive") public val ioQueueRecursive: List<BlkioStatsEntry>? = null,
@SerialName("io_service_time_recursive") public val ioServiceTimeRecursive: List<BlkioStatsEntry>? = null,
@SerialName("io_wait_time_recursive") public val ioWaitTimeRecursive: List<BlkioStatsEntry>? = null,
@SerialName("io_merged_recursive") public val ioMergedRecursive: List<BlkioStatsEntry>? = null,
@SerialName("io_time_recursive") public val ioTimeRecursive: List<BlkioStatsEntry>? = null,
@SerialName("sectors_recursive") public val sectorsRecursive: List<BlkioStatsEntry>? = null,
)

@Serializable
public data class BlkioStatsEntry internal constructor(
@SerialName("major") public val major: ULong? = null,
@SerialName("minor") public val minor: ULong? = null,
@SerialName("op") public val op: String? = null,
@SerialName("value") public val value: ULong? = null,
)

@Serializable
public data class NetworkStats internal constructor(
@SerialName("rx_bytes") public val rxBytes: ULong? = null,
@SerialName("rx_packets") public val rxPackets: ULong? = null,
@SerialName("rx_errors") public val rxErrors: ULong? = null,
@SerialName("rx_dropped") public val rxDropped: ULong? = null,
@SerialName("tx_bytes") public val txBytes: ULong? = null,
@SerialName("tx_packets") public val txPackets: ULong? = null,
@SerialName("tx_errors") public val txErrors: ULong? = null,
@SerialName("tx_dropped") public val txDropped: ULong? = null,
)

@Serializable
public data class StorageStats internal constructor(
@SerialName("read_count_normalized") public val readCountNormalized: ULong? = null,
@SerialName("read_size_bytes") public val readSizeBytes: ULong? = null,
@SerialName("write_count_normalized") public val writeCountNormalized: ULong? = null,
@SerialName("write_size_bytes") public val writeSizeBytes: ULong? = null,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package me.devnatan.dockerkt.models.container

import kotlin.jvm.JvmOverloads

/**
* Container stats endpoint options.
*
* @property stream When `true` (default), stats are pulled continuously as a stream.
* When `false`, a single snapshot is returned.
* @property oneShot Only applicable when [stream] is `false`. When `true`, the stats
* are returned immediately without a 1-second pre-read that Docker
* performs by default to compute CPU usage deltas.
*/
public class ContainerStatsOptions
@JvmOverloads
constructor(
public var stream: Boolean = true,
public var oneShot: Boolean = false,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package me.devnatan.dockerkt.models.container

import kotlinx.coroutines.flow.Flow

/**
* Result of a [me.devnatan.dockerkt.resource.container.ContainerResource.stats] operation.
*/
public sealed class ContainerStatsResult {
/**
* Streaming result. The [output] flow emits one [ContainerStats] per Docker
* stats message until the container stops or the flow is cancelled.
*/
public data class Stream(
val output: Flow<ContainerStats>,
) : ContainerStatsResult()

/**
* Single-snapshot result returned when `stream = false`.
*/
public data class Single(
val output: ContainerStats,
) : ContainerStatsResult()
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ import me.devnatan.dockerkt.models.container.ContainerLogsResult
import me.devnatan.dockerkt.models.container.ContainerPruneFilters
import me.devnatan.dockerkt.models.container.ContainerPruneResult
import me.devnatan.dockerkt.models.container.ContainerRemoveOptions
import me.devnatan.dockerkt.models.container.ContainerStats
import me.devnatan.dockerkt.models.container.ContainerStatsOptions
import me.devnatan.dockerkt.models.container.ContainerStatsResult
import me.devnatan.dockerkt.models.container.ContainerSummary
import me.devnatan.dockerkt.models.container.ContainerWaitResult
import me.devnatan.dockerkt.resource.image.ImageNotFoundException
Expand Down Expand Up @@ -312,6 +315,69 @@ public class ContainerResource internal constructor(
}
}

/**
* Get resource usage statistics for a container.
*
* Similar to the `docker stats` command, this retrieves CPU, memory, network,
* block I/O and PID statistics for a container. Results can be returned as a
* continuous stream of updates or as a single snapshot.
*
* @param container Container id or name.
* @param options Configuration options for stats retrieval. See [ContainerStatsOptions].
* @return [ContainerStatsResult] whose concrete type depends on the options:
* - [ContainerStatsResult.Stream] when [ContainerStatsOptions.stream] is `true`.
* - [ContainerStatsResult.Single] when [ContainerStatsOptions.stream] is `false`.
*
* @throws ContainerNotFoundException If the container is not found.
*/
public suspend fun stats(
container: String,
options: ContainerStatsOptions = ContainerStatsOptions(),
): ContainerStatsResult =
if (options.stream) {
ContainerStatsResult.Stream(statsStreaming(container))
} else {
ContainerStatsResult.Single(statsSingle(container, options.oneShot))
}

private suspend fun statsSingle(
container: String,
oneShot: Boolean,
): ContainerStats =
requestCatching(
HttpStatusCode.NotFound to { cause -> ContainerNotFoundException(cause, container) },
) {
httpClient.get("$BasePath/$container/stats") {
parameter("stream", false)
parameter("one-shot", oneShot)
}
}.let { response ->
val channel = response.bodyAsChannel()
val line =
channel.readUTF8Line()
?: error("Empty response from stats endpoint for container $container")
json.decodeFromString<ContainerStats>(line)
}

private fun statsStreaming(container: String): Flow<ContainerStats> =
channelFlow {
requestCatching(
HttpStatusCode.NotFound to { cause -> ContainerNotFoundException(cause, container) },
) {
httpClient
.prepareGet("$BasePath/$container/stats") {
parameter("stream", true)
}.execute { response ->
val channel = response.bodyAsChannel()
while (!channel.isClosedForRead) {
val line = channel.readUTF8Line() ?: break
if (line.isBlank()) continue
send(json.decodeFromString<ContainerStats>(line))
}
}
}
}

// TODO documentation
public suspend fun wait(
container: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import me.devnatan.dockerkt.models.container.ContainerLogsResult
import me.devnatan.dockerkt.models.container.ContainerPruneFilters
import me.devnatan.dockerkt.models.container.ContainerPruneResult
import me.devnatan.dockerkt.models.container.ContainerRemoveOptions
import me.devnatan.dockerkt.models.container.ContainerStats
import me.devnatan.dockerkt.models.container.ContainerStatsOptions
import me.devnatan.dockerkt.models.container.ContainerStatsResult
import me.devnatan.dockerkt.models.container.ContainerSummary
import me.devnatan.dockerkt.resource.image.ImageNotFoundException
import kotlin.contracts.ExperimentalContracts
Expand Down Expand Up @@ -93,6 +96,42 @@ public suspend inline fun ContainerResource.logs(
block: ContainerLogsOptions.() -> Unit,
): ContainerLogsResult = logs(container, ContainerLogsOptions().apply(block))

/**
* Get resource usage statistics for a container.
*
* @param container Container id or name.
* @param block Configuration for stats retrieval. See [ContainerStatsOptions].
* @return [ContainerStatsResult] whose concrete type depends on the options:
* - [ContainerStatsResult.Stream] when [ContainerStatsOptions.stream] is `true`.
* - [ContainerStatsResult.Single] when [ContainerStatsOptions.stream] is `false`.
*
* @throws ContainerNotFoundException If the container is not found.
*/
public suspend inline fun ContainerResource.stats(
container: String,
block: ContainerStatsOptions.() -> Unit,
): ContainerStatsResult = stats(container, ContainerStatsOptions().apply(block))

/**
* Get a single snapshot of resource usage statistics for a container.
*
* @param container Container id or name.
* @param oneShot When `true`, Docker skips its default 1-second pre-read used to
* compute CPU deltas and returns stats immediately.
*
* @throws ContainerNotFoundException If the container is not found.
*/
public suspend fun ContainerResource.statsSnapshot(
container: String,
oneShot: Boolean = false,
): ContainerStats =
(
stats(
container = container,
options = ContainerStatsOptions(stream = false, oneShot = oneShot),
) as ContainerStatsResult.Single
).output

/**
* Get logs from a container with [ContainerLogsOptions.follow], [ContainerLogsOptions.demux], [ContainerLogsOptions.stdout]
* and [ContainerLogsOptions.stderr] options already set.
Expand Down
Loading
Loading