github.com/tonistiigi/docker@v0.10.1-0.20240229224939-974013b0dc6a/distribution/manifest_test.go (about) 1 package distribution 2 3 import ( 4 "context" 5 "encoding/json" 6 "os" 7 "strings" 8 "sync" 9 "testing" 10 11 "github.com/containerd/containerd/content" 12 "github.com/containerd/containerd/content/local" 13 cerrdefs "github.com/containerd/containerd/errdefs" 14 "github.com/containerd/containerd/remotes" 15 "github.com/distribution/reference" 16 "github.com/docker/distribution" 17 "github.com/docker/distribution/manifest/manifestlist" 18 "github.com/docker/distribution/manifest/ocischema" 19 "github.com/docker/distribution/manifest/schema1" 20 "github.com/docker/distribution/manifest/schema2" 21 "github.com/google/go-cmp/cmp/cmpopts" 22 "github.com/opencontainers/go-digest" 23 ocispec "github.com/opencontainers/image-spec/specs-go/v1" 24 "github.com/pkg/errors" 25 "gotest.tools/v3/assert" 26 "gotest.tools/v3/assert/cmp" 27 ) 28 29 type mockManifestGetter struct { 30 manifests map[digest.Digest]distribution.Manifest 31 gets int 32 } 33 34 func (m *mockManifestGetter) Get(ctx context.Context, dgst digest.Digest, options ...distribution.ManifestServiceOption) (distribution.Manifest, error) { 35 m.gets++ 36 manifest, ok := m.manifests[dgst] 37 if !ok { 38 return nil, distribution.ErrManifestUnknown{Tag: dgst.String()} 39 } 40 return manifest, nil 41 } 42 43 func (m *mockManifestGetter) Exists(ctx context.Context, dgst digest.Digest) (bool, error) { 44 _, ok := m.manifests[dgst] 45 return ok, nil 46 } 47 48 type memoryLabelStore struct { 49 mu sync.Mutex 50 labels map[digest.Digest]map[string]string 51 } 52 53 // Get returns all the labels for the given digest 54 func (s *memoryLabelStore) Get(dgst digest.Digest) (map[string]string, error) { 55 s.mu.Lock() 56 labels := s.labels[dgst] 57 s.mu.Unlock() 58 return labels, nil 59 } 60 61 // Set sets all the labels for a given digest 62 func (s *memoryLabelStore) Set(dgst digest.Digest, labels map[string]string) error { 63 s.mu.Lock() 64 if s.labels == nil { 65 s.labels = make(map[digest.Digest]map[string]string) 66 } 67 s.labels[dgst] = labels 68 s.mu.Unlock() 69 return nil 70 } 71 72 // Update replaces the given labels for a digest, 73 // a key with an empty value removes a label. 74 func (s *memoryLabelStore) Update(dgst digest.Digest, update map[string]string) (map[string]string, error) { 75 s.mu.Lock() 76 defer s.mu.Unlock() 77 78 labels, ok := s.labels[dgst] 79 if !ok { 80 labels = map[string]string{} 81 } 82 for k, v := range update { 83 labels[k] = v 84 } 85 if s.labels == nil { 86 s.labels = map[digest.Digest]map[string]string{} 87 } 88 s.labels[dgst] = labels 89 90 return labels, nil 91 } 92 93 type testingContentStoreWrapper struct { 94 ContentStore 95 errorOnWriter error 96 errorOnCommit error 97 } 98 99 func (s *testingContentStoreWrapper) Writer(ctx context.Context, opts ...content.WriterOpt) (content.Writer, error) { 100 if s.errorOnWriter != nil { 101 return nil, s.errorOnWriter 102 } 103 104 w, err := s.ContentStore.Writer(ctx, opts...) 105 if err != nil { 106 return nil, err 107 } 108 109 if s.errorOnCommit != nil { 110 w = &testingContentWriterWrapper{w, s.errorOnCommit} 111 } 112 return w, nil 113 } 114 115 type testingContentWriterWrapper struct { 116 content.Writer 117 err error 118 } 119 120 func (w *testingContentWriterWrapper) Commit(ctx context.Context, size int64, dgst digest.Digest, opts ...content.Opt) error { 121 if w.err != nil { 122 // The contract for `Commit` is to always close. 123 // Since this is returning early before hitting the real `Commit`, we should close it here. 124 w.Close() 125 return w.err 126 } 127 return w.Writer.Commit(ctx, size, dgst, opts...) 128 } 129 130 func TestManifestStore(t *testing.T) { 131 ociManifest := &ocispec.Manifest{} 132 serialized, err := json.Marshal(ociManifest) 133 assert.NilError(t, err) 134 dgst := digest.Canonical.FromBytes(serialized) 135 136 setupTest := func(t *testing.T) (reference.Named, ocispec.Descriptor, *mockManifestGetter, *manifestStore, content.Store, func(*testing.T)) { 137 root, err := os.MkdirTemp("", strings.ReplaceAll(t.Name(), "/", "_")) 138 assert.NilError(t, err) 139 defer func() { 140 if t.Failed() { 141 os.RemoveAll(root) 142 } 143 }() 144 145 cs, err := local.NewLabeledStore(root, &memoryLabelStore{}) 146 assert.NilError(t, err) 147 148 mg := &mockManifestGetter{manifests: make(map[digest.Digest]distribution.Manifest)} 149 store := &manifestStore{local: cs, remote: mg} 150 desc := ocispec.Descriptor{Digest: dgst, MediaType: ocispec.MediaTypeImageManifest, Size: int64(len(serialized))} 151 152 ref, err := reference.Parse("foo/bar") 153 assert.NilError(t, err) 154 155 return ref.(reference.Named), desc, mg, store, cs, func(t *testing.T) { 156 assert.Check(t, os.RemoveAll(root)) 157 } 158 } 159 160 ctx := context.Background() 161 162 m, _, err := distribution.UnmarshalManifest(ocispec.MediaTypeImageManifest, serialized) 163 assert.NilError(t, err) 164 165 writeManifest := func(t *testing.T, cs ContentStore, desc ocispec.Descriptor, opts ...content.Opt) { 166 ingestKey := remotes.MakeRefKey(ctx, desc) 167 w, err := cs.Writer(ctx, content.WithDescriptor(desc), content.WithRef(ingestKey)) 168 assert.NilError(t, err) 169 defer func() { 170 if err := w.Close(); err != nil { 171 t.Log(err) 172 } 173 if t.Failed() { 174 if err := cs.Abort(ctx, ingestKey); err != nil { 175 t.Log(err) 176 } 177 } 178 }() 179 180 _, err = w.Write(serialized) 181 assert.NilError(t, err) 182 183 err = w.Commit(ctx, desc.Size, desc.Digest, opts...) 184 assert.NilError(t, err) 185 } 186 187 // All tests should end up with no active ingest 188 checkIngest := func(t *testing.T, cs content.Store, desc ocispec.Descriptor) { 189 ingestKey := remotes.MakeRefKey(ctx, desc) 190 _, err := cs.Status(ctx, ingestKey) 191 assert.Check(t, cerrdefs.IsNotFound(err), err) 192 } 193 194 t.Run("no remote or local", func(t *testing.T) { 195 ref, desc, _, store, cs, teardown := setupTest(t) 196 defer teardown(t) 197 198 _, err = store.Get(ctx, desc, ref) 199 checkIngest(t, cs, desc) 200 // This error is what our digest getter returns when it doesn't know about the manifest 201 assert.Error(t, err, distribution.ErrManifestUnknown{Tag: dgst.String()}.Error()) 202 }) 203 204 t.Run("no local cache", func(t *testing.T) { 205 ref, desc, mg, store, cs, teardown := setupTest(t) 206 defer teardown(t) 207 208 mg.manifests[desc.Digest] = m 209 210 m2, err := store.Get(ctx, desc, ref) 211 checkIngest(t, cs, desc) 212 assert.NilError(t, err) 213 assert.Check(t, cmp.DeepEqual(m, m2, cmpopts.IgnoreUnexported(ocischema.DeserializedManifest{}))) 214 assert.Check(t, cmp.Equal(mg.gets, 1)) 215 216 i, err := cs.Info(ctx, desc.Digest) 217 assert.NilError(t, err) 218 assert.Check(t, cmp.Equal(i.Digest, desc.Digest)) 219 220 distKey, distSource := makeDistributionSourceLabel(ref) 221 assert.Check(t, hasDistributionSource(i.Labels[distKey], distSource)) 222 223 // Now check again, this should not hit the remote 224 m2, err = store.Get(ctx, desc, ref) 225 checkIngest(t, cs, desc) 226 assert.NilError(t, err) 227 assert.Check(t, cmp.DeepEqual(m, m2, cmpopts.IgnoreUnexported(ocischema.DeserializedManifest{}))) 228 assert.Check(t, cmp.Equal(mg.gets, 1)) 229 230 t.Run("digested", func(t *testing.T) { 231 ref, err := reference.WithDigest(ref, desc.Digest) 232 assert.NilError(t, err) 233 234 _, err = store.Get(ctx, desc, ref) 235 assert.NilError(t, err) 236 }) 237 }) 238 239 t.Run("with local cache", func(t *testing.T) { 240 ref, desc, mg, store, cs, teardown := setupTest(t) 241 defer teardown(t) 242 243 // first add the manifest to the coontent store 244 writeManifest(t, cs, desc) 245 246 // now do the get 247 m2, err := store.Get(ctx, desc, ref) 248 checkIngest(t, cs, desc) 249 assert.NilError(t, err) 250 assert.Check(t, cmp.DeepEqual(m, m2, cmpopts.IgnoreUnexported(ocischema.DeserializedManifest{}))) 251 assert.Check(t, cmp.Equal(mg.gets, 0)) 252 253 i, err := cs.Info(ctx, desc.Digest) 254 assert.NilError(t, err) 255 assert.Check(t, cmp.Equal(i.Digest, desc.Digest)) 256 }) 257 258 // This is for the case of pull by digest where we don't know the media type of the manifest until it's actually pulled. 259 t.Run("unknown media type", func(t *testing.T) { 260 t.Run("no cache", func(t *testing.T) { 261 ref, desc, mg, store, cs, teardown := setupTest(t) 262 defer teardown(t) 263 264 mg.manifests[desc.Digest] = m 265 desc.MediaType = "" 266 267 m2, err := store.Get(ctx, desc, ref) 268 checkIngest(t, cs, desc) 269 assert.NilError(t, err) 270 assert.Check(t, cmp.DeepEqual(m, m2, cmpopts.IgnoreUnexported(ocischema.DeserializedManifest{}))) 271 assert.Check(t, cmp.Equal(mg.gets, 1)) 272 }) 273 274 t.Run("with cache", func(t *testing.T) { 275 t.Run("cached manifest has media type", func(t *testing.T) { 276 ref, desc, mg, store, cs, teardown := setupTest(t) 277 defer teardown(t) 278 279 writeManifest(t, cs, desc) 280 desc.MediaType = "" 281 282 m2, err := store.Get(ctx, desc, ref) 283 checkIngest(t, cs, desc) 284 assert.NilError(t, err) 285 assert.Check(t, cmp.DeepEqual(m, m2, cmpopts.IgnoreUnexported(ocischema.DeserializedManifest{}))) 286 assert.Check(t, cmp.Equal(mg.gets, 0)) 287 }) 288 289 t.Run("cached manifest has no media type", func(t *testing.T) { 290 ref, desc, mg, store, cs, teardown := setupTest(t) 291 defer teardown(t) 292 293 desc.MediaType = "" 294 writeManifest(t, cs, desc) 295 296 m2, err := store.Get(ctx, desc, ref) 297 checkIngest(t, cs, desc) 298 assert.NilError(t, err) 299 assert.Check(t, cmp.DeepEqual(m, m2, cmpopts.IgnoreUnexported(ocischema.DeserializedManifest{}))) 300 assert.Check(t, cmp.Equal(mg.gets, 0)) 301 }) 302 }) 303 }) 304 305 // Test that if there is an error with the content store, for whatever 306 // reason, that doesn't stop us from getting the manifest. 307 // 308 // Also makes sure the ingests are aborted. 309 t.Run("error persisting manifest", func(t *testing.T) { 310 t.Run("error on writer", func(t *testing.T) { 311 ref, desc, mg, store, cs, teardown := setupTest(t) 312 defer teardown(t) 313 mg.manifests[desc.Digest] = m 314 315 csW := &testingContentStoreWrapper{ContentStore: store.local, errorOnWriter: errors.New("random error")} 316 store.local = csW 317 318 m2, err := store.Get(ctx, desc, ref) 319 checkIngest(t, cs, desc) 320 assert.NilError(t, err) 321 assert.Check(t, cmp.DeepEqual(m, m2, cmpopts.IgnoreUnexported(ocischema.DeserializedManifest{}))) 322 assert.Check(t, cmp.Equal(mg.gets, 1)) 323 324 _, err = cs.Info(ctx, desc.Digest) 325 // Nothing here since we couldn't persist 326 assert.Check(t, cerrdefs.IsNotFound(err), err) 327 }) 328 329 t.Run("error on commit", func(t *testing.T) { 330 ref, desc, mg, store, cs, teardown := setupTest(t) 331 defer teardown(t) 332 mg.manifests[desc.Digest] = m 333 334 csW := &testingContentStoreWrapper{ContentStore: store.local, errorOnCommit: errors.New("random error")} 335 store.local = csW 336 337 m2, err := store.Get(ctx, desc, ref) 338 checkIngest(t, cs, desc) 339 assert.NilError(t, err) 340 assert.Check(t, cmp.DeepEqual(m, m2, cmpopts.IgnoreUnexported(ocischema.DeserializedManifest{}))) 341 assert.Check(t, cmp.Equal(mg.gets, 1)) 342 343 _, err = cs.Info(ctx, desc.Digest) 344 // Nothing here since we couldn't persist 345 assert.Check(t, cerrdefs.IsNotFound(err), err) 346 }) 347 }) 348 } 349 350 func TestDetectManifestBlobMediaType(t *testing.T) { 351 type testCase struct { 352 json []byte 353 expected string 354 } 355 cases := map[string]testCase{ 356 "mediaType is set": {[]byte(`{"mediaType": "bananas"}`), "bananas"}, 357 "oci manifest": {[]byte(`{"config": {}}`), ocispec.MediaTypeImageManifest}, 358 "schema1": {[]byte(`{"fsLayers": []}`), schema1.MediaTypeManifest}, 359 "oci index fallback": {[]byte(`{}`), ocispec.MediaTypeImageIndex}, 360 // Make sure we prefer mediaType 361 "mediaType and config set": {[]byte(`{"mediaType": "bananas", "config": {}}`), "bananas"}, 362 "mediaType and fsLayers set": {[]byte(`{"mediaType": "bananas", "fsLayers": []}`), "bananas"}, 363 } 364 365 t.Setenv("DOCKER_ENABLE_DEPRECATED_PULL_SCHEMA_1_IMAGE", "1") 366 for name, tc := range cases { 367 t.Run(name, func(t *testing.T) { 368 mt, err := detectManifestBlobMediaType(tc.json) 369 assert.NilError(t, err) 370 assert.Equal(t, mt, tc.expected) 371 }) 372 } 373 } 374 375 func TestDetectManifestBlobMediaTypeInvalid(t *testing.T) { 376 type testCase struct { 377 json []byte 378 expected string 379 } 380 cases := map[string]testCase{ 381 "schema 1 mediaType with manifests": { 382 []byte(`{"mediaType": "` + schema1.MediaTypeManifest + `","manifests":[]}`), 383 `media-type: "application/vnd.docker.distribution.manifest.v1+json" should not have "manifests" or "layers"`, 384 }, 385 "schema 1 mediaType with layers": { 386 []byte(`{"mediaType": "` + schema1.MediaTypeManifest + `","layers":[]}`), 387 `media-type: "application/vnd.docker.distribution.manifest.v1+json" should not have "manifests" or "layers"`, 388 }, 389 "schema 2 mediaType with manifests": { 390 []byte(`{"mediaType": "` + schema2.MediaTypeManifest + `","manifests":[]}`), 391 `media-type: "application/vnd.docker.distribution.manifest.v2+json" should not have "manifests" or "fsLayers"`, 392 }, 393 "schema 2 mediaType with fsLayers": { 394 []byte(`{"mediaType": "` + schema2.MediaTypeManifest + `","fsLayers":[]}`), 395 `media-type: "application/vnd.docker.distribution.manifest.v2+json" should not have "manifests" or "fsLayers"`, 396 }, 397 "oci manifest mediaType with manifests": { 398 []byte(`{"mediaType": "` + ocispec.MediaTypeImageManifest + `","manifests":[]}`), 399 `media-type: "application/vnd.oci.image.manifest.v1+json" should not have "manifests" or "fsLayers"`, 400 }, 401 "manifest list mediaType with fsLayers": { 402 []byte(`{"mediaType": "` + manifestlist.MediaTypeManifestList + `","fsLayers":[]}`), 403 `media-type: "application/vnd.docker.distribution.manifest.list.v2+json" should not have "config", "layers", or "fsLayers"`, 404 }, 405 "index mediaType with layers": { 406 []byte(`{"mediaType": "` + ocispec.MediaTypeImageIndex + `","layers":[]}`), 407 `media-type: "application/vnd.oci.image.index.v1+json" should not have "config", "layers", or "fsLayers"`, 408 }, 409 "index mediaType with config": { 410 []byte(`{"mediaType": "` + ocispec.MediaTypeImageIndex + `","config":{}}`), 411 `media-type: "application/vnd.oci.image.index.v1+json" should not have "config", "layers", or "fsLayers"`, 412 }, 413 "config and manifests": { 414 []byte(`{"config":{}, "manifests":[]}`), 415 `media-type: cannot determine`, 416 }, 417 "layers and manifests": { 418 []byte(`{"layers":[], "manifests":[]}`), 419 `media-type: cannot determine`, 420 }, 421 "layers and fsLayers": { 422 []byte(`{"layers":[], "fsLayers":[]}`), 423 `media-type: cannot determine`, 424 }, 425 "fsLayers and manifests": { 426 []byte(`{"fsLayers":[], "manifests":[]}`), 427 `media-type: cannot determine`, 428 }, 429 "config and fsLayers": { 430 []byte(`{"config":{}, "fsLayers":[]}`), 431 `media-type: cannot determine`, 432 }, 433 } 434 435 t.Setenv("DOCKER_ENABLE_DEPRECATED_PULL_SCHEMA_1_IMAGE", "1") 436 for name, tc := range cases { 437 t.Run(name, func(t *testing.T) { 438 mt, err := detectManifestBlobMediaType(tc.json) 439 assert.Error(t, err, tc.expected) 440 assert.Equal(t, mt, "") 441 }) 442 } 443 }