From 7405ac5c2d63c5db909a6c5093bb4304f376d9af Mon Sep 17 00:00:00 2001 From: Sebastiaan van Stijn Date: Thu, 24 Oct 2019 00:16:35 +0200 Subject: [PATCH] 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 | 176 +++++++++++------- cli/command/service/list_test.go | 1 + .../testdata/service-context-write-raw.golden | 26 ++- 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, 439 insertions(+), 231 deletions(-) diff --git a/cli/command/service/formatter.go b/cli/command/service/formatter.go index 3ebe52d520d3..311355618d16 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 8addeba0c718..10b7923c71e0 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 90fe33010273..61e414c6b72e 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 + ) + + 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, + } - serviceFilters := options.filter.Value() - services, err := client.ServiceList(ctx, types.ServiceListOptions{Filters: serviceFilters}) + 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{} - - activeNodes := make(map[string]struct{}) - for _, n := range nodes { - if n.Status.State != swarm.NodeStateDown { - activeNodes[n.ID] = struct{}{} +// 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 { - tasksNoShutdown[task.ServiceID]++ + status[task.ServiceID].DesiredTasks++ } - if _, nodeActive := activeNodes[task.NodeID]; nodeActive && task.Status.State == swarm.TaskStateRunning { - running[task.ServiceID]++ + status[task.ServiceID].RunningTasks++ + } + } + + for i, service := range services { + if s := status[service.ID]; s != nil { + services[i].ServiceStatus = s } } + return services, nil +} - 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]), - } +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{}{} } } - return info + return activeNodes, nil } diff --git a/cli/command/service/list_test.go b/cli/command/service/list_test.go index e52e7e03c23d..3bc46e6d6284 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 d62b9a244031..feb100c9d7a8 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 +id: 02_bar +name: bar +mode: replicated +replicas: 2/4 +image: +ports: *:80->8090/udp + +id: 01_baz name: baz mode: global -replicas: 2/4 +replicas: 1/3 image: ports: *:80->8080/tcp -id: id_bar -name: bar +id: 04_qux2 +name: qux2 mode: replicated -replicas: 2/4 +replicas: 3/3 (max 2 per node) image: -ports: *:80->8080/tcp +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 e1fdec717497..14797b081d34 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 3a5bd962c4a5..ba3368d86c07 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 8f91b7e279c1..952c439b168a 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 15a48e789399..faa54c78da97 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) }