Skip to main content

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;