1use std::path::Path;
24use std::time::Duration;
25
26use tokio::process::Command;
27
28const MAX_PULL_ATTEMPTS: u32 = 5;
31
32const BASE_RETRY_DELAY: Duration = Duration::from_secs(1);
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum PulledImage {
38 Pulled,
40 Cached { pull_error: String },
43 Present,
45}
46
47pub async fn pull_image(
55 cli: &str,
56 docker_config: Option<&Path>,
57 reference: &str,
58) -> Result<PulledImage, String> {
59 pull_image_with(cli, docker_config, reference, BASE_RETRY_DELAY).await
60}
61
62pub async fn ensure_image(
68 cli: &str,
69 docker_config: Option<&Path>,
70 reference: &str,
71) -> Result<PulledImage, String> {
72 ensure_image_with(cli, docker_config, reference, BASE_RETRY_DELAY).await
73}
74
75async fn ensure_image_with(
76 cli: &str,
77 docker_config: Option<&Path>,
78 reference: &str,
79 base_delay: Duration,
80) -> Result<PulledImage, String> {
81 if image_cached(cli, docker_config, reference).await {
82 return Ok(PulledImage::Present);
83 }
84 pull_image_with(cli, docker_config, reference, base_delay).await
85}
86
87async fn pull_image_with(
88 cli: &str,
89 docker_config: Option<&Path>,
90 reference: &str,
91 base_delay: Duration,
92) -> Result<PulledImage, String> {
93 let mut delay = base_delay;
94 let mut attempt = 1;
95 loop {
96 let mut cmd = Command::new(cli);
97 if let Some(p) = docker_config {
98 cmd.env("DOCKER_CONFIG", p);
99 }
100 let out = cmd
101 .args(["pull", reference])
102 .output()
103 .await
104 .map_err(|e| format!("{cli} pull: {e}"))?;
105 if out.status.success() {
106 return Ok(PulledImage::Pulled);
107 }
108 let pull_error = String::from_utf8_lossy(&out.stderr).trim().to_string();
109 if !is_transient(&pull_error, reference) {
110 return Err(pull_error);
111 }
112 if image_cached(cli, docker_config, reference).await {
113 tracing::warn!(
114 image = %reference,
115 error = %pull_error,
116 "image pull failed transiently; using the locally cached image"
117 );
118 return Ok(PulledImage::Cached { pull_error });
119 }
120 if attempt >= MAX_PULL_ATTEMPTS {
121 return Err(pull_error);
122 }
123 tracing::info!(
124 image = %reference,
125 attempt,
126 retry_in_ms = delay.as_millis() as u64,
127 "image pull failed transiently; retrying"
128 );
129 tokio::time::sleep(delay).await;
130 delay *= 2;
131 attempt += 1;
132 }
133}
134
135async fn image_cached(cli: &str, docker_config: Option<&Path>, reference: &str) -> bool {
140 let mut cmd = Command::new(cli);
141 if let Some(p) = docker_config {
142 cmd.env("DOCKER_CONFIG", p);
143 }
144 cmd.args(["image", "inspect", reference])
145 .stdout(std::process::Stdio::null())
146 .stderr(std::process::Stdio::null())
147 .status()
148 .await
149 .map(|s| s.success())
150 .unwrap_or(false)
151}
152
153fn is_transient(stderr: &str, reference: &str) -> bool {
165 const REFUSED: [&str; 6] = [
166 "manifest unknown",
167 "not found",
168 "denied",
169 "unauthorized",
170 "forbidden",
171 "does not exist",
172 ];
173 const TRANSIENT: [&str; 8] = [
174 "toomanyrequests",
175 "too many requests",
176 "rate exceeded",
177 "i/o timeout",
178 "tls handshake timeout",
179 "connection reset by peer",
180 "context deadline exceeded",
181 "request canceled while waiting for connection",
182 ];
183 let message = without_image_name(&stderr.to_ascii_lowercase(), reference);
184 if REFUSED.iter().any(|m| message.contains(m)) {
185 return false;
186 }
187 TRANSIENT.iter().any(|m| message.contains(m)) || has_server_error_status(&message)
188}
189
190fn without_image_name(message: &str, reference: &str) -> String {
198 let reference = reference.to_ascii_lowercase();
199 let untagged = reference.split('@').next().unwrap_or(&reference);
200 let repository = match (untagged.rfind(':'), untagged.rfind('/')) {
202 (Some(colon), Some(slash)) if colon < slash => untagged,
203 (Some(colon), _) => &untagged[..colon],
204 (None, _) => untagged,
205 };
206 let mut names = vec![reference.as_str(), repository];
207 names.extend(
208 repository
209 .match_indices('/')
210 .map(|(i, _)| &repository[i + 1..]),
211 );
212 if let Some((host, _)) = repository.split_once('/') {
214 if host.contains(['.', ':']) || host == "localhost" {
215 names.push(host);
216 }
217 }
218 names.sort_by_key(|n| std::cmp::Reverse(n.len()));
219
220 let mut out = message.to_string();
221 for name in names.into_iter().filter(|n| !n.is_empty()) {
222 out = remove_whole(&out, name);
223 }
224 out
225}
226
227fn remove_whole(haystack: &str, name: &str) -> String {
230 let is_name_char = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-');
231 let mut out = String::with_capacity(haystack.len());
232 let mut rest = haystack;
233 while let Some(i) = rest.find(name) {
234 let end = i + name.len();
235 let before = rest[..i].chars().next_back();
236 let after = rest[end..].chars().next();
237 if before.is_some_and(is_name_char) || after.is_some_and(is_name_char) {
238 out.push_str(&rest[..end]);
239 } else {
240 out.push_str(&rest[..i]);
241 out.push(' ');
242 }
243 rest = &rest[end..];
244 }
245 out.push_str(rest);
246 out
247}
248
249fn has_server_error_status(message: &str) -> bool {
254 let bytes = message.as_bytes();
255 ["status code ", "status: ", "status ", ": "]
256 .iter()
257 .flat_map(|prefix| message.match_indices(prefix).map(|(i, p)| i + p.len()))
258 .any(|start| {
259 let code = &bytes[start..bytes.len().min(start + 3)];
260 code.len() == 3
261 && code[0] == b'5'
262 && code.iter().all(u8::is_ascii_digit)
263 && !bytes
264 .get(start + 3)
265 .is_some_and(|c| c.is_ascii_alphanumeric())
266 })
267}
268
269#[cfg(all(test, unix))]
270mod tests {
271 use super::*;
272 use std::os::unix::fs::PermissionsExt;
273
274 struct FakeCli {
278 dir: tempfile::TempDir,
279 }
280
281 impl FakeCli {
282 fn new(pull_failures: u32, pull_stderr: &str, cached: bool) -> Self {
283 let dir = tempfile::tempdir().unwrap();
284 let script = format!(
285 r#"#!/bin/sh
286d="{dir}"
287echo "$*" >> "$d/calls.log"
288case "$1" in
289 pull)
290 n=$(cat "$d/pulls" 2>/dev/null || echo 0)
291 n=$((n + 1))
292 echo "$n" > "$d/pulls"
293 if [ "$n" -le {pull_failures} ]; then
294 echo '{pull_stderr}' >&2
295 exit 1
296 fi
297 exit 0 ;;
298 image)
299 [ "{cached}" = "true" ] && exit 0
300 echo 'Error: No such image' >&2
301 exit 1 ;;
302esac
303exit 2
304"#,
305 dir = dir.path().display(),
306 );
307 let path = dir.path().join("cli");
308 std::fs::write(&path, script).unwrap();
309 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
310 let mut attempts = 0;
317 loop {
318 match std::process::Command::new(&path).arg("probe").output() {
319 Err(e)
320 if e.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 200 =>
321 {
322 attempts += 1;
323 std::thread::sleep(Duration::from_millis(5));
324 }
325 Err(e) => panic!("fake CLI did not run: {e}"),
326 Ok(_) => break,
327 }
328 }
329 let _ = std::fs::remove_file(dir.path().join("calls.log"));
330 Self { dir }
331 }
332
333 fn cli(&self) -> String {
334 self.dir.path().join("cli").display().to_string()
335 }
336
337 fn calls(&self) -> Vec<String> {
338 std::fs::read_to_string(self.dir.path().join("calls.log"))
339 .unwrap_or_default()
340 .lines()
341 .map(String::from)
342 .collect()
343 }
344
345 async fn pull(&self) -> Result<PulledImage, String> {
346 self.pull_ref("alpine:3.20").await
347 }
348
349 async fn pull_ref(&self, reference: &str) -> Result<PulledImage, String> {
350 pull_image_with(&self.cli(), None, reference, Duration::from_millis(1)).await
351 }
352
353 async fn ensure(&self) -> Result<PulledImage, String> {
354 ensure_image_with(&self.cli(), None, "alpine:3.20", Duration::from_millis(1)).await
355 }
356 }
357
358 fn is_transient_for_test(stderr: &str) -> bool {
359 is_transient(stderr, "alpine:3.20")
360 }
361
362 const THROTTLED: &str = "Error response from daemon: unexpected status from HEAD request to https://public.ecr.aws/v2/docker/library/alpine/manifests/3.20: 429 Too Many Requests";
363
364 #[tokio::test]
365 async fn ensure_image_uses_a_cached_image_without_contacting_the_registry() {
366 let cli = FakeCli::new(u32::MAX, THROTTLED, true);
367 assert_eq!(cli.ensure().await, Ok(PulledImage::Present));
368 assert_eq!(cli.calls(), ["image inspect alpine:3.20"]);
369 }
370
371 #[tokio::test]
372 async fn ensure_image_retries_a_throttled_first_pull() {
373 let cli = FakeCli::new(2, THROTTLED, false);
374 assert_eq!(cli.ensure().await, Ok(PulledImage::Pulled));
375 let pulls = cli.calls().iter().filter(|c| c.starts_with("pull")).count();
376 assert_eq!(pulls, 3);
377 }
378
379 #[tokio::test]
380 async fn ensure_image_fails_on_a_refused_pull() {
381 let missing =
382 "Error response from daemon: manifest for alpine:3.20 not found: manifest unknown";
383 let cli = FakeCli::new(u32::MAX, missing, false);
384 assert_eq!(cli.ensure().await, Err(missing.to_string()));
385 }
386
387 #[tokio::test]
388 async fn a_successful_pull_needs_no_cache_check() {
389 let cli = FakeCli::new(0, "", false);
390 assert_eq!(cli.pull().await, Ok(PulledImage::Pulled));
391 assert_eq!(cli.calls(), ["pull alpine:3.20"]);
392 }
393
394 #[tokio::test]
395 async fn a_throttled_pull_uses_the_cached_image() {
396 let cli = FakeCli::new(u32::MAX, THROTTLED, true);
397 let got = cli.pull().await;
398 assert_eq!(
399 got,
400 Ok(PulledImage::Cached {
401 pull_error: THROTTLED.to_string()
402 })
403 );
404 assert_eq!(
405 cli.calls(),
406 ["pull alpine:3.20", "image inspect alpine:3.20"],
407 "a cached image is used at once, without retrying the pull"
408 );
409 }
410
411 #[tokio::test]
412 async fn a_rate_limited_pull_with_nothing_cached_is_retried() {
413 let cli = FakeCli::new(2, THROTTLED, false);
414 assert_eq!(cli.pull().await, Ok(PulledImage::Pulled));
415 let pulls = cli.calls().iter().filter(|c| c.starts_with("pull")).count();
416 assert_eq!(pulls, 3);
417 }
418
419 #[tokio::test]
420 async fn retries_stop_after_the_attempt_cap() {
421 let cli = FakeCli::new(u32::MAX, THROTTLED, false);
422 assert_eq!(cli.pull().await, Err(THROTTLED.to_string()));
423 let pulls = cli.calls().iter().filter(|c| c.starts_with("pull")).count();
424 assert_eq!(pulls, MAX_PULL_ATTEMPTS as usize);
425 }
426
427 #[tokio::test]
428 async fn a_missing_image_fails_without_retrying() {
429 let missing =
430 "Error response from daemon: manifest for alpine:nope not found: manifest unknown";
431 let cli = FakeCli::new(u32::MAX, missing, false);
432 assert_eq!(cli.pull().await, Err(missing.to_string()));
433 assert_eq!(
434 cli.calls(),
435 ["pull alpine:3.20"],
436 "a refused pull is neither retried nor checked against the cache"
437 );
438 }
439
440 #[tokio::test]
441 async fn a_refused_pull_fails_even_with_a_stale_cached_copy() {
442 for refused in [
445 "Error response from daemon: manifest for alpine:3.20 not found: manifest unknown",
446 "Error response from daemon: pull access denied for alpine, repository does not exist or may require authorization: denied",
447 ] {
448 let cli = FakeCli::new(u32::MAX, refused, true);
449 assert_eq!(cli.pull().await, Err(refused.to_string()));
450 assert_eq!(cli.calls(), ["pull alpine:3.20"]);
451 }
452 }
453
454 #[tokio::test]
455 async fn a_registry_server_error_uses_the_cached_image() {
456 let unavailable =
457 "Error response from daemon: received unexpected HTTP status: 503 Service Unavailable";
458 let cli = FakeCli::new(u32::MAX, unavailable, true);
459 assert_eq!(
460 cli.pull().await,
461 Ok(PulledImage::Cached {
462 pull_error: unavailable.to_string()
463 })
464 );
465 }
466
467 #[tokio::test]
468 async fn a_refused_pull_of_a_repository_named_like_a_marker_is_still_refused() {
469 let refused = "Error response from daemon: manifest for toomanyrequests:latest not found: manifest unknown: manifest unknown";
472 let cli = FakeCli::new(u32::MAX, refused, true);
473 assert_eq!(
474 cli.pull_ref("toomanyrequests:latest").await,
475 Err(refused.to_string())
476 );
477 assert_eq!(cli.calls(), ["pull toomanyrequests:latest"]);
478 }
479
480 #[tokio::test]
481 async fn a_throttled_pull_of_a_repository_named_like_a_refusal_uses_the_cache() {
482 let reference = "public.ecr.aws/acme/access-denied-page:1";
486 let throttled = "Error response from daemon: unexpected status from HEAD request to https://public.ecr.aws/v2/acme/access-denied-page/manifests/1: 429 Too Many Requests";
487 let cli = FakeCli::new(u32::MAX, throttled, true);
488 assert_eq!(
489 cli.pull_ref(reference).await,
490 Ok(PulledImage::Cached {
491 pull_error: throttled.to_string()
492 })
493 );
494 assert_eq!(
495 cli.calls(),
496 [
497 format!("pull {reference}"),
498 format!("image inspect {reference}")
499 ]
500 );
501 }
502
503 #[test]
504 fn transient_detection_separates_retryable_from_refused() {
505 assert!(is_transient_for_test(THROTTLED));
506 assert!(is_transient_for_test(
507 "toomanyrequests: You have reached your pull rate limit."
508 ));
509 assert!(is_transient_for_test("Error: Rate exceeded"));
510 assert!(is_transient_for_test(
511 "received unexpected HTTP status: 502 Bad Gateway"
512 ));
513 assert!(is_transient_for_test(
514 "Get \"https://public.ecr.aws/v2/\": net/http: TLS handshake timeout"
515 ));
516 assert!(is_transient_for_test(
517 "read tcp 10.0.0.2:4431->1.2.3.4:443: read: connection reset by peer"
518 ));
519 assert!(!is_transient_for_test("manifest unknown"));
520 assert!(!is_transient_for_test("pull access denied for foo"));
521 assert!(!is_transient_for_test(
522 "unauthorized: authentication required"
523 ));
524 assert!(!is_transient_for_test(
525 "pull access denied for toomanyrequests, repository does not exist or may require authorization"
526 ));
527 }
528
529 #[test]
530 fn any_5xx_status_is_transient_whatever_its_reason_phrase() {
531 for msg in [
532 "received unexpected HTTP status: 500 Internal Server Error",
533 "unexpected status from GET request to https://r.example/v2/: 507 Insufficient Storage",
534 "unexpected status code 520",
535 "error pulling image: status: 599",
536 "unexpected status from HEAD request to https://r.example/v2/a/manifests/1: 503",
537 ] {
538 assert!(is_transient_for_test(msg), "{msg}");
539 }
540 for msg in [
541 "Get \"http://127.0.0.1:5000/v2/\": dial tcp 127.0.0.1:5000: connect: connection refused",
543 "unexpected status code 400 Bad Request",
544 "status: 5001",
545 ] {
546 assert!(!is_transient_for_test(msg), "{msg}");
547 }
548 }
549
550 #[test]
551 fn a_throttled_pull_of_a_repository_named_like_a_refusal_is_still_transient() {
552 let reference = "public.ecr.aws/acme/access-denied-page:1";
553 for msg in [
554 "Error response from daemon: unexpected status from HEAD request to https://public.ecr.aws/v2/acme/access-denied-page/manifests/1: 429 Too Many Requests",
555 "Error response from daemon: toomanyrequests: Rate exceeded for public.ecr.aws/acme/access-denied-page:1",
556 ] {
557 assert!(is_transient(msg, reference), "{msg}");
558 }
559 assert!(!is_transient(
561 "Error response from daemon: manifest for public.ecr.aws/acme/access-denied-page:1 not found: manifest unknown",
562 reference
563 ));
564 }
565
566 #[test]
567 fn a_registry_host_named_like_a_refusal_does_not_hide_a_throttle() {
568 assert!(is_transient(
569 "Error response from daemon: unexpected status from HEAD request to https://denied.example/v2/app/manifests/1: 429 Too Many Requests",
570 "denied.example/app:1"
571 ));
572 }
573
574 #[test]
575 fn only_whole_names_are_removed() {
576 assert!(!is_transient(
578 "Error response from daemon: pull access denied for d, repository does not exist",
579 "d"
580 ));
581 assert_eq!(
582 without_image_name(
583 "pull access denied for docker.io/library/alpine",
584 "alpine:3.20"
585 ),
586 "pull access denied for docker.io/library/ "
587 );
588 assert_eq!(
589 without_image_name(
590 "get https://127.0.0.1:5000/v2/team/app/manifests/v1",
591 "127.0.0.1:5000/team/app:v1"
592 ),
593 "get https:// /v2/ /manifests/v1"
594 );
595 }
596}