forked from TrueCloudLab/distribution
95f1eea5f5
The OCI distribution spec allows implementations to support deleting manifests by tag, but also permits returning the `UNSUPPORTED` error code for such requests. docker/distribution has never supported deleting manifests by tag, but previously returned `DIGEST_INVALID`. The `Tag` and `Digest` fields of the `manifestHandler` are already correctly populated based on which kind of reference was given in the request URL. Return `UNSUPPORTED` if the `Tag` field is populated. Signed-off-by: Adam Wolfe Gordon <awg@digitalocean.com>
533 lines
16 KiB
Go
533 lines
16 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"mime"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/docker/distribution"
|
|
dcontext "github.com/docker/distribution/context"
|
|
"github.com/docker/distribution/manifest/manifestlist"
|
|
"github.com/docker/distribution/manifest/ocischema"
|
|
"github.com/docker/distribution/manifest/schema1"
|
|
"github.com/docker/distribution/manifest/schema2"
|
|
"github.com/docker/distribution/reference"
|
|
"github.com/docker/distribution/registry/api/errcode"
|
|
v2 "github.com/docker/distribution/registry/api/v2"
|
|
"github.com/docker/distribution/registry/auth"
|
|
"github.com/gorilla/handlers"
|
|
"github.com/opencontainers/go-digest"
|
|
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
|
)
|
|
|
|
// These constants determine which architecture and OS to choose from a
|
|
// manifest list when downconverting it to a schema1 manifest.
|
|
const (
|
|
defaultArch = "amd64"
|
|
defaultOS = "linux"
|
|
maxManifestBodySize = 4 << 20
|
|
imageClass = "image"
|
|
)
|
|
|
|
type storageType int
|
|
|
|
const (
|
|
manifestSchema1 storageType = iota // 0
|
|
manifestSchema2 // 1
|
|
manifestlistSchema // 2
|
|
ociSchema // 3
|
|
ociImageIndexSchema // 4
|
|
numStorageTypes // 5
|
|
)
|
|
|
|
// manifestDispatcher takes the request context and builds the
|
|
// appropriate handler for handling manifest requests.
|
|
func manifestDispatcher(ctx *Context, r *http.Request) http.Handler {
|
|
manifestHandler := &manifestHandler{
|
|
Context: ctx,
|
|
}
|
|
reference := getReference(ctx)
|
|
dgst, err := digest.Parse(reference)
|
|
if err != nil {
|
|
// We just have a tag
|
|
manifestHandler.Tag = reference
|
|
} else {
|
|
manifestHandler.Digest = dgst
|
|
}
|
|
|
|
mhandler := handlers.MethodHandler{
|
|
"GET": http.HandlerFunc(manifestHandler.GetManifest),
|
|
"HEAD": http.HandlerFunc(manifestHandler.GetManifest),
|
|
}
|
|
|
|
if !ctx.readOnly {
|
|
mhandler["PUT"] = http.HandlerFunc(manifestHandler.PutManifest)
|
|
mhandler["DELETE"] = http.HandlerFunc(manifestHandler.DeleteManifest)
|
|
}
|
|
|
|
return mhandler
|
|
}
|
|
|
|
// manifestHandler handles http operations on image manifests.
|
|
type manifestHandler struct {
|
|
*Context
|
|
|
|
// One of tag or digest gets set, depending on what is present in context.
|
|
Tag string
|
|
Digest digest.Digest
|
|
}
|
|
|
|
// GetManifest fetches the image manifest from the storage backend, if it exists.
|
|
func (imh *manifestHandler) GetManifest(w http.ResponseWriter, r *http.Request) {
|
|
dcontext.GetLogger(imh).Debug("GetImageManifest")
|
|
manifests, err := imh.Repository.Manifests(imh)
|
|
if err != nil {
|
|
imh.Errors = append(imh.Errors, err)
|
|
return
|
|
}
|
|
var supports [numStorageTypes]bool
|
|
|
|
// this parsing of Accept headers is not quite as full-featured as godoc.org's parser, but we don't care about "q=" values
|
|
// https://github.com/golang/gddo/blob/e91d4165076d7474d20abda83f92d15c7ebc3e81/httputil/header/header.go#L165-L202
|
|
for _, acceptHeader := range r.Header["Accept"] {
|
|
// r.Header[...] is a slice in case the request contains the same header more than once
|
|
// if the header isn't set, we'll get the zero value, which "range" will handle gracefully
|
|
|
|
// we need to split each header value on "," to get the full list of "Accept" values (per RFC 2616)
|
|
// https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1
|
|
for _, mediaType := range strings.Split(acceptHeader, ",") {
|
|
if mediaType, _, err = mime.ParseMediaType(mediaType); err != nil {
|
|
continue
|
|
}
|
|
|
|
if mediaType == schema2.MediaTypeManifest {
|
|
supports[manifestSchema2] = true
|
|
}
|
|
if mediaType == manifestlist.MediaTypeManifestList {
|
|
supports[manifestlistSchema] = true
|
|
}
|
|
if mediaType == v1.MediaTypeImageManifest {
|
|
supports[ociSchema] = true
|
|
}
|
|
if mediaType == v1.MediaTypeImageIndex {
|
|
supports[ociImageIndexSchema] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if imh.Tag != "" {
|
|
tags := imh.Repository.Tags(imh)
|
|
desc, err := tags.Get(imh, imh.Tag)
|
|
if err != nil {
|
|
if _, ok := err.(distribution.ErrTagUnknown); ok {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestUnknown.WithDetail(err))
|
|
} else {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
|
|
}
|
|
return
|
|
}
|
|
imh.Digest = desc.Digest
|
|
}
|
|
|
|
if etagMatch(r, imh.Digest.String()) {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
var options []distribution.ManifestServiceOption
|
|
if imh.Tag != "" {
|
|
options = append(options, distribution.WithTag(imh.Tag))
|
|
}
|
|
manifest, err := manifests.Get(imh, imh.Digest, options...)
|
|
if err != nil {
|
|
if _, ok := err.(distribution.ErrManifestUnknownRevision); ok {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestUnknown.WithDetail(err))
|
|
} else {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
|
|
}
|
|
return
|
|
}
|
|
// determine the type of the returned manifest
|
|
manifestType := manifestSchema1
|
|
schema2Manifest, isSchema2 := manifest.(*schema2.DeserializedManifest)
|
|
manifestList, isManifestList := manifest.(*manifestlist.DeserializedManifestList)
|
|
if isSchema2 {
|
|
manifestType = manifestSchema2
|
|
} else if _, isOCImanifest := manifest.(*ocischema.DeserializedManifest); isOCImanifest {
|
|
manifestType = ociSchema
|
|
} else if isManifestList {
|
|
if manifestList.MediaType == manifestlist.MediaTypeManifestList {
|
|
manifestType = manifestlistSchema
|
|
} else if manifestList.MediaType == v1.MediaTypeImageIndex {
|
|
manifestType = ociImageIndexSchema
|
|
}
|
|
}
|
|
|
|
if manifestType == ociSchema && !supports[ociSchema] {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestUnknown.WithMessage("OCI manifest found, but accept header does not support OCI manifests"))
|
|
return
|
|
}
|
|
if manifestType == ociImageIndexSchema && !supports[ociImageIndexSchema] {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestUnknown.WithMessage("OCI index found, but accept header does not support OCI indexes"))
|
|
return
|
|
}
|
|
// Only rewrite schema2 manifests when they are being fetched by tag.
|
|
// If they are being fetched by digest, we can't return something not
|
|
// matching the digest.
|
|
if imh.Tag != "" && manifestType == manifestSchema2 && !supports[manifestSchema2] {
|
|
// Rewrite manifest in schema1 format
|
|
dcontext.GetLogger(imh).Infof("rewriting manifest %s in schema1 format to support old client", imh.Digest.String())
|
|
|
|
manifest, err = imh.convertSchema2Manifest(schema2Manifest)
|
|
if err != nil {
|
|
return
|
|
}
|
|
} else if imh.Tag != "" && manifestType == manifestlistSchema && !supports[manifestlistSchema] {
|
|
// Rewrite manifest in schema1 format
|
|
dcontext.GetLogger(imh).Infof("rewriting manifest list %s in schema1 format to support old client", imh.Digest.String())
|
|
|
|
// Find the image manifest corresponding to the default
|
|
// platform
|
|
var manifestDigest digest.Digest
|
|
for _, manifestDescriptor := range manifestList.Manifests {
|
|
if manifestDescriptor.Platform.Architecture == defaultArch && manifestDescriptor.Platform.OS == defaultOS {
|
|
manifestDigest = manifestDescriptor.Digest
|
|
break
|
|
}
|
|
}
|
|
|
|
if manifestDigest == "" {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestUnknown)
|
|
return
|
|
}
|
|
|
|
manifest, err = manifests.Get(imh, manifestDigest)
|
|
if err != nil {
|
|
if _, ok := err.(distribution.ErrManifestUnknownRevision); ok {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestUnknown.WithDetail(err))
|
|
} else {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
// If necessary, convert the image manifest
|
|
if schema2Manifest, isSchema2 := manifest.(*schema2.DeserializedManifest); isSchema2 && !supports[manifestSchema2] {
|
|
manifest, err = imh.convertSchema2Manifest(schema2Manifest)
|
|
if err != nil {
|
|
return
|
|
}
|
|
} else {
|
|
imh.Digest = manifestDigest
|
|
}
|
|
}
|
|
|
|
ct, p, err := manifest.Payload()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", ct)
|
|
w.Header().Set("Content-Length", fmt.Sprint(len(p)))
|
|
w.Header().Set("Docker-Content-Digest", imh.Digest.String())
|
|
w.Header().Set("Etag", fmt.Sprintf(`"%s"`, imh.Digest))
|
|
w.Write(p)
|
|
}
|
|
|
|
func (imh *manifestHandler) convertSchema2Manifest(schema2Manifest *schema2.DeserializedManifest) (distribution.Manifest, error) {
|
|
targetDescriptor := schema2Manifest.Target()
|
|
blobs := imh.Repository.Blobs(imh)
|
|
configJSON, err := blobs.Get(imh, targetDescriptor.Digest)
|
|
if err != nil {
|
|
if err == distribution.ErrBlobUnknown {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestInvalid.WithDetail(err))
|
|
} else {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
|
|
}
|
|
return nil, err
|
|
}
|
|
|
|
ref := imh.Repository.Named()
|
|
|
|
if imh.Tag != "" {
|
|
ref, err = reference.WithTag(ref, imh.Tag)
|
|
if err != nil {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeTagInvalid.WithDetail(err))
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
builder := schema1.NewConfigManifestBuilder(imh.Repository.Blobs(imh), imh.Context.App.trustKey, ref, configJSON)
|
|
for _, d := range schema2Manifest.Layers {
|
|
if err := builder.AppendReference(d); err != nil {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestInvalid.WithDetail(err))
|
|
return nil, err
|
|
}
|
|
}
|
|
manifest, err := builder.Build(imh)
|
|
if err != nil {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestInvalid.WithDetail(err))
|
|
return nil, err
|
|
}
|
|
imh.Digest = digest.FromBytes(manifest.(*schema1.SignedManifest).Canonical)
|
|
|
|
return manifest, nil
|
|
}
|
|
|
|
func etagMatch(r *http.Request, etag string) bool {
|
|
for _, headerVal := range r.Header["If-None-Match"] {
|
|
if headerVal == etag || headerVal == fmt.Sprintf(`"%s"`, etag) { // allow quoted or unquoted
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// PutManifest validates and stores a manifest in the registry.
|
|
func (imh *manifestHandler) PutManifest(w http.ResponseWriter, r *http.Request) {
|
|
dcontext.GetLogger(imh).Debug("PutImageManifest")
|
|
manifests, err := imh.Repository.Manifests(imh)
|
|
if err != nil {
|
|
imh.Errors = append(imh.Errors, err)
|
|
return
|
|
}
|
|
|
|
var jsonBuf bytes.Buffer
|
|
if err := copyFullPayload(imh, w, r, &jsonBuf, maxManifestBodySize, "image manifest PUT"); err != nil {
|
|
// copyFullPayload reports the error if necessary
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestInvalid.WithDetail(err.Error()))
|
|
return
|
|
}
|
|
|
|
mediaType := r.Header.Get("Content-Type")
|
|
manifest, desc, err := distribution.UnmarshalManifest(mediaType, jsonBuf.Bytes())
|
|
if err != nil {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestInvalid.WithDetail(err))
|
|
return
|
|
}
|
|
|
|
if imh.Digest != "" {
|
|
if desc.Digest != imh.Digest {
|
|
dcontext.GetLogger(imh).Errorf("payload digest does match: %q != %q", desc.Digest, imh.Digest)
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeDigestInvalid)
|
|
return
|
|
}
|
|
} else if imh.Tag != "" {
|
|
imh.Digest = desc.Digest
|
|
} else {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeTagInvalid.WithDetail("no tag or digest specified"))
|
|
return
|
|
}
|
|
|
|
isAnOCIManifest := mediaType == v1.MediaTypeImageManifest || mediaType == v1.MediaTypeImageIndex
|
|
|
|
if isAnOCIManifest {
|
|
dcontext.GetLogger(imh).Debug("Putting an OCI Manifest!")
|
|
} else {
|
|
dcontext.GetLogger(imh).Debug("Putting a Docker Manifest!")
|
|
}
|
|
|
|
var options []distribution.ManifestServiceOption
|
|
if imh.Tag != "" {
|
|
options = append(options, distribution.WithTag(imh.Tag))
|
|
}
|
|
|
|
if err := imh.applyResourcePolicy(manifest); err != nil {
|
|
imh.Errors = append(imh.Errors, err)
|
|
return
|
|
}
|
|
|
|
_, err = manifests.Put(imh, manifest, options...)
|
|
if err != nil {
|
|
// TODO(stevvooe): These error handling switches really need to be
|
|
// handled by an app global mapper.
|
|
if err == distribution.ErrUnsupported {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnsupported)
|
|
return
|
|
}
|
|
if err == distribution.ErrAccessDenied {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeDenied)
|
|
return
|
|
}
|
|
switch err := err.(type) {
|
|
case distribution.ErrManifestVerification:
|
|
for _, verificationError := range err {
|
|
switch verificationError := verificationError.(type) {
|
|
case distribution.ErrManifestBlobUnknown:
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestBlobUnknown.WithDetail(verificationError.Digest))
|
|
case distribution.ErrManifestNameInvalid:
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeNameInvalid.WithDetail(err))
|
|
case distribution.ErrManifestUnverified:
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestUnverified)
|
|
default:
|
|
if verificationError == digest.ErrDigestInvalidFormat {
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeDigestInvalid)
|
|
} else {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown, verificationError)
|
|
}
|
|
}
|
|
}
|
|
case errcode.Error:
|
|
imh.Errors = append(imh.Errors, err)
|
|
default:
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
|
|
}
|
|
return
|
|
}
|
|
|
|
// Tag this manifest
|
|
if imh.Tag != "" {
|
|
tags := imh.Repository.Tags(imh)
|
|
err = tags.Tag(imh, imh.Tag, desc)
|
|
if err != nil {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
|
|
return
|
|
}
|
|
|
|
}
|
|
|
|
// Construct a canonical url for the uploaded manifest.
|
|
ref, err := reference.WithDigest(imh.Repository.Named(), imh.Digest)
|
|
if err != nil {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown.WithDetail(err))
|
|
return
|
|
}
|
|
|
|
location, err := imh.urlBuilder.BuildManifestURL(ref)
|
|
if err != nil {
|
|
// NOTE(stevvooe): Given the behavior above, this absurdly unlikely to
|
|
// happen. We'll log the error here but proceed as if it worked. Worst
|
|
// case, we set an empty location header.
|
|
dcontext.GetLogger(imh).Errorf("error building manifest url from digest: %v", err)
|
|
}
|
|
|
|
w.Header().Set("Location", location)
|
|
w.Header().Set("Docker-Content-Digest", imh.Digest.String())
|
|
w.WriteHeader(http.StatusCreated)
|
|
|
|
dcontext.GetLogger(imh).Debug("Succeeded in putting manifest!")
|
|
}
|
|
|
|
// applyResourcePolicy checks whether the resource class matches what has
|
|
// been authorized and allowed by the policy configuration.
|
|
func (imh *manifestHandler) applyResourcePolicy(manifest distribution.Manifest) error {
|
|
allowedClasses := imh.App.Config.Policy.Repository.Classes
|
|
if len(allowedClasses) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var class string
|
|
switch m := manifest.(type) {
|
|
case *schema1.SignedManifest:
|
|
class = imageClass
|
|
case *schema2.DeserializedManifest:
|
|
switch m.Config.MediaType {
|
|
case schema2.MediaTypeImageConfig:
|
|
class = imageClass
|
|
case schema2.MediaTypePluginConfig:
|
|
class = "plugin"
|
|
default:
|
|
return errcode.ErrorCodeDenied.WithMessage("unknown manifest class for " + m.Config.MediaType)
|
|
}
|
|
case *ocischema.DeserializedManifest:
|
|
switch m.Config.MediaType {
|
|
case v1.MediaTypeImageConfig:
|
|
class = imageClass
|
|
default:
|
|
return errcode.ErrorCodeDenied.WithMessage("unknown manifest class for " + m.Config.MediaType)
|
|
}
|
|
}
|
|
|
|
if class == "" {
|
|
return nil
|
|
}
|
|
|
|
// Check to see if class is allowed in registry
|
|
var allowedClass bool
|
|
for _, c := range allowedClasses {
|
|
if class == c {
|
|
allowedClass = true
|
|
break
|
|
}
|
|
}
|
|
if !allowedClass {
|
|
return errcode.ErrorCodeDenied.WithMessage(fmt.Sprintf("registry does not allow %s manifest", class))
|
|
}
|
|
|
|
resources := auth.AuthorizedResources(imh)
|
|
n := imh.Repository.Named().Name()
|
|
|
|
var foundResource bool
|
|
for _, r := range resources {
|
|
if r.Name == n {
|
|
if r.Class == "" {
|
|
r.Class = imageClass
|
|
}
|
|
if r.Class == class {
|
|
return nil
|
|
}
|
|
foundResource = true
|
|
}
|
|
}
|
|
|
|
// resource was found but no matching class was found
|
|
if foundResource {
|
|
return errcode.ErrorCodeDenied.WithMessage(fmt.Sprintf("repository not authorized for %s manifest", class))
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
// DeleteManifest removes the manifest with the given digest from the registry.
|
|
func (imh *manifestHandler) DeleteManifest(w http.ResponseWriter, r *http.Request) {
|
|
dcontext.GetLogger(imh).Debug("DeleteImageManifest")
|
|
|
|
if imh.Tag != "" {
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnsupported)
|
|
return
|
|
}
|
|
|
|
manifests, err := imh.Repository.Manifests(imh)
|
|
if err != nil {
|
|
imh.Errors = append(imh.Errors, err)
|
|
return
|
|
}
|
|
|
|
err = manifests.Delete(imh, imh.Digest)
|
|
if err != nil {
|
|
switch err {
|
|
case digest.ErrDigestUnsupported:
|
|
case digest.ErrDigestInvalidFormat:
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeDigestInvalid)
|
|
return
|
|
case distribution.ErrBlobUnknown:
|
|
imh.Errors = append(imh.Errors, v2.ErrorCodeManifestUnknown)
|
|
return
|
|
case distribution.ErrUnsupported:
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnsupported)
|
|
return
|
|
default:
|
|
imh.Errors = append(imh.Errors, errcode.ErrorCodeUnknown)
|
|
return
|
|
}
|
|
}
|
|
|
|
tagService := imh.Repository.Tags(imh)
|
|
referencedTags, err := tagService.Lookup(imh, distribution.Descriptor{Digest: imh.Digest})
|
|
if err != nil {
|
|
imh.Errors = append(imh.Errors, err)
|
|
return
|
|
}
|
|
|
|
for _, tag := range referencedTags {
|
|
if err := tagService.Untag(imh, tag); err != nil {
|
|
imh.Errors = append(imh.Errors, err)
|
|
return
|
|
}
|
|
}
|
|
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|