Skip to content

Commit 66bcac6

Browse files
committed
Add test for ServiceStatus
Signed-off-by: Sebastiaan van Stijn <github@gone.nl>
1 parent 9db4ad8 commit 66bcac6

2 files changed

Lines changed: 321 additions & 5 deletions

File tree

cli/command/service/client_test.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/docker/docker/api/types"
77
"github.com/docker/docker/api/types/swarm"
88
"github.com/docker/docker/client"
9+
910
// Import builders to get the builder function as package function
1011
. "github.com/docker/cli/internal/test/builders"
1112
)
@@ -18,9 +19,13 @@ type fakeClient struct {
1819
taskListFunc func(context.Context, types.TaskListOptions) ([]swarm.Task, error)
1920
infoFunc func(ctx context.Context) (types.Info, error)
2021
networkInspectFunc func(ctx context.Context, networkID string, options types.NetworkInspectOptions) (types.NetworkResource, error)
22+
nodeListFunc func(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error)
2123
}
2224

2325
func (f *fakeClient) NodeList(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error) {
26+
if f.nodeListFunc != nil {
27+
return f.nodeListFunc(ctx, options)
28+
}
2429
return nil, nil
2530
}
2631

@@ -69,9 +74,6 @@ func (f *fakeClient) NetworkInspect(ctx context.Context, networkID string, optio
6974
return types.NetworkResource{}, nil
7075
}
7176

72-
func newService(id string, name string) swarm.Service {
73-
return swarm.Service{
74-
ID: id,
75-
Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: name}},
76-
}
77+
func newService(id string, name string, opts ...func(*swarm.Service)) swarm.Service {
78+
return *Service(append(opts, ServiceID(id), ServiceName(name))...)
7779
}

cli/command/service/list_test.go

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@ package service
22

33
import (
44
"context"
5+
"encoding/json"
6+
"fmt"
7+
"strings"
58
"testing"
69

710
"github.com/docker/cli/internal/test"
11+
// Import builders to get the builder function as package function
12+
. "github.com/docker/cli/internal/test/builders"
813
"github.com/docker/docker/api/types"
914
"github.com/docker/docker/api/types/swarm"
15+
"github.com/docker/docker/api/types/versions"
1016
"gotest.tools/assert"
17+
is "gotest.tools/assert/cmp"
1118
"gotest.tools/golden"
1219
)
1320

@@ -27,3 +34,310 @@ func TestServiceListOrder(t *testing.T) {
2734
assert.NilError(t, cmd.Execute())
2835
golden.Assert(t, cli.OutBuffer().String(), "service-list-sort.golden")
2936
}
37+
38+
// TestServiceListServiceStatus tests that the ServiceStatus struct is correctly
39+
// propagated. For older API versions, the ServiceStatus is calculated locally,
40+
// based on the tasks that are present in the swarm, and the nodes that they are
41+
// running on.
42+
// If the list command is ran with `--quiet` option, no attempt should be done to
43+
// propagate the ServiceStatus struct if not present, and it should be set to an
44+
// empty struct.
45+
func TestServiceListServiceStatus(t *testing.T) {
46+
type listResponse struct {
47+
ID string
48+
Replicas string
49+
}
50+
51+
type testCase struct {
52+
doc string
53+
withQuiet bool
54+
opts clusterOpts
55+
cluster *cluster
56+
expected []listResponse
57+
}
58+
59+
tests := []testCase{
60+
{
61+
// Getting no nodes, services or tasks back from the daemon should
62+
// not cause any problems
63+
doc: "empty cluster",
64+
cluster: &cluster{}, // force an empty cluster
65+
expected: []listResponse{},
66+
},
67+
{
68+
// Services are running, but no active nodes were found. On API v1.40
69+
// and below, this will cause looking up the "running" tasks to fail,
70+
// as well as looking up "desired" tasks for global services.
71+
doc: "API v1.40 no active nodes",
72+
opts: clusterOpts{
73+
apiVersion: "1.40",
74+
activeNodes: 0,
75+
runningTasks: 2,
76+
desiredTasks: 4,
77+
},
78+
expected: []listResponse{
79+
{ID: "replicated", Replicas: "0/4"},
80+
{ID: "global", Replicas: "0/0"},
81+
{ID: "none-id", Replicas: "0/0"},
82+
},
83+
},
84+
{
85+
doc: "API v1.40 3 active nodes, 1 task running",
86+
opts: clusterOpts{
87+
apiVersion: "1.40",
88+
activeNodes: 3,
89+
runningTasks: 1,
90+
desiredTasks: 2,
91+
},
92+
expected: []listResponse{
93+
{ID: "replicated", Replicas: "1/2"},
94+
{ID: "global", Replicas: "1/3"},
95+
{ID: "none-id", Replicas: "0/0"},
96+
},
97+
},
98+
{
99+
doc: "API v1.40 3 active nodes, all tasks running",
100+
opts: clusterOpts{
101+
apiVersion: "1.40",
102+
activeNodes: 3,
103+
runningTasks: 3,
104+
desiredTasks: 3,
105+
},
106+
expected: []listResponse{
107+
{ID: "replicated", Replicas: "3/3"},
108+
{ID: "global", Replicas: "3/3"},
109+
{ID: "none-id", Replicas: "0/0"},
110+
},
111+
},
112+
113+
{
114+
// Services are running, but no active nodes were found. On API v1.41
115+
// and up, the ServiceStatus is sent by the daemon, so this should not
116+
// affect the results.
117+
doc: "API v1.41 no active nodes",
118+
opts: clusterOpts{
119+
apiVersion: "1.41",
120+
activeNodes: 0,
121+
runningTasks: 2,
122+
desiredTasks: 4,
123+
},
124+
expected: []listResponse{
125+
{ID: "replicated", Replicas: "2/4"},
126+
{ID: "global", Replicas: "0/0"},
127+
{ID: "none-id", Replicas: "0/0"},
128+
},
129+
},
130+
{
131+
doc: "API v1.41 3 active nodes, 1 task running",
132+
opts: clusterOpts{
133+
apiVersion: "1.41",
134+
activeNodes: 3,
135+
runningTasks: 1,
136+
desiredTasks: 2,
137+
},
138+
expected: []listResponse{
139+
{ID: "replicated", Replicas: "1/2"},
140+
{ID: "global", Replicas: "1/3"},
141+
{ID: "none-id", Replicas: "0/0"},
142+
},
143+
},
144+
{
145+
doc: "API v1.41 3 active nodes, all tasks running",
146+
opts: clusterOpts{
147+
apiVersion: "1.41",
148+
activeNodes: 3,
149+
runningTasks: 3,
150+
desiredTasks: 3,
151+
},
152+
expected: []listResponse{
153+
{ID: "replicated", Replicas: "3/3"},
154+
{ID: "global", Replicas: "3/3"},
155+
{ID: "none-id", Replicas: "0/0"},
156+
},
157+
},
158+
}
159+
160+
matrix := make([]testCase, 0)
161+
for _, quiet := range []bool{false, true} {
162+
for _, tc := range tests {
163+
if quiet {
164+
tc.withQuiet = quiet
165+
tc.doc = tc.doc + " with quiet"
166+
}
167+
matrix = append(matrix, tc)
168+
}
169+
}
170+
171+
for _, tc := range matrix {
172+
tc := tc
173+
t.Run(tc.doc, func(t *testing.T) {
174+
if tc.cluster == nil {
175+
tc.cluster = generateCluster(t, tc.opts)
176+
}
177+
cli := test.NewFakeCli(&fakeClient{
178+
serviceListFunc: func(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) {
179+
if !options.Status || versions.LessThan(tc.opts.apiVersion, "1.41") {
180+
// Don't return "ServiceStatus" if not requested, or on older API versions
181+
for i := range tc.cluster.services {
182+
tc.cluster.services[i].ServiceStatus = nil
183+
}
184+
}
185+
return tc.cluster.services, nil
186+
},
187+
taskListFunc: func(context.Context, types.TaskListOptions) ([]swarm.Task, error) {
188+
return tc.cluster.tasks, nil
189+
},
190+
nodeListFunc: func(ctx context.Context, options types.NodeListOptions) ([]swarm.Node, error) {
191+
return tc.cluster.nodes, nil
192+
},
193+
})
194+
cmd := newListCommand(cli)
195+
cmd.SetArgs([]string{})
196+
if tc.withQuiet {
197+
cmd.SetArgs([]string{"--quiet"})
198+
}
199+
_ = cmd.Flags().Set("format", "{{ json .}}")
200+
assert.NilError(t, cmd.Execute())
201+
202+
lines := strings.Split(strings.TrimSpace(cli.OutBuffer().String()), "\n")
203+
jsonArr := fmt.Sprintf("[%s]", strings.Join(lines, ","))
204+
results := make([]listResponse, 0)
205+
assert.NilError(t, json.Unmarshal([]byte(jsonArr), &results))
206+
207+
if tc.withQuiet {
208+
// With "quiet" enabled, ServiceStatus should not be propagated
209+
for i := range tc.expected {
210+
tc.expected[i].Replicas = "0/0"
211+
}
212+
}
213+
assert.Check(t, is.DeepEqual(tc.expected, results), "%+v", results)
214+
})
215+
}
216+
}
217+
218+
type clusterOpts struct {
219+
apiVersion string
220+
activeNodes uint64
221+
desiredTasks uint64
222+
runningTasks uint64
223+
}
224+
225+
type cluster struct {
226+
services []swarm.Service
227+
tasks []swarm.Task
228+
nodes []swarm.Node
229+
}
230+
231+
func generateCluster(t *testing.T, opts clusterOpts) *cluster {
232+
t.Helper()
233+
c := cluster{
234+
services: generateServices(t, opts),
235+
nodes: generateNodes(t, opts.activeNodes),
236+
}
237+
c.tasks = generateTasks(t, c.services, c.nodes, opts)
238+
return &c
239+
}
240+
241+
func generateServices(t *testing.T, opts clusterOpts) []swarm.Service {
242+
t.Helper()
243+
244+
// Can't have more global tasks than nodes
245+
globalTasks := opts.runningTasks
246+
if globalTasks > opts.activeNodes {
247+
globalTasks = opts.activeNodes
248+
}
249+
250+
return []swarm.Service{
251+
*Service(
252+
ServiceID("replicated"),
253+
ServiceName("01-replicated-service"),
254+
ReplicatedService(opts.desiredTasks),
255+
ServiceStatus(opts.desiredTasks, opts.runningTasks),
256+
),
257+
*Service(
258+
ServiceID("global"),
259+
ServiceName("02-global-service"),
260+
GlobalService(),
261+
ServiceStatus(opts.activeNodes, globalTasks),
262+
),
263+
*Service(
264+
ServiceID("none-id"),
265+
ServiceName("03-none-service"),
266+
),
267+
}
268+
}
269+
270+
func generateTasks(t *testing.T, services []swarm.Service, nodes []swarm.Node, opts clusterOpts) []swarm.Task {
271+
t.Helper()
272+
tasks := make([]swarm.Task, 0)
273+
274+
for _, s := range services {
275+
if s.Spec.Mode.Replicated == nil && s.Spec.Mode.Global == nil {
276+
continue
277+
}
278+
var runningTasks, failedTasks, desiredTasks uint64
279+
280+
// Set the number of desired tasks to generate, based on the service's mode
281+
if s.Spec.Mode.Replicated != nil {
282+
desiredTasks = *s.Spec.Mode.Replicated.Replicas
283+
} else if s.Spec.Mode.Global != nil {
284+
desiredTasks = opts.activeNodes
285+
}
286+
287+
for _, n := range nodes {
288+
if runningTasks < opts.runningTasks && n.Status.State != swarm.NodeStateDown {
289+
tasks = append(tasks, swarm.Task{
290+
NodeID: n.ID,
291+
ServiceID: s.ID,
292+
Status: swarm.TaskStatus{State: swarm.TaskStateRunning},
293+
DesiredState: swarm.TaskStateRunning,
294+
})
295+
runningTasks++
296+
}
297+
298+
// If the number of "running" tasks is lower than the desired number
299+
// of tasks of the service, fill in the remaining number of tasks
300+
// with failed tasks. These tasks have a desired "running" state,
301+
// and thus will be included when calculating the "desired" tasks
302+
// for services.
303+
if failedTasks < (desiredTasks - opts.runningTasks) {
304+
tasks = append(tasks, swarm.Task{
305+
NodeID: n.ID,
306+
ServiceID: s.ID,
307+
Status: swarm.TaskStatus{State: swarm.TaskStateFailed},
308+
DesiredState: swarm.TaskStateRunning,
309+
})
310+
failedTasks++
311+
}
312+
313+
// Also add tasks with DesiredState: Shutdown. These should not be
314+
// counted as running or desired tasks.
315+
tasks = append(tasks, swarm.Task{
316+
NodeID: n.ID,
317+
ServiceID: s.ID,
318+
Status: swarm.TaskStatus{State: swarm.TaskStateShutdown},
319+
DesiredState: swarm.TaskStateShutdown,
320+
})
321+
}
322+
}
323+
return tasks
324+
}
325+
326+
// generateNodes generates a "nodes" endpoint API response with the requested
327+
// number of "ready" nodes. In addition, a "down" node is generated.
328+
func generateNodes(t *testing.T, activeNodes uint64) []swarm.Node {
329+
t.Helper()
330+
nodes := make([]swarm.Node, 0)
331+
var i uint64
332+
for i = 0; i < activeNodes; i++ {
333+
nodes = append(nodes, swarm.Node{
334+
ID: fmt.Sprintf("node-ready-%d", i),
335+
Status: swarm.NodeStatus{State: swarm.NodeStateReady},
336+
})
337+
nodes = append(nodes, swarm.Node{
338+
ID: fmt.Sprintf("node-down-%d", i),
339+
Status: swarm.NodeStatus{State: swarm.NodeStateDown},
340+
})
341+
}
342+
return nodes
343+
}

0 commit comments

Comments
 (0)