go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/swarming/server/rpcs/bots_list_bots_test.go (about) 1 // Copyright 2024 The LUCI Authors. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 package rpcs 16 17 import ( 18 "context" 19 "fmt" 20 "testing" 21 22 "google.golang.org/grpc/codes" 23 24 "go.chromium.org/luci/gae/impl/memory" 25 "go.chromium.org/luci/gae/service/datastore" 26 27 apipb "go.chromium.org/luci/swarming/proto/api_v2" 28 "go.chromium.org/luci/swarming/server/acls" 29 "go.chromium.org/luci/swarming/server/model" 30 31 . "github.com/smartystreets/goconvey/convey" 32 . "go.chromium.org/luci/common/testing/assertions" 33 ) 34 35 // setupTestBots mocks a bunch of bots and pools. 36 func setupTestBots(ctx context.Context) *MockedRequestState { 37 state := NewMockedRequestState() 38 state.Configs.Settings.BotDeathTimeoutSecs = 1234 39 40 state.MockPool("visible-pool1", "project:visible-realm") 41 state.MockPool("visible-pool2", "project:visible-realm") 42 state.MockPool("hidden-pool1", "project:hidden-realm") 43 state.MockPool("hidden-pool2", "project:hidden-realm") 44 45 state.MockPerm("project:visible-realm", acls.PermPoolsListBots) 46 47 type testBot struct { 48 id string 49 pool string 50 dims []string 51 quarantined bool 52 maintenance bool 53 busy bool 54 dead bool 55 } 56 57 testBots := []testBot{} 58 addMany := func(num int, pfx testBot) { 59 id := pfx.id 60 for i := 0; i < num; i++ { 61 pfx.id = fmt.Sprintf("%s-%d", id, i) 62 pfx.dims = []string{fmt.Sprintf("idx:%d", i), fmt.Sprintf("dup:%d", i)} 63 testBots = append(testBots, pfx) 64 } 65 } 66 67 addMany(3, testBot{ 68 id: "visible1", 69 pool: "visible-pool1", 70 }) 71 addMany(3, testBot{ 72 id: "visible2", 73 pool: "visible-pool2", 74 }) 75 addMany(3, testBot{ 76 id: "quarantined", 77 pool: "visible-pool1", 78 quarantined: true, 79 }) 80 addMany(3, testBot{ 81 id: "maintenance", 82 pool: "visible-pool1", 83 maintenance: true, 84 }) 85 addMany(3, testBot{ 86 id: "busy", 87 pool: "visible-pool1", 88 busy: true, 89 }) 90 addMany(3, testBot{ 91 id: "dead", 92 pool: "visible-pool1", 93 dead: true, 94 }) 95 addMany(3, testBot{ 96 id: "hidden1", 97 pool: "hidden-pool1", 98 }) 99 addMany(3, testBot{ 100 id: "hidden2", 101 pool: "hidden-pool2", 102 }) 103 104 for _, bot := range testBots { 105 pick := func(attr bool, yes model.BotStateEnum, no model.BotStateEnum) model.BotStateEnum { 106 if attr { 107 return yes 108 } 109 return no 110 } 111 state.MockBot(bot.id, bot.pool) // add it to ACLs 112 err := datastore.Put(ctx, &model.BotInfo{ 113 Key: model.BotInfoKey(ctx, bot.id), 114 Dimensions: append(bot.dims, "pool:"+bot.pool), 115 Composite: []model.BotStateEnum{ 116 pick(bot.maintenance, model.BotStateInMaintenance, model.BotStateNotInMaintenance), 117 pick(bot.dead, model.BotStateDead, model.BotStateAlive), 118 pick(bot.quarantined, model.BotStateQuarantined, model.BotStateHealthy), 119 pick(bot.busy, model.BotStateBusy, model.BotStateIdle), 120 }, 121 }) 122 if err != nil { 123 panic(err) 124 } 125 } 126 127 return state 128 } 129 130 func TestListBots(t *testing.T) { 131 t.Parallel() 132 133 ctx := memory.Use(context.Background()) 134 datastore.GetTestable(ctx).AutoIndex(true) 135 datastore.GetTestable(ctx).Consistent(true) 136 137 state := setupTestBots(ctx) 138 139 callImpl := func(ctx context.Context, req *apipb.BotsRequest) (*apipb.BotInfoListResponse, error) { 140 return (&BotsServer{ 141 // memory.Use(...) datastore fake doesn't support IN queries currently. 142 BotQuerySplitMode: model.SplitCompletely, 143 }).ListBots(ctx, req) 144 } 145 call := func(req *apipb.BotsRequest) (*apipb.BotInfoListResponse, error) { 146 return callImpl(MockRequestState(ctx, state), req) 147 } 148 callAsAdmin := func(req *apipb.BotsRequest) (*apipb.BotInfoListResponse, error) { 149 return callImpl(MockRequestState(ctx, state.SetCaller(AdminFakeCaller)), req) 150 } 151 152 botIDs := func(bots []*apipb.BotInfo) []string { 153 var ids []string 154 for _, bot := range bots { 155 ids = append(ids, bot.BotId) 156 } 157 return ids 158 } 159 160 Convey("Limit is checked", t, func() { 161 _, err := call(&apipb.BotsRequest{ 162 Limit: -10, 163 }) 164 So(err, ShouldHaveGRPCStatus, codes.InvalidArgument) 165 _, err = call(&apipb.BotsRequest{ 166 Limit: 1001, 167 }) 168 So(err, ShouldHaveGRPCStatus, codes.InvalidArgument) 169 }) 170 171 Convey("Cursor is checked", t, func() { 172 _, err := call(&apipb.BotsRequest{ 173 Cursor: "!!!!", 174 }) 175 So(err, ShouldHaveGRPCStatus, codes.InvalidArgument) 176 }) 177 178 Convey("Dimensions filter is checked", t, func() { 179 _, err := call(&apipb.BotsRequest{ 180 Dimensions: []*apipb.StringPair{ 181 {Key: "", Value: ""}, 182 }, 183 }) 184 So(err, ShouldHaveGRPCStatus, codes.InvalidArgument) 185 }) 186 187 Convey("ACLs", t, func() { 188 Convey("Listing only visible pools: OK", func() { 189 _, err := call(&apipb.BotsRequest{ 190 Dimensions: []*apipb.StringPair{ 191 {Key: "pool", Value: "visible-pool1|visible-pool2"}, 192 }, 193 }) 194 So(err, ShouldBeNil) 195 }) 196 197 Convey("Listing visible and invisible pool: permission denied", func() { 198 _, err := call(&apipb.BotsRequest{ 199 Dimensions: []*apipb.StringPair{ 200 {Key: "pool", Value: "visible-pool1|hidden-pool1"}, 201 }, 202 }) 203 So(err, ShouldHaveGRPCStatus, codes.PermissionDenied) 204 }) 205 206 Convey("Listing visible and invisible pool as admin: OK", func() { 207 _, err := callAsAdmin(&apipb.BotsRequest{ 208 Dimensions: []*apipb.StringPair{ 209 {Key: "pool", Value: "visible-pool1|hidden-pool1"}, 210 }, 211 }) 212 So(err, ShouldBeNil) 213 }) 214 215 Convey("Listing all pools as non-admin: permission denied", func() { 216 _, err := call(&apipb.BotsRequest{}) 217 So(err, ShouldHaveGRPCStatus, codes.PermissionDenied) 218 }) 219 220 Convey("Listing all pools as admin: OK", func() { 221 _, err := callAsAdmin(&apipb.BotsRequest{}) 222 So(err, ShouldBeNil) 223 }) 224 }) 225 226 Convey("Filtering and cursors", t, func() { 227 checkQuery := func(req *apipb.BotsRequest, expected []string) { 228 // An unlimited query first. 229 resp, err := callAsAdmin(req) 230 So(err, ShouldBeNil) 231 So(botIDs(resp.Items), ShouldResemble, expected) 232 233 // A paginated one should return the same results. 234 var out []*apipb.BotInfo 235 var cursor string 236 for { 237 req.Cursor = cursor 238 req.Limit = 2 239 resp, err := callAsAdmin(req) 240 So(err, ShouldBeNil) 241 So(len(resp.Items), ShouldBeLessThanOrEqualTo, req.Limit) 242 out = append(out, resp.Items...) 243 cursor = resp.Cursor 244 if cursor == "" { 245 break 246 } 247 } 248 So(botIDs(out), ShouldResemble, expected) 249 } 250 251 Convey("No filters", func() { 252 checkQuery( 253 &apipb.BotsRequest{}, 254 []string{ 255 "busy-0", "busy-1", "busy-2", 256 "dead-0", "dead-1", "dead-2", 257 "hidden1-0", "hidden1-1", "hidden1-2", 258 "hidden2-0", "hidden2-1", "hidden2-2", 259 "maintenance-0", "maintenance-1", "maintenance-2", 260 "quarantined-0", "quarantined-1", "quarantined-2", 261 "visible1-0", "visible1-1", "visible1-2", 262 "visible2-0", "visible2-1", "visible2-2", 263 }, 264 ) 265 }) 266 267 Convey("Busy only", func() { 268 checkQuery( 269 &apipb.BotsRequest{ 270 IsBusy: apipb.NullableBool_TRUE, 271 }, 272 []string{ 273 "busy-0", "busy-1", "busy-2", 274 }, 275 ) 276 }) 277 278 Convey("Dead only", func() { 279 checkQuery( 280 &apipb.BotsRequest{ 281 IsDead: apipb.NullableBool_TRUE, 282 }, 283 []string{ 284 "dead-0", "dead-1", "dead-2", 285 }, 286 ) 287 }) 288 289 Convey("Maintenance only", func() { 290 checkQuery( 291 &apipb.BotsRequest{ 292 InMaintenance: apipb.NullableBool_TRUE, 293 }, 294 []string{ 295 "maintenance-0", "maintenance-1", "maintenance-2", 296 }, 297 ) 298 }) 299 300 Convey("Quarantined only", func() { 301 checkQuery( 302 &apipb.BotsRequest{ 303 Quarantined: apipb.NullableBool_TRUE, 304 }, 305 []string{ 306 "quarantined-0", "quarantined-1", "quarantined-2", 307 }, 308 ) 309 }) 310 311 // Note: assuming all "positive" filter checks passed, it is sufficient to 312 // test only one "negative" filter. Negative tests are just a tweak in 313 // a code path already tested by "positive" filters. 314 Convey("Non-busy only", func() { 315 checkQuery( 316 &apipb.BotsRequest{ 317 IsBusy: apipb.NullableBool_FALSE, 318 }, 319 []string{ 320 "dead-0", "dead-1", "dead-2", 321 "hidden1-0", "hidden1-1", "hidden1-2", 322 "hidden2-0", "hidden2-1", "hidden2-2", 323 "maintenance-0", "maintenance-1", "maintenance-2", 324 "quarantined-0", "quarantined-1", "quarantined-2", 325 "visible1-0", "visible1-1", "visible1-2", 326 "visible2-0", "visible2-1", "visible2-2", 327 }, 328 ) 329 }) 330 331 Convey("Empty state intersection", func() { 332 checkQuery( 333 &apipb.BotsRequest{ 334 IsBusy: apipb.NullableBool_TRUE, 335 IsDead: apipb.NullableBool_TRUE, 336 }, nil) 337 }) 338 339 Convey("Simple dimension filter", func() { 340 checkQuery( 341 &apipb.BotsRequest{ 342 Dimensions: []*apipb.StringPair{ 343 {Key: "idx", Value: "1"}, 344 }, 345 }, 346 []string{ 347 "busy-1", 348 "dead-1", 349 "hidden1-1", 350 "hidden2-1", 351 "maintenance-1", 352 "quarantined-1", 353 "visible1-1", 354 "visible2-1", 355 }, 356 ) 357 }) 358 359 Convey("Simple dimension filter + state filter", func() { 360 checkQuery( 361 &apipb.BotsRequest{ 362 Dimensions: []*apipb.StringPair{ 363 {Key: "idx", Value: "1"}, 364 }, 365 IsDead: apipb.NullableBool_TRUE, 366 }, 367 []string{ 368 "dead-1", 369 }, 370 ) 371 }) 372 373 Convey("AND dimension filter", func() { 374 checkQuery( 375 &apipb.BotsRequest{ 376 Dimensions: []*apipb.StringPair{ 377 {Key: "idx", Value: "1"}, 378 {Key: "pool", Value: "visible-pool2"}, 379 }, 380 }, 381 []string{ 382 "visible2-1", 383 }, 384 ) 385 }) 386 387 Convey("Complex OR dimension filter", func() { 388 checkQuery( 389 &apipb.BotsRequest{ 390 Dimensions: []*apipb.StringPair{ 391 {Key: "idx", Value: "0|2|ignore"}, 392 {Key: "pool", Value: "visible-pool2|visible-pool1"}, 393 }, 394 }, 395 []string{ 396 // Note: no hidden* bots here. 397 "busy-0", "busy-2", 398 "dead-0", "dead-2", 399 "maintenance-0", "maintenance-2", 400 "quarantined-0", "quarantined-2", 401 "visible1-0", "visible1-2", 402 "visible2-0", "visible2-2", 403 }, 404 ) 405 }) 406 407 Convey("Complex OR dimension filter + state filter", func() { 408 checkQuery( 409 &apipb.BotsRequest{ 410 Dimensions: []*apipb.StringPair{ 411 {Key: "idx", Value: "0|2|ignore"}, 412 {Key: "pool", Value: "visible-pool2|visible-pool1"}, 413 }, 414 IsDead: apipb.NullableBool_TRUE, 415 }, 416 []string{ 417 "dead-0", "dead-2", 418 }, 419 ) 420 }) 421 }) 422 423 Convey("DeathTimeout", t, func() { 424 resp, err := callAsAdmin(&apipb.BotsRequest{}) 425 So(err, ShouldBeNil) 426 So(resp.DeathTimeout, ShouldEqual, state.Configs.Settings.BotDeathTimeoutSecs) 427 }) 428 }