Merge pull request #2157 from thaJeztah/servicestatus

Services: use ServiceStatus on API v1.41 and up
This commit is contained in:
Sebastiaan van Stijn 2019-10-29 15:57:32 +01:00 committed by GitHub
commit b3cde356f6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 809 additions and 257 deletions

View File

@ -6,6 +6,7 @@ import (
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client" "github.com/docker/docker/client"
// Import builders to get the builder function as package function // Import builders to get the builder function as package function
. "github.com/docker/cli/internal/test/builders" . "github.com/docker/cli/internal/test/builders"
) )
@ -18,9 +19,13 @@ type fakeClient struct {
taskListFunc func(context.Context, types.TaskListOptions) ([]swarm.Task, error) taskListFunc func(context.Context, types.TaskListOptions) ([]swarm.Task, error)
infoFunc func(ctx context.Context) (types.Info, error) infoFunc func(ctx context.Context) (types.Info, error)
networkInspectFunc func(ctx context.Context, networkID string, options types.NetworkInspectOptions) (types.NetworkResource, 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) { 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 return nil, nil
} }
@ -69,9 +74,6 @@ func (f *fakeClient) NetworkInspect(ctx context.Context, networkID string, optio
return types.NetworkResource{}, nil return types.NetworkResource{}, nil
} }
func newService(id string, name string) swarm.Service { func newService(id string, name string, opts ...func(*swarm.Service)) swarm.Service {
return swarm.Service{ return *Service(append(opts, ServiceID(id), ServiceName(name))...)
ID: id,
Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: name}},
}
} }

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
} }
@ -551,9 +549,7 @@ 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

@ -33,29 +33,37 @@ func TestServiceContextWrite(t *testing.T) {
// Table format // Table format
{ {
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: "03_qux10",
"id_baz": { Spec: swarm.ServiceSpec{
Mode: "global", Annotations: swarm.Annotations{Name: "qux10"},
Replicas: "2/4", 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", ID: "04_qux2",
Replicas: "2/4", 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,
info := map[string]ListInfo{ DesiredTasks: 4,
"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_bar": { {
Mode: "replicated", ID: "24_bar",
Replicas: "2/4", Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{Name: "bar"},
Mode: swarm.ServiceMode{
Replicated: &swarm.ReplicatedService{},
},
},
ServiceStatus: &swarm.ServiceStatus{
RunningTasks: 2,
DesiredTasks: 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 := options.format format := opts.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

@ -2,12 +2,19 @@ package service
import ( import (
"context" "context"
"encoding/json"
"fmt"
"strings"
"testing" "testing"
"github.com/docker/cli/internal/test" "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"
"github.com/docker/docker/api/types/swarm" "github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/api/types/versions"
"gotest.tools/assert" "gotest.tools/assert"
is "gotest.tools/assert/cmp"
"gotest.tools/golden" "gotest.tools/golden"
) )
@ -22,7 +29,315 @@ 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")
} }
// 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
}

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
assert.NilError(t, err) t.Run(tc.doc, func(t *testing.T) {
assert.DeepEqual(t, tc.expectedServices, swarmServices) swarmServices, err := convertToServices(tc.replicas, &appsv1beta2.DaemonSetList{}, tc.services)
assert.DeepEqual(t, tc.expectedListInfo, listInfo) 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 { 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

@ -6,6 +6,7 @@ import (
"github.com/docker/cli/cli/config/configfile" "github.com/docker/cli/cli/config/configfile"
"github.com/docker/cli/internal/test" "github.com/docker/cli/internal/test"
// Import builders to get the builder function as package function // Import builders to get the builder function as package function
. "github.com/docker/cli/internal/test/builders" . "github.com/docker/cli/internal/test/builders"
"github.com/docker/docker/api/types" "github.com/docker/docker/api/types"
@ -35,17 +36,20 @@ func TestStackServicesErrors(t *testing.T) {
{ {
args: []string{"foo"}, args: []string{"foo"},
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) { 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) { nodeListFunc: func(options types.NodeListOptions) ([]swarm.Node, error) {
return nil, errors.Errorf("error getting nodes") return nil, errors.Errorf("error getting nodes")
}, },
taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
return []swarm.Task{*Task()}, nil
},
expectedError: "error getting nodes", expectedError: "error getting nodes",
}, },
{ {
args: []string{"foo"}, args: []string{"foo"},
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) { 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) { taskListFunc: func(options types.TaskListOptions) ([]swarm.Task, error) {
return nil, errors.Errorf("error getting tasks") return nil, errors.Errorf("error getting tasks")
@ -65,18 +69,21 @@ func TestStackServicesErrors(t *testing.T) {
} }
for _, tc := range testCases { for _, tc := range testCases {
cli := test.NewFakeCli(&fakeClient{ tc := tc
serviceListFunc: tc.serviceListFunc, t.Run(tc.expectedError, func(t *testing.T) {
nodeListFunc: tc.nodeListFunc, cli := test.NewFakeCli(&fakeClient{
taskListFunc: tc.taskListFunc, 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)
} }
} }

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)
} }

View File

@ -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) { func ReplicatedService(replicas uint64) func(*swarm.Service) {
return func(service *swarm.Service) { return func(service *swarm.Service) {
service.Spec.Mode = swarm.ServiceMode{Replicated: &swarm.ReplicatedService{Replicas: &replicas}} 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,
}
} }
} }