From 9ba2adce5d29df39c84146fdf7ba396416c68469 Mon Sep 17 00:00:00 2001 From: howieyuen Date: Mon, 7 Nov 2022 20:26:58 +0800 Subject: [PATCH] feat: warn when kusion is out of date --- go.mod | 2 + go.sum | 5 + pkg/kusionctl/cmd/cmd.go | 202 ++++++++++++++++++++++++++++++++++ pkg/kusionctl/cmd/cmd_test.go | 23 ++++ pkg/util/kfile/file.go | 12 ++ 5 files changed, 244 insertions(+) diff --git a/go.mod b/go.mod index 64136058..5ef25008 100644 --- a/go.mod +++ b/go.mod @@ -8,9 +8,11 @@ require ( github.com/Azure/go-autorest/autorest/mocks v0.4.1 github.com/aliyun/aliyun-oss-go-sdk v2.1.8+incompatible github.com/aws/aws-sdk-go v1.42.35 + github.com/blang/semver/v4 v4.0.0 github.com/chai2010/gettext-go v0.0.0-20170215093142-bf70f2a70fb1 github.com/davecgh/go-spew v1.1.1 github.com/didi/gendry v1.7.0 + github.com/djherbis/times v1.5.0 github.com/elazarl/goproxy v0.0.0-20191011121108-aa519ddbe484 // indirect github.com/evanphx/json-patch v4.12.0+incompatible github.com/fatih/color v1.13.0 // indirect diff --git a/go.sum b/go.sum index 9dd48935..b9575a11 100644 --- a/go.sum +++ b/go.sum @@ -164,7 +164,10 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/bketelsen/crypt v0.0.3-0.20200106085610-5cbc8cc4026c/go.mod h1:MKsuJmJgSg28kpZDP6UIiPt0e0Oz0kqKNGyRaWEPv84= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/bmatcuk/doublestar v1.1.5/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE= github.com/boltdb/bolt v1.3.1/go.mod h1:clJnj/oiGkjum5o1McbSZDSLxVThjynRyGBgiAx27Ps= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -221,6 +224,8 @@ github.com/didi/gendry v1.7.0 h1:dFR6+TVCnbjvLfNiGN53xInG/C5HqG7u0gfnkF5J/Vo= github.com/didi/gendry v1.7.0/go.mod h1:cSLuShZ1Zbs1S05RIOLNQv616aBaOQ1BDrXJP9A3J+M= github.com/dimchansky/utfbom v1.1.0/go.mod h1:rO41eb7gLfo8SF1jd9F8HplJm1Fewwi4mQvIirEdv+8= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU= +github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0= github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/go-units v0.3.3/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= diff --git a/pkg/kusionctl/cmd/cmd.go b/pkg/kusionctl/cmd/cmd.go index b8c0e35d..e9d1ddc1 100644 --- a/pkg/kusionctl/cmd/cmd.go +++ b/pkg/kusionctl/cmd/cmd.go @@ -1,9 +1,18 @@ package cmd import ( + "errors" + "fmt" "io" "os" + "os/user" + "path/filepath" + "regexp" + "strings" + "time" + "github.com/blang/semver/v4" + "github.com/djherbis/times" "github.com/spf13/cobra" cliflag "k8s.io/component-base/cli/flag" "k8s.io/kubectl/pkg/util/templates" @@ -18,7 +27,12 @@ import ( "kusionstack.io/kusion/pkg/kusionctl/cmd/ls" "kusionstack.io/kusion/pkg/kusionctl/cmd/preview" "kusionstack.io/kusion/pkg/kusionctl/cmd/version" + "kusionstack.io/kusion/pkg/log" + "kusionstack.io/kusion/pkg/util/gitutil" "kusionstack.io/kusion/pkg/util/i18n" + "kusionstack.io/kusion/pkg/util/kfile" + "kusionstack.io/kusion/pkg/util/pretty" + versionInfo "kusionstack.io/kusion/pkg/version" ) // NewDefaultKusionctlCommand creates the `kusionctl` command with default arguments @@ -53,15 +67,45 @@ func NewKusionctlCmd(in io.Reader, out, err io.Writer) *cobra.Command { // the language, instead of just loading from the LANG env. variable. _ = i18n.LoadTranslations("kusion", nil) + updateCheckResult := make(chan string) + // Parent command to which all subcommands are added. cmds := &cobra.Command{ Use: "kusion", Short: i18n.T(rootShort), Long: templates.LongDesc(i18n.T(rootLong)), SilenceErrors: true, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // If we fail before we start the async update check, go ahead and close the + // channel since we know it will never receive a value. + var waitForUpdateCheck bool + defer func() { + if !waitForUpdateCheck { + close(updateCheckResult) + } + }() + + if v := os.Getenv("KUSION_SKIP_UPDATE_CHECK"); v == "true" { + log.Infof("skipping update check") + } else { + // Run the version check in parallel so that it doesn't block executing the command. + // If there is a new version to report, we will do so after the command has finished. + waitForUpdateCheck = true + go func() { + updateCheckResult <- checkForUpdate() + close(updateCheckResult) + }() + } + }, Run: func(cmd *cobra.Command, args []string) { _ = cmd.Help() }, + PersistentPostRun: func(cmd *cobra.Command, args []string) { + checkVersionMsg, ok := <-updateCheckResult + if ok && checkVersionMsg != "" { + fmt.Println(checkVersionMsg) + } + }, } // From this point and forward we get warnings on flags that contain "_" separators @@ -100,3 +144,161 @@ func NewKusionctlCmd(in io.Reader, out, err io.Writer) *cobra.Command { return cmds } + +// checkForUpdate checks to see if the CLI needs to be updated, +// and if so emits a warning, as well as information as to how it can be upgraded. +func checkForUpdate() string { + curVer, err := semver.ParseTolerant(versionInfo.ReleaseVersion()) + if err != nil { + log.Infof("error parsing current version: %s", err) + } + + // We don't care about warning for you to update if you have installed a developer version + if isDevVersion(curVer) { + return "" + } + + latestVer, err := getLatestVersionInfo() + if err != nil { + log.Infof("error fetching latest version information "+ + "(set `KUSION_SKIP_UPDATE_CHECK=true` to skip update checks): %s", err) + } + + if latestVer.GT(curVer) { + return pretty.LightYellow("warning: ") + getUpgradeMessage(latestVer, curVer) + } + + return "" +} + +func isDevVersion(s semver.Version) bool { + if len(s.Build) != 0 { + return true + } + + if len(s.Pre) == 0 { + return false + } + + devStrings := regexp.MustCompile(`alpha|beta|dev|rc`) + return !s.Pre[0].IsNum && devStrings.MatchString(s.Pre[0].VersionStr) +} + +// getLatestVersionInfo returns information about the latest version of the CLI. +// It caches data from the server for a day. +func getLatestVersionInfo() (semver.Version, error) { + cached, err := getCachedVersionInfo() + if err == nil { + return cached, nil + } + + latestTag, err := gitutil.GetLatestTag() + if err != nil { + return semver.Version{}, err + } + + latest, err := semver.ParseTolerant(latestTag) + if err != nil { + return semver.Version{}, err + } + + if err = cacheVersionInfo(latest); err != nil { + log.Infof("failed to cache version info: %s", err) + } + + return latest, nil +} + +// getCachedVersionInfo reads cached information about the newest CLI version, returning the newest version available. +func getCachedVersionInfo() (semver.Version, error) { + updateCheckFile, err := kfile.GetCachedVersionFilePath() + if err != nil { + return semver.Version{}, err + } + + ts, err := times.Stat(updateCheckFile) + if err != nil { + return semver.Version{}, err + } + + if time.Now().After(ts.ModTime().Add(24 * time.Hour)) { + return semver.Version{}, errors.New("cached expired") + } + + cached, err := os.ReadFile(updateCheckFile) + if err != nil { + return semver.Version{}, err + } + + latest, err := semver.ParseTolerant(string(cached)) + if err != nil { + return semver.Version{}, err + } + + return latest, err +} + +// cacheVersionInfo saves version information in a cache file to be looked up later. +func cacheVersionInfo(latest semver.Version) error { + updateCheckFile, err := kfile.GetCachedVersionFilePath() + if err != nil { + return err + } + + file, err := os.OpenFile(updateCheckFile, os.O_CREATE|os.O_TRUNC|os.O_RDWR, 0600) + if err != nil { + return err + } + defer file.Close() + + _, err = file.WriteString(latest.String()) + return err +} + +// getUpgradeMessage gets a message to display to a user instructing that +// they are out of date and how to move from current to latest. +func getUpgradeMessage(latest semver.Version, current semver.Version) string { + cmd := getUpgradeCommand() + + msg := fmt.Sprintf("A new version of Kusion is available. To upgrade from version '%s' to '%s', ", current, latest) + if cmd != "" { + msg += "run \n " + cmd + "\nor " + } + + msg += "visit https://kusionstack.io/docs/user_docs/getting-started/install/ for manual instructions." + return msg +} + +// getUpgradeCommand returns a command that will upgrade the CLI to the newest version. +// If we can not determine how the CLI was installed, the empty string is returned. +func getUpgradeCommand() string { + exe, err := os.Executable() + if err != nil { + return "" + } + + isKusionup, err := isKusionUpInstall(exe) + if err != nil { + log.Infof("error determining if the running executable was installed with kusionup: %s", err) + } + if isKusionup { + return "$ kusionup install" + } + return "" +} + +// isKusionUpInstall returns true if the current running executable is running on linux based and was installed with kusionup. +func isKusionUpInstall(exe string) (bool, error) { + exePath, err := filepath.EvalSymlinks(exe) + if err != nil { + return false, err + } + + curUser, err := user.Current() + if err != nil { + return false, err + } + + prefix := filepath.Join(curUser.HomeDir, ".kusionup") + return strings.HasPrefix(exePath, prefix), nil +} diff --git a/pkg/kusionctl/cmd/cmd_test.go b/pkg/kusionctl/cmd/cmd_test.go index ade967db..39204d70 100644 --- a/pkg/kusionctl/cmd/cmd_test.go +++ b/pkg/kusionctl/cmd/cmd_test.go @@ -2,8 +2,31 @@ package cmd import ( "testing" + + "github.com/blang/semver/v4" + "github.com/stretchr/testify/assert" ) func TestNewKusionctlCmd(t *testing.T) { NewDefaultKusionctlCommand() } + +func TestIsDevVersion(t *testing.T) { + stableVer, _ := semver.ParseTolerant("1.0.0") + assert.False(t, isDevVersion(stableVer)) + + devVer, _ := semver.ParseTolerant("v1.0.0-dev") + assert.True(t, isDevVersion(devVer)) + + alphaVer, _ := semver.ParseTolerant("v1.0.0-alpha.1590772212+g4ff08363.dirty") + assert.True(t, isDevVersion(alphaVer)) + + betaVer, _ := semver.ParseTolerant("v1.0.0-beta.1590772212") + assert.True(t, isDevVersion(betaVer)) + + rcVer, _ := semver.ParseTolerant("v1.0.0-rc.1") + assert.True(t, isDevVersion(rcVer)) + + cmVer, _ := semver.ParseTolerant("v0.7.1+3d300d71") + assert.True(t, isDevVersion(cmVer)) +} diff --git a/pkg/util/kfile/file.go b/pkg/util/kfile/file.go index 57ef982d..817484c4 100644 --- a/pkg/util/kfile/file.go +++ b/pkg/util/kfile/file.go @@ -12,6 +12,8 @@ import ( const ( EnvKusionPath = "KUSION_PATH" + // CachedVersionFile is the name of the file we use to store when we last checked if the CLI was out of date + CachedVersionFile = ".cached_version" ) func Stat(filename string) (fileInfo os.FileInfo, err error) { @@ -115,3 +117,13 @@ func GetCredentials() (map[string]interface{}, error) { } return credentials, nil } + +// GetCachedVersionFilePath returns the location where the CLI caches information from pulumi.com on the newest +// available version of the CLI +func GetCachedVersionFilePath() (string, error) { + homeDir, err := KusionDataFolder() + if err != nil { + return "", err + } + return filepath.Join(homeDir, CachedVersionFile), nil +}