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  }