k8s.io/test-infra@v0.0.0-20240520184403-27c6b4c223d8/experiment/gerrit-onboarder/onboarder.go (about) 1 /* 2 Copyright 2021 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package main 18 19 import ( 20 "bytes" 21 "errors" 22 "flag" 23 "fmt" 24 "io" 25 "math/rand" 26 "os" 27 "os/exec" 28 "path" 29 "regexp" 30 "strings" 31 32 "github.com/sirupsen/logrus" 33 34 "sigs.k8s.io/prow/cmd/generic-autobumper/bumper" 35 ) 36 37 const ( 38 uuID = 0 39 groupName = 1 40 groupsFile = "groups" 41 projectConfigFile = "project.config" 42 43 accessHeader = `[access "refs/*"]` 44 prowReadAccessFormat = "read = group %s" 45 prowLabelAccessFormat = "label-Verified = -1..+1 group %s" 46 labelHeader = `[label "Verified"]` 47 labelEquals = "label-Verified =" 48 ) 49 50 var ( 51 labelLines = []string{ 52 "function = MaxWithBlock", 53 "value = -1 Failed", 54 "value = 0 No score", 55 "value = +1 Verified", 56 "copyAllScoresIfNoCodeChange = true", 57 "defaultValue = 0", 58 } 59 60 accessRefsRegex = regexp.MustCompile(`^\[access "refs\/.*"\]`) 61 ) 62 63 type options struct { 64 host string 65 repo string 66 uuID string 67 groupName string 68 dryRun bool 69 local bool 70 } 71 72 func parseAndValidateOptions() (*options, error) { 73 var o options 74 flag.StringVar(&o.host, "host", "", "The gerrit host.") 75 flag.StringVar(&o.repo, "repo", "", "The gerrit Repo.") 76 flag.StringVar(&o.uuID, "uuid", "", "The UUID to be added to the file.") 77 flag.StringVar(&o.groupName, "group", "", "The corresponding group name for the UUID.") 78 flag.BoolVar(&o.dryRun, "dry_run", false, "If dry_run is true, PR will not be created") 79 flag.BoolVar(&o.local, "local", false, "If local is true, changes will be made to local repo instead of new temp dir.") 80 flag.Parse() 81 82 if o.host == "" || o.repo == "" || o.uuID == "" || o.groupName == "" { 83 return &o, errors.New("host, repo, uuid, and group are all required fields") 84 } 85 86 return &o, nil 87 } 88 89 func intMax(x, y int) int { 90 if x > y { 91 return x 92 } 93 return y 94 } 95 96 func maxIDLen(values []string) int { 97 max := 0 98 for _, item := range values { 99 max = intMax(max, len(item)) 100 } 101 return intMax(max, len("# UUID")) 102 } 103 104 func getFormatString(maxLine int) string { 105 return "%-" + fmt.Sprintf("%d", maxLine) + "v\t%s\n" 106 } 107 108 func mapToGroups(groupsMap map[string]string, orderedUUIDs []string) string { 109 maxLine := maxIDLen(orderedUUIDs) 110 groups := fmt.Sprintf(getFormatString(maxLine), "# UUID", "Group Name") 111 112 for _, id := range orderedUUIDs { 113 if strings.HasPrefix(id, "#") { 114 groups = groups + id + "\n" 115 } else { 116 groups = groups + fmt.Sprintf(getFormatString(maxLine), id, groupsMap[id]) 117 } 118 } 119 return groups 120 } 121 122 func groupsToMap(groupsFile string) (map[string]string, []string) { 123 orderedKeys := []string{} 124 groupsMap := map[string]string{} 125 lines := strings.Split(groupsFile, "\n") 126 for _, line := range lines { 127 if !strings.HasPrefix(line, "# UUID") && line != "" { 128 if strings.HasPrefix(line, "#") { 129 orderedKeys = append(orderedKeys, line) 130 } else { 131 pair := strings.Split(line, "\t") 132 orderedKeys = append(orderedKeys, strings.TrimSpace(pair[uuID])) 133 groupsMap[strings.TrimSpace(pair[uuID])] = strings.TrimSpace(pair[groupName]) 134 } 135 136 } 137 } 138 return groupsMap, orderedKeys 139 } 140 141 func ensureUUID(groupsFile, uuid, group string) (string, error) { 142 groupsMap, orderedKeys := groupsToMap(groupsFile) 143 144 // Group already exists 145 if value, ok := groupsMap[uuid]; ok && group == value { 146 return groupsFile, nil 147 } 148 // UUID already exists with different group 149 if value, ok := groupsMap[uuid]; ok && group != value { 150 return "", fmt.Errorf("UUID, %s, already in use for group %s", uuid, value) 151 } 152 // Group name already in use with different UUID 153 for cur_id, groupName := range groupsMap { 154 if groupName == group { 155 return "", fmt.Errorf("%s already used as group name for %s", group, cur_id) 156 } 157 } 158 159 groupsMap[uuid] = group 160 orderedKeys = append(orderedKeys, uuid) 161 return mapToGroups(groupsMap, orderedKeys), nil 162 } 163 164 func updateGroups(workDir, uuid, group string) error { 165 data, err := os.ReadFile(path.Join(workDir, groupsFile)) 166 if err != nil { 167 return fmt.Errorf("failed to read groups file: %w", err) 168 } 169 170 newData, err := ensureUUID(string(data), uuid, group) 171 if err != nil { 172 return fmt.Errorf("failed to ensure group exists: %w", err) 173 } 174 175 err = os.WriteFile(path.Join(workDir, groupsFile), []byte(newData), 0755) 176 if err != nil { 177 return fmt.Errorf("failed to write groups file: %w", err) 178 } 179 180 return nil 181 } 182 183 func configToMap(configFile string) (map[string][]string, []string) { 184 configMap := map[string][]string{} 185 orderedKeys := []string{} 186 var curKey string 187 if configFile == "" { 188 return configMap, orderedKeys 189 } 190 for _, line := range strings.Split(configFile, "\n") { 191 if strings.HasPrefix(line, "[") { 192 curKey = line 193 orderedKeys = append(orderedKeys, line) 194 } else if line != "" { 195 if curList, ok := configMap[curKey]; ok { 196 configMap[curKey] = append(curList, line) 197 } else { 198 configMap[curKey] = []string{line} 199 } 200 } 201 } 202 return configMap, orderedKeys 203 } 204 205 func mapToConfig(configMap map[string][]string, orderedIDs []string) string { 206 res := "" 207 for _, header := range orderedIDs { 208 res = res + header + "\n" 209 for _, line := range configMap[header] { 210 res = res + line + "\n" 211 } 212 } 213 if res == "" { 214 return res 215 } 216 return strings.TrimSpace(res) + "\n" 217 } 218 219 func contains(s []string, v string) bool { 220 for _, item := range s { 221 if strings.TrimSpace(item) == strings.TrimSpace(v) { 222 return true 223 } 224 } 225 return false 226 } 227 228 func getInheritedRepo(configMap map[string][]string) string { 229 if section, ok := configMap["[access]"]; ok { 230 for _, line := range section { 231 if strings.Contains(line, "inheritFrom") { 232 return strings.TrimSpace(strings.Split(line, "=")[1]) 233 } 234 } 235 } 236 return "" 237 } 238 239 func addSection(header string, configMap map[string][]string, configOrder, neededLines []string) (map[string][]string, []string) { 240 if _, ok := configMap[header]; !ok { 241 configMap[header] = []string{} 242 configOrder = append(configOrder, header) 243 } 244 for _, line := range neededLines { 245 configMap[header] = append(configMap[header], "\t"+line) 246 } 247 248 return configMap, configOrder 249 } 250 251 func labelExists(configMap map[string][]string) bool { 252 _, ok := configMap[labelHeader] 253 return ok 254 } 255 256 func lineInMatchingHeaderFunc(regex *regexp.Regexp, line string) func(map[string][]string) bool { 257 return func(configMap map[string][]string) bool { 258 for header, lines := range configMap { 259 match := regex.MatchString(header) 260 if match { 261 if contains(lines, line) { 262 return true 263 } 264 } 265 } 266 return false 267 } 268 } 269 270 // returns a function that checks if a line exists anywhere in the config that sets sets "label-Verified" = to some values for the given group Name 271 // this is a best-attempt at checking if the group is given access to the label in as unitrusive way. 272 func labelAccessExistsFunc(groupName string) func(map[string][]string) bool { 273 return func(configMap map[string][]string) bool { 274 for _, value := range configMap { 275 for _, item := range value { 276 if strings.HasPrefix(strings.TrimSpace(item), labelEquals) && strings.HasSuffix(strings.TrimSpace(item), fmt.Sprintf("group %s", groupName)) { 277 return true 278 } 279 } 280 } 281 return false 282 } 283 } 284 285 func verifyInTree(workDir, host, cur_branch string, configMap map[string][]string, verify func(map[string][]string) bool) (bool, error) { 286 if verify(configMap) { 287 return true, nil 288 } else if inheritance := getInheritedRepo(configMap); inheritance != "" { 289 parent_branch := cur_branch + "_parent" 290 if err := fetchMetaConfig(host, inheritance, parent_branch, workDir); err != nil { 291 // This likely won't happen, but if the fail is due to switching branches, we want to fail 292 if strings.Contains(err.Error(), "failed to switch") { 293 return false, fmt.Errorf("unable to fetch refs/meta/config for %s: %w", inheritance, err) 294 } 295 // If it failed to fetch refs/meta/config for parent, or checkout the FETCH_HEAD, just catch the error and return False 296 return false, nil 297 } 298 data, err := os.ReadFile(path.Join(workDir, projectConfigFile)) 299 if err != nil { 300 return false, fmt.Errorf("failed to read project.config file: %w", err) 301 } 302 newConfig, _ := configToMap(string(data)) 303 ret, err := verifyInTree(workDir, host, parent_branch, newConfig, verify) 304 if err != nil { 305 return false, fmt.Errorf("failed to check if lines in config for %s/%s: %w", host, inheritance, err) 306 } 307 if err := execInDir(os.Stdout, os.Stderr, workDir, "git", "checkout", cur_branch); err != nil { 308 return false, fmt.Errorf("failed to checkout %s, %w", cur_branch, err) 309 } 310 if err := execInDir(os.Stdout, os.Stderr, workDir, "git", "branch", "-D", parent_branch); err != nil { 311 return false, fmt.Errorf("failed to delete %s branch, %w", parent_branch, err) 312 } 313 return ret, nil 314 } 315 return false, nil 316 } 317 318 func ensureProjectConfig(workDir, config, host, cur_branch, groupName string) (string, error) { 319 configMap, orderedKeys := configToMap(config) 320 321 // Check that prow automation robot has access to refs/* 322 accessLines := []string{} 323 readAccessLine := fmt.Sprintf(prowReadAccessFormat, groupName) 324 prowReadAccess, err := verifyInTree(workDir, host, cur_branch, configMap, lineInMatchingHeaderFunc(accessRefsRegex, readAccessLine)) 325 if err != nil { 326 return "", fmt.Errorf("failed to check if needed lines in config: %w", err) 327 } 328 if !prowReadAccess { 329 accessLines = append(accessLines, readAccessLine) 330 } 331 332 // Check that the line "label-verified" = ... group GROUPNAME exists under ANY header 333 labelAccessLine := fmt.Sprintf(prowLabelAccessFormat, groupName) 334 prowLabelAccess, err := verifyInTree(workDir, host, cur_branch, configMap, labelAccessExistsFunc(groupName)) 335 if err != nil { 336 return "", fmt.Errorf("failed to check if needed lines in config: %w", err) 337 } 338 if !prowLabelAccess { 339 accessLines = append(accessLines, labelAccessLine) 340 } 341 configMap, orderedKeys = addSection(accessHeader, configMap, orderedKeys, accessLines) 342 343 // We need to be less exact with the Label-Verified header so we are just checking if it exists anywhere: 344 labelExists, err := verifyInTree(workDir, host, cur_branch, configMap, labelExists) 345 if err != nil { 346 return "", fmt.Errorf("failed to check if needed lines in config: %w", err) 347 } 348 if !labelExists { 349 configMap, orderedKeys = addSection(labelHeader, configMap, orderedKeys, labelLines) 350 } 351 return mapToConfig(configMap, orderedKeys), nil 352 353 } 354 355 func updatePojectConfig(workDir, host, cur_branch, groupName string) error { 356 data, err := os.ReadFile(path.Join(workDir, projectConfigFile)) 357 if err != nil { 358 return fmt.Errorf("failed to read project.config file: %w", err) 359 } 360 361 newData, err := ensureProjectConfig(workDir, string(data), host, cur_branch, groupName) 362 if err != nil { 363 return fmt.Errorf("failed to ensure updated project config: %w", err) 364 } 365 err = os.WriteFile(path.Join(workDir, projectConfigFile), []byte(newData), 0755) 366 if err != nil { 367 return fmt.Errorf("failed to write groups file: %w", err) 368 } 369 370 return nil 371 } 372 373 func fetchMetaConfig(host, repo, branch, workDir string) error { 374 if err := execInDir(os.Stdout, os.Stderr, workDir, "git", "fetch", fmt.Sprintf("sso://%s/%s", host, repo), "refs/meta/config"); err != nil { 375 return fmt.Errorf("failed to fetch refs/meta/config, %w", err) 376 } 377 if err := execInDir(os.Stdout, os.Stderr, workDir, "git", "checkout", "FETCH_HEAD"); err != nil { 378 return fmt.Errorf("failed to checkout FETCH_HEAD, %w", err) 379 } 380 if err := execInDir(os.Stdout, os.Stderr, workDir, "git", "switch", "-c", branch); err != nil { 381 return fmt.Errorf("failed to switch to new branch, %w", err) 382 } 383 384 return nil 385 } 386 387 func execInDir(stdout, stderr io.Writer, dir string, cmd string, args ...string) error { 388 (&logrus.Logger{ 389 Out: os.Stderr, 390 Formatter: logrus.StandardLogger().Formatter, 391 Hooks: logrus.StandardLogger().Hooks, 392 Level: logrus.StandardLogger().Level, 393 }).WithField("dir", dir). 394 WithField("cmd", cmd). 395 // The default formatting uses a space as separator, which is hard to read if an arg contains a space 396 WithField("args", fmt.Sprintf("['%s']", strings.Join(args, "', '"))). 397 Info("running command") 398 399 c := exec.Command(cmd, args...) 400 c.Dir = dir 401 c.Stdout = stdout 402 c.Stderr = stderr 403 return c.Run() 404 } 405 406 func createCR(workDir string, dryRun bool) error { 407 diff, err := getDiff(workDir) 408 if err != nil { 409 return err 410 } 411 if diff == "" { 412 logrus.Info("No changes made. Returning without creating CR") 413 return nil 414 } 415 commitMessage := fmt.Sprintf("Grant the Prow cluster read and label permissions\n\nChange-Id: I%s", bumper.GitHash(fmt.Sprintf("%d", rand.Int()))) 416 if err := execInDir(os.Stdout, os.Stderr, workDir, "git", "commit", "-a", "-v", "-m", commitMessage); err != nil { 417 return fmt.Errorf("unable to commit: %w", err) 418 } 419 if !dryRun { 420 if err := execInDir(os.Stdout, os.Stderr, workDir, "git", "push", "origin", "HEAD:refs/for/refs/meta/config"); err != nil { 421 return fmt.Errorf("unable to push: %w", err) 422 } 423 } 424 return nil 425 } 426 427 func getDiff(workDir string) (string, error) { 428 var diffBuf bytes.Buffer 429 var errBuf bytes.Buffer 430 if err := execInDir(&diffBuf, &errBuf, workDir, "git", "diff"); err != nil { 431 return "", fmt.Errorf("diffing previous bump: %v -- %s", err, errBuf.String()) 432 } 433 return diffBuf.String(), nil 434 } 435 436 func getRepoClonedName(repo string) string { 437 lst := strings.Split(repo, "/") 438 return lst[len(lst)-1] 439 } 440 441 func main() { 442 o, err := parseAndValidateOptions() 443 if err != nil { 444 logrus.Fatal(err) 445 } 446 447 var workDir string 448 if o.local { 449 workDir, err = os.Getwd() 450 if err != nil { 451 logrus.Fatal(err) 452 } 453 } else { 454 workDir, err = os.MkdirTemp("", "gerrit_onboarding") 455 if err != nil { 456 logrus.Fatal(err) 457 } 458 defer os.RemoveAll(workDir) 459 460 if err := execInDir(os.Stdout, os.Stderr, workDir, "git", "clone", fmt.Sprintf("sso://%s/%s", o.host, o.repo)); err != nil { 461 logrus.Fatal(fmt.Errorf("failed to clone sso://%s/%s %w", o.host, o.repo, err)) 462 } 463 464 workDir = path.Join(workDir, getRepoClonedName(o.repo)) 465 } 466 467 branchName := fmt.Sprintf("gerritOnboarding_%d", rand.Int()) 468 469 if err = fetchMetaConfig(o.host, o.repo, branchName, workDir); err != nil { 470 logrus.Fatal(err) 471 } 472 473 // It is important that we update projectConfig BEFORE we update groups, because updating 474 // project config involves switching branches and we need to have no uncommitted changes to do that. 475 if err = updatePojectConfig(workDir, o.host, branchName, o.groupName); err != nil { 476 logrus.Fatal(err) 477 } 478 479 if err = updateGroups(workDir, o.uuID, o.groupName); err != nil { 480 logrus.Fatal(err) 481 } 482 483 if err = createCR(workDir, o.dryRun); err != nil { 484 logrus.Fatal(err) 485 } 486 487 }