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  }