diff --git a/pkg/api/controller_test.go b/pkg/api/controller_test.go index bf24e892..c105fd65 100644 --- a/pkg/api/controller_test.go +++ b/pkg/api/controller_test.go @@ -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, diff --git a/pkg/test/image-utils/upload.go b/pkg/test/image-utils/upload.go index b5f4bf8d..2a09ce57 100644 --- a/pkg/test/image-utils/upload.go +++ b/pkg/test/image-utils/upload.go @@ -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) diff --git a/pkg/test/image-utils/upload_test.go b/pkg/test/image-utils/upload_test.go index c388cc56..40ef9409 100644 --- a/pkg/test/image-utils/upload_test.go +++ b/pkg/test/image-utils/upload_test.go @@ -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()