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 <github@gone.nl>
This commit is contained in:
Sebastiaan van Stijn 2019-10-24 00:16:35 +02:00
parent 228e0f5e76
commit 7405ac5c2d
No known key found for this signature in database
GPG Key ID: 76698F39D527CE8C
9 changed files with 446 additions and 238 deletions

View File

@ -16,6 +16,7 @@ import (
"github.com/docker/docker/pkg/stringid" "github.com/docker/docker/pkg/stringid"
units "github.com/docker/go-units" units "github.com/docker/go-units"
"github.com/pkg/errors" "github.com/pkg/errors"
"vbom.ml/util/sortorder"
) )
const serviceInspectPrettyTemplate formatter.Format = ` const serviceInspectPrettyTemplate formatter.Format = `
@ -520,17 +521,14 @@ func NewListFormat(source string, quiet bool) formatter.Format {
return formatter.Format(source) 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 // 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 { 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 { 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 { if err := format(serviceCtx); err != nil {
return err return err
} }
@ -552,8 +550,6 @@ func ListFormatWrite(ctx formatter.Context, services []swarm.Service, info map[s
type serviceContext struct { type serviceContext struct {
formatter.HeaderContext formatter.HeaderContext
service swarm.Service service swarm.Service
mode string
replicas string
} }
func (c *serviceContext) MarshalJSON() ([]byte, error) { func (c *serviceContext) MarshalJSON() ([]byte, error) {
@ -569,11 +565,35 @@ func (c *serviceContext) Name() string {
} }
func (c *serviceContext) Mode() 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 { 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 { func (c *serviceContext) Image() string {

View File

@ -34,28 +34,36 @@ func TestServiceContextWrite(t *testing.T) {
{ {
formatter.Context{Format: NewListFormat("table", false)}, formatter.Context{Format: NewListFormat("table", false)},
`ID NAME MODE REPLICAS IMAGE PORTS `ID NAME MODE REPLICAS IMAGE PORTS
id_baz baz global 2/4 *:80->8080/tcp 02_bar bar replicated 2/4 *:80->8090/udp
id_bar bar replicated 2/4 *:80->8080/tcp 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)}, formatter.Context{Format: NewListFormat("table", true)},
`id_baz `02_bar
id_bar 01_baz
04_qux2
03_qux10
`, `,
}, },
{ {
formatter.Context{Format: NewListFormat("table {{.Name}}", false)}, formatter.Context{Format: NewListFormat("table {{.Name}}\t{{.Mode}}", false)},
`NAME `NAME MODE
baz bar replicated
bar baz global
qux2 replicated
qux10 replicated
`, `,
}, },
{ {
formatter.Context{Format: NewListFormat("table {{.Name}}", true)}, formatter.Context{Format: NewListFormat("table {{.Name}}", true)},
`NAME `NAME
baz
bar bar
baz
qux2
qux10
`, `,
}, },
// Raw Format // Raw Format
@ -65,15 +73,19 @@ bar
}, },
{ {
formatter.Context{Format: NewListFormat("raw", true)}, formatter.Context{Format: NewListFormat("raw", true)},
`id: id_baz `id: 02_bar
id: id_bar id: 01_baz
id: 04_qux2
id: 03_qux10
`, `,
}, },
// Custom Format // Custom Format
{ {
formatter.Context{Format: NewListFormat("{{.Name}}", false)}, formatter.Context{Format: NewListFormat("{{.Name}}", false)},
`baz `bar
bar baz
qux2
qux10
`, `,
}, },
} }
@ -81,9 +93,12 @@ bar
for _, testcase := range cases { for _, testcase := range cases {
services := []swarm.Service{ services := []swarm.Service{
{ {
ID: "id_baz", ID: "01_baz",
Spec: swarm.ServiceSpec{ Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{Name: "baz"}, Annotations: swarm.Annotations{Name: "baz"},
Mode: swarm.ServiceMode{
Global: &swarm.GlobalService{},
},
}, },
Endpoint: swarm.Endpoint{ Endpoint: swarm.Endpoint{
Ports: []swarm.PortConfig{ Ports: []swarm.PortConfig{
@ -95,37 +110,70 @@ bar
}, },
}, },
}, },
ServiceStatus: &swarm.ServiceStatus{
RunningTasks: 1,
DesiredTasks: 3,
},
}, },
{ {
ID: "id_bar", ID: "02_bar",
Spec: swarm.ServiceSpec{ Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{Name: "bar"}, Annotations: swarm.Annotations{Name: "bar"},
Mode: swarm.ServiceMode{
Replicated: &swarm.ReplicatedService{},
},
}, },
Endpoint: swarm.Endpoint{ Endpoint: swarm.Endpoint{
Ports: []swarm.PortConfig{ Ports: []swarm.PortConfig{
{ {
PublishMode: "ingress", PublishMode: "ingress",
PublishedPort: 80, PublishedPort: 80,
TargetPort: 8080, TargetPort: 8090,
Protocol: "tcp", Protocol: "udp",
}, },
}, },
}, },
ServiceStatus: &swarm.ServiceStatus{
RunningTasks: 2,
DesiredTasks: 4,
}, },
}
info := map[string]ListInfo{
"id_baz": {
Mode: "global",
Replicas: "2/4",
}, },
"id_bar": { {
Mode: "replicated", ID: "03_qux10",
Replicas: "2/4", 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: "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("") out := bytes.NewBufferString("")
testcase.context.Output = out testcase.context.Output = out
err := ListFormatWrite(testcase.context, services, info) err := ListFormatWrite(testcase.context, services)
if err != nil { if err != nil {
assert.Error(t, err, testcase.expected) assert.Error(t, err, testcase.expected)
} else { } else {
@ -137,9 +185,12 @@ bar
func TestServiceContextWriteJSON(t *testing.T) { func TestServiceContextWriteJSON(t *testing.T) {
services := []swarm.Service{ services := []swarm.Service{
{ {
ID: "id_baz", ID: "01_baz",
Spec: swarm.ServiceSpec{ Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{Name: "baz"}, Annotations: swarm.Annotations{Name: "baz"},
Mode: swarm.ServiceMode{
Global: &swarm.GlobalService{},
},
}, },
Endpoint: swarm.Endpoint{ Endpoint: swarm.Endpoint{
Ports: []swarm.PortConfig{ 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{ Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{Name: "bar"}, Annotations: swarm.Annotations{Name: "bar"},
Mode: swarm.ServiceMode{
Replicated: &swarm.ReplicatedService{},
},
}, },
Endpoint: swarm.Endpoint{ Endpoint: swarm.Endpoint{
Ports: []swarm.PortConfig{ Ports: []swarm.PortConfig{
@ -167,25 +225,19 @@ func TestServiceContextWriteJSON(t *testing.T) {
}, },
}, },
}, },
ServiceStatus: &swarm.ServiceStatus{
RunningTasks: 2,
DesiredTasks: 4,
}, },
}
info := map[string]ListInfo{
"id_baz": {
Mode: "global",
Replicas: "2/4",
},
"id_bar": {
Mode: "replicated",
Replicas: "2/4",
}, },
} }
expectedJSONs := []map[string]interface{}{ expectedJSONs := []map[string]interface{}{
{"ID": "id_baz", "Name": "baz", "Mode": "global", "Replicas": "2/4", "Image": "", "Ports": "*:80->8080/tcp"}, {"ID": "02_bar", "Name": "bar", "Mode": "replicated", "Replicas": "2/4", "Image": "", "Ports": "*:80->8080/tcp"},
{"ID": "id_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("") 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@ -199,21 +251,35 @@ func TestServiceContextWriteJSON(t *testing.T) {
} }
func TestServiceContextWriteJSONField(t *testing.T) { func TestServiceContextWriteJSONField(t *testing.T) {
services := []swarm.Service{ 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"}}}, ID: "01_baz",
} Spec: swarm.ServiceSpec{
info := map[string]ListInfo{ Annotations: swarm.Annotations{Name: "baz"},
"id_baz": { Mode: swarm.ServiceMode{
Mode: "global", Global: &swarm.GlobalService{},
Replicas: "2/4", },
},
ServiceStatus: &swarm.ServiceStatus{
RunningTasks: 2,
DesiredTasks: 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,
}, },
"id_bar": {
Mode: "replicated",
Replicas: "2/4",
}, },
} }
out := bytes.NewBufferString("") 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 { if err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@ -2,10 +2,6 @@ package service
import ( import (
"context" "context"
"fmt"
"sort"
"vbom.ml/util/sortorder"
"github.com/docker/cli/cli" "github.com/docker/cli/cli"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
@ -14,6 +10,7 @@ import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -44,43 +41,49 @@ func newListCommand(dockerCli command.Cli) *cobra.Command {
return cmd return cmd
} }
func runList(dockerCli command.Cli, options listOptions) error { func runList(dockerCli command.Cli, opts listOptions) error {
ctx := context.Background() var (
client := dockerCli.Client() apiClient = dockerCli.Client()
ctx = context.Background()
err error
)
serviceFilters := options.filter.Value() listOpts := types.ServiceListOptions{
services, err := client.ServiceList(ctx, types.ServiceListOptions{Filters: serviceFilters}) 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 { if err != nil {
return err return err
} }
sort.Slice(services, func(i, j int) bool { if listOpts.Status {
return sortorder.NaturalLess(services[i].Spec.Name, services[j].Spec.Name) // Now that a request was made, we know what API version was used (either
}) // through configuration, or after client and daemon negotiated a version).
info := map[string]ListInfo{} // If API version v1.41 or up was used; the daemon should already have done
if len(services) > 0 && !options.quiet { // the legwork for us, and we don't have to calculate the number of desired
// only non-empty services and not quiet, should we call TaskList and NodeList api // and running tasks. On older API versions, we need to do some extra requests
taskFilter := filters.NewArgs() // to get that information.
for _, service := range services { //
taskFilter.Add("service", service.ID) // 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
tasks, err := client.TaskList(ctx, types.TaskListOptions{Filters: taskFilter}) // 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 { if err != nil {
return err return err
} }
nodes, err := client.NodeList(ctx, types.NodeListOptions{})
if err != nil {
return err
} }
info = GetServicesStatus(services, nodes, tasks) format := opts.format
}
format := options.format
if len(format) == 0 { if len(format) == 0 {
if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !options.quiet { if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !opts.quiet {
format = dockerCli.ConfigFile().ServicesFormat format = dockerCli.ConfigFile().ServicesFormat
} else { } else {
format = formatter.TableFormatKey format = formatter.TableFormatKey
@ -89,54 +92,97 @@ func runList(dockerCli command.Cli, options listOptions) error {
servicesCtx := formatter.Context{ servicesCtx := formatter.Context{
Output: dockerCli.Out(), 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 // AppendServiceStatus propagates the ServiceStatus field for "services".
func GetServicesStatus(services []swarm.Service, nodes []swarm.Node, tasks []swarm.Task) map[string]ListInfo { //
running := map[string]int{} // If API version v1.41 or up is used, this information is already set by the
tasksNoShutdown := map[string]int{} // 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{}) activeNodes := make(map[string]struct{})
for _, n := range nodes { for _, n := range nodes {
if n.Status.State != swarm.NodeStateDown { if n.Status.State != swarm.NodeStateDown {
activeNodes[n.ID] = struct{}{} activeNodes[n.ID] = struct{}{}
} }
} }
return activeNodes, nil
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
} }

View File

@ -22,6 +22,7 @@ func TestServiceListOrder(t *testing.T) {
}, },
}) })
cmd := newListCommand(cli) cmd := newListCommand(cli)
cmd.SetArgs([]string{})
cmd.Flags().Set("format", "{{.Name}}") cmd.Flags().Set("format", "{{.Name}}")
assert.NilError(t, cmd.Execute()) assert.NilError(t, cmd.Execute())
golden.Assert(t, cli.OutBuffer().String(), "service-list-sort.golden") golden.Assert(t, cli.OutBuffer().String(), "service-list-sort.golden")

View File

@ -1,14 +1,28 @@
id: id_baz id: 02_bar
name: baz
mode: global
replicas: 2/4
image:
ports: *:80->8080/tcp
id: id_bar
name: bar name: bar
mode: replicated mode: replicated
replicas: 2/4 replicas: 2/4
image: image:
ports: *:80->8090/udp
id: 01_baz
name: baz
mode: global
replicas: 1/3
image:
ports: *:80->8080/tcp 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:

View File

@ -6,7 +6,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/docker/cli/cli/command/service"
"github.com/docker/compose-on-kubernetes/api/labels" "github.com/docker/compose-on-kubernetes/api/labels"
"github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
@ -154,35 +153,65 @@ const (
publishedOnRandomPortSuffix = "-random-ports" 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)) 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 { 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 { if err != nil {
return nil, nil, err return nil, err
} }
result[i] = *s 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 { 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 { if err != nil {
return nil, nil, err return nil, err
} }
result = append(result, *s) 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 { sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID 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) { func convertToService(serviceName string, services *apiv1.ServiceList, containers []apiv1.Container) (*swarm.Service, error) {

View File

@ -3,7 +3,6 @@ package kubernetes
import ( import (
"testing" "testing"
"github.com/docker/cli/cli/command/service"
"github.com/docker/compose-on-kubernetes/api/labels" "github.com/docker/compose-on-kubernetes/api/labels"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"gotest.tools/assert" "gotest.tools/assert"
@ -19,49 +18,45 @@ func TestReplicasConversionNeedsAService(t *testing.T) {
Items: []appsv1beta2.ReplicaSet{makeReplicaSet("unknown", 0, 0)}, Items: []appsv1beta2.ReplicaSet{makeReplicaSet("unknown", 0, 0)},
} }
services := apiv1.ServiceList{} services := apiv1.ServiceList{}
_, _, err := convertToServices(&replicas, &appsv1beta2.DaemonSetList{}, &services) _, err := convertToServices(&replicas, &appsv1beta2.DaemonSetList{}, &services)
assert.ErrorContains(t, err, "could not find service") assert.ErrorContains(t, err, "could not find service")
} }
func TestKubernetesServiceToSwarmServiceConversion(t *testing.T) { func TestKubernetesServiceToSwarmServiceConversion(t *testing.T) {
testCases := []struct { testCases := []struct {
doc string
replicas *appsv1beta2.ReplicaSetList replicas *appsv1beta2.ReplicaSetList
services *apiv1.ServiceList services *apiv1.ServiceList
expectedServices []swarm.Service 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{ Items: []appsv1beta2.ReplicaSet{
makeReplicaSet("service1", 2, 5), makeReplicaSet("service1", 2, 5),
makeReplicaSet("service2", 3, 3), makeReplicaSet("service2", 3, 3),
}, },
}, },
&apiv1.ServiceList{ services: &apiv1.ServiceList{
Items: []apiv1.Service{ Items: []apiv1.Service{
makeKubeService("service1", "stack", "uid1", apiv1.ServiceTypeClusterIP, nil), makeKubeService("service1", "stack", "uid1", apiv1.ServiceTypeClusterIP, nil),
makeKubeService("service2", "stack", "uid2", apiv1.ServiceTypeClusterIP, nil), makeKubeService("service2", "stack", "uid2", apiv1.ServiceTypeClusterIP, nil),
makeKubeService("service3", "other-stack", "uid2", apiv1.ServiceTypeClusterIP, nil), makeKubeService("service3", "other-stack", "uid2", apiv1.ServiceTypeClusterIP, nil),
}, },
}, },
[]swarm.Service{ expectedServices: []swarm.Service{
makeSwarmService("stack_service1", "uid1", nil), makeSwarmService(t, "stack_service1", "uid1", withMode("replicated", 5), withStatus(2, 5)),
makeSwarmService("stack_service2", "uid2", nil), makeSwarmService(t, "stack_service2", "uid2", withMode("replicated", 3), withStatus(3, 3)),
},
map[string]service.ListInfo{
"uid1": {Mode: "replicated", Replicas: "2/5"},
"uid2": {Mode: "replicated", Replicas: "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{ Items: []appsv1beta2.ReplicaSet{
makeReplicaSet("service", 1, 1), makeReplicaSet("service", 1, 1),
}, },
}, },
&apiv1.ServiceList{ services: &apiv1.ServiceList{
Items: []apiv1.Service{ Items: []apiv1.Service{
makeKubeService("service", "stack", "uid1", apiv1.ServiceTypeClusterIP, nil), makeKubeService("service", "stack", "uid1", apiv1.ServiceTypeClusterIP, nil),
makeKubeService("service-published", "stack", "uid2", apiv1.ServiceTypeLoadBalancer, []apiv1.ServicePort{ makeKubeService("service-published", "stack", "uid2", apiv1.ServiceTypeLoadBalancer, []apiv1.ServicePort{
@ -73,29 +68,26 @@ func TestKubernetesServiceToSwarmServiceConversion(t *testing.T) {
}), }),
}, },
}, },
[]swarm.Service{ expectedServices: []swarm.Service{
makeSwarmService("stack_service", "uid1", []swarm.PortConfig{ makeSwarmService(t, "stack_service", "uid1",
{ withMode("replicated", 1),
withStatus(1, 1), withPort(swarm.PortConfig{
PublishMode: swarm.PortConfigPublishModeIngress, PublishMode: swarm.PortConfigPublishModeIngress,
PublishedPort: 80, PublishedPort: 80,
TargetPort: 80, TargetPort: 80,
Protocol: swarm.PortConfigProtocolTCP, 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{ Items: []appsv1beta2.ReplicaSet{
makeReplicaSet("service", 1, 1), makeReplicaSet("service", 1, 1),
}, },
}, },
&apiv1.ServiceList{ services: &apiv1.ServiceList{
Items: []apiv1.Service{ Items: []apiv1.Service{
makeKubeService("service", "stack", "uid1", apiv1.ServiceTypeClusterIP, nil), makeKubeService("service", "stack", "uid1", apiv1.ServiceTypeClusterIP, nil),
makeKubeService("service-random-ports", "stack", "uid2", apiv1.ServiceTypeNodePort, []apiv1.ServicePort{ makeKubeService("service-random-ports", "stack", "uid2", apiv1.ServiceTypeNodePort, []apiv1.ServicePort{
@ -107,27 +99,28 @@ func TestKubernetesServiceToSwarmServiceConversion(t *testing.T) {
}), }),
}, },
}, },
[]swarm.Service{ expectedServices: []swarm.Service{
makeSwarmService("stack_service", "uid1", []swarm.PortConfig{ makeSwarmService(t, "stack_service", "uid1",
{ withMode("replicated", 1),
withStatus(1, 1),
withPort(swarm.PortConfig{
PublishMode: swarm.PortConfigPublishModeHost, PublishMode: swarm.PortConfigPublishModeHost,
PublishedPort: 35666, PublishedPort: 35666,
TargetPort: 80, TargetPort: 80,
Protocol: swarm.PortConfigProtocolTCP, Protocol: swarm.PortConfigProtocolTCP,
},
}), }),
}, ),
map[string]service.ListInfo{
"uid1": {Mode: "replicated", Replicas: "1/1"},
}, },
}, },
} }
for _, tc := range testCases { for _, tc := range testCases {
swarmServices, listInfo, err := convertToServices(tc.replicas, &appsv1beta2.DaemonSetList{}, tc.services) tc := tc
t.Run(tc.doc, func(t *testing.T) {
swarmServices, err := convertToServices(tc.replicas, &appsv1beta2.DaemonSetList{}, tc.services)
assert.NilError(t, err) assert.NilError(t, err)
assert.DeepEqual(t, tc.expectedServices, swarmServices) assert.DeepEqual(t, tc.expectedServices, swarmServices)
assert.DeepEqual(t, tc.expectedListInfo, listInfo) })
} }
} }
@ -172,8 +165,46 @@ func makeKubeService(service, stack, uid string, serviceType apiv1.ServiceType,
} }
} }
func makeSwarmService(service, id string, ports []swarm.PortConfig) swarm.Service { func withMode(mode string, replicas uint64) func(*swarm.Service) {
return 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, ID: id,
Spec: swarm.ServiceSpec{ Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{ 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
} }

View File

@ -109,16 +109,12 @@ func RunServices(dockerCli *KubeCli, opts options.Services) error {
} }
// Convert Replicas sets and kubernetes services to swarm services and formatter information // 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 { if err != nil {
return err return err
} }
services = filterServicesByName(services, filters.Get("name"), stackName) services = filterServicesByName(services, filters.Get("name"), stackName)
if opts.Quiet {
info = map[string]service.ListInfo{}
}
format := opts.Format format := opts.Format
if len(format) == 0 { if len(format) == 0 {
if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !opts.Quiet { if len(dockerCli.ConfigFile().ServicesFormat) > 0 && !opts.Quiet {
@ -132,7 +128,7 @@ func RunServices(dockerCli *KubeCli, opts options.Services) error {
Output: dockerCli.Out(), Output: dockerCli.Out(),
Format: service.NewListFormat(format, opts.Quiet), 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 { func filterServicesByName(services []swarm.Service, names []string, stackName string) []swarm.Service {

View File

@ -3,55 +3,59 @@ package swarm
import ( import (
"context" "context"
"fmt" "fmt"
"sort"
"github.com/docker/cli/cli/command" "github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/service" "github.com/docker/cli/cli/command/service"
"github.com/docker/cli/cli/command/stack/formatter" "github.com/docker/cli/cli/command/stack/formatter"
"github.com/docker/cli/cli/command/stack/options" "github.com/docker/cli/cli/command/stack/options"
"github.com/docker/docker/api/types" "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 // RunServices is the swarm implementation of docker stack services
func RunServices(dockerCli command.Cli, opts options.Services) error { func RunServices(dockerCli command.Cli, opts options.Services) error {
ctx := context.Background() var (
client := dockerCli.Client() err error
ctx = context.Background()
client = dockerCli.Client()
)
filter := getStackFilterFromOpt(opts.Namespace, opts.Filter) listOpts := types.ServiceListOptions{
services, err := client.ServiceList(ctx, types.ServiceListOptions{Filters: filter}) 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 { if err != nil {
return err return err
} }
// if no services in this stack, print message and exit 0 // if no services in this stack, print message and exit 0
if len(services) == 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 return nil
} }
sort.Slice(services, func(i, j int) bool { if listOpts.Status {
return sortorder.NaturalLess(services[i].Spec.Name, services[j].Spec.Name) // Now that a request was made, we know what API version was used (either
}) // through configuration, or after client and daemon negotiated a version).
info := map[string]service.ListInfo{} // If API version v1.41 or up was used; the daemon should already have done
if !opts.Quiet { // the legwork for us, and we don't have to calculate the number of desired
taskFilter := filters.NewArgs() // and running tasks. On older API versions, we need to do some extra requests
for _, service := range services { // to get that information.
taskFilter.Add("service", service.ID) //
} // 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
tasks, err := client.TaskList(ctx, types.TaskListOptions{Filters: taskFilter}) // 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 { if err != nil {
return err return err
} }
nodes, err := client.NodeList(ctx, types.NodeListOptions{})
if err != nil {
return err
}
info = service.GetServicesStatus(services, nodes, tasks)
} }
format := opts.Format format := opts.Format
@ -67,5 +71,5 @@ func RunServices(dockerCli command.Cli, opts options.Services) error {
Output: dockerCli.Out(), Output: dockerCli.Out(),
Format: service.NewListFormat(format, opts.Quiet), Format: service.NewListFormat(format, opts.Quiet),
} }
return service.ListFormatWrite(servicesCtx, services, info) return service.ListFormatWrite(servicesCtx, services)
} }