Skip to content

Commit

Permalink
add remote.Head (#770)
Browse files Browse the repository at this point in the history
* add remote.Head

this is useful for fetching a reference's digest/size/type via a HEAD
request, which does not count towards Docker Hub image pull rate limits

Signed-off-by: Alex Suraci <suraci.alex@gmail.com>

* fix up modules

Signed-off-by: Alex Suraci <suraci.alex@gmail.com>
  • Loading branch information
vito committed Sep 16, 2020
1 parent d7f8d06 commit 2ff6f48
Show file tree
Hide file tree
Showing 7 changed files with 252 additions and 19 deletions.
3 changes: 2 additions & 1 deletion go.mod

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

76 changes: 76 additions & 0 deletions pkg/v1/remote/descriptor.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"io/ioutil"
"net/http"
"net/url"
"strconv"
"strings"

"github.com/google/go-containerregistry/pkg/logs"
Expand Down Expand Up @@ -76,6 +77,8 @@ func (d *Descriptor) RawManifest() ([]byte, error) {
// Get returns a remote.Descriptor for the given reference. The response from
// the registry is left un-interpreted, for the most part. This is useful for
// querying what kind of artifact a reference represents.
//
// See Head if you don't need the response body.
func Get(ref name.Reference, options ...Option) (*Descriptor, error) {
acceptable := []types.MediaType{
// Just to look at them.
Expand All @@ -87,6 +90,30 @@ func Get(ref name.Reference, options ...Option) (*Descriptor, error) {
return get(ref, acceptable, options...)
}

// Head returns a v1.Descriptor for the given reference by issuing a HEAD
// request.
func Head(ref name.Reference, options ...Option) (*v1.Descriptor, error) {
acceptable := []types.MediaType{
// Just to look at them.
types.DockerManifestSchema1,
types.DockerManifestSchema1Signed,
}
acceptable = append(acceptable, acceptableImageMediaTypes...)
acceptable = append(acceptable, acceptableIndexMediaTypes...)

o, err := makeOptions(ref.Context(), options...)
if err != nil {
return nil, err
}

f, err := makeFetcher(ref, o)
if err != nil {
return nil, err
}

return f.headManifest(ref, acceptable)
}

// Handle options and fetch the manifest with the acceptable MediaTypes in the
// Accept header.
func get(ref name.Reference, acceptable []types.MediaType, options ...Option) (*Descriptor, error) {
Expand Down Expand Up @@ -277,6 +304,55 @@ func (f *fetcher) fetchManifest(ref name.Reference, acceptable []types.MediaType
return manifest, &desc, nil
}

func (f *fetcher) headManifest(ref name.Reference, acceptable []types.MediaType) (*v1.Descriptor, error) {
u := f.url("manifests", ref.Identifier())
req, err := http.NewRequest(http.MethodHead, u.String(), nil)
if err != nil {
return nil, err
}
accept := []string{}
for _, mt := range acceptable {
accept = append(accept, string(mt))
}
req.Header.Set("Accept", strings.Join(accept, ","))

resp, err := f.Client.Do(req.WithContext(f.context))
if err != nil {
return nil, err
}
defer resp.Body.Close()

if err := transport.CheckError(resp, http.StatusOK); err != nil {
return nil, err
}

mediaType := types.MediaType(resp.Header.Get("Content-Type"))

size, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64)
if err != nil {
return nil, err
}

digest, err := v1.NewHash(resp.Header.Get("Docker-Content-Digest"))
if err != nil {
return nil, err
}

// Validate the digest matches what we asked for, if pulling by digest.
if dgst, ok := ref.(name.Digest); ok {
if digest.String() != dgst.DigestStr() {
return nil, fmt.Errorf("manifest digest: %q does not match requested digest: %q for %q", digest, dgst.DigestStr(), f.Ref)
}
}

// Return all this info since we have to calculate it anyway.
return &v1.Descriptor{
Digest: digest,
Size: size,
MediaType: mediaType,
}, nil
}

func (f *fetcher) fetchBlob(h v1.Hash) (io.ReadCloser, error) {
u := f.url("blobs", h.String())
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
Expand Down
51 changes: 51 additions & 0 deletions pkg/v1/remote/descriptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"

"github.com/google/go-cmp/cmp"
Expand Down Expand Up @@ -123,3 +124,53 @@ func TestGetImageAsIndex(t *testing.T) {
t.Errorf("ImageIndex() = %v, expected err", err)
}
}

func TestHeadSchema1(t *testing.T) {
expectedRepo := "foo/bar"
mediaType := types.DockerManifestSchema1Signed
fakeDigest := "sha256:0000000000000000000000000000000000000000000000000000000000000000"
response := []byte("doesn't matter")
manifestPath := fmt.Sprintf("/v2/%s/manifests/latest", expectedRepo)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/":
w.WriteHeader(http.StatusOK)
case manifestPath:
if r.Method != http.MethodHead {
t.Errorf("Method; got %v, want %v", r.Method, http.MethodHead)
}
w.Header().Set("Content-Type", string(mediaType))
w.Header().Set("Content-Length", strconv.Itoa(len(response)))
w.Header().Set("Docker-Content-Digest", fakeDigest)
w.Write(response)
default:
t.Fatalf("Unexpected path: %v", r.URL.Path)
}
}))
defer server.Close()
u, err := url.Parse(server.URL)
if err != nil {
t.Fatalf("url.Parse(%v) = %v", server.URL, err)
}

tag := mustNewTag(t, fmt.Sprintf("%s/%s:latest", u.Host, expectedRepo))

// Head should succeed even for invalid json. We don't parse the response.
desc, err := Head(tag)
if err != nil {
t.Fatalf("Head(%s) = %v", tag, err)
}

if desc.MediaType != mediaType {
t.Errorf("Descriptor.MediaType = %q, expected %q", desc.MediaType, mediaType)
}

if desc.Digest.String() != fakeDigest {
t.Errorf("Descriptor.Digest = %q, expected %q", desc.Digest, fakeDigest)
}

if desc.Size != int64(len(response)) {
t.Errorf("Descriptor.Size = %q, expected %q", desc.Size, len(response))
}
}
18 changes: 17 additions & 1 deletion vendor/golang.org/x/tools/go/packages/golist.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

117 changes: 101 additions & 16 deletions vendor/golang.org/x/tools/go/packages/golist_overlay.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion vendor/modules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ golang.org/x/text/unicode/norm
# golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1
## explicit
golang.org/x/time/rate
# golang.org/x/tools v0.0.0-20200911153331-7ad463ce66dd
# golang.org/x/tools v0.0.0-20200916195026-c9a70fc28ce3
## explicit
golang.org/x/tools/go/ast/astutil
golang.org/x/tools/go/gcexportdata
Expand Down

0 comments on commit 2ff6f48

Please sign in to comment.