From 162f0fc6a62587d36867490f7743438470cd6f71 Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Thu, 21 May 2026 23:49:18 -0700 Subject: [PATCH] fix(dind): qualify single-segment refs with tag (alpine:3.20) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qualifyDockerHubRef returned the input unchanged for "alpine:3.20" because its host-detection heuristic looked at the first segment for either "." or ":" and bailed if either was present — meant to catch "localhost:5000/foo" and "gcr.io/bar", it also matched the tag colon in "alpine:3.20". Downstream, containerd's resolver wraps unqualified refs in "dummy://" so url.Parse can split them, and url.Parse("dummy://alpine:3.20") fails with `invalid port ":3.20" after host` — that's the error showing up as "image alpine:3.20 not found" in `docker run` from dind. Fix: use the slash as the disambiguator. - No slash → no path → single-segment name with optional tag/digest. Always qualifies with docker.io/library/. Covers "alpine", "alpine:3.20", "alpine@sha256:abc". - Slash present → segment before the slash is a host candidate, the existing ".:" or "localhost" check is correct for distinguishing "localhost:5000/foo" or "gcr.io/bar" from "myorg/myimage". The test previously documented the broken behavior as a "known limitation"; replace those assertions with the correct expected values and add coverage for `alpine:3.20` and `alpine@sha256:...`. --- pkg/dind/registry.go | 28 ++++++++++++++++++++-------- pkg/dind/registry_helpers_test.go | 13 ++++++++----- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/pkg/dind/registry.go b/pkg/dind/registry.go index 0641ed8..736eed4 100644 --- a/pkg/dind/registry.go +++ b/pkg/dind/registry.go @@ -338,20 +338,32 @@ func tailHex(s string) string { // qualifyDockerHubRef ensures a reference carries an explicit registry // host. containerd's resolver treats the first path segment of an // unqualified reference as the hostname (so "ephpm/ephemerd:tag" → host -// "ephpm" → DNS lookup fails). Docker CLI conventions default unqualified -// refs to docker.io, prepending "library/" for single-segment names like +// "ephpm" → DNS lookup fails; "alpine:3.20" → host "alpine", port "3.20" +// → url.Parse rejects). Docker CLI conventions default unqualified refs +// to docker.io, prepending "library/" for single-segment names like // "ubuntu". This helper applies the same rule. +// +// The slash is the disambiguator between a "host[:port]/path" reference +// and a "name[:tag]" reference. With a slash, the part before it is a +// host candidate and a dot or colon there means we've got a real +// registry host (gcr.io, localhost:5000, my-registry.com:443, etc.). +// Without a slash, there's no path and the whole string is a single- +// segment image name with an optional tag — always docker.io/library/. func qualifyDockerHubRef(ref string) string { - first := ref - if i := strings.IndexByte(ref, '/'); i >= 0 { - first = ref[:i] + i := strings.IndexByte(ref, '/') + if i < 0 { + // No slash → single-segment name (possibly with tag/digest). + // Always qualifies with docker.io/library/. This covers the + // `alpine`, `alpine:3.20`, `alpine@sha256:...` cases. + return "docker.io/library/" + ref } + first := ref[:i] if strings.ContainsAny(first, ".:") || first == "localhost" { + // Looks like a real host (has a dot, or has a port, or is + // literally "localhost") — already qualified. return ref } - if !strings.Contains(ref, "/") { - return "docker.io/library/" + ref - } + // Multi-segment name without a host (e.g. "myorg/myimage[:tag]"). return "docker.io/" + ref } diff --git a/pkg/dind/registry_helpers_test.go b/pkg/dind/registry_helpers_test.go index 43ac3de..1db8120 100644 --- a/pkg/dind/registry_helpers_test.go +++ b/pkg/dind/registry_helpers_test.go @@ -80,11 +80,14 @@ func TestQualifyDockerHubRef(t *testing.T) { }{ // Single-segment unqualified names get library/ prepended. {"alpine", "docker.io/library/alpine"}, - // Single-segment with tag — qualifyDockerHubRef sees the ":" and - // thinks "alpine:latest" is already a host, so it passes through - // unchanged. This is a known limitation; passing "alpine" without - // a tag is the supported form when callers want library/ added. - {"alpine:latest", "alpine:latest"}, + // Single-segment WITH tag: the previous implementation bailed on the + // tag colon and passed it through unchanged, which made containerd's + // resolver try to parse "alpine" as a host and "3.20" as a port + // number. The disambiguator is the slash — no slash means no path, + // so the colon must be a tag separator. + {"alpine:latest", "docker.io/library/alpine:latest"}, + {"alpine:3.20", "docker.io/library/alpine:3.20"}, + {"alpine@sha256:abc123", "docker.io/library/alpine@sha256:abc123"}, // Two-segment names get docker.io/ prepended. {"myorg/myimage", "docker.io/myorg/myimage"}, {"myorg/myimage:tag", "docker.io/myorg/myimage:tag"},