podup 3.0.2

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
//! Health and completion polling for service dependency ordering.
//!
//! [`Engine::wait_healthy`] polls until the container reports `healthy` (used when
//! a dependent service declares `condition: service_healthy`).
//! [`Engine::wait_completed`] polls until the container exits with code 0 (used for
//! `condition: service_completed_successfully`).

use std::time::Duration;

use serde::Deserialize;

use crate::compose::types::{ComposeFile, Service};
use crate::error::{ComposeError, Result};
use crate::libpod::types::container::ContainerInspect;
use crate::libpod::API_PREFIX;

use super::Engine;

/// Response from `GET {API_PREFIX}/containers/{name}/healthcheck`, which *runs*
/// the container's healthcheck on demand and reports the resulting status.
#[derive(Deserialize)]
struct HealthCheckRun {
	#[serde(rename = "Status")]
	status: Option<String>,
}

/// Per-poll verdict while waiting for `service_healthy`.
enum HealthVerdict {
	/// The runtime reports the container as `healthy`.
	Healthy,
	/// The container has no effective healthcheck, so `healthy` is unreachable;
	/// treat the dependency as satisfied rather than blocking until timeout.
	NoHealthcheck,
	/// The container has already exited non-zero. It can never become healthy and
	/// the service failed to start, so the wait must fail rather than report the
	/// dependency satisfied. Carries the exit code.
	Failed(i64),
	/// Not healthy yet — keep polling.
	Pending,
}

/// Classify a container inspect while waiting for `service_healthy`.
///
/// Pure decision logic for [`Engine::wait_healthy`], split out so the gating
/// behaviour can be unit-tested without a live Podman socket.
fn classify_health(info: &ContainerInspect) -> HealthVerdict {
	if let Some(state) = &info.state {
		if let Some(health) = &state.health {
			if health.status.as_deref() == Some("healthy") {
				return HealthVerdict::Healthy;
			}
		}
		// A container that has already exited can never become healthy. If it
		// exited non-zero the service failed to start, so fail the wait instead of
		// reporting it satisfied (which would mask the failure in `up --wait`/CI).
		// An exit code of 0 is a one-shot that completed; let it fall through.
		if state.status.as_deref() == Some("exited") {
			let code = state.exit_code.unwrap_or(0);
			if code != 0 {
				return HealthVerdict::Failed(code);
			}
		}
	}
	if !info
		.config
		.as_ref()
		.map(|c| c.has_healthcheck())
		.unwrap_or(false)
	{
		return HealthVerdict::NoHealthcheck;
	}
	HealthVerdict::Pending
}

/// How often the health *status* is read while waiting between check runs.
///
/// Reading is a plain inspect: it does not execute the healthcheck, so it costs
/// a request and nothing inside the container. Podman runs the check on its own
/// schedule where systemd is available, and this is what notices promptly when
/// it does — previously the status was only ever looked at once per `interval`,
/// so a container that turned healthy just after a probe went unnoticed for the
/// rest of the window.
const STATUS_READ_INTERVAL: Duration = Duration::from_millis(150);

/// Lower bound on how often podup will *run* a healthcheck.
///
/// Running is not free — it executes the command inside the container — so an
/// `interval` of `10ms` must not turn into a hundred executions a second.
const MIN_RUN_INTERVAL: Duration = Duration::from_millis(100);

/// Compute the `(run_interval, budget)` for [`Engine::wait_healthy`] from a
/// healthcheck's `interval`/`start_period`/`retries`.
///
/// `run_interval` is how often podup actively executes the check; `budget` is
/// how long it keeps trying. Sub-second intervals are honoured down to
/// [`MIN_RUN_INTERVAL`]: they used to be discarded and replaced by the 2s
/// default, so asking for `500ms` polling produced *slower* polling than asking
/// for `1s` — the opposite of the request. Pure so the timing is unit-testable.
fn health_poll_plan(
	interval: Option<&str>,
	start_period: Option<&str>,
	retries: Option<u32>,
) -> (Duration, Duration) {
	let run_interval = interval
		.and_then(crate::size::parse_duration_nanos)
		.filter(|n| *n > 0)
		.map(|n| Duration::from_nanos(n as u64).max(MIN_RUN_INTERVAL))
		.unwrap_or(Duration::from_secs(2));
	let start = start_period
		.and_then(crate::size::parse_duration_nanos)
		.filter(|n| *n > 0)
		.map(|n| Duration::from_nanos(n as u64))
		.unwrap_or_default();
	// Same budget as before, expressed as time rather than as a probe count: a
	// count stopped meaning a duration once the wait polls at two cadences.
	let budget = run_interval.saturating_mul(retries.unwrap_or(30)) + start;
	(run_interval, budget)
}

/// Poll iterations to actually run, given the healthcheck poll-plan and an
/// optional `--wait-timeout`.
///
/// `--wait-timeout` must be able to *extend* the wait, not merely cap it: a
/// healthcheck with a short `interval × retries` budget would otherwise time
/// out long before a generous `--wait-timeout` elapsed. So when a wait-timeout
/// is given we run at least enough iterations to cover it (plus a small margin),
/// letting the outer `--wait-timeout` deadline — not the poll plan — decide when
/// to give up. Without a wait-timeout the poll plan governs unchanged. Pure so
/// the budget arithmetic is unit-tested without a live socket.
fn effective_budget(
	run_interval: Duration,
	plan_budget: Duration,
	wait_timeout: Option<Duration>,
) -> Duration {
	match wait_timeout {
		// Two extra run intervals so this wait outlasts the outer deadline,
		// ensuring an exhausted wait surfaces as the `--wait-timeout` error
		// rather than a spurious health-check timeout that fired just early.
		Some(wt) => plan_budget.max(wt + run_interval.saturating_mul(2)),
		None => plan_budget,
	}
}

impl Engine {
	/// Wait until every targeted service's first replica is healthy (`up
	/// --wait`). A service with no effective healthcheck is treated as ready
	/// once started. All services when `target_services` is empty.
	pub async fn wait_services_healthy(
		&self,
		file: &ComposeFile,
		target_services: &[String],
	) -> Result<()> {
		self.wait_services_healthy_within(file, target_services, None)
			.await
	}

	/// As [`wait_services_healthy`](Self::wait_services_healthy), but a
	/// `Some(wait_timeout)` extends each service's poll budget to cover the
	/// supplied `--wait-timeout` (rather than only capping it). The caller still
	/// wraps the whole wait in a hard `--wait-timeout` deadline, which becomes the
	/// authoritative limit; this just stops the per-service poll plan from giving
	/// up early and reporting a misleading health-check timeout.
	pub async fn wait_services_healthy_within(
		&self,
		file: &ComposeFile,
		target_services: &[String],
		wait_timeout: Option<Duration>,
	) -> Result<()> {
		// Poll services concurrently: each `wait_healthy` is its own poll loop, so
		// total `--wait` latency is the slowest service, not the sum of all.
		let waits = file
			.services
			.iter()
			.filter(|(name, _)| {
				target_services.is_empty() || target_services.iter().any(|t| t == *name)
			})
			.map(|(name, service)| {
				let container = self.first_replica_name(name, service);
				async move { self.wait_healthy(&container, service, wait_timeout).await }
			});
		futures_util::future::try_join_all(waits).await?;
		Ok(())
	}

	/// Poll a container until its health status is `healthy` or timeout.
	///
	/// Polls at the compose `healthcheck.interval` (default 2 s) for
	/// `healthcheck.retries` (default 30) probes, plus extra probes covering
	/// `healthcheck.start_period` so a slow-starting service is not timed out early.
	///
	/// The wait is driven by the container's *effective* healthcheck reported by
	/// the runtime, so healthchecks inherited from the image count too — not just
	/// those declared in compose. If the container has no effective healthcheck at
	/// all (none in the image or compose), it can never report `healthy`, so the
	/// wait short-circuits as satisfied rather than blocking until timeout.
	pub(super) async fn wait_healthy(
		&self,
		container_name: &str,
		service: &Service,
		wait_timeout: Option<Duration>,
	) -> Result<()> {
		let hc = service.healthcheck.as_ref();
		let (run_interval, plan_budget) = health_poll_plan(
			hc.and_then(|h| h.interval.as_deref()),
			hc.and_then(|h| h.start_period.as_deref()),
			hc.and_then(|h| h.retries),
		);
		// `--wait-timeout` extends the budget so it, not the (often shorter)
		// healthcheck interval×retries plan, decides when the wait gives up.
		let budget = effective_budget(run_interval, plan_budget, wait_timeout);

		// One inspect decides the short-circuits: already healthy, or no effective
		// healthcheck at all (image or compose) — in which case a server-side
		// `wait?condition=healthy` would block forever, so treat it as satisfied.
		let info = self
			.client
			.get_json::<crate::libpod::types::container::ContainerInspect>(&format!(
				"{API_PREFIX}/containers/{}/json",
				crate::libpod::urlencoded(container_name),
			))
			.await
			.map_err(ComposeError::Podman)?;
		match classify_health(&info) {
			HealthVerdict::Healthy => return Ok(()),
			HealthVerdict::NoHealthcheck => {
				tracing::debug!(
					"{container_name} has no effective healthcheck; treating service_healthy as satisfied"
				);
				return Ok(());
			}
			HealthVerdict::Failed(code) => {
				return Err(ComposeError::WaitServiceExited {
					container: container_name.to_string(),
					code,
				});
			}
			HealthVerdict::Pending => {}
		}

		// Actively drive the healthcheck on demand. A server-side
		// `wait?condition=healthy` only returns once the health *status* flips to
		// `healthy`, but Podman updates that status only when the healthcheck runs
		// — and it schedules those runs via systemd transient timers. Without
		// systemd (containers, minimal hosts) the timer never fires and the status
		// stays `starting`, so the wait would block until the whole budget elapsed.
		//
		// `GET {API_PREFIX}/containers/{name}/healthcheck` *runs* the check and
		// returns the resulting status, so polling it works with or without
		// systemd, on Podman 4.9.3 and 5.x alike. Extra on-demand runs on a
		// systemd host are harmless.
		let path = format!(
			"{API_PREFIX}/containers/{}/healthcheck",
			crate::libpod::urlencoded(container_name),
		);
		// Two cadences, because running a check and observing its result are not
		// the same cost. Running executes a command inside the container, so it
		// happens at the interval the compose file asked for and no faster.
		// Observing is a plain inspect, so it happens often — Podman runs the
		// check on its own systemd schedule where one exists, and this is what
		// notices promptly when it does.
		//
		// Before, the status was read only once per run, so a container that
		// turned healthy a moment after a probe went unseen for the rest of the
		// interval: measured at 2507ms to notice a service healthy at ~1200ms,
		// against 1706ms for docker compose (#1147).
		let inspect_path = format!(
			"{API_PREFIX}/containers/{}/json",
			crate::libpod::urlencoded(container_name),
		);
		let deadline = tokio::time::Instant::now() + budget;
		let mut next_run = tokio::time::Instant::now();
		while tokio::time::Instant::now() < deadline {
			if tokio::time::Instant::now() >= next_run {
				match self.client.get_json::<HealthCheckRun>(&path).await {
					Ok(run) if run.status.as_deref() == Some("healthy") => return Ok(()),
					Ok(_) => {}
					// A transient error (container not yet running, 409, 500, …)
					// just means "not healthy yet" — keep going rather than
					// failing hard.
					Err(e) => tracing::debug!("{container_name} healthcheck run failed: {e}"),
				}
				next_run = tokio::time::Instant::now() + run_interval;
			} else if let Ok(info) = self
				.client
				.get_json::<crate::libpod::types::container::ContainerInspect>(&inspect_path)
				.await
			{
				match classify_health(&info) {
					HealthVerdict::Healthy => return Ok(()),
					HealthVerdict::Failed(code) => {
						return Err(ComposeError::WaitServiceExited {
							container: container_name.to_string(),
							code,
						})
					}
					_ => {}
				}
			}
			let nap = STATUS_READ_INTERVAL.min(run_interval);
			tokio::time::sleep(
				nap.min(deadline.saturating_duration_since(tokio::time::Instant::now())),
			)
			.await;
		}
		Err(ComposeError::HealthCheckTimeout(container_name.into()))
	}

	/// Wait until a container exits, then require status 0.
	///
	/// Blocks server-side on `wait?condition=stopped` (which returns the exit
	/// code) instead of polling inspect, bounded by a 600 s client-side timeout.
	/// Errors if the container exits non-zero or the deadline is exceeded.
	pub(super) async fn wait_completed(&self, container_name: &str) -> Result<()> {
		let path = format!(
			"{API_PREFIX}/containers/{}/wait?condition=stopped",
			crate::libpod::urlencoded(container_name),
		);
		let budget = std::time::Duration::from_secs(600);
		match tokio::time::timeout(budget, self.client.post_empty_json_unbounded::<i64>(&path))
			.await
		{
			Ok(Ok(0)) => Ok(()),
			Ok(Ok(code)) => Err(ComposeError::HealthCheckTimeout(format!(
				"{container_name} exited with non-zero status {code}"
			))),
			Ok(Err(e)) => {
				tracing::debug!("{container_name} wait?condition=stopped failed: {e}");
				Err(ComposeError::HealthCheckTimeout(container_name.into()))
			}
			Err(_elapsed) => Err(ComposeError::HealthCheckTimeout(container_name.into())),
		}
	}
}

#[cfg(test)]
mod tests {
	use super::*;

	fn inspect(json: &str) -> ContainerInspect {
		serde_json::from_str(json).expect("fixture parses")
	}

	// --- wait_healthy poll plan (#418) ---------------------------------------

	#[test]
	fn poll_plan_defaults_match_legacy_60s() {
		// No healthcheck timing set → 2s between runs, 30 runs (60s budget).
		assert_eq!(
			super::health_poll_plan(None, None, None),
			(Duration::from_secs(2), Duration::from_secs(60))
		);
	}

	#[test]
	fn poll_plan_uses_interval_and_honors_start_period() {
		// interval=10s, start_period=60s, retries=3 → run every 10s, budget
		// 3×10s + 60s.
		let (run, budget) = super::health_poll_plan(Some("10s"), Some("60s"), Some(3));
		assert_eq!(
			(run, budget),
			(Duration::from_secs(10), Duration::from_secs(90))
		);
	}

	/// A sub-second interval used to be discarded and replaced by the 2s
	/// default, so asking for 500ms polling produced *slower* polling than
	/// asking for 1s — the opposite of the request (#1147). It is honoured now,
	/// with a floor so `10ms` cannot become a hundred check executions a second.
	#[test]
	fn poll_plan_honours_a_sub_second_interval() {
		let (run, _) = super::health_poll_plan(Some("500ms"), None, Some(5));
		assert_eq!(run, Duration::from_millis(500));
	}

	#[test]
	fn poll_plan_floors_a_pathological_interval() {
		let (run, _) = super::health_poll_plan(Some("1ms"), None, Some(5));
		assert_eq!(run, super::MIN_RUN_INTERVAL);
	}

	/// Reading the status must never be slower than running the check, or the
	/// fast observation path would be pointless.
	#[test]
	fn the_status_read_is_never_slower_than_the_default_run() {
		assert!(super::STATUS_READ_INTERVAL < Duration::from_secs(2));
	}

	// --- effective_budget (--wait-timeout, #891) -----------------------------

	#[test]
	fn budget_without_wait_timeout_uses_the_plan() {
		let plan = Duration::from_secs(60);
		assert_eq!(
			super::effective_budget(Duration::from_secs(2), plan, None),
			plan
		);
	}

	#[test]
	fn budget_extends_to_cover_wait_timeout() {
		// A short plan must not cut a generous --wait-timeout short.
		let b = super::effective_budget(
			Duration::from_secs(10),
			Duration::from_secs(10),
			Some(Duration::from_secs(120)),
		);
		assert!(b > Duration::from_secs(120), "{b:?}");
	}

	#[test]
	fn budget_keeps_the_larger_plan() {
		// A generous plan is not shortened by a small --wait-timeout.
		let b = super::effective_budget(
			Duration::from_secs(2),
			Duration::from_secs(200),
			Some(Duration::from_secs(10)),
		);
		assert_eq!(b, Duration::from_secs(200));
	}

	#[test]
	fn health_reported_healthy() {
		let info = inspect(r#"{"State":{"Status":"running","Health":{"Status":"healthy"}}}"#);
		assert!(matches!(classify_health(&info), HealthVerdict::Healthy));
	}

	#[test]
	fn health_no_effective_healthcheck_is_satisfied() {
		// A disabled healthcheck (Test ["NONE"]) can never report healthy, so the
		// dependency short-circuits as satisfied rather than blocking to timeout.
		let info =
			inspect(r#"{"State":{"Status":"running"},"Config":{"Healthcheck":{"Test":["NONE"]}}}"#);
		assert!(matches!(
			classify_health(&info),
			HealthVerdict::NoHealthcheck
		));
	}

	#[test]
	fn health_starting_with_healthcheck_pends() {
		let info = inspect(
			r#"{"State":{"Status":"running","Health":{"Status":"starting"}},"Config":{"Healthcheck":{"Test":["CMD","true"]}}}"#,
		);
		assert!(matches!(classify_health(&info), HealthVerdict::Pending));
	}

	#[test]
	fn health_exited_nonzero_fails() {
		// A no-healthcheck service that crashed during the wait must fail, not be
		// reported satisfied (the `up --wait` masking bug).
		let info = inspect(r#"{"State":{"Status":"exited","ExitCode":7}}"#);
		assert!(matches!(classify_health(&info), HealthVerdict::Failed(7)));
	}

	#[test]
	fn health_exited_zero_is_satisfied() {
		// A one-shot that completed cleanly with no healthcheck is still satisfied.
		let info = inspect(r#"{"State":{"Status":"exited","ExitCode":0}}"#);
		assert!(matches!(
			classify_health(&info),
			HealthVerdict::NoHealthcheck
		));
	}
}