podup/engine/stats.rs
1//! `stats` — live resource-usage stream for a project's service containers.
2
3use std::collections::{HashMap, HashSet};
4
5use futures_util::StreamExt;
6use serde::Deserialize;
7
8use crate::compose::types::ComposeFile;
9use crate::error::{ComposeError, Result};
10use crate::libpod::types::container::ContainerListEntry;
11use crate::libpod::{parse_json_lines, urlencoded, API_PREFIX};
12use crate::units::SizeFormat;
13
14use super::Engine;
15
16/// Options for [`Engine::stats_with_options`], mirroring `docker compose stats`
17/// and the table-shaping flags the other list commands expose. Kept off the
18/// frozen [`Engine::stats`] signature so the published library API stays stable across minors.
19#[derive(Default)]
20pub struct StatsOptions {
21 /// Disable streaming; print a single snapshot and exit, `--no-stream`.
22 pub no_stream: bool,
23 /// Include non-running containers as zeroed rows, `-a/--all`.
24 pub all: bool,
25 /// Emit JSON instead of the table, `--format json`.
26 pub json: bool,
27 /// Disable container-name truncation in the table, `--no-trunc`.
28 pub no_trunc: bool,
29}
30
31impl StatsOptions {
32 /// Build options from the four CLI flags, in `--no-stream`/`--all`/
33 /// `--no-trunc`/`--format json` order. A terse constructor so the CLI keeps
34 /// the field names (all `pub`) available for clarity while the dispatch site
35 /// stays compact.
36 pub fn new(no_stream: bool, all: bool, no_trunc: bool, json: bool) -> Self {
37 Self {
38 no_stream,
39 all,
40 no_trunc,
41 json,
42 }
43 }
44}
45
46/// Width of the table NAME column; long names are truncated to this width (with
47/// a trailing ellipsis) unless `--no-trunc` is given, so a long container name
48/// no longer overflows and shifts every following column. The header is built
49/// from this same constant, so the two cannot drift apart.
50const NAME_WIDTH: usize = 32;
51
52/// Build the query fragment scoping a stats request to the `wanted` containers,
53/// or an empty string when none are wanted (which falls back to the daemon
54/// default). libpod's `/containers/stats` expects the `containers` parameter
55/// **repeated** once per container (`&containers=a&containers=b`), not a single
56/// comma-joined value — a comma-joined list is parsed as one container name and
57/// 404s. Names are sorted for a stable URL and each is URL-encoded.
58fn containers_query(wanted: &HashSet<String>) -> String {
59 if wanted.is_empty() {
60 return String::new();
61 }
62 let mut names: Vec<&String> = wanted.iter().collect();
63 names.sort();
64 names
65 .iter()
66 .map(|n| format!("&containers={}", urlencoded(n)))
67 .collect::<String>()
68}
69
70/// Whether a `stats --stream` stream that ended with an error broke while a
71/// sampled container was still running.
72///
73/// The stream lives as long as any sampled container runs and ends once none
74/// remain, so the error is a real failure only when the re-checked running set
75/// still holds one of the containers the stream was sampling. When every
76/// sampled container has stopped, the end was expected and the missing terminal
77/// frame is the finished-vs-broken ambiguity (#1104), not a fault. Pure so the
78/// decision is unit-tested without a live socket.
79fn stats_stream_broke_mid_sample(
80 sampled: &HashSet<String>,
81 still_running: &HashSet<String>,
82) -> bool {
83 sampled.iter().any(|c| still_running.contains(c))
84}
85
86/// Deserialize a map field, treating an explicit JSON `null` as the default
87/// (empty) map. libpod sends `"Network": null` for a container with no
88/// interfaces, which plain `#[serde(default)]` does not tolerate.
89fn null_default<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
90where
91 D: serde::Deserializer<'de>,
92 T: Default + Deserialize<'de>,
93{
94 Option::<T>::deserialize(d).map(|v| v.unwrap_or_default())
95}
96
97/// One frame of the libpod `/containers/stats` response.
98#[derive(Deserialize, Default)]
99struct StatsReport {
100 #[serde(rename = "Stats", default)]
101 stats: Vec<ContainerStat>,
102}
103
104/// Per-container resource sample within a [`StatsReport`].
105#[derive(Deserialize, Default, Clone)]
106struct ContainerStat {
107 #[serde(rename = "Name", default)]
108 name: String,
109 #[serde(rename = "CPU", default)]
110 cpu: f64,
111 #[serde(rename = "MemUsage", default)]
112 mem_usage: u64,
113 #[serde(rename = "MemLimit", default)]
114 mem_limit: u64,
115 #[serde(rename = "MemPerc", default)]
116 mem_perc: f64,
117 #[serde(rename = "BlockInput", default)]
118 block_in: u64,
119 #[serde(rename = "BlockOutput", default)]
120 block_out: u64,
121 #[serde(rename = "PIDs", default)]
122 pids: u64,
123 // `#[serde(default)]` also tolerates an explicit `null` frame value: libpod
124 // sends `"network": null` for a container with no interfaces, which would
125 // otherwise fail with `invalid type: null, expected a map`.
126 #[serde(rename = "Network", default, deserialize_with = "null_default")]
127 network: HashMap<String, NetStat>,
128}
129
130/// Per-interface network counters.
131#[derive(Deserialize, Default, Clone)]
132struct NetStat {
133 #[serde(rename = "RxBytes", default)]
134 rx: u64,
135 #[serde(rename = "TxBytes", default)]
136 tx: u64,
137}
138
139/// How `stats` renders a size: binary units at one decimal.
140///
141/// Binary because this table is read next to `free` and `htop`, which are
142/// binary — not next to podman's own output, which is decimal. One decimal
143/// because the columns below are fixed-width by design (a live view must not
144/// resize itself mid-repaint), and a second decimal does not fit `NET I/O`.
145const SIZE_FORMAT: SizeFormat = SizeFormat::binary().with_decimals(1);
146
147/// Render a byte count as a compact human string (`1.5MiB`). Pure for testing.
148///
149/// The ladder, the rounding and the totality guarantee live in [`crate::units`]
150/// now; this is the surface's choice of base and precision, nothing else.
151fn format_bytes(bytes: u64) -> String {
152 crate::units::format_bytes(bytes, &SIZE_FORMAT)
153}
154
155/// Sum a container's per-interface network counters into one `(rx, tx)` pair.
156fn net_totals(s: &ContainerStat) -> (u64, u64) {
157 s.network
158 .values()
159 .fold((0u64, 0u64), |(rx, tx), n| (rx + n.rx, tx + n.tx))
160}
161
162/// The NAME cell for the table: the full name when `no_trunc`, otherwise
163/// truncated to [`NAME_WIDTH`] with a trailing ellipsis so a long name keeps the
164/// row aligned. Counts characters (not bytes) so multi-byte names truncate
165/// safely. Pure for testing.
166fn truncate_name(name: &str, no_trunc: bool) -> String {
167 if no_trunc || name.chars().count() <= NAME_WIDTH {
168 return name.to_string();
169 }
170 let head: String = name.chars().take(NAME_WIDTH - 1).collect();
171 format!("{head}…")
172}
173
174/// Format one stats row into the table layout. With `no_trunc` a long name is
175/// left intact (and may overflow its column); otherwise it is truncated to
176/// [`NAME_WIDTH`]. Pure for testing.
177/// Colour band for a utilisation percentage.
178///
179/// A container at 95% of its memory limit is minutes from being OOM-killed, and
180/// in a plain table that number looks exactly like 0.02%. The whole reason to
181/// read `stats` is to find the row that is in trouble, so the number that says
182/// so should be the one that catches the eye.
183fn load_style(pct: f64) -> crate::ui::Style {
184 use crate::ui::AnsiColor;
185 // Four bands, not three, and the lowest one is dim rather than green.
186 // Everything under 70% used to be the same green, so a container at 0.02%
187 // looked exactly like one at 69% — colour that does not vary with the value
188 // carries no information, and the whole reason to read `stats` is to find
189 // the row in trouble. Idle rows now recede instead of competing.
190 if pct < 5.0 {
191 return crate::ui::Style::new().dimmed();
192 }
193 let colour = if pct >= 85.0 {
194 AnsiColor::Red
195 } else if pct >= 50.0 {
196 AnsiColor::Yellow
197 } else {
198 AnsiColor::Green
199 };
200 crate::ui::Style::new().fg_color(Some(colour.into()))
201}
202
203/// Format one stats row into the table layout, optionally coloured. With
204/// `no_trunc` a long name is left intact (and may overflow its column);
205/// otherwise it is truncated to [`NAME_WIDTH`]. Pure, so the layout is testable
206/// without a terminal — the tests pass `colour = false`.
207///
208/// Each cell is padded to its width *before* being painted: the ANSI codes are
209/// zero-width, so padding afterwards would count them and knock every later
210/// column out of alignment.
211fn format_row_with(s: &ContainerStat, no_trunc: bool, colour: bool) -> String {
212 use crate::ui::{identity_style, paint};
213 let (rx, tx) = net_totals(s);
214 let dim = crate::ui::Style::new().dimmed();
215
216 let name = format!("{:<NAME_WIDTH$}", truncate_name(&s.name, no_trunc));
217 let name = paint(identity_style(s.name.trim()), &name, colour);
218 let cpu = paint(
219 load_style(s.cpu),
220 &format!("{:>width$.2}%", s.cpu, width = CPU_WIDTH - 1),
221 colour,
222 );
223 let mem_pct = paint(
224 load_style(s.mem_perc),
225 &format!("{:>width$.2}%", s.mem_perc, width = MEM_PCT_WIDTH - 1),
226 colour,
227 );
228 // Secondary detail: the absolute figures matter once a percentage has drawn
229 // you to the row, not before.
230 let mem = paint(
231 dim,
232 &format!(
233 "{:>width$} / {:<width$}",
234 format_bytes(s.mem_usage),
235 format_bytes(s.mem_limit),
236 width = (MEM_WIDTH - 3) / 2
237 ),
238 colour,
239 );
240 let net = paint(
241 dim,
242 &format!(
243 "{:>width$} / {:<width$}",
244 format_bytes(rx),
245 format_bytes(tx),
246 width = (NET_WIDTH - 3) / 2
247 ),
248 colour,
249 );
250 let block = paint(
251 dim,
252 &format!(
253 "{:>width$} / {:<width$}",
254 format_bytes(s.block_in),
255 format_bytes(s.block_out),
256 width = (BLOCK_WIDTH - 3) / 2
257 ),
258 colour,
259 );
260 // PIDS was the only unstyled column of the six. It is secondary detail like
261 // the absolute byte figures beside it, so it takes the same dim treatment
262 // rather than a colour of its own.
263 let pids = paint(dim, &format!("{:>PIDS_WIDTH$}", s.pids), colour);
264 format!("{name} {cpu} {mem} {mem_pct} {net} {block} {pids}")
265}
266
267/// Build one `stats --format json` row with numeric values (raw bytes/percent),
268/// so machine consumers get exact figures rather than the table's rounded,
269/// human-formatted cells. Pure so it can be unit-tested.
270fn stat_json_row(s: &ContainerStat) -> serde_json::Value {
271 let (rx, tx) = net_totals(s);
272 serde_json::json!({
273 "Name": s.name,
274 "CPUPerc": s.cpu,
275 "MemUsage": s.mem_usage,
276 "MemLimit": s.mem_limit,
277 "MemPerc": s.mem_perc,
278 "NetInput": rx,
279 "NetOutput": tx,
280 "BlockInput": s.block_in,
281 "BlockOutput": s.block_out,
282 "PIDs": s.pids,
283 })
284}
285
286/// Width of each column after NAME, in the order they are printed.
287///
288/// One source for the header and the rows. They used to be two hand-maintained
289/// layouts that nothing checked against each other, and they had drifted:
290/// measured against a representative row, the `MEM %` label sat one column past
291/// where its own data ended and `PIDS` started exactly where its data *stopped*,
292/// so the label was entirely off its column. Patching the constant would have
293/// fixed it until the next edit.
294const CPU_WIDTH: usize = 8;
295const MEM_WIDTH: usize = 23;
296const MEM_PCT_WIDTH: usize = 7;
297const NET_WIDTH: usize = 21;
298const BLOCK_WIDTH: usize = 21;
299const PIDS_WIDTH: usize = 5;
300
301/// The table header, built from the same widths the rows are.
302///
303/// Fixed widths rather than `ui::Table`'s content sizing, deliberately: this
304/// table repaints every second, and a column that resizes itself as the numbers
305/// change makes every row jump sideways while you are trying to read it. The
306/// shared table is right for a list printed once; a live view wants columns that
307/// stay put.
308fn header() -> String {
309 format!(
310 "{:<NAME_WIDTH$} {:>CPU_WIDTH$} {:<MEM_WIDTH$} {:>MEM_PCT_WIDTH$} \
311 {:<NET_WIDTH$} {:<BLOCK_WIDTH$} {:>PIDS_WIDTH$}",
312 "NAME", "CPU %", "MEM USAGE / LIMIT", "MEM %", "NET I/O", "BLOCK I/O", "PIDS"
313 )
314}
315
316impl Engine {
317 /// Stream resource usage for the project's service containers (docker
318 /// `compose stats`). Streams continuously until interrupted; `no_stream`
319 /// prints a single snapshot. `target_services` narrows to specific services.
320 pub async fn stats(
321 &self,
322 file: &ComposeFile,
323 target_services: &[String],
324 no_stream: bool,
325 ) -> Result<()> {
326 self.stats_with_options(
327 file,
328 target_services,
329 StatsOptions {
330 no_stream,
331 ..StatsOptions::default()
332 },
333 )
334 .await
335 }
336
337 /// Stream resource usage with `docker compose stats`-style options:
338 /// `--no-stream` (single snapshot), `-a/--all` (include non-running
339 /// containers as zeroed rows), `--format` (table | json), and `--no-trunc`
340 /// (keep full container names). `target_services` narrows to specific
341 /// services.
342 pub async fn stats_with_options(
343 &self,
344 file: &ComposeFile,
345 target_services: &[String],
346 opts: StatsOptions,
347 ) -> Result<()> {
348 // Reject unknown/typo service names instead of silently sampling the whole
349 // host and printing a header-only table, matching the other commands.
350 if let Some(unknown) = first_unknown_service(file, target_services) {
351 return Err(ComposeError::ServiceNotFound(unknown.into()));
352 }
353 let targets = self.target_containers(file, target_services).await?;
354
355 // Only running containers carry live samples, so scope the libpod
356 // `containers=` filter to them: a stopped/created container fed to that
357 // filter 404s the whole request. Non-running rows are synthesized locally
358 // (as zeros) when `--all` is set.
359 let running: HashSet<String> = targets
360 .iter()
361 .filter(|t| t.running)
362 .map(|t| t.name.clone())
363 .collect();
364 let stopped: Vec<String> = if opts.all {
365 targets
366 .iter()
367 .filter(|t| !t.running)
368 .map(|t| t.name.clone())
369 .collect()
370 } else {
371 Vec::new()
372 };
373
374 // Scope the stats stream to just the running containers server-side via the
375 // `containers=` query param, so the daemon does not sample every container
376 // on the host (the response is still filtered locally by `running`).
377 let containers = containers_query(&running);
378
379 if opts.no_stream || running.is_empty() {
380 // Nothing running means nothing to sample (and an empty `containers=`
381 // filter would otherwise fall back to the whole host) — skip the call
382 // and render an empty/`--all`-only frame.
383 let report = if running.is_empty() {
384 StatsReport::default()
385 } else {
386 self.client
387 .get_json(&format!(
388 "{API_PREFIX}/containers/stats?stream=false{containers}"
389 ))
390 .await
391 .map_err(ComposeError::Podman)?
392 };
393 print_frame(&report, &running, &stopped, &opts, None);
394 return Ok(());
395 }
396
397 let resp = self
398 .client
399 .get_stream(&format!(
400 "{API_PREFIX}/containers/stats?stream=true{containers}"
401 ))
402 .await
403 .map_err(ComposeError::Podman)?;
404 let mut frames = parse_json_lines::<StatsReport>(resp.into_body());
405
406 // A live region only where one belongs: stdout a terminal, colour on, the
407 // width readable — the same three conditions the lifecycle board uses,
408 // asked of stdout rather than stderr because `stats` *is* its output. A
409 // `--format json` stream is a machine path and never repaints.
410 let mut region = wants_region(opts.json, live_stats_allowed())
411 .then(|| crate::ui::progress::Region::new(crate::ui::progress::Target::Stdout));
412
413 while let Some(frame) = frames.next().await {
414 match frame {
415 Ok(report) => print_frame(&report, &running, &stopped, &opts, region.as_mut()),
416 Err(e) => {
417 // A `stats --stream` stream lives as long as any sampled
418 // container is running, and ends once none remain. libpod
419 // signals that end with a chunked terminator, but a lost
420 // terminator (a dropped connection, or a version that omits it)
421 // reaches here as an `Err` that is *indistinguishable* from a
422 // real mid-sample break at the transport layer (#1104). Resolve
423 // it out of band: re-check what is still running. If nothing the
424 // stream was sampling is alive, the end was expected and the
425 // command succeeded; if something is still running, the stream
426 // truncated a live sample and the command failed — which is the
427 // exit code a monitor scraping `stats` needs (#1080).
428 //
429 // The re-check is point-in-time: it samples state a moment after
430 // the break, so a genuine break that happens to coincide with
431 // every sampled container stopping is knowingly tolerated as a
432 // clean end — the transport layer cannot tell the two apart, and
433 // the sampled containers are gone either way.
434 let still_running = match self.target_containers(file, target_services).await {
435 Ok(targets) => targets
436 .into_iter()
437 .filter(|c| c.running)
438 .map(|c| c.name)
439 .collect::<HashSet<String>>(),
440 // Fail closed: an unreadable running set is not confirmation
441 // the end was expected, so the original error stands rather
442 // than masking a possible failure. Decided here, at the point
443 // of the inconclusive re-check, so the guarantee does not
444 // depend on the sampled set being non-empty.
445 Err(_) => {
446 tracing::warn!(
447 "stats: stream ended and the running set could not be \
448 re-checked [{}]: {e}",
449 e.stream_end_kind()
450 );
451 return Err(ComposeError::Podman(e));
452 }
453 };
454 if stats_stream_broke_mid_sample(&running, &still_running) {
455 tracing::warn!(
456 "stats: stream broke while a container was still running [{}]: {e}",
457 e.stream_end_kind()
458 );
459 return Err(ComposeError::Podman(e));
460 }
461 tracing::debug!(
462 "stats: stream ended as its containers stopped [{}]",
463 e.stream_end_kind()
464 );
465 break;
466 }
467 }
468 }
469 Ok(())
470 }
471
472 /// The containers to report on — every existing replica of the targeted
473 /// services (all services when `target_services` is empty), paired with
474 /// whether each is currently running. Only containers that actually exist are
475 /// returned (no static-name fallback): an absent service simply contributes
476 /// no rows.
477 async fn target_containers(
478 &self,
479 file: &ComposeFile,
480 target_services: &[String],
481 ) -> Result<Vec<TargetContainer>> {
482 let filters = serde_json::json!({ "label": [format!("podup.project={}", self.project)] });
483 let path = format!(
484 "{API_PREFIX}/containers/json?all=true&filters={}",
485 urlencoded(&filters.to_string()),
486 );
487 let entries = self
488 .client
489 .get_json::<Vec<ContainerListEntry>>(&path)
490 .await
491 .map_err(ComposeError::Podman)?;
492
493 let mut out = Vec::new();
494 for e in entries {
495 let service = e
496 .labels
497 .get("podup.service")
498 .map(String::as_str)
499 .unwrap_or("");
500 // Skip containers whose service the compose file no longer defines, and
501 // honour a positional `SERVICE` filter.
502 if !file.services.contains_key(service) {
503 continue;
504 }
505 if !target_services.is_empty() && !target_services.iter().any(|t| t == service) {
506 continue;
507 }
508 if let Some(raw) = e.names.first() {
509 out.push(TargetContainer {
510 name: raw.trim_start_matches('/').to_string(),
511 running: e.state == "running",
512 });
513 }
514 }
515 Ok(out)
516 }
517}
518
519/// A project container considered for `stats`, with its run state so non-running
520/// containers can be folded in (as zeroed rows) only under `--all`.
521struct TargetContainer {
522 name: String,
523 running: bool,
524}
525
526/// The first targeted service name that the compose file does not define, if any.
527/// Pure so the validation is unit-tested without a live Podman socket.
528fn first_unknown_service<'a>(file: &ComposeFile, targets: &'a [String]) -> Option<&'a str> {
529 targets
530 .iter()
531 .map(String::as_str)
532 .find(|t| !file.services.contains_key(*t))
533}
534
535/// Assemble the rows for one frame: the live samples for `running` containers
536/// plus synthesized zero rows for each `stopped` container (already empty when
537/// `--all` is off), sorted by name for stable output.
538fn frame_rows(
539 report: &StatsReport,
540 running: &HashSet<String>,
541 stopped: &[String],
542) -> Vec<ContainerStat> {
543 let mut rows: Vec<ContainerStat> = report
544 .stats
545 .iter()
546 .filter(|s| running.contains(&s.name))
547 .cloned()
548 .collect();
549 for name in stopped {
550 rows.push(ContainerStat {
551 name: name.clone(),
552 ..ContainerStat::default()
553 });
554 }
555 rows.sort_by(|a, b| a.name.cmp(&b.name));
556 rows
557}
558
559/// Whether a streaming `stats` frame should repaint in place.
560///
561/// Pure, and separate from the terminal probe, so the `--format json` half is
562/// testable: a piped test cannot exercise it at all, because the terminal probe
563/// is already false there — a version that let JSON repaint on a tty passed
564/// every integration test in the suite.
565fn wants_region(json: bool, live_terminal: bool) -> bool {
566 // Never for the machine path, whatever the terminal says. A parser reading
567 // NDJSON would otherwise get cursor moves interleaved with its documents.
568 !json && live_terminal
569}
570
571/// Whether `stats` may repaint in place.
572///
573/// Asked of **stdout**, unlike the lifecycle board's stderr: `stats` renders its
574/// table to stdout, so that is the stream that has to be a terminal for a
575/// repaint to make sense. `stats > file` on a terminal must still produce a file
576/// of readable frames rather than a file of cursor moves.
577fn live_stats_allowed() -> bool {
578 use std::io::IsTerminal;
579 std::io::stdout().is_terminal()
580 && crate::ui::stdout_colored()
581 && crate::engine::query::terminal::window_size().is_some()
582}
583
584/// Print one stats frame: the table (a bold header plus one row per container)
585/// or a JSON array when `--format json`. Table frames end with a blank line.
586fn print_frame(
587 report: &StatsReport,
588 running: &HashSet<String>,
589 stopped: &[String],
590 opts: &StatsOptions,
591 region: Option<&mut crate::ui::progress::Region>,
592) {
593 let rows = frame_rows(report, running, stopped);
594
595 if opts.json {
596 let json: Vec<_> = rows.iter().map(stat_json_row).collect();
597 // While streaming, one compact array per line — NDJSON, the shape
598 // `events` already emits. A pretty-printed array per frame, concatenated,
599 // is neither a single JSON document nor NDJSON, so no parser accepts it:
600 // `stats --format json` was unreadable by anything for as long as it
601 // streamed. `--no-stream` prints one frame and exits, so it stays a
602 // single pretty document, which is valid JSON and nicer to read.
603 let text = if opts.no_stream {
604 serde_json::to_string_pretty(&json)
605 } else {
606 serde_json::to_string(&json)
607 };
608 println!("{}", text.unwrap_or_default());
609 return;
610 }
611
612 let colour = crate::ui::stdout_colored();
613 let mut lines = vec![crate::ui::paint(crate::ui::bold(), &header(), colour)];
614 lines.extend(
615 rows.iter()
616 .map(|s| format_row_with(s, opts.no_trunc, colour)),
617 );
618
619 // A live region only while streaming. `--no-stream` prints one frame and
620 // exits, so there is nothing to repaint over and a region would only hide
621 // the cursor for no reason.
622 if let Some(region) = region {
623 region.show(&[], &lines);
624 return;
625 }
626 for line in lines {
627 println!("{line}");
628 }
629 println!();
630}
631
632#[cfg(test)]
633#[path = "stats_tests.rs"]
634mod tests;