diff --git a/pkg/registry/manifest.go b/pkg/registry/manifest.go index 1df3f58af..cd788f7ba 100644 --- a/pkg/registry/manifest.go +++ b/pkg/registry/manifest.go @@ -79,6 +79,16 @@ func isCatalog(req *http.Request) bool { return elems[len(elems)-1] == "_catalog" } +// Returns whether this url should be handled by the referrers handler +func isReferrers(req *http.Request) bool { + elems := strings.Split(req.URL.Path, "/") + elems = elems[1:] + if len(elems) < 4 { + return false + } + return elems[len(elems)-2] == "referrers" +} + // https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pulling-an-image-manifest // https://github.com/opencontainers/distribution-spec/blob/master/spec.md#pushing-an-image func (m *manifests) handle(resp http.ResponseWriter, req *http.Request) *regError { @@ -339,3 +349,82 @@ func (m *manifests) handleCatalog(resp http.ResponseWriter, req *http.Request) * Message: "We don't understand your method + url", } } + +// TODO: implement handling of artifactType querystring +func (m *manifests) handleReferrers(resp http.ResponseWriter, req *http.Request) *regError { + // Ensure this is a GET request + if req.Method != "GET" { + return ®Error{ + Status: http.StatusBadRequest, + Code: "METHOD_UNKNOWN", + Message: "We don't understand your method + url", + } + } + + elem := strings.Split(req.URL.Path, "/") + elem = elem[1:] + target := elem[len(elem)-1] + repo := strings.Join(elem[1:len(elem)-2], "/") + + // Validate that incoming target is a valid digest + if _, err := v1.NewHash(target); err != nil { + return ®Error{ + Status: http.StatusBadRequest, + Code: "UNSUPPORTED", + Message: "Target must be a valid digest", + } + } + + m.lock.Lock() + defer m.lock.Unlock() + + digestToManifestMap, repoExists := m.manifests[repo] + if !repoExists { + return ®Error{ + Status: http.StatusNotFound, + Code: "NAME_UNKNOWN", + Message: "Unknown name", + } + } + + im := v1.IndexManifest{ + SchemaVersion: 2, + MediaType: types.OCIImageIndex, + Manifests: []v1.Descriptor{}, + } + for digest, manifest := range digestToManifestMap { + h, err := v1.NewHash(digest) + if err != nil { + continue + } + var refPointer struct { + Subject *v1.Descriptor `json:"subject"` + } + json.Unmarshal(manifest.blob, &refPointer) + if refPointer.Subject == nil { + continue + } + referenceDigest := refPointer.Subject.Digest + if referenceDigest.String() != target { + continue + } + // At this point, we know the current digest references the target + var imageAsArtifact struct { + Config struct { + MediaType string `json:"mediaType"` + } `json:"config"` + } + json.Unmarshal(manifest.blob, &imageAsArtifact) + im.Manifests = append(im.Manifests, v1.Descriptor{ + MediaType: types.MediaType(manifest.contentType), + Size: int64(len(manifest.blob)), + Digest: h, + ArtifactType: imageAsArtifact.Config.MediaType, + }) + } + msg, _ := json.Marshal(&im) + resp.Header().Set("Content-Length", fmt.Sprint(len(msg))) + resp.WriteHeader(http.StatusOK) + io.Copy(resp, bytes.NewReader([]byte(msg))) + return nil +} diff --git a/pkg/registry/registry.go b/pkg/registry/registry.go index e61290920..303e6e7ef 100644 --- a/pkg/registry/registry.go +++ b/pkg/registry/registry.go @@ -30,9 +30,10 @@ import ( ) type registry struct { - log *log.Logger - blobs blobs - manifests manifests + log *log.Logger + blobs blobs + manifests manifests + referrersEnabled bool } // https://docs.docker.com/registry/spec/api/#api-version-check @@ -50,6 +51,9 @@ func (r *registry) v2(resp http.ResponseWriter, req *http.Request) *regError { if isCatalog(req) { return r.manifests.handleCatalog(resp, req) } + if r.referrersEnabled && isReferrers(req) { + return r.manifests.handleReferrers(resp, req) + } resp.Header().Set("Docker-Distribution-API-Version", "registry/2.0") if req.URL.Path != "/v2/" && req.URL.Path != "/v2" { return ®Error{ @@ -104,3 +108,10 @@ func Logger(l *log.Logger) Option { r.blobs.log = l } } + +// WithReferrersSupport enables the referrers API endpoint (OCI 1.1+) +func WithReferrersSupport(enabled bool) Option { + return func(r *registry) { + r.referrersEnabled = enabled + } +} diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go index f745c1984..0ee492ed8 100644 --- a/pkg/registry/registry_test.go +++ b/pkg/registry/registry_test.go @@ -437,6 +437,53 @@ func TestCalls(t *testing.T) { URL: "/v2/_catalog?n=1000", Code: http.StatusOK, }, + { + Description: "fetch references", + Method: "GET", + URL: "/v2/foo/referrers/sha256:" + sha256String("foo"), + Code: http.StatusOK, + Manifests: map[string]string{ + "foo/manifests/image": "foo", + "foo/manifests/points-to-image": "{\"subject\": {\"digest\": \"sha256:" + sha256String("foo") + "\"}}", + }, + }, + { + Description: "fetch references, subject pointing elsewhere", + Method: "GET", + URL: "/v2/foo/referrers/sha256:" + sha256String("foo"), + Code: http.StatusOK, + Manifests: map[string]string{ + "foo/manifests/image": "foo", + "foo/manifests/points-to-image": "{\"subject\": {\"digest\": \"sha256:" + sha256String("nonexistant") + "\"}}", + }, + }, + { + Description: "fetch references, no results", + Method: "GET", + URL: "/v2/foo/referrers/sha256:" + sha256String("foo"), + Code: http.StatusOK, + Manifests: map[string]string{ + "foo/manifests/image": "foo", + }, + }, + { + Description: "fetch references, missing repo", + Method: "GET", + URL: "/v2/does-not-exist/referrers/sha256:" + sha256String("foo"), + Code: http.StatusNotFound, + }, + { + Description: "fetch references, bad target (tag vs. digest)", + Method: "GET", + URL: "/v2/foo/referrers/latest", + Code: http.StatusBadRequest, + }, + { + Description: "fetch references, bad method", + Method: "POST", + URL: "/v2/foo/referrers/sha256:" + sha256String("foo"), + Code: http.StatusBadRequest, + }, } for _, tc := range tcs { @@ -444,10 +491,11 @@ func TestCalls(t *testing.T) { var logger *log.Logger testf := func(t *testing.T) { - r := registry.New() + opts := []registry.Option{registry.WithReferrersSupport(true)} if logger != nil { - r = registry.New(registry.Logger(logger)) + opts = append(opts, registry.Logger(logger)) } + r := registry.New(opts...) s := httptest.NewServer(r) defer s.Close() diff --git a/pkg/v1/remote/descriptor.go b/pkg/v1/remote/descriptor.go index 878d16542..78919d7a8 100644 --- a/pkg/v1/remote/descriptor.go +++ b/pkg/v1/remote/descriptor.go @@ -245,7 +245,32 @@ func fallbackTag(d name.Digest) name.Tag { } func (f *fetcher) fetchReferrers(ctx context.Context, filter map[string]string, d name.Digest) (*v1.IndexManifest, error) { - // Assume the registry doesn't support the Referrers API endpoint, so we'll use the fallback tag scheme. + // Check the Referrers API endpoint first. + u := f.url("referrers", d.DigestStr()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", string(types.OCIImageIndex)) + + resp, err := f.Client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if err := transport.CheckError(resp, http.StatusOK, http.StatusNotFound, http.StatusBadRequest); err != nil { + return nil, err + } + if resp.StatusCode == http.StatusOK { + var im v1.IndexManifest + if err := json.NewDecoder(resp.Body).Decode(&im); err != nil { + return nil, err + } + return filterReferrersResponse(filter, &im), nil + } + + // The registry doesn't support the Referrers API endpoint, so we'll use the fallback tag scheme. b, _, err := f.fetchManifest(fallbackTag(d), []types.MediaType{types.OCIImageIndex}) if err != nil { return nil, err @@ -261,21 +286,7 @@ func (f *fetcher) fetchReferrers(ctx context.Context, filter map[string]string, return nil, err } - // If filter applied, filter out by artifactType and add annotation - // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-referrers - if filter != nil { - if v, ok := filter["artifactType"]; ok { - tmp := []v1.Descriptor{} - for _, desc := range im.Manifests { - if desc.ArtifactType == v { - tmp = append(tmp, desc) - } - } - im.Manifests = tmp - } - } - - return &im, nil + return filterReferrersResponse(filter, &im), nil } func (f *fetcher) fetchManifest(ref name.Reference, acceptable []types.MediaType) ([]byte, *v1.Descriptor, error) { @@ -479,3 +490,22 @@ func (f *fetcher) blobExists(h v1.Hash) (bool, error) { return resp.StatusCode == http.StatusOK, nil } + +// If filter applied, filter out by artifactType. +// See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-referrers +func filterReferrersResponse(filter map[string]string, origIndex *v1.IndexManifest) *v1.IndexManifest { + newIndex := origIndex + if filter == nil { + return newIndex + } + if v, ok := filter["artifactType"]; ok { + tmp := []v1.Descriptor{} + for _, desc := range newIndex.Manifests { + if desc.ArtifactType == v { + tmp = append(tmp, desc) + } + } + newIndex.Manifests = tmp + } + return newIndex +} diff --git a/pkg/v1/remote/referrers_test.go b/pkg/v1/remote/referrers_test.go index 7a0969dd3..91f9edcc6 100644 --- a/pkg/v1/remote/referrers_test.go +++ b/pkg/v1/remote/referrers_test.go @@ -30,134 +30,154 @@ import ( "github.com/google/go-containerregistry/pkg/v1/types" ) -func TestReferrers_FallbackTag(t *testing.T) { - // Set up a fake registry that doesn't support the Referrers API. - s := httptest.NewServer(registry.New()) - defer s.Close() - u, err := url.Parse(s.URL) - if err != nil { - t.Fatal(err) - } - - descriptor := func(img v1.Image) v1.Descriptor { - d, err := img.Digest() +func TestReferrers(t *testing.T) { + // Run all tests against: + // + // (1) A OCI 1.0 registry (without referrers API) + // (2) An OCI 1.1+ registry (with referrers API) + // + for _, leg := range []struct { + server *httptest.Server + tryFallback bool + }{ + { + server: httptest.NewServer(registry.New(registry.WithReferrersSupport(false))), + tryFallback: true, + }, + { + server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))), + tryFallback: false, + }, + } { + s := leg.server + defer s.Close() + u, err := url.Parse(s.URL) if err != nil { t.Fatal(err) } - sz, err := img.Size() + + descriptor := func(img v1.Image) v1.Descriptor { + d, err := img.Digest() + if err != nil { + t.Fatal(err) + } + sz, err := img.Size() + if err != nil { + t.Fatal(err) + } + mt, err := img.MediaType() + if err != nil { + t.Fatal(err) + } + return v1.Descriptor{ + Digest: d, + Size: sz, + MediaType: mt, + ArtifactType: "application/testing123", + } + } + + // Push an image we'll attach things to. + // We'll copy from src to dst. + rootRef, err := name.ParseReference(fmt.Sprintf("%s/repo:root", u.Host)) if err != nil { t.Fatal(err) } - mt, err := img.MediaType() + rootImg, err := random.Image(10, 10) if err != nil { t.Fatal(err) } - return v1.Descriptor{ - Digest: d, - Size: sz, - MediaType: mt, - ArtifactType: "application/testing123", + rootImg = mutate.ConfigMediaType(rootImg, types.MediaType("application/testing123")) + if err := remote.Write(rootRef, rootImg); err != nil { + t.Fatal(err) } - } + rootDesc := descriptor(rootImg) + t.Logf("root image is %s", rootDesc.Digest) - // Push an image we'll attach things to. - // We'll copy from src to dst. - rootRef, err := name.ParseReference(fmt.Sprintf("%s/repo:root", u.Host)) - if err != nil { - t.Fatal(err) - } - rootImg, err := random.Image(10, 10) - if err != nil { - t.Fatal(err) - } - rootImg = mutate.ConfigMediaType(rootImg, types.MediaType("application/testing123")) - if err := remote.Write(rootRef, rootImg); err != nil { - t.Fatal(err) - } - rootDesc := descriptor(rootImg) - t.Logf("root image is %s", rootDesc.Digest) - - // Push an image that refers to the root image as its subject. - leafRef, err := name.ParseReference(fmt.Sprintf("%s/repo:leaf", u.Host)) - if err != nil { - t.Fatal(err) - } - leafImg, err := random.Image(20, 20) - if err != nil { - t.Fatal(err) - } - leafImg = mutate.ConfigMediaType(leafImg, types.MediaType("application/testing123")) - leafImg = mutate.Subject(leafImg, rootDesc).(v1.Image) - if err := remote.Write(leafRef, leafImg); err != nil { - t.Fatal(err) - } - leafDesc := descriptor(leafImg) - t.Logf("leaf image is %s", leafDesc.Digest) + // Push an image that refers to the root image as its subject. + leafRef, err := name.ParseReference(fmt.Sprintf("%s/repo:leaf", u.Host)) + if err != nil { + t.Fatal(err) + } + leafImg, err := random.Image(20, 20) + if err != nil { + t.Fatal(err) + } + leafImg = mutate.ConfigMediaType(leafImg, types.MediaType("application/testing123")) + leafImg = mutate.Subject(leafImg, rootDesc).(v1.Image) + if err := remote.Write(leafRef, leafImg); err != nil { + t.Fatal(err) + } + leafDesc := descriptor(leafImg) + t.Logf("leaf image is %s", leafDesc.Digest) - // Get the referrers of the root image, by digest. - rootRefDigest := rootRef.Context().Digest(rootDesc.Digest.String()) - index, err := remote.Referrers(rootRefDigest) - if err != nil { - t.Fatal(err) - } - if d := cmp.Diff([]v1.Descriptor{leafDesc}, index.Manifests); d != "" { - t.Fatalf("referrers diff (-want,+got): %s", d) - } + // Get the referrers of the root image, by digest. + rootRefDigest := rootRef.Context().Digest(rootDesc.Digest.String()) + index, err := remote.Referrers(rootRefDigest) + if err != nil { + t.Fatal(err) + } + if d := cmp.Diff([]v1.Descriptor{leafDesc}, index.Manifests); d != "" { + t.Fatalf("referrers diff (-want,+got): %s", d) + } - // Get the referrers by querying the root image's fallback tag directly. - tag, err := name.ParseReference(fmt.Sprintf("%s/repo:sha256-%s", u.Host, rootDesc.Digest.Hex)) - if err != nil { - t.Fatal(err) - } - idx, err := remote.Index(tag) - if err != nil { - t.Fatal(err) - } - mf, err := idx.IndexManifest() - if err != nil { - t.Fatal(err) - } - if d := cmp.Diff(index.Manifests, mf.Manifests); d != "" { - t.Fatalf("fallback tag diff (-want,+got): %s", d) - } + if leg.tryFallback { + // Get the referrers by querying the root image's fallback tag directly. + tag, err := name.ParseReference(fmt.Sprintf("%s/repo:sha256-%s", u.Host, rootDesc.Digest.Hex)) + if err != nil { + t.Fatal(err) + } + idx, err := remote.Index(tag) + if err != nil { + t.Fatal(err) + } + mf, err := idx.IndexManifest() + if err != nil { + t.Fatal(err) + } + if d := cmp.Diff(index.Manifests, mf.Manifests); d != "" { + t.Fatalf("fallback tag diff (-want,+got): %s", d) + } + } - // Push the leaf image again, this time with a different tag. - // This shouldn't add another item to the root image's referrers, - // because it's the same digest. - // Push an image that refers to the root image as its subject. - leaf2Ref, err := name.ParseReference(fmt.Sprintf("%s/repo:leaf2", u.Host)) - if err != nil { - t.Fatal(err) - } - if err := remote.Write(leaf2Ref, leafImg); err != nil { - t.Fatal(err) - } - // Get the referrers of the root image again, which should only have one entry. - rootRefDigest = rootRef.Context().Digest(rootDesc.Digest.String()) - index, err = remote.Referrers(rootRefDigest) - if err != nil { - t.Fatal(err) - } - if d := cmp.Diff([]v1.Descriptor{leafDesc}, index.Manifests); d != "" { - t.Fatalf("referrers diff after second push (-want,+got): %s", d) - } + // Push the leaf image again, this time with a different tag. + // This shouldn't add another item to the root image's referrers, + // because it's the same digest. + // Push an image that refers to the root image as its subject. + leaf2Ref, err := name.ParseReference(fmt.Sprintf("%s/repo:leaf2", u.Host)) + if err != nil { + t.Fatal(err) + } + if err := remote.Write(leaf2Ref, leafImg); err != nil { + t.Fatal(err) + } + // Get the referrers of the root image again, which should only have one entry. + rootRefDigest = rootRef.Context().Digest(rootDesc.Digest.String()) + index, err = remote.Referrers(rootRefDigest) + if err != nil { + t.Fatal(err) + } + if d := cmp.Diff([]v1.Descriptor{leafDesc}, index.Manifests); d != "" { + t.Fatalf("referrers diff after second push (-want,+got): %s", d) + } - // Try applying filters and verify number of manifests and and annotations - index, err = remote.Referrers(rootRefDigest, - remote.WithFilter("artifactType", "application/testing123")) - if err != nil { - t.Fatal(err) - } - if numManifests := len(index.Manifests); numManifests == 0 { - t.Fatal("index contained 0 manifests") - } + // Try applying filters and verify number of manifests and and annotations + index, err = remote.Referrers(rootRefDigest, + remote.WithFilter("artifactType", "application/testing123")) + if err != nil { + t.Fatal(err) + } + if numManifests := len(index.Manifests); numManifests == 0 { + t.Fatal("index contained 0 manifests") + } - index, err = remote.Referrers(rootRefDigest, - remote.WithFilter("artifactType", "application/testing123BADDDD")) - if err != nil { - t.Fatal(err) - } - if numManifests := len(index.Manifests); numManifests != 0 { - t.Fatalf("expected index to contain 0 manifests, but had %d", numManifests) + index, err = remote.Referrers(rootRefDigest, + remote.WithFilter("artifactType", "application/testing123BADDDD")) + if err != nil { + t.Fatal(err) + } + if numManifests := len(index.Manifests); numManifests != 0 { + t.Fatalf("expected index to contain 0 manifests, but had %d", numManifests) + } } } diff --git a/pkg/v1/remote/write.go b/pkg/v1/remote/write.go index d8d86777c..5dbaa7c23 100644 --- a/pkg/v1/remote/write.go +++ b/pkg/v1/remote/write.go @@ -580,19 +580,40 @@ func unpackTaggable(t Taggable) ([]byte, *v1.Descriptor, error) { } // commitSubjectReferrers is responsible for updating the fallback tag manifest to track descriptors referring to a subject for registries that don't yet support the Referrers API. -// TODO: this method only uses the fallback tag for now // TODO: use conditional requests to avoid race conditions func (w *writer) commitSubjectReferrers(ctx context.Context, sub name.Digest, add v1.Descriptor) error { + // Check if the registry supports Referrers API. + // TODO: This should be done once per registry, not once per subject. + u := w.url(fmt.Sprintf("/v2/%s/referrers/%s", w.repo.RepositoryStr(), sub.DigestStr())) + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return err + } + req.Header.Set("Accept", string(types.OCIImageIndex)) + resp, err := w.client.Do(req.WithContext(ctx)) + if err != nil { + return err + } + defer resp.Body.Close() + + if err := transport.CheckError(resp, http.StatusOK, http.StatusNotFound, http.StatusBadRequest); err != nil { + return err + } + if resp.StatusCode == http.StatusOK { + // The registry supports Referrers API. The registry is responsible for updating the referrers list. + return nil + } + // The registry doesn't support Referrers API, we need to update the manifest tagged with the fallback tag. // Make the request to GET the current manifest. t := fallbackTag(sub) - u := w.url(fmt.Sprintf("/v2/%s/manifests/%s", w.repo.RepositoryStr(), t.Identifier())) - req, err := http.NewRequest(http.MethodGet, u.String(), nil) + u = w.url(fmt.Sprintf("/v2/%s/manifests/%s", w.repo.RepositoryStr(), t.Identifier())) + req, err = http.NewRequest(http.MethodGet, u.String(), nil) if err != nil { return err } req.Header.Set("Accept", string(types.OCIImageIndex)) - resp, err := w.client.Do(req.WithContext(ctx)) + resp, err = w.client.Do(req.WithContext(ctx)) if err != nil { return err }