Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cmd/nerdctl/image/image_push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
121 changes: 121 additions & 0 deletions cmd/nerdctl/image/image_push_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package image

import (
"encoding/json"
"errors"
"fmt"
"net/http"
Expand All @@ -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()

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 11 additions & 1 deletion pkg/api/types/image_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
46 changes: 42 additions & 4 deletions pkg/cmd/image/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
Expand Down
Loading