diff --git a/earthfile2llb/with_docker_run_base.go b/earthfile2llb/with_docker_run_base.go index bbdf63fe81..feb7d2e71c 100644 --- a/earthfile2llb/with_docker_run_base.go +++ b/earthfile2llb/with_docker_run_base.go @@ -234,6 +234,22 @@ func composeParams(opt WithDockerOpt) []string { } } +// stripImageDigest drops a trailing `@sha256:…` (or other algo) from an image +// name so it's a valid `docker tag` target — dockerd refuses digest-bearing +// tags. Digest-pinned compose services still get verified at `docker compose +// up` time: compose re-fetches the manifest upstream (the pre-pull's retagged +// image has no RepoDigest) and reuses local layers. So the pre-pull gives +// layer-level dedup but no offline path for digest-pinned services. +// See issue #512. +func stripImageDigest(name string) string { +i := strings.Index(name, "@") +if i >= 0 { + return name[:i] +} + + return name +} + func platformIncompatMsg(platr *platutil.Resolver) string { currentPlatStr := platr.Materialize(platr.Current()).String() nativePlatStr := platr.Materialize(platutil.NativePlatform).String() diff --git a/earthfile2llb/with_docker_run_base_test.go b/earthfile2llb/with_docker_run_base_test.go new file mode 100644 index 0000000000..f402908b5c --- /dev/null +++ b/earthfile2llb/with_docker_run_base_test.go @@ -0,0 +1,67 @@ +package earthfile2llb + +import "testing" + +// Test_stripImageDigest covers the retag target produced for WITH DOCKER pulls. +// Docker engine refuses `docker tag ` when carries an +// `@sha256:...` (or any digest-algorithm) suffix, so we strip it before retagging. +// Regression: https://github.com/EarthBuild/earthbuild/issues/512 +func Test_stripImageDigest(t *testing.T) { + t.Parallel() + + const digest64 = "d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc" + + tests := []struct { + name string + in string + want string + }{ + { + name: "no digest, tag-only", + in: "alpine:3.20", + want: "alpine:3.20", + }, + { + name: "no digest, no tag", + in: "alpine", + want: "alpine", + }, + { + name: "tag plus digest", + in: "alpine:3.20@sha256:" + digest64, + want: "alpine:3.20", + }, + { + name: "digest only, no tag", + in: "alpine@sha256:" + digest64, + want: "alpine", + }, + { + name: "registry path with port, tag, digest", + in: "registry.example.com:5000/team/app:v1.2.3@sha256:" + digest64, + want: "registry.example.com:5000/team/app:v1.2.3", + }, + { + name: "non-sha256 digest algorithm", + in: "alpine:3.20@sha512:" + digest64 + digest64, + want: "alpine:3.20", + }, + { + name: "empty", + in: "", + want: "", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + got := stripImageDigest(tc.in) + if got != tc.want { + t.Errorf("stripImageDigest(%q) = %q; want %q", tc.in, got, tc.want) + } + }) + } +} diff --git a/earthfile2llb/with_docker_run_local_reg.go b/earthfile2llb/with_docker_run_local_reg.go index ea53edd6ae..a0db60b852 100644 --- a/earthfile2llb/with_docker_run_local_reg.go +++ b/earthfile2llb/with_docker_run_local_reg.go @@ -108,12 +108,16 @@ func (w *withDockerRunLocalReg) Run(ctx context.Context, args []string, opt With return err } + // Strip any `@sha256:…` before retagging — `docker tag` rejects + // digest-bearing targets. See stripImageDigest. Issue #512. + retagAs := stripImageDigest(result.FinalImageName) + err = w.c.containerFrontend.ImageTag(ctx, containerutil.ImageTag{ SourceRef: pullImage, - TargetRef: result.FinalImageName, + TargetRef: retagAs, }) if err != nil { - return errors.Wrapf(err, "tag image %q", result.FinalImageName) + return errors.Wrapf(err, "tag image %q", retagAs) } } diff --git a/earthfile2llb/with_docker_run_reg.go b/earthfile2llb/with_docker_run_reg.go index a7d2f35d7c..94cbe266dd 100644 --- a/earthfile2llb/with_docker_run_reg.go +++ b/earthfile2llb/with_docker_run_reg.go @@ -189,10 +189,14 @@ func (w *withDockerRunRegistry) Run(ctx context.Context, args []string, opt With imgsWithDigests := make([]string, 0, len(results)) for _, result := range results { + // Strip any `@sha256:…` before handing the name to the wrapper's retag + // step — `docker tag` rejects digest-bearing targets. The pin is still + // enforced at compose-up time (see stripImageDigest). Issue #512. + retagAs := stripImageDigest(result.FinalImageName) // This will be decoded in the wrapper. if result.NewInterImgFormat { pullImages = append( - pullImages, fmt.Sprintf("%s|%s", result.IntermediateImageName, result.FinalImageName)) + pullImages, fmt.Sprintf("%s|%s", result.IntermediateImageName, retagAs)) } else { pullImages = append(pullImages, result.IntermediateImageName) } diff --git a/tests/with-docker-compose/Earthfile b/tests/with-docker-compose/Earthfile index e3dd625e95..e36f148849 100644 --- a/tests/with-docker-compose/Earthfile +++ b/tests/with-docker-compose/Earthfile @@ -11,6 +11,7 @@ all: --INDEX=3 \ --INDEX=4 \ --INDEX=5 + BUILD +test-digest-pinned-image print-countries: FROM jbergknoff/postgresql-client:latest @@ -29,3 +30,11 @@ test: RUN while ! pg_isready --host=localhost --port=5432 --dbname=iso3166 --username=postgres; do sleep 1; done ;\ docker-compose up --exit-code-from print-countries print-countries | grep Brazil END + +# Regression: https://github.com/EarthBuild/earthbuild/issues/512 — a compose +# service pinned by `name:tag@sha256:…` must not break the wrapper's retag step. +test-digest-pinned-image: + COPY docker-compose.digest.yml ./docker-compose.yml + WITH DOCKER --compose docker-compose.yml + RUN docker compose up --exit-code-from app | grep 'hello from digest-pinned' + END diff --git a/tests/with-docker-compose/docker-compose.digest.yml b/tests/with-docker-compose/docker-compose.digest.yml new file mode 100644 index 0000000000..f52436d865 --- /dev/null +++ b/tests/with-docker-compose/docker-compose.digest.yml @@ -0,0 +1,4 @@ +services: + app: + image: alpine:3.20@sha256:d9e853e87e55526f6b2917df91a2115c36dd7c696a35be12163d44e6e2a4b6bc + command: ["sh", "-c", "echo hello from digest-pinned alpine"]