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
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
//! `exec` command: run a command inside a service container.

use std::io::Write;

use futures_util::StreamExt;

use crate::compose::types::ComposeFile;
use crate::error::{ComposeError, Result};
use crate::libpod::types::exec::{
	ExecCreateConfig, ExecCreateResponse, ExecInspect, ExecStartConfig,
};
use crate::libpod::{urlencoded, LogOutput, API_PREFIX};

use super::Engine;

/// Ceiling on how long to wait for the libpod exec-start *response head*. A
/// healthy engine returns it almost instantly — either the hijacked stream or a
/// prompt error (e.g. HTTP 500 "unable to find user … no matching entries in
/// passwd file"). When the target user/workdir does not resolve, some engine
/// builds instead stall before answering, which the default client read timeout
/// would only abort after ~120s and then report as a misleading socket-timeout.
/// Bounding the head here lets [`Engine::exec_with_options`] fail fast with a
/// clear, exec-specific message. It covers only the head; the streamed exec
/// output is left unbounded so a legitimate long-running command runs to
/// completion.
const EXEC_START_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);

/// Options for [`Engine::exec`], mirroring `docker compose exec` flags.
///
/// `#[non_exhaustive]`: built with `..Default::default()`, so adding a flag as
/// the exec surface grows is not another breaking change. The two autostart
/// option structs learned the same lesson in the same release.
#[derive(Default)]
#[non_exhaustive]
pub struct ExecOptions {
	/// Extra environment variables (`KEY=VAL`), `-e/--env`.
	pub env: Vec<String>,
	/// Run as this user, `-u/--user`.
	pub user: Option<String>,
	/// Working directory inside the container, `-w/--workdir`.
	pub workdir: Option<String>,
	/// Run with extended privileges, `--privileged`.
	pub privileged: bool,
	/// Detach: start the exec and return without streaming output, `-d/--detach`.
	pub detach: bool,
	/// 1-based replica index for a scaled service, `--index` (default: first).
	pub index: Option<u32>,
	/// Suppress the pseudo-terminal, `-T/--no-tty`.
	///
	/// Interactive is the default when stdin is a terminal, matching
	/// `docker compose exec`; this is how a caller opts out. It is also what a
	/// non-terminal stdin implies, so a scripted `exec` behaves the same with or
	/// without the flag.
	pub no_tty: bool,
}

impl ExecOptions {
	/// Run the command as this user, `-u/--user`. Builder-style.
	pub fn with_user(mut self, user: Option<String>) -> Self {
		self.user = user;
		self
	}

	/// Working directory inside the container, `-w/--workdir`. Builder-style.
	pub fn with_workdir(mut self, workdir: Option<String>) -> Self {
		self.workdir = workdir;
		self
	}

	#[cfg(test)]
	fn with_no_tty_for_test(mut self, v: bool) -> Self {
		self.no_tty = v;
		self
	}

	#[cfg(test)]
	fn with_detach_for_test(mut self, v: bool) -> Self {
		self.detach = v;
		self
	}

	/// Target this replica (1-based) of a scaled service, `--index`.
	/// Builder-style.
	pub fn with_index(mut self, index: Option<u32>) -> Self {
		self.index = index;
		self
	}

	/// Extra environment variables (`KEY=VAL`), `-e/--env`. Builder-style.
	pub fn with_env(mut self, env: Vec<String>) -> Self {
		self.env = env;
		self
	}

	/// Every `docker compose exec` flag, in CLI order. A constructor rather than
	/// a struct literal because the type is `#[non_exhaustive]`, so the next flag
	/// to land is not a breaking change for anyone building one.
	#[allow(clippy::too_many_arguments)]
	pub fn new(
		env: Vec<String>,
		user: Option<String>,
		workdir: Option<String>,
		privileged: bool,
		detach: bool,
		index: Option<u32>,
		no_tty: bool,
	) -> Self {
		Self {
			env,
			user,
			workdir,
			privileged,
			detach,
			index,
			no_tty,
		}
	}
}

/// Build the exec environment list, expanding a bare `KEY` (no `=`) from podup's
/// own environment the way docker-compose does. A `KEY=VALUE` entry passes
/// through unchanged; a value-less `KEY` is replaced with `KEY=<host value>` and
/// dropped entirely when the variable is unset (libpod rejects a bare key with
/// HTTP 400). Pure so it is unit-tested without a container.
fn expand_exec_env(env: &[String]) -> Vec<String> {
	env.iter()
		.filter_map(|e| {
			if e.contains('=') {
				Some(e.clone())
			} else {
				std::env::var(e).ok().map(|v| format!("{e}={v}"))
			}
		})
		.collect()
}

/// True for the in-band stream-teardown line Podman/conmon emits on the exec
/// stderr channel when an exec launch fails (e.g. a bad `--workdir`/`--user`):
/// a secondary `read unixpacket ... connection reset by peer` frame that adds
/// nothing to the real diagnostic. Matching is deliberately narrow so ordinary
/// program output is never suppressed.
fn is_exec_teardown_noise(line: &str) -> bool {
	line.contains("unixpacket") && line.contains("connection reset by peer")
}

/// Map a libpod error from an `exec` target into a friendly
/// [`ComposeError::NotRunning`] when it means the container is absent (404) or
/// stopped ("can only create exec sessions on running containers"), so the user
/// sees "service X is not running" instead of a raw HTTP 404/500. Any other
/// failure passes through unchanged. Pure so it is unit-tested.
fn map_not_running(e: crate::libpod::PodmanError, service_name: &str) -> ComposeError {
	let not_running = e.is_status(404)
		|| matches!(
			&e,
			crate::libpod::PodmanError::Api { message, .. }
				if {
					let m = message.to_ascii_lowercase();
					m.contains("can only create exec sessions on running containers")
						|| m.contains("is not running")
						|| m.contains("no such container")
				}
		);
	if not_running {
		ComposeError::NotRunning(service_name.to_string())
	} else {
		ComposeError::Podman(e)
	}
}

/// Translate a failure *starting* the exec session into a clear error. A
/// client-side timeout means the libpod exec-start never returned its response
/// head within [`EXEC_START_TIMEOUT`] — almost always a wedged launch (e.g. a
/// nonexistent `--user`/`--workdir` the server stalls resolving) rather than an
/// unhealthy socket, so surface that with the likely cause instead of the
/// generic "timed out waiting for the Podman socket" message. Every other error
/// — including the prompt HTTP error an engine *does* return for a bad user
/// ("unable to find user … no matching entries in passwd file") — passes through
/// unchanged so legitimate diagnostics are never masked. Pure so it is
/// unit-tested.
fn map_exec_start_err(e: crate::libpod::PodmanError, opts: &ExecOptions) -> ComposeError {
	if e.is_timeout() {
		let cause = if let Some(user) = &opts.user {
			format!(" (the requested user '{user}' may not exist in the container)")
		} else if let Some(dir) = &opts.workdir {
			format!(" (the requested working directory '{dir}' may not exist)")
		} else {
			String::new()
		};
		return ComposeError::ExecFailed(format!(
			"the exec session did not start within {}s{cause}",
			EXEC_START_TIMEOUT.as_secs()
		));
	}
	ComposeError::Podman(e)
}

impl Engine {
	/// Run a command in the first replica of the named service with default
	/// options. Exits with the command's exit code.
	pub async fn exec(
		&self,
		file: &ComposeFile,
		service_name: &str,
		cmd: Vec<String>,
	) -> Result<()> {
		self.exec_with_options(file, service_name, cmd, ExecOptions::default())
			.await
	}

	/// Run a command in a service container with `docker compose exec`-style
	/// overrides (env, user, workdir, privileged, detach, replica index).
	pub async fn exec_with_options(
		&self,
		file: &ComposeFile,
		service_name: &str,
		cmd: Vec<String>,
		opts: ExecOptions,
	) -> Result<()> {
		// An empty command would be forwarded as an empty `cmd` and surface a raw
		// podman HTTP 500; reject it up front with a clear message.
		if cmd.is_empty() {
			return Err(ComposeError::Unsupported(
				"exec: a command is required".into(),
			));
		}
		let service = file
			.services
			.get(service_name)
			.ok_or_else(|| ComposeError::ServiceNotFound(service_name.into()))?;
		// Resolve the target replica against the *running* containers (matching
		// `cp`), so `--index N` and a bare `exec` reach a live replica of a service
		// scaled by an earlier `up --scale`/`scale` — not just the compose static
		// count. `--index 0`/out-of-range indexes stay rejected consistently.
		let container_name = self
			.live_replica_name_at(service_name, service, opts.index)
			.await?;

		// Interactive when there is a terminal to be interactive with, unless the
		// caller opted out or detached. This is `docker compose exec`'s rule: no
		// `-i` flag, because a TTY on both ends is the default and `-T` is how you
		// say no. A non-terminal stdin (a script, a pipeline) takes the
		// unchanged streaming path, so nothing about scripted `exec` moves.
		let interactive = interactive_exec(&opts);

		let env = expand_exec_env(&opts.env);
		let exec_cfg = ExecCreateConfig {
			cmd: Some(cmd),
			attach_stdout: Some(true),
			attach_stderr: Some(true),
			attach_stdin: interactive.then_some(true),
			tty: interactive.then_some(true),
			user: opts.user.clone(),
			working_dir: opts.workdir.clone(),
			privileged: opts.privileged.then_some(true),
			env: (!env.is_empty()).then_some(env),
		};
		let create_path = format!(
			"{API_PREFIX}/containers/{}/exec",
			urlencoded(&container_name),
		);
		let resp: ExecCreateResponse = self
			.client
			.post_json(&create_path, &exec_cfg)
			.await
			.map_err(|e| map_not_running(e, service_name))?;
		let exec_id = resp.id;

		// `-d/--detach`: start the exec and return without streaming output or
		// waiting for the exit code. The server returns immediately, so the
		// response body is dropped.
		if opts.detach {
			let start_cfg = ExecStartConfig {
				detach: true,
				tty: false,
			};
			let start_path = format!("{API_PREFIX}/exec/{}/start", urlencoded(&exec_id));
			let _ = self
				.client
				.post_json_stream_within(&start_path, &start_cfg, Some(EXEC_START_TIMEOUT))
				.await
				.map_err(|e| map_exec_start_err(e, &opts))?;
			return Ok(());
		}

		// The interactive path takes over here: it needs the connection kept open
		// in both directions, which the request/response client cannot give it.
		if interactive {
			self.exec_interactive(&exec_id).await?;
			return self.exec_exit_status(&exec_id).await;
		}

		let start_cfg = ExecStartConfig {
			detach: false,
			tty: false,
		};
		let start_path = format!("{API_PREFIX}/exec/{}/start", urlencoded(&exec_id));
		let start_resp = self
			.client
			.post_json_stream_within(&start_path, &start_cfg, Some(EXEC_START_TIMEOUT))
			.await
			.map_err(|e| map_exec_start_err(e, &opts))?;
		let mut stream = crate::libpod::parse_multiplexed(start_resp.into_body());

		// Lock stdout once for the whole stream instead of re-acquiring the lock
		// (and issuing a syscall) per frame; stdout is ours exclusively on this
		// path. stderr is locked per frame because the tracing subscriber also
		// writes there: holding its lock across the await loop would starve
		// concurrent log emissions. Flush after each frame so exec streams
		// promptly.
		{
			let mut out = std::io::stdout().lock();
			while let Some(msg) = stream.next().await {
				match msg.map_err(ComposeError::Podman)? {
					LogOutput::StdOut { message } => {
						let _ = out.write_all(String::from_utf8_lossy(&message).as_bytes());
						let _ = out.flush();
					}
					LogOutput::StdErr { message } => {
						let text = String::from_utf8_lossy(&message);
						// Drop the spurious connection-reset teardown frame an OCI
						// exec launch-failure emits, so only the real diagnostic shows.
						if is_exec_teardown_noise(&text) {
							continue;
						}
						let mut err = std::io::stderr().lock();
						let _ = err.write_all(text.as_bytes());
						let _ = err.flush();
					}
				}
			}
		}

		self.exec_exit_status(&exec_id).await
	}

	/// Read the session's exit code and turn a non-zero one into
	/// `RunExited`, so `podup exec` propagates the command's status the way
	/// `docker compose exec` does. Shared by the streaming and interactive
	/// paths — an interactive shell that exits 1 must still exit 1.
	async fn exec_exit_status(&self, exec_id: &str) -> Result<()> {
		let inspect_path = format!("{API_PREFIX}/exec/{}/json", urlencoded(exec_id));
		let inspect: ExecInspect = self
			.client
			.get_json(&inspect_path)
			.await
			.map_err(ComposeError::Podman)?;
		if let Some(code) = inspect.exit_code {
			if code != 0 {
				return Err(ComposeError::RunExited(code));
			}
		}
		Ok(())
	}
}

/// Whether this exec should get a pseudo-terminal and a live stdin.
///
/// Three things must all hold: the caller did not pass `-T`, the caller did not
/// detach, and stdin is actually a terminal. The last is what keeps a scripted
/// `podup exec` on the unchanged streaming path — allocating a pty for a
/// pipeline would change output framing for every existing script.
///
/// Pure except for the `isatty` probe, which is behind `stdin_is_terminal` so
/// the decision itself is unit-testable.
fn interactive_exec(opts: &ExecOptions) -> bool {
	// Both ends, not just stdin. A pty merges stdout and stderr and writes
	// CRLF, so `podup exec db psql > out.txt` typed at a shell — stdin still a
	// terminal, stdout a file — wrote pty bytes with stderr folded in where it
	// used to write clean demultiplexed output. Measured against
	// `docker compose`, which keeps that redirect clean.
	wants_interactive(opts, stdin_is_terminal() && stdout_is_terminal())
}

/// The decision itself, with the environment passed in.
///
/// Split out so it can be tested: reading stdin here would make every
/// assertion depend on how `cargo test` happened to be invoked.
fn wants_interactive(opts: &ExecOptions, stdin_is_tty: bool) -> bool {
	!opts.no_tty && !opts.detach && stdin_is_tty
}

/// Whether stdout is a terminal.
///
/// A pty merges stdout and stderr and writes CRLF, so allocating one when
/// stdout is redirected changes the bytes a file receives. Asked alongside
/// stdin, never instead of it. On a native Windows console this agrees with the
/// `GetConsoleMode` probe raw mode uses; under an emulated pty (MSYS/Cygwin,
/// e.g. Git Bash) `IsTerminal` can say terminal where raw mode cannot engage,
/// in which case the session degrades to plain streaming rather than failing.
pub(crate) fn stdout_is_terminal() -> bool {
	use std::io::IsTerminal;
	std::io::stdout().is_terminal()
}

/// Whether stdin is a terminal — the gate that keeps a scripted `exec` on the
/// unchanged streaming path.
pub(crate) fn stdin_is_terminal() -> bool {
	use std::io::IsTerminal;
	std::io::stdin().is_terminal()
}

#[cfg(test)]
mod interactive_decision_tests {
	use super::{wants_interactive, ExecOptions};

	/// #1079: `-T` opts out, matching `docker compose exec` — which has no `-i`
	/// because a TTY on both ends is the default.
	#[test]
	fn no_tty_flag_disables_the_pty() {
		let opts = ExecOptions::default().with_no_tty_for_test(true);
		assert!(!wants_interactive(&opts, true));
	}

	/// `-d` detaches, so there is nobody to be interactive with.
	#[test]
	fn detach_disables_the_pty() {
		let opts = ExecOptions::default().with_detach_for_test(true);
		assert!(!wants_interactive(&opts, true));
	}

	/// The decisive one for existing users: with stdin not a terminal — any
	/// script or pipeline — `exec` stays on the unchanged streaming path.
	/// Allocating a pty there would change output framing for every script that
	/// already calls `podup exec`.
	#[test]
	fn a_non_terminal_stdin_stays_on_the_streaming_path() {
		assert!(!wants_interactive(&ExecOptions::default(), false));
	}

	/// And the positive case, which the old ambient-stdin test could never
	/// assert: defaults plus a terminal is what turns the pty on.
	#[test]
	fn a_terminal_stdin_with_defaults_is_interactive() {
		assert!(wants_interactive(&ExecOptions::default(), true));
	}
}

#[cfg(test)]
mod tests {
	use super::{
		expand_exec_env, is_exec_teardown_noise, map_exec_start_err, map_not_running, ExecOptions,
		EXEC_START_TIMEOUT,
	};

	#[test]
	fn expand_exec_env_passes_through_key_value() {
		let out = expand_exec_env(&["FOO=bar".to_string(), "BAZ=qux".to_string()]);
		assert_eq!(out, vec!["FOO=bar".to_string(), "BAZ=qux".to_string()]);
	}

	#[test]
	fn expand_exec_env_resolves_bare_key_from_host() {
		// A bare `KEY` takes its value from podup's own environment; an unset bare
		// key is dropped (libpod rejects a value-less env entry).
		std::env::set_var("PODUP_TEST_EXEC_ENV", "from-host");
		let out = expand_exec_env(&[
			"PODUP_TEST_EXEC_ENV".to_string(),
			"PODUP_TEST_EXEC_UNSET_ENV".to_string(),
		]);
		std::env::remove_var("PODUP_TEST_EXEC_ENV");
		assert_eq!(out, vec!["PODUP_TEST_EXEC_ENV=from-host".to_string()]);
	}

	#[test]
	fn teardown_noise_matches_only_connection_reset_frame() {
		assert!(is_exec_teardown_noise(
			"read unixpacket @->/run/...: read: connection reset by peer"
		));
		// Ordinary program output is never suppressed.
		assert!(!is_exec_teardown_noise("connection reset by peer"));
		assert!(!is_exec_teardown_noise("hello world"));
	}

	#[test]
	fn map_not_running_maps_404_and_stopped() {
		use crate::error::ComposeError;
		use crate::libpod::PodmanError;
		let e404 = PodmanError::Api {
			status: 404,
			message: "no such container: web".into(),
		};
		assert!(matches!(
			map_not_running(e404, "web"),
			ComposeError::NotRunning(s) if s == "web"
		));
		let e500 = PodmanError::Api {
			status: 500,
			message: "can only create exec sessions on running containers".into(),
		};
		assert!(matches!(
			map_not_running(e500, "web"),
			ComposeError::NotRunning(_)
		));
		// An unrelated error passes through unchanged.
		let other = PodmanError::Api {
			status: 500,
			message: "disk full".into(),
		};
		assert!(matches!(
			map_not_running(other, "web"),
			ComposeError::Podman(_)
		));
	}

	#[test]
	fn exec_start_timeout_with_user_names_the_user() {
		use crate::libpod::PodmanError;
		// A client-side head timeout (the wedged-launch symptom) becomes a clear,
		// fast ExecFailed naming the likely culprit — never the raw socket-timeout.
		let timeout = PodmanError::Api {
			status: 0,
			message: format!(
				"timed out after {}s waiting for the Podman socket to respond",
				EXEC_START_TIMEOUT.as_secs()
			),
		};
		let opts = ExecOptions {
			user: Some("doesnotexist".into()),
			..Default::default()
		};
		let mapped = map_exec_start_err(timeout, &opts);
		match mapped {
			crate::error::ComposeError::ExecFailed(msg) => {
				assert!(msg.contains("doesnotexist"), "got {msg}");
				assert!(msg.contains("did not start"), "got {msg}");
				assert!(
					!msg.to_ascii_lowercase().contains("podman socket"),
					"must not leak the socket-timeout wording: {msg}"
				);
			}
			other => panic!("expected ExecFailed, got {other:?}"),
		}
	}

	#[test]
	fn exec_start_timeout_without_user_names_the_workdir() {
		use crate::libpod::PodmanError;
		let timeout = PodmanError::Api {
			status: 0,
			message: "timed out after 20s waiting for the Podman socket to respond".into(),
		};
		let opts = ExecOptions {
			workdir: Some("/no/such/dir".into()),
			..Default::default()
		};
		match map_exec_start_err(timeout, &opts) {
			crate::error::ComposeError::ExecFailed(msg) => {
				assert!(msg.contains("/no/such/dir"), "got {msg}");
			}
			other => panic!("expected ExecFailed, got {other:?}"),
		}
	}

	#[test]
	fn exec_start_real_api_error_passes_through() {
		use crate::error::ComposeError;
		use crate::libpod::PodmanError;
		// The prompt HTTP error an engine returns for a bad user is a genuine
		// diagnostic and must reach the user verbatim, not be rewritten.
		let api = PodmanError::Api {
			status: 500,
			message: "unable to find user doesnotexist: no matching entries in passwd file".into(),
		};
		let opts = ExecOptions {
			user: Some("doesnotexist".into()),
			..Default::default()
		};
		match map_exec_start_err(api, &opts) {
			ComposeError::Podman(e) => {
				assert!(e.to_string().contains("no matching entries in passwd file"));
			}
			other => panic!("expected Podman passthrough, got {other:?}"),
		}
	}
}