vitess.io/vitess@v0.16.2/go/tools/go-upgrade/go-upgrade.go (about)

     1  /*
     2  Copyright 2023 The Vitess 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  	"fmt"
    21  	"io"
    22  	"log"
    23  	"net/http"
    24  	"os"
    25  	"path"
    26  	"regexp"
    27  	"strconv"
    28  	"strings"
    29  	"time"
    30  
    31  	"encoding/json"
    32  
    33  	"github.com/hashicorp/go-version"
    34  	"github.com/spf13/cobra"
    35  )
    36  
    37  const (
    38  	goDevAPI = "https://go.dev/dl/?mode=json"
    39  )
    40  
    41  type (
    42  	latestGolangRelease struct {
    43  		Version string `json:"version"`
    44  		Stable  bool   `json:"stable"`
    45  	}
    46  
    47  	bootstrapVersion struct {
    48  		major, minor int // when minor == -1, it means there are no minor version
    49  	}
    50  )
    51  
    52  var (
    53  	workflowUpdate    = true
    54  	allowMajorUpgrade = false
    55  	isMainBranch      = false
    56  	goTo              = ""
    57  
    58  	rootCmd = &cobra.Command{
    59  		Use:   "go-upgrade",
    60  		Short: "Automates the Golang upgrade.",
    61  		Long: `go-upgrade allows us to automate some tasks required to bump the version of Golang used throughout our codebase.
    62  
    63  It mostly used by the update_golang_version.yml CI workflow that runs on a CRON.
    64  
    65  This tool is meant to be run at the root of the repository.
    66  `,
    67  		Run: func(cmd *cobra.Command, args []string) {
    68  			_ = cmd.Help()
    69  		},
    70  		Args: cobra.NoArgs,
    71  	}
    72  
    73  	getCmd = &cobra.Command{
    74  		Use:   "get",
    75  		Short: "Command to get useful information about the codebase.",
    76  		Long:  "Command to get useful information about the codebase.",
    77  		Run: func(cmd *cobra.Command, args []string) {
    78  			_ = cmd.Help()
    79  		},
    80  		Args: cobra.NoArgs,
    81  	}
    82  
    83  	getGoCmd = &cobra.Command{
    84  		Use:   "go-version",
    85  		Short: "go-version prints the Golang version used by the current codebase.",
    86  		Long:  "go-version prints the Golang version used by the current codebase.",
    87  		Run:   runGetGoCmd,
    88  		Args:  cobra.NoArgs,
    89  	}
    90  
    91  	getBootstrapCmd = &cobra.Command{
    92  		Use:   "bootstrap-version",
    93  		Short: "bootstrap-version prints the Docker Bootstrap version used by the current codebase.",
    94  		Long:  "bootstrap-version prints the Docker Bootstrap version used by the current codebase.",
    95  		Run:   runGetBootstrapCmd,
    96  		Args:  cobra.NoArgs,
    97  	}
    98  
    99  	upgradeCmd = &cobra.Command{
   100  		Use:   "upgrade",
   101  		Short: "upgrade will upgrade the Golang and Bootstrap versions of the codebase to the latest available version.",
   102  		Long: `This command bumps the Golang and Bootstrap versions of the codebase.
   103  
   104  The latest available version of Golang will be fetched and used instead of the old version.
   105  
   106  By default, we do not allow major Golang version upgrade such as 1.20 to 1.21 but this can be overridden using the
   107  --allow-major-upgrade CLI flag. Usually, we only allow such upgrade on the main branch of the repository.
   108  
   109  In CI, particularly, we do not want to modify the workflow files before automatically creating a Pull Request to
   110  avoid permission issues. The rewrite of workflow files can be disabled using the --workflow-update=false CLI flag.
   111  
   112  Moreover, this command automatically bumps the bootstrap version of our codebase. If we are on the main branch, we
   113  want to use the CLI flag --main to remember to increment the bootstrap version by 1 instead of 0.1.`,
   114  		Run:  runUpgradeCmd,
   115  		Args: cobra.NoArgs,
   116  	}
   117  
   118  	upgradeWorkflowsCmd = &cobra.Command{
   119  		Use:   "workflows",
   120  		Short: "workflows will upgrade the Golang version used in our CI workflows files.",
   121  		Long:  "This step is omitted by the bot since. We let the maintainers of Vitess manually upgrade the version used by the workflows using this command.",
   122  		Run:   runUpgradeWorkflowsCmd,
   123  		Args:  cobra.NoArgs,
   124  	}
   125  )
   126  
   127  func init() {
   128  	rootCmd.AddCommand(getCmd)
   129  	rootCmd.AddCommand(upgradeCmd)
   130  
   131  	getCmd.AddCommand(getGoCmd)
   132  	getCmd.AddCommand(getBootstrapCmd)
   133  
   134  	upgradeCmd.AddCommand(upgradeWorkflowsCmd)
   135  
   136  	upgradeCmd.Flags().BoolVar(&workflowUpdate, "workflow-update", workflowUpdate, "Whether or not the workflow files should be updated. Useful when using this script to auto-create PRs.")
   137  	upgradeCmd.Flags().BoolVar(&allowMajorUpgrade, "allow-major-upgrade", allowMajorUpgrade, "Defines if Golang major version upgrade are allowed.")
   138  	upgradeCmd.Flags().BoolVar(&isMainBranch, "main", isMainBranch, "Defines if the current branch is the main branch.")
   139  
   140  	upgradeWorkflowsCmd.Flags().StringVar(&goTo, "go-to", goTo, "The Golang version we want to upgrade to.")
   141  }
   142  
   143  func main() {
   144  	cobra.CheckErr(rootCmd.Execute())
   145  }
   146  
   147  func runGetGoCmd(_ *cobra.Command, _ []string) {
   148  	currentVersion, err := currentGolangVersion()
   149  	if err != nil {
   150  		log.Fatal(err)
   151  	}
   152  	fmt.Println(currentVersion.String())
   153  }
   154  
   155  func runGetBootstrapCmd(_ *cobra.Command, _ []string) {
   156  	currentVersion, err := currentBootstrapVersion()
   157  	if err != nil {
   158  		log.Fatal(err)
   159  	}
   160  	fmt.Println(currentVersion.toString())
   161  }
   162  
   163  func runUpgradeWorkflowsCmd(_ *cobra.Command, _ []string) {
   164  	err := updateWorkflowFilesOnly(goTo)
   165  	if err != nil {
   166  		log.Fatal(err)
   167  	}
   168  }
   169  
   170  func runUpgradeCmd(_ *cobra.Command, _ []string) {
   171  	err := upgradePath(allowMajorUpgrade, workflowUpdate, isMainBranch)
   172  	if err != nil {
   173  		log.Fatal(err)
   174  	}
   175  }
   176  
   177  func updateWorkflowFilesOnly(goTo string) error {
   178  	newV, err := version.NewVersion(goTo)
   179  	if err != nil {
   180  		return err
   181  	}
   182  	filesToChange, err := getListOfFilesInPaths([]string{"./.github/workflows"})
   183  	if err != nil {
   184  		return err
   185  	}
   186  
   187  	for _, fileToChange := range filesToChange {
   188  		err = replaceInFile(
   189  			[]*regexp.Regexp{regexp.MustCompile(`go-version:[[:space:]]*([0-9.]+).*`)},
   190  			[]string{"go-version: " + newV.String()},
   191  			fileToChange,
   192  		)
   193  		if err != nil {
   194  			return err
   195  		}
   196  	}
   197  	return nil
   198  }
   199  
   200  func upgradePath(allowMajorUpgrade, workflowUpdate, isMainBranch bool) error {
   201  	currentVersion, err := currentGolangVersion()
   202  	if err != nil {
   203  		return err
   204  	}
   205  
   206  	availableVersions, err := getLatestStableGolangReleases()
   207  	if err != nil {
   208  		return err
   209  	}
   210  
   211  	upgradeTo := chooseNewVersion(currentVersion, availableVersions, allowMajorUpgrade)
   212  	if upgradeTo == nil {
   213  		return nil
   214  	}
   215  
   216  	err = replaceGoVersionInCodebase(currentVersion, upgradeTo, workflowUpdate)
   217  	if err != nil {
   218  		return err
   219  	}
   220  
   221  	currentBootstrapVersionF, err := currentBootstrapVersion()
   222  	if err != nil {
   223  		return err
   224  	}
   225  	nextBootstrapVersionF := currentBootstrapVersionF
   226  	if isMainBranch {
   227  		nextBootstrapVersionF.major += 1
   228  	} else {
   229  		nextBootstrapVersionF.minor += 1
   230  	}
   231  	err = updateBootstrapVersionInCodebase(currentBootstrapVersionF.toString(), nextBootstrapVersionF.toString(), upgradeTo)
   232  	if err != nil {
   233  		return err
   234  	}
   235  	return nil
   236  }
   237  
   238  // currentGolangVersion gets the running version of Golang in Vitess
   239  // and returns it as a *version.Version.
   240  //
   241  // The file `./build.env` describes which version of Golang is expected by Vitess.
   242  // We use this file to detect the current Golang version of our codebase.
   243  // The file contains `goversion_min x.xx.xx`, we will grep `goversion_min` to finally find
   244  // the precise golang version we're using.
   245  func currentGolangVersion() (*version.Version, error) {
   246  	contentRaw, err := os.ReadFile("build.env")
   247  	if err != nil {
   248  		return nil, err
   249  	}
   250  	content := string(contentRaw)
   251  
   252  	versre := regexp.MustCompile("(?i).*goversion_min[[:space:]]*([0-9.]+).*")
   253  	versionStr := versre.FindStringSubmatch(content)
   254  	if len(versionStr) != 2 {
   255  		return nil, fmt.Errorf("malformatted error, got: %v", versionStr)
   256  	}
   257  	return version.NewVersion(versionStr[1])
   258  }
   259  
   260  func currentBootstrapVersion() (bootstrapVersion, error) {
   261  	contentRaw, err := os.ReadFile("Makefile")
   262  	if err != nil {
   263  		return bootstrapVersion{}, err
   264  	}
   265  	content := string(contentRaw)
   266  
   267  	versre := regexp.MustCompile("(?i).*BOOTSTRAP_VERSION[[:space:]]*=[[:space:]]*([0-9.]+).*")
   268  	versionStr := versre.FindStringSubmatch(content)
   269  	if len(versionStr) != 2 {
   270  		return bootstrapVersion{}, fmt.Errorf("malformatted error, got: %v", versionStr)
   271  	}
   272  
   273  	vs := strings.Split(versionStr[1], ".")
   274  	major, err := strconv.Atoi(vs[0])
   275  	if err != nil {
   276  		return bootstrapVersion{}, err
   277  	}
   278  
   279  	minor := -1
   280  	if len(vs) > 1 {
   281  		minor, err = strconv.Atoi(vs[1])
   282  		if err != nil {
   283  			return bootstrapVersion{}, err
   284  		}
   285  	}
   286  
   287  	return bootstrapVersion{
   288  		major: major,
   289  		minor: minor,
   290  	}, nil
   291  }
   292  
   293  // getLatestStableGolangReleases fetches the latest stable releases of Golang from
   294  // the official website using the goDevAPI URL.
   295  // Once fetched, the releases are returned as version.Collection.
   296  func getLatestStableGolangReleases() (version.Collection, error) {
   297  	resp, err := http.Get(goDevAPI)
   298  	if err != nil {
   299  		return nil, err
   300  	}
   301  	defer resp.Body.Close()
   302  
   303  	body, err := io.ReadAll(resp.Body)
   304  	if err != nil {
   305  		return nil, err
   306  	}
   307  
   308  	var latestGoReleases []latestGolangRelease
   309  	err = json.Unmarshal(body, &latestGoReleases)
   310  	if err != nil {
   311  		return nil, err
   312  	}
   313  
   314  	var versions version.Collection
   315  	for _, release := range latestGoReleases {
   316  		if !release.Stable {
   317  			continue
   318  		}
   319  		if !strings.HasPrefix(release.Version, "go") {
   320  			return nil, fmt.Errorf("golang version malformatted: %s", release.Version)
   321  		}
   322  		newVersion, err := version.NewVersion(release.Version[2:])
   323  		if err != nil {
   324  			return nil, err
   325  		}
   326  		versions = append(versions, newVersion)
   327  	}
   328  	return versions, nil
   329  }
   330  
   331  // chooseNewVersion decides what will be the next version we're going to use in our codebase.
   332  // Given the current Golang version, the available latest versions and whether we allow major upgrade or not,
   333  // chooseNewVersion will return either the new version or nil if we cannot/don't need to upgrade.
   334  func chooseNewVersion(curVersion *version.Version, latestVersions version.Collection, allowMajorUpgrade bool) *version.Version {
   335  	selectedVersion := curVersion
   336  	for _, latestVersion := range latestVersions {
   337  		if !allowMajorUpgrade && !isSameMajorMinorVersion(latestVersion, selectedVersion) {
   338  			continue
   339  		}
   340  		if latestVersion.GreaterThan(selectedVersion) {
   341  			selectedVersion = latestVersion
   342  		}
   343  	}
   344  	// No change detected, return nil meaning that we do not want to have a new Golang version.
   345  	if selectedVersion.Equal(curVersion) {
   346  		return nil
   347  	}
   348  	return selectedVersion
   349  }
   350  
   351  // replaceGoVersionInCodebase goes through all the files in the codebase where the
   352  // Golang version must be updated
   353  func replaceGoVersionInCodebase(old, new *version.Version, workflowUpdate bool) error {
   354  	if old.Equal(new) {
   355  		return nil
   356  	}
   357  	explore := []string{
   358  		"./test/templates",
   359  		"./build.env",
   360  		"./docker/bootstrap/Dockerfile.common",
   361  	}
   362  	if workflowUpdate {
   363  		explore = append(explore, "./.github/workflows")
   364  	}
   365  	filesToChange, err := getListOfFilesInPaths(explore)
   366  	if err != nil {
   367  		return err
   368  	}
   369  
   370  	for _, fileToChange := range filesToChange {
   371  		err = replaceInFile(
   372  			[]*regexp.Regexp{regexp.MustCompile(fmt.Sprintf(`(%s)`, old.String()))},
   373  			[]string{new.String()},
   374  			fileToChange,
   375  		)
   376  		if err != nil {
   377  			return err
   378  		}
   379  	}
   380  
   381  	if !isSameMajorMinorVersion(old, new) {
   382  		err = replaceInFile(
   383  			[]*regexp.Regexp{regexp.MustCompile(`go[[:space:]]*([0-9.]+)`)},
   384  			[]string{fmt.Sprintf("go %d.%d", new.Segments()[0], new.Segments()[1])},
   385  			"./go.mod",
   386  		)
   387  		if err != nil {
   388  			return err
   389  		}
   390  	}
   391  	return nil
   392  }
   393  
   394  func updateBootstrapVersionInCodebase(old, new string, newGoVersion *version.Version) error {
   395  	if old == new {
   396  		return nil
   397  	}
   398  	files, err := getListOfFilesInPaths([]string{
   399  		"./docker/base",
   400  		"./docker/lite",
   401  		"./docker/local",
   402  		"./docker/vttestserver",
   403  		"./Makefile",
   404  		"./test/templates",
   405  	})
   406  	if err != nil {
   407  		return err
   408  	}
   409  
   410  	for _, file := range files {
   411  		err = replaceInFile(
   412  			[]*regexp.Regexp{
   413  				regexp.MustCompile(`ARG[[:space:]]*bootstrap_version[[:space:]]*=[[:space:]]*[0-9.]+`), // Dockerfile
   414  				regexp.MustCompile(`BOOTSTRAP_VERSION[[:space:]]*=[[:space:]]*[0-9.]+`),                // Makefile
   415  			},
   416  			[]string{
   417  				fmt.Sprintf("ARG bootstrap_version=%s", new), // Dockerfile
   418  				fmt.Sprintf("BOOTSTRAP_VERSION=%s", new),     // Makefile
   419  			},
   420  			file,
   421  		)
   422  		if err != nil {
   423  			return err
   424  		}
   425  	}
   426  
   427  	err = replaceInFile(
   428  		[]*regexp.Regexp{regexp.MustCompile(`\"bootstrap-version\",[[:space:]]*\"([0-9.]+)\"`)},
   429  		[]string{fmt.Sprintf("\"bootstrap-version\", \"%s\"", new)},
   430  		"./test.go",
   431  	)
   432  	if err != nil {
   433  		return err
   434  	}
   435  
   436  	err = updateBootstrapChangelog(new, newGoVersion)
   437  	if err != nil {
   438  		return err
   439  	}
   440  
   441  	return nil
   442  }
   443  
   444  func updateBootstrapChangelog(new string, goVersion *version.Version) error {
   445  	file, err := os.OpenFile("./docker/bootstrap/CHANGELOG.md", os.O_RDWR, 0600)
   446  	if err != nil {
   447  		return err
   448  	}
   449  	defer file.Close()
   450  
   451  	s, err := file.Stat()
   452  	if err != nil {
   453  		return err
   454  	}
   455  	newContent := fmt.Sprintf(`
   456  
   457  ## [%s] - %s
   458  ### Changes
   459  - Update build to golang %s`, new, time.Now().Format(time.DateOnly), goVersion.String())
   460  
   461  	_, err = file.WriteAt([]byte(newContent), s.Size())
   462  	if err != nil {
   463  		return err
   464  	}
   465  	return nil
   466  }
   467  
   468  func isSameMajorMinorVersion(a, b *version.Version) bool {
   469  	return a.Segments()[0] == b.Segments()[0] && a.Segments()[1] == b.Segments()[1]
   470  }
   471  
   472  func getListOfFilesInPaths(pathsToExplore []string) ([]string, error) {
   473  	var filesToChange []string
   474  	for _, pathToExplore := range pathsToExplore {
   475  		stat, err := os.Stat(pathToExplore)
   476  		if err != nil {
   477  			return nil, err
   478  		}
   479  		if stat.IsDir() {
   480  			dirEntries, err := os.ReadDir(pathToExplore)
   481  			if err != nil {
   482  				return nil, err
   483  			}
   484  			for _, entry := range dirEntries {
   485  				if entry.IsDir() {
   486  					continue
   487  				}
   488  				filesToChange = append(filesToChange, path.Join(pathToExplore, entry.Name()))
   489  			}
   490  		} else {
   491  			filesToChange = append(filesToChange, pathToExplore)
   492  		}
   493  	}
   494  	return filesToChange, nil
   495  }
   496  
   497  // replaceInFile replaces old with new in the given file.
   498  func replaceInFile(oldexps []*regexp.Regexp, new []string, fileToChange string) error {
   499  	if len(oldexps) != len(new) {
   500  		panic("old and new should be of the same length")
   501  	}
   502  
   503  	f, err := os.OpenFile(fileToChange, os.O_RDWR, 0600)
   504  	if err != nil {
   505  		return err
   506  	}
   507  	defer f.Close()
   508  
   509  	content, err := io.ReadAll(f)
   510  	if err != nil {
   511  		return err
   512  	}
   513  	contentStr := string(content)
   514  
   515  	for i, oldex := range oldexps {
   516  		contentStr = oldex.ReplaceAllString(contentStr, new[i])
   517  	}
   518  
   519  	_, err = f.WriteAt([]byte(contentStr), 0)
   520  	if err != nil {
   521  		return err
   522  	}
   523  	return nil
   524  }
   525  
   526  func (b bootstrapVersion) toString() string {
   527  	if b.minor == -1 {
   528  		return fmt.Sprintf("%d", b.major)
   529  	}
   530  	return fmt.Sprintf("%d.%d", b.major, b.minor)
   531  }