diff --git a/cmd/skopeo/list_tags.go b/cmd/skopeo/list_tags.go new file mode 100644 index 00000000..40499a5a --- /dev/null +++ b/cmd/skopeo/list_tags.go @@ -0,0 +1,142 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "github.com/containers/image/v5/docker" + "github.com/containers/image/v5/transports" + "github.com/containers/image/v5/transports/alltransports" + "github.com/containers/image/v5/types" + "github.com/docker/distribution/reference" + "github.com/pkg/errors" + "github.com/urfave/cli" + "strings" + + "io" +) + +// tagListOutput is the output format of (skopeo list-tags), primarily so that we can format it with a simple json.MarshalIndent. +type tagListOutput struct { + Repository string + Tags []string +} + +type tagsOptions struct { + global *globalOptions + image *imageOptions +} + +func tagsCmd(global *globalOptions) cli.Command { + sharedFlags, sharedOpts := sharedImageFlags() + imageFlags, imageOpts := dockerImageFlags(global, sharedOpts, "", "") + + opts := tagsOptions{ + global: global, + image: imageOpts, + } + + return cli.Command{ + Name: "list-tags", + Usage: "List tags in the transport/repository specified by the REPOSITORY-NAME", + Description: ` + Return the list of tags from the transport/repository "REPOSITORY-NAME" + + Supported transports: + docker + + See skopeo-list-tags(1) section "REPOSITORY NAMES" for the expected format + `, + ArgsUsage: "REPOSITORY-NAME", + Flags: append(sharedFlags, imageFlags...), + Action: commandAction(opts.run), + } +} + +// Customized version of the alltransports.ParseImageName and docker.ParseReference that does not place a default tag in the reference +// Would really love to not have this, but needed to enforce tag-less and digest-less names +func parseDockerRepositoryReference(refString string) (types.ImageReference, error) { + parts := strings.SplitN(refString, ":", 2) + if len(parts) != 2 { + return nil, errors.Errorf(`Invalid image name "%s", expected colon-separated transport:reference`, refString) + } + + transport := transports.Get(parts[0]) + if transport == nil || transport.Name() != docker.Transport.Name() { + return nil, errors.New("Invalid transport, can only parse docker transport references") + } + + if !strings.HasPrefix(parts[1], "//") { + return nil, errors.Errorf("docker: image reference %s does not start with //", parts[1]) + } + + ref, err := reference.ParseNormalizedNamed(strings.TrimPrefix(parts[1], "//")) + if err != nil { + return nil, err + } + + if !reference.IsNameOnly(ref) { + return nil, errors.New(`No tag or digest allowed in reference`) + } + + // Checks ok, now return a reference. This is a hack because the tag listing code expects a full image reference even though the tag is ignored + return docker.NewReference(reference.TagNameOnly(ref)) +} + +func listDockerTags(ctx context.Context, sys *types.SystemContext, referenceInput string) (string, []string, error) { + imgRef, err := parseDockerRepositoryReference(referenceInput) + if err != nil { + return ``, nil, fmt.Errorf("Cannot parse repository reference: %v", err) + } + + repositoryName := imgRef.DockerReference().Name() + + tags, err := docker.GetRepositoryTags(ctx, sys, imgRef) + if err != nil { + return ``, nil, fmt.Errorf("Error listing repository tags: %v", err) + } + return repositoryName, tags, nil +} + +func (opts *tagsOptions) run(args []string, stdout io.Writer) (retErr error) { + ctx, cancel := opts.global.commandTimeoutContext() + defer cancel() + var repositoryName string + var tagListing []string + + if len(args) != 1 { + return errorShouldDisplayUsage{errors.New("Exactly one non-option argument expected")} + } + + sys, err := opts.image.newSystemContext() + if err != nil { + return err + } + + transport := alltransports.TransportFromImageName(args[0]) + if transport == nil { + return errors.New("Invalid transport") + } + + if transport.Name() == docker.Transport.Name() { + repositoryName, tagListing, err = listDockerTags(ctx, sys, args[0]) + if err != nil { + return err + } + } else { + return fmt.Errorf("Unsupported transport '%v' for tag listing. Only '%v' currently supported", transport.Name(), docker.Transport.Name()) + } + + outputData := tagListOutput{ + Repository: repositoryName, + Tags: tagListing, + } + + out, err := json.MarshalIndent(outputData, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintf(stdout, "%s\n", string(out)) + + return err +} diff --git a/cmd/skopeo/list_tags_test.go b/cmd/skopeo/list_tags_test.go new file mode 100644 index 00000000..e628bf42 --- /dev/null +++ b/cmd/skopeo/list_tags_test.go @@ -0,0 +1,62 @@ +package main + +import ( + "github.com/containers/image/v5/transports/alltransports" + "github.com/stretchr/testify/assert" + "testing" +) + +// Tests the kinds of inputs allowed and expected to the command +func TestDockerRepositoryReferenceParser(t *testing.T) { + for _, test := range [][]string{ + {"docker://myhost.com:1000/nginx", "myhost.com:1000/nginx"}, //no tag + {"docker://myhost.com/nginx", "myhost.com/nginx"}, //no port or tag + {"docker://somehost.com", "docker.io/library/somehost.com"}, // Valid default expansion + {"docker://nginx", "docker.io/library/nginx"}, // Valid default expansion + {"docker://myhost.com:1000/nginx:foobar:foobar", ""}, // Invalid repository ref + {"docker://somehost.com:5000/", ""}, // no repo + {"docker://myhost.com:1000/nginx:latest", ""}, //tag not allowed + {"docker://myhost.com:1000/nginx@sha256:abcdef1234567890", ""}, //digest not allowed + } { + + ref, err := parseDockerRepositoryReference(test[0]) + + if test[1] == "" { + assert.Error(t, err, "Expected error in parsing but no error raised for %v", test[0]) + } else { + if assert.NoError(t, err, "Could not parse, got error on %v", test[0]) { + assert.Equal(t, test[1], ref.DockerReference().Name(), "Mismatched parse result for input %v", test[0]) + } + } + } +} + +func TestDockerRepositoryReferenceParserDrift(t *testing.T) { + for _, test := range [][]string{ + {"docker://myhost.com:1000/nginx", "myhost.com:1000/nginx"}, //no tag + {"docker://myhost.com/nginx", "myhost.com/nginx"}, //no port or tag + {"docker://somehost.com", "docker.io/library/somehost.com"}, // Valid default expansion + {"docker://nginx", "docker.io/library/nginx"}, // Valid default expansion + } { + + ref, err := parseDockerRepositoryReference(test[0]) + ref2, err2 := alltransports.ParseImageName(test[0]) + + if assert.NoError(t, err, "Could not parse, got error on %v", test[0]) && assert.NoError(t, err2, "Could not parse with regular parser, got error on %v", test[0]) { + assert.Equal(t, ref.DockerReference().String(), ref2.DockerReference().String(), "Different parsing output for input %v. Repo parse = %v, regular parser = %v", test[0], ref, ref2) + } + } +} + +func TestUnsupportedRepositoryReferenceParser(t *testing.T) { + for _, test := range [][]string{ + {"oci://somedir"}, + {"dir:/somepath"}, + {"docker-archive:/tmp/dir"}, + {"container-storage:myhost.com/someimage"}, + {"docker-daemon:myhost.com/someimage"}, + } { + _, err := parseDockerRepositoryReference(test[0]) + assert.Error(t, err, test[0]) + } +} diff --git a/cmd/skopeo/main.go b/cmd/skopeo/main.go index df608063..278a8a00 100644 --- a/cmd/skopeo/main.go +++ b/cmd/skopeo/main.go @@ -102,6 +102,7 @@ func createApp() (*cli.App, *globalOptions) { standaloneSignCmd(), standaloneVerifyCmd(), untrustedSignatureDumpCmd(), + tagsCmd(&opts), } return app, &opts } diff --git a/completions/bash/skopeo b/completions/bash/skopeo index 4797408e..72e6bab5 100644 --- a/completions/bash/skopeo +++ b/completions/bash/skopeo @@ -144,6 +144,20 @@ _skopeo_layers() { _complete_ "$options_with_args" "$boolean_options" } +_skopeo_list_repository_tags() { + local options_with_args=" + --authfile + --creds + --cert-dir + " + + local boolean_options=" + --tls-verify + --no-creds + " + _complete_ "$options_with_args" "$boolean_options" +} + _skopeo_skopeo() { # XXX: Changes here need to be refleceted in the manually expanded # string in the `case` statement below as well. @@ -194,7 +208,7 @@ _cli_bash_autocomplete() { local counter=1 while [ $counter -lt "$cword" ]; do case "${words[$counter]}" in - skopeo|copy|inspect|delete|manifest-digest|standalone-sign|standalone-verify|help|h) + skopeo|copy|inspect|delete|manifest-digest|standalone-sign|standalone-verify|help|h|list-repository-tags) command="${words[$counter]//-/_}" cpos=$counter (( cpos++ )) diff --git a/docs/skopeo-list-tags.1.md b/docs/skopeo-list-tags.1.md new file mode 100644 index 00000000..90ed8091 --- /dev/null +++ b/docs/skopeo-list-tags.1.md @@ -0,0 +1,102 @@ +% skopeo-list-tags(1) + +## NAME +skopeo\-list\-tags - Return a list of tags the transport-specific image repository + +## SYNOPSIS +**skopeo list-tags** _repository-name_ + +Return a list of tags from _repository-name_ in a registry. + + _repository-name_ name of repository to retrieve tag listing from + + **--authfile** _path_ + + Path of the authentication file. Default is ${XDG\_RUNTIME\_DIR}/containers/auth.json, which is set using `podman login`. + If the authorization state is not found there, $HOME/.docker/config.json is checked, which is set using `docker login`. + + **--creds** _username[:password]_ for accessing the registry + + **--cert-dir** _path_ Use certificates at _path_ (\*.crt, \*.cert, \*.key) to connect to the registry + + **--tls-verify** _bool-value_ Require HTTPS and verify certificates when talking to container registries (defaults to true) + + **--no-creds** _bool-value_ Access the registry anonymously. + +## REPOSITORY NAMES + +Repository names are transport-specific references as each transport may have its own concept of a "repository" and "tags". Currently, only the Docker transport is supported. + +This commands refers to repositories using a _transport_`:`_details_ format. The following formats are supported: + + **docker://**_docker-repository-reference_ + A repository in a registry implementing the "Docker Registry HTTP API V2". By default, uses the authorization state in either `$XDG_RUNTIME_DIR/containers/auth.json`, which is set using `(podman login)`. If the authorization state is not found there, `$HOME/.docker/config.json` is checked, which is set using `(docker login)`. + A _docker-repository-reference_ is of the form: **registryhost:port/repositoryname** which is similar to an _image-reference_ but with no tag or digest allowed as the last component (e.g no `:latest` or `@sha256:xyz`) + + Examples of valid docker-repository-references: + "docker.io/myuser/myrepo" + "docker.io/nginx" + "docker.io/library/fedora" + "localhost:5000/myrepository" + + Examples of invalid references: + "docker.io/nginx:latest" + "docker.io/myuser/myimage:v1.0" + "docker.io/myuser/myimage@sha256:f48c4cc192f4c3c6a069cb5cca6d0a9e34d6076ba7c214fd0cc3ca60e0af76bb" + + +## EXAMPLES + +### Docker Transport +To get the list of tags in the "fedora" repository from the docker.io registry (the repository name expands to "library/fedora" per docker transport canonical form): +```sh +$ skopeo list-tags docker://docker.io/fedora +{ + "Repository": "docker.io/library/fedora", + "Tags": [ + "20", + "21", + "22", + "23", + "24", + "25", + "26-modular", + "26", + "27", + "28", + "29", + "30", + "31", + "32", + "branched", + "heisenbug", + "latest", + "modular", + "rawhide" + ] +} + +``` + +To list the tags in a local host docker/distribution registry on port 5000, in this case for the "fedora" repository: + +```sh +$ skopeo list-tags docker://localhost:5000/fedora +{ + "Repository": "localhost:5000/myapp", + "Tags": [ + "latest", + "v1.1.1", + "v1.2.0" + ] +} + +``` + +# SEE ALSO +skopeo(1), podman-login(1), docker-login(1) + +## AUTHORS + +Zach Hill + diff --git a/docs/skopeo.1.md b/docs/skopeo.1.md index dca05ec6..4adcbb66 100644 --- a/docs/skopeo.1.md +++ b/docs/skopeo.1.md @@ -75,6 +75,7 @@ Most commands refer to container images, using a _transport_`:`_details_ format. | [skopeo-standalone-sign(1)](skopeo-standalone-sign.1.md) | Sign an image. | | [skopeo-standalone-verify(1)](skopeo-standalone-verify.1.md)| Verify an image. | | [skopeo-sync(1)](skopeo-sync.1.md)| Copy images from one or more repositories to a user specified destination. | +| [skopeo-list-tags(1)](skopeo-list-tags.1.md) | List the tags for the given transport/repository. | ## FILES **/etc/containers/policy.json** diff --git a/integration/list_tags_test.go b/integration/list_tags_test.go new file mode 100644 index 00000000..a3d7e5c1 --- /dev/null +++ b/integration/list_tags_test.go @@ -0,0 +1,22 @@ +package main + +import ( + "github.com/go-check/check" +) + +func init() { + check.Suite(&TagListSuite{}) +} + +type TagListSuite struct { + registry *testRegistryV2 +} + +// Simple tag listing +func (s *TagListSuite) TestListSimple(c *check.C) { + assertSkopeoSucceeds(c, `.*Repository: docker\.io/library/centos.*`, "list-tags", "docker://docker.io/library/centos") + assertSkopeoSucceeds(c, `.*Repository: docker\.io/library/centos.*`, "list-tags", "docker://centos") + assertSkopeoSucceeds(c, `.*Repository: docker\.io/library/centos.*`, "list-tags", "docker://docker.io/centos") + assertSkopeoFails(c, ".*No tag or digest allowed.*", "", "list-tags", "docker://docker.io/centos:7") + assertSkopeoFails(c, ".*Unsupported transport.*", "", "list-tags", "docker-daemon://docker.io/centos:7") +}