diff --git a/cmd/nerdctl/image/image_push.go b/cmd/nerdctl/image/image_push.go index 47104a4b7e5..7b9bbb0b5d8 100644 --- a/cmd/nerdctl/image/image_push.go +++ b/cmd/nerdctl/image/image_push.go @@ -69,6 +69,9 @@ func PushCommand() *cobra.Command { cmd.Flags().Bool(allowNonDistFlag, false, "Allow pushing images with non-distributable blobs") + // support Docker-compatible all-tags flag + cmd.Flags().BoolP("all-tags", "a", false, "Push all local tags of the repository") + return cmd } @@ -101,6 +104,10 @@ func pushOptions(cmd *cobra.Command) (types.ImagePushOptions, error) { if err != nil { return types.ImagePushOptions{}, err } + allTags, err := cmd.Flags().GetBool("all-tags") + if err != nil { + return types.ImagePushOptions{}, err + } allowNonDist, err := cmd.Flags().GetBool(allowNonDistFlag) if err != nil { return types.ImagePushOptions{}, err @@ -123,6 +130,7 @@ func pushOptions(cmd *cobra.Command) (types.ImagePushOptions, error) { IpfsEnsureImage: ipfsEnsureImage, IpfsAddress: ipfsAddress, Quiet: quiet, + AllTags: allTags, AllowNondistributableArtifacts: allowNonDist, Stdout: cmd.OutOrStdout(), }, nil diff --git a/cmd/nerdctl/image/image_push_linux_test.go b/cmd/nerdctl/image/image_push_linux_test.go index c547341d012..f4a9898de7f 100644 --- a/cmd/nerdctl/image/image_push_linux_test.go +++ b/cmd/nerdctl/image/image_push_linux_test.go @@ -17,6 +17,7 @@ package image import ( + "encoding/json" "errors" "fmt" "net/http" @@ -33,6 +34,11 @@ import ( "github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry" ) +type registryTagList struct { + Name string `json:"name"` + Tags []string `json:"tags"` +} + func TestPush(t *testing.T) { nerdtest.Setup() @@ -145,6 +151,79 @@ func TestPush(t *testing.T) { }, Expected: test.Expects(0, nil, nil), }, + { + Description: "all-tags pushes all tags for a repository", + Require: require.Not(nerdtest.Docker), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.CommonImage) + + repo := fmt.Sprintf("%s:%d/%s", + registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier()) + data.Labels().Set("testImageRepo", repo) + + tag1 := repo + ":v1" + tag2 := repo + ":v2" + data.Labels().Set("testImageRefV1", tag1) + data.Labels().Set("testImageRefV2", tag2) + + helpers.Ensure("tag", testutil.CommonImage, tag1) + helpers.Ensure("tag", testutil.CommonImage, tag2) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if v := data.Labels().Get("testImageRefV1"); v != "" { + helpers.Anyhow("rmi", "-f", v) + } + if v := data.Labels().Get("testImageRefV2"); v != "" { + helpers.Anyhow("rmi", "-f", v) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command( + "push", + "--insecure-registry", + "--all-tags", + data.Labels().Get("testImageRepo"), + ) + }, + Expected: func(data test.Data, helpers test.Helpers) *test.Expected { + return &test.Expected{ + ExitCode: 0, + Output: func(stdout string, t tig.T) { + tagsURL := fmt.Sprintf("http://%s:%d/v2/%s/tags/list", + registryNoAuthHTTPRandom.IP.String(), + registryNoAuthHTTPRandom.Port, + data.Identifier(), + ) + resp, err := http.Get(tagsURL) + assert.NilError(t, err, "error making HTTP request for tag list") + defer func() { + if resp.Body != nil { + _ = resp.Body.Close() + } + }() + + assert.Equal(t, resp.StatusCode, http.StatusOK, "expected tag list endpoint to be available") + + var tl registryTagList + err = json.NewDecoder(resp.Body).Decode(&tl) + assert.NilError(t, err, "failed to decode tag list JSON") + + foundV1 := false + foundV2 := false + for _, tag := range tl.Tags { + if tag == "v1" { + foundV1 = true + } + if tag == "v2" { + foundV2 = true + } + } + assert.Assert(t, foundV1, "expected tag v1 to be pushed") + assert.Assert(t, foundV2, "expected tag v2 to be pushed") + }, + } + }, + }, { Description: "with insecure, with login", Require: require.Not(nerdtest.Docker), @@ -278,6 +357,48 @@ func TestPush(t *testing.T) { }, Expected: test.Expects(0, nil, nil), }, + { + Description: "soci with all-tags pushes multiple tags without duplicate index failure", + Require: require.All( + nerdtest.Soci, + require.Not(nerdtest.Docker), + ), + Setup: func(data test.Data, helpers test.Helpers) { + helpers.Ensure("pull", "--quiet", testutil.UbuntuImage) + + repo := fmt.Sprintf("%s:%d/%s", + registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier()) + data.Labels().Set("testImageRepo", repo) + + tag1 := repo + ":image_tag" + tag2 := repo + ":latest" + data.Labels().Set("testImageRef1", tag1) + data.Labels().Set("testImageRef2", tag2) + + helpers.Ensure("tag", testutil.UbuntuImage, tag1) + helpers.Ensure("tag", testutil.UbuntuImage, tag2) + }, + Cleanup: func(data test.Data, helpers test.Helpers) { + if v := data.Labels().Get("testImageRef1"); v != "" { + helpers.Anyhow("rmi", "-f", v) + } + if v := data.Labels().Get("testImageRef2"); v != "" { + helpers.Anyhow("rmi", "-f", v) + } + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command( + "push", + "--snapshotter=soci", + "--insecure-registry", + "--all-tags", + "--soci-span-size=2097152", + "--soci-min-layer-size=0", + data.Labels().Get("testImageRepo"), + ) + }, + Expected: test.Expects(0, nil, nil), + }, }, } testCase.Run(t) diff --git a/pkg/api/types/image_types.go b/pkg/api/types/image_types.go index 8999d659213..7a60281adf6 100644 --- a/pkg/api/types/image_types.go +++ b/pkg/api/types/image_types.go @@ -183,7 +183,11 @@ type ImageInspectOptions struct { // ImagePushOptions specifies options for `nerdctl (image) push`. type ImagePushOptions struct { - Stdout io.Writer + Stdout io.Writer + Stderr io.Writer + // ProgressOutputToStdout directs progress output to stdout instead of stderr + ProgressOutputToStdout bool + GOptions GlobalCommandOptions SignOptions ImageSignOptions SociOptions SociOptions @@ -202,6 +206,12 @@ type ImagePushOptions struct { Quiet bool // AllowNondistributableArtifacts allow pushing non-distributable artifacts AllowNondistributableArtifacts bool + + // AllTags if true, push all local tags for the repository when no tag is specified + AllTags bool + + // SkipSoci when true, skip creating/pushing SOCI index (used when pushing multiple tags to avoid overwriting) + SkipSoci bool } // RemoteSnapshotterFlags are used for pulling with remote snapshotters diff --git a/pkg/cmd/image/push.go b/pkg/cmd/image/push.go index 8731b0cfc94..c2a4fe9fb52 100644 --- a/pkg/cmd/image/push.go +++ b/pkg/cmd/image/push.go @@ -24,6 +24,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "github.com/opencontainers/go-digest" ocispec "github.com/opencontainers/image-spec/specs-go/v1" @@ -62,6 +63,10 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options } if parsedReference.Protocol != "" { + if options.AllTags { + return fmt.Errorf("--all-tags is not supported for %q references", parsedReference.Protocol) + } + if parsedReference.Protocol != referenceutil.IPFSProtocol { return fmt.Errorf("ipfs scheme is only supported but got %q", parsedReference.Protocol) } @@ -105,10 +110,43 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options return nil } - parsedReference, err = referenceutil.Parse(rawRef) - if err != nil { - return err + // Handle --all-tags + if options.AllTags { + repo := "" + if parsedReference.Domain != "" { + repo = parsedReference.Domain + "/" + } + repo += parsedReference.Path + + imgList, err := client.ImageService().List(ctx) + if err != nil { + return err + } + + var tagRefs []string + for _, img := range imgList { + if strings.HasPrefix(img.Name, repo+":") { + tagRefs = append(tagRefs, img.Name) + } + } + + if len(tagRefs) == 0 { + return fmt.Errorf("no local tags found for repository %q", repo) + } + + for i, tagRef := range tagRefs { + tagOpts := options + tagOpts.AllTags = false // avoid infinite recursion + tagOpts.SkipSoci = i > 0 // avoid SOCI indexing for the same image + + if err := Push(ctx, client, tagRef, tagOpts); err != nil { + return err + } + } + + return nil } + ref := parsedReference.String() refDomain := parsedReference.Domain @@ -209,7 +247,7 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options options.SignOptions); err != nil { return err } - if options.GOptions.Snapshotter == "soci" { + if options.GOptions.Snapshotter == "soci" && !options.SkipSoci { if err = snapshotterutil.CreateSociIndexV1(ref, options.GOptions, options.AllPlatforms, options.Platforms, options.SociOptions); err != nil { return err }