go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/swarming/server/cfg/bots.go (about)

     1  // Copyright 2023 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 cfg
    16  
    17  import (
    18  	"strings"
    19  
    20  	"github.com/armon/go-radix"
    21  
    22  	"go.chromium.org/luci/common/data/stringset"
    23  	"go.chromium.org/luci/common/data/text/intsetexpr"
    24  	"go.chromium.org/luci/common/errors"
    25  	"go.chromium.org/luci/config/validation"
    26  
    27  	configpb "go.chromium.org/luci/swarming/proto/config"
    28  	"go.chromium.org/luci/swarming/server/validate"
    29  )
    30  
    31  // unassignedPools is returned as pools of a bot not in the config.
    32  var unassignedPools = []string{"unassigned"}
    33  
    34  // BotGroup is one parsed section of bots.cfg config.
    35  //
    36  // It defines configuration that applies to all bots within that section.
    37  type BotGroup struct {
    38  	// Dimensions is bot dimensions assigned to matched bots via the config.
    39  	//
    40  	// Includes as least "pool" dimension, but potentially more.
    41  	Dimensions map[string][]string
    42  
    43  	// TODO(vadimsh): Add the rest.
    44  }
    45  
    46  // Pools returns pools assigned to the bot or ["unassigned"] if not set.
    47  //
    48  // The returned slice always has at least one element.
    49  func (gr *BotGroup) Pools() []string {
    50  	if pools := gr.Dimensions["pool"]; len(pools) > 0 {
    51  		return pools
    52  	}
    53  	return unassignedPools
    54  }
    55  
    56  // HostBotID takes a bot ID like `<host>--<sfx>` and returns just `<host>`.
    57  //
    58  // Bot IDs like `<host>--<sfx>` are called composite. They are used to represent
    59  // multiple bots running on the same host (e.g. as docker containers) sharing
    60  // the same host credentials. The `<host>` part identifies this host. It is used
    61  // when checking the authentication tokens and looking up the bot group config.
    62  //
    63  // If the bot ID is not composite, returns it as is.
    64  func HostBotID(botID string) string {
    65  	if hostID, _, ok := strings.Cut(botID, "--"); ok {
    66  		return hostID
    67  	}
    68  	return botID
    69  }
    70  
    71  // botGroups contains parsed bots.cfg config.
    72  //
    73  // See Config.BotGroup(...) for where it is queried.
    74  type botGroups struct {
    75  	trustedDimensions []string             // dimensions enforced by the server
    76  	directMatches     map[string]*BotGroup // bot ID => its config
    77  	prefixMatches     *radix.Tree          // bot ID prefix => config
    78  	defaultGroup      *BotGroup            // the fallback, always non-nil
    79  }
    80  
    81  // newBotGroups converts bots.cfg into a queryable representation.
    82  //
    83  // bots.cfg here already passed the validation when it was first ingested. It
    84  // is possible the server code itself changed and the existing config is no
    85  // longer correct in some bad way. An error is returned in that case.
    86  func newBotGroups(cfg *configpb.BotsCfg) (*botGroups, error) {
    87  	bg := &botGroups{
    88  		trustedDimensions: stringset.NewFromSlice(cfg.TrustedDimensions...).ToSortedSlice(),
    89  		directMatches:     map[string]*BotGroup{},
    90  		prefixMatches:     radix.New(),
    91  		// This is the hardcoded default group that will be replaced by the default
    92  		// group from the config, if there's any. A default group is designated in
    93  		// the config by absence of bot_id and bot_id_prefix fields.
    94  		defaultGroup: &BotGroup{
    95  			Dimensions: map[string][]string{"pool": unassignedPools},
    96  		},
    97  	}
    98  
    99  	for _, gr := range cfg.BotGroup {
   100  		group, err := newBotGroup(gr)
   101  		if err != nil {
   102  			return nil, err
   103  		}
   104  		if len(gr.BotId) == 0 && len(gr.BotIdPrefix) == 0 {
   105  			bg.defaultGroup = group
   106  		} else {
   107  			for _, botIDExpr := range gr.BotId {
   108  				botIDs, err := intsetexpr.Expand(botIDExpr)
   109  				if err != nil {
   110  					return nil, errors.Annotate(err, "bad bot_id expression %q", botIDExpr).Err()
   111  				}
   112  				for _, botID := range botIDs {
   113  					bg.directMatches[botID] = group
   114  				}
   115  			}
   116  			for _, botPfx := range gr.BotIdPrefix {
   117  				bg.prefixMatches.Insert(botPfx, group)
   118  			}
   119  		}
   120  	}
   121  
   122  	return bg, nil
   123  }
   124  
   125  // newBotGroup constructs BotGroup from its proto representation.
   126  func newBotGroup(gr *configpb.BotGroup) (*BotGroup, error) {
   127  	dims := map[string][]string{}
   128  	for _, dim := range gr.Dimensions {
   129  		key, val, ok := strings.Cut(dim, ":")
   130  		if !ok {
   131  			return nil, errors.Reason("invalid bot dimension %q", dim).Err()
   132  		}
   133  		dims[key] = append(dims[key], val)
   134  	}
   135  	for key, val := range dims {
   136  		dims[key] = stringset.NewFromSlice(val...).ToSortedSlice()
   137  	}
   138  
   139  	// TODO(vadimsh): Add the rest.
   140  	return &BotGroup{
   141  		Dimensions: dims,
   142  	}, nil
   143  }
   144  
   145  // validateBotsCfg validates bots.cfg, writing errors into `ctx`.
   146  func validateBotsCfg(ctx *validation.Context, cfg *configpb.BotsCfg) {
   147  	seenPool := false
   148  	for i, dim := range cfg.TrustedDimensions {
   149  		seenPool = seenPool || dim == "pool"
   150  		ctx.Enter("trusted_dimensions #%d (%q)", i, dim)
   151  		if err := validate.DimensionKey(dim); err != nil {
   152  			ctx.Errorf("%s", err)
   153  		}
   154  		ctx.Exit()
   155  	}
   156  	if !seenPool {
   157  		ctx.Enter("trusted_dimensions")
   158  		ctx.Errorf(`"pool" must be specified as a trusted dimension`)
   159  		ctx.Exit()
   160  	}
   161  
   162  	// Explicitly mentioned bot_id => index of a group where it was mentioned.
   163  	botIDs := map[string]int{}
   164  	// bot_id_prefix => index of a group where it was defined.
   165  	botIDPrefixes := map[string]int{}
   166  	// Index of a group to use as a default fallback (there can be only one).
   167  	defaultGroupIdx := -1
   168  
   169  	// Validates bot_id value in a group and updates botIDs.
   170  	validateGroupBotID := func(botIDExpr string, idx int) {
   171  		if botIDExpr == "" {
   172  			ctx.Errorf("empty bot_id is not allowed")
   173  			return
   174  		}
   175  		ids, err := intsetexpr.Expand(botIDExpr)
   176  		if err != nil {
   177  			ctx.Errorf("bad bot_id expression: %s", err)
   178  			return
   179  		}
   180  		for _, botID := range ids {
   181  			if groupIdx, yes := botIDs[botID]; yes {
   182  				ctx.Errorf("bot_id %q was already mentioned in group #%d", botID, groupIdx)
   183  			} else {
   184  				botIDs[botID] = idx
   185  			}
   186  		}
   187  	}
   188  
   189  	// Validates bot_id_prefixes and updates botIDPrefixes.
   190  	validateGroupBotIDPrefix := func(botIDPfx string, idx int) {
   191  		if botIDPfx == "" {
   192  			ctx.Errorf("empty bot_id_prefix is not allowed")
   193  			return
   194  		}
   195  		if groupIdx, yes := botIDPrefixes[botIDPfx]; yes {
   196  			ctx.Errorf("bot_id_prefix %q is already specified in group #%d", botIDPfx, groupIdx)
   197  			return
   198  		}
   199  
   200  		// There should be no "intersecting" prefixes, they introduce ambiguities.
   201  		// This check is O(N^2) (considering validateGroupBotIDPrefix is called N
   202  		// times and the loop below does N iterations), but it executes only when
   203  		// the config is changing, so it is not a big deal.
   204  		for knownPfx, groupIdx := range botIDPrefixes {
   205  			if strings.HasPrefix(knownPfx, botIDPfx) {
   206  				ctx.Errorf(
   207  					"bot_id_prefix %q is a prefix of %q, defined in group #%d, "+
   208  						"making group assignment for bots with prefix %q ambiguous",
   209  					botIDPfx, knownPfx, groupIdx, knownPfx,
   210  				)
   211  			} else if strings.HasPrefix(botIDPfx, knownPfx) {
   212  				ctx.Errorf(
   213  					"bot_id_prefix %q starts with prefix %q, defined in group #%d, "+
   214  						"making group assignment for bots with prefix %q ambiguous",
   215  					botIDPfx, knownPfx, groupIdx, botIDPfx,
   216  				)
   217  			}
   218  		}
   219  
   220  		botIDPrefixes[botIDPfx] = idx
   221  	}
   222  
   223  	// Validates auth entry.
   224  	validateAuth := func(cfg *configpb.BotAuth) {
   225  		// TODO(vadimsh): Implement.
   226  	}
   227  
   228  	// Validates the string looks like an email.
   229  	validateEmail := func(val, what string) {
   230  		// TODO(vadimsh): Implement.
   231  	}
   232  
   233  	// Validates system_service_account field.
   234  	validateSystemServiceAccount := func(val string) {
   235  		// TODO(vadimsh): Implement.
   236  	}
   237  
   238  	// Validates a "key:val" dimension string.
   239  	validateFlatDimension := func(dim string) {
   240  		key, val, ok := strings.Cut(dim, ":")
   241  		if !ok {
   242  			ctx.Errorf(`not a "key:value" pair`)
   243  			return
   244  		}
   245  		if err := validate.DimensionKey(key); err != nil {
   246  			ctx.Errorf("bad dimension key %q: %s", key, err)
   247  		}
   248  		if err := validate.DimensionValue(val); err != nil {
   249  			ctx.Errorf("bad dimension value %q: %s", val, err)
   250  		}
   251  	}
   252  
   253  	// Validates a path looks like a python file name.
   254  	validateBotConfigScript := func(val string) {
   255  		// TODO(vadimsh): Implement.
   256  	}
   257  
   258  	for idx, gr := range cfg.BotGroup {
   259  		ctx.Enter("bot_group #%d", idx)
   260  
   261  		// Validate 'bot_id' field and make sure bot_id groups do not intersect.
   262  		for i, botIDExpr := range gr.BotId {
   263  			ctx.Enter("bot_id #%d (%q)", i, botIDExpr)
   264  			validateGroupBotID(botIDExpr, idx)
   265  			ctx.Exit()
   266  		}
   267  
   268  		// Validate 'bot_id_prefix' and make sure prefix groups do not intersect.
   269  		for i, botIDPfx := range gr.BotIdPrefix {
   270  			ctx.Enter("bot_id_prefix #%d (%q)", i, botIDPfx)
   271  			validateGroupBotIDPrefix(botIDPfx, idx)
   272  			ctx.Exit()
   273  		}
   274  
   275  		// A group without 'bot_id' and 'bot_id_prefix' is applied to bots that
   276  		// don't fit any other groups. There should be at most one such group.
   277  		if len(gr.BotId) == 0 && len(gr.BotIdPrefix) == 0 {
   278  			if defaultGroupIdx != -1 {
   279  				ctx.Errorf("group #%d is already set as default", defaultGroupIdx)
   280  			} else {
   281  				defaultGroupIdx = idx
   282  			}
   283  		}
   284  
   285  		// Validate 'auth' and 'system_service_account' fields.
   286  		if len(gr.Auth) == 0 {
   287  			ctx.Errorf(`an "auth" entry is required`)
   288  		} else {
   289  			for i, auth := range gr.Auth {
   290  				ctx.Enter("auth #%d", i)
   291  				validateAuth(auth)
   292  				ctx.Exit()
   293  			}
   294  		}
   295  		if gr.SystemServiceAccount != "" {
   296  			ctx.Enter("system_service_account (%q)", gr.SystemServiceAccount)
   297  			validateSystemServiceAccount(gr.SystemServiceAccount)
   298  			ctx.Exit()
   299  		}
   300  
   301  		// Validate 'owners'. Just check they are emails.
   302  		for i, entry := range gr.Owners {
   303  			ctx.Enter("owners #%d (%q)", i, entry)
   304  			validateEmail(entry, "owner")
   305  			ctx.Exit()
   306  		}
   307  
   308  		// Validate 'dimensions'.
   309  		for i, dim := range gr.Dimensions {
   310  			ctx.Enter("dimensions #%d (%q)", i, dim)
   311  			validateFlatDimension(dim)
   312  			ctx.Exit()
   313  		}
   314  
   315  		// Validate 'bot_config_script' looks like a python file name.
   316  		if gr.BotConfigScript != "" {
   317  			ctx.Enter("bot_config_script (%q)", gr.BotConfigScript)
   318  			validateBotConfigScript(gr.BotConfigScript)
   319  			ctx.Exit()
   320  		}
   321  
   322  		// 'bot_config_script_content' must be unset. It is used internally by
   323  		// Python code, we don't use it.
   324  		if len(gr.BotConfigScriptContent) != 0 {
   325  			ctx.Enter("bot_config_script_content")
   326  			ctx.Errorf("this field is used only internally and must be unset in the config")
   327  			ctx.Exit()
   328  		}
   329  
   330  		ctx.Exit()
   331  	}
   332  }