From 962015b05797194b94f8dbd79f60e442533d89db Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 28 Oct 2019 01:54:22 +0100 Subject: [PATCH 1/4] internal/builders: add GlobalService, ServiceStatus, NodeList() This patch: - Adds new GlobalService and ServiceStatus options - Makes the NodeList() function functional - Minor improvment to the `newService()` function to allow passing options Signed-off-by: Sebastiaan van Stijn --- cli/command/service/client_test.go | 12 +++++++----- internal/test/builders/service.go | 23 ++++++++++++++++++++++- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/cli/command/service/client_test.go b/cli/command/service/client_test.go index 8d0d592cec..b6b9f99058 100644 --- a/cli/command/service/client_test.go +++ b/cli/command/service/client_test.go @@ -6,6 +6,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/swarm" "github.com/docker/docker/client" + // Import builders to get the builder function as package function . "github.com/docker/cli/internal/test/builders" ) @@ -18,9 +19,13 @@ type fakeClient struct { taskListFunc func(context.Context, types.TaskListOptions) ([]swarm.Task, error) infoFunc func(ctx context.Context) (types.Info, error) networkInspectFunc func(ctx context.Context, networkID string, options types.NetworkInspectOptions) (types.NetworkResource, error) + nodeListFunc func(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error) } func (f *fakeClient) NodeList(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error) { + if f.nodeListFunc != nil { + return f.nodeListFunc(ctx, options) + } return nil, nil } @@ -69,9 +74,6 @@ func (f *fakeClient) NetworkInspect(ctx context.Context, networkID string, optio return types.NetworkResource{}, nil } -func newService(id string, name string) swarm.Service { - return swarm.Service{ - ID: id, - Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: name}}, - } +func newService(id string, name string, opts ...func(*swarm.Service)) swarm.Service { + return *Service(append(opts, ServiceID(id), ServiceName(name))...) } diff --git a/internal/test/builders/service.go b/internal/test/builders/service.go index 2817e6e2eb..ea2c95a301 100644 --- a/internal/test/builders/service.go +++ b/internal/test/builders/service.go @@ -46,10 +46,31 @@ func ServiceLabels(labels map[string]string) func(*swarm.Service) { } } -// ReplicatedService sets the number of replicas for the service +// GlobalService sets the service to use "global" mode +func GlobalService() func(*swarm.Service) { + return func(service *swarm.Service) { + service.Spec.Mode = swarm.ServiceMode{Global: &swarm.GlobalService{}} + } +} + +// ReplicatedService sets the service to use "replicated" mode with the specified number of replicas func ReplicatedService(replicas uint64) func(*swarm.Service) { return func(service *swarm.Service) { service.Spec.Mode = swarm.ServiceMode{Replicated: &swarm.ReplicatedService{Replicas: &replicas}} + if service.ServiceStatus == nil { + service.ServiceStatus = &swarm.ServiceStatus{} + } + service.ServiceStatus.DesiredTasks = replicas + } +} + +// ServiceStatus sets the services' ServiceStatus (API v1.41 and above) +func ServiceStatus(desired, running uint64) func(*swarm.Service) { + return func(service *swarm.Service) { + service.ServiceStatus = &swarm.ServiceStatus{ + RunningTasks: running, + DesiredTasks: desired, + } } } From 228e0f5e76d9bd0dbc922da0c8f8c86012cc8d24 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 28 Oct 2019 12:12:28 +0100 Subject: [PATCH 2/4] TestStackServicesErrors: use sub-tests, and return tasks This also sets the services to have a Mode set, otherwise they would be invalid. Signed-off-by: Sebastiaan van Stijn --- cli/command/stack/services_test.go | 33 ++++++++++++++++++------------ 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/cli/command/stack/services_test.go b/cli/command/stack/services_test.go index 64a58b9970..a54e967983 100644 --- a/cli/command/stack/services_test.go +++ b/cli/command/stack/services_test.go @@ -6,6 +6,7 @@ import ( "github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/internal/test" + // Import builders to get the builder function as package function . "github.com/docker/cli/internal/test/builders" "github.com/docker/docker/api/types" @@ -35,17 +36,20 @@ func TestStackServicesErrors(t *testing.T) { { args: []string{"foo"}, serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) { - return []swarm.Service{*Service()}, nil + return []swarm.Service{*Service(GlobalService())}, nil }, nodeListFunc: func(options types.NodeListOptions) ([]swarm.Node, error) { return nil, errors.Errorf("error getting nodes") }, + taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) { + return []swarm.Task{*Task()}, nil + }, expectedError: "error getting nodes", }, { args: []string{"foo"}, serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) { - return []swarm.Service{*Service()}, nil + return []swarm.Service{*Service(GlobalService())}, nil }, taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) { return nil, errors.Errorf("error getting tasks") @@ -65,18 +69,21 @@ func TestStackServicesErrors(t *testing.T) { } for _, tc := range testCases { - cli := test.NewFakeCli(&fakeClient{ - serviceListFunc: tc.serviceListFunc, - nodeListFunc: tc.nodeListFunc, - taskListFunc: tc.taskListFunc, + tc := tc + t.Run(tc.expectedError, func(t *testing.T) { + cli := test.NewFakeCli(&fakeClient{ + serviceListFunc: tc.serviceListFunc, + nodeListFunc: tc.nodeListFunc, + taskListFunc: tc.taskListFunc, + }) + cmd := newServicesCommand(cli, &orchestrator) + cmd.SetArgs(tc.args) + for key, value := range tc.flags { + cmd.Flags().Set(key, value) + } + cmd.SetOutput(ioutil.Discard) + assert.ErrorContains(t, cmd.Execute(), tc.expectedError) }) - cmd := newServicesCommand(cli, &orchestrator) - cmd.SetArgs(tc.args) - for key, value := range tc.flags { - cmd.Flags().Set(key, value) - } - cmd.SetOutput(ioutil.Discard) - assert.ErrorContains(t, cmd.Execute(), tc.expectedError) } } From 7405ac5c2d63c5db909a6c5093bb4304f376d9af Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 24 Oct 2019 00:16:35 +0200 Subject: [PATCH 3/4] Services: use ServiceStatus on API v1.41 and up API v1.41 adds a new option to get the number of desired and running tasks when listing services. This patch enables this functionality, and provides a fallback mechanism when the ServiceStatus is not available, which would be when using an older API version. Now that the swarm.Service struct captures this information, the `ListInfo` type is no longer needed, so it is removed, and the related list- and formatting functions have been modified accordingly. To reduce repetition, sorting the services has been moved to the formatter. This is a slight change in behavior, but all calls to the formatter performed this sort first, so the change will not lead to user-facing changes. Signed-off-by: Sebastiaan van Stijn --- cli/command/service/formatter.go | 46 +++-- cli/command/service/formatter_test.go | 172 +++++++++++----- cli/command/service/list.go | 186 +++++++++++------- cli/command/service/list_test.go | 1 + .../testdata/service-context-write-raw.golden | 30 ++- cli/command/stack/kubernetes/conversion.go | 61 ++++-- .../stack/kubernetes/conversion_test.go | 122 +++++++----- cli/command/stack/kubernetes/services.go | 8 +- cli/command/stack/swarm/services.go | 58 +++--- 9 files changed, 446 insertions(+), 238 deletions(-) diff --git a/cli/command/service/formatter.go b/cli/command/service/formatter.go index 3ebe52d520..311355618d 100644 --- a/cli/command/service/formatter.go +++ b/cli/command/service/formatter.go @@ -16,6 +16,7 @@ import ( "github.com/docker/docker/pkg/stringid" units "github.com/docker/go-units" "github.com/pkg/errors" + "vbom.ml/util/sortorder" ) const serviceInspectPrettyTemplate formatter.Format = ` @@ -520,17 +521,14 @@ func NewListFormat(source string, quiet bool) formatter.Format { return formatter.Format(source) } -// ListInfo stores the information about mode and replicas to be used by template -type ListInfo struct { - Mode string - Replicas string -} - // ListFormatWrite writes the context -func ListFormatWrite(ctx formatter.Context, services []swarm.Service, info map[string]ListInfo) error { +func ListFormatWrite(ctx formatter.Context, services []swarm.Service) error { render := func(format func(subContext formatter.SubContext) error) error { + sort.Slice(services, func(i, j int) bool { + return sortorder.NaturalLess(services[i].Spec.Name, services[j].Spec.Name) + }) for _, service := range services { - serviceCtx := &serviceContext{service: service, mode: info[service.ID].Mode, replicas: info[service.ID].Replicas} + serviceCtx := &serviceContext{service: service} if err := format(serviceCtx); err != nil { return err } @@ -551,9 +549,7 @@ func ListFormatWrite(ctx formatter.Context, services []swarm.Service, info map[s type serviceContext struct { formatter.HeaderContext - service swarm.Service - mode string - replicas string + service swarm.Service } func (c *serviceContext) MarshalJSON() ([]byte, error) { @@ -569,11 +565,35 @@ func (c *serviceContext) Name() string { } func (c *serviceContext) Mode() string { - return c.mode + switch { + case c.service.Spec.Mode.Global != nil: + return "global" + case c.service.Spec.Mode.Replicated != nil: + return "replicated" + default: + return "" + } } func (c *serviceContext) Replicas() string { - return c.replicas + s := &c.service + + var running, desired uint64 + if s.ServiceStatus != nil { + running = c.service.ServiceStatus.RunningTasks + desired = c.service.ServiceStatus.DesiredTasks + } + if r := c.maxReplicas(); r > 0 { + return fmt.Sprintf("%d/%d (max %d per node)", running, desired, r) + } + return fmt.Sprintf("%d/%d", running, desired) +} + +func (c *serviceContext) maxReplicas() uint64 { + if c.Mode() != "replicated" || c.service.Spec.TaskTemplate.Placement == nil { + return 0 + } + return c.service.Spec.TaskTemplate.Placement.MaxReplicas } func (c *serviceContext) Image() string { diff --git a/cli/command/service/formatter_test.go b/cli/command/service/formatter_test.go index 8addeba0c7..10b7923c71 100644 --- a/cli/command/service/formatter_test.go +++ b/cli/command/service/formatter_test.go @@ -33,29 +33,37 @@ func TestServiceContextWrite(t *testing.T) { // Table format { formatter.Context{Format: NewListFormat("table", false)}, - `ID NAME MODE REPLICAS IMAGE PORTS -id_baz baz global 2/4 *:80->8080/tcp -id_bar bar replicated 2/4 *:80->8080/tcp + `ID NAME MODE REPLICAS IMAGE PORTS +02_bar bar replicated 2/4 *:80->8090/udp +01_baz baz global 1/3 *:80->8080/tcp +04_qux2 qux2 replicated 3/3 (max 2 per node) +03_qux10 qux10 replicated 2/3 (max 1 per node) `, }, { formatter.Context{Format: NewListFormat("table", true)}, - `id_baz -id_bar + `02_bar +01_baz +04_qux2 +03_qux10 `, }, { - formatter.Context{Format: NewListFormat("table {{.Name}}", false)}, - `NAME -baz -bar + formatter.Context{Format: NewListFormat("table {{.Name}}\t{{.Mode}}", false)}, + `NAME MODE +bar replicated +baz global +qux2 replicated +qux10 replicated `, }, { formatter.Context{Format: NewListFormat("table {{.Name}}", true)}, `NAME -baz bar +baz +qux2 +qux10 `, }, // Raw Format @@ -65,15 +73,19 @@ bar }, { formatter.Context{Format: NewListFormat("raw", true)}, - `id: id_baz -id: id_bar + `id: 02_bar +id: 01_baz +id: 04_qux2 +id: 03_qux10 `, }, // Custom Format { formatter.Context{Format: NewListFormat("{{.Name}}", false)}, - `baz -bar + `bar +baz +qux2 +qux10 `, }, } @@ -81,9 +93,12 @@ bar for _, testcase := range cases { services := []swarm.Service{ { - ID: "id_baz", + ID: "01_baz", Spec: swarm.ServiceSpec{ Annotations: swarm.Annotations{Name: "baz"}, + Mode: swarm.ServiceMode{ + Global: &swarm.GlobalService{}, + }, }, Endpoint: swarm.Endpoint{ Ports: []swarm.PortConfig{ @@ -95,37 +110,70 @@ bar }, }, }, + ServiceStatus: &swarm.ServiceStatus{ + RunningTasks: 1, + DesiredTasks: 3, + }, }, { - ID: "id_bar", + ID: "02_bar", Spec: swarm.ServiceSpec{ Annotations: swarm.Annotations{Name: "bar"}, + Mode: swarm.ServiceMode{ + Replicated: &swarm.ReplicatedService{}, + }, }, Endpoint: swarm.Endpoint{ Ports: []swarm.PortConfig{ { PublishMode: "ingress", PublishedPort: 80, - TargetPort: 8080, - Protocol: "tcp", + TargetPort: 8090, + Protocol: "udp", }, }, }, + ServiceStatus: &swarm.ServiceStatus{ + RunningTasks: 2, + DesiredTasks: 4, + }, }, - } - info := map[string]ListInfo{ - "id_baz": { - Mode: "global", - Replicas: "2/4", + { + ID: "03_qux10", + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "qux10"}, + Mode: swarm.ServiceMode{ + Replicated: &swarm.ReplicatedService{}, + }, + TaskTemplate: swarm.TaskSpec{ + Placement: &swarm.Placement{MaxReplicas: 1}, + }, + }, + ServiceStatus: &swarm.ServiceStatus{ + RunningTasks: 2, + DesiredTasks: 3, + }, }, - "id_bar": { - Mode: "replicated", - Replicas: "2/4", + { + ID: "04_qux2", + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "qux2"}, + Mode: swarm.ServiceMode{ + Replicated: &swarm.ReplicatedService{}, + }, + TaskTemplate: swarm.TaskSpec{ + Placement: &swarm.Placement{MaxReplicas: 2}, + }, + }, + ServiceStatus: &swarm.ServiceStatus{ + RunningTasks: 3, + DesiredTasks: 3, + }, }, } out := bytes.NewBufferString("") testcase.context.Output = out - err := ListFormatWrite(testcase.context, services, info) + err := ListFormatWrite(testcase.context, services) if err != nil { assert.Error(t, err, testcase.expected) } else { @@ -137,9 +185,12 @@ bar func TestServiceContextWriteJSON(t *testing.T) { services := []swarm.Service{ { - ID: "id_baz", + ID: "01_baz", Spec: swarm.ServiceSpec{ Annotations: swarm.Annotations{Name: "baz"}, + Mode: swarm.ServiceMode{ + Global: &swarm.GlobalService{}, + }, }, Endpoint: swarm.Endpoint{ Ports: []swarm.PortConfig{ @@ -151,11 +202,18 @@ func TestServiceContextWriteJSON(t *testing.T) { }, }, }, + ServiceStatus: &swarm.ServiceStatus{ + RunningTasks: 1, + DesiredTasks: 3, + }, }, { - ID: "id_bar", + ID: "02_bar", Spec: swarm.ServiceSpec{ Annotations: swarm.Annotations{Name: "bar"}, + Mode: swarm.ServiceMode{ + Replicated: &swarm.ReplicatedService{}, + }, }, Endpoint: swarm.Endpoint{ Ports: []swarm.PortConfig{ @@ -167,25 +225,19 @@ func TestServiceContextWriteJSON(t *testing.T) { }, }, }, - }, - } - info := map[string]ListInfo{ - "id_baz": { - Mode: "global", - Replicas: "2/4", - }, - "id_bar": { - Mode: "replicated", - Replicas: "2/4", + ServiceStatus: &swarm.ServiceStatus{ + RunningTasks: 2, + DesiredTasks: 4, + }, }, } expectedJSONs := []map[string]interface{}{ - {"ID": "id_baz", "Name": "baz", "Mode": "global", "Replicas": "2/4", "Image": "", "Ports": "*:80->8080/tcp"}, - {"ID": "id_bar", "Name": "bar", "Mode": "replicated", "Replicas": "2/4", "Image": "", "Ports": "*:80->8080/tcp"}, + {"ID": "02_bar", "Name": "bar", "Mode": "replicated", "Replicas": "2/4", "Image": "", "Ports": "*:80->8080/tcp"}, + {"ID": "01_baz", "Name": "baz", "Mode": "global", "Replicas": "1/3", "Image": "", "Ports": "*:80->8080/tcp"}, } out := bytes.NewBufferString("") - err := ListFormatWrite(formatter.Context{Format: "{{json .}}", Output: out}, services, info) + err := ListFormatWrite(formatter.Context{Format: "{{json .}}", Output: out}, services) if err != nil { t.Fatal(err) } @@ -199,21 +251,35 @@ func TestServiceContextWriteJSON(t *testing.T) { } func TestServiceContextWriteJSONField(t *testing.T) { services := []swarm.Service{ - {ID: "id_baz", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "baz"}}}, - {ID: "id_bar", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "bar"}}}, - } - info := map[string]ListInfo{ - "id_baz": { - Mode: "global", - Replicas: "2/4", + { + ID: "01_baz", + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "baz"}, + Mode: swarm.ServiceMode{ + Global: &swarm.GlobalService{}, + }, + }, + ServiceStatus: &swarm.ServiceStatus{ + RunningTasks: 2, + DesiredTasks: 4, + }, }, - "id_bar": { - Mode: "replicated", - Replicas: "2/4", + { + ID: "24_bar", + Spec: swarm.ServiceSpec{ + Annotations: swarm.Annotations{Name: "bar"}, + Mode: swarm.ServiceMode{ + Replicated: &swarm.ReplicatedService{}, + }, + }, + ServiceStatus: &swarm.ServiceStatus{ + RunningTasks: 2, + DesiredTasks: 4, + }, }, } out := bytes.NewBufferString("") - err := ListFormatWrite(formatter.Context{Format: "{{json .Name}}", Output: out}, services, info) + err := ListFormatWrite(formatter.Context{Format: "{{json .Name}}", Output: out}, services) if err != nil { t.Fatal(err) } diff --git a/cli/command/service/list.go b/cli/command/service/list.go index 90fe330102..61e414c6b7 100644 --- a/cli/command/service/list.go +++ b/cli/command/service/list.go @@ -2,10 +2,6 @@ package service import ( "context" - "fmt" - "sort" - - "vbom.ml/util/sortorder" "github.com/docker/cli/cli" "github.com/docker/cli/cli/command" @@ -14,6 +10,7 @@ import ( "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/client" "github.com/spf13/cobra" ) @@ -44,43 +41,49 @@ func newListCommand(dockerCli command.Cli) *cobra.Command { return cmd } -func runList(dockerCli command.Cli, options listOptions) error { - ctx := context.Background() - client := dockerCli.Client() +func runList(dockerCli command.Cli, opts listOptions) error { + var ( + apiClient = dockerCli.Client() + ctx = context.Background() + err error + ) - serviceFilters := options.filter.Value() - services, err := client.ServiceList(ctx, types.ServiceListOptions{Filters: serviceFilters}) + listOpts := types.ServiceListOptions{ + Filters: opts.filter.Value(), + // When not running "quiet", also get service status (number of running + // and desired tasks). Note that this is only supported on API v1.41 and + // up; older API versions ignore this option, and we will have to collect + // the information manually below. + Status: !opts.quiet, + } + + services, err := apiClient.ServiceList(ctx, listOpts) if err != nil { return err } - sort.Slice(services, func(i, j int) bool { - return sortorder.NaturalLess(services[i].Spec.Name, services[j].Spec.Name) - }) - info := map[string]ListInfo{} - if len(services) > 0 && !options.quiet { - // only non-empty services and not quiet, should we call TaskList and NodeList api - taskFilter := filters.NewArgs() - for _, service := range services { - taskFilter.Add("service", service.ID) - } - - tasks, err := client.TaskList(ctx, types.TaskListOptions{Filters: taskFilter}) + if listOpts.Status { + // Now that a request was made, we know what API version was used (either + // through configuration, or after client and daemon negotiated a version). + // If API version v1.41 or up was used; the daemon should already have done + // the legwork for us, and we don't have to calculate the number of desired + // and running tasks. On older API versions, we need to do some extra requests + // to get that information. + // + // So theoretically, this step can be skipped based on API version, however, + // some of our unit tests don't set the API version, and there may be other + // situations where the client uses the "default" version. To account for + // these situations, we do a quick check for services that do not have + // a ServiceStatus set, and perform a lookup for those. + services, err = AppendServiceStatus(ctx, apiClient, services) if err != nil { return err } - - nodes, err := client.NodeList(ctx, types.NodeListOptions{}) - if err != nil { - return err - } - - info = GetServicesStatus(services, nodes, tasks) } - format := options.format + format := opts.format if len(format) == 0 { - if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !options.quiet { + if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !opts.quiet { format = dockerCli.ConfigFile().ServicesFormat } else { format = formatter.TableFormatKey @@ -89,54 +92,97 @@ func runList(dockerCli command.Cli, options listOptions) error { servicesCtx := formatter.Context{ Output: dockerCli.Out(), - Format: NewListFormat(format, options.quiet), + Format: NewListFormat(format, opts.quiet), } - return ListFormatWrite(servicesCtx, services, info) + return ListFormatWrite(servicesCtx, services) } -// GetServicesStatus returns a map of mode and replicas -func GetServicesStatus(services []swarm.Service, nodes []swarm.Node, tasks []swarm.Task) map[string]ListInfo { - running := map[string]int{} - tasksNoShutdown := map[string]int{} +// AppendServiceStatus propagates the ServiceStatus field for "services". +// +// If API version v1.41 or up is used, this information is already set by the +// daemon. On older API versions, we need to do some extra requests to get +// that information. Theoretically, this function can be skipped based on API +// version, however, some of our unit tests don't set the API version, and +// there may be other situations where the client uses the "default" version. +// To take these situations into account, we do a quick check for services +// that don't have ServiceStatus set, and perform a lookup for those. +// nolint: gocyclo +func AppendServiceStatus(ctx context.Context, c client.APIClient, services []swarm.Service) ([]swarm.Service, error) { + status := map[string]*swarm.ServiceStatus{} + taskFilter := filters.NewArgs() + for i, s := range services { + switch { + case s.ServiceStatus != nil: + // Server already returned service-status, so we don't + // have to look-up tasks for this service. + continue + case s.Spec.Mode.Replicated != nil: + // For replicated services, set the desired number of tasks; + // that way we can present this information in case we're unable + // to get a list of tasks from the server. + services[i].ServiceStatus = &swarm.ServiceStatus{DesiredTasks: *s.Spec.Mode.Replicated.Replicas} + status[s.ID] = &swarm.ServiceStatus{} + taskFilter.Add("service", s.ID) + case s.Spec.Mode.Global != nil: + // No such thing as number of desired tasks for global services + services[i].ServiceStatus = &swarm.ServiceStatus{} + status[s.ID] = &swarm.ServiceStatus{} + taskFilter.Add("service", s.ID) + default: + // Unknown task type + } + } + if len(status) == 0 { + // All services have their ServiceStatus set, so we're done + return services, nil + } + tasks, err := c.TaskList(ctx, types.TaskListOptions{Filters: taskFilter}) + if err != nil { + return nil, err + } + if len(tasks) == 0 { + return services, nil + } + activeNodes, err := getActiveNodes(ctx, c) + if err != nil { + return nil, err + } + + for _, task := range tasks { + if status[task.ServiceID] == nil { + // This should not happen in practice; either all services have + // a ServiceStatus set, or none of them. + continue + } + // TODO: this should only be needed for "global" services. Replicated + // services have `Spec.Mode.Replicated.Replicas`, which should give this value. + if task.DesiredState != swarm.TaskStateShutdown { + status[task.ServiceID].DesiredTasks++ + } + if _, nodeActive := activeNodes[task.NodeID]; nodeActive && task.Status.State == swarm.TaskStateRunning { + status[task.ServiceID].RunningTasks++ + } + } + + for i, service := range services { + if s := status[service.ID]; s != nil { + services[i].ServiceStatus = s + } + } + return services, nil +} + +func getActiveNodes(ctx context.Context, c client.NodeAPIClient) (map[string]struct{}, error) { + nodes, err := c.NodeList(ctx, types.NodeListOptions{}) + if err != nil { + return nil, err + } activeNodes := make(map[string]struct{}) for _, n := range nodes { if n.Status.State != swarm.NodeStateDown { activeNodes[n.ID] = struct{}{} } } - - for _, task := range tasks { - if task.DesiredState != swarm.TaskStateShutdown { - tasksNoShutdown[task.ServiceID]++ - } - - if _, nodeActive := activeNodes[task.NodeID]; nodeActive && task.Status.State == swarm.TaskStateRunning { - running[task.ServiceID]++ - } - } - - info := map[string]ListInfo{} - for _, service := range services { - info[service.ID] = ListInfo{} - if service.Spec.Mode.Replicated != nil && service.Spec.Mode.Replicated.Replicas != nil { - if service.Spec.TaskTemplate.Placement != nil && service.Spec.TaskTemplate.Placement.MaxReplicas > 0 { - info[service.ID] = ListInfo{ - Mode: "replicated", - Replicas: fmt.Sprintf("%d/%d (max %d per node)", running[service.ID], *service.Spec.Mode.Replicated.Replicas, service.Spec.TaskTemplate.Placement.MaxReplicas), - } - } else { - info[service.ID] = ListInfo{ - Mode: "replicated", - Replicas: fmt.Sprintf("%d/%d", running[service.ID], *service.Spec.Mode.Replicated.Replicas), - } - } - } else if service.Spec.Mode.Global != nil { - info[service.ID] = ListInfo{ - Mode: "global", - Replicas: fmt.Sprintf("%d/%d", running[service.ID], tasksNoShutdown[service.ID]), - } - } - } - return info + return activeNodes, nil } diff --git a/cli/command/service/list_test.go b/cli/command/service/list_test.go index e52e7e03c2..3bc46e6d62 100644 --- a/cli/command/service/list_test.go +++ b/cli/command/service/list_test.go @@ -22,6 +22,7 @@ func TestServiceListOrder(t *testing.T) { }, }) cmd := newListCommand(cli) + cmd.SetArgs([]string{}) cmd.Flags().Set("format", "{{.Name}}") assert.NilError(t, cmd.Execute()) golden.Assert(t, cli.OutBuffer().String(), "service-list-sort.golden") diff --git a/cli/command/service/testdata/service-context-write-raw.golden b/cli/command/service/testdata/service-context-write-raw.golden index d62b9a2440..feb100c9d7 100644 --- a/cli/command/service/testdata/service-context-write-raw.golden +++ b/cli/command/service/testdata/service-context-write-raw.golden @@ -1,14 +1,28 @@ -id: id_baz -name: baz -mode: global -replicas: 2/4 -image: -ports: *:80->8080/tcp - -id: id_bar +id: 02_bar name: bar mode: replicated replicas: 2/4 image: +ports: *:80->8090/udp + +id: 01_baz +name: baz +mode: global +replicas: 1/3 +image: ports: *:80->8080/tcp +id: 04_qux2 +name: qux2 +mode: replicated +replicas: 3/3 (max 2 per node) +image: +ports: + +id: 03_qux10 +name: qux10 +mode: replicated +replicas: 2/3 (max 1 per node) +image: +ports: + diff --git a/cli/command/stack/kubernetes/conversion.go b/cli/command/stack/kubernetes/conversion.go index e1fdec7174..14797b081d 100644 --- a/cli/command/stack/kubernetes/conversion.go +++ b/cli/command/stack/kubernetes/conversion.go @@ -6,7 +6,6 @@ import ( "strings" "time" - "github.com/docker/cli/cli/command/service" "github.com/docker/compose-on-kubernetes/api/labels" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/swarm" @@ -154,35 +153,65 @@ const ( publishedOnRandomPortSuffix = "-random-ports" ) -func convertToServices(replicas *appsv1beta2.ReplicaSetList, daemons *appsv1beta2.DaemonSetList, services *apiv1.ServiceList) ([]swarm.Service, map[string]service.ListInfo, error) { +func convertToServices(replicas *appsv1beta2.ReplicaSetList, daemons *appsv1beta2.DaemonSetList, services *apiv1.ServiceList) ([]swarm.Service, error) { result := make([]swarm.Service, len(replicas.Items)) - infos := make(map[string]service.ListInfo, len(replicas.Items)+len(daemons.Items)) + for i, r := range replicas.Items { - s, err := convertToService(r.Labels[labels.ForServiceName], services, r.Spec.Template.Spec.Containers) + s, err := replicatedService(r, services) if err != nil { - return nil, nil, err + return nil, err } result[i] = *s - infos[s.ID] = service.ListInfo{ - Mode: "replicated", - Replicas: fmt.Sprintf("%d/%d", r.Status.AvailableReplicas, r.Status.Replicas), - } } for _, d := range daemons.Items { - s, err := convertToService(d.Labels[labels.ForServiceName], services, d.Spec.Template.Spec.Containers) + s, err := globalService(d, services) if err != nil { - return nil, nil, err + return nil, err } result = append(result, *s) - infos[s.ID] = service.ListInfo{ - Mode: "global", - Replicas: fmt.Sprintf("%d/%d", d.Status.NumberReady, d.Status.DesiredNumberScheduled), - } } sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) - return result, infos, nil + return result, nil +} + +func uint64ptr(i int32) *uint64 { + var o uint64 + if i > 0 { + o = uint64(i) + } + return &o +} + +func replicatedService(r appsv1beta2.ReplicaSet, services *apiv1.ServiceList) (*swarm.Service, error) { + s, err := convertToService(r.Labels[labels.ForServiceName], services, r.Spec.Template.Spec.Containers) + if err != nil { + return nil, err + } + s.Spec.Mode = swarm.ServiceMode{ + Replicated: &swarm.ReplicatedService{Replicas: uint64ptr(r.Status.Replicas)}, + } + s.ServiceStatus = &swarm.ServiceStatus{ + RunningTasks: uint64(r.Status.AvailableReplicas), + DesiredTasks: uint64(r.Status.Replicas), + } + return s, nil +} + +func globalService(d appsv1beta2.DaemonSet, services *apiv1.ServiceList) (*swarm.Service, error) { + s, err := convertToService(d.Labels[labels.ForServiceName], services, d.Spec.Template.Spec.Containers) + if err != nil { + return nil, err + } + s.Spec.Mode = swarm.ServiceMode{ + Global: &swarm.GlobalService{}, + } + s.ServiceStatus = &swarm.ServiceStatus{ + RunningTasks: uint64(d.Status.NumberReady), + DesiredTasks: uint64(d.Status.DesiredNumberScheduled), + } + return s, nil } func convertToService(serviceName string, services *apiv1.ServiceList, containers []apiv1.Container) (*swarm.Service, error) { diff --git a/cli/command/stack/kubernetes/conversion_test.go b/cli/command/stack/kubernetes/conversion_test.go index 3a5bd962c4..ba3368d86c 100644 --- a/cli/command/stack/kubernetes/conversion_test.go +++ b/cli/command/stack/kubernetes/conversion_test.go @@ -3,7 +3,6 @@ package kubernetes import ( "testing" - "github.com/docker/cli/cli/command/service" "github.com/docker/compose-on-kubernetes/api/labels" "github.com/docker/docker/api/types/swarm" "gotest.tools/assert" @@ -19,49 +18,45 @@ func TestReplicasConversionNeedsAService(t *testing.T) { Items: []appsv1beta2.ReplicaSet{makeReplicaSet("unknown", 0, 0)}, } services := apiv1.ServiceList{} - _, _, err := convertToServices(&replicas, &appsv1beta2.DaemonSetList{}, &services) + _, err := convertToServices(&replicas, &appsv1beta2.DaemonSetList{}, &services) assert.ErrorContains(t, err, "could not find service") } func TestKubernetesServiceToSwarmServiceConversion(t *testing.T) { testCases := []struct { + doc string replicas *appsv1beta2.ReplicaSetList services *apiv1.ServiceList expectedServices []swarm.Service - expectedListInfo map[string]service.ListInfo }{ - // Match replicas with headless stack services { - &appsv1beta2.ReplicaSetList{ + doc: "Match replicas with headless stack services", + replicas: &appsv1beta2.ReplicaSetList{ Items: []appsv1beta2.ReplicaSet{ makeReplicaSet("service1", 2, 5), makeReplicaSet("service2", 3, 3), }, }, - &apiv1.ServiceList{ + services: &apiv1.ServiceList{ Items: []apiv1.Service{ makeKubeService("service1", "stack", "uid1", apiv1.ServiceTypeClusterIP, nil), makeKubeService("service2", "stack", "uid2", apiv1.ServiceTypeClusterIP, nil), makeKubeService("service3", "other-stack", "uid2", apiv1.ServiceTypeClusterIP, nil), }, }, - []swarm.Service{ - makeSwarmService("stack_service1", "uid1", nil), - makeSwarmService("stack_service2", "uid2", nil), - }, - map[string]service.ListInfo{ - "uid1": {Mode: "replicated", Replicas: "2/5"}, - "uid2": {Mode: "replicated", Replicas: "3/3"}, + expectedServices: []swarm.Service{ + makeSwarmService(t, "stack_service1", "uid1", withMode("replicated", 5), withStatus(2, 5)), + makeSwarmService(t, "stack_service2", "uid2", withMode("replicated", 3), withStatus(3, 3)), }, }, - // Headless service and LoadBalancer Service are tied to the same Swarm service { - &appsv1beta2.ReplicaSetList{ + doc: "Headless service and LoadBalancer Service are tied to the same Swarm service", + replicas: &appsv1beta2.ReplicaSetList{ Items: []appsv1beta2.ReplicaSet{ makeReplicaSet("service", 1, 1), }, }, - &apiv1.ServiceList{ + services: &apiv1.ServiceList{ Items: []apiv1.Service{ makeKubeService("service", "stack", "uid1", apiv1.ServiceTypeClusterIP, nil), makeKubeService("service-published", "stack", "uid2", apiv1.ServiceTypeLoadBalancer, []apiv1.ServicePort{ @@ -73,29 +68,26 @@ func TestKubernetesServiceToSwarmServiceConversion(t *testing.T) { }), }, }, - []swarm.Service{ - makeSwarmService("stack_service", "uid1", []swarm.PortConfig{ - { + expectedServices: []swarm.Service{ + makeSwarmService(t, "stack_service", "uid1", + withMode("replicated", 1), + withStatus(1, 1), withPort(swarm.PortConfig{ PublishMode: swarm.PortConfigPublishModeIngress, PublishedPort: 80, TargetPort: 80, Protocol: swarm.PortConfigProtocolTCP, - }, - }), - }, - map[string]service.ListInfo{ - "uid1": {Mode: "replicated", Replicas: "1/1"}, + }), + ), }, }, - // Headless service and NodePort Service are tied to the same Swarm service - { - &appsv1beta2.ReplicaSetList{ + doc: "Headless service and NodePort Service are tied to the same Swarm service", + replicas: &appsv1beta2.ReplicaSetList{ Items: []appsv1beta2.ReplicaSet{ makeReplicaSet("service", 1, 1), }, }, - &apiv1.ServiceList{ + services: &apiv1.ServiceList{ Items: []apiv1.Service{ makeKubeService("service", "stack", "uid1", apiv1.ServiceTypeClusterIP, nil), makeKubeService("service-random-ports", "stack", "uid2", apiv1.ServiceTypeNodePort, []apiv1.ServicePort{ @@ -107,27 +99,28 @@ func TestKubernetesServiceToSwarmServiceConversion(t *testing.T) { }), }, }, - []swarm.Service{ - makeSwarmService("stack_service", "uid1", []swarm.PortConfig{ - { + expectedServices: []swarm.Service{ + makeSwarmService(t, "stack_service", "uid1", + withMode("replicated", 1), + withStatus(1, 1), + withPort(swarm.PortConfig{ PublishMode: swarm.PortConfigPublishModeHost, PublishedPort: 35666, TargetPort: 80, Protocol: swarm.PortConfigProtocolTCP, - }, - }), - }, - map[string]service.ListInfo{ - "uid1": {Mode: "replicated", Replicas: "1/1"}, + }), + ), }, }, } for _, tc := range testCases { - swarmServices, listInfo, err := convertToServices(tc.replicas, &appsv1beta2.DaemonSetList{}, tc.services) - assert.NilError(t, err) - assert.DeepEqual(t, tc.expectedServices, swarmServices) - assert.DeepEqual(t, tc.expectedListInfo, listInfo) + tc := tc + t.Run(tc.doc, func(t *testing.T) { + swarmServices, err := convertToServices(tc.replicas, &appsv1beta2.DaemonSetList{}, tc.services) + assert.NilError(t, err) + assert.DeepEqual(t, tc.expectedServices, swarmServices) + }) } } @@ -172,8 +165,46 @@ func makeKubeService(service, stack, uid string, serviceType apiv1.ServiceType, } } -func makeSwarmService(service, id string, ports []swarm.PortConfig) swarm.Service { - return swarm.Service{ +func withMode(mode string, replicas uint64) func(*swarm.Service) { + return func(service *swarm.Service) { + switch mode { + case "global": + service.Spec.Mode = swarm.ServiceMode{ + Global: &swarm.GlobalService{}, + } + case "replicated": + service.Spec.Mode = swarm.ServiceMode{ + Replicated: &swarm.ReplicatedService{Replicas: &replicas}, + } + withStatus(0, replicas) + default: + service.Spec.Mode = swarm.ServiceMode{} + withStatus(0, 0) + } + } +} + +func withPort(port swarm.PortConfig) func(*swarm.Service) { + return func(service *swarm.Service) { + if service.Endpoint.Ports == nil { + service.Endpoint.Ports = make([]swarm.PortConfig, 0) + } + service.Endpoint.Ports = append(service.Endpoint.Ports, port) + } +} + +func withStatus(running, desired uint64) func(*swarm.Service) { + return func(service *swarm.Service) { + service.ServiceStatus = &swarm.ServiceStatus{ + RunningTasks: running, + DesiredTasks: desired, + } + } +} + +func makeSwarmService(t *testing.T, service, id string, opts ...func(*swarm.Service)) swarm.Service { + t.Helper() + s := swarm.Service{ ID: id, Spec: swarm.ServiceSpec{ Annotations: swarm.Annotations{ @@ -185,8 +216,9 @@ func makeSwarmService(service, id string, ports []swarm.PortConfig) swarm.Servic }, }, }, - Endpoint: swarm.Endpoint{ - Ports: ports, - }, } + for _, o := range opts { + o(&s) + } + return s } diff --git a/cli/command/stack/kubernetes/services.go b/cli/command/stack/kubernetes/services.go index 8f91b7e279..952c439b16 100644 --- a/cli/command/stack/kubernetes/services.go +++ b/cli/command/stack/kubernetes/services.go @@ -109,16 +109,12 @@ func RunServices(dockerCli *KubeCli, opts options.Services) error { } // Convert Replicas sets and kubernetes services to swarm services and formatter information - services, info, err := convertToServices(replicasList, daemonsList, servicesList) + services, err := convertToServices(replicasList, daemonsList, servicesList) if err != nil { return err } services = filterServicesByName(services, filters.Get("name"), stackName) - if opts.Quiet { - info = map[string]service.ListInfo{} - } - format := opts.Format if len(format) == 0 { if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !opts.Quiet { @@ -132,7 +128,7 @@ func RunServices(dockerCli *KubeCli, opts options.Services) error { Output: dockerCli.Out(), Format: service.NewListFormat(format, opts.Quiet), } - return service.ListFormatWrite(servicesCtx, services, info) + return service.ListFormatWrite(servicesCtx, services) } func filterServicesByName(services []swarm.Service, names []string, stackName string) []swarm.Service { diff --git a/cli/command/stack/swarm/services.go b/cli/command/stack/swarm/services.go index 15a48e7893..faa54c78da 100644 --- a/cli/command/stack/swarm/services.go +++ b/cli/command/stack/swarm/services.go @@ -3,55 +3,59 @@ package swarm import ( "context" "fmt" - "sort" "github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command/service" "github.com/docker/cli/cli/command/stack/formatter" "github.com/docker/cli/cli/command/stack/options" "github.com/docker/docker/api/types" - "github.com/docker/docker/api/types/filters" - "vbom.ml/util/sortorder" ) // RunServices is the swarm implementation of docker stack services func RunServices(dockerCli command.Cli, opts options.Services) error { - ctx := context.Background() - client := dockerCli.Client() + var ( + err error + ctx = context.Background() + client = dockerCli.Client() + ) - filter := getStackFilterFromOpt(opts.Namespace, opts.Filter) - services, err := client.ServiceList(ctx, types.ServiceListOptions{Filters: filter}) + listOpts := types.ServiceListOptions{ + Filters: getStackFilterFromOpt(opts.Namespace, opts.Filter), + // When not running "quiet", also get service status (number of running + // and desired tasks). Note that this is only supported on API v1.41 and + // up; older API versions ignore this option, and we will have to collect + // the information manually below. + Status: !opts.Quiet, + } + + services, err := client.ServiceList(ctx, listOpts) if err != nil { return err } // if no services in this stack, print message and exit 0 if len(services) == 0 { - fmt.Fprintf(dockerCli.Err(), "Nothing found in stack: %s\n", opts.Namespace) + _, _ = fmt.Fprintf(dockerCli.Err(), "Nothing found in stack: %s\n", opts.Namespace) return nil } - sort.Slice(services, func(i, j int) bool { - return sortorder.NaturalLess(services[i].Spec.Name, services[j].Spec.Name) - }) - info := map[string]service.ListInfo{} - if !opts.Quiet { - taskFilter := filters.NewArgs() - for _, service := range services { - taskFilter.Add("service", service.ID) - } - - tasks, err := client.TaskList(ctx, types.TaskListOptions{Filters: taskFilter}) + if listOpts.Status { + // Now that a request was made, we know what API version was used (either + // through configuration, or after client and daemon negotiated a version). + // If API version v1.41 or up was used; the daemon should already have done + // the legwork for us, and we don't have to calculate the number of desired + // and running tasks. On older API versions, we need to do some extra requests + // to get that information. + // + // So theoretically, this step can be skipped based on API version, however, + // some of our unit tests don't set the API version, and there may be other + // situations where the client uses the "default" version. To account for + // these situations, we do a quick check for services that do not have + // a ServiceStatus set, and perform a lookup for those. + services, err = service.AppendServiceStatus(ctx, client, services) if err != nil { return err } - - nodes, err := client.NodeList(ctx, types.NodeListOptions{}) - if err != nil { - return err - } - - info = service.GetServicesStatus(services, nodes, tasks) } format := opts.Format @@ -67,5 +71,5 @@ func RunServices(dockerCli command.Cli, opts options.Services) error { Output: dockerCli.Out(), Format: service.NewListFormat(format, opts.Quiet), } - return service.ListFormatWrite(servicesCtx, services, info) + return service.ListFormatWrite(servicesCtx, services) } From 20424e2f51f0252a5776be7f6f1c988503faebf3 Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Mon, 28 Oct 2019 01:56:42 +0100 Subject: [PATCH 4/4] Add test for ServiceStatus Signed-off-by: Sebastiaan van Stijn --- cli/command/service/list_test.go | 314 +++++++++++++++++++++++++++++++ 1 file changed, 314 insertions(+) diff --git a/cli/command/service/list_test.go b/cli/command/service/list_test.go index 3bc46e6d62..fc84240988 100644 --- a/cli/command/service/list_test.go +++ b/cli/command/service/list_test.go @@ -2,12 +2,19 @@ package service import ( "context" + "encoding/json" + "fmt" + "strings" "testing" "github.com/docker/cli/internal/test" + // Import builders to get the builder function as package function + . "github.com/docker/cli/internal/test/builders" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/swarm" + "github.com/docker/docker/api/types/versions" "gotest.tools/assert" + is "gotest.tools/assert/cmp" "gotest.tools/golden" ) @@ -27,3 +34,310 @@ func TestServiceListOrder(t *testing.T) { assert.NilError(t, cmd.Execute()) golden.Assert(t, cli.OutBuffer().String(), "service-list-sort.golden") } + +// TestServiceListServiceStatus tests that the ServiceStatus struct is correctly +// propagated. For older API versions, the ServiceStatus is calculated locally, +// based on the tasks that are present in the swarm, and the nodes that they are +// running on. +// If the list command is ran with `--quiet` option, no attempt should be done to +// propagate the ServiceStatus struct if not present, and it should be set to an +// empty struct. +func TestServiceListServiceStatus(t *testing.T) { + type listResponse struct { + ID string + Replicas string + } + + type testCase struct { + doc string + withQuiet bool + opts clusterOpts + cluster *cluster + expected []listResponse + } + + tests := []testCase{ + { + // Getting no nodes, services or tasks back from the daemon should + // not cause any problems + doc: "empty cluster", + cluster: &cluster{}, // force an empty cluster + expected: []listResponse{}, + }, + { + // Services are running, but no active nodes were found. On API v1.40 + // and below, this will cause looking up the "running" tasks to fail, + // as well as looking up "desired" tasks for global services. + doc: "API v1.40 no active nodes", + opts: clusterOpts{ + apiVersion: "1.40", + activeNodes: 0, + runningTasks: 2, + desiredTasks: 4, + }, + expected: []listResponse{ + {ID: "replicated", Replicas: "0/4"}, + {ID: "global", Replicas: "0/0"}, + {ID: "none-id", Replicas: "0/0"}, + }, + }, + { + doc: "API v1.40 3 active nodes, 1 task running", + opts: clusterOpts{ + apiVersion: "1.40", + activeNodes: 3, + runningTasks: 1, + desiredTasks: 2, + }, + expected: []listResponse{ + {ID: "replicated", Replicas: "1/2"}, + {ID: "global", Replicas: "1/3"}, + {ID: "none-id", Replicas: "0/0"}, + }, + }, + { + doc: "API v1.40 3 active nodes, all tasks running", + opts: clusterOpts{ + apiVersion: "1.40", + activeNodes: 3, + runningTasks: 3, + desiredTasks: 3, + }, + expected: []listResponse{ + {ID: "replicated", Replicas: "3/3"}, + {ID: "global", Replicas: "3/3"}, + {ID: "none-id", Replicas: "0/0"}, + }, + }, + + { + // Services are running, but no active nodes were found. On API v1.41 + // and up, the ServiceStatus is sent by the daemon, so this should not + // affect the results. + doc: "API v1.41 no active nodes", + opts: clusterOpts{ + apiVersion: "1.41", + activeNodes: 0, + runningTasks: 2, + desiredTasks: 4, + }, + expected: []listResponse{ + {ID: "replicated", Replicas: "2/4"}, + {ID: "global", Replicas: "0/0"}, + {ID: "none-id", Replicas: "0/0"}, + }, + }, + { + doc: "API v1.41 3 active nodes, 1 task running", + opts: clusterOpts{ + apiVersion: "1.41", + activeNodes: 3, + runningTasks: 1, + desiredTasks: 2, + }, + expected: []listResponse{ + {ID: "replicated", Replicas: "1/2"}, + {ID: "global", Replicas: "1/3"}, + {ID: "none-id", Replicas: "0/0"}, + }, + }, + { + doc: "API v1.41 3 active nodes, all tasks running", + opts: clusterOpts{ + apiVersion: "1.41", + activeNodes: 3, + runningTasks: 3, + desiredTasks: 3, + }, + expected: []listResponse{ + {ID: "replicated", Replicas: "3/3"}, + {ID: "global", Replicas: "3/3"}, + {ID: "none-id", Replicas: "0/0"}, + }, + }, + } + + matrix := make([]testCase, 0) + for _, quiet := range []bool{false, true} { + for _, tc := range tests { + if quiet { + tc.withQuiet = quiet + tc.doc = tc.doc + " with quiet" + } + matrix = append(matrix, tc) + } + } + + for _, tc := range matrix { + tc := tc + t.Run(tc.doc, func(t *testing.T) { + if tc.cluster == nil { + tc.cluster = generateCluster(t, tc.opts) + } + cli := test.NewFakeCli(&fakeClient{ + serviceListFunc: func(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { + if !options.Status || versions.LessThan(tc.opts.apiVersion, "1.41") { + // Don't return "ServiceStatus" if not requested, or on older API versions + for i := range tc.cluster.services { + tc.cluster.services[i].ServiceStatus = nil + } + } + return tc.cluster.services, nil + }, + taskListFunc: func(context.Context, types.TaskListOptions) ([]swarm.Task, error) { + return tc.cluster.tasks, nil + }, + nodeListFunc: func(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error) { + return tc.cluster.nodes, nil + }, + }) + cmd := newListCommand(cli) + cmd.SetArgs([]string{}) + if tc.withQuiet { + cmd.SetArgs([]string{"--quiet"}) + } + _ = cmd.Flags().Set("format", "{{ json .}}") + assert.NilError(t, cmd.Execute()) + + lines := strings.Split(strings.TrimSpace(cli.OutBuffer().String()), "\n") + jsonArr := fmt.Sprintf("[%s]", strings.Join(lines, ",")) + results := make([]listResponse, 0) + assert.NilError(t, json.Unmarshal([]byte(jsonArr), &results)) + + if tc.withQuiet { + // With "quiet" enabled, ServiceStatus should not be propagated + for i := range tc.expected { + tc.expected[i].Replicas = "0/0" + } + } + assert.Check(t, is.DeepEqual(tc.expected, results), "%+v", results) + }) + } +} + +type clusterOpts struct { + apiVersion string + activeNodes uint64 + desiredTasks uint64 + runningTasks uint64 +} + +type cluster struct { + services []swarm.Service + tasks []swarm.Task + nodes []swarm.Node +} + +func generateCluster(t *testing.T, opts clusterOpts) *cluster { + t.Helper() + c := cluster{ + services: generateServices(t, opts), + nodes: generateNodes(t, opts.activeNodes), + } + c.tasks = generateTasks(t, c.services, c.nodes, opts) + return &c +} + +func generateServices(t *testing.T, opts clusterOpts) []swarm.Service { + t.Helper() + + // Can't have more global tasks than nodes + globalTasks := opts.runningTasks + if globalTasks > opts.activeNodes { + globalTasks = opts.activeNodes + } + + return []swarm.Service{ + *Service( + ServiceID("replicated"), + ServiceName("01-replicated-service"), + ReplicatedService(opts.desiredTasks), + ServiceStatus(opts.desiredTasks, opts.runningTasks), + ), + *Service( + ServiceID("global"), + ServiceName("02-global-service"), + GlobalService(), + ServiceStatus(opts.activeNodes, globalTasks), + ), + *Service( + ServiceID("none-id"), + ServiceName("03-none-service"), + ), + } +} + +func generateTasks(t *testing.T, services []swarm.Service, nodes []swarm.Node, opts clusterOpts) []swarm.Task { + t.Helper() + tasks := make([]swarm.Task, 0) + + for _, s := range services { + if s.Spec.Mode.Replicated == nil && s.Spec.Mode.Global == nil { + continue + } + var runningTasks, failedTasks, desiredTasks uint64 + + // Set the number of desired tasks to generate, based on the service's mode + if s.Spec.Mode.Replicated != nil { + desiredTasks = *s.Spec.Mode.Replicated.Replicas + } else if s.Spec.Mode.Global != nil { + desiredTasks = opts.activeNodes + } + + for _, n := range nodes { + if runningTasks < opts.runningTasks && n.Status.State != swarm.NodeStateDown { + tasks = append(tasks, swarm.Task{ + NodeID: n.ID, + ServiceID: s.ID, + Status: swarm.TaskStatus{State: swarm.TaskStateRunning}, + DesiredState: swarm.TaskStateRunning, + }) + runningTasks++ + } + + // If the number of "running" tasks is lower than the desired number + // of tasks of the service, fill in the remaining number of tasks + // with failed tasks. These tasks have a desired "running" state, + // and thus will be included when calculating the "desired" tasks + // for services. + if failedTasks < (desiredTasks - opts.runningTasks) { + tasks = append(tasks, swarm.Task{ + NodeID: n.ID, + ServiceID: s.ID, + Status: swarm.TaskStatus{State: swarm.TaskStateFailed}, + DesiredState: swarm.TaskStateRunning, + }) + failedTasks++ + } + + // Also add tasks with DesiredState: Shutdown. These should not be + // counted as running or desired tasks. + tasks = append(tasks, swarm.Task{ + NodeID: n.ID, + ServiceID: s.ID, + Status: swarm.TaskStatus{State: swarm.TaskStateShutdown}, + DesiredState: swarm.TaskStateShutdown, + }) + } + } + return tasks +} + +// generateNodes generates a "nodes" endpoint API response with the requested +// number of "ready" nodes. In addition, a "down" node is generated. +func generateNodes(t *testing.T, activeNodes uint64) []swarm.Node { + t.Helper() + nodes := make([]swarm.Node, 0) + var i uint64 + for i = 0; i < activeNodes; i++ { + nodes = append(nodes, swarm.Node{ + ID: fmt.Sprintf("node-ready-%d", i), + Status: swarm.NodeStatus{State: swarm.NodeStateReady}, + }) + nodes = append(nodes, swarm.Node{ + ID: fmt.Sprintf("node-down-%d", i), + Status: swarm.NodeStatus{State: swarm.NodeStateDown}, + }) + } + return nodes +}