Skip to content

Commit 49e8d67

Browse files
authored
Docker stats support (#259)
1 parent d34eedf commit 49e8d67

9 files changed

Lines changed: 1183 additions & 1 deletion

File tree

SUPPORTED_ENDPOINTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Supports 48 of 106 endpoints
1010
* [x] Get container logs - **GET /containers/:id/logs**
1111
* [ ] Get changes on container's filesystem - **GET /containers/:id/changes**
1212
* [ ] Export a container - **GET /containers/:id/export**
13-
* [ ] Get container stats based on resource usage - **GET /containers/:id/stats**
13+
* [x] Get container stats based on resource usage - **GET /containers/:id/stats**
1414
* [x] Resize a container TTY - **POST /containers/:id/resize**
1515
* [x] Start a container - **POST /containers/:id/start**
1616
* [x] Stop a container - **POST /containers/:id/stop**

api/docker-kotlin.api

Lines changed: 385 additions & 0 deletions
Large diffs are not rendered by default.

api/docker-kotlin.klib.api

Lines changed: 421 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package me.devnatan.dockerkt.models.container
2+
3+
import kotlinx.serialization.SerialName
4+
import kotlinx.serialization.Serializable
5+
6+
/**
7+
* Sentinel value Docker uses in `uint64` fields (e.g. memory/pids limits) to
8+
* indicate "unlimited". Equivalent to [ULong.MAX_VALUE].
9+
*
10+
* ```
11+
* if (stats.memoryStats?.limit == Unlimited) { ... }
12+
* ```
13+
*/
14+
public const val Unlimited: ULong = ULong.MAX_VALUE
15+
16+
/**
17+
* Container resource usage statistics.
18+
*
19+
* Fields are nullable because Docker returns different subsets depending
20+
* on the container platform (Linux vs Windows) and state (running vs stopped).
21+
*
22+
* Counter values are modeled as [ULong] to match Docker's `uint64` API types —
23+
* some fields (e.g. memory/pids limits) use the `uint64` max value as a sentinel
24+
* for "unlimited".
25+
*/
26+
@Serializable
27+
public data class ContainerStats internal constructor(
28+
@SerialName("read") public val read: String,
29+
@SerialName("preread") public val preread: String? = null,
30+
@SerialName("name") public val name: String? = null,
31+
@SerialName("id") public val id: String? = null,
32+
@SerialName("num_procs") public val numProcs: ULong? = null,
33+
@SerialName("pids_stats") public val pidsStats: PidsStats? = null,
34+
@SerialName("cpu_stats") public val cpuStats: CpuStats? = null,
35+
@SerialName("precpu_stats") public val precpuStats: CpuStats? = null,
36+
@SerialName("memory_stats") public val memoryStats: MemoryStats? = null,
37+
@SerialName("blkio_stats") public val blkioStats: BlkioStats? = null,
38+
@SerialName("networks") public val networks: Map<String, NetworkStats>? = null,
39+
@SerialName("storage_stats") public val storageStats: StorageStats? = null,
40+
)
41+
42+
@Serializable
43+
public data class PidsStats internal constructor(
44+
@SerialName("current") public val current: ULong? = null,
45+
@SerialName("limit") public val limit: ULong? = null,
46+
)
47+
48+
@Serializable
49+
public data class CpuStats internal constructor(
50+
@SerialName("cpu_usage") public val cpuUsage: CpuUsage? = null,
51+
@SerialName("system_cpu_usage") public val systemCpuUsage: ULong? = null,
52+
@SerialName("online_cpus") public val onlineCpus: ULong? = null,
53+
@SerialName("throttling_data") public val throttlingData: ThrottlingData? = null,
54+
)
55+
56+
@Serializable
57+
public data class CpuUsage internal constructor(
58+
@SerialName("total_usage") public val totalUsage: ULong? = null,
59+
@SerialName("usage_in_kernelmode") public val usageInKernelmode: ULong? = null,
60+
@SerialName("usage_in_usermode") public val usageInUsermode: ULong? = null,
61+
@SerialName("percpu_usage") public val percpuUsage: List<ULong>? = null,
62+
)
63+
64+
@Serializable
65+
public data class ThrottlingData internal constructor(
66+
@SerialName("periods") public val periods: ULong? = null,
67+
@SerialName("throttled_periods") public val throttledPeriods: ULong? = null,
68+
@SerialName("throttled_time") public val throttledTime: ULong? = null,
69+
)
70+
71+
@Serializable
72+
public data class MemoryStats internal constructor(
73+
@SerialName("usage") public val usage: ULong? = null,
74+
@SerialName("max_usage") public val maxUsage: ULong? = null,
75+
@SerialName("limit") public val limit: ULong? = null,
76+
@SerialName("failcnt") public val failcnt: ULong? = null,
77+
@SerialName("stats") public val stats: Map<String, ULong>? = null,
78+
@SerialName("commitbytes") public val commitBytes: ULong? = null,
79+
@SerialName("commitpeakbytes") public val commitPeakBytes: ULong? = null,
80+
@SerialName("privateworkingset") public val privateWorkingSet: ULong? = null,
81+
)
82+
83+
@Serializable
84+
public data class BlkioStats internal constructor(
85+
@SerialName("io_service_bytes_recursive") public val ioServiceBytesRecursive: List<BlkioStatsEntry>? = null,
86+
@SerialName("io_serviced_recursive") public val ioServicedRecursive: List<BlkioStatsEntry>? = null,
87+
@SerialName("io_queue_recursive") public val ioQueueRecursive: List<BlkioStatsEntry>? = null,
88+
@SerialName("io_service_time_recursive") public val ioServiceTimeRecursive: List<BlkioStatsEntry>? = null,
89+
@SerialName("io_wait_time_recursive") public val ioWaitTimeRecursive: List<BlkioStatsEntry>? = null,
90+
@SerialName("io_merged_recursive") public val ioMergedRecursive: List<BlkioStatsEntry>? = null,
91+
@SerialName("io_time_recursive") public val ioTimeRecursive: List<BlkioStatsEntry>? = null,
92+
@SerialName("sectors_recursive") public val sectorsRecursive: List<BlkioStatsEntry>? = null,
93+
)
94+
95+
@Serializable
96+
public data class BlkioStatsEntry internal constructor(
97+
@SerialName("major") public val major: ULong? = null,
98+
@SerialName("minor") public val minor: ULong? = null,
99+
@SerialName("op") public val op: String? = null,
100+
@SerialName("value") public val value: ULong? = null,
101+
)
102+
103+
@Serializable
104+
public data class NetworkStats internal constructor(
105+
@SerialName("rx_bytes") public val rxBytes: ULong? = null,
106+
@SerialName("rx_packets") public val rxPackets: ULong? = null,
107+
@SerialName("rx_errors") public val rxErrors: ULong? = null,
108+
@SerialName("rx_dropped") public val rxDropped: ULong? = null,
109+
@SerialName("tx_bytes") public val txBytes: ULong? = null,
110+
@SerialName("tx_packets") public val txPackets: ULong? = null,
111+
@SerialName("tx_errors") public val txErrors: ULong? = null,
112+
@SerialName("tx_dropped") public val txDropped: ULong? = null,
113+
)
114+
115+
@Serializable
116+
public data class StorageStats internal constructor(
117+
@SerialName("read_count_normalized") public val readCountNormalized: ULong? = null,
118+
@SerialName("read_size_bytes") public val readSizeBytes: ULong? = null,
119+
@SerialName("write_count_normalized") public val writeCountNormalized: ULong? = null,
120+
@SerialName("write_size_bytes") public val writeSizeBytes: ULong? = null,
121+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package me.devnatan.dockerkt.models.container
2+
3+
import kotlin.jvm.JvmOverloads
4+
5+
/**
6+
* Container stats endpoint options.
7+
*
8+
* @property stream When `true` (default), stats are pulled continuously as a stream.
9+
* When `false`, a single snapshot is returned.
10+
* @property oneShot Only applicable when [stream] is `false`. When `true`, the stats
11+
* are returned immediately without a 1-second pre-read that Docker
12+
* performs by default to compute CPU usage deltas.
13+
*/
14+
public class ContainerStatsOptions
15+
@JvmOverloads
16+
constructor(
17+
public var stream: Boolean = true,
18+
public var oneShot: Boolean = false,
19+
)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package me.devnatan.dockerkt.models.container
2+
3+
import kotlinx.coroutines.flow.Flow
4+
5+
/**
6+
* Result of a [me.devnatan.dockerkt.resource.container.ContainerResource.stats] operation.
7+
*/
8+
public sealed class ContainerStatsResult {
9+
/**
10+
* Streaming result. The [output] flow emits one [ContainerStats] per Docker
11+
* stats message until the container stops or the flow is cancelled.
12+
*/
13+
public data class Stream(
14+
val output: Flow<ContainerStats>,
15+
) : ContainerStatsResult()
16+
17+
/**
18+
* Single-snapshot result returned when `stream = false`.
19+
*/
20+
public data class Single(
21+
val output: ContainerStats,
22+
) : ContainerStatsResult()
23+
}

src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResource.kt

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ import me.devnatan.dockerkt.models.container.ContainerLogsResult
4848
import me.devnatan.dockerkt.models.container.ContainerPruneFilters
4949
import me.devnatan.dockerkt.models.container.ContainerPruneResult
5050
import me.devnatan.dockerkt.models.container.ContainerRemoveOptions
51+
import me.devnatan.dockerkt.models.container.ContainerStats
52+
import me.devnatan.dockerkt.models.container.ContainerStatsOptions
53+
import me.devnatan.dockerkt.models.container.ContainerStatsResult
5154
import me.devnatan.dockerkt.models.container.ContainerSummary
5255
import me.devnatan.dockerkt.models.container.ContainerWaitResult
5356
import me.devnatan.dockerkt.resource.image.ImageNotFoundException
@@ -312,6 +315,69 @@ public class ContainerResource internal constructor(
312315
}
313316
}
314317

318+
/**
319+
* Get resource usage statistics for a container.
320+
*
321+
* Similar to the `docker stats` command, this retrieves CPU, memory, network,
322+
* block I/O and PID statistics for a container. Results can be returned as a
323+
* continuous stream of updates or as a single snapshot.
324+
*
325+
* @param container Container id or name.
326+
* @param options Configuration options for stats retrieval. See [ContainerStatsOptions].
327+
* @return [ContainerStatsResult] whose concrete type depends on the options:
328+
* - [ContainerStatsResult.Stream] when [ContainerStatsOptions.stream] is `true`.
329+
* - [ContainerStatsResult.Single] when [ContainerStatsOptions.stream] is `false`.
330+
*
331+
* @throws ContainerNotFoundException If the container is not found.
332+
*/
333+
public suspend fun stats(
334+
container: String,
335+
options: ContainerStatsOptions = ContainerStatsOptions(),
336+
): ContainerStatsResult =
337+
if (options.stream) {
338+
ContainerStatsResult.Stream(statsStreaming(container))
339+
} else {
340+
ContainerStatsResult.Single(statsSingle(container, options.oneShot))
341+
}
342+
343+
private suspend fun statsSingle(
344+
container: String,
345+
oneShot: Boolean,
346+
): ContainerStats =
347+
requestCatching(
348+
HttpStatusCode.NotFound to { cause -> ContainerNotFoundException(cause, container) },
349+
) {
350+
httpClient.get("$BasePath/$container/stats") {
351+
parameter("stream", false)
352+
parameter("one-shot", oneShot)
353+
}
354+
}.let { response ->
355+
val channel = response.bodyAsChannel()
356+
val line =
357+
channel.readUTF8Line()
358+
?: error("Empty response from stats endpoint for container $container")
359+
json.decodeFromString<ContainerStats>(line)
360+
}
361+
362+
private fun statsStreaming(container: String): Flow<ContainerStats> =
363+
channelFlow {
364+
requestCatching(
365+
HttpStatusCode.NotFound to { cause -> ContainerNotFoundException(cause, container) },
366+
) {
367+
httpClient
368+
.prepareGet("$BasePath/$container/stats") {
369+
parameter("stream", true)
370+
}.execute { response ->
371+
val channel = response.bodyAsChannel()
372+
while (!channel.isClosedForRead) {
373+
val line = channel.readUTF8Line() ?: break
374+
if (line.isBlank()) continue
375+
send(json.decodeFromString<ContainerStats>(line))
376+
}
377+
}
378+
}
379+
}
380+
315381
// TODO documentation
316382
public suspend fun wait(
317383
container: String,

src/commonMain/kotlin/me/devnatan/dockerkt/resource/container/ContainerResourceExt.kt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import me.devnatan.dockerkt.models.container.ContainerLogsResult
1414
import me.devnatan.dockerkt.models.container.ContainerPruneFilters
1515
import me.devnatan.dockerkt.models.container.ContainerPruneResult
1616
import me.devnatan.dockerkt.models.container.ContainerRemoveOptions
17+
import me.devnatan.dockerkt.models.container.ContainerStats
18+
import me.devnatan.dockerkt.models.container.ContainerStatsOptions
19+
import me.devnatan.dockerkt.models.container.ContainerStatsResult
1720
import me.devnatan.dockerkt.models.container.ContainerSummary
1821
import me.devnatan.dockerkt.resource.image.ImageNotFoundException
1922
import kotlin.contracts.ExperimentalContracts
@@ -93,6 +96,42 @@ public suspend inline fun ContainerResource.logs(
9396
block: ContainerLogsOptions.() -> Unit,
9497
): ContainerLogsResult = logs(container, ContainerLogsOptions().apply(block))
9598

99+
/**
100+
* Get resource usage statistics for a container.
101+
*
102+
* @param container Container id or name.
103+
* @param block Configuration for stats retrieval. See [ContainerStatsOptions].
104+
* @return [ContainerStatsResult] whose concrete type depends on the options:
105+
* - [ContainerStatsResult.Stream] when [ContainerStatsOptions.stream] is `true`.
106+
* - [ContainerStatsResult.Single] when [ContainerStatsOptions.stream] is `false`.
107+
*
108+
* @throws ContainerNotFoundException If the container is not found.
109+
*/
110+
public suspend inline fun ContainerResource.stats(
111+
container: String,
112+
block: ContainerStatsOptions.() -> Unit,
113+
): ContainerStatsResult = stats(container, ContainerStatsOptions().apply(block))
114+
115+
/**
116+
* Get a single snapshot of resource usage statistics for a container.
117+
*
118+
* @param container Container id or name.
119+
* @param oneShot When `true`, Docker skips its default 1-second pre-read used to
120+
* compute CPU deltas and returns stats immediately.
121+
*
122+
* @throws ContainerNotFoundException If the container is not found.
123+
*/
124+
public suspend fun ContainerResource.statsSnapshot(
125+
container: String,
126+
oneShot: Boolean = false,
127+
): ContainerStats =
128+
(
129+
stats(
130+
container = container,
131+
options = ContainerStatsOptions(stream = false, oneShot = oneShot),
132+
) as ContainerStatsResult.Single
133+
).output
134+
96135
/**
97136
* Get logs from a container with [ContainerLogsOptions.follow], [ContainerLogsOptions.demux], [ContainerLogsOptions.stdout]
98137
* and [ContainerLogsOptions.stderr] options already set.

0 commit comments

Comments
 (0)