Skip to content

Commit 0aa1103

Browse files
committed
feat: support pushing multiple tags for a single manifest
See opencontainers/distribution-spec#600 Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
1 parent 9c7e77e commit 0aa1103

22 files changed

Lines changed: 565 additions & 211 deletions

errors/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,7 @@ var (
117117
ErrEmptyRepoName = errors.New("repo name can't be empty string")
118118
ErrEmptyTag = errors.New("tag can't be empty string")
119119
ErrEmptyDigest = errors.New("digest can't be empty string")
120+
ErrEmptyManifestTagQuery = errors.New("empty tag query parameter")
120121
ErrInvalidRepoRefFormat = errors.New("invalid image reference format, use [repo:tag] or [repo@digest]")
121122
ErrLimitIsNegative = errors.New("pagination limit has negative value")
122123
ErrLimitIsExcessive = errors.New("pagination limit has excessive value")

pkg/api/constants/consts.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,19 @@ package constants
33
import "time"
44

55
const (
6-
RoutePrefix = "/v2"
7-
Blobs = "blobs"
8-
Uploads = "uploads"
9-
DistAPIVersion = "Docker-Distribution-API-Version"
10-
DistContentDigestKey = "Docker-Content-Digest"
11-
SubjectDigestKey = "OCI-Subject"
6+
RoutePrefix = "/v2"
7+
Blobs = "blobs"
8+
Uploads = "uploads"
9+
DistAPIVersion = "Docker-Distribution-API-Version"
10+
DistContentDigestKey = "Docker-Content-Digest"
11+
// OCITagResponseKey is returned on digest manifest pushes that include tag query
12+
// parameters (distribution-spec PR #600).
13+
OCITagResponseKey = "OCI-Tag"
14+
SubjectDigestKey = "OCI-Subject"
15+
// MaxManifestDigestQueryTags is the maximum number of tag query parameters accepted on
16+
// PUT .../manifests/<digest>?tag=... (draft OCI distribution-spec: registries MUST
17+
// support at least this many and MAY respond with 414 beyond it).
18+
MaxManifestDigestQueryTags = 10
1219
BlobUploadUUID = "Blob-Upload-UUID"
1320
DefaultMediaType = "application/json"
1421
BinaryMediaType = "application/octet-stream"

pkg/api/controller_test.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7808,6 +7808,115 @@ func TestManifestValidation(t *testing.T) {
78087808
})
78097809
}
78107810

7811+
func TestManifestDigestQueryTags(t *testing.T) {
7812+
Convey("Manifest PUT with digest ?tag= query parameters", t, func() {
7813+
port := test.GetFreePort()
7814+
baseURL := test.GetBaseURL(port)
7815+
7816+
conf := config.New()
7817+
conf.HTTP.Port = port
7818+
7819+
dir := t.TempDir()
7820+
ctlr := makeController(conf, dir)
7821+
cm := test.NewControllerManager(ctlr)
7822+
cm.StartServer()
7823+
time.Sleep(1000 * time.Millisecond)
7824+
7825+
defer cm.StopServer()
7826+
7827+
repoName := "digest-query-tags"
7828+
img := CreateRandomImage()
7829+
manifestBytes := img.ManifestDescriptor.Data
7830+
manifestDigest := img.ManifestDescriptor.Digest
7831+
7832+
err := UploadImage(img, baseURL, repoName, "initial")
7833+
So(err, ShouldBeNil)
7834+
7835+
putManifestByDigest := func(rawQuery string) *resty.Response {
7836+
t.Helper()
7837+
7838+
u, perr := url.Parse(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, manifestDigest.String()))
7839+
So(perr, ShouldBeNil)
7840+
u.RawQuery = rawQuery
7841+
7842+
resp, rerr := resty.R().
7843+
SetHeader("Content-Type", ispec.MediaTypeImageManifest).
7844+
SetBody(manifestBytes).
7845+
Put(u.String())
7846+
So(rerr, ShouldBeNil)
7847+
7848+
return resp
7849+
}
7850+
7851+
Convey("multiple tag query parameters add tags and return OCI-Tag headers", func() {
7852+
u, err := url.Parse(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, manifestDigest.String()))
7853+
So(err, ShouldBeNil)
7854+
7855+
q := u.Query()
7856+
q.Add("tag", "v1.0.0")
7857+
q.Add("tag", "v1.0")
7858+
q.Add("tag", "edge")
7859+
u.RawQuery = q.Encode()
7860+
7861+
resp, err := resty.R().
7862+
SetHeader("Content-Type", ispec.MediaTypeImageManifest).
7863+
SetBody(manifestBytes).
7864+
Put(u.String())
7865+
So(err, ShouldBeNil)
7866+
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
7867+
So(resp.Header().Get(constants.DistContentDigestKey), ShouldEqual, manifestDigest.String())
7868+
7869+
ociTags := resp.Header().Values(constants.OCITagResponseKey)
7870+
sort.Strings(ociTags)
7871+
So(ociTags, ShouldResemble, []string{"edge", "v1.0", "v1.0.0"})
7872+
7873+
for _, tag := range []string{"v1.0.0", "v1.0", "edge"} {
7874+
gresp, gerr := resty.R().Get(baseURL + fmt.Sprintf("/v2/%s/manifests/%s", repoName, tag))
7875+
So(gerr, ShouldBeNil)
7876+
So(gresp.StatusCode(), ShouldEqual, http.StatusOK)
7877+
}
7878+
})
7879+
7880+
Convey("tag query with non-digest path reference returns 400", func() {
7881+
u, err := url.Parse(baseURL + fmt.Sprintf("/v2/%s/manifests/initial", repoName))
7882+
So(err, ShouldBeNil)
7883+
u.RawQuery = "tag=notallowed"
7884+
7885+
resp, err := resty.R().
7886+
SetHeader("Content-Type", ispec.MediaTypeImageManifest).
7887+
SetBody(manifestBytes).
7888+
Put(u.String())
7889+
So(err, ShouldBeNil)
7890+
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
7891+
})
7892+
7893+
Convey("empty tag query parameter returns 400", func() {
7894+
resp := putManifestByDigest("tag=")
7895+
So(resp.StatusCode(), ShouldEqual, http.StatusBadRequest)
7896+
})
7897+
7898+
Convey("more than max tag query parameters returns 414", func() {
7899+
q := url.Values{}
7900+
for i := range constants.MaxManifestDigestQueryTags + 1 {
7901+
q.Add("tag", fmt.Sprintf("t%d", i))
7902+
}
7903+
7904+
resp := putManifestByDigest(q.Encode())
7905+
So(resp.StatusCode(), ShouldEqual, http.StatusRequestURITooLong)
7906+
})
7907+
7908+
Convey("duplicate tag query values are deduplicated in response headers", func() {
7909+
q := url.Values{}
7910+
q.Add("tag", "dup")
7911+
q.Add("tag", "dup")
7912+
7913+
resp := putManifestByDigest(q.Encode())
7914+
So(resp.StatusCode(), ShouldEqual, http.StatusCreated)
7915+
So(resp.Header().Values(constants.OCITagResponseKey), ShouldResemble, []string{"dup"})
7916+
})
7917+
})
7918+
}
7919+
78117920
func TestArtifactReferences(t *testing.T) {
78127921
Convey("Validate Artifact References", t, func() {
78137922
// start a new server

pkg/api/routes.go

Lines changed: 90 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,34 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
727727
return
728728
}
729729

730-
digest, subjectDigest, err := imgStore.PutImageManifest(name, reference, mediaType, body)
730+
rawTagQuery, tagQueryPresent := request.URL.Query()["tag"]
731+
digestQueryTags, normErr := normalizeManifestExtraTags(tagQueryPresent, rawTagQuery)
732+
if normErr != nil {
733+
err := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{"reason": normErr.Error()})
734+
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(err))
735+
736+
return
737+
}
738+
739+
if len(digestQueryTags) > 0 && !zcommon.IsDigest(reference) {
740+
err := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{
741+
"reason": "tag query parameters are only valid when pushing a manifest by digest",
742+
})
743+
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(err))
744+
745+
return
746+
}
747+
748+
if len(digestQueryTags) > constants.MaxManifestDigestQueryTags {
749+
e := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{
750+
"reason": fmt.Sprintf("too many tag query parameters (max %d)", constants.MaxManifestDigestQueryTags),
751+
})
752+
zcommon.WriteJSON(response, http.StatusRequestURITooLong, apiErr.NewErrorList(e))
753+
754+
return
755+
}
756+
757+
digest, subjectDigest, err := imgStore.PutImageManifest(name, reference, mediaType, body, digestQueryTags)
731758
if err != nil {
732759
details := zerr.GetDetails(err)
733760
if errors.Is(err, zerr.ErrRepoNotFound) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
@@ -769,12 +796,33 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
769796
}
770797

771798
if rh.c.MetaDB != nil {
772-
err := meta.OnUpdateManifest(request.Context(), name, reference, mediaType,
773-
digest, body, rh.c.StoreController, rh.c.MetaDB, rh.c.Log)
774-
if err != nil {
775-
response.WriteHeader(http.StatusInternalServerError)
799+
if len(digestQueryTags) > 0 {
800+
metaUpdateFailed := false
776801

777-
return
802+
for _, tag := range digestQueryTags {
803+
mErr := meta.SetImageMetaFromInput(request.Context(), name, tag, mediaType,
804+
digest, body, imgStore, rh.c.MetaDB, rh.c.Log)
805+
if mErr != nil {
806+
rh.c.Log.Error().Err(mErr).Str("repository", name).Str("tag", tag).
807+
Msg("multi-tag digest push: failed to update meta for tag")
808+
809+
metaUpdateFailed = true
810+
}
811+
}
812+
813+
if metaUpdateFailed {
814+
response.WriteHeader(http.StatusInternalServerError)
815+
816+
return
817+
}
818+
} else {
819+
err := meta.OnUpdateManifest(request.Context(), name, reference, mediaType,
820+
digest, body, rh.c.StoreController, rh.c.MetaDB, rh.c.Log)
821+
if err != nil {
822+
response.WriteHeader(http.StatusInternalServerError)
823+
824+
return
825+
}
778826
}
779827
}
780828

@@ -784,9 +832,45 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
784832

785833
response.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, digest))
786834
response.Header().Set(constants.DistContentDigestKey, digest.String())
835+
836+
for _, tag := range digestQueryTags {
837+
response.Header().Add(constants.OCITagResponseKey, tag) //nolint:canonicalheader
838+
}
839+
787840
response.WriteHeader(http.StatusCreated)
788841
}
789842

843+
// normalizeManifestExtraTags deduplicates tag query values in order and rejects empty tag components.
844+
func normalizeManifestExtraTags(tagQueryPresent bool, raw []string) ([]string, error) {
845+
if !tagQueryPresent {
846+
return nil, nil
847+
}
848+
849+
seen := map[string]struct{}{}
850+
851+
out := make([]string, 0, len(raw))
852+
853+
for _, rawTag := range raw {
854+
rawTag = strings.TrimSpace(rawTag)
855+
if rawTag == "" {
856+
return nil, zerr.ErrEmptyManifestTagQuery
857+
}
858+
859+
if _, ok := seen[rawTag]; ok {
860+
continue
861+
}
862+
863+
seen[rawTag] = struct{}{}
864+
out = append(out, rawTag)
865+
}
866+
867+
if len(out) == 0 {
868+
return nil, zerr.ErrEmptyManifestTagQuery
869+
}
870+
871+
return out, nil
872+
}
873+
790874
// DeleteManifest godoc
791875
// @Summary Delete image manifest
792876
// @Description Delete an image's manifest given a reference or a digest

pkg/api/routes_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ func TestRoutes(t *testing.T) {
265265
"reference": "reference",
266266
},
267267
&mocks.MockedImageStore{
268-
PutImageManifestFn: func(repo, reference, mediaType string, body []byte) (godigest.Digest,
268+
PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest,
269269
godigest.Digest, error,
270270
) {
271271
return "", "", zerr.ErrRepoNotFound
@@ -280,7 +280,7 @@ func TestRoutes(t *testing.T) {
280280
},
281281

282282
&mocks.MockedImageStore{
283-
PutImageManifestFn: func(repo, reference, mediaType string, body []byte) (godigest.Digest,
283+
PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest,
284284
godigest.Digest, error,
285285
) {
286286
return "", "", zerr.ErrManifestNotFound
@@ -294,7 +294,7 @@ func TestRoutes(t *testing.T) {
294294
"reference": "reference",
295295
},
296296
&mocks.MockedImageStore{
297-
PutImageManifestFn: func(repo, reference, mediaType string, body []byte) (godigest.Digest,
297+
PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest,
298298
godigest.Digest, error,
299299
) {
300300
return "", "", zerr.ErrBadManifest
@@ -308,7 +308,7 @@ func TestRoutes(t *testing.T) {
308308
"reference": "reference",
309309
},
310310
&mocks.MockedImageStore{
311-
PutImageManifestFn: func(repo, reference, mediaType string, body []byte) (godigest.Digest,
311+
PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest,
312312
godigest.Digest, error,
313313
) {
314314
return "", "", zerr.ErrBlobNotFound
@@ -323,7 +323,7 @@ func TestRoutes(t *testing.T) {
323323
"reference": "reference",
324324
},
325325
&mocks.MockedImageStore{
326-
PutImageManifestFn: func(repo, reference, mediaType string, body []byte) (godigest.Digest,
326+
PutImageManifestFn: func(repo, reference, mediaType string, body []byte, _ []string) (godigest.Digest,
327327
godigest.Digest, error,
328328
) {
329329
return "", "", zerr.ErrRepoBadVersion

0 commit comments

Comments
 (0)