test: add tests for pushing manifests with non-canonical digests together with tags (#3920)

test: add tests for pushing manifests with non-cannonical digests together with tags

Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com>
This commit is contained in:
Andrei Aaron
2026-03-31 12:30:19 +03:00
committed by GitHub
parent 79ab6464dc
commit aa742aa1c0
3 changed files with 248 additions and 116 deletions
+41
View File
@@ -13920,6 +13920,47 @@ func TestSupportedDigestAlgorithms(t *testing.T) {
verifyReturnedManifestDigest(t, client, baseURL, name,
subImage2.ManifestDescriptor.Digest.String(), subImage2.ManifestDescriptor.Digest.String())
})
Convey("Test digest push with multiple tags via UploadImageWithOpts", t, func() {
image := CreateImageWithDigestAlgorithm(godigest.SHA512).
RandomLayers(1, 13).DefaultConfig().Build()
name := "multi-tag-digest-push"
tagA, tagB, tagC := "mtag-a", "mtag-b", "mtag-c"
err := UploadImageWithOpts(image, baseURL, name, "", WithExtraTags(tagA, tagB, tagC))
So(err, ShouldBeNil)
client := resty.New()
// Same digest the uploader puts in PUT .../manifests/{digest}?tag=... (see Image.Digest / digestAlgorithm).
manifestDigest := image.Digest()
expectedDigestStr := manifestDigest.String()
for _, tag := range []string{tagA, tagB, tagC} {
verifyReturnedManifestDigest(t, client, baseURL, name, tag, expectedDigestStr)
}
verifyReturnedManifestDigest(t, client, baseURL, name, expectedDigestStr, expectedDigestStr)
tagsFromStore, err := readTagsFromStorage(dir, name, manifestDigest)
So(err, ShouldBeNil)
tagsSeen := map[string]struct{}{}
for _, refName := range tagsFromStore {
if refName != "" {
tagsSeen[refName] = struct{}{}
}
}
So(len(tagsSeen), ShouldEqual, 3)
for _, want := range []string{tagA, tagB, tagC} {
_, ok := tagsSeen[want]
So(ok, ShouldBeTrue)
}
})
}
func verifyReturnedManifestDigest(t *testing.T, client *resty.Client, baseURL, repoName,
+96 -116
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
godigest "github.com/opencontainers/go-digest"
@@ -16,14 +17,75 @@ import (
)
var (
ErrPostBlob = errors.New("can't post blob")
ErrPutBlob = errors.New("can't put blob")
ErrPutIndex = errors.New("can't put index")
ErrPostBlob = errors.New("can't post blob")
ErrPutBlob = errors.New("can't put blob")
ErrPutIndex = errors.New("can't put index")
ErrInvalidRefForExtraTags = errors.New("ref must be empty or a valid digest when using extra tags")
)
// UploadOption configures an upload request.
type UploadOption func(*uploadConfig)
type uploadConfig struct {
user string
password string
extraTags []string
}
func (c *uploadConfig) withAuth(req *resty.Request) *resty.Request {
if c.user != "" {
return req.SetBasicAuth(c.user, c.password)
}
return req
}
func (c *uploadConfig) withTagParams(req *resty.Request) *resty.Request {
tagParams := make(url.Values)
for _, t := range c.extraTags {
tagParams.Add("tag", t)
}
return req.SetMultiValueQueryParams(tagParams)
}
// WithBasicAuth sets HTTP basic authentication credentials for the upload.
func WithBasicAuth(user, password string) UploadOption {
return func(c *uploadConfig) {
c.user = user
c.password = password
}
}
// WithExtraTags attaches additional tags to the manifest via the digest-push
// API (PUT /v2/{repo}/manifests/{digest}?tag=...).
func WithExtraTags(tags ...string) UploadOption {
return func(c *uploadConfig) {
c.extraTags = append(c.extraTags, tags...)
}
}
func UploadImage(img Image, baseURL, repo, ref string) error {
return UploadImageWithOpts(img, baseURL, repo, ref)
}
func UploadImageWithBasicAuth(img Image, baseURL, repo, ref, user, password string) error {
return UploadImageWithOpts(img, baseURL, repo, ref, WithBasicAuth(user, password))
}
func UploadImageWithOpts(img Image, baseURL, repo, ref string, opts ...UploadOption) error {
cfg := &uploadConfig{}
for _, opt := range opts {
opt(cfg)
}
if ref == "" {
ref = img.DigestStr()
} else if len(cfg.extraTags) > 0 {
if _, err := godigest.Parse(ref); err != nil {
return ErrInvalidRefForExtraTags
}
}
digestAlgorithm := img.digestAlgorithm
@@ -33,7 +95,8 @@ func UploadImage(img Image, baseURL, repo, ref string) error {
}
for _, blob := range img.Layers {
resp, err := resty.R().Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
resp, err := cfg.withAuth(resty.R()).
Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
if err != nil {
return err
}
@@ -46,7 +109,7 @@ func UploadImage(img Image, baseURL, repo, ref string) error {
digest := digestAlgorithm.FromBytes(blob).String()
resp, err = resty.R().
resp, err = cfg.withAuth(resty.R()).
SetHeader("Content-Length", strconv.Itoa(len(blob))).
SetHeader("Content-Type", "application/octet-stream").
SetQueryParam("digest", digest).
@@ -81,7 +144,7 @@ func UploadImage(img Image, baseURL, repo, ref string) error {
cdigest = ispec.DescriptorEmptyJSON.Digest
}
resp, err := resty.R().
resp, err := cfg.withAuth(resty.R()).
Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
if err = inject.Error(err); err != nil {
return err
@@ -94,7 +157,7 @@ func UploadImage(img Image, baseURL, repo, ref string) error {
loc := tcommon.Location(baseURL, resp)
// uploading blob should get 201
resp, err = resty.R().
resp, err = cfg.withAuth(resty.R()).
SetHeader("Content-Length", strconv.Itoa(len(cblob))).
SetHeader("Content-Type", "application/octet-stream").
SetQueryParam("digest", cdigest.String()).
@@ -129,7 +192,7 @@ func UploadImage(img Image, baseURL, repo, ref string) error {
mediaType = ispec.MediaTypeImageManifest
}
resp, err = resty.R().
resp, err = cfg.withTagParams(cfg.withAuth(resty.R())).
SetHeader("Content-type", mediaType).
SetBody(manifestBlob).
Put(baseURL + "/v2/" + repo + "/manifests/" + ref)
@@ -141,121 +204,38 @@ func UploadImage(img Image, baseURL, repo, ref string) error {
return err
}
func UploadImageWithBasicAuth(img Image, baseURL, repo, ref, user, password string) error {
digestAlgorithm := img.digestAlgorithm
if digestAlgorithm == "" {
digestAlgorithm = godigest.Canonical
}
for _, blob := range img.Layers {
resp, err := resty.R().
SetBasicAuth(user, password).
Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
if err != nil {
return err
}
if resp.StatusCode() != http.StatusAccepted {
return ErrPostBlob
}
loc := resp.Header().Get("Location")
digest := digestAlgorithm.FromBytes(blob).String()
resp, err = resty.R().
SetBasicAuth(user, password).
SetHeader("Content-Length", strconv.Itoa(len(blob))).
SetHeader("Content-Type", "application/octet-stream").
SetQueryParam("digest", digest).
SetBody(blob).
Put(baseURL + loc)
if err != nil {
return err
}
if resp.StatusCode() != http.StatusCreated {
return ErrPutBlob
}
}
// upload config
cblob, err := json.Marshal(img.Config)
if err = inject.Error(err); err != nil {
return err
}
cdigest := digestAlgorithm.FromBytes(cblob)
if img.Manifest.Config.MediaType == ispec.MediaTypeEmptyJSON {
cblob = ispec.DescriptorEmptyJSON.Data
cdigest = ispec.DescriptorEmptyJSON.Digest
}
resp, err := resty.R().
SetBasicAuth(user, password).
Post(baseURL + "/v2/" + repo + "/blobs/uploads/")
if err = inject.Error(err); err != nil {
return err
}
if inject.ErrStatusCode(resp.StatusCode()) != http.StatusAccepted || inject.ErrStatusCode(resp.StatusCode()) == -1 {
return ErrPostBlob
}
loc := tcommon.Location(baseURL, resp)
// uploading blob should get 201
resp, err = resty.R().
SetBasicAuth(user, password).
SetHeader("Content-Length", strconv.Itoa(len(cblob))).
SetHeader("Content-Type", "application/octet-stream").
SetQueryParam("digest", cdigest.String()).
SetBody(cblob).
Put(loc)
if err = inject.Error(err); err != nil {
return err
}
if inject.ErrStatusCode(resp.StatusCode()) != http.StatusCreated || inject.ErrStatusCode(resp.StatusCode()) == -1 {
return ErrPostBlob
}
// put manifest
manifestBlob, err := json.Marshal(img.Manifest)
if err = inject.Error(err); err != nil {
return err
}
// Use the media type from ManifestDescriptor, or fall back to Manifest.MediaType, or default to OCI
mediaType := img.ManifestDescriptor.MediaType
if mediaType == "" {
mediaType = img.Manifest.MediaType
}
if mediaType == "" {
mediaType = ispec.MediaTypeImageManifest
}
_, err = resty.R().
SetBasicAuth(user, password).
SetHeader("Content-type", mediaType).
SetBody(manifestBlob).
Put(baseURL + "/v2/" + repo + "/manifests/" + ref)
return err
func UploadMultiarchImage(multiImage MultiarchImage, baseURL string, repo, ref string) error {
return UploadMultiarchImageWithOpts(multiImage, baseURL, repo, ref)
}
func UploadMultiarchImage(multiImage MultiarchImage, baseURL string, repo, ref string) error {
func UploadMultiarchImageWithOpts(multiImage MultiarchImage, baseURL string, repo, ref string,
opts ...UploadOption,
) error {
cfg := &uploadConfig{}
for _, opt := range opts {
opt(cfg)
}
if ref == "" {
ref = multiImage.DigestStr()
} else if len(cfg.extraTags) > 0 {
if _, err := godigest.Parse(ref); err != nil {
return ErrInvalidRefForExtraTags
}
}
for _, image := range multiImage.Images {
err := UploadImage(image, baseURL, repo, image.DigestStr())
var perImageOpts []UploadOption
if cfg.user != "" {
perImageOpts = append(perImageOpts, WithBasicAuth(cfg.user, cfg.password))
}
err := UploadImageWithOpts(image, baseURL, repo, image.DigestStr(), perImageOpts...)
if err != nil {
return err
}
}
// put manifest
indexBlob := multiImage.IndexDescriptor.Data
if len(indexBlob) == 0 {
@@ -278,7 +258,7 @@ func UploadMultiarchImage(multiImage MultiarchImage, baseURL string, repo, ref s
mediaType = ispec.MediaTypeImageIndex
}
resp, err := resty.R().
resp, err := cfg.withTagParams(cfg.withAuth(resty.R())).
SetHeader("Content-type", mediaType).
SetBody(indexBlob).
Put(baseURL + "/v2/" + repo + "/manifests/" + ref)
+111
View File
@@ -482,6 +482,117 @@ func TestUploadMultiarchImage(t *testing.T) {
})
}
func TestUploadImageWithOpts(t *testing.T) {
Convey("WithBasicAuth uploads image successfully with valid credentials", t, func() {
port := tcommon.GetFreePort()
baseURL := tcommon.GetBaseURL(port)
user := "user"
password := "password"
testString := tcommon.GetBcryptCredString(user, password)
htpasswdPath := tcommon.MakeHtpasswdFileFromString(t, testString)
conf := config.New()
conf.HTTP.Port = port
conf.Storage.RootDirectory = t.TempDir()
conf.HTTP.Auth = &config.AuthConfig{
HTPasswd: config.AuthHTPasswd{
Path: htpasswdPath,
},
}
ctlr := api.NewController(conf)
ctlrManager := tcommon.NewControllerManager(ctlr)
ctlrManager.StartAndWait(port)
defer ctlrManager.StopServer()
layerBlob := []byte("test")
img := Image{
Layers: [][]byte{layerBlob},
Manifest: ispec.Manifest{
Versioned: specs.Versioned{
SchemaVersion: 2,
},
Layers: []ispec.Descriptor{
{
Digest: godigest.FromBytes(layerBlob),
Size: int64(len(layerBlob)),
MediaType: ispec.MediaTypeImageLayerGzip,
},
},
Config: ispec.DescriptorEmptyJSON,
},
Config: ispec.Image{},
}
err := UploadImageWithOpts(img, baseURL, "test", "mytag", WithBasicAuth(user, password))
So(err, ShouldBeNil)
})
Convey("WithExtraTags exercises tag params path", t, func() {
port := tcommon.GetFreePort()
baseURL := tcommon.GetBaseURL(port)
conf := config.New()
conf.HTTP.Port = port
conf.Storage.RootDirectory = t.TempDir()
ctlr := api.NewController(conf)
ctlrManager := tcommon.NewControllerManager(ctlr)
ctlrManager.StartAndWait(port)
defer ctlrManager.StopServer()
layerBlob := []byte("test")
img := Image{
Layers: [][]byte{layerBlob},
Manifest: ispec.Manifest{
Versioned: specs.Versioned{
SchemaVersion: 2,
},
Layers: []ispec.Descriptor{
{
Digest: godigest.FromBytes(layerBlob),
Size: int64(len(layerBlob)),
MediaType: ispec.MediaTypeImageLayerGzip,
},
},
Config: ispec.DescriptorEmptyJSON,
},
Config: ispec.Image{},
}
err := UploadImageWithOpts(img, baseURL, "test", "", WithExtraTags("v1", "v2"))
So(err, ShouldBeNil)
})
Convey("WithExtraTags rejects non-digest non-empty ref", t, func() {
err := UploadImageWithOpts(
Image{Layers: [][]byte{{1, 2, 3}}},
"http://127.0.0.1:0",
"repo",
"latest",
WithExtraTags("v1"),
)
So(err, ShouldEqual, ErrInvalidRefForExtraTags)
})
Convey("UploadMultiarchImageWithOpts rejects non-digest non-empty ref with extra tags", t, func() {
err := UploadMultiarchImageWithOpts(
MultiarchImage{},
"http://127.0.0.1:0",
"repo",
"latest",
WithExtraTags("v1"),
)
So(err, ShouldEqual, ErrInvalidRefForExtraTags)
})
}
func TestInjectUploadImageWithBasicAuth(t *testing.T) {
Convey("Inject failures for unreachable lines", t, func() {
port := tcommon.GetFreePort()