diff --git a/libimage/push.go b/libimage/push.go index 7203838aa..88caf2f57 100644 --- a/libimage/push.go +++ b/libimage/push.go @@ -2,10 +2,12 @@ package libimage import ( "context" + "fmt" "time" dockerArchiveTransport "github.com/containers/image/v5/docker/archive" "github.com/containers/image/v5/docker/reference" + "github.com/containers/image/v5/transports" "github.com/containers/image/v5/transports/alltransports" "github.com/sirupsen/logrus" ) @@ -13,6 +15,10 @@ import ( // PushOptions allows for custommizing image pushes. type PushOptions struct { CopyOptions + // If true then all images and tags matching a given repository + // will be pushed. Only supported for the docker transport. + // Usage of this flag will cause Push() to return a nil []byte. + AllTags bool } // Push pushes the specified source which must refer to an image in the local @@ -24,33 +30,93 @@ type PushOptions struct { // // Return storage.ErrImageUnknown if source could not be found in the local // containers storage. +// Returns the bytes of the copied manifest when pushing a single tag, +// which may be used for digest computation. +// When pushing with AllTags=true then the returned []byte is always nil. func (r *Runtime) Push(ctx context.Context, source, destination string, options *PushOptions) ([]byte, error) { if options == nil { options = &PushOptions{} } - // Look up the local image. Note that we need to ignore the platform - // and push what the user specified (containers/podman/issues/10344). - image, resolvedSource, err := r.LookupImage(source, nil) + // Push the single image + if !options.AllTags { + + // Look up the local image. Note that we need to ignore the platform + // and push what the user specified (containers/podman/issues/10344). + image, resolvedSource, err := r.LookupImage(source, nil) + if err != nil { + return nil, err + } + + // Make sure we have a proper destination, and parse it into an image + // reference for copying. + if destination == "" { + // Doing an ID check here is tempting but false positives (due + // to a short partial IDs) are more painful than false + // negatives. + destination = resolvedSource + } + + return pushImage(ctx, image, destination, options, resolvedSource, r) + } + + // Below handles the AllTags option, for which we have to build a list of + // all the local images that match the provided repository and then push them. + // + // For now, make sure a destination was not specified and get it from the source. + // This could change in the future, but that gets close to the Copy() functionality. + if len(destination) != 0 { + return nil, fmt.Errorf("`destination` should not be specified if using AllTags") + } + + // Make sure the source repository does not have a tag + srcNamed, err := reference.ParseNormalizedNamed(source) if err != nil { return nil, err } + if !reference.IsNameOnly(srcNamed) { + return nil, fmt.Errorf("can't push with AllTags if source tag is specified") + } + + logrus.Debugf("Finding all images for source %s", srcNamed.Name()) + listOptions := &ListImagesOptions{} + srcImages, _ := r.ListImages(ctx, []string{srcNamed.Name()}, listOptions) + // Push each tag for every image in the list + for _, img := range srcImages { + namedTagged, err := img.NamedTaggedRepoTags() + if err != nil { + return nil, err + } + for _, n := range namedTagged { + // Filter on repo name again to avoid pushing an image that matches + // the source image ID but has a different repository than the source + currentNamed, err := reference.ParseNormalizedNamed(n.Name()) + if err != nil { + return nil, err + } + if reference.Path(currentNamed) == reference.Path(srcNamed) { + // Have to use Sprintf because pushImage expects a string + destWithTag := fmt.Sprintf("%s:%s", source, n.Tag()) + _, err := pushImage(ctx, img, destWithTag, options, "", r) + if err != nil { + return nil, err + } + } + } + } + + return nil, nil +} + +// pushImage sends a single image to be copied to the destination +func pushImage(ctx context.Context, image *Image, destination string, options *PushOptions, resolvedSource string, r *Runtime) ([]byte, error) { srcRef, err := image.StorageReference() if err != nil { return nil, err } - // Make sure we have a proper destination, and parse it into an image - // reference for copying. - if destination == "" { - // Doing an ID check here is tempting but false positives (due - // to a short partial IDs) are more painful than false - // negatives. - destination = resolvedSource - } - - logrus.Debugf("Pushing image %s to %s", source, destination) + logrus.Debugf("Pushing image %s to %s", transports.ImageName(srcRef), destination) destRef, err := alltransports.ParseImageName(destination) if err != nil { diff --git a/libimage/push_test.go b/libimage/push_test.go index 444f54d00..45d3872c5 100644 --- a/libimage/push_test.go +++ b/libimage/push_test.go @@ -67,6 +67,59 @@ func TestPush(t *testing.T) { } } +func TestPushAllTags(t *testing.T) { + runtime, cleanup := testNewRuntime(t) + defer cleanup() + ctx := context.Background() + + // Prefetch two different alpine images and make some tags + pullOptions := &PullOptions{} + pullOptions.Writer = os.Stdout + _, err := runtime.Pull(ctx, "docker.io/library/alpine:3.15", config.PullPolicyAlways, pullOptions) + require.NoError(t, err) + lookupOptions := &LookupImageOptions{} + img, _, err := runtime.LookupImage("docker.io/library/alpine:3.15", lookupOptions) + require.NoError(t, err) + img.Tag("docker.io/library/alpine") // imply latest + img.Tag("docker.io/library/alpine:3.15alpha") + _, err = runtime.Pull(ctx, "docker.io/library/alpine:3.14", config.PullPolicyAlways, pullOptions) + require.NoError(t, err) + + pushOptions := &PushOptions{} + pushOptions.AllTags = true // primary thing being tested here + pushOptions.Writer = os.Stdout + + workdir, err := ioutil.TempDir("", "libimagepush") + require.NoError(t, err) + defer os.RemoveAll(workdir) + + for _, test := range []struct { + source string + destination string + expectError bool + }{ + {"alpine", "docker.io/library/alpine", true}, // fail for destination + {"docker://docker.io/library/alpine", "", true}, // fail for transport + {"docker.io/library/alpine:latest", "", true}, // fail for tag + {"alpine:latest", "", true}, // fail for tag + // These two tests require authentication to a real registry to work + // {"myregistry/alpine", "", false}, + // {"example.com/myregistry/alpine", "", false}, + } { + _, err := runtime.Push(ctx, test.source, test.destination, pushOptions) + if test.expectError { + require.Error(t, err, "%v", test) + continue + } + require.NoError(t, err, "%v", test) + } + + // And now remove all of them. + rmReports, rmErrors := runtime.RemoveImages(ctx, nil, nil) + require.Len(t, rmErrors, 0) + require.Len(t, rmReports, 2) +} + func TestPushOtherPlatform(t *testing.T) { runtime, cleanup := testNewRuntime(t) defer cleanup()