From 721be68caebb55667dbee2c327b431eb55b45ae2 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:53:55 -0400 Subject: [PATCH 1/3] fix(dockerrun): a local image named by digest is present, not something to pull MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageExists compared RepoTags only. An image named by digest — the form a local build hands back, and the one a run then asks for — was read as absent whether Docker held it under RepoDigests or only under its content-addressable ID, and getImageIfNotPresent went on to pull an image that was already here. With the registry absent or refused, that pull is the failure the user sees. imageMatchesReference matches a tag on RepoTags as before, and a digest on either any RepoDigests entry carrying that digest, under whatever repository name it was pulled as, or the image ID, which is the only digest an image built or loaded locally and never pushed has. Co-Authored-By: Claude Fable 5.1 --- runners/dockerrun/docker_runner.go | 37 ++++++++++++++++++++++---- runners/dockerrun/image_exists_test.go | 36 +++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 runners/dockerrun/image_exists_test.go diff --git a/runners/dockerrun/docker_runner.go b/runners/dockerrun/docker_runner.go index 5ff275b0..11619264 100644 --- a/runners/dockerrun/docker_runner.go +++ b/runners/dockerrun/docker_runner.go @@ -2067,12 +2067,39 @@ func ImageExists(ctx context.Context, c *client.Client, imag *resources.DockerIm return false, w.Wrapf(err, "cannot list images") } for i := range images { - img := &images[i] - for _, repoTag := range img.RepoTags { - if repoTag == imag.FullName() { - return true, nil - } + if imageMatchesReference(&images[i], imag) { + return true, nil } } return false, nil } + +// imageMatchesReference reports whether a local image is the one imag names. +// +// A tag reference is matched on RepoTags, as before. A digest reference +// (name@sha256:…) is matched on the digest alone, because that is what a digest +// is: the same manifest pulled under another repository name is the same bytes, +// and Docker records it in RepoDigests per name it was pulled as. An image +// built or loaded here and never pushed has no RepoDigests at all; its only +// digest is the content-addressable ID, which is what a local build hands back +// and what a caller then asks for. Comparing RepoTags only — the behaviour this +// replaces — read every such image as absent and sent the caller to pull an +// image that was already present. +func imageMatchesReference(img *image.Summary, imag *resources.DockerImage) bool { + wanted := imag.FullName() + if slices.Contains(img.RepoTags, wanted) { + return true + } + if imag.Digest == "" { + return false + } + if img.ID == imag.Digest { + return true + } + for _, repoDigest := range img.RepoDigests { + if _, digest, ok := strings.Cut(repoDigest, "@"); ok && digest == imag.Digest { + return true + } + } + return false +} diff --git a/runners/dockerrun/image_exists_test.go b/runners/dockerrun/image_exists_test.go new file mode 100644 index 00000000..b55159d8 --- /dev/null +++ b/runners/dockerrun/image_exists_test.go @@ -0,0 +1,36 @@ +package dockerrun + +import ( + "testing" + + "github.com/codefly-dev/core/resources" + "github.com/docker/docker/api/types/image" + "github.com/stretchr/testify/require" +) + +// TestImageMatchesReference: a tag is found on RepoTags; a digest reference is +// found on RepoDigests under any repository name, or on the ID of an image that +// was built here and never pushed; and neither form matches the other's image. +func TestImageMatchesReference(t *testing.T) { + const digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + const other = "sha256:2222222222222222222222222222222222222222222222222222222222222222" + byTag := &resources.DockerImage{Repository: "ghcr.io/codefly-dev", Name: "documents/docx", Tag: "0.2.0"} + byDigest := &resources.DockerImage{Repository: "ghcr.io/codefly-dev", Name: "documents/docx", Tag: "0.2.0", Digest: digest} + + tagged := &image.Summary{ID: other, RepoTags: []string{"ghcr.io/codefly-dev/documents/docx:0.2.0"}} + require.True(t, imageMatchesReference(tagged, byTag)) + require.False(t, imageMatchesReference(tagged, byDigest), "a tag says nothing about the digest") + + pulled := &image.Summary{ID: other, RepoDigests: []string{"ghcr.io/codefly-dev/documents/docx@" + digest}} + require.True(t, imageMatchesReference(pulled, byDigest)) + require.False(t, imageMatchesReference(pulled, byTag), "a digest-only image carries no tag") + + mirrored := &image.Summary{ID: other, RepoDigests: []string{"mirror.example/docx@" + digest}} + require.True(t, imageMatchesReference(mirrored, byDigest), "the digest names the bytes, not the repository") + + builtLocally := &image.Summary{ID: digest} + require.True(t, imageMatchesReference(builtLocally, byDigest), "a local build has no RepoDigests; its ID is its digest") + + different := &image.Summary{ID: other, RepoDigests: []string{"ghcr.io/codefly-dev/documents/docx@" + other}} + require.False(t, imageMatchesReference(different, byDigest)) +} From 6403dd55964ba29220598292988aec75b6b471f0 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:59:52 -0400 Subject: [PATCH 2/3] fix(dockerrun): ImageExists resolves the exact reference through the daemon, not a digest scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the first head: matching any RepoDigests entry or the raw image ID reported present a reference the daemon cannot resolve — a digest resolves only under the repository name the image carries it as, so the same bytes requested under another name inspect as "No such image" while the scan said yes, and the container create then failed on the very reference the check had approved. ImageExists now asks the daemon to resolve the exact reference with ImageInspect, the resolution the create performs. Not found is false with no error; any other daemon failure is an error, so "cannot tell" never becomes "pull it". A locally imported image is present under its tag and under its own name@sha256:, which is the form a local build hands back and the case the RepoTags-only comparison read as absent. The test imports an image from a tarball (nothing pulled) and covers: present by tag; present by name@digest; the same digest under another repository name is absent, not present; an unknown tag is absent; an unreachable daemon is an error. docs/service-image-plumbing.md states the behaviour. Co-Authored-By: Claude Fable 5.1 --- docs/service-image-plumbing.md | 15 +++++ runners/dockerrun/docker_runner.go | 56 ++++++----------- runners/dockerrun/image_exists_test.go | 87 +++++++++++++++++++------- 3 files changed, 99 insertions(+), 59 deletions(-) diff --git a/docs/service-image-plumbing.md b/docs/service-image-plumbing.md index d714d0ae..b37720e7 100644 --- a/docs/service-image-plumbing.md +++ b/docs/service-image-plumbing.md @@ -199,3 +199,18 @@ The credential boundary and repository dispatch restrictions are described in `cmd/image-sbom` stays in the services: it chooses service images, platforms, output paths, and CLI behavior using these APIs. Adoption follows the core release by pinning it and removing service-local copies. + + +## Image presence at run time + +Before a local container run pulls an image, `runners/dockerrun.ImageExists` +asks the daemon to resolve the **exact reference** the run names — `name:tag` +or `name@sha256:…` — with `ImageInspect`, the same resolution the later +container create performs. A digest reference resolves only under the +repository name the image carries it as: the same bytes under another name, +or an image whose ID merely equals the digest, are not that reference, and a +match on them would report present an image the create then cannot find. A +reference the daemon cannot resolve is an answer (`false`, no error) and the +pull follows; any other daemon failure is an error, so "cannot tell" never +becomes "pull it". An image built or imported locally and never pushed is +present under its own `name@sha256:` and under its tag, and is not pulled. diff --git a/runners/dockerrun/docker_runner.go b/runners/dockerrun/docker_runner.go index 11619264..e3036d3f 100644 --- a/runners/dockerrun/docker_runner.go +++ b/runners/dockerrun/docker_runner.go @@ -2060,46 +2060,26 @@ func (docker *DockerEnvironment) ImageExists(ctx context.Context, imag *resource } +// ImageExists reports whether the daemon can resolve the exact reference imag +// names — a tag (name:tag) or a digest (name@sha256:…) — the way the later +// container create will have to. It asks the daemon rather than reading the +// image list: a digest reference resolves only under the repository name the +// image carries it as, so the same bytes under another name, or an image whose +// ID merely equals the digest, are NOT this reference, and a scan that matched +// them reported an image present that the create then could not find. Before +// this it compared RepoTags alone, which read every digest reference — the form +// a local build hands back — as absent and sent the caller to pull it. +// +// Not found is an answer, false with no error; any other daemon failure is an +// error, because "cannot tell" must not become "pull it". func ImageExists(ctx context.Context, c *client.Client, imag *resources.DockerImage) (bool, error) { w := wool.Get(ctx).In("Docker.ImageExists") - images, err := c.ImageList(ctx, image.ListOptions{}) - if err != nil { - return false, w.Wrapf(err, "cannot list images") - } - for i := range images { - if imageMatchesReference(&images[i], imag) { - return true, nil - } - } - return false, nil -} - -// imageMatchesReference reports whether a local image is the one imag names. -// -// A tag reference is matched on RepoTags, as before. A digest reference -// (name@sha256:…) is matched on the digest alone, because that is what a digest -// is: the same manifest pulled under another repository name is the same bytes, -// and Docker records it in RepoDigests per name it was pulled as. An image -// built or loaded here and never pushed has no RepoDigests at all; its only -// digest is the content-addressable ID, which is what a local build hands back -// and what a caller then asks for. Comparing RepoTags only — the behaviour this -// replaces — read every such image as absent and sent the caller to pull an -// image that was already present. -func imageMatchesReference(img *image.Summary, imag *resources.DockerImage) bool { - wanted := imag.FullName() - if slices.Contains(img.RepoTags, wanted) { - return true - } - if imag.Digest == "" { - return false - } - if img.ID == imag.Digest { - return true - } - for _, repoDigest := range img.RepoDigests { - if _, digest, ok := strings.Cut(repoDigest, "@"); ok && digest == imag.Digest { - return true + reference := imag.FullName() + if _, err := c.ImageInspect(ctx, reference); err != nil { + if client.IsErrNotFound(err) { + return false, nil } + return false, w.Wrapf(err, "cannot inspect image %s", reference) } - return false + return true, nil } diff --git a/runners/dockerrun/image_exists_test.go b/runners/dockerrun/image_exists_test.go index b55159d8..c2de70b9 100644 --- a/runners/dockerrun/image_exists_test.go +++ b/runners/dockerrun/image_exists_test.go @@ -1,36 +1,81 @@ -package dockerrun +package dockerrun_test import ( + "archive/tar" + "bytes" + "context" + "fmt" + "io" + "strings" "testing" + "time" "github.com/codefly-dev/core/resources" + "github.com/codefly-dev/core/runners/dockerrun" "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" "github.com/stretchr/testify/require" ) -// TestImageMatchesReference: a tag is found on RepoTags; a digest reference is -// found on RepoDigests under any repository name, or on the ID of an image that -// was built here and never pushed; and neither form matches the other's image. -func TestImageMatchesReference(t *testing.T) { - const digest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" - const other = "sha256:2222222222222222222222222222222222222222222222222222222222222222" - byTag := &resources.DockerImage{Repository: "ghcr.io/codefly-dev", Name: "documents/docx", Tag: "0.2.0"} - byDigest := &resources.DockerImage{Repository: "ghcr.io/codefly-dev", Name: "documents/docx", Tag: "0.2.0", Digest: digest} +// TestImageExistsResolvesTheExactReference: an image imported here, never +// pulled, is present under its tag and under its own name@digest, which is the +// form a local build hands back; the same digest requested under another +// repository name is a reference the daemon cannot resolve and is reported +// absent, not present; and a daemon that cannot be reached is an error, not an +// absence. +func TestImageExistsResolvesTheExactReference(t *testing.T) { + requireDocker(t) + ctx := context.Background() + c, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + require.NoError(t, err) + defer c.Close() - tagged := &image.Summary{ID: other, RepoTags: []string{"ghcr.io/codefly-dev/documents/docx:0.2.0"}} - require.True(t, imageMatchesReference(tagged, byTag)) - require.False(t, imageMatchesReference(tagged, byDigest), "a tag says nothing about the digest") + // Imported from a tarball, so nothing is pulled: the image exists only here. + var tarball bytes.Buffer + tw := tar.NewWriter(&tarball) + content := []byte("codefly image-exists test " + time.Now().String()) + require.NoError(t, tw.WriteHeader(&tar.Header{Name: "marker", Mode: 0o600, Size: int64(len(content))})) + _, err = tw.Write(content) + require.NoError(t, err) + require.NoError(t, tw.Close()) + name := fmt.Sprintf("codefly-test/image-exists-%d", time.Now().UnixNano()) + tag := "local" + body, err := c.ImageImport(ctx, image.ImportSource{Source: &tarball, SourceName: "-"}, name+":"+tag, image.ImportOptions{}) + require.NoError(t, err) + _, _ = io.Copy(io.Discard, body) + _ = body.Close() + inspected, err := c.ImageInspect(ctx, name+":"+tag) + require.NoError(t, err) + defer func() { _, _ = c.ImageRemove(ctx, inspected.ID, image.RemoveOptions{Force: true}) }() + digest := inspected.ID + require.True(t, strings.HasPrefix(digest, "sha256:")) - pulled := &image.Summary{ID: other, RepoDigests: []string{"ghcr.io/codefly-dev/documents/docx@" + digest}} - require.True(t, imageMatchesReference(pulled, byDigest)) - require.False(t, imageMatchesReference(pulled, byTag), "a digest-only image carries no tag") + byTag := &resources.DockerImage{Name: name, Tag: tag} + exists, err := dockerrun.ImageExists(ctx, c, byTag) + require.NoError(t, err) + require.True(t, exists, "present under its tag") - mirrored := &image.Summary{ID: other, RepoDigests: []string{"mirror.example/docx@" + digest}} - require.True(t, imageMatchesReference(mirrored, byDigest), "the digest names the bytes, not the repository") + byDigest := &resources.DockerImage{Name: name, Tag: tag, Digest: digest} + exists, err = dockerrun.ImageExists(ctx, c, byDigest) + require.NoError(t, err) + require.True(t, exists, "present under its own name@digest, the form a local build hands back") - builtLocally := &image.Summary{ID: digest} - require.True(t, imageMatchesReference(builtLocally, byDigest), "a local build has no RepoDigests; its ID is its digest") + otherRepository := &resources.DockerImage{Repository: "example.invalid", Name: "review-no-such-image", Digest: digest} + exists, err = dockerrun.ImageExists(ctx, c, otherRepository) + require.NoError(t, err, "a reference the daemon cannot resolve is an answer, not a failure") + require.False(t, exists, "the same digest under another repository name is not a reference this daemon resolves") - different := &image.Summary{ID: other, RepoDigests: []string{"ghcr.io/codefly-dev/documents/docx@" + other}} - require.False(t, imageMatchesReference(different, byDigest)) + otherTag := &resources.DockerImage{Name: name, Tag: "never-tagged"} + exists, err = dockerrun.ImageExists(ctx, c, otherTag) + require.NoError(t, err) + require.False(t, exists) + + unreachable, err := client.NewClientWithOpts(client.WithHost("tcp://127.0.0.1:1"), client.WithVersion(c.ClientVersion())) + require.NoError(t, err) + defer unreachable.Close() + deadline, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + _, err = dockerrun.ImageExists(deadline, unreachable, byTag) + require.Error(t, err, "a daemon that cannot answer is an error, never an absence that leads to a pull") + require.ErrorContains(t, err, "cannot inspect image") } From ee1ede134e39978bfcc053bc138e39449bd253ee Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:12:32 -0400 Subject: [PATCH 3/3] test(dockerrun): the exact-reference image test carries the package's !skip_infra constraint requireDocker lives behind //go:build !skip_infra; a test file without it broke go test -tags skip_infra ./runners/dockerrun with 'undefined: requireDocker'. Co-Authored-By: Claude Fable 5.1 --- runners/dockerrun/image_exists_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/runners/dockerrun/image_exists_test.go b/runners/dockerrun/image_exists_test.go index c2de70b9..d5ae6ce5 100644 --- a/runners/dockerrun/image_exists_test.go +++ b/runners/dockerrun/image_exists_test.go @@ -1,3 +1,5 @@ +//go:build !skip_infra + package dockerrun_test import (