From e930bbd1dc3c8cb1d3e0e38251985d3b8b3a8e56 Mon Sep 17 00:00:00 2001 From: Udaya Tejas Date: Fri, 2 Oct 2026 11:12:06 -0400 Subject: [PATCH] fix(docker): pull single tag for tagless --from reference The Docker driver passed a bare reference as CreateImageOptions.from_image with no tag. The daemon interprets a tagless fromImage as a request for every tag in the repository and pulls them all (issue #4029). Normalize a pull reference by appending ':latest' when it has neither an explicit tag nor a digest, matching 'docker pull' and the Podman driver. Parsing inspects only the final path component so a registry port (e.g. 'registry:5000/team/app') is not mistaken for a tag and a digest-pinned reference ('...@sha256:...') is left untouched. Applied at both pull sites (pull_image and pull_runtime_image). Signed-off-by: Udaya Tejas --- crates/openshell-driver-docker/src/lib.rs | 42 ++++++++++++++++++++- crates/openshell-driver-docker/src/tests.rs | 29 ++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/crates/openshell-driver-docker/src/lib.rs b/crates/openshell-driver-docker/src/lib.rs index 950b2742e8..dad13d74a4 100644 --- a/crates/openshell-driver-docker/src/lib.rs +++ b/crates/openshell-driver-docker/src/lib.rs @@ -65,6 +65,7 @@ use openshell_sandbox_backend::boundary_protocol::{ }; use opentelemetry::trace::TraceContextExt as _; use sha2::{Digest as _, Sha256}; +use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::fmt::Write as _; use std::net::{IpAddr, Ipv4Addr, SocketAddr}; @@ -2968,7 +2969,7 @@ impl DockerComputeDriver { ); let mut stream = self.docker.create_image( Some(CreateImageOptions { - from_image: Some(image.to_string()), + from_image: Some(normalize_pull_reference(image).into_owned()), ..Default::default() }), None, @@ -6298,6 +6299,43 @@ fn container_name_for_sandbox(sandbox: &DriverSandbox) -> String { format!("{CONTAINER_NAME_PREFIX}{workspace}--{truncated_name}-{id_suffix}") } +/// Normalize an image reference for a pull request by appending `:latest` +/// when it carries neither an explicit tag nor a digest. +/// +/// The Docker daemon's `create_image` endpoint interprets a `fromImage` with +/// no tag as "every tag in the repository" and pulls them all, so a bare +/// `--from` reference such as `nicolaka/netshoot` must be resolved to a single +/// tag the way `docker pull` (and the Podman driver) do. +/// +/// Parsing mirrors [`openshell_core::driver_utils::supervisor_image_tag`]: only +/// the final path component may carry a tag, so a registry port such as the +/// `:5000` in `registry:5000/team/app` is not mistaken for one, and a +/// digest-pinned reference (`...@sha256:...`) already names an exact image and +/// is left untouched. A separate helper is needed because `supervisor_image_tag` +/// resolves a bare reference to an implied `latest` and so cannot distinguish a +/// reference that still needs a tag appended. +/// +/// Examples: +/// - `"nicolaka/netshoot"` → `"nicolaka/netshoot:latest"` +/// - `"foo:1.2"` → `"foo:1.2"` (already tagged) +/// - `"foo@sha256:abc"` → `"foo@sha256:abc"` (digest-pinned) +/// - `"registry:5000/team/app"` → `"registry:5000/team/app:latest"` +/// - `"registry:5000/team/app:v1"` → `"registry:5000/team/app:v1"` +fn normalize_pull_reference(image: &str) -> Cow<'_, str> { + // A digest-pinned reference already identifies an exact image. + if image.contains('@') { + return Cow::Borrowed(image); + } + // A `:` only denotes a tag in the final path component; earlier ones are + // registry ports (e.g. `registry:5000/team/app`). + let last_component = image.rsplit('/').next().unwrap_or(image); + if last_component.contains(':') { + Cow::Borrowed(image) + } else { + Cow::Owned(format!("{image}:latest")) + } +} + /// Docker container names may not end with `-`, `.`, or `_`. Truncation can /// leave one of those trailing, so strip them before returning. fn trim_container_name_tail(mut value: String) -> String { @@ -6329,7 +6367,7 @@ fn sanitize_docker_name(value: &str) -> String { async fn pull_runtime_image(docker: &Docker, image: &str, role: &str) -> CoreResult<()> { let mut stream = docker.create_image( Some(CreateImageOptions { - from_image: Some(image.to_string()), + from_image: Some(normalize_pull_reference(image).into_owned()), ..Default::default() }), None, diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 08576f422a..435c2b7b98 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -3688,3 +3688,32 @@ fn admission_provisioning_failure_distinguishes_denials_from_lookup_failures() { assert_eq!(lookup.reason, "ResourceAdmissionLookupFailed"); assert_eq!(lookup.message, "inspect docker volume failed"); } + +#[test] +fn normalize_pull_reference_appends_latest_only_when_untagged() { + // A bare repository reference must resolve to a single tag so the daemon + // does not pull every tag in the repository (issue #4029). + assert_eq!( + normalize_pull_reference("nicolaka/netshoot"), + "nicolaka/netshoot:latest" + ); + // An explicit tag is preserved untouched. + assert_eq!(normalize_pull_reference("foo:1.2"), "foo:1.2"); + // A digest-pinned reference already names an exact image. + assert_eq!( + normalize_pull_reference( + "foo@sha256:0000000000000000000000000000000000000000000000000000000000000000" + ), + "foo@sha256:0000000000000000000000000000000000000000000000000000000000000000" + ); + // A registry port is not a tag, so `:latest` is still appended. + assert_eq!( + normalize_pull_reference("registry:5000/team/app"), + "registry:5000/team/app:latest" + ); + // A registry port combined with an explicit tag is left unchanged. + assert_eq!( + normalize_pull_reference("registry:5000/team/app:v1"), + "registry:5000/team/app:v1" + ); +}