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