@@ -2,12 +2,19 @@ package service
22
33import (
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