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};
12
13use super::Engine;
14
15/// Options for [`Engine::stats_with_options`], mirroring `docker compose stats`
16/// and the table-shaping flags the other list commands expose. Kept off the
17/// frozen [`Engine::stats`] signature so the 1.0 library API stays stable.
18#[derive(Default)]
19pub struct StatsOptions {
20	/// Disable streaming; print a single snapshot and exit, `--no-stream`.
21	pub no_stream: bool,
22	/// Include non-running containers as zeroed rows, `-a/--all`.
23	pub all: bool,
24	/// Emit JSON instead of the table, `--format json`.
25	pub json: bool,
26	/// Disable container-name truncation in the table, `--no-trunc`.
27	pub no_trunc: bool,
28}
29
30impl StatsOptions {
31	/// Build options from the four CLI flags, in `--no-stream`/`--all`/
32	/// `--no-trunc`/`--format json` order. A terse constructor so the CLI keeps
33	/// the field names (all `pub`) available for clarity while the dispatch site
34	/// stays compact.
35	pub fn new(no_stream: bool, all: bool, no_trunc: bool, json: bool) -> Self {
36		Self {
37			no_stream,
38			all,
39			no_trunc,
40			json,
41		}
42	}
43}
44
45/// Width of the table NAME column; long names are truncated to this width (with
46/// a trailing ellipsis) unless `--no-trunc` is given, so a long container name
47/// no longer overflows and shifts every following column. Matches [`HEADER`].
48const NAME_WIDTH: usize = 32;
49
50/// Build the query fragment scoping a stats request to the `wanted` containers,
51/// or an empty string when none are wanted (which falls back to the daemon
52/// default). libpod's `/containers/stats` expects the `containers` parameter
53/// **repeated** once per container (`&containers=a&containers=b`), not a single
54/// comma-joined value — a comma-joined list is parsed as one container name and
55/// 404s. Names are sorted for a stable URL and each is URL-encoded.
56fn containers_query(wanted: &HashSet<String>) -> String {
57	if wanted.is_empty() {
58		return String::new();
59	}
60	let mut names: Vec<&String> = wanted.iter().collect();
61	names.sort();
62	names
63		.iter()
64		.map(|n| format!("&containers={}", urlencoded(n)))
65		.collect::<String>()
66}
67
68/// Deserialize a map field, treating an explicit JSON `null` as the default
69/// (empty) map. libpod sends `"Network": null` for a container with no
70/// interfaces, which plain `#[serde(default)]` does not tolerate.
71fn null_default<'de, D, T>(d: D) -> std::result::Result<T, D::Error>
72where
73	D: serde::Deserializer<'de>,
74	T: Default + Deserialize<'de>,
75{
76	Option::<T>::deserialize(d).map(|v| v.unwrap_or_default())
77}
78
79/// One frame of the libpod `/containers/stats` response.
80#[derive(Deserialize, Default)]
81struct StatsReport {
82	#[serde(rename = "Stats", default)]
83	stats: Vec<ContainerStat>,
84}
85
86/// Per-container resource sample within a [`StatsReport`].
87#[derive(Deserialize, Default, Clone)]
88struct ContainerStat {
89	#[serde(rename = "Name", default)]
90	name: String,
91	#[serde(rename = "CPU", default)]
92	cpu: f64,
93	#[serde(rename = "MemUsage", default)]
94	mem_usage: u64,
95	#[serde(rename = "MemLimit", default)]
96	mem_limit: u64,
97	#[serde(rename = "MemPerc", default)]
98	mem_perc: f64,
99	#[serde(rename = "BlockInput", default)]
100	block_in: u64,
101	#[serde(rename = "BlockOutput", default)]
102	block_out: u64,
103	#[serde(rename = "PIDs", default)]
104	pids: u64,
105	// `#[serde(default)]` also tolerates an explicit `null` frame value: libpod
106	// sends `"network": null` for a container with no interfaces, which would
107	// otherwise fail with `invalid type: null, expected a map`.
108	#[serde(rename = "Network", default, deserialize_with = "null_default")]
109	network: HashMap<String, NetStat>,
110}
111
112/// Per-interface network counters.
113#[derive(Deserialize, Default, Clone)]
114struct NetStat {
115	#[serde(rename = "RxBytes", default)]
116	rx: u64,
117	#[serde(rename = "TxBytes", default)]
118	tx: u64,
119}
120
121/// Render a byte count as a compact human string (`1.5MiB`). Pure for testing.
122fn format_bytes(bytes: u64) -> String {
123	const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
124	let mut value = bytes as f64;
125	let mut unit = 0;
126	while value >= 1024.0 && unit < UNITS.len() - 1 {
127		value /= 1024.0;
128		unit += 1;
129	}
130	if unit == 0 {
131		format!("{bytes}B")
132	} else {
133		format!("{value:.1}{}", UNITS[unit])
134	}
135}
136
137/// Sum a container's per-interface network counters into one `(rx, tx)` pair.
138fn net_totals(s: &ContainerStat) -> (u64, u64) {
139	s.network
140		.values()
141		.fold((0u64, 0u64), |(rx, tx), n| (rx + n.rx, tx + n.tx))
142}
143
144/// The NAME cell for the table: the full name when `no_trunc`, otherwise
145/// truncated to [`NAME_WIDTH`] with a trailing ellipsis so a long name keeps the
146/// row aligned. Counts characters (not bytes) so multi-byte names truncate
147/// safely. Pure for testing.
148fn truncate_name(name: &str, no_trunc: bool) -> String {
149	if no_trunc || name.chars().count() <= NAME_WIDTH {
150		return name.to_string();
151	}
152	let head: String = name.chars().take(NAME_WIDTH - 1).collect();
153	format!("{head}…")
154}
155
156/// Format one stats row into the table layout. With `no_trunc` a long name is
157/// left intact (and may overflow its column); otherwise it is truncated to
158/// [`NAME_WIDTH`]. Pure for testing.
159fn format_row(s: &ContainerStat, no_trunc: bool) -> String {
160	let (rx, tx) = net_totals(s);
161	format!(
162		"{:<NAME_WIDTH$} {:>7.2}% {:>10} / {:<10} {:>6.2}% {:>9} / {:<9} {:>9} / {:<9} {:>5}",
163		truncate_name(&s.name, no_trunc),
164		s.cpu,
165		format_bytes(s.mem_usage),
166		format_bytes(s.mem_limit),
167		s.mem_perc,
168		format_bytes(rx),
169		format_bytes(tx),
170		format_bytes(s.block_in),
171		format_bytes(s.block_out),
172		s.pids,
173	)
174}
175
176/// Build one `stats --format json` row with numeric values (raw bytes/percent),
177/// so machine consumers get exact figures rather than the table's rounded,
178/// human-formatted cells. Pure so it can be unit-tested.
179fn stat_json_row(s: &ContainerStat) -> serde_json::Value {
180	let (rx, tx) = net_totals(s);
181	serde_json::json!({
182		"Name": s.name,
183		"CPUPerc": s.cpu,
184		"MemUsage": s.mem_usage,
185		"MemLimit": s.mem_limit,
186		"MemPerc": s.mem_perc,
187		"NetInput": rx,
188		"NetOutput": tx,
189		"BlockInput": s.block_in,
190		"BlockOutput": s.block_out,
191		"PIDs": s.pids,
192	})
193}
194
195const HEADER: &str = "NAME                                 CPU %       MEM USAGE / LIMIT        MEM %    NET I/O             BLOCK I/O           PIDS";
196
197impl Engine {
198	/// Stream resource usage for the project's service containers (docker
199	/// `compose stats`). Streams continuously until interrupted; `no_stream`
200	/// prints a single snapshot. `target_services` narrows to specific services.
201	pub async fn stats(
202		&self,
203		file: &ComposeFile,
204		target_services: &[String],
205		no_stream: bool,
206	) -> Result<()> {
207		self.stats_with_options(
208			file,
209			target_services,
210			StatsOptions {
211				no_stream,
212				..StatsOptions::default()
213			},
214		)
215		.await
216	}
217
218	/// Stream resource usage with `docker compose stats`-style options:
219	/// `--no-stream` (single snapshot), `-a/--all` (include non-running
220	/// containers as zeroed rows), `--format` (table | json), and `--no-trunc`
221	/// (keep full container names). `target_services` narrows to specific
222	/// services.
223	pub async fn stats_with_options(
224		&self,
225		file: &ComposeFile,
226		target_services: &[String],
227		opts: StatsOptions,
228	) -> Result<()> {
229		// Reject unknown/typo service names instead of silently sampling the whole
230		// host and printing a header-only table, matching the other commands.
231		if let Some(unknown) = first_unknown_service(file, target_services) {
232			return Err(ComposeError::ServiceNotFound(unknown.into()));
233		}
234		let targets = self.target_containers(file, target_services).await?;
235
236		// Only running containers carry live samples, so scope the libpod
237		// `containers=` filter to them: a stopped/created container fed to that
238		// filter 404s the whole request. Non-running rows are synthesized locally
239		// (as zeros) when `--all` is set.
240		let running: HashSet<String> = targets
241			.iter()
242			.filter(|t| t.running)
243			.map(|t| t.name.clone())
244			.collect();
245		let stopped: Vec<String> = if opts.all {
246			targets
247				.iter()
248				.filter(|t| !t.running)
249				.map(|t| t.name.clone())
250				.collect()
251		} else {
252			Vec::new()
253		};
254
255		// Scope the stats stream to just the running containers server-side via the
256		// `containers=` query param, so the daemon does not sample every container
257		// on the host (the response is still filtered locally by `running`).
258		let containers = containers_query(&running);
259
260		if opts.no_stream || running.is_empty() {
261			// Nothing running means nothing to sample (and an empty `containers=`
262			// filter would otherwise fall back to the whole host) — skip the call
263			// and render an empty/`--all`-only frame.
264			let report = if running.is_empty() {
265				StatsReport::default()
266			} else {
267				self.client
268					.get_json(&format!(
269						"{API_PREFIX}/containers/stats?stream=false{containers}"
270					))
271					.await
272					.map_err(ComposeError::Podman)?
273			};
274			print_frame(&report, &running, &stopped, &opts);
275			return Ok(());
276		}
277
278		let resp = self
279			.client
280			.get_stream(&format!(
281				"{API_PREFIX}/containers/stats?stream=true{containers}"
282			))
283			.await
284			.map_err(ComposeError::Podman)?;
285		let mut frames = parse_json_lines::<StatsReport>(resp.into_body());
286		while let Some(frame) = frames.next().await {
287			match frame {
288				Ok(report) => print_frame(&report, &running, &stopped, &opts),
289				Err(e) => {
290					tracing::debug!("stats stream ended: {e}");
291					break;
292				}
293			}
294		}
295		Ok(())
296	}
297
298	/// The containers to report on — every existing replica of the targeted
299	/// services (all services when `target_services` is empty), paired with
300	/// whether each is currently running. Only containers that actually exist are
301	/// returned (no static-name fallback): an absent service simply contributes
302	/// no rows.
303	async fn target_containers(
304		&self,
305		file: &ComposeFile,
306		target_services: &[String],
307	) -> Result<Vec<TargetContainer>> {
308		let filters = serde_json::json!({ "label": [format!("podup.project={}", self.project)] });
309		let path = format!(
310			"{API_PREFIX}/containers/json?all=true&filters={}",
311			urlencoded(&filters.to_string()),
312		);
313		let entries = self
314			.client
315			.get_json::<Vec<ContainerListEntry>>(&path)
316			.await
317			.map_err(ComposeError::Podman)?;
318
319		let mut out = Vec::new();
320		for e in entries {
321			let service = e
322				.labels
323				.get("podup.service")
324				.map(String::as_str)
325				.unwrap_or("");
326			// Skip containers whose service the compose file no longer defines, and
327			// honour a positional `SERVICE` filter.
328			if !file.services.contains_key(service) {
329				continue;
330			}
331			if !target_services.is_empty() && !target_services.iter().any(|t| t == service) {
332				continue;
333			}
334			if let Some(raw) = e.names.first() {
335				out.push(TargetContainer {
336					name: raw.trim_start_matches('/').to_string(),
337					running: e.state == "running",
338				});
339			}
340		}
341		Ok(out)
342	}
343}
344
345/// A project container considered for `stats`, with its run state so non-running
346/// containers can be folded in (as zeroed rows) only under `--all`.
347struct TargetContainer {
348	name: String,
349	running: bool,
350}
351
352/// The first targeted service name that the compose file does not define, if any.
353/// Pure so the validation is unit-tested without a live Podman socket.
354fn first_unknown_service<'a>(file: &ComposeFile, targets: &'a [String]) -> Option<&'a str> {
355	targets
356		.iter()
357		.map(String::as_str)
358		.find(|t| !file.services.contains_key(*t))
359}
360
361/// Assemble the rows for one frame: the live samples for `running` containers
362/// plus synthesized zero rows for each `stopped` container (already empty when
363/// `--all` is off), sorted by name for stable output.
364fn frame_rows(
365	report: &StatsReport,
366	running: &HashSet<String>,
367	stopped: &[String],
368) -> Vec<ContainerStat> {
369	let mut rows: Vec<ContainerStat> = report
370		.stats
371		.iter()
372		.filter(|s| running.contains(&s.name))
373		.cloned()
374		.collect();
375	for name in stopped {
376		rows.push(ContainerStat {
377			name: name.clone(),
378			..ContainerStat::default()
379		});
380	}
381	rows.sort_by(|a, b| a.name.cmp(&b.name));
382	rows
383}
384
385/// Print one stats frame: the table (a bold header plus one row per container)
386/// or a JSON array when `--format json`. Table frames end with a blank line.
387fn print_frame(
388	report: &StatsReport,
389	running: &HashSet<String>,
390	stopped: &[String],
391	opts: &StatsOptions,
392) {
393	let rows = frame_rows(report, running, stopped);
394
395	if opts.json {
396		let json: Vec<_> = rows.iter().map(stat_json_row).collect();
397		println!(
398			"{}",
399			serde_json::to_string_pretty(&json).unwrap_or_default()
400		);
401		return;
402	}
403
404	crate::ui::print_bold_header(HEADER);
405	for s in &rows {
406		println!("{}", format_row(s, opts.no_trunc));
407	}
408	println!();
409}
410
411#[cfg(test)]
412mod tests {
413	use super::*;
414
415	#[test]
416	fn format_bytes_scales_units() {
417		assert_eq!(format_bytes(512), "512B");
418		assert_eq!(format_bytes(1024), "1.0KiB");
419		assert_eq!(format_bytes(1536), "1.5KiB");
420		assert_eq!(format_bytes(1024 * 1024), "1.0MiB");
421		assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.0GiB");
422	}
423
424	#[test]
425	fn format_row_sums_network_and_shows_name() {
426		let mut network = HashMap::new();
427		network.insert("eth0".to_string(), NetStat { rx: 1024, tx: 2048 });
428		let s = ContainerStat {
429			name: "proj-web".into(),
430			cpu: 12.5,
431			mem_usage: 1024 * 1024,
432			mem_limit: 1024 * 1024 * 1024,
433			mem_perc: 0.1,
434			block_in: 0,
435			block_out: 0,
436			pids: 3,
437			network,
438		};
439		let row = format_row(&s, false);
440		assert!(row.contains("proj-web"));
441		assert!(row.contains("12.50%"));
442		assert!(row.contains("1.0MiB"));
443		assert!(row.contains('3'));
444	}
445
446	#[test]
447	fn truncate_name_shortens_long_names_with_ellipsis() {
448		let short = "proj-web-1";
449		assert_eq!(truncate_name(short, false), short);
450		// Exactly the column width is kept verbatim.
451		let exact = "a".repeat(NAME_WIDTH);
452		assert_eq!(truncate_name(&exact, false), exact);
453		// One over the width is truncated to width-1 chars plus an ellipsis.
454		let long = "a".repeat(NAME_WIDTH + 10);
455		let cut = truncate_name(&long, false);
456		assert_eq!(cut.chars().count(), NAME_WIDTH);
457		assert!(cut.ends_with('…'));
458		// `--no-trunc` keeps the full name regardless of length.
459		assert_eq!(truncate_name(&long, true), long);
460	}
461
462	#[test]
463	fn format_row_truncates_long_name_but_no_trunc_keeps_it() {
464		let s = ContainerStat {
465			name: "really-long-project-web-container-name-1".into(),
466			..Default::default()
467		};
468		// Default: the long name is truncated (ellipsis present, full name gone).
469		let row = format_row(&s, false);
470		assert!(row.contains('…'));
471		assert!(!row.contains(&s.name));
472		// `--no-trunc`: the full name survives intact.
473		let row_full = format_row(&s, true);
474		assert!(row_full.contains(&s.name));
475	}
476
477	#[test]
478	fn stat_json_row_emits_numeric_fields() {
479		let mut network = HashMap::new();
480		network.insert("eth0".to_string(), NetStat { rx: 100, tx: 200 });
481		let s = ContainerStat {
482			name: "proj-db-1".into(),
483			cpu: 5.5,
484			mem_usage: 2048,
485			mem_limit: 4096,
486			mem_perc: 50.0,
487			block_in: 1,
488			block_out: 2,
489			pids: 7,
490			network,
491		};
492		let row = stat_json_row(&s);
493		assert_eq!(row["Name"], "proj-db-1");
494		assert_eq!(row["CPUPerc"], 5.5);
495		assert_eq!(row["MemUsage"], 2048);
496		assert_eq!(row["MemLimit"], 4096);
497		assert_eq!(row["NetInput"], 100);
498		assert_eq!(row["NetOutput"], 200);
499		assert_eq!(row["PIDs"], 7);
500	}
501
502	#[test]
503	fn frame_rows_keeps_running_and_adds_stopped_zeros_sorted() {
504		let report = StatsReport {
505			stats: vec![
506				ContainerStat {
507					name: "proj-web-1".into(),
508					cpu: 1.0,
509					..Default::default()
510				},
511				// Not in `running` → must be dropped (stale daemon sample).
512				ContainerStat {
513					name: "other".into(),
514					..Default::default()
515				},
516			],
517		};
518		let mut running = HashSet::new();
519		running.insert("proj-web-1".to_string());
520		let stopped = vec!["proj-db-1".to_string()];
521		let rows = frame_rows(&report, &running, &stopped);
522		// Sorted: db (stopped) before web (running); the unrelated sample is gone.
523		assert_eq!(rows.len(), 2);
524		assert_eq!(rows[0].name, "proj-db-1");
525		assert_eq!(rows[0].cpu, 0.0);
526		assert_eq!(rows[1].name, "proj-web-1");
527		assert_eq!(rows[1].cpu, 1.0);
528	}
529
530	#[test]
531	fn frame_rows_without_all_has_no_stopped_rows() {
532		let report = StatsReport::default();
533		let running = HashSet::new();
534		// `--all` off → caller passes an empty `stopped` slice → no rows.
535		let rows = frame_rows(&report, &running, &[]);
536		assert!(rows.is_empty());
537	}
538
539	#[test]
540	fn stat_tolerates_null_network() {
541		// libpod sends `"Network": null` for a container with no interfaces; it
542		// must deserialize to an empty map rather than erroring.
543		let json = r#"{"Name":"proj-web","CPU":1.0,"Network":null}"#;
544		let stat: ContainerStat = serde_json::from_str(json).unwrap();
545		assert_eq!(stat.name, "proj-web");
546		assert!(stat.network.is_empty());
547	}
548
549	#[test]
550	fn stat_tolerates_missing_network() {
551		let json = r#"{"Name":"proj-web"}"#;
552		let stat: ContainerStat = serde_json::from_str(json).unwrap();
553		assert!(stat.network.is_empty());
554	}
555
556	#[test]
557	fn containers_query_repeats_param_per_container() {
558		// libpod wants `containers` repeated, not comma-joined — a comma-joined
559		// list is read as a single container name and 404s.
560		let mut wanted = HashSet::new();
561		wanted.insert("proj-web-1".to_string());
562		wanted.insert("proj-db-1".to_string());
563		assert_eq!(
564			containers_query(&wanted),
565			"&containers=proj-db-1&containers=proj-web-1"
566		);
567	}
568
569	#[test]
570	fn containers_query_empty_when_none_wanted() {
571		assert_eq!(containers_query(&HashSet::new()), "");
572	}
573
574	#[test]
575	fn first_unknown_service_flags_typos() {
576		let file =
577			crate::parse_str("services:\n  web:\n    image: nginx\n  db:\n    image: postgres\n")
578				.unwrap();
579		assert_eq!(first_unknown_service(&file, &[]), None);
580		assert_eq!(
581			first_unknown_service(&file, &["web".into(), "db".into()]),
582			None
583		);
584		assert_eq!(
585			first_unknown_service(&file, &["web".into(), "bogus".into()]),
586			Some("bogus")
587		);
588	}
589}