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 5ff275b0..e3036d3f 100644 --- a/runners/dockerrun/docker_runner.go +++ b/runners/dockerrun/docker_runner.go @@ -2060,19 +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 { - img := &images[i] - for _, repoTag := range img.RepoTags { - if repoTag == imag.FullName() { - return true, nil - } + 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, nil + return true, nil } diff --git a/runners/dockerrun/image_exists_test.go b/runners/dockerrun/image_exists_test.go new file mode 100644 index 00000000..d5ae6ce5 --- /dev/null +++ b/runners/dockerrun/image_exists_test.go @@ -0,0 +1,83 @@ +//go:build !skip_infra + +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" +) + +// 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() + + // 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:")) + + 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") + + 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") + + 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") + + 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") +}