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 }