Skip to content

Commit 9a3cfc5

Browse files
committed
(feat): Support push --all-tags to push all tags
Signed-off-by: Yash Kukrecha <[email protected]>
1 parent e86b70a commit 9a3cfc5

File tree

4 files changed

+222
-5
lines changed

4 files changed

+222
-5
lines changed

cmd/nerdctl/image/image_push.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ func PushCommand() *cobra.Command {
6969

7070
cmd.Flags().Bool(allowNonDistFlag, false, "Allow pushing images with non-distributable blobs")
7171

72+
// support Docker-compatible all-tags flag
73+
cmd.Flags().BoolP("all-tags", "a", false, "Push all local tags of the repository")
74+
7275
return cmd
7376
}
7477

@@ -101,6 +104,10 @@ func pushOptions(cmd *cobra.Command) (types.ImagePushOptions, error) {
101104
if err != nil {
102105
return types.ImagePushOptions{}, err
103106
}
107+
allTags, err := cmd.Flags().GetBool("all-tags")
108+
if err != nil {
109+
return types.ImagePushOptions{}, err
110+
}
104111
allowNonDist, err := cmd.Flags().GetBool(allowNonDistFlag)
105112
if err != nil {
106113
return types.ImagePushOptions{}, err
@@ -123,6 +130,7 @@ func pushOptions(cmd *cobra.Command) (types.ImagePushOptions, error) {
123130
IpfsEnsureImage: ipfsEnsureImage,
124131
IpfsAddress: ipfsAddress,
125132
Quiet: quiet,
133+
AllTags: allTags,
126134
AllowNondistributableArtifacts: allowNonDist,
127135
Stdout: cmd.OutOrStdout(),
128136
}, nil

cmd/nerdctl/image/image_push_linux_test.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package image
1818

1919
import (
20+
"encoding/json"
2021
"errors"
2122
"fmt"
2223
"net/http"
@@ -33,6 +34,11 @@ import (
3334
"github.com/containerd/nerdctl/v2/pkg/testutil/nerdtest/registry"
3435
)
3536

37+
type registryTagList struct {
38+
Name string `json:"name"`
39+
Tags []string `json:"tags"`
40+
}
41+
3642
func TestPush(t *testing.T) {
3743
nerdtest.Setup()
3844

@@ -145,6 +151,79 @@ func TestPush(t *testing.T) {
145151
},
146152
Expected: test.Expects(0, nil, nil),
147153
},
154+
{
155+
Description: "all-tags pushes all tags for a repository",
156+
Require: require.Not(nerdtest.Docker),
157+
Setup: func(data test.Data, helpers test.Helpers) {
158+
helpers.Ensure("pull", "--quiet", testutil.CommonImage)
159+
160+
repo := fmt.Sprintf("%s:%d/%s",
161+
registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier())
162+
data.Labels().Set("testImageRepo", repo)
163+
164+
tag1 := repo + ":v1"
165+
tag2 := repo + ":v2"
166+
data.Labels().Set("testImageRefV1", tag1)
167+
data.Labels().Set("testImageRefV2", tag2)
168+
169+
helpers.Ensure("tag", testutil.CommonImage, tag1)
170+
helpers.Ensure("tag", testutil.CommonImage, tag2)
171+
},
172+
Cleanup: func(data test.Data, helpers test.Helpers) {
173+
if v := data.Labels().Get("testImageRefV1"); v != "" {
174+
helpers.Anyhow("rmi", "-f", v)
175+
}
176+
if v := data.Labels().Get("testImageRefV2"); v != "" {
177+
helpers.Anyhow("rmi", "-f", v)
178+
}
179+
},
180+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
181+
return helpers.Command(
182+
"push",
183+
"--insecure-registry",
184+
"--all-tags",
185+
data.Labels().Get("testImageRepo"),
186+
)
187+
},
188+
Expected: func(data test.Data, helpers test.Helpers) *test.Expected {
189+
return &test.Expected{
190+
ExitCode: 0,
191+
Output: func(stdout string, t tig.T) {
192+
tagsURL := fmt.Sprintf("http://%s:%d/v2/%s/tags/list",
193+
registryNoAuthHTTPRandom.IP.String(),
194+
registryNoAuthHTTPRandom.Port,
195+
data.Identifier(),
196+
)
197+
resp, err := http.Get(tagsURL)
198+
assert.NilError(t, err, "error making HTTP request for tag list")
199+
defer func() {
200+
if resp.Body != nil {
201+
_ = resp.Body.Close()
202+
}
203+
}()
204+
205+
assert.Equal(t, resp.StatusCode, http.StatusOK, "expected tag list endpoint to be available")
206+
207+
var tl registryTagList
208+
err = json.NewDecoder(resp.Body).Decode(&tl)
209+
assert.NilError(t, err, "failed to decode tag list JSON")
210+
211+
foundV1 := false
212+
foundV2 := false
213+
for _, tag := range tl.Tags {
214+
if tag == "v1" {
215+
foundV1 = true
216+
}
217+
if tag == "v2" {
218+
foundV2 = true
219+
}
220+
}
221+
assert.Assert(t, foundV1, "expected tag v1 to be pushed")
222+
assert.Assert(t, foundV2, "expected tag v2 to be pushed")
223+
},
224+
}
225+
},
226+
},
148227
{
149228
Description: "with insecure, with login",
150229
Require: require.Not(nerdtest.Docker),
@@ -278,6 +357,81 @@ func TestPush(t *testing.T) {
278357
},
279358
Expected: test.Expects(0, nil, nil),
280359
},
360+
{
361+
Description: "soci with all-tags pushes multiple tags without duplicate index failure",
362+
Require: require.All(
363+
nerdtest.Soci,
364+
require.Not(nerdtest.Docker),
365+
),
366+
Setup: func(data test.Data, helpers test.Helpers) {
367+
helpers.Ensure("pull", "--quiet", testutil.UbuntuImage)
368+
369+
repo := fmt.Sprintf("%s:%d/%s",
370+
registryNoAuthHTTPRandom.IP.String(), registryNoAuthHTTPRandom.Port, data.Identifier())
371+
data.Labels().Set("testImageRepo", repo)
372+
373+
tag1 := repo + ":image_tag"
374+
tag2 := repo + ":latest"
375+
data.Labels().Set("testImageRef1", tag1)
376+
data.Labels().Set("testImageRef2", tag2)
377+
378+
helpers.Ensure("tag", testutil.UbuntuImage, tag1)
379+
helpers.Ensure("tag", testutil.UbuntuImage, tag2)
380+
},
381+
Cleanup: func(data test.Data, helpers test.Helpers) {
382+
if v := data.Labels().Get("testImageRef1"); v != "" {
383+
helpers.Anyhow("rmi", "-f", v)
384+
}
385+
if v := data.Labels().Get("testImageRef2"); v != "" {
386+
helpers.Anyhow("rmi", "-f", v)
387+
}
388+
},
389+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
390+
return helpers.Command(
391+
"push",
392+
"--snapshotter=soci",
393+
"--insecure-registry",
394+
"--all-tags",
395+
"--soci-span-size=2097152",
396+
"--soci-min-layer-size=0",
397+
data.Labels().Get("testImageRepo"),
398+
)
399+
},
400+
Expected: test.Expects(0, nil, nil),
401+
},
402+
{
403+
Description: "all-tags with explicit tag returns error",
404+
Require: require.Not(nerdtest.Docker),
405+
Setup: func(data test.Data, helpers test.Helpers) {
406+
helpers.Ensure("pull", "--quiet", testutil.CommonImage)
407+
testImageRef := fmt.Sprintf("%s:%d/%s:v1",
408+
registryNoAuthHTTPRandom.IP.String(),
409+
registryNoAuthHTTPRandom.Port,
410+
data.Identifier(),
411+
)
412+
data.Labels().Set("testImageRef", testImageRef)
413+
414+
helpers.Ensure("tag", testutil.CommonImage, testImageRef)
415+
},
416+
Cleanup: func(data test.Data, helpers test.Helpers) {
417+
if ref := data.Labels().Get("testImageRef"); ref != "" {
418+
helpers.Anyhow("rmi", "-f", ref)
419+
}
420+
},
421+
Command: func(data test.Data, helpers test.Helpers) test.TestableCommand {
422+
return helpers.Command(
423+
"push",
424+
"--insecure-registry",
425+
"--all-tags",
426+
data.Labels().Get("testImageRef"),
427+
)
428+
},
429+
Expected: test.Expects(
430+
1,
431+
[]error{errors.New("tag can't be used with --all-tags/-a")},
432+
nil,
433+
),
434+
},
281435
},
282436
}
283437
testCase.Run(t)

pkg/api/types/image_types.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,11 @@ type ImageInspectOptions struct {
183183

184184
// ImagePushOptions specifies options for `nerdctl (image) push`.
185185
type ImagePushOptions struct {
186-
Stdout io.Writer
186+
Stdout io.Writer
187+
Stderr io.Writer
188+
// ProgressOutputToStdout directs progress output to stdout instead of stderr
189+
ProgressOutputToStdout bool
190+
187191
GOptions GlobalCommandOptions
188192
SignOptions ImageSignOptions
189193
SociOptions SociOptions
@@ -202,6 +206,12 @@ type ImagePushOptions struct {
202206
Quiet bool
203207
// AllowNondistributableArtifacts allow pushing non-distributable artifacts
204208
AllowNondistributableArtifacts bool
209+
210+
// AllTags if true, push all local tags for the repository when no tag is specified
211+
AllTags bool
212+
213+
// SkipSoci when true, skip creating/pushing SOCI index (used when pushing multiple tags to avoid overwriting)
214+
SkipSoci bool
205215
}
206216

207217
// RemoteSnapshotterFlags are used for pulling with remote snapshotters

pkg/cmd/image/push.go

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"net/http"
2525
"os"
2626
"path/filepath"
27+
"strings"
2728

2829
"github.com/opencontainers/go-digest"
2930
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
@@ -61,7 +62,18 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options
6162
return err
6263
}
6364

65+
// Disallow tag (or digest) together with --all-tags
66+
if options.AllTags {
67+
if parsedReference.Tag != "" || parsedReference.Digest != "" {
68+
return fmt.Errorf("tag can't be used with --all-tags/-a")
69+
}
70+
}
71+
6472
if parsedReference.Protocol != "" {
73+
if options.AllTags {
74+
return fmt.Errorf("--all-tags is not supported for %q references", parsedReference.Protocol)
75+
}
76+
6577
if parsedReference.Protocol != referenceutil.IPFSProtocol {
6678
return fmt.Errorf("ipfs scheme is only supported but got %q", parsedReference.Protocol)
6779
}
@@ -105,10 +117,43 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options
105117
return nil
106118
}
107119

108-
parsedReference, err = referenceutil.Parse(rawRef)
109-
if err != nil {
110-
return err
120+
// Handle --all-tags
121+
if options.AllTags {
122+
repo := ""
123+
if parsedReference.Domain != "" {
124+
repo = parsedReference.Domain + "/"
125+
}
126+
repo += parsedReference.Path
127+
128+
imgList, err := client.ImageService().List(ctx)
129+
if err != nil {
130+
return err
131+
}
132+
133+
var tagRefs []string
134+
for _, img := range imgList {
135+
if strings.HasPrefix(img.Name, repo+":") {
136+
tagRefs = append(tagRefs, img.Name)
137+
}
138+
}
139+
140+
if len(tagRefs) == 0 {
141+
return fmt.Errorf("no local tags found for repository %q", repo)
142+
}
143+
144+
for i, tagRef := range tagRefs {
145+
tagOpts := options
146+
tagOpts.AllTags = false // avoid infinite recursion
147+
tagOpts.SkipSoci = i > 0 // avoid SOCI indexing for the same image
148+
149+
if err := Push(ctx, client, tagRef, tagOpts); err != nil {
150+
return err
151+
}
152+
}
153+
154+
return nil
111155
}
156+
112157
ref := parsedReference.String()
113158
refDomain := parsedReference.Domain
114159

@@ -209,7 +254,7 @@ func Push(ctx context.Context, client *containerd.Client, rawRef string, options
209254
options.SignOptions); err != nil {
210255
return err
211256
}
212-
if options.GOptions.Snapshotter == "soci" {
257+
if options.GOptions.Snapshotter == "soci" && !options.SkipSoci {
213258
if err = snapshotterutil.CreateSociIndexV1(ref, options.GOptions, options.AllPlatforms, options.Platforms, options.SociOptions); err != nil {
214259
return err
215260
}

0 commit comments

Comments
 (0)