From 582de4bc3cd4385e28d2cea8a9264687fb8b64e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Gronowski?= Date: Mon, 26 Feb 2024 17:43:41 +0100 Subject: [PATCH] c8d/list: Add TestImageList MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add unit test for `Images` implementation. Signed-off-by: Paweł Gronowski --- daemon/containerd/image_list_test.go | 243 ++++++++++++++++++ daemon/containerd/image_test.go | 13 + .../testutils/specialimage/twoplatform.go | 117 +++++++++ 3 files changed, 373 insertions(+) create mode 100644 daemon/containerd/image_list_test.go create mode 100644 internal/testutils/specialimage/twoplatform.go diff --git a/daemon/containerd/image_list_test.go b/daemon/containerd/image_list_test.go new file mode 100644 index 0000000000..eddd3ca2bb --- /dev/null +++ b/daemon/containerd/image_list_test.go @@ -0,0 +1,243 @@ +package containerd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/containerd/containerd" + "github.com/containerd/containerd/content" + "github.com/containerd/containerd/images" + "github.com/containerd/containerd/metadata" + "github.com/containerd/containerd/namespaces" + "github.com/containerd/containerd/snapshots" + "github.com/containerd/log/logtest" + imagetypes "github.com/docker/docker/api/types/image" + daemonevents "github.com/docker/docker/daemon/events" + "github.com/docker/docker/internal/testutils/specialimage" + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "gotest.tools/v3/assert" + is "gotest.tools/v3/assert/cmp" +) + +func imagesFromIndex(index ...*ocispec.Index) []images.Image { + var imgs []images.Image + for _, idx := range index { + for _, desc := range idx.Manifests { + imgs = append(imgs, images.Image{ + Name: desc.Annotations["io.containerd.image.name"], + Target: desc, + }) + } + } + return imgs +} + +func TestImageList(t *testing.T) { + ctx := namespaces.WithNamespace(context.TODO(), "testing") + + blobsDir := t.TempDir() + + multilayer, err := specialimage.MultiLayer(blobsDir) + assert.NilError(t, err) + + twoplatform, err := specialimage.TwoPlatform(blobsDir) + assert.NilError(t, err) + + cs := &blobsDirContentStore{blobs: filepath.Join(blobsDir, "blobs/sha256")} + + snapshotter := &testSnapshotterService{} + + for _, tc := range []struct { + name string + images []images.Image + opts imagetypes.ListOptions + + check func(*testing.T, []*imagetypes.Summary) // Change the type of the check function + }{ + { + name: "one multi-layer image", + images: imagesFromIndex(multilayer), + check: func(t *testing.T, all []*imagetypes.Summary) { // Change the type of the check function + assert.Check(t, is.Len(all, 1)) + + assert.Check(t, is.Equal(all[0].ID, multilayer.Manifests[0].Digest.String())) + assert.Check(t, is.DeepEqual(all[0].RepoTags, []string{"multilayer:latest"})) + }, + }, + { + name: "one image with two platforms is still one entry", + images: imagesFromIndex(twoplatform), + check: func(t *testing.T, all []*imagetypes.Summary) { // Change the type of the check function + assert.Check(t, is.Len(all, 1)) + + assert.Check(t, is.Equal(all[0].ID, twoplatform.Manifests[0].Digest.String())) + assert.Check(t, is.DeepEqual(all[0].RepoTags, []string{"twoplatform:latest"})) + }, + }, + { + name: "two images are two entries", + images: imagesFromIndex(multilayer, twoplatform), + check: func(t *testing.T, all []*imagetypes.Summary) { // Change the type of the check function + assert.Check(t, is.Len(all, 2)) + + assert.Check(t, is.Equal(all[0].ID, multilayer.Manifests[0].Digest.String())) + assert.Check(t, is.DeepEqual(all[0].RepoTags, []string{"multilayer:latest"})) + + assert.Check(t, is.Equal(all[1].ID, twoplatform.Manifests[0].Digest.String())) + assert.Check(t, is.DeepEqual(all[1].RepoTags, []string{"twoplatform:latest"})) + }, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + ctx := logtest.WithT(ctx, t) + mdb := newTestDB(ctx, t) + + snapshotters := map[string]snapshots.Snapshotter{ + containerd.DefaultSnapshotter: snapshotter, + } + + service := &ImageService{ + images: metadata.NewImageStore(mdb), + containers: emptyTestContainerStore(), + content: cs, + eventsService: daemonevents.New(), + snapshotterServices: snapshotters, + snapshotter: containerd.DefaultSnapshotter, + } + + // containerd.Image gets the services directly from containerd.Client + // so we need to create a "fake" containerd.Client with the test services. + c8dCli, err := containerd.New("", containerd.WithServices( + containerd.WithImageStore(service.images), + containerd.WithContentStore(cs), + containerd.WithSnapshotters(snapshotters), + )) + assert.NilError(t, err) + + service.client = c8dCli + + for _, img := range tc.images { + _, err := service.images.Create(ctx, img) + assert.NilError(t, err) + } + + all, err := service.Images(ctx, tc.opts) + assert.NilError(t, err) + + sort.Slice(all, func(i, j int) bool { + firstTag := func(idx int) string { + if len(all[idx].RepoTags) > 0 { + return all[idx].RepoTags[0] + } + return "" + } + return firstTag(i) < firstTag(j) + }) + + tc.check(t, all) + }) + } + +} + +type blobsDirContentStore struct { + blobs string +} + +type fileReaderAt struct { + *os.File +} + +func (f *fileReaderAt) Size() int64 { + fi, err := f.Stat() + if err != nil { + return -1 + } + return fi.Size() +} + +func (s *blobsDirContentStore) ReaderAt(ctx context.Context, desc ocispec.Descriptor) (content.ReaderAt, error) { + p := filepath.Join(s.blobs, desc.Digest.Encoded()) + r, err := os.Open(p) + if err != nil { + return nil, err + } + return &fileReaderAt{r}, nil +} + +func (s *blobsDirContentStore) Writer(ctx context.Context, opts ...content.WriterOpt) (content.Writer, error) { + return nil, fmt.Errorf("read-only") +} + +func (s *blobsDirContentStore) Status(ctx context.Context, _ string) (content.Status, error) { + return content.Status{}, fmt.Errorf("not implemented") +} + +func (s *blobsDirContentStore) Delete(ctx context.Context, dgst digest.Digest) error { + return fmt.Errorf("read-only") +} + +func (s *blobsDirContentStore) ListStatuses(ctx context.Context, filters ...string) ([]content.Status, error) { + return nil, nil +} + +func (s *blobsDirContentStore) Abort(ctx context.Context, ref string) error { + return fmt.Errorf("not implemented") +} + +func (s *blobsDirContentStore) Walk(ctx context.Context, fn content.WalkFunc, filters ...string) error { + entries, err := os.ReadDir(s.blobs) + if err != nil { + return err + } + + for _, e := range entries { + if e.IsDir() { + continue + } + + d := digest.FromString(e.Name()) + if d == "" { + continue + } + + stat, err := e.Info() + if err != nil { + return err + } + + if err := fn(content.Info{Digest: d, Size: stat.Size()}); err != nil { + return err + } + } + + return nil +} + +func (s *blobsDirContentStore) Info(ctx context.Context, dgst digest.Digest) (content.Info, error) { + f, err := os.Open(filepath.Join(s.blobs, dgst.Encoded())) + if err != nil { + return content.Info{}, err + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return content.Info{}, err + } + + return content.Info{ + Digest: dgst, + Size: stat.Size(), + }, nil +} + +func (s *blobsDirContentStore) Update(ctx context.Context, info content.Info, fieldpaths ...string) (content.Info, error) { + return content.Info{}, fmt.Errorf("read-only") +} diff --git a/daemon/containerd/image_test.go b/daemon/containerd/image_test.go index d3b671558d..adeb169b87 100644 --- a/daemon/containerd/image_test.go +++ b/daemon/containerd/image_test.go @@ -10,6 +10,7 @@ import ( "github.com/containerd/containerd/images" "github.com/containerd/containerd/metadata" "github.com/containerd/containerd/namespaces" + "github.com/containerd/containerd/snapshots" "github.com/containerd/log/logtest" "github.com/distribution/reference" dockerimages "github.com/docker/docker/daemon/images" @@ -296,3 +297,15 @@ func newTestDB(ctx context.Context, t *testing.T) *metadata.DB { return mdb } + +type testSnapshotterService struct { + snapshots.Snapshotter +} + +func (s *testSnapshotterService) Stat(ctx context.Context, key string) (snapshots.Info, error) { + return snapshots.Info{}, nil +} + +func (s *testSnapshotterService) Usage(ctx context.Context, key string) (snapshots.Usage, error) { + return snapshots.Usage{}, nil +} diff --git a/internal/testutils/specialimage/twoplatform.go b/internal/testutils/specialimage/twoplatform.go new file mode 100644 index 0000000000..8dc631d4d9 --- /dev/null +++ b/internal/testutils/specialimage/twoplatform.go @@ -0,0 +1,117 @@ +package specialimage + +import ( + "os" + "path/filepath" + + "github.com/containerd/containerd/platforms" + "github.com/distribution/reference" + "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" +) + +func TwoPlatform(dir string) (*ocispec.Index, error) { + const imageRef = "twoplatform:latest" + + layer1Desc, err := writeLayerWithOneFile(dir, "bash", []byte("layer1")) + if err != nil { + return nil, err + } + layer2Desc, err := writeLayerWithOneFile(dir, "bash", []byte("layer2")) + if err != nil { + return nil, err + } + + config1Desc, err := writeJsonBlob(dir, ocispec.MediaTypeImageConfig, ocispec.Image{ + Platform: platforms.MustParse("linux/amd64"), + Config: ocispec.ImageConfig{ + Env: []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"}, + }, + RootFS: ocispec.RootFS{ + Type: "layers", + DiffIDs: []digest.Digest{layer1Desc.Digest}, + }, + }) + if err != nil { + return nil, err + } + + manifest1Desc, err := writeJsonBlob(dir, ocispec.MediaTypeImageManifest, ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + Config: config1Desc, + Layers: []ocispec.Descriptor{layer1Desc}, + }) + if err != nil { + return nil, err + } + + config2Desc, err := writeJsonBlob(dir, ocispec.MediaTypeImageConfig, ocispec.Image{ + Platform: platforms.MustParse("linux/arm64"), + Config: ocispec.ImageConfig{ + Env: []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"}, + }, + RootFS: ocispec.RootFS{ + Type: "layers", + DiffIDs: []digest.Digest{layer1Desc.Digest}, + }, + }) + if err != nil { + return nil, err + } + + manifest2Desc, err := writeJsonBlob(dir, ocispec.MediaTypeImageManifest, ocispec.Manifest{ + MediaType: ocispec.MediaTypeImageManifest, + Config: config2Desc, + Layers: []ocispec.Descriptor{layer2Desc}, + }) + if err != nil { + return nil, err + } + + index := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{manifest1Desc, manifest2Desc}, + } + + ref, err := reference.ParseNormalizedNamed(imageRef) + if err != nil { + return nil, err + } + return multiPlatformImage(dir, ref, index) +} + +func multiPlatformImage(dir string, ref reference.Named, target ocispec.Index) (*ocispec.Index, error) { + targetDesc, err := writeJsonBlob(dir, ocispec.MediaTypeImageIndex, target) + if err != nil { + return nil, err + } + + if ref != nil { + targetDesc.Annotations = map[string]string{ + "io.containerd.image.name": ref.String(), + } + + if tagged, ok := ref.(reference.Tagged); ok { + targetDesc.Annotations[ocispec.AnnotationRefName] = tagged.Tag() + } + } + + index := ocispec.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: ocispec.MediaTypeImageIndex, + Manifests: []ocispec.Descriptor{targetDesc}, + } + + if err := writeJson(index, filepath.Join(dir, "index.json")); err != nil { + return nil, err + } + + err = os.WriteFile(filepath.Join(dir, "oci-layout"), []byte(`{"imageLayoutVersion": "1.0.0"}`), 0o644) + if err != nil { + return nil, err + } + + return &index, nil +}