c8d/history: Fix non-native platforms

When building a non-native platform, it's not unpacked by default.
History tries to read the disk usage of all the layer and it doesn't
handle missing snapshots gracefully.

This patch fixes this.

Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
This commit is contained in:
Paweł Gronowski
2025-09-01 16:03:34 +02:00
parent ad830a47af
commit 27fca93b65
2 changed files with 127 additions and 4 deletions

View File

@@ -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 {

View File

@@ -1,11 +1,20 @@
package image
import (
"context"
"io"
"testing"
"github.com/moby/moby/v2/integration/internal/build"
"github.com/containerd/platforms"
buildtypes "github.com/moby/moby/api/types/build"
"github.com/moby/moby/client"
build "github.com/moby/moby/v2/integration/internal/build"
"github.com/moby/moby/v2/testutil"
"github.com/moby/moby/v2/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 +40,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, client.ImageRemoveOptions{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, client.ImagePullOptions{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, client.ImageRemoveOptions{Force: true})
})
}