diff --git a/cli/command/manifest/cmd.go b/cli/command/manifest/cmd.go index 939f02b7bc..c2405753b3 100644 --- a/cli/command/manifest/cmd.go +++ b/cli/command/manifest/cmd.go @@ -28,6 +28,7 @@ func NewManifestCommand(dockerCli command.Cli) *cobra.Command { newAnnotateCommand(dockerCli), newPushListCommand(dockerCli), newRmManifestListCommand(dockerCli), + newListCommand(dockerCli), ) return cmd } diff --git a/cli/command/manifest/formatter.go b/cli/command/manifest/formatter.go new file mode 100644 index 0000000000..9c30aeeeff --- /dev/null +++ b/cli/command/manifest/formatter.go @@ -0,0 +1,103 @@ +package manifest + +import ( + "fmt" + "strings" + + "github.com/distribution/reference" + "github.com/docker/cli/cli/command/formatter" + "github.com/docker/cli/cli/manifest/types" +) + +const ( + defaultManifestListQuietFormat = "{{.Name}}" + defaultManifestListTableFormat = "table {{.Repository}}\t{{.Tag}}\t{{.Platforms}}" + + repositoryHeader = "REPOSITORY" + tagHeader = "TAG" + platformsHeader = "PLATFORMS" +) + +// NewFormat returns a Format for rendering using a manifest list Context +func NewFormat(source string, quiet bool) formatter.Format { + switch source { + case formatter.TableFormatKey: + if quiet { + return defaultManifestListQuietFormat + } + return defaultManifestListTableFormat + case formatter.RawFormatKey: + if quiet { + return `name: {{.Name}}` + } + return `repo: {{.Repository}}\ntag: {{.Tag}}\n` + } + return formatter.Format(source) +} + +// FormatWrite writes formatted manifestLists using the Context +func FormatWrite(ctx formatter.Context, manifestLists []reference.Reference, manifests map[string][]types.ImageManifest) error { + render := func(format func(subContext formatter.SubContext) error) error { + for _, manifestList := range manifestLists { + if n, ok := manifestList.(reference.Named); ok { + if nt, ok := n.(reference.NamedTagged); ok { + if err := format(&manifestListContext{ + name: reference.FamiliarString(manifestList), + repo: reference.FamiliarName(nt), + tag: nt.Tag(), + imageManifests: manifests[manifestList.String()], + }); err != nil { + return err + } + } + } + } + return nil + } + return ctx.Write(newManifestListContext(), render) +} + +type manifestListContext struct { + formatter.HeaderContext + name string + repo string + tag string + imageManifests []types.ImageManifest +} + +func newManifestListContext() *manifestListContext { + manifestListCtx := manifestListContext{} + manifestListCtx.Header = formatter.SubHeaderContext{ + "Name": formatter.NameHeader, + "Repository": repositoryHeader, + "Tag": tagHeader, + "Platforms": platformsHeader, + } + return &manifestListCtx +} + +func (c *manifestListContext) MarshalJSON() ([]byte, error) { + return formatter.MarshalJSON(c) +} + +func (c *manifestListContext) Name() string { + return c.name +} + +func (c *manifestListContext) Repository() string { + return c.repo +} + +func (c *manifestListContext) Tag() string { + return c.tag +} + +func (c *manifestListContext) Platforms() string { + platforms := []string{} + for _, manifest := range c.imageManifests { + os := manifest.Descriptor.Platform.OS + arch := manifest.Descriptor.Platform.Architecture + platforms = append(platforms, fmt.Sprintf("%s/%s", os, arch)) + } + return strings.Join(platforms, ", ") +} diff --git a/cli/command/manifest/list.go b/cli/command/manifest/list.go new file mode 100644 index 0000000000..2357575396 --- /dev/null +++ b/cli/command/manifest/list.go @@ -0,0 +1,74 @@ +package manifest + +import ( + "sort" + + "github.com/distribution/reference" + "github.com/docker/cli/cli" + "github.com/docker/cli/cli/command" + "github.com/docker/cli/cli/command/formatter" + flagsHelper "github.com/docker/cli/cli/flags" + "github.com/docker/cli/cli/manifest/types" + "github.com/fvbommel/sortorder" + "github.com/spf13/cobra" +) + +type listOptions struct { + quiet bool + format string +} + +func newListCommand(dockerCli command.Cli) *cobra.Command { + var options listOptions + + cmd := &cobra.Command{ + Use: "ls [OPTIONS]", + Aliases: []string{"list"}, + Short: "List local manifest lists", + Args: cli.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(dockerCli, options) + }, + } + + flags := cmd.Flags() + flags.BoolVarP(&options.quiet, "quiet", "q", false, "Only show manifest list NAMEs") + flags.StringVar(&options.format, "format", "", flagsHelper.FormatHelp) + return cmd +} + +func runList(dockerCli command.Cli, options listOptions) error { + manifestStore := dockerCli.ManifestStore() + + var manifestLists []reference.Reference + + manifestLists, err := manifestStore.List() + if err != nil { + return err + } + + manifests := map[string][]types.ImageManifest{} + for _, manifestList := range manifestLists { + if imageManifests, err := manifestStore.GetList(manifestList); err == nil { + manifests[manifestList.String()] = imageManifests + } + } + + format := options.format + if len(format) == 0 { + if len(dockerCli.ConfigFile().ManifestListsFormat) > 0 && !options.quiet { + format = dockerCli.ConfigFile().ManifestListsFormat + } else { + format = formatter.TableFormatKey + } + } + + manifestListsCtx := formatter.Context{ + Output: dockerCli.Out(), + Format: NewFormat(format, options.quiet), + } + sort.Slice(manifestLists, func(i, j int) bool { + return sortorder.NaturalLess(manifestLists[i].String(), manifestLists[j].String()) + }) + return FormatWrite(manifestListsCtx, manifestLists, manifests) +} diff --git a/cli/command/manifest/list_test.go b/cli/command/manifest/list_test.go new file mode 100644 index 0000000000..a8ac74f481 --- /dev/null +++ b/cli/command/manifest/list_test.go @@ -0,0 +1,105 @@ +package manifest + +import ( + "io" + "testing" + + "github.com/docker/cli/cli/manifest/store" + "github.com/docker/cli/internal/test" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/filters" + + "gotest.tools/v3/assert" + "gotest.tools/v3/golden" +) + +func TestListErrors(t *testing.T) { + manifestStore := store.NewStore(t.TempDir()) + + testCases := []struct { + description string + args []string + flags map[string]string + expectedError string + }{ + { + description: "too many arguments", + args: []string{"foo"}, + expectedError: "accepts no arguments", + }, + { + description: "invalid format", + args: []string{}, + flags: map[string]string{ + "format": "{{invalid format}}", + }, + expectedError: "template parsing error", + }, + } + + for _, tc := range testCases { + cli := test.NewFakeCli(nil) + cli.SetManifestStore(manifestStore) + cmd := newListCommand(cli) + cmd.SetArgs(tc.args) + for key, value := range tc.flags { + cmd.Flags().Set(key, value) + } + cmd.SetOut(io.Discard) + assert.ErrorContains(t, cmd.Execute(), tc.expectedError) + } +} + +func TestList(t *testing.T) { + manifestStore := store.NewStore(t.TempDir()) + + list1 := ref(t, "first:1") + namedRef := ref(t, "alpine:3.0") + err := manifestStore.Save(list1, namedRef, fullImageManifest(t, namedRef)) + assert.NilError(t, err) + namedRef = ref(t, "alpine:3.1") + imageManifest := fullImageManifest(t, namedRef) + imageManifest.Descriptor.Platform.OS = "linux" + imageManifest.Descriptor.Platform.Architecture = "arm64" + err = manifestStore.Save(list1, namedRef, imageManifest) + assert.NilError(t, err) + + list2 := ref(t, "second:2") + namedRef = ref(t, "alpine:3.2") + err = manifestStore.Save(list2, namedRef, fullImageManifest(t, namedRef)) + assert.NilError(t, err) + + testCases := []struct { + description string + args []string + flags map[string]string + golden string + listFunc func(filter filters.Args) (types.PluginsListResponse, error) + }{ + { + description: "list with no additional flags", + args: []string{}, + golden: "manifest-list.golden", + }, + { + description: "list with quiet option", + args: []string{}, + flags: map[string]string{ + "quiet": "true", + }, + golden: "manifest-list-with-quiet-option.golden", + }, + } + + for _, tc := range testCases { + cli := test.NewFakeCli(nil) + cli.SetManifestStore(manifestStore) + cmd := newListCommand(cli) + cmd.SetArgs(tc.args) + for key, value := range tc.flags { + cmd.Flags().Set(key, value) + } + assert.NilError(t, cmd.Execute()) + golden.Assert(t, cli.OutBuffer().String(), tc.golden) + } +} diff --git a/cli/command/manifest/testdata/manifest-list-with-quiet-option.golden b/cli/command/manifest/testdata/manifest-list-with-quiet-option.golden new file mode 100644 index 0000000000..a91a0080a6 --- /dev/null +++ b/cli/command/manifest/testdata/manifest-list-with-quiet-option.golden @@ -0,0 +1,2 @@ +example.com/first:1 +example.com/second:2 diff --git a/cli/command/manifest/testdata/manifest-list.golden b/cli/command/manifest/testdata/manifest-list.golden new file mode 100644 index 0000000000..a9dd162089 --- /dev/null +++ b/cli/command/manifest/testdata/manifest-list.golden @@ -0,0 +1,3 @@ +REPOSITORY TAG PLATFORMS +example.com/first 1 linux/amd64, linux/arm64 +example.com/second 2 linux/amd64 diff --git a/cli/config/configfile/file.go b/cli/config/configfile/file.go index ae9dcb3370..3a126e0691 100644 --- a/cli/config/configfile/file.go +++ b/cli/config/configfile/file.go @@ -24,6 +24,7 @@ type ConfigFile struct { PluginsFormat string `json:"pluginsFormat,omitempty"` VolumesFormat string `json:"volumesFormat,omitempty"` StatsFormat string `json:"statsFormat,omitempty"` + ManifestListsFormat string `json:"manifestListsFormat,omitempty"` DetachKeys string `json:"detachKeys,omitempty"` CredentialsStore string `json:"credsStore,omitempty"` CredentialHelpers map[string]string `json:"credHelpers,omitempty"` diff --git a/cli/manifest/store/store.go b/cli/manifest/store/store.go index c4f8219cec..db81f91cf3 100644 --- a/cli/manifest/store/store.go +++ b/cli/manifest/store/store.go @@ -20,6 +20,7 @@ type Store interface { Get(listRef reference.Reference, manifest reference.Reference) (types.ImageManifest, error) GetList(listRef reference.Reference) ([]types.ImageManifest, error) Save(listRef reference.Reference, manifest reference.Reference, image types.ImageManifest) error + List() ([]reference.Reference, error) } // fsStore manages manifest files stored on the local filesystem @@ -85,6 +86,26 @@ func (s *fsStore) getFromFilename(ref reference.Reference, filename string) (typ return manifestInfo.ImageManifest, nil } +// List returns the local manifest lists for a transaction +func (s *fsStore) List() ([]reference.Reference, error) { + fileInfos, err := os.ReadDir(s.root) + switch { + case os.IsNotExist(err): + return nil, nil + case err != nil: + return nil, err + } + + listRefs := make([]reference.Reference, 0, len(fileInfos)) + for _, info := range fileInfos { + refString := filenameToRefString(info.Name()) + if listRef, err := reference.Parse(refString); err == nil { + listRefs = append(listRefs, listRef) + } + } + return listRefs, nil +} + // GetList returns all the local manifests for a transaction func (s *fsStore) GetList(listRef reference.Reference) ([]types.ImageManifest, error) { filenames, err := s.listManifests(listRef.String()) @@ -148,8 +169,13 @@ func manifestToFilename(root, manifestList, manifest string) string { } func makeFilesafeName(ref string) string { - fileName := strings.ReplaceAll(ref, ":", "-") - return strings.ReplaceAll(fileName, "/", "_") + fileName := strings.ReplaceAll(ref, ":", "%") + return strings.ReplaceAll(fileName, "/", "#") +} + +func filenameToRefString(filename string) string { + refString := strings.ReplaceAll(filename, "%", ":") + return strings.ReplaceAll(refString, "#", "/") } type notFoundError struct { diff --git a/contrib/completion/bash/docker b/contrib/completion/bash/docker index 4c6235211c..484e0c5815 100644 --- a/contrib/completion/bash/docker +++ b/contrib/completion/bash/docker @@ -4139,10 +4139,15 @@ _docker_manifest() { annotate create inspect + ls push rm " - __docker_subcommands "$subcommands" && return + + local aliases=" + list + " + __docker_subcommands "$subcommands $aliases" && return case "$cur" in -*) @@ -4250,6 +4255,24 @@ _docker_manifest_rm() { esac } +_docker_manifest_list() { + _docker_manifest_ls +} + +_docker_manifest_ls() { + case "$prev" in + --format) + return + ;; + esac + + case "$cur" in + -*) + COMPREPLY=( $( compgen -W "--format --help --quiet -q" -- "$cur" ) ) + ;; + esac +} + _docker_node() { local subcommands=" demote diff --git a/docs/reference/commandline/manifest.md b/docs/reference/commandline/manifest.md index 682974b237..93941bb411 100644 --- a/docs/reference/commandline/manifest.md +++ b/docs/reference/commandline/manifest.md @@ -21,6 +21,7 @@ For full details on using docker manifest lists, see the registry v2 specificati | [`annotate`](manifest_annotate.md) | Add additional information to a local image manifest | | [`create`](manifest_create.md) | Create a local manifest list for annotating and pushing to a registry | | [`inspect`](manifest_inspect.md) | Display an image manifest, or manifest list | +| [`ls`](manifest_ls.md) | List local manifest lists | | [`push`](manifest_push.md) | Push a manifest list to a repository | | [`rm`](manifest_rm.md) | Delete one or more manifest lists from local storage | diff --git a/docs/reference/commandline/manifest_ls.md b/docs/reference/commandline/manifest_ls.md new file mode 100644 index 0000000000..1fd0fc6842 --- /dev/null +++ b/docs/reference/commandline/manifest_ls.md @@ -0,0 +1,19 @@ +# docker manifest ls + + +List local manifest lists + +### Aliases + +`docker manifest ls`, `docker manifest list` + +### Options + +| Name | Type | Default | Description | +|:----------------|:---------|:--------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `--format` | `string` | | Format output using a custom template:
'table': Print output in table format with column headers (default)
'table TEMPLATE': Print output in table format using the given Go template
'json': Print in JSON format
'TEMPLATE': Print output using the given Go template.
Refer to https://docs.docker.com/go/formatting/ for more information about formatting output with templates | +| `-q`, `--quiet` | | | Only show manifest list NAMEs | + + + +