podup 3.3.0

Translate and run docker-compose files on rootless Podman
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
//! `events` — stream Podman events scoped to the project (`docker compose
//! events`). Filters the libpod event stream by the `podup.project` label.

use futures_util::StreamExt;
use serde_json::Value;

use crate::error::{ComposeError, Result};
use crate::libpod::{urlencoded, API_PREFIX};

use super::Engine;

/// Options for [`Engine::stream_events`], mirroring `docker compose events`
/// (`--since`, `--until`, `--filter`).
#[derive(Debug, Clone, Default)]
pub struct EventsOptions {
	/// Only events at or after this timestamp/relative time (`--since`).
	pub since: Option<String>,
	/// Only events up to this timestamp/relative time (`--until`).
	pub until: Option<String>,
	/// Extra `KEY=VALUE` event filters (`--filter`, e.g. `event=start`).
	pub filters: Vec<String>,
}

impl Engine {
	/// Stream events for this project's containers. With `json`, each event is
	/// printed as a compact JSON line; otherwise as `TYPE ACTION NAME`.
	///
	/// The feed is unbounded, so it normally ends only when the caller stops it.
	/// Returning at all therefore means the stream was lost, and this returns
	/// `Err` — see [`Engine::stream_events_with_options`] for the bounded case.
	pub async fn stream_events(&self, json: bool) -> Result<()> {
		self.stream_events_with_options(json, &EventsOptions::default())
			.await
	}

	/// [`Engine::stream_events`] with `docker compose events`-style `--since`,
	/// `--until`, and `--filter` options.
	///
	/// # Errors
	///
	/// A transport failure always returns the underlying error, whatever was
	/// asked for. Beyond that, whether a *clean* ending is an error depends on
	/// what the caller asked for:
	///
	/// - **`since` and `until` both set, both already elapsed** — the window
	///   closes on its own, so a clean ending is what was asked for. Returns
	///   `Ok(())`.
	/// - **anything else** — the feed is unbounded and libpod never ends it, so
	///   any ending means the stream was lost. Returns
	///   [`ComposeError::StreamTruncated`](crate::ComposeError::StreamTruncated).
	///
	/// Also returns `Err` if a `--filter` is malformed or the stream cannot be
	/// opened.
	///
	/// A window needs **both** ends to close, and both must already have
	/// elapsed. Measured against Podman 5.4.2: `since` and `until` together
	/// close the feed, whether absolute or relative (`-2h`..`-1h`); either one
	/// alone leaves it open, as does any `until` in the future. So `--until 5m`
	/// follows indefinitely rather than stopping in five minutes, and `--until
	/// -5m` alone does too.
	pub async fn stream_events_with_options(&self, json: bool, opts: &EventsOptions) -> Result<()> {
		let filters = build_event_filters(&self.project, &opts.filters)?;
		let mut path = format!(
			"{API_PREFIX}/events?stream=true&filters={}",
			urlencoded(&filters.to_string()),
		);
		if let Some(since) = &opts.since {
			path.push_str(&format!("&since={}", urlencoded(since)));
		}
		if let Some(until) = &opts.until {
			path.push_str(&format!("&until={}", urlencoded(until)));
		}
		let resp = self
			.client
			.get_stream(&path)
			.await
			.map_err(ComposeError::Podman)?;
		let mut stream = crate::libpod::parse_json_lines::<Value>(resp.into_body());
		// Whether a clean end was expected is decided by what was asked for, not
		// by the shape of the end.
		//
		// The other streaming commands re-check the container they followed
		// (#1169, #1204, #1242). An events feed is project-scoped and follows no
		// single container, so there is nothing to re-check. What the client
		// asked for answers it instead, at no API cost.
		//
		// Keying on the request rather than on `Err` versus a clean `None`
		// matters: a closed window ends the feed *cleanly*, so an unbounded feed
		// reaching `None` is the same anomaly as one reaching `Err`, and the
		// previous code exited 0 for both.
		//
		// A window needs BOTH ends to close. Measured against 5.4.2 with curl,
		// no podup involved, `stream=true`:
		//
		//   since + until, both past, absolute   closes
		//   since + until, relative (-2h..-1h)   closes
		//   until alone, past                    stays open
		//   since alone, past                    stays open
		//
		// So `--until` on its own does not bound anything, whatever the user
		// meant by it, and treating it as intent-to-bound would call an unbounded
		// feed bounded. A future `until` never closes either, with or without
		// `since`.
		let bounded = opts.since.is_some() && opts.until.is_some();
		if opts.until.is_some() && opts.since.is_none() {
			tracing::warn!(
				"events: --until without --since does not bound the feed; libpod keeps it open. \
				 Pass both to bound a window."
			);
		}
		let mut broke: Option<crate::libpod::PodmanError> = None;
		while let Some(event) = stream.next().await {
			match event {
				Ok(value) => println!("{}", format_event(&value, json)),
				Err(e) => {
					tracing::warn!("events: stream ended early [{}]: {e}", e.stream_end_kind());
					broke = Some(e);
					break;
				}
			}
		}
		// A transport failure is never expected, whatever was asked for. Intent
		// says whether an *ending* was expected; it cannot make a severed socket
		// expected. Reading it as "bounded means always fine" inverted the whole
		// point of #1104 on the scriptable path: `--until` with `--format json`
		// is what a script uses, and it would have truncated its window and
		// reported success, while the interactive unbounded form got the strict
		// check.
		if let Some(e) = broke {
			return Err(ComposeError::Podman(e));
		}
		if bounded {
			return Ok(());
		}
		// An unbounded feed that ended cleanly still lost its connection: libpod
		// does not end one, so reaching here at all is the anomaly.
		Err(ComposeError::StreamTruncated(
			"events stream ended on its own: an unbounded feed only ends when the client stops it"
				.to_string(),
		))
	}
}

/// Build the libpod events `filters` object: always scope to this project's
/// `podup.project` label, then merge each user `KEY=VALUE` predicate (appending
/// to that key's value array). Pure so the merge is unit-tested.
///
/// A predicate with no `=` is an error rather than a skip. Dropping it scoped
/// the stream to the whole project instead — `events --filter garbage` printed
/// everything, which a caller reads as "these all matched". docker compose
/// errors on a malformed filter too.
fn build_event_filters(project: &str, user_filters: &[String]) -> Result<Value> {
	use serde_json::{Map, Value};
	let mut map: Map<String, Value> = Map::new();
	map.insert(
		"label".to_string(),
		Value::Array(vec![Value::String(format!("podup.project={project}"))]),
	);
	for f in user_filters {
		let Some((key, value)) = f.split_once('=') else {
			return Err(ComposeError::Unsupported(format!(
				"malformed events filter {f:?}: expected KEY=VALUE (e.g. event=start)"
			)));
		};
		match map
			.entry(key.to_string())
			.or_insert_with(|| Value::Array(Vec::new()))
		{
			Value::Array(arr) => arr.push(Value::String(value.to_string())),
			other => *other = Value::Array(vec![Value::String(value.to_string())]),
		}
	}
	Ok(Value::Object(map))
}

/// Render one event. `json` emits the raw object as a compact line; otherwise a
/// `TYPE ACTION NAME` summary, tolerant of both the docker-compat shape
/// (`Type`/`Action`/`Actor.Attributes.name`) and the libpod-native one
/// (`status`/`id`).
fn format_event(value: &Value, json: bool) -> String {
	if json {
		return serde_json::to_string(value).unwrap_or_default();
	}
	let typ = value.get("Type").and_then(Value::as_str).unwrap_or("");
	let action = value
		.get("Action")
		.or_else(|| value.get("status"))
		.and_then(Value::as_str)
		.unwrap_or("");
	let name = value
		.pointer("/Actor/Attributes/name")
		.or_else(|| value.get("id"))
		.and_then(Value::as_str)
		.unwrap_or("");
	format_event_line(typ, action, name, crate::ui::stdout_colored() && !json)
}

/// Join an event's three fields, tinting the two that carry meaning.
///
/// A `--follow` stream is a wall of near-identical lines; `ACTION` is what
/// distinguishes a `start` from a `die`, and `NAME` is which container it
/// happened to. The type (`container`, `network`) repeats on almost every line
/// and is dimmed so it stops competing.
fn format_event_line(typ: &str, action: &str, name: &str, colour: bool) -> String {
	use crate::ui::{identity_style, paint, Style};
	let typ = paint(Style::new().dimmed(), typ, colour);
	let action = match crate::ui::action_or_status_style(action) {
		Some(style) => paint(style, action, colour),
		None => action.to_string(),
	};
	let name = paint(identity_style(name), name, colour && !name.is_empty());
	format!("{typ} {action} {name}").trim().to_string()
}

#[cfg(test)]
mod tests {
	use super::{build_event_filters, format_event};
	use serde_json::json;

	#[test]
	fn build_event_filters_scopes_to_project_label() {
		let f = build_event_filters("demo", &[]).unwrap();
		assert_eq!(f, json!({ "label": ["podup.project=demo"] }));
	}

	#[test]
	fn build_event_filters_merges_user_predicates() {
		let f = build_event_filters(
			"demo",
			&[
				"event=start".to_string(),
				"event=die".to_string(),
				"type=container".to_string(),
			],
		)
		.unwrap();
		assert_eq!(
			f,
			json!({
				"label": ["podup.project=demo"],
				"event": ["start", "die"],
				"type": ["container"],
			})
		);
	}

	/// #1081: a predicate with no `=` used to be dropped, so `events --filter
	/// garbage` silently scoped to the whole project and printed everything — a
	/// caller reads that back as "these all matched".
	#[test]
	fn malformed_filter_is_rejected_not_dropped() {
		let err = build_event_filters("demo", &["bogus".to_string()])
			.expect_err("a filter with no `=` must not be silently ignored");
		assert!(format!("{err}").contains("bogus"), "got {err}");
	}

	#[test]
	fn formats_docker_compat_shape() {
		let ev = json!({
			"Type": "container",
			"Action": "start",
			"Actor": { "Attributes": { "name": "web-1" } },
		});
		assert_eq!(format_event(&ev, false), "container start web-1");
	}

	#[test]
	fn formats_libpod_native_shape() {
		let ev = json!({ "Type": "container", "status": "die", "id": "abc123" });
		assert_eq!(format_event(&ev, false), "container die abc123");
	}

	#[test]
	fn json_mode_emits_raw_object() {
		let ev = json!({ "Type": "container", "Action": "start" });
		let out = format_event(&ev, true);
		assert!(out.contains("\"Type\":\"container\""));
		assert!(out.contains("\"Action\":\"start\""));
	}
}

#[cfg(test)]
mod event_colour_tests {
	use super::format_event_line;

	/// Without a colour sink the line is byte-identical to what it always was,
	/// so `--json`, a pipe and the output contract are untouched.
	#[test]
	fn plain_output_is_unchanged() {
		assert_eq!(
			format_event_line("container", "start", "proj-web-1", false),
			"container start proj-web-1"
		);
	}

	/// The two fields that distinguish one line from the next carry colour; the
	/// type, which repeats on nearly every line, is dimmed rather than absent.
	#[test]
	fn action_and_name_are_tinted_apart() {
		let died = format_event_line("container", "die", "proj-web-1", true);
		let started = format_event_line("container", "start", "proj-web-1", true);
		assert_ne!(
			died,
			started.replace("start", "die"),
			"die and start must differ by more than the verb"
		);
	}

	/// An event with no container name must not emit a stray colour reset.
	#[test]
	fn an_empty_name_is_not_painted() {
		let out = format_event_line("network", "create", "", true);
		assert!(
			out.ends_with("create\u{1b}[0m") || !out.ends_with("\u{1b}[0m "),
			"{out:?}"
		);
	}
}

/// Whether an events feed that ended did so because it was asked to.
///
/// The four other streaming commands re-check the container they followed. An
/// events feed follows none, so the discriminator is intent — which the client
/// knows without an API call.
#[cfg(test)]
#[cfg(unix)]
mod stream_end_tests {
	use crate::engine::fake_podman::{self, FakeReply};
	use crate::engine::{Engine, EventsOptions};

	fn engine(fake: &fake_podman::FakePodman) -> Engine {
		Engine::with_base_dir(fake.client(), "proj".into(), std::env::temp_dir())
	}

	/// One event, then the body ends the way the server chose.
	fn fake(reply: fn() -> FakeReply) -> fake_podman::FakePodman {
		fake_podman::start_replying(move |_method, _target| reply())
	}

	fn one_event() -> Vec<String> {
		vec![r#"{"Type":"container","Action":"start","id":"abc"}"#.to_string()]
	}

	/// Both ends of an elapsed window, which is the only form libpod closes.
	fn bounded() -> EventsOptions {
		EventsOptions {
			since: Some("2026-01-01T00:00:00Z".to_string()),
			until: Some("2026-01-01T01:00:00Z".to_string()),
			..Default::default()
		}
	}

	#[tokio::test]
	async fn an_unbounded_feed_that_ends_cleanly_is_still_a_failure() {
		// The case no error-shaped check could catch: the server closed the body
		// properly, so the parser reports a clean end, and this used to exit 0.
		let fake = fake(|| FakeReply::ChunkedEnd(one_event()));
		let err = engine(&fake)
			.stream_events_with_options(false, &EventsOptions::default())
			.await
			.expect_err("only the client ends an unbounded feed, so any end is unexpected");
		assert!(
			matches!(err, crate::error::ComposeError::StreamTruncated(_)),
			"expected the intent verdict, got {err:?}"
		);
	}

	#[tokio::test]
	async fn an_unbounded_feed_cut_mid_body_is_a_failure() {
		let fake = fake(|| FakeReply::ChunkedTruncated(one_event()));
		let err = engine(&fake)
			.stream_events_with_options(false, &EventsOptions::default())
			.await
			.expect_err("a severed unbounded feed is a failure too");
		assert!(
			matches!(err, crate::error::ComposeError::Podman(_)),
			"the transport error must survive so the operator sees the cause, got {err:?}"
		);
	}

	#[tokio::test]
	async fn a_bounded_feed_that_ends_is_success() {
		// `--until` is the client saying the window closes on its own, so the end
		// is what was asked for. Measured on 5.4.2: an already-elapsed window does
		// end the feed cleanly.
		let fake = fake(|| FakeReply::ChunkedEnd(one_event()));
		engine(&fake)
			.stream_events_with_options(false, &bounded())
			.await
			.expect("a bounded feed reaching the end of its window succeeded");
	}

	/// `--until` alone does not bound anything: measured against 5.4.2, libpod
	/// leaves the feed open without a `since` to pair it with. Treating it as
	/// bounded would call an unbounded feed bounded and hand back a success.
	#[tokio::test]
	async fn until_without_since_is_not_a_bounded_feed() {
		let fake = fake(|| FakeReply::ChunkedEnd(one_event()));
		let opts = EventsOptions {
			until: Some("2026-01-01T00:00:00Z".to_string()),
			..Default::default()
		};
		let err = engine(&fake)
			.stream_events_with_options(false, &opts)
			.await
			.expect_err("until alone leaves the feed unbounded, so any end is unexpected");
		assert!(
			matches!(err, crate::error::ComposeError::StreamTruncated(_)),
			"expected the unbounded verdict, got {err:?}"
		);
	}

	#[tokio::test]
	async fn a_bounded_feed_cut_mid_body_is_a_failure() {
		// Intent says whether an *ending* was expected. It cannot make a severed
		// socket expected, and this is the case that matters most: `--until` with
		// `--format json` is the scriptable form, so swallowing the error here
		// would truncate a window and report success on exactly the path a script
		// trusts, while the interactive unbounded form kept the strict check.
		// That inverts #1104 rather than completing it.
		let fake = fake(|| FakeReply::ChunkedTruncated(one_event()));
		let err = engine(&fake)
			.stream_events_with_options(false, &bounded())
			.await
			.expect_err("a severed window is a failed read, bounded or not");
		assert!(
			matches!(err, crate::error::ComposeError::Podman(_)),
			"the transport error must survive so the operator sees the cause, got {err:?}"
		);
	}
}