diff --git a/pkg/k3d/k3d.go b/pkg/k3d/k3d.go index 84abc8063..571114e7f 100644 --- a/pkg/k3d/k3d.go +++ b/pkg/k3d/k3d.go @@ -551,6 +551,17 @@ func (c *Cluster) importImages(images ...string) error { return nil } + // localhost/ images only exist via local builds; nothing can pull them. + var pullable []string + for _, img := range needed { + if !strings.HasPrefix(normalizeImageRef(img), "localhost/") { + pullable = append(pullable, img) + } + } + if err := pullMissingFromHost(pullable); err != nil { + return err + } + args := append([]string{"image", "import", fmt.Sprintf("--cluster=%s", c.Name)}, needed...) if out, err := exec.Command("k3d", args...).CombinedOutput(); err != nil { return fmt.Errorf("%w: %s", err, out) @@ -563,6 +574,47 @@ func (c *Cluster) importImages(images ...string) error { return nil } +// pullMissingFromHost pulls any of the given images that the host Docker +// daemon doesn't list. k3d only imports images the daemon lists, and an image +// pre-pulled by CI isn't guaranteed to still be there when a test imports it. +func pullMissingFromHost(images []string) error { + out, err := exec.Command("docker", "image", "ls", "--format", "{{.Repository}}:{{.Tag}}").Output() + if err != nil { + return errors.Wrap(err, "listing host images") + } + present := make(map[string]bool) + for _, ref := range strings.Fields(string(out)) { + present[normalizeImageRef(ref)] = true + } + for _, img := range images { + if present[normalizeImageRef(img)] { + continue + } + if out, err := exec.Command("docker", "pull", "-q", img).CombinedOutput(); err != nil { + return errors.Wrapf(err, "pulling %q: %s", img, out) + } + } + return nil +} + +// normalizeImageRef mirrors containerd's docker-reference normalization so +// differently spelled refs to the same image compare equal: bare names gain +// docker.io/library/, hub-style names gain docker.io/, and refs without a +// tag get :latest. localhost/ and dotted-registry refs stay as given. +func normalizeImageRef(image string) string { + if strings.LastIndex(image, ":") < strings.LastIndex(image, "/") || !strings.Contains(image, ":") { + image += ":latest" + } + first, _, found := strings.Cut(image, "/") + switch { + case !found: + return "docker.io/library/" + image + case first != "localhost" && !strings.ContainsAny(first, ".:"): + return "docker.io/" + image + } + return image +} + func (c *Cluster) DeleteNode(name string) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/pkg/k3d/k3d_test.go b/pkg/k3d/k3d_test.go index 97cff15c0..9a5e0a006 100644 --- a/pkg/k3d/k3d_test.go +++ b/pkg/k3d/k3d_test.go @@ -11,6 +11,7 @@ package k3d import ( "fmt" + "os/exec" "sync" "testing" @@ -59,6 +60,23 @@ func TestIntegrationMultiInstance(t *testing.T) { assert.NoError(t, errors.Join(errs...)) } +func TestIntegrationImportRepullsMissingImage(t *testing.T) { + testutil.SkipIfNotIntegration(t) + + // Unused by any other test, so removing it from the host can't break a + // concurrent import. + const image = "busybox:1.37.0" + + name := "import-repull" + cluster, err := GetOrCreate(name, WithAgents(0)) + t.Cleanup(func() { forceCleanup(name) }) + require.NoError(t, err) + + // k3d alone fails this with "no valid images specified". + _ = exec.Command("docker", "image", "rm", "--force", image).Run() + require.NoError(t, cluster.ImportImage(image)) +} + func TestClusterCreateArgs(t *testing.T) { fastDetectionArgs := []string{ `--kube-controller-manager-arg=node-monitor-grace-period=10s@server:*`, @@ -82,3 +100,19 @@ func TestClusterCreateArgs(t *testing.T) { } }) } + +func TestNormalizeImageRef(t *testing.T) { + for given, want := range map[string]string{ + "localhost/redpanda-operator:dev": "localhost/redpanda-operator:dev", + "redpandadata/redpanda:v25.2.1": "docker.io/redpandadata/redpanda:v25.2.1", + "rancher/mirrored-library-busybox:1.36.1": "docker.io/rancher/mirrored-library-busybox:1.36.1", + "busybox:1.36.1": "docker.io/library/busybox:1.36.1", + "busybox": "docker.io/library/busybox:latest", + "redpandadata/redpanda": "docker.io/redpandadata/redpanda:latest", + "quay.io/jetstack/cert-manager-controller:v1.17": "quay.io/jetstack/cert-manager-controller:v1.17", + "ghcr.io/loft-sh/vcluster-pro:4.4.0": "ghcr.io/loft-sh/vcluster-pro:4.4.0", + "registry:5000/img": "registry:5000/img:latest", + } { + require.Equal(t, want, normalizeImageRef(given), "input %q", given) + } +}