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 |
+
+
+
+