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  }