Skip to content

Commit fb59c36

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 fb59c36

File tree

23 files changed

+910
-211
lines changed

23 files changed

+910
-211
lines changed

errors/errors.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ 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")
121+
ErrInvalidManifestTagQuery = errors.New("invalid tag query parameter: not a valid OCI/Docker tag")
120122
ErrInvalidRepoRefFormat = errors.New("invalid image reference format, use [repo:tag] or [repo@digest]")
121123
ErrLimitIsNegative = errors.New("pagination limit has negative value")
122124
ErrLimitIsExcessive = errors.New("pagination limit has excessive value")

pkg/api/constants/consts.go

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,23 @@ 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 raw `tag=` query parameters accepted on
16+
// PUT .../manifests/<digest>?tag=... (draft OCI distribution-spec: registries MUST support at
17+
// least 10 and MAY respond with 414 beyond this limit). It uses the OCI tag max length (128;
18+
// must match pkg/regexp.TagMaxLen) and an ~8KiB request-target budget, reserving 2048 bytes
19+
// for path and digest:
20+
//
21+
// (8192 - 2048) / (len("tag=") + 128 + 1) == 46
22+
MaxManifestDigestQueryTags = (8192 - 2048) / (len("tag=") + 128 + 1)
1223
BlobUploadUUID = "Blob-Upload-UUID"
1324
DefaultMediaType = "application/json"
1425
BinaryMediaType = "application/octet-stream"

pkg/api/controller_test.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import (
5959
extconf "zotregistry.dev/zot/v2/pkg/extensions/config"
6060
"zotregistry.dev/zot/v2/pkg/log"
6161
"zotregistry.dev/zot/v2/pkg/meta"
62+
zreg "zotregistry.dev/zot/v2/pkg/regexp"
6263
"zotregistry.dev/zot/v2/pkg/storage"
6364
storageConstants "zotregistry.dev/zot/v2/pkg/storage/constants"
6465
"zotregistry.dev/zot/v2/pkg/storage/gc"
@@ -7808,6 +7809,142 @@ func TestManifestValidation(t *testing.T) {
78087809
})
78097810
}
78107811

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

pkg/api/routes.go

Lines changed: 93 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,30 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
717717
return
718718
}
719719

720+
var digestQueryTags []string
721+
722+
rawTagQuery := request.URL.Query()["tag"]
723+
if len(rawTagQuery) > 0 {
724+
if len(rawTagQuery) > constants.MaxManifestDigestQueryTags {
725+
e := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{
726+
"reason": fmt.Sprintf("too many tag query parameters (max %d)", constants.MaxManifestDigestQueryTags),
727+
})
728+
zcommon.WriteJSON(response, http.StatusRequestURITooLong, apiErr.NewErrorList(e))
729+
730+
return
731+
}
732+
733+
var normErr error
734+
735+
digestQueryTags, normErr = normalizeManifestExtraTags(rawTagQuery)
736+
if normErr != nil {
737+
err := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{"reason": normErr.Error()})
738+
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(err))
739+
740+
return
741+
}
742+
}
743+
720744
body, err := io.ReadAll(request.Body)
721745
// hard to reach test case, injected error (simulates an interrupted image manifest upload)
722746
// err could be io.ErrUnexpectedEOF
@@ -727,7 +751,16 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
727751
return
728752
}
729753

730-
digest, subjectDigest, err := imgStore.PutImageManifest(name, reference, mediaType, body)
754+
if len(digestQueryTags) > 0 && !zcommon.IsDigest(reference) {
755+
err := apiErr.NewError(apiErr.MANIFEST_INVALID).AddDetail(map[string]string{
756+
"reason": "tag query parameters are only valid when pushing a manifest by digest",
757+
})
758+
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(err))
759+
760+
return
761+
}
762+
763+
digest, subjectDigest, err := imgStore.PutImageManifest(name, reference, mediaType, body, digestQueryTags)
731764
if err != nil {
732765
details := zerr.GetDetails(err)
733766
if errors.Is(err, zerr.ErrRepoNotFound) { //nolint:gocritic // errorslint conflicts with gocritic:IfElseChain
@@ -769,12 +802,33 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
769802
}
770803

771804
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)
805+
if len(digestQueryTags) > 0 {
806+
metaUpdateFailed := false
776807

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

@@ -784,9 +838,42 @@ func (rh *RouteHandler) UpdateManifest(response http.ResponseWriter, request *ht
784838

785839
response.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, digest))
786840
response.Header().Set(constants.DistContentDigestKey, digest.String())
841+
842+
for _, tag := range digestQueryTags {
843+
response.Header().Add(constants.OCITagResponseKey, tag) //nolint:canonicalheader
844+
}
845+
787846
response.WriteHeader(http.StatusCreated)
788847
}
789848

849+
// normalizeManifestExtraTags deduplicates tag query values in order, rejects empty components, and
850+
// requires each value to match the OCI distribution-spec tag grammar (zreg.IsDistributionSpecTag).
851+
func normalizeManifestExtraTags(raw []string) ([]string, error) {
852+
seen := map[string]struct{}{}
853+
854+
out := make([]string, 0, len(raw))
855+
856+
for _, rawTag := range raw {
857+
cleanedTag := strings.TrimSpace(rawTag)
858+
if cleanedTag == "" {
859+
return nil, zerr.ErrEmptyManifestTagQuery
860+
}
861+
862+
if !zreg.IsDistributionSpecTag(cleanedTag) {
863+
return nil, zerr.ErrInvalidManifestTagQuery
864+
}
865+
866+
if _, ok := seen[cleanedTag]; ok {
867+
continue
868+
}
869+
870+
seen[cleanedTag] = struct{}{}
871+
out = append(out, cleanedTag)
872+
}
873+
874+
return out, nil
875+
}
876+
790877
// DeleteManifest godoc
791878
// @Summary Delete image manifest
792879
// @Description Delete an image's manifest given a reference or a digest

0 commit comments

Comments
 (0)