From bdf9dca0f49082927c07aae7bfa793d4759e0e20 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Tue, 15 Oct 2024 19:05:36 +0000 Subject: [PATCH 1/2] Add completion for `events --filter` Signed-off-by: Harald Albers --- cli/command/system/completion.go | 241 +++++++++++++++++++++++++++++++ cli/command/system/events.go | 2 + 2 files changed, 243 insertions(+) create mode 100644 cli/command/system/completion.go diff --git a/cli/command/system/completion.go b/cli/command/system/completion.go new file mode 100644 index 000000000000..cb7f36896228 --- /dev/null +++ b/cli/command/system/completion.go @@ -0,0 +1,241 @@ +package system + +import ( + "strings" + + "github.com/docker/cli/cli/command/completion" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/events" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/volume" + "github.com/spf13/cobra" +) + +var ( + eventFilters = []string{"container", "daemon", "event", "image", "label", "network", "node", "scope", "type", "volume"} + + // eventTypes is a list of all event types. + // This should be moved to the moby codebase once its usage is consolidated here. + eventTypes = []events.Type{ + events.BuilderEventType, + events.ConfigEventType, + events.ContainerEventType, + events.DaemonEventType, + events.ImageEventType, + events.NetworkEventType, + events.NodeEventType, + events.PluginEventType, + events.SecretEventType, + events.ServiceEventType, + events.VolumeEventType, + } + + // eventActions is a list of all event actions. + // This should be moved to the moby codebase once its usage is consolidated here. + eventActions = []events.Action{ + events.ActionCreate, + events.ActionStart, + events.ActionRestart, + events.ActionStop, + events.ActionCheckpoint, + events.ActionPause, + events.ActionUnPause, + events.ActionAttach, + events.ActionDetach, + events.ActionResize, + events.ActionUpdate, + events.ActionRename, + events.ActionKill, + events.ActionDie, + events.ActionOOM, + events.ActionDestroy, + events.ActionRemove, + events.ActionCommit, + events.ActionTop, + events.ActionCopy, + events.ActionArchivePath, + events.ActionExtractToDir, + events.ActionExport, + events.ActionImport, + events.ActionSave, + events.ActionLoad, + events.ActionTag, + events.ActionUnTag, + events.ActionPush, + events.ActionPull, + events.ActionPrune, + events.ActionDelete, + events.ActionEnable, + events.ActionDisable, + events.ActionConnect, + events.ActionDisconnect, + events.ActionReload, + events.ActionMount, + events.ActionUnmount, + events.ActionExecCreate, + events.ActionExecStart, + events.ActionExecDie, + events.ActionExecDetach, + events.ActionHealthStatus, + events.ActionHealthStatusRunning, + events.ActionHealthStatusHealthy, + events.ActionHealthStatusUnhealthy, + } +) + +// completeEventFilters provides completion for the filters that can be used with `--filter`. +func completeEventFilters(dockerCLI completion.APIClientProvider) completion.ValidArgsFn { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if strings.HasPrefix(toComplete, "container=") { + return prefixWith("container=", containerNames(dockerCLI, cmd, args, toComplete)), cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "daemon=") { + return prefixWith("daemon=", daemonNames(dockerCLI, cmd)), cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "event=") { + return prefixWith("event=", validEventNames()), cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "image=") { + return prefixWith("image=", imageNames(dockerCLI, cmd)), cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "label=") { + return nil, cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "network=") { + return prefixWith("network=", networkNames(dockerCLI, cmd)), cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "node=") { + return prefixWith("node=", nodeNames(dockerCLI, cmd)), cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "scope=") { + return prefixWith("scope=", []string{"local", "swarm"}), cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "type=") { + return prefixWith("type=", eventTypeNames()), cobra.ShellCompDirectiveNoFileComp + } + if strings.HasPrefix(toComplete, "volume=") { + return prefixWith("volume=", volumeNames(dockerCLI, cmd)), cobra.ShellCompDirectiveNoFileComp + } + + return postfixWith("=", eventFilters), cobra.ShellCompDirectiveNoSpace + } +} + +// prefixWith prefixes every element in the slice with the given prefix. +func prefixWith(prefix string, values []string) []string { + result := make([]string, len(values)) + for i, v := range values { + result[i] = prefix + v + } + return result +} + +// postfixWith appends postfix to every element in the slice. +func postfixWith(postfix string, values []string) []string { + result := make([]string, len(values)) + for i, v := range values { + result[i] = v + postfix + } + return result +} + +// eventTypeNames provides a list of all event types. +// The list is derived from eventTypes. +func eventTypeNames() []string { + names := make([]string, len(eventTypes)) + for i, eventType := range eventTypes { + names[i] = string(eventType) + } + return names +} + +// validEventNames provides a list of all event actions. +// The list is derived from eventActions. +// Actions that are not suitable for usage in completions are removed. +func validEventNames() []string { + names := []string{} + for _, eventAction := range eventActions { + if strings.Contains(string(eventAction), " ") { + continue + } + names = append(names, string(eventAction)) + } + return names +} + +// containerNames contacts the API to get names and optionally IDs of containers. +// In case of an error, an empty list is returned. +func containerNames(dockerCLI completion.APIClientProvider, cmd *cobra.Command, args []string, toComplete string) []string { + names, _ := completion.ContainerNames(dockerCLI, true)(cmd, args, toComplete) + if names == nil { + return []string{} + } + return names +} + +// daemonNames contacts the API to get name and ID of the current docker daemon. +// In case of an error, an empty list is returned. +func daemonNames(dockerCLI completion.APIClientProvider, cmd *cobra.Command) []string { + info, err := dockerCLI.Client().Info(cmd.Context()) + if err != nil { + return []string{} + } + return []string{info.Name, info.ID} +} + +// imageNames contacts the API to get a list of image names. +// In case of an error, an empty list is returned. +func imageNames(dockerCLI completion.APIClientProvider, cmd *cobra.Command) []string { + list, err := dockerCLI.Client().ImageList(cmd.Context(), image.ListOptions{}) + if err != nil { + return []string{} + } + names := []string{} + for _, img := range list { + names = append(names, img.RepoTags...) + } + return names +} + +// networkNames contacts the API to get a list of network names. +// In case of an error, an empty list is returned. +func networkNames(dockerCLI completion.APIClientProvider, cmd *cobra.Command) []string { + list, err := dockerCLI.Client().NetworkList(cmd.Context(), network.ListOptions{}) + if err != nil { + return []string{} + } + names := []string{} + for _, nw := range list { + names = append(names, nw.Name) + } + return names +} + +// nodeNames contacts the API to get a list of node names. +// In case of an error, an empty list is returned. +func nodeNames(dockerCLI completion.APIClientProvider, cmd *cobra.Command) []string { + list, err := dockerCLI.Client().NodeList(cmd.Context(), types.NodeListOptions{}) + if err != nil { + return []string{} + } + names := []string{} + for _, node := range list { + names = append(names, node.Description.Hostname) + } + return names +} + +// volumeNames contacts the API to get a list of volume names. +// In case of an error, an empty list is returned. +func volumeNames(dockerCLI completion.APIClientProvider, cmd *cobra.Command) []string { + list, err := dockerCLI.Client().VolumeList(cmd.Context(), volume.ListOptions{}) + if err != nil { + return []string{} + } + names := []string{} + for _, v := range list.Volumes { + names = append(names, v.Name) + } + return names +} diff --git a/cli/command/system/events.go b/cli/command/system/events.go index a54d30ffed38..9b5f965e5b3c 100644 --- a/cli/command/system/events.go +++ b/cli/command/system/events.go @@ -50,6 +50,8 @@ func NewEventsCommand(dockerCli command.Cli) *cobra.Command { flags.VarP(&options.filter, "filter", "f", "Filter output based on conditions provided") flags.StringVar(&options.format, "format", "", flagsHelper.InspectFormatHelp) // using the same flag description as "inspect" commands for now. + _ = cmd.RegisterFlagCompletionFunc("filter", completeEventFilters(dockerCli)) + return cmd } From 2926d0f8c5a7aa2a93421a3a7932c65f09daf9a7 Mon Sep 17 00:00:00 2001 From: Harald Albers Date: Sat, 19 Oct 2024 16:00:41 +0000 Subject: [PATCH 2/2] Add tests for completions that call the API Signed-off-by: Harald Albers --- cli/command/system/client_test.go | 68 +++++++++-- cli/command/system/completion_test.go | 165 ++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 cli/command/system/completion_test.go diff --git a/cli/command/system/client_test.go b/cli/command/system/client_test.go index b6eeb3bd9294..10334f808939 100644 --- a/cli/command/system/client_test.go +++ b/cli/command/system/client_test.go @@ -7,7 +7,11 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/events" "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/api/types/system" + "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" ) @@ -15,22 +19,27 @@ type fakeClient struct { client.Client version string - serverVersion func(ctx context.Context) (types.Version, error) - eventsFn func(context.Context, events.ListOptions) (<-chan events.Message, <-chan error) + containerListFunc func(context.Context, container.ListOptions) ([]container.Summary, error) containerPruneFunc func(ctx context.Context, pruneFilters filters.Args) (container.PruneReport, error) + eventsFn func(context.Context, events.ListOptions) (<-chan events.Message, <-chan error) + imageListFunc func(ctx context.Context, options image.ListOptions) ([]image.Summary, error) + infoFunc func(ctx context.Context) (system.Info, error) + networkListFunc func(ctx context.Context, options network.ListOptions) ([]network.Summary, error) networkPruneFunc func(ctx context.Context, pruneFilter filters.Args) (network.PruneReport, error) -} - -func (cli *fakeClient) ServerVersion(ctx context.Context) (types.Version, error) { - return cli.serverVersion(ctx) + nodeListFunc func(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error) + serverVersion func(ctx context.Context) (types.Version, error) + volumeListFunc func(ctx context.Context, options volume.ListOptions) (volume.ListResponse, error) } func (cli *fakeClient) ClientVersion() string { return cli.version } -func (cli *fakeClient) Events(ctx context.Context, opts events.ListOptions) (<-chan events.Message, <-chan error) { - return cli.eventsFn(ctx, opts) +func (cli *fakeClient) ContainerList(ctx context.Context, options container.ListOptions) ([]container.Summary, error) { + if cli.containerListFunc != nil { + return cli.containerListFunc(ctx, options) + } + return []container.Summary{}, nil } func (cli *fakeClient) ContainersPrune(ctx context.Context, pruneFilters filters.Args) (container.PruneReport, error) { @@ -40,9 +49,52 @@ func (cli *fakeClient) ContainersPrune(ctx context.Context, pruneFilters filters return container.PruneReport{}, nil } +func (cli *fakeClient) Events(ctx context.Context, opts events.ListOptions) (<-chan events.Message, <-chan error) { + return cli.eventsFn(ctx, opts) +} + +func (cli *fakeClient) ImageList(ctx context.Context, options image.ListOptions) ([]image.Summary, error) { + if cli.imageListFunc != nil { + return cli.imageListFunc(ctx, options) + } + return []image.Summary{}, nil +} + +func (cli *fakeClient) Info(ctx context.Context) (system.Info, error) { + if cli.infoFunc != nil { + return cli.infoFunc(ctx) + } + return system.Info{}, nil +} + +func (cli *fakeClient) NetworkList(ctx context.Context, options network.ListOptions) ([]network.Summary, error) { + if cli.networkListFunc != nil { + return cli.networkListFunc(ctx, options) + } + return []network.Summary{}, nil +} + func (cli *fakeClient) NetworksPrune(ctx context.Context, pruneFilter filters.Args) (network.PruneReport, error) { if cli.networkPruneFunc != nil { return cli.networkPruneFunc(ctx, pruneFilter) } return network.PruneReport{}, nil } + +func (cli *fakeClient) NodeList(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error) { + if cli.nodeListFunc != nil { + return cli.nodeListFunc(ctx, options) + } + return []swarm.Node{}, nil +} + +func (cli *fakeClient) ServerVersion(ctx context.Context) (types.Version, error) { + return cli.serverVersion(ctx) +} + +func (cli *fakeClient) VolumeList(ctx context.Context, options volume.ListOptions) (volume.ListResponse, error) { + if cli.volumeListFunc != nil { + return cli.volumeListFunc(ctx, options) + } + return volume.ListResponse{}, nil +} diff --git a/cli/command/system/completion_test.go b/cli/command/system/completion_test.go new file mode 100644 index 000000000000..e3961f466102 --- /dev/null +++ b/cli/command/system/completion_test.go @@ -0,0 +1,165 @@ +package system + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/docker/cli/internal/test" + "github.com/docker/cli/internal/test/builders" + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/api/types/system" + "github.com/docker/docker/api/types/volume" + "github.com/spf13/cobra" + "gotest.tools/v3/assert" +) + +func TestCompleteEventFilter(t *testing.T) { + tests := []struct { + client *fakeClient + toComplete string + expected []string + }{ + { + client: &fakeClient{ + containerListFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + return []container.Summary{ + *builders.Container("c1"), + *builders.Container("c2"), + }, nil + }, + }, + toComplete: "container=", + expected: []string{"container=c1", "container=c2"}, + }, + { + client: &fakeClient{ + containerListFunc: func(_ context.Context, _ container.ListOptions) ([]container.Summary, error) { + return nil, errors.New("API error") + }, + }, + toComplete: "container=", + expected: []string{}, + }, + { + client: &fakeClient{ + infoFunc: func(ctx context.Context) (system.Info, error) { + return system.Info{ + ID: "daemon-id", + Name: "daemon-name", + }, nil + }, + }, + toComplete: "daemon=", + expected: []string{"daemon=daemon-name", "daemon=daemon-id"}, + }, + { + client: &fakeClient{ + infoFunc: func(ctx context.Context) (system.Info, error) { + return system.Info{}, errors.New("API error") + }, + }, + toComplete: "daemon=", + expected: []string{}, + }, + { + client: &fakeClient{ + imageListFunc: func(_ context.Context, _ image.ListOptions) ([]image.Summary, error) { + return []image.Summary{ + {RepoTags: []string{"img:1"}}, + {RepoTags: []string{"img:2"}}, + }, nil + }, + }, + toComplete: "image=", + expected: []string{"image=img:1", "image=img:2"}, + }, + { + client: &fakeClient{ + imageListFunc: func(_ context.Context, _ image.ListOptions) ([]image.Summary, error) { + return []image.Summary{}, errors.New("API error") + }, + }, + toComplete: "image=", + expected: []string{}, + }, + { + client: &fakeClient{ + networkListFunc: func(_ context.Context, _ network.ListOptions) ([]network.Summary, error) { + return []network.Summary{ + *builders.NetworkResource(builders.NetworkResourceName("nw1")), + *builders.NetworkResource(builders.NetworkResourceName("nw2")), + }, nil + }, + }, + toComplete: "network=", + expected: []string{"network=nw1", "network=nw2"}, + }, + { + client: &fakeClient{ + networkListFunc: func(_ context.Context, _ network.ListOptions) ([]network.Summary, error) { + return nil, errors.New("API error") + }, + }, + toComplete: "network=", + expected: []string{}, + }, + { + client: &fakeClient{ + nodeListFunc: func(_ context.Context, _ types.NodeListOptions) ([]swarm.Node, error) { + return []swarm.Node{ + *builders.Node(builders.Hostname("n1")), + }, nil + }, + }, + toComplete: "node=", + expected: []string{"node=n1"}, + }, + { + client: &fakeClient{ + nodeListFunc: func(_ context.Context, _ types.NodeListOptions) ([]swarm.Node, error) { + return []swarm.Node{}, errors.New("API error") + }, + }, + toComplete: "node=", + expected: []string{}, + }, + { + client: &fakeClient{ + volumeListFunc: func(ctx context.Context, options volume.ListOptions) (volume.ListResponse, error) { + return volume.ListResponse{ + Volumes: []*volume.Volume{ + builders.Volume(builders.VolumeName("v1")), + builders.Volume(builders.VolumeName("v2")), + }, + }, nil + }, + }, + toComplete: "volume=", + expected: []string{"volume=v1", "volume=v2"}, + }, + { + client: &fakeClient{ + volumeListFunc: func(ctx context.Context, options volume.ListOptions) (volume.ListResponse, error) { + return volume.ListResponse{}, errors.New("API error") + }, + }, + toComplete: "volume=", + expected: []string{}, + }, + } + + for _, tc := range tests { + cli := test.NewFakeCli(tc.client) + + completions, directive := completeEventFilters(cli)(NewEventsCommand(cli), nil, tc.toComplete) + + assert.DeepEqual(t, completions, tc.expected) + assert.Equal(t, directive, cobra.ShellCompDirectiveNoFileComp, fmt.Sprintf("wrong directive in completion for '%s'", tc.toComplete)) + } +}