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, Copy, PartialEq, Eq)]
37pub enum RegistryTransport {
38 Https,
40 PlainHttp,
46}
47
48impl RegistryTransport {
49 pub fn for_local_rewrite(rewritten: bool) -> Self {
53 if rewritten {
54 Self::PlainHttp
55 } else {
56 Self::Https
57 }
58 }
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum PulledImage {
64 Pulled,
66 Cached { pull_error: String },
69 Present,
71}
72
73pub async fn pull_image(
82 cli: &str,
83 docker_config: Option<&Path>,
84 reference: &str,
85 transport: RegistryTransport,
86) -> Result<PulledImage, String> {
87 pull_image_with(cli, docker_config, reference, transport, BASE_RETRY_DELAY).await
88}
89
90pub async fn ensure_image(
96 cli: &str,
97 docker_config: Option<&Path>,
98 reference: &str,
99) -> Result<PulledImage, String> {
100 ensure_image_with(cli, docker_config, reference, BASE_RETRY_DELAY).await
101}
102
103async fn ensure_image_with(
104 cli: &str,
105 docker_config: Option<&Path>,
106 reference: &str,
107 base_delay: Duration,
108) -> Result<PulledImage, String> {
109 if image_cached(cli, docker_config, reference).await {
110 return Ok(PulledImage::Present);
111 }
112 pull_image_with(
113 cli,
114 docker_config,
115 reference,
116 RegistryTransport::Https,
117 base_delay,
118 )
119 .await
120}
121
122async fn pull_image_with(
123 cli: &str,
124 docker_config: Option<&Path>,
125 reference: &str,
126 transport: RegistryTransport,
127 base_delay: Duration,
128) -> Result<PulledImage, String> {
129 let mut delay = base_delay;
130 let mut attempt = 1;
131 loop {
132 let mut cmd = Command::new(cli);
133 if let Some(p) = docker_config {
134 cmd.env("DOCKER_CONFIG", p);
135 }
136 let out = cmd
137 .args(pull_args(cli, reference, transport))
138 .output()
139 .await
140 .map_err(|e| format!("{cli} pull: {e}"))?;
141 if out.status.success() {
142 return Ok(PulledImage::Pulled);
143 }
144 let pull_error = String::from_utf8_lossy(&out.stderr).trim().to_string();
145 if !is_transient(&pull_error, reference) {
146 return Err(pull_error);
147 }
148 if image_cached(cli, docker_config, reference).await {
149 tracing::warn!(
150 image = %reference,
151 error = %pull_error,
152 "image pull failed transiently; using the locally cached image"
153 );
154 return Ok(PulledImage::Cached { pull_error });
155 }
156 if attempt >= MAX_PULL_ATTEMPTS {
157 return Err(pull_error);
158 }
159 tracing::info!(
160 image = %reference,
161 attempt,
162 retry_in_ms = delay.as_millis() as u64,
163 "image pull failed transiently; retrying"
164 );
165 tokio::time::sleep(delay).await;
166 delay *= 2;
167 attempt += 1;
168 }
169}
170
171fn pull_args(cli: &str, reference: &str, transport: RegistryTransport) -> Vec<String> {
175 let mut args = vec!["pull".to_string()];
176 if transport == RegistryTransport::PlainHttp && crate::container_net::is_podman_binary(cli) {
177 args.push("--tls-verify=false".to_string());
178 }
179 args.push(reference.to_string());
180 args
181}
182
183async fn image_cached(cli: &str, docker_config: Option<&Path>, reference: &str) -> bool {
188 let mut cmd = Command::new(cli);
189 if let Some(p) = docker_config {
190 cmd.env("DOCKER_CONFIG", p);
191 }
192 cmd.args(["image", "inspect", reference])
193 .stdout(std::process::Stdio::null())
194 .stderr(std::process::Stdio::null())
195 .status()
196 .await
197 .map(|s| s.success())
198 .unwrap_or(false)
199}
200
201fn is_transient(stderr: &str, reference: &str) -> bool {
213 const REFUSED: [&str; 6] = [
214 "manifest unknown",
215 "not found",
216 "denied",
217 "unauthorized",
218 "forbidden",
219 "does not exist",
220 ];
221 const TRANSIENT: [&str; 8] = [
222 "toomanyrequests",
223 "too many requests",
224 "rate exceeded",
225 "i/o timeout",
226 "tls handshake timeout",
227 "connection reset by peer",
228 "context deadline exceeded",
229 "request canceled while waiting for connection",
230 ];
231 let message = without_image_name(&stderr.to_ascii_lowercase(), reference);
232 if REFUSED.iter().any(|m| message.contains(m)) {
233 return false;
234 }
235 TRANSIENT.iter().any(|m| message.contains(m)) || has_server_error_status(&message)
236}
237
238fn without_image_name(message: &str, reference: &str) -> String {
246 let reference = reference.to_ascii_lowercase();
247 let untagged = reference.split('@').next().unwrap_or(&reference);
248 let repository = match (untagged.rfind(':'), untagged.rfind('/')) {
250 (Some(colon), Some(slash)) if colon < slash => untagged,
251 (Some(colon), _) => &untagged[..colon],
252 (None, _) => untagged,
253 };
254 let mut names = vec![reference.as_str(), repository];
255 names.extend(
256 repository
257 .match_indices('/')
258 .map(|(i, _)| &repository[i + 1..]),
259 );
260 if let Some((host, _)) = repository.split_once('/') {
262 if host.contains(['.', ':']) || host == "localhost" {
263 names.push(host);
264 }
265 }
266 names.sort_by_key(|n| std::cmp::Reverse(n.len()));
267
268 let mut out = message.to_string();
269 for name in names.into_iter().filter(|n| !n.is_empty()) {
270 out = remove_whole(&out, name);
271 }
272 out
273}
274
275fn remove_whole(haystack: &str, name: &str) -> String {
278 let is_name_char = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-');
279 let mut out = String::with_capacity(haystack.len());
280 let mut rest = haystack;
281 while let Some(i) = rest.find(name) {
282 let end = i + name.len();
283 let before = rest[..i].chars().next_back();
284 let after = rest[end..].chars().next();
285 if before.is_some_and(is_name_char) || after.is_some_and(is_name_char) {
286 out.push_str(&rest[..end]);
287 } else {
288 out.push_str(&rest[..i]);
289 out.push(' ');
290 }
291 rest = &rest[end..];
292 }
293 out.push_str(rest);
294 out
295}
296
297fn has_server_error_status(message: &str) -> bool {
302 let bytes = message.as_bytes();
303 ["status code ", "status: ", "status ", ": "]
304 .iter()
305 .flat_map(|prefix| message.match_indices(prefix).map(|(i, p)| i + p.len()))
306 .any(|start| {
307 let code = &bytes[start..bytes.len().min(start + 3)];
308 code.len() == 3
309 && code[0] == b'5'
310 && code.iter().all(u8::is_ascii_digit)
311 && !bytes
312 .get(start + 3)
313 .is_some_and(|c| c.is_ascii_alphanumeric())
314 })
315}
316
317#[cfg(all(test, unix))]
318mod tests {
319 use super::*;
320 use std::os::unix::fs::PermissionsExt;
321
322 struct FakeCli {
326 dir: tempfile::TempDir,
327 name: String,
328 }
329
330 impl FakeCli {
331 fn new(pull_failures: u32, pull_stderr: &str, cached: bool) -> Self {
332 Self::named("cli", pull_failures, pull_stderr, cached)
333 }
334
335 fn named(name: &str, pull_failures: u32, pull_stderr: &str, cached: bool) -> Self {
338 let dir = tempfile::tempdir().unwrap();
339 let script = format!(
340 r#"#!/bin/sh
341d="{dir}"
342echo "$*" >> "$d/calls.log"
343case "$1" in
344 pull)
345 n=$(cat "$d/pulls" 2>/dev/null || echo 0)
346 n=$((n + 1))
347 echo "$n" > "$d/pulls"
348 if [ "$n" -le {pull_failures} ]; then
349 echo '{pull_stderr}' >&2
350 exit 1
351 fi
352 exit 0 ;;
353 image)
354 [ "{cached}" = "true" ] && exit 0
355 echo 'Error: No such image' >&2
356 exit 1 ;;
357esac
358exit 2
359"#,
360 dir = dir.path().display(),
361 );
362 let path = dir.path().join(name);
363 std::fs::write(&path, script).unwrap();
364 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
365 let mut attempts = 0;
372 loop {
373 match std::process::Command::new(&path).arg("probe").output() {
374 Err(e)
375 if e.kind() == std::io::ErrorKind::ExecutableFileBusy && attempts < 200 =>
376 {
377 attempts += 1;
378 std::thread::sleep(Duration::from_millis(5));
379 }
380 Err(e) => panic!("fake CLI did not run: {e}"),
381 Ok(_) => break,
382 }
383 }
384 let _ = std::fs::remove_file(dir.path().join("calls.log"));
385 Self {
386 dir,
387 name: name.to_string(),
388 }
389 }
390
391 fn cli(&self) -> String {
392 self.dir.path().join(&self.name).display().to_string()
393 }
394
395 fn calls(&self) -> Vec<String> {
396 std::fs::read_to_string(self.dir.path().join("calls.log"))
397 .unwrap_or_default()
398 .lines()
399 .map(String::from)
400 .collect()
401 }
402
403 async fn pull(&self) -> Result<PulledImage, String> {
404 self.pull_ref("alpine:3.20").await
405 }
406
407 async fn pull_ref(&self, reference: &str) -> Result<PulledImage, String> {
408 self.pull_via(reference, RegistryTransport::Https).await
409 }
410
411 async fn pull_via(
412 &self,
413 reference: &str,
414 transport: RegistryTransport,
415 ) -> Result<PulledImage, String> {
416 pull_image_with(
417 &self.cli(),
418 None,
419 reference,
420 transport,
421 Duration::from_millis(1),
422 )
423 .await
424 }
425
426 async fn ensure(&self) -> Result<PulledImage, String> {
427 ensure_image_with(&self.cli(), None, "alpine:3.20", Duration::from_millis(1)).await
428 }
429 }
430
431 fn is_transient_for_test(stderr: &str) -> bool {
432 is_transient(stderr, "alpine:3.20")
433 }
434
435 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";
436
437 #[tokio::test]
438 async fn ensure_image_uses_a_cached_image_without_contacting_the_registry() {
439 let cli = FakeCli::new(u32::MAX, THROTTLED, true);
440 assert_eq!(cli.ensure().await, Ok(PulledImage::Present));
441 assert_eq!(cli.calls(), ["image inspect alpine:3.20"]);
442 }
443
444 #[tokio::test]
445 async fn ensure_image_retries_a_throttled_first_pull() {
446 let cli = FakeCli::new(2, THROTTLED, false);
447 assert_eq!(cli.ensure().await, Ok(PulledImage::Pulled));
448 let pulls = cli.calls().iter().filter(|c| c.starts_with("pull")).count();
449 assert_eq!(pulls, 3);
450 }
451
452 #[tokio::test]
453 async fn ensure_image_fails_on_a_refused_pull() {
454 let missing =
455 "Error response from daemon: manifest for alpine:3.20 not found: manifest unknown";
456 let cli = FakeCli::new(u32::MAX, missing, false);
457 assert_eq!(cli.ensure().await, Err(missing.to_string()));
458 }
459
460 #[tokio::test]
461 async fn podman_pulls_fakecloud_registry_over_plain_http() {
462 let cli = FakeCli::named("podman", 0, "", false);
465 let got = cli
466 .pull_via("127.0.0.1:4566/test:healthy", RegistryTransport::PlainHttp)
467 .await;
468 assert_eq!(got, Ok(PulledImage::Pulled));
469 assert_eq!(
470 cli.calls(),
471 ["pull --tls-verify=false 127.0.0.1:4566/test:healthy"]
472 );
473 }
474
475 #[tokio::test]
476 async fn podman_keeps_tls_for_upstream_registries() {
477 let cli = FakeCli::named("podman", 0, "", false);
478 let got = cli
479 .pull_via(
480 "public.ecr.aws/docker/library/alpine:3.20",
481 RegistryTransport::Https,
482 )
483 .await;
484 assert_eq!(got, Ok(PulledImage::Pulled));
485 assert_eq!(
486 cli.calls(),
487 ["pull public.ecr.aws/docker/library/alpine:3.20"]
488 );
489 }
490
491 #[tokio::test]
492 async fn docker_pulls_fakecloud_registry_without_a_tls_flag() {
493 let cli = FakeCli::named("docker", 0, "", false);
496 let got = cli
497 .pull_via("127.0.0.1:4566/test:healthy", RegistryTransport::PlainHttp)
498 .await;
499 assert_eq!(got, Ok(PulledImage::Pulled));
500 assert_eq!(cli.calls(), ["pull 127.0.0.1:4566/test:healthy"]);
501 }
502
503 #[test]
504 fn transport_follows_the_local_rewrite() {
505 assert_eq!(
506 RegistryTransport::for_local_rewrite(true),
507 RegistryTransport::PlainHttp
508 );
509 assert_eq!(
510 RegistryTransport::for_local_rewrite(false),
511 RegistryTransport::Https
512 );
513 }
514
515 #[tokio::test]
516 async fn a_successful_pull_needs_no_cache_check() {
517 let cli = FakeCli::new(0, "", false);
518 assert_eq!(cli.pull().await, Ok(PulledImage::Pulled));
519 assert_eq!(cli.calls(), ["pull alpine:3.20"]);
520 }
521
522 #[tokio::test]
523 async fn a_throttled_pull_uses_the_cached_image() {
524 let cli = FakeCli::new(u32::MAX, THROTTLED, true);
525 let got = cli.pull().await;
526 assert_eq!(
527 got,
528 Ok(PulledImage::Cached {
529 pull_error: THROTTLED.to_string()
530 })
531 );
532 assert_eq!(
533 cli.calls(),
534 ["pull alpine:3.20", "image inspect alpine:3.20"],
535 "a cached image is used at once, without retrying the pull"
536 );
537 }
538
539 #[tokio::test]
540 async fn a_rate_limited_pull_with_nothing_cached_is_retried() {
541 let cli = FakeCli::new(2, THROTTLED, false);
542 assert_eq!(cli.pull().await, Ok(PulledImage::Pulled));
543 let pulls = cli.calls().iter().filter(|c| c.starts_with("pull")).count();
544 assert_eq!(pulls, 3);
545 }
546
547 #[tokio::test]
548 async fn retries_stop_after_the_attempt_cap() {
549 let cli = FakeCli::new(u32::MAX, THROTTLED, false);
550 assert_eq!(cli.pull().await, Err(THROTTLED.to_string()));
551 let pulls = cli.calls().iter().filter(|c| c.starts_with("pull")).count();
552 assert_eq!(pulls, MAX_PULL_ATTEMPTS as usize);
553 }
554
555 #[tokio::test]
556 async fn a_missing_image_fails_without_retrying() {
557 let missing =
558 "Error response from daemon: manifest for alpine:nope not found: manifest unknown";
559 let cli = FakeCli::new(u32::MAX, missing, false);
560 assert_eq!(cli.pull().await, Err(missing.to_string()));
561 assert_eq!(
562 cli.calls(),
563 ["pull alpine:3.20"],
564 "a refused pull is neither retried nor checked against the cache"
565 );
566 }
567
568 #[tokio::test]
569 async fn a_refused_pull_fails_even_with_a_stale_cached_copy() {
570 for refused in [
573 "Error response from daemon: manifest for alpine:3.20 not found: manifest unknown",
574 "Error response from daemon: pull access denied for alpine, repository does not exist or may require authorization: denied",
575 ] {
576 let cli = FakeCli::new(u32::MAX, refused, true);
577 assert_eq!(cli.pull().await, Err(refused.to_string()));
578 assert_eq!(cli.calls(), ["pull alpine:3.20"]);
579 }
580 }
581
582 #[tokio::test]
583 async fn a_registry_server_error_uses_the_cached_image() {
584 let unavailable =
585 "Error response from daemon: received unexpected HTTP status: 503 Service Unavailable";
586 let cli = FakeCli::new(u32::MAX, unavailable, true);
587 assert_eq!(
588 cli.pull().await,
589 Ok(PulledImage::Cached {
590 pull_error: unavailable.to_string()
591 })
592 );
593 }
594
595 #[tokio::test]
596 async fn a_refused_pull_of_a_repository_named_like_a_marker_is_still_refused() {
597 let refused = "Error response from daemon: manifest for toomanyrequests:latest not found: manifest unknown: manifest unknown";
600 let cli = FakeCli::new(u32::MAX, refused, true);
601 assert_eq!(
602 cli.pull_ref("toomanyrequests:latest").await,
603 Err(refused.to_string())
604 );
605 assert_eq!(cli.calls(), ["pull toomanyrequests:latest"]);
606 }
607
608 #[tokio::test]
609 async fn a_throttled_pull_of_a_repository_named_like_a_refusal_uses_the_cache() {
610 let reference = "public.ecr.aws/acme/access-denied-page:1";
614 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";
615 let cli = FakeCli::new(u32::MAX, throttled, true);
616 assert_eq!(
617 cli.pull_ref(reference).await,
618 Ok(PulledImage::Cached {
619 pull_error: throttled.to_string()
620 })
621 );
622 assert_eq!(
623 cli.calls(),
624 [
625 format!("pull {reference}"),
626 format!("image inspect {reference}")
627 ]
628 );
629 }
630
631 #[test]
632 fn transient_detection_separates_retryable_from_refused() {
633 assert!(is_transient_for_test(THROTTLED));
634 assert!(is_transient_for_test(
635 "toomanyrequests: You have reached your pull rate limit."
636 ));
637 assert!(is_transient_for_test("Error: Rate exceeded"));
638 assert!(is_transient_for_test(
639 "received unexpected HTTP status: 502 Bad Gateway"
640 ));
641 assert!(is_transient_for_test(
642 "Get \"https://public.ecr.aws/v2/\": net/http: TLS handshake timeout"
643 ));
644 assert!(is_transient_for_test(
645 "read tcp 10.0.0.2:4431->1.2.3.4:443: read: connection reset by peer"
646 ));
647 assert!(!is_transient_for_test("manifest unknown"));
648 assert!(!is_transient_for_test("pull access denied for foo"));
649 assert!(!is_transient_for_test(
650 "unauthorized: authentication required"
651 ));
652 assert!(!is_transient_for_test(
653 "pull access denied for toomanyrequests, repository does not exist or may require authorization"
654 ));
655 }
656
657 #[test]
658 fn any_5xx_status_is_transient_whatever_its_reason_phrase() {
659 for msg in [
660 "received unexpected HTTP status: 500 Internal Server Error",
661 "unexpected status from GET request to https://r.example/v2/: 507 Insufficient Storage",
662 "unexpected status code 520",
663 "error pulling image: status: 599",
664 "unexpected status from HEAD request to https://r.example/v2/a/manifests/1: 503",
665 ] {
666 assert!(is_transient_for_test(msg), "{msg}");
667 }
668 for msg in [
669 "Get \"http://127.0.0.1:5000/v2/\": dial tcp 127.0.0.1:5000: connect: connection refused",
671 "unexpected status code 400 Bad Request",
672 "status: 5001",
673 ] {
674 assert!(!is_transient_for_test(msg), "{msg}");
675 }
676 }
677
678 #[test]
679 fn a_throttled_pull_of_a_repository_named_like_a_refusal_is_still_transient() {
680 let reference = "public.ecr.aws/acme/access-denied-page:1";
681 for msg in [
682 "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",
683 "Error response from daemon: toomanyrequests: Rate exceeded for public.ecr.aws/acme/access-denied-page:1",
684 ] {
685 assert!(is_transient(msg, reference), "{msg}");
686 }
687 assert!(!is_transient(
689 "Error response from daemon: manifest for public.ecr.aws/acme/access-denied-page:1 not found: manifest unknown",
690 reference
691 ));
692 }
693
694 #[test]
695 fn a_registry_host_named_like_a_refusal_does_not_hide_a_throttle() {
696 assert!(is_transient(
697 "Error response from daemon: unexpected status from HEAD request to https://denied.example/v2/app/manifests/1: 429 Too Many Requests",
698 "denied.example/app:1"
699 ));
700 }
701
702 #[test]
703 fn only_whole_names_are_removed() {
704 assert!(!is_transient(
706 "Error response from daemon: pull access denied for d, repository does not exist",
707 "d"
708 ));
709 assert_eq!(
710 without_image_name(
711 "pull access denied for docker.io/library/alpine",
712 "alpine:3.20"
713 ),
714 "pull access denied for docker.io/library/ "
715 );
716 assert_eq!(
717 without_image_name(
718 "get https://127.0.0.1:5000/v2/team/app/manifests/v1",
719 "127.0.0.1:5000/team/app:v1"
720 ),
721 "get https:// /v2/ /manifests/v1"
722 );
723 }
724}