diff --git a/pkg/imgpkg/bundle/contents.go b/pkg/imgpkg/bundle/contents.go index 90b9a3114..66a59a3a0 100644 --- a/pkg/imgpkg/bundle/contents.go +++ b/pkg/imgpkg/bundle/contents.go @@ -42,8 +42,8 @@ func NewContents(paths []string, excludedPaths []string, preservePermissions boo return Contents{paths: paths, excludedPaths: excludedPaths, preservePermissions: preservePermissions} } -// Push the contents of the bundle to the registry as an OCI Image -func (b Contents) Push(uploadRef regname.Tag, labels map[string]string, registry ImagesMetadataWriter, logger Logger) (string, error) { +// Push the contents of the bundle to the registry as an OCI Image with one or more tags +func (b Contents) Push(uploadRefs []regname.Tag, labels map[string]string, registry ImagesMetadataWriter, logger Logger) (string, error) { err := b.validate() if err != nil { return "", err @@ -54,7 +54,7 @@ func (b Contents) Push(uploadRef regname.Tag, labels map[string]string, registry } labels[BundleConfigLabel] = "true" - return plainimage.NewContents(b.paths, b.excludedPaths, b.preservePermissions).Push(uploadRef, labels, registry, logger) + return plainimage.NewContents(b.paths, b.excludedPaths, b.preservePermissions).Push(uploadRefs, labels, registry, logger) } // PresentsAsBundle checks if the provided folders have the needed structure to be a bundle diff --git a/pkg/imgpkg/bundle/contents_test.go b/pkg/imgpkg/bundle/contents_test.go index 8e31d2ab0..8edb05cc9 100644 --- a/pkg/imgpkg/bundle/contents_test.go +++ b/pkg/imgpkg/bundle/contents_test.go @@ -44,7 +44,7 @@ images: t.Fatalf("failed to read tag: %s", err) } - _, err = subject.Push(imgTag, map[string]string{}, fakeRegistry, util.NewNoopLevelLogger()) + _, err = subject.Push([]name.Tag{imgTag}, map[string]string{}, fakeRegistry, util.NewNoopLevelLogger()) if err != nil { t.Fatalf("not expecting push to fail: %s", err) } @@ -78,7 +78,7 @@ images: t.Fatalf("failed to read tag: %s", err) } - _, err = subject.Push(imgTag, map[string]string{}, fakeRegistry, util.NewNoopLevelLogger()) + _, err = subject.Push([]name.Tag{imgTag}, map[string]string{}, fakeRegistry, util.NewNoopLevelLogger()) if err != nil { t.Fatalf("not expecting push to fail: %s", err) } diff --git a/pkg/imgpkg/bundle/locations_configs.go b/pkg/imgpkg/bundle/locations_configs.go index 19da35157..a5338036f 100644 --- a/pkg/imgpkg/bundle/locations_configs.go +++ b/pkg/imgpkg/bundle/locations_configs.go @@ -133,7 +133,7 @@ func (r LocationsConfigs) Save(reg ImagesMetadataWriter, bundleRef name.Digest, r.ui.Tracef("Pushing image\n") - _, err = plainimage.NewContents([]string{tmpDir}, nil, false).Push(locRef, nil, reg.CloneWithLogger(util.NewNoopProgressBar()), logger) + _, err = plainimage.NewContents([]string{tmpDir}, nil, false).Push([]name.Tag{locRef}, nil, reg.CloneWithLogger(util.NewNoopProgressBar()), logger) if err != nil { // Immutable tag errors within registries are not standardized. // Assume word "immutable" would be present in most cases. diff --git a/pkg/imgpkg/cmd/push.go b/pkg/imgpkg/cmd/push.go index 7ad0c0d38..a931ef6dc 100644 --- a/pkg/imgpkg/cmd/push.go +++ b/pkg/imgpkg/cmd/push.go @@ -5,6 +5,7 @@ package cmd import ( "fmt" + "strings" "github.com/cppforlife/go-cli-ui/ui" regname "github.com/google/go-containerregistry/pkg/name" @@ -25,6 +26,7 @@ type PushOptions struct { FileFlags FileFlags RegistryFlags RegistryFlags LabelFlags LabelFlags + TagFlags TagFlags } func NewPushOptions(ui ui.UI) *PushOptions { @@ -49,6 +51,7 @@ func NewPushCmd(o *PushOptions) *cobra.Command { o.FileFlags.Set(cmd) o.RegistryFlags.Set(cmd) o.LabelFlags.Set(cmd) + o.TagFlags.Set(cmd) return cmd } @@ -92,19 +95,41 @@ func (po *PushOptions) Run() error { panic("Unreachable code") } - po.ui.BeginLinef("Pushed '%s'", imageURL) + po.ui.BeginLinef("\nPushed: \n%s\n", imageURL) return nil } func (po *PushOptions) pushBundle(registry registry.Registry) (string, error) { - uploadRef, err := regname.NewTag(po.BundleFlags.Bundle, regname.WeakValidation) + imageURL := "" + imageRefs := []string{} + + baseImageName, err := po.stripTag() + if err != nil { + return "", err + } + + baseRef, err := regname.NewTag(po.BundleFlags.Bundle, regname.WeakValidation) if err != nil { return "", fmt.Errorf("Parsing '%s': %s", po.BundleFlags.Bundle, err) } + // Append the base image_tag to the list of refs to upload + uploadRefs := []regname.Tag{baseRef} + + // Loop through all tags specified by the user and push the related image+tag + for _, tag := range po.TagFlags.Tags { + uploadRef, err := regname.NewTag(baseImageName+":"+tag, regname.WeakValidation) + if err != nil { + return "", fmt.Errorf("Parsing '%s': %s", tag, err) + } + + uploadRefs = append(uploadRefs, uploadRef) + } + logger := util.NewUILevelLogger(util.LogWarn, util.NewLogger(po.ui)) - imageURL, err := bundle.NewContents(po.FileFlags.Files, po.FileFlags.ExcludedFilePaths, po.FileFlags.PreservePermissions).Push(uploadRef, po.LabelFlags.Labels, registry, logger) + + imageURL, err = bundle.NewContents(po.FileFlags.Files, po.FileFlags.ExcludedFilePaths, po.FileFlags.PreservePermissions).Push(uploadRefs, po.LabelFlags.Labels, registry, logger) if err != nil { return "", err } @@ -116,8 +141,9 @@ func (po *PushOptions) pushBundle(registry registry.Registry) (string, error) { Kind: lockconfig.BundleLockKind, }, Bundle: lockconfig.BundleRef{ - Image: imageURL, - Tag: uploadRef.TagStr(), + Image: imageURL, + Tag: uploadRefs[0].TagStr(), + OtherTags: strings.Join(po.TagFlags.Tags, ","), }, } @@ -127,19 +153,23 @@ func (po *PushOptions) pushBundle(registry registry.Registry) (string, error) { } } - return imageURL, nil + if !strings.Contains(strings.Join(imageRefs, ","), imageURL) { + imageRefs = append(imageRefs, imageURL) + } + + po.ui.BeginLinef("\nTags: %s, %s\n", baseRef.TagStr(), strings.Join(po.TagFlags.Tags, ", ")) + + return strings.Join(imageRefs, "\n"), nil } func (po *PushOptions) pushImage(registry registry.Registry) (string, error) { + imageURL := "" + imageRefs := []string{} + if po.LockOutputFlags.LockFilePath != "" { return "", fmt.Errorf("Lock output is not compatible with image, use bundle for lock output") } - uploadRef, err := regname.NewTag(po.ImageFlags.Image, regname.WeakValidation) - if err != nil { - return "", fmt.Errorf("Parsing '%s': %s", po.ImageFlags.Image, err) - } - isBundle, err := bundle.NewContents(po.FileFlags.Files, po.FileFlags.ExcludedFilePaths, po.FileFlags.PreservePermissions).PresentsAsBundle() if err != nil { return "", err @@ -148,8 +178,43 @@ func (po *PushOptions) pushImage(registry registry.Registry) (string, error) { return "", fmt.Errorf("Images cannot be pushed with '.imgpkg' directories, consider using --bundle (-b) option") } + baseImageName, err := po.stripTag() + if err != nil { + return "", err + } + + baseRef, err := regname.NewTag(po.ImageFlags.Image, regname.WeakValidation) + if err != nil { + return "", fmt.Errorf("Parsing '%s': %s", po.BundleFlags.Bundle, err) + } + + // Append the base image_tag to the list of refs to upload + uploadRefs := []regname.Tag{baseRef} + + // Loop through all tags specified by the user and push the related image+tag + for _, tag := range po.TagFlags.Tags { + uploadRef, err := regname.NewTag(baseImageName+":"+tag, regname.WeakValidation) + if err != nil { + return "", fmt.Errorf("Parsing '%s': %s", tag, err) + } + + uploadRefs = append(uploadRefs, uploadRef) + } + logger := util.NewUILevelLogger(util.LogWarn, util.NewLogger(po.ui)) - return plainimage.NewContents(po.FileFlags.Files, po.FileFlags.ExcludedFilePaths, po.FileFlags.PreservePermissions).Push(uploadRef, po.LabelFlags.Labels, registry, logger) + + imageURL, err = plainimage.NewContents(po.FileFlags.Files, po.FileFlags.ExcludedFilePaths, po.FileFlags.PreservePermissions).Push(uploadRefs, po.LabelFlags.Labels, registry, logger) + if err != nil { + return "", err + } + + if !strings.Contains(strings.Join(imageRefs, ","), imageURL) { + imageRefs = append(imageRefs, imageURL) + } + + po.ui.BeginLinef("\nTags: %s, %s\n", baseRef.TagStr(), strings.Join(po.TagFlags.Tags, ", ")) + + return strings.Join(imageRefs, "\n"), nil } // validateFlags checks if the provided flags are valid @@ -165,3 +230,34 @@ func (po *PushOptions) validateFlags() error { return nil } + +// stripTag removes the tag from the provided image or bundle reference +func (po *PushOptions) stripTag() (string, error) { + object := "" + isBundle := po.BundleFlags.Bundle != "" + isImage := po.ImageFlags.Image != "" + + switch { + case isBundle: + object = po.BundleFlags.Bundle + + case isImage: + object = po.ImageFlags.Image + + default: + panic("Unreachable code") + } + + objectRef, err := regname.NewTag(object, regname.WeakValidation) + if err != nil { + return "", fmt.Errorf("Parsing '%s': %s", object, err) + } + + baseObjectName := strings.TrimSuffix(objectRef.Name(), ":"+objectRef.TagStr()) + + if baseObjectName == "" { + return "", fmt.Errorf("'%s' is not a valid image reference", object) + } + + return baseObjectName, nil +} diff --git a/pkg/imgpkg/cmd/push_test.go b/pkg/imgpkg/cmd/push_test.go index df743b8c6..fa51011c7 100644 --- a/pkg/imgpkg/cmd/push_test.go +++ b/pkg/imgpkg/cmd/push_test.go @@ -326,6 +326,118 @@ func TestLabels(t *testing.T) { } } +func TestTags(t *testing.T) { + testCases := []struct { + name string + opType string + expectedError string + expectedTags []string + tagInput string + inlineTag string + }{ + { + name: "bundle with one inline tag", + opType: "bundle", + expectedError: "", + tagInput: "", + expectedTags: []string{"v1.0.1"}, + inlineTag: "v1.0.1", + }, + { + name: "bundle with one tag via flag", + opType: "bundle", + expectedError: "", + tagInput: "v1.0.1", + expectedTags: []string{"v1.0.1", "latest"}, + inlineTag: "", + }, + { + name: "bundle with inline tag and tag via flag", + opType: "bundle", + expectedError: "", + tagInput: "v1.2.0-alpha,latest", + expectedTags: []string{"v1.0.1", "v1.2.0-alpha", "latest"}, + inlineTag: "v1.0.1", + }, + { + name: "bundle with multiple tags via flag", + opType: "bundle", + expectedError: "", + tagInput: "v1.0.1,v1.0.2", + expectedTags: []string{"v1.0.1", "v1.0.2", "latest"}, + inlineTag: "", + }, + { + name: "image with one inline tag", + opType: "image", + expectedError: "", + tagInput: "", + expectedTags: []string{"v1.0.1"}, + inlineTag: "v1.0.1", + }, + { + name: "image with one tag via flag", + opType: "image", + expectedError: "", + tagInput: "v1.0.1", + expectedTags: []string{"v1.0.1", "latest"}, + inlineTag: "", + }, + { + name: "image with inline tag and tags via flag", + opType: "image", + expectedError: "", + tagInput: "latest,stable", + expectedTags: []string{"v1.0.1", "latest"}, + inlineTag: "v1.0.1", + }, + } + + for _, tc := range testCases { + f := func(t *testing.T) { + env := helpers.BuildEnv(t) + targetImage := env.Image + imgpkg := helpers.Imgpkg{T: t, ImgpkgPath: env.ImgpkgPath} + defer env.Cleanup() + + opTypeFlag := "-b" + pushDir := env.BundleFactory.CreateBundleDir(helpers.BundleYAML, helpers.ImagesYAML) + + if tc.opType == "image" { + opTypeFlag = "-i" + pushDir = env.Assets.CreateAndCopySimpleApp("image-to-push") + } + + if tc.inlineTag != "" { + targetImage = env.Image + ":" + tc.inlineTag + } + + if tc.tagInput == "" { + imgpkg.Run([]string{"push", opTypeFlag, targetImage, "-f", pushDir}) + } else { + imgpkg.Run([]string{"push", opTypeFlag, targetImage, "--additional-tags", tc.tagInput, "-f", pushDir}) + } + + // Loop through expected tags and validate they exist on the image + for _, tag := range tc.expectedTags { + ref, _ := name.NewTag(env.Image+":"+tag, name.WeakValidation) + image, err := remote.Image(ref, remote.WithAuthFromKeychain(authn.DefaultKeychain)) + require.NoError(t, err) + + tagList := imgpkg.Run([]string{"tag", "ls", "-i", env.Image + ":" + tag}) + + _, err = image.ConfigFile() + require.NoError(t, err) + + require.Contains(t, tagList, tag, "Expected tags provided via flags to match tags discovered for image") + + } + } + + t.Run(tc.name, f) + } +} + func Cleanup(dirs ...string) { for _, dir := range dirs { os.RemoveAll(dir) diff --git a/pkg/imgpkg/cmd/tag_flags.go b/pkg/imgpkg/cmd/tag_flags.go new file mode 100644 index 000000000..9c33a108f --- /dev/null +++ b/pkg/imgpkg/cmd/tag_flags.go @@ -0,0 +1,18 @@ +// Copyright 2020 VMware, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "github.com/spf13/cobra" +) + +// TagFlags is a struct that holds the additional tags for an OCI artifact +type TagFlags struct { + Tags []string +} + +// Set sets additional tags for an OCI artifact +func (t *TagFlags) Set(cmd *cobra.Command) { + cmd.Flags().StringSliceVar(&t.Tags, "additional-tags", []string{}, "Set additional tags on image") +} diff --git a/pkg/imgpkg/lockconfig/bundle_lock.go b/pkg/imgpkg/lockconfig/bundle_lock.go index 39b39317d..081c3cf97 100644 --- a/pkg/imgpkg/lockconfig/bundle_lock.go +++ b/pkg/imgpkg/lockconfig/bundle_lock.go @@ -22,8 +22,9 @@ type BundleLock struct { } type BundleRef struct { - Image string `json:"image,omitempty"` // This generated yaml, but due to lib we need to use `json` - Tag string `json:"tag,omitempty"` // This generated yaml, but due to lib we need to use `json` + Image string `json:"image,omitempty"` // This generated yaml, but due to lib we need to use `json` + Tag string `json:"tag,omitempty"` // This generated yaml, but due to lib we need to use `json` + OtherTags string `json:"otherTags,omitempty"` } func NewBundleLockFromPath(path string) (BundleLock, error) { diff --git a/pkg/imgpkg/plainimage/contents.go b/pkg/imgpkg/plainimage/contents.go index fa767f22c..6b1612a57 100644 --- a/pkg/imgpkg/plainimage/contents.go +++ b/pkg/imgpkg/plainimage/contents.go @@ -34,44 +34,54 @@ func NewContents(paths []string, excludedPaths []string, preservePermissions boo return Contents{paths: paths, excludedPaths: excludedPaths, preservePermissions: preservePermissions} } -// Push the OCI Image to the registry -func (i Contents) Push(uploadRef regname.Tag, labels map[string]string, writer ImagesWriter, logger Logger) (string, error) { +// Push pushes the OCI Image to the registry with multiple one or more tags +func (i Contents) Push(uploadRefs []regname.Tag, labels map[string]string, writer ImagesWriter, logger Logger) (string, error) { + + var digest regv1.Hash + var primaryUploadRef regname.Tag + err := i.validate() if err != nil { return "", err } tarImg := ctlimg.NewTarImage(i.paths, i.excludedPaths, logger, i.preservePermissions) + primaryUploadRef = uploadRefs[0] - img, err := tarImg.AsFileImage(labels) - if err != nil { - return "", err - } + for _, uploadRef := range uploadRefs { - defer img.Remove() + img, err := tarImg.AsFileImage(labels) + if err != nil { + return "", err + } - err = writer.WriteImage(uploadRef, img, nil) + defer img.Remove() - if err != nil { - return "", fmt.Errorf("Writing '%s': %s", uploadRef.Name(), err) - } + err = writer.WriteImage(uploadRef, img, nil) - digest, err := img.Digest() - if err != nil { - return "", err - } + if err != nil { + return "", fmt.Errorf("Writing '%s': %s", uploadRef.Name(), err) + } - uploadTagRef, err := util.BuildDefaultUploadTagRef(img, uploadRef.Repository) - if err != nil { - return "", fmt.Errorf("Building default upload tag image ref: %s", err) - } + digest, err = img.Digest() + if err != nil { + return "", err + } + + uploadTagRef, err := util.BuildDefaultUploadTagRef(img, uploadRef.Repository) + if err != nil { + return "", fmt.Errorf("Building default upload tag image ref: %s", err) + } + + err = writer.WriteTag(uploadTagRef, img) + if err != nil { + return "", fmt.Errorf("Writing Tag '%s': %s", uploadRef.Name(), err) + } - err = writer.WriteTag(uploadTagRef, img) - if err != nil { - return "", fmt.Errorf("Writing Tag '%s': %s", uploadRef.Name(), err) } - return fmt.Sprintf("%s@%s", uploadRef.Context(), digest), nil + return fmt.Sprintf("%s@%s", primaryUploadRef.Context(), digest), nil + } func (i Contents) validate() error {