go.mondoo.com/cnquery@v0.0.0-20231005093811-59568235f6ea/providers-sdk/v1/util/version/version.go (about) 1 // Copyright (c) Mondoo, Inc. 2 // SPDX-License-Identifier: BUSL-1.1 3 4 package main 5 6 import ( 7 "encoding/json" 8 "errors" 9 "fmt" 10 "go/format" 11 "os" 12 "os/exec" 13 "path/filepath" 14 "regexp" 15 "strconv" 16 "strings" 17 "time" 18 19 mastermind "github.com/Masterminds/semver" 20 tea "github.com/charmbracelet/bubbletea" 21 "github.com/go-git/go-git/v5" 22 "github.com/go-git/go-git/v5/plumbing" 23 "github.com/go-git/go-git/v5/plumbing/object" 24 "github.com/rs/zerolog" 25 "github.com/rs/zerolog/log" 26 "github.com/spf13/cobra" 27 "go.mondoo.com/cnquery/cli/components" 28 "go.mondoo.com/cnquery/logger" 29 "go.mondoo.com/cnquery/providers-sdk/v1/plugin" 30 "golang.org/x/mod/modfile" 31 ) 32 33 var rootCmd = &cobra.Command{ 34 Short: "cnquery versioning tool", 35 Long: ` 36 cnquery versioning tool allows us to update the version of one or more providers. 37 38 The tool will automatically detect the current version of the provider and 39 suggest a new version. It will also create a commit with the new version and 40 push it to a new branch. 41 42 $ version update providers/*/ --increment=patch --commit 43 44 The tool will also check if the provider go dependencies have changed since the 45 last version and will suggest to update them as well. To just clean up the go.mod 46 and go.sum files, run: 47 48 $ version mod-tidy providers/*/ 49 50 To update all provider go dependencies to the latest patch version, run: 51 52 $ version mod-update providers/*/ --patch 53 54 To update all provider go dependencies to the latest version, run: 55 56 $ version mod-update providers/*/ --latest 57 `, 58 } 59 60 var updateCmd = &cobra.Command{ 61 Use: "update [PROVIDERS]", 62 Short: "try to update the version of the provider", 63 Args: cobra.MinimumNArgs(1), 64 Run: func(cmd *cobra.Command, args []string) { 65 updateVersions(args) 66 }, 67 } 68 69 var checkCmd = &cobra.Command{ 70 Use: "check [PROVIDERS]", 71 Short: "checks if providers need updates", 72 Args: cobra.MinimumNArgs(1), 73 Run: func(cmd *cobra.Command, args []string) { 74 for i := range args { 75 checkUpdate(args[i]) 76 } 77 }, 78 } 79 80 var modTidyCmd = &cobra.Command{ 81 Use: "mod-tidy [PROVIDERS]", 82 Short: "run 'go mod tidy' for all provided providers", 83 Args: cobra.MinimumNArgs(1), 84 Run: func(cmd *cobra.Command, args []string) { 85 for i := range args { 86 goModTidy(args[i]) 87 } 88 }, 89 } 90 91 var modUpdateCmd = &cobra.Command{ 92 Use: "mod-update [PROVIDERS]", 93 Short: "update all go dependencies for all provided providers", 94 Args: cobra.MinimumNArgs(1), 95 Run: func(cmd *cobra.Command, args []string) { 96 updateStrategy := UpdateStrategyNone 97 98 if latestPatchVersion { 99 updateStrategy = UpdateStrategyPatch 100 } else if latestVersion { 101 updateStrategy = UpdateStrategyLatest 102 } 103 104 for i := range args { 105 checkGoModUpdate(args[i], updateStrategy) 106 } 107 }, 108 } 109 110 type UpdateStrategy int 111 112 const ( 113 // UpdateStrategyNone indicates that version should not be updated 114 UpdateStrategyNone UpdateStrategy = iota 115 // UpdateStrategyLatest indicates that version should be updated to the latest 116 UpdateStrategyLatest 117 // UpdateStrategyPatch indicates that version should be updated to the latest patch 118 UpdateStrategyPatch 119 ) 120 121 func checkGoModUpdate(providerPath string, updateStrategy UpdateStrategy) { 122 log.Info().Msgf("Updating dependencies for %s...", providerPath) 123 124 // Define the path to your project's go.mod file 125 goModPath := filepath.Join(providerPath, "go.mod") 126 127 // Read the content of the go.mod file 128 modContent, err := os.ReadFile(goModPath) 129 if err != nil { 130 log.Info().Msgf("Error reading go.mod file: %v", err) 131 return 132 } 133 134 // Parse the go.mod file 135 modFile, err := modfile.Parse("go.mod", modContent, nil) 136 if err != nil { 137 log.Info().Msgf("Error parsing go.mod file: %v", err) 138 return 139 } 140 141 // Iterate through the require statements and update dependencies 142 for _, require := range modFile.Require { 143 // Skip indirect dependencies 144 if require.Indirect { 145 continue 146 } 147 148 var modPath string 149 switch updateStrategy { 150 case UpdateStrategyLatest: 151 modPath = require.Mod.Path + "@latest" 152 case UpdateStrategyPatch: 153 modPath = require.Mod.Path + "@patch" // see https://github.com/golang/go/issues/26812 154 default: 155 modPath = require.Mod.Path + "@" + require.Mod.Version 156 } 157 158 cmd := exec.Command("go", "get", "-u", modPath) 159 160 // Redirect standard output and standard error to the console 161 cmd.Stdout = os.Stdout 162 cmd.Stderr = os.Stderr 163 164 // Set the working directory for the command 165 cmd.Dir = providerPath 166 167 log.Info().Msgf("Updating %s to the latest version...", require.Mod.Path) 168 169 // Run the `go get` command to update the dependency 170 err := cmd.Run() 171 if err != nil { 172 log.Info().Msgf("Error updating %s: %v", require.Mod.Path, err) 173 } 174 } 175 176 // Re-read the content of the go.mod file after updating 177 modContent, err = os.ReadFile(goModPath) 178 if err != nil { 179 fmt.Printf("Error reading go.mod file: %v\n", err) 180 return 181 } 182 183 // Parse the go.mod file again with the updated content 184 modFile, err = modfile.Parse("go.mod", modContent, nil) 185 if err != nil { 186 fmt.Printf("Error parsing go.mod file: %v\n", err) 187 return 188 } 189 190 // Write the updated go.mod file 191 updatedModContent, err := modFile.Format() 192 if err != nil { 193 log.Info().Msgf("Error formatting go.mod file: %v", err) 194 return 195 } 196 197 err = os.WriteFile(goModPath, updatedModContent, 0o644) 198 if err != nil { 199 log.Info().Msgf("Error writing updated go.mod file: %v", err) 200 return 201 } 202 203 log.Info().Msgf("All dependencies updated.") 204 205 // Run 'go mod tidy' to clean up the go.mod and go.sum files 206 goModTidy(providerPath) 207 208 log.Info().Msgf("All dependencies updated and cleaned up successfully.") 209 } 210 211 func goModTidy(providerPath string) { 212 log.Info().Msgf("Running 'go mod tidy' for %s...", providerPath) 213 214 // Run 'go mod tidy' to clean up the go.mod and go.sum files 215 tidyCmd := exec.Command("go", "mod", "tidy") 216 217 // Redirect standard output and standard error 218 tidyCmd.Stdout = os.Stdout 219 tidyCmd.Stderr = os.Stderr 220 221 // Set the working directory for the command 222 tidyCmd.Dir = providerPath 223 224 err := tidyCmd.Run() 225 if err != nil { 226 log.Error().Msgf("Error running 'go mod tidy': %v", err) 227 return 228 } 229 } 230 231 var defaultsCmd = &cobra.Command{ 232 Use: "defaults [PROVIDERS]", 233 Short: "generates the content for the defaults list of providers", 234 Args: cobra.MinimumNArgs(1), 235 Run: func(cmd *cobra.Command, args []string) { 236 defaults := parseDefaults(args) 237 fmt.Println(defaults) 238 }, 239 } 240 241 func checkUpdate(providerPath string) { 242 conf, err := getConfig(providerPath) 243 if err != nil { 244 log.Error().Err(err).Str("path", providerPath).Msg("failed to process version") 245 return 246 } 247 248 changes := countChangesSince(conf, providerPath) 249 logChanges(changes, conf) 250 } 251 252 func logChanges(changes int, conf *providerConf) { 253 if changes == 0 { 254 log.Info().Str("version", conf.version).Str("provider", conf.name).Msg("no changes") 255 } else if fastMode { 256 log.Info().Str("version", conf.version).Str("provider", conf.name).Msg("provider changed") 257 } else { 258 log.Info().Int("changes", changes).Str("version", conf.version).Str("provider", conf.name).Msg("provider changed") 259 } 260 } 261 262 var ( 263 reVersion = regexp.MustCompile(`Version:\s*"([^"]+)"`) 264 reName = regexp.MustCompile(`Name:\s*"([^"]+)",`) 265 ) 266 267 const ( 268 titlePrefix = "🎉 " 269 ) 270 271 type providerConf struct { 272 path string 273 content string 274 version string 275 name string 276 } 277 278 func (conf *providerConf) title() string { 279 return conf.name + "-" + conf.version 280 } 281 282 func (conf *providerConf) commitTitle() string { 283 return titlePrefix + conf.title() 284 } 285 286 type updateConfs []*providerConf 287 288 func (confs updateConfs) titles() []string { 289 titles := make([]string, len(confs)) 290 for i := range confs { 291 titles[i] = confs[i].title() 292 } 293 return titles 294 } 295 296 func (confs updateConfs) commitTitle() string { 297 return "🎉 " + strings.Join(confs.titles(), ", ") 298 } 299 300 func (confs updateConfs) branchName() string { 301 if len(confs) <= 5 { 302 return "version/" + strings.Join(confs.titles(), "+") 303 } 304 305 now := time.Now() 306 return "versions/" + strconv.Itoa(len(confs)) + "-provider-updates-" + now.Format(time.DateOnly) 307 } 308 309 func getVersion(content string) string { 310 m := reVersion.FindStringSubmatch(content) 311 if len(m) == 0 { 312 return "" 313 } 314 return m[1] 315 } 316 317 func getConfig(providerPath string) (*providerConf, error) { 318 var conf providerConf 319 320 conf.path = filepath.Join(providerPath, "config/config.go") 321 raw, err := os.ReadFile(conf.path) 322 if err != nil { 323 return nil, errors.New("failed to read provider config file") 324 } 325 conf.content = string(raw) 326 327 // Note: name and version must come first in the config, since 328 // we only regex-match, instead of reading the structure properly 329 m := reName.FindStringSubmatch(conf.content) 330 if len(m) == 0 { 331 return nil, errors.New("no provider name found in config") 332 } 333 conf.name = m[1] 334 335 conf.version = getVersion(conf.content) 336 if conf.version == "" { 337 return nil, errors.New("no provider version found in config") 338 } 339 return &conf, nil 340 } 341 342 func updateVersions(providerPaths []string) { 343 updated := []*providerConf{} 344 345 for _, path := range providerPaths { 346 conf, err := tryUpdate(path) 347 if err != nil { 348 log.Error().Err(err).Str("path", path).Msg("failed to process version") 349 continue 350 } 351 if conf == nil { 352 log.Info().Str("path", path).Msg("nothing to update") 353 continue 354 } 355 updated = append(updated, conf) 356 } 357 358 if doCommit { 359 if err := commitChanges(updated); err != nil { 360 log.Error().Err(err).Msg("failed to commit changes") 361 } 362 } 363 } 364 365 func tryUpdate(providerPath string) (*providerConf, error) { 366 conf, err := getConfig(providerPath) 367 if err != nil { 368 return nil, err 369 } 370 371 changes := countChangesSince(conf, providerPath) 372 logChanges(changes, conf) 373 374 if changes == 0 { 375 return nil, nil 376 } 377 378 version, err := bumpVersion(conf.version) 379 if err != nil || version == "" { 380 return nil, err 381 } 382 383 res := reVersion.ReplaceAllStringFunc(conf.content, func(v string) string { 384 return "Version: \"" + version + "\"" 385 }) 386 387 raw, err := format.Source([]byte(res)) 388 if err != nil { 389 return nil, err 390 } 391 392 // no switching config to the new version => gets new commitTitle + branchName! 393 log.Info().Str("provider", conf.name).Str("version", version).Str("previous", conf.version).Msg("set new version") 394 conf.version = version 395 396 if err = os.WriteFile(conf.path, raw, 0o644); err != nil { 397 log.Fatal().Err(err).Str("path", conf.path).Msg("failed to write file") 398 } 399 log.Info().Str("path", conf.path).Msg("updated config") 400 401 if !doCommit { 402 log.Info().Msg("git add " + conf.path + " && git commit -m \"" + conf.commitTitle() + "\"") 403 } 404 405 return conf, nil 406 } 407 408 func bumpVersion(version string) (string, error) { 409 v, err := mastermind.NewVersion(version) 410 if err != nil { 411 return "", errors.New("version '" + version + "' is not a semver") 412 } 413 414 patch := v.IncPatch() 415 minor := v.IncMinor() 416 // TODO: check if the major version of the repo has changed and bump it 417 418 if increment == "patch" { 419 return (&patch).String(), nil 420 } 421 if increment == "minor" { 422 return (&minor).String(), nil 423 } 424 if increment != "" { 425 return "", errors.New("do not understand --increment=" + increment + ", either pick patch or minor") 426 } 427 428 versions := []string{ 429 v.String() + " - no change, keep developing", 430 (&patch).String(), 431 (&minor).String(), 432 } 433 434 selection := -1 435 model := components.NewListModel("Select version", versions, func(s int) { 436 selection = s 437 }) 438 _, err = tea.NewProgram(model, tea.WithInputTTY()).Run() 439 if err != nil { 440 return "", err 441 } 442 443 if selection == -1 || selection == 0 { 444 return "", nil 445 } 446 447 return versions[selection], nil 448 } 449 450 func commitChanges(confs updateConfs) error { 451 repo, err := git.PlainOpen(".") 452 if err != nil { 453 return errors.New("failed to open git: " + err.Error()) 454 } 455 456 headRef, err := repo.Head() 457 if err != nil { 458 return errors.New("failed to get git head: " + err.Error()) 459 } 460 461 worktree, err := repo.Worktree() 462 if err != nil { 463 return errors.New("failed to get git tree: " + err.Error()) 464 } 465 466 branchName := confs.branchName() 467 branchRef := plumbing.NewBranchReferenceName(branchName) 468 469 // Note: The branch may be local and thus won't be found in repo.Branch(branchName) 470 // This is consufing and I couldn't find any further docs on this behavior, 471 // but we have to work around it. 472 if _, err := repo.Reference(branchRef, true); err == nil { 473 err = repo.Storer.RemoveReference(branchRef) 474 if err != nil { 475 return errors.New("failed to git delete branch " + branchName + ": " + err.Error()) 476 } 477 } 478 479 err = worktree.Checkout(&git.CheckoutOptions{ 480 Hash: headRef.Hash(), 481 Branch: branchRef, 482 Create: true, 483 Keep: true, 484 }) 485 if err != nil { 486 return errors.New("failed to git checkout+create " + branchName + ": " + err.Error()) 487 } 488 489 fmt.Print("Adding providers to commit ") 490 for i := range confs { 491 _, err = worktree.Add(confs[i].path) 492 if err != nil { 493 return errors.New("failed to git add: " + err.Error()) 494 } 495 fmt.Print(".") 496 } 497 fmt.Println(" done") 498 499 body := "\n\nThis release was created by cnquery's provider versioning bot.\n\n" + 500 "You can find me under: `providers-sdk/v1/util/version`.\n" 501 502 commit, err := worktree.Commit(confs.commitTitle()+body, &git.CommitOptions{ 503 Author: &object.Signature{ 504 Name: "Mondoo", 505 Email: "hello@mondoo.com", 506 When: time.Now(), 507 }, 508 }) 509 if err != nil { 510 return errors.New("failed to commit: " + err.Error()) 511 } 512 513 _, err = repo.CommitObject(commit) 514 if err != nil { 515 return errors.New("commit is not in repo: " + err.Error()) 516 } 517 518 // Getting the GPG key is a hassle, so we use CLI for now... 519 err = exec.Command("git", "commit", "--amend", "--no-edit", "-S").Run() 520 if err != nil { 521 return err 522 } 523 524 log.Info().Msg("committed changes for " + strings.Join(confs.titles(), ", ")) 525 log.Info().Msg("running: git push -u origin " + branchName) 526 527 // Not sure why the auth method doesn't work... so we exec here 528 err = exec.Command("git", "push", "-u", "origin", branchName).Run() 529 if err != nil { 530 return err 531 } 532 533 log.Info().Msg("updates pushed successfully, open: \n\t" + 534 "https://github.com/mondoohq/cnquery/compare/" + branchName + "?expand=1") 535 return nil 536 } 537 538 func titleOf(msg string) string { 539 i := strings.Index(msg, "\n") 540 if i != -1 { 541 return msg[0:i] 542 } 543 return msg 544 } 545 546 func countChangesSince(conf *providerConf, repoPath string) int { 547 repo, err := git.PlainOpen(".") 548 if err != nil { 549 log.Fatal().Err(err).Msg("failed to open git repo") 550 } 551 iter, err := repo.Log(&git.LogOptions{ 552 PathFilter: func(p string) bool { 553 return strings.HasPrefix(p, repoPath) 554 }, 555 }) 556 if err != nil { 557 log.Fatal().Err(err).Msg("failed to iterate git history") 558 } 559 560 if !fastMode { 561 fmt.Print("crawling git history...") 562 } 563 564 var found *object.Commit 565 var count int 566 for c, err := iter.Next(); err == nil; c, err = iter.Next() { 567 if !fastMode { 568 fmt.Print(".") 569 } 570 571 if strings.HasPrefix(c.Message, titlePrefix) && strings.Contains(titleOf(c.Message), " "+conf.title()) { 572 found = c 573 break 574 } 575 576 count++ 577 if fastMode { 578 return count 579 } 580 } 581 if !fastMode { 582 fmt.Println() 583 } 584 585 if found == nil { 586 log.Warn().Msg("looks like there is no previous version in your commit history => we assume this is the first version commit") 587 } 588 return count 589 } 590 591 func parseDefaults(paths []string) string { 592 confs := []*plugin.Provider{} 593 for _, path := range paths { 594 name := filepath.Base(path) 595 data, err := os.ReadFile(filepath.Join(path, "dist", name+".json")) 596 if err != nil { 597 log.Fatal().Err(err).Msg("failed to read config json") 598 } 599 var v plugin.Provider 600 if err = json.Unmarshal(data, &v); err != nil { 601 log.Fatal().Err(err).Msg("failed to parse config json") 602 } 603 confs = append(confs, &v) 604 } 605 606 var res strings.Builder 607 for i := range confs { 608 conf := confs[i] 609 var connectors strings.Builder 610 for j := range conf.Connectors { 611 conn := conf.Connectors[j] 612 connectors.WriteString(fmt.Sprintf(` 613 { 614 Name: %#v, 615 Short: %#v, 616 },`, conn.Name, conn.Short)) 617 } 618 619 res.WriteString(fmt.Sprintf(` 620 "%s": { 621 Provider: &plugin.Provider{ 622 Name: "%s", 623 ConnectionTypes: %#v, 624 Connectors: []plugin.Connector{%s 625 }, 626 }, 627 },`, conf.Name, conf.Name, conf.ConnectionTypes, connectors.String())) 628 } 629 630 return res.String() 631 } 632 633 var ( 634 fastMode bool 635 doCommit bool 636 increment string 637 latestVersion bool 638 latestPatchVersion bool 639 ) 640 641 func init() { 642 rootCmd.PersistentFlags().BoolVar(&fastMode, "fast", false, "perform fast checking of git repo (not counting changes)") 643 rootCmd.PersistentFlags().BoolVar(&doCommit, "commit", false, "commit the change to git if there is a version bump") 644 rootCmd.PersistentFlags().StringVar(&increment, "increment", "", "automatically bump either patch or minor version") 645 646 modUpdateCmd.PersistentFlags().BoolVar(&latestVersion, "latest", false, "update versions to latest") 647 modUpdateCmd.PersistentFlags().BoolVar(&latestPatchVersion, "patch", false, "update versions to latest patch") 648 rootCmd.AddCommand(updateCmd, checkCmd, modUpdateCmd, modTidyCmd) 649 rootCmd.AddCommand(updateCmd, checkCmd, defaultsCmd) 650 } 651 652 func main() { 653 logger.CliCompactLogger(logger.LogOutputWriter) 654 zerolog.SetGlobalLevel(zerolog.DebugLevel) 655 656 if err := rootCmd.Execute(); err != nil { 657 fmt.Fprintln(os.Stderr, err) 658 os.Exit(1) 659 } 660 }