podup 1.7.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
//! Error type for libpod REST API calls.

use std::fmt;

/// Errors from the Podman libpod REST API client.
#[derive(Debug)]
pub enum PodmanError {
	/// I/O or socket connection error.
	Connect(std::io::Error),
	/// Hyper HTTP error.
	Hyper(hyper::Error),
	/// JSON serialization or deserialization error.
	Json(serde_json::Error),
	/// Podman API returned an error response (4xx/5xx).
	Api { status: u16, message: String },
	/// The reachable Podman server speaks a libpod API version below the minimum
	/// podup supports. Carries the version string the server reported (empty when
	/// the server sent no `Libpod-API-Version` header).
	IncompatibleApiVersion { reported: String },
}

impl fmt::Display for PodmanError {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
		match self {
			Self::Connect(e) => write!(f, "podman socket connection error: {e}"),
			Self::Hyper(e) => write!(f, "http error: {e}"),
			Self::Json(e) => write!(f, "json error: {e}"),
			Self::Api { status, message } => match conflict_hint(message) {
				Some(hint) => write!(f, "{hint} (podman: {message})"),
				None => write!(f, "podman API error (HTTP {status}): {message}"),
			},
			Self::IncompatibleApiVersion { reported } => {
				let reported = if reported.is_empty() {
					"an unknown version"
				} else {
					reported.as_str()
				};
				write!(
					f,
					"podup requires Podman >= 5.0; this server reports libpod API version {reported}"
				)
			}
		}
	}
}

/// A short, actionable hint for the common Podman container state-conflict
/// errors, so the CLI leads with plain guidance instead of the raw HTTP message
/// (which still follows in parentheses). Returns `None` for anything unrecognised
/// so the original message is shown verbatim. Pure, so it is unit-tested.
fn conflict_hint(message: &str) -> Option<&'static str> {
	let m = message.to_ascii_lowercase();
	if m.contains("without force") || (m.contains("cannot remove") && m.contains("running")) {
		// Podman's removal refusal wording is the same for a running and a paused
		// container ("running or paused containers cannot be removed without
		// force"); the state is only in the leading "as it is paused/running"
		// clause. Match that so a paused container is not mislabelled as running.
		if m.contains("is paused") {
			Some("the container is paused — unpause it first, or pass `-f` to force removal")
		} else {
			Some("the container is running — stop it first, or pass `-f` to force removal")
		}
	} else if m.contains("already paused") {
		Some("the container is already paused")
	} else if m.contains("not paused") {
		Some("the container is not paused")
	} else if m.contains("not running")
		|| m.contains("can only kill running containers")
		|| m.contains("can only create exec sessions on running containers")
	{
		// kill/exec against a stopped container, plus the generic "not running".
		Some("the container is not running")
	} else if m.contains("already running") {
		Some("the container is already running")
	} else if m.contains("must be in created or stopped state")
		|| (m.contains("unable to start") && m.contains("state"))
	{
		// start of a container that is not in a startable state (e.g. paused).
		Some("the container cannot be started in its current state")
	} else if m.contains("container state improper") {
		// restart/other ops that podman rejects with the generic state message.
		Some("the container is not in a valid state for this operation")
	} else {
		None
	}
}

impl std::error::Error for PodmanError {
	fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
		match self {
			Self::Connect(e) => Some(e),
			Self::Hyper(e) => Some(e),
			Self::Json(e) => Some(e),
			Self::Api { .. } | Self::IncompatibleApiVersion { .. } => None,
		}
	}
}

impl From<std::io::Error> for PodmanError {
	fn from(e: std::io::Error) -> Self {
		Self::Connect(e)
	}
}

impl From<hyper::Error> for PodmanError {
	fn from(e: hyper::Error) -> Self {
		Self::Hyper(e)
	}
}

impl From<serde_json::Error> for PodmanError {
	fn from(e: serde_json::Error) -> Self {
		Self::Json(e)
	}
}

/// Whether an API error has the given HTTP status code.
impl PodmanError {
	/// True if this is an API error carrying the given HTTP status code.
	pub fn is_status(&self, code: u16) -> bool {
		matches!(self, Self::Api { status, .. } if *status == code)
	}

	/// True if this is a client-side timeout (the request was aborted because the
	/// socket never responded within the deadline). These carry a synthetic
	/// status `0` and a `timed out` message; lifecycle callers use this to
	/// escalate a wedged `stop` to an explicit `SIGKILL`.
	pub(crate) fn is_timeout(&self) -> bool {
		matches!(self, Self::Api { status: 0, message } if message.contains("timed out"))
	}

	/// True if this is the libpod 409 returned when `kill` targets a container
	/// that is not running ("can only kill running containers …"). `docker
	/// compose kill` is best-effort across all targets, so this is treated as an
	/// idempotent no-op rather than a fatal error that aborts the loop. The
	/// message is unique to the kill endpoint, so matching it cannot mask another
	/// op's 409.
	pub(crate) fn is_kill_of_stopped(&self) -> bool {
		matches!(
			self,
			Self::Api { status: 409, message }
				if message.to_ascii_lowercase().contains("can only kill running")
		)
	}

	/// True if this API error reports that the resource already exists: an HTTP
	/// 409 conflict, or an HTTP 500 whose message says so. Podman's libpod
	/// volume-create endpoint returns 500 (not 409) for a duplicate name, so an
	/// idempotent create must accept both to let a re-`up` succeed.
	pub(crate) fn is_already_exists(&self) -> bool {
		match self {
			Self::Api { status: 409, .. } => true,
			Self::Api {
				status: 500,
				message,
			} => message.contains("already exists"),
			_ => false,
		}
	}

	/// True if this API error reports the target image is still referenced by a
	/// container. A non-force `down --rmi` must skip such an image (matching
	/// docker compose) instead of force-removing it and cascading the deletion of
	/// every dependent container — including ones owned by other projects. Podman
	/// returns this as a 409 conflict, or on some versions a 500 whose message
	/// names the in-use cause.
	pub(crate) fn is_image_in_use(&self) -> bool {
		match self {
			Self::Api { status: 409, .. } => true,
			Self::Api {
				status: 500,
				message,
			} => {
				let m = message.to_ascii_lowercase();
				m.contains("in use") || m.contains("being used") || m.contains("used by")
			}
			_ => false,
		}
	}

	/// True if this API error reports a container is in the wrong state for the
	/// attempted lifecycle op (already paused, not paused, not running). Podman
	/// returns these as a 409/500 with a "container state improper" cause. Lets
	/// `pause`/`unpause` stay idempotent no-ops, matching docker compose.
	pub(crate) fn is_state_conflict(&self) -> bool {
		match self {
			Self::Api { status, message } if *status == 409 || *status == 500 => {
				let m = message.to_ascii_lowercase();
				m.contains("state improper")
					|| m.contains("already paused")
					|| m.contains("not paused")
					|| m.contains("not running")
			}
			_ => false,
		}
	}
}

#[cfg(test)]
mod tests {
	use super::{conflict_hint, PodmanError};

	#[test]
	fn conflict_hint_recognises_common_state_errors() {
		let rm = "cannot remove container abc as it is running - running or paused containers \
			cannot be removed without force: container state improper";
		assert!(conflict_hint(rm).unwrap().contains("-f"));
		assert!(conflict_hint("container abc is already paused")
			.unwrap()
			.contains("already paused"));
		assert!(conflict_hint("container abc is not paused")
			.unwrap()
			.contains("not paused"));
		assert!(
			conflict_hint("cannot kill container abc: container abc is not running")
				.unwrap()
				.contains("not running")
		);
		// libpod's kill-of-stopped 409 message ("can only kill running
		// containers …") gets the same friendly "not running" hint.
		assert!(conflict_hint(
			"can only kill running containers. abc is in state exited: container state improper"
		)
		.unwrap()
		.contains("not running"));
	}

	#[test]
	fn is_kill_of_stopped_matches_only_the_kill_409() {
		let stopped = PodmanError::Api {
			status: 409,
			message: "can only kill running containers. abc is in state exited: \
				container state improper"
				.into(),
		};
		assert!(stopped.is_kill_of_stopped());
		// A different 409 (e.g. already-paused) must not be swallowed by kill.
		let paused = PodmanError::Api {
			status: 409,
			message: "container abc is already paused".into(),
		};
		assert!(!paused.is_kill_of_stopped());
		// Wrong status, even with a matching message, is not a kill-of-stopped.
		let other = PodmanError::Api {
			status: 500,
			message: "can only kill running containers".into(),
		};
		assert!(!other.is_kill_of_stopped());
		assert!(
			!PodmanError::Json(serde_json::from_str::<u8>("bad").unwrap_err()).is_kill_of_stopped()
		);
	}

	#[test]
	fn conflict_hint_paused_rm_is_not_labelled_running() {
		// Podman's removal refusal for a *paused* container shares the "without
		// force" wording; the hint must say paused, not running.
		let paused = "cannot remove container abc as it is paused - running or paused containers \
			cannot be removed without force: container state improper";
		let hint = conflict_hint(paused).unwrap();
		assert!(hint.contains("paused"), "got {hint:?}");
		assert!(!hint.contains("running"), "must not say running: {hint:?}");
		assert!(hint.contains("-f"));
	}

	#[test]
	fn conflict_hint_covers_kill_exec_and_start() {
		// kill a stopped container.
		assert!(
			conflict_hint("can only kill running containers. abc is in state exited")
				.unwrap()
				.contains("not running")
		);
		// exec into a stopped container.
		assert!(conflict_hint(
			"can only create exec sessions on running containers: container state improper"
		)
		.unwrap()
		.contains("not running"));
		// start a container that is not startable.
		assert!(conflict_hint(
			"unable to start container abc: container must be in Created or Stopped state to be \
			 started"
		)
		.unwrap()
		.contains("cannot be started"));
	}

	#[test]
	fn conflict_hint_none_for_unrecognised() {
		assert!(conflict_hint("some unrelated error").is_none());
		assert!(conflict_hint("no such container: abc").is_none());
	}

	#[test]
	fn api_error_display_leads_with_hint_and_keeps_message() {
		let e = PodmanError::Api {
			status: 409,
			message: "container abc is already paused".into(),
		};
		let s = e.to_string();
		assert!(s.starts_with("the container is already paused"));
		assert!(s.contains("podman: container abc is already paused"));
	}

	#[test]
	fn api_error_display_raw_when_no_hint() {
		let e = PodmanError::Api {
			status: 500,
			message: "boom".into(),
		};
		assert_eq!(e.to_string(), "podman API error (HTTP 500): boom");
	}

	#[test]
	fn is_status_matches_code() {
		let e = PodmanError::Api {
			status: 404,
			message: "not found".into(),
		};
		assert!(e.is_status(404));
		assert!(!e.is_status(200));
		assert!(!e.is_status(500));
	}

	#[test]
	fn is_timeout_matches_synthetic_timeout_error() {
		// Client-side timeouts carry status 0 and a "timed out" message; lifecycle
		// stop escalation keys off this.
		assert!(PodmanError::Api {
			status: 0,
			message: "timed out after 40s waiting for the Podman socket to respond".into(),
		}
		.is_timeout());
		// A real HTTP error (non-zero status) is not a timeout.
		assert!(!PodmanError::Api {
			status: 500,
			message: "boom".into(),
		}
		.is_timeout());
		// A status-0 error without the timeout marker is not a timeout.
		assert!(!PodmanError::Api {
			status: 0,
			message: "invalid API path".into(),
		}
		.is_timeout());
	}

	#[test]
	fn is_status_false_for_non_api() {
		let e = PodmanError::Json(serde_json::from_str::<u8>("bad").unwrap_err());
		assert!(!e.is_status(404));
	}

	#[test]
	fn already_exists_accepts_409_and_500_with_message() {
		// 409 conflict: always an already-exists.
		assert!(PodmanError::Api {
			status: 409,
			message: "network already used".into(),
		}
		.is_already_exists());
		// 500 carrying the libpod "already exists" cause (Podman's volume-create
		// path) must also count as already-exists for idempotent create.
		assert!(PodmanError::Api {
			status: 500,
			message: "volume with name p_v already exists: volume already exists".into(),
		}
		.is_already_exists());
	}

	#[test]
	fn image_in_use_accepts_409_and_500_with_message() {
		// A 409 on image delete is always an in-use conflict.
		assert!(PodmanError::Api {
			status: 409,
			message: "image is in use by 1 container".into(),
		}
		.is_image_in_use());
		// Some Podman versions report it as a 500 naming the cause.
		assert!(PodmanError::Api {
			status: 500,
			message: "image used by a container: image in use".into(),
		}
		.is_image_in_use());
		// An unrelated 500 still propagates.
		assert!(!PodmanError::Api {
			status: 500,
			message: "internal error".into(),
		}
		.is_image_in_use());
		assert!(!PodmanError::Api {
			status: 404,
			message: "no such image".into(),
		}
		.is_image_in_use());
	}

	#[test]
	fn state_conflict_recognises_pause_unpause_mismatches() {
		for msg in [
			"container abc is already paused: container state improper",
			"container abc is not paused: container state improper",
			"cannot pause container abc: container abc is not running",
			"unpausing container: container state improper",
		] {
			assert!(
				PodmanError::Api {
					status: 500,
					message: msg.into(),
				}
				.is_state_conflict(),
				"should treat {msg:?} as a state conflict"
			);
		}
		// A genuine failure is not a state conflict.
		assert!(!PodmanError::Api {
			status: 500,
			message: "internal error".into(),
		}
		.is_state_conflict());
		assert!(!PodmanError::Api {
			status: 404,
			message: "no such container".into(),
		}
		.is_state_conflict());
	}

	#[test]
	fn already_exists_false_for_other_errors() {
		// A 500 that is not an already-exists must still propagate.
		assert!(!PodmanError::Api {
			status: 500,
			message: "internal error".into(),
		}
		.is_already_exists());
		assert!(!PodmanError::Api {
			status: 404,
			message: "no such volume".into(),
		}
		.is_already_exists());
		assert!(
			!PodmanError::Json(serde_json::from_str::<u8>("bad").unwrap_err()).is_already_exists()
		);
	}

	#[test]
	fn display_api_error() {
		let e = PodmanError::Api {
			status: 500,
			message: "internal error".into(),
		};
		assert_eq!(e.to_string(), "podman API error (HTTP 500): internal error");
	}

	#[test]
	fn display_json_error() {
		let e = PodmanError::Json(serde_json::from_str::<u8>("bad").unwrap_err());
		assert!(e.to_string().contains("json error"));
	}

	#[test]
	fn display_connect_error() {
		let e = PodmanError::Connect(std::io::Error::new(
			std::io::ErrorKind::NotFound,
			"no socket",
		));
		assert!(e.to_string().contains("podman socket connection error"));
	}

	#[test]
	fn display_incompatible_api_version_reports_version() {
		let e = PodmanError::IncompatibleApiVersion {
			reported: "4.9.3".into(),
		};
		let msg = e.to_string();
		assert!(msg.contains("Podman >= 5.0"));
		assert!(msg.contains("4.9.3"));
	}

	#[test]
	fn display_incompatible_api_version_handles_missing_header() {
		// An empty reported version (no `Libpod-API-Version` header) renders a
		// readable placeholder rather than a blank.
		let e = PodmanError::IncompatibleApiVersion {
			reported: String::new(),
		};
		let msg = e.to_string();
		assert!(msg.contains("an unknown version"));
	}

	#[test]
	fn source_present_for_wrapped_errors_absent_for_owned() {
		use std::error::Error;
		// Wrapped lower-level errors expose their source...
		let connect = PodmanError::Connect(std::io::Error::new(
			std::io::ErrorKind::NotFound,
			"no socket",
		));
		assert!(connect.source().is_some());
		let json = PodmanError::Json(serde_json::from_str::<u8>("bad").unwrap_err());
		assert!(json.source().is_some());
		// ...while the owned variants have no underlying source.
		assert!(PodmanError::Api {
			status: 500,
			message: "x".into(),
		}
		.source()
		.is_none());
		assert!(PodmanError::IncompatibleApiVersion {
			reported: "4.0.0".into(),
		}
		.source()
		.is_none());
	}

	#[test]
	fn from_io_error_becomes_connect() {
		// The `?`-operator conversion an io error takes when bubbling out of the
		// client maps onto the Connect variant.
		let io = std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused");
		let e: PodmanError = io.into();
		assert!(matches!(e, PodmanError::Connect(_)));
		assert!(e.to_string().contains("podman socket connection error"));
	}

	#[test]
	fn from_json_error_becomes_json() {
		let json_err = serde_json::from_str::<u8>("not-json").unwrap_err();
		let e: PodmanError = json_err.into();
		assert!(matches!(e, PodmanError::Json(_)));
		assert!(e.to_string().contains("json error"));
	}
}