diff --git a/cmd/dist/common.go b/cmd/dist/common.go index 4a738ea..461ee6e 100644 --- a/cmd/dist/common.go +++ b/cmd/dist/common.go @@ -1,6 +1,7 @@ package main import ( + "context" "net" "path/filepath" "time" @@ -8,6 +9,8 @@ import ( "github.com/boltdb/bolt" "github.com/docker/containerd/content" "github.com/docker/containerd/images" + "github.com/docker/containerd/remotes" + "github.com/docker/containerd/remotes/docker" "github.com/urfave/cli" "google.golang.org/grpc" ) @@ -57,3 +60,8 @@ func getDB(ctx *cli.Context, readonly bool) (*bolt.DB, error) { return db, nil } + +// getResolver prepares the resolver from the environment and options. +func getResolver(ctx context.Context) (remotes.Resolver, error) { + return docker.NewResolver(), nil +} diff --git a/cmd/dist/fetchobject.go b/cmd/dist/fetchobject.go index ae2662f..c4d5048 100644 --- a/cmd/dist/fetchobject.go +++ b/cmd/dist/fetchobject.go @@ -2,31 +2,11 @@ package main import ( contextpkg "context" - "encoding/json" - "fmt" "io" - "io/ioutil" - "net/http" - "net/url" "os" - "path" - "strconv" - "strings" - "github.com/Sirupsen/logrus" "github.com/docker/containerd/log" - "github.com/docker/containerd/reference" - "github.com/docker/containerd/remotes" - digest "github.com/opencontainers/go-digest" - ocispec "github.com/opencontainers/image-spec/specs-go/v1" - "github.com/pkg/errors" "github.com/urfave/cli" - "golang.org/x/net/context/ctxhttp" -) - -const ( - MediaTypeDockerSchema2Manifest = "application/vnd.docker.distribution.manifest.v2+json" - MediaTypeDockerSchema2ManifestList = "application/vnd.docker.distribution.manifest.list.v2+json" ) // TODO(stevvooe): Create "multi-fetch" mode that just takes a remote @@ -85,277 +65,3 @@ var fetchObjectCommand = cli.Command{ return nil }, } - -// getResolver prepares the resolver from the environment and options. -func getResolver(ctx contextpkg.Context) (remotes.Resolver, error) { - return newDockerResolver(), nil -} - -// NOTE(stevvooe): Most of the code below this point is prototype code to -// demonstrate a very simplified docker.io fetcher. We have a lot of hard coded -// values but we leave many of the details down to the fetcher, creating a lot -// of room for ways to fetch content. - -type dockerFetcher struct { - base url.URL - token string -} - -func (r *dockerFetcher) url(ps ...string) string { - url := r.base - url.Path = path.Join(url.Path, path.Join(ps...)) - return url.String() -} - -func (r *dockerFetcher) Fetch(ctx contextpkg.Context, desc ocispec.Descriptor) (io.ReadCloser, error) { - ctx = log.WithLogger(ctx, log.G(ctx).WithFields( - logrus.Fields{ - "base": r.base.String(), - "digest": desc.Digest, - }, - )) - - paths, err := getV2URLPaths(desc) - if err != nil { - return nil, err - } - - for _, path := range paths { - u := r.url(path) - - req, err := http.NewRequest(http.MethodGet, u, nil) - if err != nil { - return nil, err - } - - req.Header.Set("Accept", strings.Join([]string{desc.MediaType, `*`}, ", ")) - resp, err := r.doRequest(ctx, req) - if err != nil { - return nil, err - } - - if resp.StatusCode > 299 { - if resp.StatusCode == http.StatusNotFound { - continue // try one of the other urls. - } - resp.Body.Close() - return nil, errors.Errorf("unexpected status code %v: %v", u, resp.Status) - } - - return resp.Body, nil - } - - return nil, errors.New("not found") -} - -func (r *dockerFetcher) doRequest(ctx contextpkg.Context, req *http.Request) (*http.Response, error) { - ctx = log.WithLogger(ctx, log.G(ctx).WithField("url", req.URL.String())) - log.G(ctx).WithField("request.headers", req.Header).Debug("fetch content") - if r.token != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.token)) - } - - resp, err := ctxhttp.Do(ctx, http.DefaultClient, req) - if err != nil { - return nil, err - } - log.G(ctx).WithFields(logrus.Fields{ - "status": resp.Status, - "response.headers": resp.Header, - }).Debug("fetch response received") - - return resp, err -} - -type dockerResolver struct{} - -var _ remotes.Resolver = &dockerResolver{} - -func newDockerResolver() remotes.Resolver { - return &dockerResolver{} -} - -func (r *dockerResolver) Resolve(ctx contextpkg.Context, ref string) (string, ocispec.Descriptor, remotes.Fetcher, error) { - refspec, err := reference.Parse(ref) - if err != nil { - return "", ocispec.Descriptor{}, nil, err - } - - var ( - base url.URL - token string - ) - - switch refspec.Hostname() { - case "docker.io": - base.Scheme = "https" - base.Host = "registry-1.docker.io" - prefix := strings.TrimPrefix(refspec.Locator, "docker.io/") - base.Path = path.Join("/v2", prefix) - token, err = getToken(ctx, "repository:"+prefix+":pull") - if err != nil { - return "", ocispec.Descriptor{}, nil, err - } - case "localhost:5000": - base.Scheme = "http" - base.Host = "localhost:5000" - base.Path = path.Join("/v2", strings.TrimPrefix(refspec.Locator, "localhost:5000/")) - default: - return "", ocispec.Descriptor{}, nil, errors.Errorf("unsupported locator: %q", refspec.Locator) - } - - fetcher := &dockerFetcher{ - base: base, - token: token, - } - - var ( - urls []string - dgst = refspec.Digest() - ) - - if dgst != "" { - if err := dgst.Validate(); err != nil { - // need to fail here, since we can't actually resolve the invalid - // digest. - return "", ocispec.Descriptor{}, nil, err - } - - // turns out, we have a valid digest, make a url. - urls = append(urls, fetcher.url("manifests", dgst.String())) - } else { - urls = append(urls, fetcher.url("manifests", refspec.Object)) - } - - // fallback to blobs on not found. - urls = append(urls, fetcher.url("blobs", dgst.String())) - - for _, u := range urls { - req, err := http.NewRequest(http.MethodHead, u, nil) - if err != nil { - return "", ocispec.Descriptor{}, nil, err - } - - // set headers for all the types we support for resolution. - req.Header.Set("Accept", strings.Join([]string{ - MediaTypeDockerSchema2Manifest, - MediaTypeDockerSchema2ManifestList, - ocispec.MediaTypeImageManifest, - ocispec.MediaTypeImageIndex, "*"}, ", ")) - - log.G(ctx).Debug("resolving") - resp, err := fetcher.doRequest(ctx, req) - if err != nil { - return "", ocispec.Descriptor{}, nil, err - } - resp.Body.Close() // don't care about body contents. - - if resp.StatusCode > 299 { - if resp.StatusCode == http.StatusNotFound { - continue - } - return "", ocispec.Descriptor{}, nil, errors.Errorf("unexpected status code %v: %v", u, resp.Status) - } - - // this is the only point at which we trust the registry. we use the - // content headers to assemble a descriptor for the name. when this becomes - // more robust, we mostly get this information from a secure trust store. - dgstHeader := digest.Digest(resp.Header.Get("Docker-Content-Digest")) - - if dgstHeader != "" { - if err := dgstHeader.Validate(); err != nil { - if err == nil { - return "", ocispec.Descriptor{}, nil, errors.Errorf("%q in header not a valid digest", dgstHeader) - } - return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "%q in header not a valid digest", dgstHeader) - } - dgst = dgstHeader - } - - if dgst == "" { - return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "could not resolve digest for %v", ref) - } - - var ( - size int64 - sizeHeader = resp.Header.Get("Content-Length") - ) - - size, err = strconv.ParseInt(sizeHeader, 10, 64) - if err != nil || size < 0 { - return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "%q in header not a valid size", sizeHeader) - } - - desc := ocispec.Descriptor{ - Digest: dgst, - MediaType: resp.Header.Get("Content-Type"), // need to strip disposition? - Size: size, - } - - log.G(ctx).WithField("desc.digest", desc.Digest).Debug("resolved") - return ref, desc, fetcher, nil - } - - return "", ocispec.Descriptor{}, nil, errors.Errorf("%v not found", ref) -} - -func getToken(ctx contextpkg.Context, scopes ...string) (string, error) { - var ( - u = url.URL{ - Scheme: "https", - Host: "auth.docker.io", - Path: "/token", - } - - q = url.Values{ - "scope": scopes, - "service": []string{"registry.docker.io"}, // usually comes from auth challenge - } - ) - - u.RawQuery = q.Encode() - - log.G(ctx).WithField("token.url", u.String()).Debug("requesting token") - resp, err := ctxhttp.Get(ctx, http.DefaultClient, u.String()) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if resp.StatusCode > 299 { - return "", errors.Errorf("unexpected status code: %v %v", resp.StatusCode, resp.Status) - } - - p, err := ioutil.ReadAll(resp.Body) - if err != nil { - return "", err - } - - var tokenResponse struct { - Token string `json:"token"` - } - - if err := json.Unmarshal(p, &tokenResponse); err != nil { - return "", err - } - - return tokenResponse.Token, nil -} - -// getV2URLPaths generates the candidate urls paths for the object based on the -// set of hints and the provided object id. URLs are returned in the order of -// most to least likely succeed. -func getV2URLPaths(desc ocispec.Descriptor) ([]string, error) { - var urls []string - - switch desc.MediaType { - case MediaTypeDockerSchema2Manifest, MediaTypeDockerSchema2ManifestList, - ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex: - urls = append(urls, path.Join("manifests", desc.Digest.String())) - } - - // always fallback to attempting to get the object out of the blobs store. - urls = append(urls, path.Join("blobs", desc.Digest.String())) - - return urls, nil -} diff --git a/images/handlers.go b/images/handlers.go index f5f507b..99caa06 100644 --- a/images/handlers.go +++ b/images/handlers.go @@ -11,14 +11,6 @@ import ( "golang.org/x/sync/errgroup" ) -const ( - MediaTypeDockerSchema2Layer = "application/vnd.docker.image.rootfs.diff.tar" - MediaTypeDockerSchema2LayerGzip = "application/vnd.docker.image.rootfs.diff.tar.gzip" - MediaTypeDockerSchema2Config = "application/vnd.docker.container.image.v1+json" - MediaTypeDockerSchema2Manifest = "application/vnd.docker.distribution.manifest.v2+json" - MediaTypeDockerSchema2ManifestList = "application/vnd.docker.distribution.manifest.list.v2+json" -) - var SkipDesc = fmt.Errorf("skip descriptor") type Handler interface { diff --git a/images/mediatypes.go b/images/mediatypes.go new file mode 100644 index 0000000..f7cfe09 --- /dev/null +++ b/images/mediatypes.go @@ -0,0 +1,13 @@ +package images + +// mediatype definitions for image components handled in containerd. +// +// oci components are generally referenced directly, although we may centralize +// here for clarity. +const ( + MediaTypeDockerSchema2Layer = "application/vnd.docker.image.rootfs.diff.tar" + MediaTypeDockerSchema2LayerGzip = "application/vnd.docker.image.rootfs.diff.tar.gzip" + MediaTypeDockerSchema2Config = "application/vnd.docker.container.image.v1+json" + MediaTypeDockerSchema2Manifest = "application/vnd.docker.distribution.manifest.v2+json" + MediaTypeDockerSchema2ManifestList = "application/vnd.docker.distribution.manifest.list.v2+json" +) diff --git a/remotes/docker/resolver.go b/remotes/docker/resolver.go new file mode 100644 index 0000000..f3bc509 --- /dev/null +++ b/remotes/docker/resolver.go @@ -0,0 +1,293 @@ +package docker + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/url" + "path" + "strconv" + "strings" + + "github.com/Sirupsen/logrus" + "github.com/docker/containerd/images" + "github.com/docker/containerd/log" + "github.com/docker/containerd/reference" + "github.com/docker/containerd/remotes" + digest "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/pkg/errors" + "golang.org/x/net/context/ctxhttp" +) + +// NOTE(stevvooe): Most of the code below this point is prototype code to +// demonstrate a very simplified docker.io fetcher. We have a lot of hard coded +// values but we leave many of the details down to the fetcher, creating a lot +// of room for ways to fetch content. + +type dockerResolver struct{} + +func NewResolver() remotes.Resolver { + return &dockerResolver{} +} + +var _ remotes.Resolver = &dockerResolver{} + +func (r *dockerResolver) Resolve(ctx context.Context, ref string) (string, ocispec.Descriptor, remotes.Fetcher, error) { + refspec, err := reference.Parse(ref) + if err != nil { + return "", ocispec.Descriptor{}, nil, err + } + + var ( + base url.URL + token string + ) + + switch refspec.Hostname() { + case "docker.io": + base.Scheme = "https" + base.Host = "registry-1.docker.io" + prefix := strings.TrimPrefix(refspec.Locator, "docker.io/") + base.Path = path.Join("/v2", prefix) + token, err = getToken(ctx, "repository:"+prefix+":pull") + if err != nil { + return "", ocispec.Descriptor{}, nil, err + } + case "localhost:5000": + base.Scheme = "http" + base.Host = "localhost:5000" + base.Path = path.Join("/v2", strings.TrimPrefix(refspec.Locator, "localhost:5000/")) + default: + return "", ocispec.Descriptor{}, nil, errors.Errorf("unsupported locator: %q", refspec.Locator) + } + + fetcher := &dockerFetcher{ + base: base, + token: token, + } + + var ( + urls []string + dgst = refspec.Digest() + ) + + if dgst != "" { + if err := dgst.Validate(); err != nil { + // need to fail here, since we can't actually resolve the invalid + // digest. + return "", ocispec.Descriptor{}, nil, err + } + + // turns out, we have a valid digest, make a url. + urls = append(urls, fetcher.url("manifests", dgst.String())) + } else { + urls = append(urls, fetcher.url("manifests", refspec.Object)) + } + + // fallback to blobs on not found. + urls = append(urls, fetcher.url("blobs", dgst.String())) + + for _, u := range urls { + req, err := http.NewRequest(http.MethodHead, u, nil) + if err != nil { + return "", ocispec.Descriptor{}, nil, err + } + + // set headers for all the types we support for resolution. + req.Header.Set("Accept", strings.Join([]string{ + images.MediaTypeDockerSchema2Manifest, + images.MediaTypeDockerSchema2ManifestList, + ocispec.MediaTypeImageManifest, + ocispec.MediaTypeImageIndex, "*"}, ", ")) + + log.G(ctx).Debug("resolving") + resp, err := fetcher.doRequest(ctx, req) + if err != nil { + return "", ocispec.Descriptor{}, nil, err + } + resp.Body.Close() // don't care about body contents. + + if resp.StatusCode > 299 { + if resp.StatusCode == http.StatusNotFound { + continue + } + return "", ocispec.Descriptor{}, nil, errors.Errorf("unexpected status code %v: %v", u, resp.Status) + } + + // this is the only point at which we trust the registry. we use the + // content headers to assemble a descriptor for the name. when this becomes + // more robust, we mostly get this information from a secure trust store. + dgstHeader := digest.Digest(resp.Header.Get("Docker-Content-Digest")) + + if dgstHeader != "" { + if err := dgstHeader.Validate(); err != nil { + if err == nil { + return "", ocispec.Descriptor{}, nil, errors.Errorf("%q in header not a valid digest", dgstHeader) + } + return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "%q in header not a valid digest", dgstHeader) + } + dgst = dgstHeader + } + + if dgst == "" { + return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "could not resolve digest for %v", ref) + } + + var ( + size int64 + sizeHeader = resp.Header.Get("Content-Length") + ) + + size, err = strconv.ParseInt(sizeHeader, 10, 64) + if err != nil || size < 0 { + return "", ocispec.Descriptor{}, nil, errors.Wrapf(err, "%q in header not a valid size", sizeHeader) + } + + desc := ocispec.Descriptor{ + Digest: dgst, + MediaType: resp.Header.Get("Content-Type"), // need to strip disposition? + Size: size, + } + + log.G(ctx).WithField("desc.digest", desc.Digest).Debug("resolved") + return ref, desc, fetcher, nil + } + + return "", ocispec.Descriptor{}, nil, errors.Errorf("%v not found", ref) +} + +type dockerFetcher struct { + base url.URL + token string +} + +func (r *dockerFetcher) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.ReadCloser, error) { + ctx = log.WithLogger(ctx, log.G(ctx).WithFields( + logrus.Fields{ + "base": r.base.String(), + "digest": desc.Digest, + }, + )) + + paths, err := getV2URLPaths(desc) + if err != nil { + return nil, err + } + + for _, path := range paths { + u := r.url(path) + + req, err := http.NewRequest(http.MethodGet, u, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Accept", strings.Join([]string{desc.MediaType, `*`}, ", ")) + resp, err := r.doRequest(ctx, req) + if err != nil { + return nil, err + } + + if resp.StatusCode > 299 { + if resp.StatusCode == http.StatusNotFound { + continue // try one of the other urls. + } + resp.Body.Close() + return nil, errors.Errorf("unexpected status code %v: %v", u, resp.Status) + } + + return resp.Body, nil + } + + return nil, errors.New("not found") +} + +func (r *dockerFetcher) url(ps ...string) string { + url := r.base + url.Path = path.Join(url.Path, path.Join(ps...)) + return url.String() +} + +func (r *dockerFetcher) doRequest(ctx context.Context, req *http.Request) (*http.Response, error) { + ctx = log.WithLogger(ctx, log.G(ctx).WithField("url", req.URL.String())) + log.G(ctx).WithField("request.headers", req.Header).Debug("fetch content") + if r.token != "" { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", r.token)) + } + + resp, err := ctxhttp.Do(ctx, http.DefaultClient, req) + if err != nil { + return nil, err + } + log.G(ctx).WithFields(logrus.Fields{ + "status": resp.Status, + "response.headers": resp.Header, + }).Debug("fetch response received") + + return resp, err +} + +func getToken(ctx context.Context, scopes ...string) (string, error) { + var ( + u = url.URL{ + Scheme: "https", + Host: "auth.docker.io", + Path: "/token", + } + + q = url.Values{ + "scope": scopes, + "service": []string{"registry.docker.io"}, // usually comes from auth challenge + } + ) + + u.RawQuery = q.Encode() + + log.G(ctx).WithField("token.url", u.String()).Debug("requesting token") + resp, err := ctxhttp.Get(ctx, http.DefaultClient, u.String()) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return "", errors.Errorf("unexpected status code: %v %v", resp.StatusCode, resp.Status) + } + + p, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + var tokenResponse struct { + Token string `json:"token"` + } + + if err := json.Unmarshal(p, &tokenResponse); err != nil { + return "", err + } + + return tokenResponse.Token, nil +} + +// getV2URLPaths generates the candidate urls paths for the object based on the +// set of hints and the provided object id. URLs are returned in the order of +// most to least likely succeed. +func getV2URLPaths(desc ocispec.Descriptor) ([]string, error) { + var urls []string + + switch desc.MediaType { + case images.MediaTypeDockerSchema2Manifest, images.MediaTypeDockerSchema2ManifestList, + ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex: + urls = append(urls, path.Join("manifests", desc.Digest.String())) + } + + // always fallback to attempting to get the object out of the blobs store. + urls = append(urls, path.Join("blobs", desc.Digest.String())) + + return urls, nil +}