diff --git a/.github/workflows/arm64.yml b/.github/workflows/arm64.yml index fee8e545aa..7077fb8793 100644 --- a/.github/workflows/arm64.yml +++ b/.github/workflows/arm64.yml @@ -109,6 +109,9 @@ jobs: version: ${{ env.SETUP_BUILDX_VERSION }} driver-opts: image=${{ env.SETUP_BUILDKIT_IMAGE }} buildkitd-flags: --debug + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 - name: Build dev image uses: docker/bake-action@v6 @@ -198,6 +201,9 @@ jobs: version: ${{ env.SETUP_BUILDX_VERSION }} driver-opts: image=${{ env.SETUP_BUILDKIT_IMAGE }} buildkitd-flags: --debug + - + name: Set up QEMU + uses: docker/setup-qemu-action@v3 - name: Build dev image uses: docker/bake-action@v6 diff --git a/daemon/containerd/image_history.go b/daemon/containerd/image_history.go index 32afe7d640..079e7d738b 100644 --- a/daemon/containerd/image_history.go +++ b/daemon/containerd/image_history.go @@ -2,9 +2,11 @@ package containerd import ( "context" + "fmt" "time" c8dimages "github.com/containerd/containerd/v2/core/images" + cerrdefs "github.com/containerd/errdefs" "github.com/containerd/log" "github.com/containerd/platforms" "github.com/distribution/reference" @@ -44,20 +46,33 @@ func (i *ImageService) ImageHistory(ctx context.Context, name string, platform * var ( history []*imagetype.HistoryResponseItem - sizes []int64 ) s := i.client.SnapshotService(i.snapshotter) diffIDs := ociImage.RootFS.DiffIDs + + sizes := make([]int64, len(diffIDs)) for i := range diffIDs { chainID := identity.ChainID(diffIDs[0 : i+1]).String() use, err := s.Usage(ctx, chainID) if err != nil { - return nil, err + if !cerrdefs.IsNotFound(err) { + return nil, fmt.Errorf("%w: failed to calculate disk usage of chain: %w", cerrdefs.ErrInternal, err) + } + + log.G(ctx).WithFields(log.Fields{ + "error": err, + "chainID": chainID, + "name": name, + "platform": platform, + }).Warn("failed to calculate disk usage of chain - snapshot not found") + + sizes[i] = 0 + continue } - sizes = append(sizes, use.Size) + sizes[i] = use.Size } for _, h := range ociImage.History { diff --git a/integration/image/history_test.go b/integration/image/history_test.go index 9dd7ebf3bf..9cb9be7b08 100644 --- a/integration/image/history_test.go +++ b/integration/image/history_test.go @@ -1,11 +1,21 @@ package image import ( + "context" + "io" "testing" + "github.com/containerd/platforms" + buildtypes "github.com/docker/docker/api/types/build" + imagetypes "github.com/docker/docker/api/types/image" + "github.com/docker/docker/client" "github.com/docker/docker/integration/internal/build" + "github.com/docker/docker/testutil" "github.com/docker/docker/testutil/fakecontext" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" + "gotest.tools/v3/skip" ) func TestAPIImagesHistory(t *testing.T) { @@ -31,3 +41,102 @@ func TestAPIImagesHistory(t *testing.T) { assert.Assert(t, found) } + +// TestAPIImageHistoryCrossPlatform tests the image history functionality +// when dealing with cross-platform image builds. +// This is a regression test for https://github.com/moby/moby/issues/50851 +// where `docker history` fails with "snapshot does not exist" error for +// images built for non-native platforms. +func TestAPIImageHistoryCrossPlatform(t *testing.T) { + skip.If(t, testEnv.DaemonInfo.OSType == "windows") + + ctx := setupTest(t) + apiClient := testEnv.APIClient() + + // Determine the non-native platform to use for testing + nonNativePlatform := ocispec.Platform{OS: testEnv.DaemonInfo.OSType, Architecture: "amd64"} + if testEnv.DaemonInfo.Architecture == "amd64" { + nonNativePlatform = ocispec.Platform{OS: testEnv.DaemonInfo.OSType, Architecture: "arm64"} + } + + // We need to pull the image for the non-native platform + // TODO: Make sure we have a multi-platform frozen image we could use + pullImageForPlatform(t, ctx, apiClient, "alpine", nonNativePlatform) + + dockerfile := "FROM alpine\nRUN true" + + buildCtx := fakecontext.New(t, t.TempDir(), fakecontext.WithDockerfile(dockerfile)) + defer buildCtx.Close() + + // Build the image for a non-native platform + resp, err := apiClient.ImageBuild(ctx, buildCtx.AsTarReader(t), buildtypes.ImageBuildOptions{ + Version: buildtypes.BuilderBuildKit, + Tags: []string{"cross-platform-test"}, + Platform: platforms.FormatAll(nonNativePlatform), + }) + assert.NilError(t, err) + defer resp.Body.Close() + + imgID := build.GetImageIDFromBody(t, resp.Body) + t.Cleanup(func() { + apiClient.ImageRemove(ctx, imgID, imagetypes.RemoveOptions{Force: true}) + }) + + testCases := []struct { + name string + imageRef string + options []client.ImageHistoryOption + }{ + { + name: "without explicit platform", + imageRef: imgID, + options: nil, + }, + { + name: "with explicit platform", + imageRef: imgID, + options: []client.ImageHistoryOption{client.ImageHistoryWithPlatform(nonNativePlatform)}, + }, + { + name: "using image reference", + imageRef: "cross-platform-test", + options: nil, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := testutil.StartSpan(ctx, t) + + hist, err := apiClient.ImageHistory(ctx, tc.imageRef, tc.options...) + + assert.NilError(t, err) + found := false + for _, layer := range hist { + if layer.ID == imgID { + found = true + break + } + } + assert.Assert(t, found, "History should contain the built image ID") + assert.Assert(t, is.Len(hist, 3)) + + for i, layer := range hist { + assert.Assert(t, layer.Size >= 0, "Layer %d should not have negative size", i) + } + }) + } +} + +func pullImageForPlatform(t *testing.T, ctx context.Context, apiClient client.APIClient, ref string, platform ocispec.Platform) { + pullResp, err := apiClient.ImagePull(ctx, ref, imagetypes.PullOptions{Platform: platforms.FormatAll(platform)}) + assert.NilError(t, err) + _, _ = io.Copy(io.Discard, pullResp) + + _, err = apiClient.ImageInspect(ctx, ref) + assert.NilError(t, err) + + t.Cleanup(func() { + _, _ = apiClient.ImageRemove(ctx, ref, imagetypes.RemoveOptions{Force: true}) + }) +} diff --git a/integration/internal/build/build.go b/integration/internal/build/build.go index 1be988cd51..91fb084711 100644 --- a/integration/internal/build/build.go +++ b/integration/internal/build/build.go @@ -1,16 +1,19 @@ package build import ( + "bytes" "context" "encoding/json" "io" "testing" + "github.com/containerd/containerd/v2/pkg/protobuf/proto" "github.com/docker/docker/api/types/build" "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" "github.com/docker/docker/pkg/jsonmessage" "github.com/docker/docker/testutil/fakecontext" + controlapi "github.com/moby/buildkit/api/services/control" "gotest.tools/v3/assert" ) @@ -30,24 +33,82 @@ func Do(ctx context.Context, t *testing.T, client client.APIClient, buildCtx *fa // GetImageIDFromBody reads the image ID from the build response body. func GetImageIDFromBody(t *testing.T, body io.Reader) string { - var ( - jm jsonmessage.JSONMessage - br build.Result - dec = json.NewDecoder(body) - ) + var id string + buf := bytes.NewBuffer(nil) + dec := json.NewDecoder(body) for { + var jm jsonmessage.JSONMessage err := dec.Decode(&jm) if err == io.EOF { break } assert.NilError(t, err) + + if handled := processBuildkitAux(t, &jm, &id); handled { + continue + } + + buf.Reset() + jm.Display(buf, false) + if buf.Len() == 0 { + continue + } + + t.Log(buf.String()) + if jm.Aux == nil { continue } - assert.NilError(t, json.Unmarshal(*jm.Aux, &br)) - assert.Assert(t, br.ID != "", "could not read image ID from build output") - break + + var br build.Result + if err := json.Unmarshal(*jm.Aux, &br); err == nil { + if br.ID == "" { + continue + } + id = br.ID + continue + } + + t.Log("Raw Aux", string(*jm.Aux)) } - io.Copy(io.Discard, body) - return br.ID + _, _ = io.Copy(io.Discard, body) + + assert.Assert(t, id != "", "could not read image ID from build output") + return id +} + +func processBuildkitAux(t *testing.T, jm *jsonmessage.JSONMessage, id *string) bool { + if jm.ID == "moby.buildkit.trace" { + var dt []byte + if err := json.Unmarshal(*jm.Aux, &dt); err != nil { + t.Log("Error unmarshalling buildkit trace", err) + return true + } + var sr controlapi.StatusResponse + if err := proto.Unmarshal(dt, &sr); err != nil { + t.Log("Error unmarshalling buildkit trace proto", err) + return true + } + for _, vtx := range sr.GetVertexes() { + t.Log(vtx.String()) + } + for _, vtx := range sr.GetStatuses() { + t.Log(vtx.String()) + } + for _, vtx := range sr.GetLogs() { + t.Log(vtx.String()) + } + for _, vtx := range sr.GetWarnings() { + t.Log(vtx.String()) + } + return true + } + if jm.ID == "moby.image.id" { + var br build.Result + if err := json.Unmarshal(*jm.Aux, &br); err == nil { + *id = br.ID + return true + } + } + return false }