github.com/panekj/cli@v0.0.0-20230304125325-467dd2f3797e/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 // Import builders to get the builder function as package function 12 . "github.com/docker/cli/internal/test/builders" 13 "github.com/docker/docker/api/types" 14 "github.com/docker/docker/api/types/swarm" 15 "github.com/docker/docker/api/types/versions" 16 "gotest.tools/v3/assert" 17 is "gotest.tools/v3/assert/cmp" 18 "gotest.tools/v3/golden" 19 ) 20 21 func TestServiceListOrder(t *testing.T) { 22 cli := test.NewFakeCli(&fakeClient{ 23 serviceListFunc: func(ctx context.Context, options types.ServiceListOptions) ([]swarm.Service, error) { 24 return []swarm.Service{ 25 newService("a57dbe8", "service-1-foo"), 26 newService("a57dbdd", "service-10-foo"), 27 newService("aaaaaaa", "service-2-foo"), 28 }, nil 29 }, 30 }) 31 cmd := newListCommand(cli) 32 cmd.SetArgs([]string{}) 33 cmd.Flags().Set("format", "{{.Name}}") 34 assert.NilError(t, cmd.Execute()) 35 golden.Assert(t, cli.OutBuffer().String(), "service-list-sort.golden") 36 } 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 }