Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions cli/command/completion/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions cli/command/completion/functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions cli/command/node/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Comment on lines +65 to +66

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing the old bash-completion couldn't easily do this because the CLI does not have a comment to list tasks, but as a follow up we could look if we actually could use the API client for this;

filter := options.filter.Value()
filter.Add("node", res.Node.ID)
nodeTasks, err := apiClient.TaskList(ctx, client.TaskListOptions{Filters: filter})
if err != nil {
errs = append(errs, err)
continue
}

Definitely OK for a follow-up though, no need to cramp it into this PR.

@JessThrysoee JessThrysoee Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • label= a TaskList/ServiceList call and pluck the label from the Items
  • name= a little heavier with multiple api calls, should system.taskNames be made available for this?
  • id= probably not as useful, but also just a TaskList call

But I think you are right, this feels like a separate PR.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup! No need to do it for this one; I was only glancing over the changes (at a glance they look great! but will have a closer look tomorrow), and was thinking out loud to see if we could fill in the remaining bits.

But indeed; more than fine for a follow-up, so that we can see if it makes sense (outweigh the overhead against how much it improves).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(marked it "unresolved" so that it stays visible, but no need to fix)

default:
return completion.WithSuffix("=", nodePsFilters), cobra.ShellCompDirectiveNoSpace | cobra.ShellCompDirectiveNoFileComp
}
}
}
52 changes: 52 additions & 0 deletions cli/command/node/completion_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
}
2 changes: 2 additions & 0 deletions cli/command/node/ps.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
91 changes: 91 additions & 0 deletions cli/command/service/completion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
}
Loading