diff --git a/cli/command/completion/functions.go b/cli/command/completion/functions.go index 1fa4ff621182..7e040f60cc71 100644 --- a/cli/command/completion/functions.go +++ b/cli/command/completion/functions.go @@ -172,6 +172,28 @@ func FromList(options ...string) cobra.CompletionFunc { return Unique(cobra.FixedCompletions(options, cobra.ShellCompDirectiveNoFileComp)) } +// WithPrefix prefixes every element in the slice with the given prefix. +// It is a helper for building "--filter" completions, where each candidate +// value is offered as "key=value". +func WithPrefix(prefix string, values []string) []string { + result := make([]string, len(values)) + for i, v := range values { + result[i] = prefix + v + } + return result +} + +// WithSuffix appends the given suffix to every element in the slice. It is a +// helper for building "--filter" completions, where filter keys are offered +// with a trailing "=" (combined with [cobra.ShellCompDirectiveNoSpace]). +func WithSuffix(suffix string, values []string) []string { + result := make([]string, len(values)) + for i, v := range values { + result[i] = v + suffix + } + return result +} + // FileNames is a convenience function to use [cobra.ShellCompDirectiveDefault], // which indicates to let the shell perform its default behavior after // completions have been provided. diff --git a/cli/command/completion/functions_test.go b/cli/command/completion/functions_test.go index 58fee8de06fd..bb2fa1b62ddb 100644 --- a/cli/command/completion/functions_test.go +++ b/cli/command/completion/functions_test.go @@ -196,6 +196,18 @@ func TestCompleteFromList(t *testing.T) { assert.Check(t, is.DeepEqual(values, expected)) } +func TestWithPrefix(t *testing.T) { + assert.Check(t, is.DeepEqual(WithPrefix("node=", []string{"n1", "n2"}), []string{"node=n1", "node=n2"})) + assert.Check(t, is.DeepEqual(WithPrefix("node=", []string{}), []string{})) + assert.Check(t, is.DeepEqual(WithPrefix("", []string{"n1"}), []string{"n1"})) +} + +func TestWithSuffix(t *testing.T) { + assert.Check(t, is.DeepEqual(WithSuffix("=", []string{"id", "name"}), []string{"id=", "name="})) + assert.Check(t, is.DeepEqual(WithSuffix("=", []string{}), []string{})) + assert.Check(t, is.DeepEqual(WithSuffix("", []string{"id"}), []string{"id"})) +} + func TestCompleteImageNames(t *testing.T) { tests := []struct { doc string diff --git a/cli/command/node/completion.go b/cli/command/node/completion.go index f3ccf757279e..7fb7bdfb9834 100644 --- a/cli/command/node/completion.go +++ b/cli/command/node/completion.go @@ -2,12 +2,26 @@ package node import ( "os" + "strings" "github.com/docker/cli/cli/command/completion" + "github.com/moby/moby/api/types/swarm" "github.com/moby/moby/client" "github.com/spf13/cobra" ) +var ( + // nodePsFilters are the filters that can be used with "docker node ps --filter". + nodePsFilters = []string{"desired-state", "id", "label", "name"} + + // taskDesiredStates are the valid values for the "desired-state" task filter. + taskDesiredStates = []string{ + string(swarm.TaskStateRunning), + string(swarm.TaskStateShutdown), + string(swarm.TaskStateAccepted), + } +) + // completeNodeNames offers completion for swarm node (host)names and optional IDs. // By default, only names are returned. // Set DOCKER_COMPLETION_SHOW_NODE_IDS=yes to also complete IDs. @@ -35,3 +49,23 @@ func completeNodeNames(dockerCLI completion.APIClientProvider) cobra.CompletionF return names, cobra.ShellCompDirectiveNoFileComp } } + +// completeNodePsFilters provides completion for the filters that can be used +// with "docker node ps --filter". +func completeNodePsFilters(_ completion.APIClientProvider) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + key, _, ok := strings.Cut(toComplete, "=") + if !ok { + return completion.WithSuffix("=", nodePsFilters), cobra.ShellCompDirectiveNoSpace + } + switch key { + case "desired-state": + return completion.WithPrefix("desired-state=", taskDesiredStates), cobra.ShellCompDirectiveNoFileComp + case "id", "name", "label": + // Task IDs, names, and labels are not easily discoverable; only offer the key. + return nil, cobra.ShellCompDirectiveNoFileComp + default: + return completion.WithSuffix("=", nodePsFilters), cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } + } +} diff --git a/cli/command/node/completion_test.go b/cli/command/node/completion_test.go new file mode 100644 index 000000000000..13068319de26 --- /dev/null +++ b/cli/command/node/completion_test.go @@ -0,0 +1,52 @@ +package node + +import ( + "testing" + + "github.com/docker/cli/internal/test" + "github.com/spf13/cobra" + "gotest.tools/v3/assert" +) + +func TestCompleteNodePsFilters(t *testing.T) { + tests := []struct { + doc string + toComplete string + expected []string + directive cobra.ShellCompDirective + }{ + { + doc: "no input offers the filter keys", + toComplete: "", + expected: []string{"desired-state=", "id=", "label=", "name="}, + directive: cobra.ShellCompDirectiveNoSpace, + }, + { + doc: "desired-state values", + toComplete: "desired-state=", + expected: []string{"desired-state=running", "desired-state=shutdown", "desired-state=accepted"}, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "label offers no values", + toComplete: "label=", + expected: nil, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "unknown key falls back to the filter keys", + toComplete: "bogus=", + expected: []string{"desired-state=", "id=", "label=", "name="}, + directive: cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp, + }, + } + + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{}) + completions, directive := completeNodePsFilters(cli)(newPsCommand(cli), nil, tc.toComplete) + assert.DeepEqual(t, completions, tc.expected) + assert.Equal(t, directive, tc.directive) + }) + } +} diff --git a/cli/command/node/ps.go b/cli/command/node/ps.go index 4ad64530b187..1cd397883b74 100644 --- a/cli/command/node/ps.go +++ b/cli/command/node/ps.go @@ -48,6 +48,8 @@ func newPsCommand(dockerCLI command.Cli) *cobra.Command { flags.StringVar(&options.format, "format", "", "Pretty-print tasks using a Go template") flags.BoolVarP(&options.quiet, "quiet", "q", false, "Only display task IDs") + _ = cmd.RegisterFlagCompletionFunc("filter", completeNodePsFilters(dockerCLI)) + return cmd } diff --git a/cli/command/service/completion.go b/cli/command/service/completion.go index 9e244ac41019..6d5438c2cd83 100644 --- a/cli/command/service/completion.go +++ b/cli/command/service/completion.go @@ -2,12 +2,32 @@ package service import ( "os" + "strings" "github.com/docker/cli/cli/command/completion" + "github.com/moby/moby/api/types/swarm" "github.com/moby/moby/client" "github.com/spf13/cobra" ) +var ( + // serviceListFilters are the filters that can be used with "docker service ls --filter". + serviceListFilters = []string{"id", "label", "mode", "name"} + + // serviceModes are the valid values for the "mode" filter of "docker service ls". + serviceModes = []string{"replicated", "global", "replicated-job", "global-job"} + + // servicePsFilters are the filters that can be used with "docker service ps --filter". + servicePsFilters = []string{"desired-state", "id", "name", "node"} + + // taskDesiredStates are the valid values for the "desired-state" task filter. + taskDesiredStates = []string{ + string(swarm.TaskStateRunning), + string(swarm.TaskStateShutdown), + string(swarm.TaskStateAccepted), + } +) + // completeServiceNames offers completion for swarm service names and optional IDs. // By default, only names are returned. // Set DOCKER_COMPLETION_SHOW_SERVICE_IDS=yes to also complete IDs. @@ -31,3 +51,74 @@ func completeServiceNames(dockerCLI completion.APIClientProvider) cobra.Completi return names, cobra.ShellCompDirectiveNoFileComp } } + +// completeServiceListFilters provides completion for the filters that can be +// used with "docker service ls --filter". +func completeServiceListFilters(dockerCLI completion.APIClientProvider) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + key, _, ok := strings.Cut(toComplete, "=") + if !ok { + return completion.WithSuffix("=", serviceListFilters), cobra.ShellCompDirectiveNoSpace + } + switch key { + case "id", "name": + return completion.WithPrefix(key+"=", serviceNames(dockerCLI, cmd)), cobra.ShellCompDirectiveNoFileComp + case "mode": + return completion.WithPrefix("mode=", serviceModes), cobra.ShellCompDirectiveNoFileComp + case "label": + return nil, cobra.ShellCompDirectiveNoFileComp + default: + return completion.WithSuffix("=", serviceListFilters), cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } + } +} + +// completeServicePsFilters provides completion for the filters that can be +// used with "docker service ps --filter". +func completeServicePsFilters(dockerCLI completion.APIClientProvider) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + key, _, ok := strings.Cut(toComplete, "=") + if !ok { + return completion.WithSuffix("=", servicePsFilters), cobra.ShellCompDirectiveNoSpace + } + switch key { + case "desired-state": + return completion.WithPrefix("desired-state=", taskDesiredStates), cobra.ShellCompDirectiveNoFileComp + case "node": + return completion.WithPrefix("node=", nodeNames(dockerCLI, cmd)), cobra.ShellCompDirectiveNoFileComp + case "id", "name": + // Task IDs and names are not easily discoverable; only offer the key. + return nil, cobra.ShellCompDirectiveNoFileComp + default: + return completion.WithSuffix("=", servicePsFilters), cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp + } + } +} + +// serviceNames contacts the API to get a list of service names. +// In case of an error, an empty list is returned. +func serviceNames(dockerCLI completion.APIClientProvider, cmd *cobra.Command) []string { + res, err := dockerCLI.Client().ServiceList(cmd.Context(), client.ServiceListOptions{}) + if err != nil { + return []string{} + } + names := make([]string, 0, len(res.Items)) + for _, service := range res.Items { + names = append(names, service.Spec.Name) + } + return names +} + +// nodeNames contacts the API to get a list of node (host)names. +// In case of an error, an empty list is returned. +func nodeNames(dockerCLI completion.APIClientProvider, cmd *cobra.Command) []string { + res, err := dockerCLI.Client().NodeList(cmd.Context(), client.NodeListOptions{}) + if err != nil { + return []string{} + } + names := make([]string, 0, len(res.Items)) + for _, node := range res.Items { + names = append(names, node.Description.Hostname) + } + return names +} diff --git a/cli/command/service/completion_test.go b/cli/command/service/completion_test.go new file mode 100644 index 000000000000..9df2580f8a41 --- /dev/null +++ b/cli/command/service/completion_test.go @@ -0,0 +1,156 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/docker/cli/internal/test" + "github.com/docker/cli/internal/test/builders" + "github.com/moby/moby/api/types/swarm" + "github.com/moby/moby/client" + "github.com/spf13/cobra" + "gotest.tools/v3/assert" +) + +func TestCompleteServicePsFilters(t *testing.T) { + tests := []struct { + doc string + client *fakeClient + toComplete string + expected []string + directive cobra.ShellCompDirective + }{ + { + doc: "no input offers the filter keys", + toComplete: "", + expected: []string{"desired-state=", "id=", "name=", "node="}, + directive: cobra.ShellCompDirectiveNoSpace, + }, + { + doc: "desired-state values", + toComplete: "desired-state=", + expected: []string{"desired-state=running", "desired-state=shutdown", "desired-state=accepted"}, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "node values", + client: &fakeClient{ + nodeListFunc: func(_ context.Context, _ client.NodeListOptions) (client.NodeListResult, error) { + return client.NodeListResult{ + Items: []swarm.Node{ + *builders.Node(builders.Hostname("n1")), + *builders.Node(builders.Hostname("n2")), + }, + }, nil + }, + }, + toComplete: "node=", + expected: []string{"node=n1", "node=n2"}, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "node values on API error", + client: &fakeClient{ + nodeListFunc: func(_ context.Context, _ client.NodeListOptions) (client.NodeListResult, error) { + return client.NodeListResult{}, errors.New("API error") + }, + }, + toComplete: "node=", + expected: []string{}, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "id offers no values", + toComplete: "id=", + expected: nil, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "unknown key falls back to the filter keys", + toComplete: "bogus=", + expected: []string{"desired-state=", "id=", "name=", "node="}, + directive: cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp, + }, + } + + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + cli := test.NewFakeCli(tc.client) + completions, directive := completeServicePsFilters(cli)(newPsCommand(cli), nil, tc.toComplete) + assert.DeepEqual(t, completions, tc.expected) + assert.Equal(t, directive, tc.directive) + }) + } +} + +func TestCompleteServiceListFilters(t *testing.T) { + tests := []struct { + doc string + client *fakeClient + toComplete string + expected []string + directive cobra.ShellCompDirective + }{ + { + doc: "no input offers the filter keys", + toComplete: "", + expected: []string{"id=", "label=", "mode=", "name="}, + directive: cobra.ShellCompDirectiveNoSpace, + }, + { + doc: "mode values", + toComplete: "mode=", + expected: []string{"mode=replicated", "mode=global", "mode=replicated-job", "mode=global-job"}, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "name values", + client: &fakeClient{ + serviceListFunc: func(_ context.Context, _ client.ServiceListOptions) (client.ServiceListResult, error) { + return client.ServiceListResult{ + Items: []swarm.Service{ + *builders.Service(builders.ServiceName("s1")), + *builders.Service(builders.ServiceName("s2")), + }, + }, nil + }, + }, + toComplete: "name=", + expected: []string{"name=s1", "name=s2"}, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "name values on API error", + client: &fakeClient{ + serviceListFunc: func(_ context.Context, _ client.ServiceListOptions) (client.ServiceListResult, error) { + return client.ServiceListResult{}, errors.New("API error") + }, + }, + toComplete: "name=", + expected: []string{}, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "label offers no values", + toComplete: "label=", + expected: nil, + directive: cobra.ShellCompDirectiveNoFileComp, + }, + { + doc: "unknown key falls back to the filter keys", + toComplete: "bogus=", + expected: []string{"id=", "label=", "mode=", "name="}, + directive: cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp, + }, + } + + for _, tc := range tests { + t.Run(tc.doc, func(t *testing.T) { + cli := test.NewFakeCli(tc.client) + completions, directive := completeServiceListFilters(cli)(newListCommand(cli), nil, tc.toComplete) + assert.DeepEqual(t, completions, tc.expected) + assert.Equal(t, directive, tc.directive) + }) + } +} diff --git a/cli/command/service/list.go b/cli/command/service/list.go index d2e2fe6a1003..346eb6651f59 100644 --- a/cli/command/service/list.go +++ b/cli/command/service/list.go @@ -38,6 +38,8 @@ func newListCommand(dockerCLI command.Cli) *cobra.Command { flags.StringVar(&options.format, "format", "", flagsHelper.FormatHelp) flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided") + _ = cmd.RegisterFlagCompletionFunc("filter", completeServiceListFilters(dockerCLI)) + return cmd } diff --git a/cli/command/service/ps.go b/cli/command/service/ps.go index 4591f1cf0a5e..791949184cd4 100644 --- a/cli/command/service/ps.go +++ b/cli/command/service/ps.go @@ -45,6 +45,8 @@ func newPsCommand(dockerCLI command.Cli) *cobra.Command { flags.StringVar(&options.format, "format", "", "Pretty-print tasks using a Go template") flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided") + _ = cmd.RegisterFlagCompletionFunc("filter", completeServicePsFilters(dockerCLI)) + return cmd }