Add unit test for `Images` implementation.
Signed-off-by: Paweł Gronowski <pawel.gronowski@docker.com>
| 1 | 1 |
new file mode 100644 |
| ... | ... |
@@ -0,0 +1,243 @@ |
| 0 |
+package containerd |
|
| 1 |
+ |
|
| 2 |
+import ( |
|
| 3 |
+ "context" |
|
| 4 |
+ "fmt" |
|
| 5 |
+ "os" |
|
| 6 |
+ "path/filepath" |
|
| 7 |
+ "sort" |
|
| 8 |
+ "testing" |
|
| 9 |
+ |
|
| 10 |
+ "github.com/containerd/containerd" |
|
| 11 |
+ "github.com/containerd/containerd/content" |
|
| 12 |
+ "github.com/containerd/containerd/images" |
|
| 13 |
+ "github.com/containerd/containerd/metadata" |
|
| 14 |
+ "github.com/containerd/containerd/namespaces" |
|
| 15 |
+ "github.com/containerd/containerd/snapshots" |
|
| 16 |
+ "github.com/containerd/log/logtest" |
|
| 17 |
+ imagetypes "github.com/docker/docker/api/types/image" |
|
| 18 |
+ daemonevents "github.com/docker/docker/daemon/events" |
|
| 19 |
+ "github.com/docker/docker/internal/testutils/specialimage" |
|
| 20 |
+ "github.com/opencontainers/go-digest" |
|
| 21 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 22 |
+ "gotest.tools/v3/assert" |
|
| 23 |
+ is "gotest.tools/v3/assert/cmp" |
|
| 24 |
+) |
|
| 25 |
+ |
|
| 26 |
+func imagesFromIndex(index ...*ocispec.Index) []images.Image {
|
|
| 27 |
+ var imgs []images.Image |
|
| 28 |
+ for _, idx := range index {
|
|
| 29 |
+ for _, desc := range idx.Manifests {
|
|
| 30 |
+ imgs = append(imgs, images.Image{
|
|
| 31 |
+ Name: desc.Annotations["io.containerd.image.name"], |
|
| 32 |
+ Target: desc, |
|
| 33 |
+ }) |
|
| 34 |
+ } |
|
| 35 |
+ } |
|
| 36 |
+ return imgs |
|
| 37 |
+} |
|
| 38 |
+ |
|
| 39 |
+func TestImageList(t *testing.T) {
|
|
| 40 |
+ ctx := namespaces.WithNamespace(context.TODO(), "testing") |
|
| 41 |
+ |
|
| 42 |
+ blobsDir := t.TempDir() |
|
| 43 |
+ |
|
| 44 |
+ multilayer, err := specialimage.MultiLayer(blobsDir) |
|
| 45 |
+ assert.NilError(t, err) |
|
| 46 |
+ |
|
| 47 |
+ twoplatform, err := specialimage.TwoPlatform(blobsDir) |
|
| 48 |
+ assert.NilError(t, err) |
|
| 49 |
+ |
|
| 50 |
+ cs := &blobsDirContentStore{blobs: filepath.Join(blobsDir, "blobs/sha256")}
|
|
| 51 |
+ |
|
| 52 |
+ snapshotter := &testSnapshotterService{}
|
|
| 53 |
+ |
|
| 54 |
+ for _, tc := range []struct {
|
|
| 55 |
+ name string |
|
| 56 |
+ images []images.Image |
|
| 57 |
+ opts imagetypes.ListOptions |
|
| 58 |
+ |
|
| 59 |
+ check func(*testing.T, []*imagetypes.Summary) // Change the type of the check function |
|
| 60 |
+ }{
|
|
| 61 |
+ {
|
|
| 62 |
+ name: "one multi-layer image", |
|
| 63 |
+ images: imagesFromIndex(multilayer), |
|
| 64 |
+ check: func(t *testing.T, all []*imagetypes.Summary) { // Change the type of the check function
|
|
| 65 |
+ assert.Check(t, is.Len(all, 1)) |
|
| 66 |
+ |
|
| 67 |
+ assert.Check(t, is.Equal(all[0].ID, multilayer.Manifests[0].Digest.String())) |
|
| 68 |
+ assert.Check(t, is.DeepEqual(all[0].RepoTags, []string{"multilayer:latest"}))
|
|
| 69 |
+ }, |
|
| 70 |
+ }, |
|
| 71 |
+ {
|
|
| 72 |
+ name: "one image with two platforms is still one entry", |
|
| 73 |
+ images: imagesFromIndex(twoplatform), |
|
| 74 |
+ check: func(t *testing.T, all []*imagetypes.Summary) { // Change the type of the check function
|
|
| 75 |
+ assert.Check(t, is.Len(all, 1)) |
|
| 76 |
+ |
|
| 77 |
+ assert.Check(t, is.Equal(all[0].ID, twoplatform.Manifests[0].Digest.String())) |
|
| 78 |
+ assert.Check(t, is.DeepEqual(all[0].RepoTags, []string{"twoplatform:latest"}))
|
|
| 79 |
+ }, |
|
| 80 |
+ }, |
|
| 81 |
+ {
|
|
| 82 |
+ name: "two images are two entries", |
|
| 83 |
+ images: imagesFromIndex(multilayer, twoplatform), |
|
| 84 |
+ check: func(t *testing.T, all []*imagetypes.Summary) { // Change the type of the check function
|
|
| 85 |
+ assert.Check(t, is.Len(all, 2)) |
|
| 86 |
+ |
|
| 87 |
+ assert.Check(t, is.Equal(all[0].ID, multilayer.Manifests[0].Digest.String())) |
|
| 88 |
+ assert.Check(t, is.DeepEqual(all[0].RepoTags, []string{"multilayer:latest"}))
|
|
| 89 |
+ |
|
| 90 |
+ assert.Check(t, is.Equal(all[1].ID, twoplatform.Manifests[0].Digest.String())) |
|
| 91 |
+ assert.Check(t, is.DeepEqual(all[1].RepoTags, []string{"twoplatform:latest"}))
|
|
| 92 |
+ }, |
|
| 93 |
+ }, |
|
| 94 |
+ } {
|
|
| 95 |
+ tc := tc |
|
| 96 |
+ t.Run(tc.name, func(t *testing.T) {
|
|
| 97 |
+ ctx := logtest.WithT(ctx, t) |
|
| 98 |
+ mdb := newTestDB(ctx, t) |
|
| 99 |
+ |
|
| 100 |
+ snapshotters := map[string]snapshots.Snapshotter{
|
|
| 101 |
+ containerd.DefaultSnapshotter: snapshotter, |
|
| 102 |
+ } |
|
| 103 |
+ |
|
| 104 |
+ service := &ImageService{
|
|
| 105 |
+ images: metadata.NewImageStore(mdb), |
|
| 106 |
+ containers: emptyTestContainerStore(), |
|
| 107 |
+ content: cs, |
|
| 108 |
+ eventsService: daemonevents.New(), |
|
| 109 |
+ snapshotterServices: snapshotters, |
|
| 110 |
+ snapshotter: containerd.DefaultSnapshotter, |
|
| 111 |
+ } |
|
| 112 |
+ |
|
| 113 |
+ // containerd.Image gets the services directly from containerd.Client |
|
| 114 |
+ // so we need to create a "fake" containerd.Client with the test services. |
|
| 115 |
+ c8dCli, err := containerd.New("", containerd.WithServices(
|
|
| 116 |
+ containerd.WithImageStore(service.images), |
|
| 117 |
+ containerd.WithContentStore(cs), |
|
| 118 |
+ containerd.WithSnapshotters(snapshotters), |
|
| 119 |
+ )) |
|
| 120 |
+ assert.NilError(t, err) |
|
| 121 |
+ |
|
| 122 |
+ service.client = c8dCli |
|
| 123 |
+ |
|
| 124 |
+ for _, img := range tc.images {
|
|
| 125 |
+ _, err := service.images.Create(ctx, img) |
|
| 126 |
+ assert.NilError(t, err) |
|
| 127 |
+ } |
|
| 128 |
+ |
|
| 129 |
+ all, err := service.Images(ctx, tc.opts) |
|
| 130 |
+ assert.NilError(t, err) |
|
| 131 |
+ |
|
| 132 |
+ sort.Slice(all, func(i, j int) bool {
|
|
| 133 |
+ firstTag := func(idx int) string {
|
|
| 134 |
+ if len(all[idx].RepoTags) > 0 {
|
|
| 135 |
+ return all[idx].RepoTags[0] |
|
| 136 |
+ } |
|
| 137 |
+ return "" |
|
| 138 |
+ } |
|
| 139 |
+ return firstTag(i) < firstTag(j) |
|
| 140 |
+ }) |
|
| 141 |
+ |
|
| 142 |
+ tc.check(t, all) |
|
| 143 |
+ }) |
|
| 144 |
+ } |
|
| 145 |
+ |
|
| 146 |
+} |
|
| 147 |
+ |
|
| 148 |
+type blobsDirContentStore struct {
|
|
| 149 |
+ blobs string |
|
| 150 |
+} |
|
| 151 |
+ |
|
| 152 |
+type fileReaderAt struct {
|
|
| 153 |
+ *os.File |
|
| 154 |
+} |
|
| 155 |
+ |
|
| 156 |
+func (f *fileReaderAt) Size() int64 {
|
|
| 157 |
+ fi, err := f.Stat() |
|
| 158 |
+ if err != nil {
|
|
| 159 |
+ return -1 |
|
| 160 |
+ } |
|
| 161 |
+ return fi.Size() |
|
| 162 |
+} |
|
| 163 |
+ |
|
| 164 |
+func (s *blobsDirContentStore) ReaderAt(ctx context.Context, desc ocispec.Descriptor) (content.ReaderAt, error) {
|
|
| 165 |
+ p := filepath.Join(s.blobs, desc.Digest.Encoded()) |
|
| 166 |
+ r, err := os.Open(p) |
|
| 167 |
+ if err != nil {
|
|
| 168 |
+ return nil, err |
|
| 169 |
+ } |
|
| 170 |
+ return &fileReaderAt{r}, nil
|
|
| 171 |
+} |
|
| 172 |
+ |
|
| 173 |
+func (s *blobsDirContentStore) Writer(ctx context.Context, opts ...content.WriterOpt) (content.Writer, error) {
|
|
| 174 |
+ return nil, fmt.Errorf("read-only")
|
|
| 175 |
+} |
|
| 176 |
+ |
|
| 177 |
+func (s *blobsDirContentStore) Status(ctx context.Context, _ string) (content.Status, error) {
|
|
| 178 |
+ return content.Status{}, fmt.Errorf("not implemented")
|
|
| 179 |
+} |
|
| 180 |
+ |
|
| 181 |
+func (s *blobsDirContentStore) Delete(ctx context.Context, dgst digest.Digest) error {
|
|
| 182 |
+ return fmt.Errorf("read-only")
|
|
| 183 |
+} |
|
| 184 |
+ |
|
| 185 |
+func (s *blobsDirContentStore) ListStatuses(ctx context.Context, filters ...string) ([]content.Status, error) {
|
|
| 186 |
+ return nil, nil |
|
| 187 |
+} |
|
| 188 |
+ |
|
| 189 |
+func (s *blobsDirContentStore) Abort(ctx context.Context, ref string) error {
|
|
| 190 |
+ return fmt.Errorf("not implemented")
|
|
| 191 |
+} |
|
| 192 |
+ |
|
| 193 |
+func (s *blobsDirContentStore) Walk(ctx context.Context, fn content.WalkFunc, filters ...string) error {
|
|
| 194 |
+ entries, err := os.ReadDir(s.blobs) |
|
| 195 |
+ if err != nil {
|
|
| 196 |
+ return err |
|
| 197 |
+ } |
|
| 198 |
+ |
|
| 199 |
+ for _, e := range entries {
|
|
| 200 |
+ if e.IsDir() {
|
|
| 201 |
+ continue |
|
| 202 |
+ } |
|
| 203 |
+ |
|
| 204 |
+ d := digest.FromString(e.Name()) |
|
| 205 |
+ if d == "" {
|
|
| 206 |
+ continue |
|
| 207 |
+ } |
|
| 208 |
+ |
|
| 209 |
+ stat, err := e.Info() |
|
| 210 |
+ if err != nil {
|
|
| 211 |
+ return err |
|
| 212 |
+ } |
|
| 213 |
+ |
|
| 214 |
+ if err := fn(content.Info{Digest: d, Size: stat.Size()}); err != nil {
|
|
| 215 |
+ return err |
|
| 216 |
+ } |
|
| 217 |
+ } |
|
| 218 |
+ |
|
| 219 |
+ return nil |
|
| 220 |
+} |
|
| 221 |
+ |
|
| 222 |
+func (s *blobsDirContentStore) Info(ctx context.Context, dgst digest.Digest) (content.Info, error) {
|
|
| 223 |
+ f, err := os.Open(filepath.Join(s.blobs, dgst.Encoded())) |
|
| 224 |
+ if err != nil {
|
|
| 225 |
+ return content.Info{}, err
|
|
| 226 |
+ } |
|
| 227 |
+ defer f.Close() |
|
| 228 |
+ |
|
| 229 |
+ stat, err := f.Stat() |
|
| 230 |
+ if err != nil {
|
|
| 231 |
+ return content.Info{}, err
|
|
| 232 |
+ } |
|
| 233 |
+ |
|
| 234 |
+ return content.Info{
|
|
| 235 |
+ Digest: dgst, |
|
| 236 |
+ Size: stat.Size(), |
|
| 237 |
+ }, nil |
|
| 238 |
+} |
|
| 239 |
+ |
|
| 240 |
+func (s *blobsDirContentStore) Update(ctx context.Context, info content.Info, fieldpaths ...string) (content.Info, error) {
|
|
| 241 |
+ return content.Info{}, fmt.Errorf("read-only")
|
|
| 242 |
+} |
| ... | ... |
@@ -10,6 +10,7 @@ import ( |
| 10 | 10 |
"github.com/containerd/containerd/images" |
| 11 | 11 |
"github.com/containerd/containerd/metadata" |
| 12 | 12 |
"github.com/containerd/containerd/namespaces" |
| 13 |
+ "github.com/containerd/containerd/snapshots" |
|
| 13 | 14 |
"github.com/containerd/log/logtest" |
| 14 | 15 |
"github.com/distribution/reference" |
| 15 | 16 |
dockerimages "github.com/docker/docker/daemon/images" |
| ... | ... |
@@ -296,3 +297,15 @@ func newTestDB(ctx context.Context, t *testing.T) *metadata.DB {
|
| 296 | 296 |
|
| 297 | 297 |
return mdb |
| 298 | 298 |
} |
| 299 |
+ |
|
| 300 |
+type testSnapshotterService struct {
|
|
| 301 |
+ snapshots.Snapshotter |
|
| 302 |
+} |
|
| 303 |
+ |
|
| 304 |
+func (s *testSnapshotterService) Stat(ctx context.Context, key string) (snapshots.Info, error) {
|
|
| 305 |
+ return snapshots.Info{}, nil
|
|
| 306 |
+} |
|
| 307 |
+ |
|
| 308 |
+func (s *testSnapshotterService) Usage(ctx context.Context, key string) (snapshots.Usage, error) {
|
|
| 309 |
+ return snapshots.Usage{}, nil
|
|
| 310 |
+} |
| 299 | 311 |
new file mode 100644 |
| ... | ... |
@@ -0,0 +1,117 @@ |
| 0 |
+package specialimage |
|
| 1 |
+ |
|
| 2 |
+import ( |
|
| 3 |
+ "os" |
|
| 4 |
+ "path/filepath" |
|
| 5 |
+ |
|
| 6 |
+ "github.com/containerd/containerd/platforms" |
|
| 7 |
+ "github.com/distribution/reference" |
|
| 8 |
+ "github.com/opencontainers/go-digest" |
|
| 9 |
+ "github.com/opencontainers/image-spec/specs-go" |
|
| 10 |
+ ocispec "github.com/opencontainers/image-spec/specs-go/v1" |
|
| 11 |
+) |
|
| 12 |
+ |
|
| 13 |
+func TwoPlatform(dir string) (*ocispec.Index, error) {
|
|
| 14 |
+ const imageRef = "twoplatform:latest" |
|
| 15 |
+ |
|
| 16 |
+ layer1Desc, err := writeLayerWithOneFile(dir, "bash", []byte("layer1"))
|
|
| 17 |
+ if err != nil {
|
|
| 18 |
+ return nil, err |
|
| 19 |
+ } |
|
| 20 |
+ layer2Desc, err := writeLayerWithOneFile(dir, "bash", []byte("layer2"))
|
|
| 21 |
+ if err != nil {
|
|
| 22 |
+ return nil, err |
|
| 23 |
+ } |
|
| 24 |
+ |
|
| 25 |
+ config1Desc, err := writeJsonBlob(dir, ocispec.MediaTypeImageConfig, ocispec.Image{
|
|
| 26 |
+ Platform: platforms.MustParse("linux/amd64"),
|
|
| 27 |
+ Config: ocispec.ImageConfig{
|
|
| 28 |
+ Env: []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"},
|
|
| 29 |
+ }, |
|
| 30 |
+ RootFS: ocispec.RootFS{
|
|
| 31 |
+ Type: "layers", |
|
| 32 |
+ DiffIDs: []digest.Digest{layer1Desc.Digest},
|
|
| 33 |
+ }, |
|
| 34 |
+ }) |
|
| 35 |
+ if err != nil {
|
|
| 36 |
+ return nil, err |
|
| 37 |
+ } |
|
| 38 |
+ |
|
| 39 |
+ manifest1Desc, err := writeJsonBlob(dir, ocispec.MediaTypeImageManifest, ocispec.Manifest{
|
|
| 40 |
+ MediaType: ocispec.MediaTypeImageManifest, |
|
| 41 |
+ Config: config1Desc, |
|
| 42 |
+ Layers: []ocispec.Descriptor{layer1Desc},
|
|
| 43 |
+ }) |
|
| 44 |
+ if err != nil {
|
|
| 45 |
+ return nil, err |
|
| 46 |
+ } |
|
| 47 |
+ |
|
| 48 |
+ config2Desc, err := writeJsonBlob(dir, ocispec.MediaTypeImageConfig, ocispec.Image{
|
|
| 49 |
+ Platform: platforms.MustParse("linux/arm64"),
|
|
| 50 |
+ Config: ocispec.ImageConfig{
|
|
| 51 |
+ Env: []string{"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"},
|
|
| 52 |
+ }, |
|
| 53 |
+ RootFS: ocispec.RootFS{
|
|
| 54 |
+ Type: "layers", |
|
| 55 |
+ DiffIDs: []digest.Digest{layer1Desc.Digest},
|
|
| 56 |
+ }, |
|
| 57 |
+ }) |
|
| 58 |
+ if err != nil {
|
|
| 59 |
+ return nil, err |
|
| 60 |
+ } |
|
| 61 |
+ |
|
| 62 |
+ manifest2Desc, err := writeJsonBlob(dir, ocispec.MediaTypeImageManifest, ocispec.Manifest{
|
|
| 63 |
+ MediaType: ocispec.MediaTypeImageManifest, |
|
| 64 |
+ Config: config2Desc, |
|
| 65 |
+ Layers: []ocispec.Descriptor{layer2Desc},
|
|
| 66 |
+ }) |
|
| 67 |
+ if err != nil {
|
|
| 68 |
+ return nil, err |
|
| 69 |
+ } |
|
| 70 |
+ |
|
| 71 |
+ index := ocispec.Index{
|
|
| 72 |
+ Versioned: specs.Versioned{SchemaVersion: 2},
|
|
| 73 |
+ MediaType: ocispec.MediaTypeImageIndex, |
|
| 74 |
+ Manifests: []ocispec.Descriptor{manifest1Desc, manifest2Desc},
|
|
| 75 |
+ } |
|
| 76 |
+ |
|
| 77 |
+ ref, err := reference.ParseNormalizedNamed(imageRef) |
|
| 78 |
+ if err != nil {
|
|
| 79 |
+ return nil, err |
|
| 80 |
+ } |
|
| 81 |
+ return multiPlatformImage(dir, ref, index) |
|
| 82 |
+} |
|
| 83 |
+ |
|
| 84 |
+func multiPlatformImage(dir string, ref reference.Named, target ocispec.Index) (*ocispec.Index, error) {
|
|
| 85 |
+ targetDesc, err := writeJsonBlob(dir, ocispec.MediaTypeImageIndex, target) |
|
| 86 |
+ if err != nil {
|
|
| 87 |
+ return nil, err |
|
| 88 |
+ } |
|
| 89 |
+ |
|
| 90 |
+ if ref != nil {
|
|
| 91 |
+ targetDesc.Annotations = map[string]string{
|
|
| 92 |
+ "io.containerd.image.name": ref.String(), |
|
| 93 |
+ } |
|
| 94 |
+ |
|
| 95 |
+ if tagged, ok := ref.(reference.Tagged); ok {
|
|
| 96 |
+ targetDesc.Annotations[ocispec.AnnotationRefName] = tagged.Tag() |
|
| 97 |
+ } |
|
| 98 |
+ } |
|
| 99 |
+ |
|
| 100 |
+ index := ocispec.Index{
|
|
| 101 |
+ Versioned: specs.Versioned{SchemaVersion: 2},
|
|
| 102 |
+ MediaType: ocispec.MediaTypeImageIndex, |
|
| 103 |
+ Manifests: []ocispec.Descriptor{targetDesc},
|
|
| 104 |
+ } |
|
| 105 |
+ |
|
| 106 |
+ if err := writeJson(index, filepath.Join(dir, "index.json")); err != nil {
|
|
| 107 |
+ return nil, err |
|
| 108 |
+ } |
|
| 109 |
+ |
|
| 110 |
+ err = os.WriteFile(filepath.Join(dir, "oci-layout"), []byte(`{"imageLayoutVersion": "1.0.0"}`), 0o644)
|
|
| 111 |
+ if err != nil {
|
|
| 112 |
+ return nil, err |
|
| 113 |
+ } |
|
| 114 |
+ |
|
| 115 |
+ return &index, nil |
|
| 116 |
+} |