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 }