From 2f85175ca061495ebf18a417344a36f4de24c56a Mon Sep 17 00:00:00 2001 From: Angel Misevski Date: Thu, 18 Jul 2024 14:31:05 -0400 Subject: [PATCH 1/4] Add a notification when a newer version of Kit is available Print a message before running commands if we find a newer version of Kit is available on GitHub. If the current version of Kit doesn't look to be a regular release, or if any error occurs while checking, we don't print anything (but do print some debug logs). --- cmd/root.go | 3 ++ go.mod | 1 + go.sum | 2 + pkg/lib/update/update.go | 89 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+) create mode 100644 pkg/lib/update/update.go diff --git a/cmd/root.go b/cmd/root.go index 5a4d31ca..dbb679b1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( "kitops/pkg/cmd/version" "kitops/pkg/lib/constants" "kitops/pkg/lib/repo/local" + "kitops/pkg/lib/update" "kitops/pkg/output" "github.com/spf13/cobra" @@ -74,6 +75,8 @@ func RunCommand() *cobra.Command { output.SetProgressBars("none") } + update.CheckForUpdate() + configHome, err := getConfigHome(opts) if err != nil { output.Errorf("Failed to read base config directory") diff --git a/go.mod b/go.mod index 2a7fee96..9159c3d9 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.9.0 github.com/vbauerster/mpb/v8 v8.7.3 + golang.org/x/mod v0.19.0 golang.org/x/sync v0.7.0 golang.org/x/sys v0.22.0 golang.org/x/term v0.22.0 diff --git a/go.sum b/go.sum index e4ec43d9..83264abb 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/vbauerster/mpb/v8 v8.7.3 h1:n/mKPBav4FFWp5fH4U0lPpXfiOmCEgl5Yx/NM3tKJA0= github.com/vbauerster/mpb/v8 v8.7.3/go.mod h1:9nFlNpDGVoTmQ4QvNjSLtwLmAFjwmq0XaAF26toHGNM= +golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= diff --git a/pkg/lib/update/update.go b/pkg/lib/update/update.go new file mode 100644 index 00000000..3689bae2 --- /dev/null +++ b/pkg/lib/update/update.go @@ -0,0 +1,89 @@ +// Copyright 2024 The KitOps Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package update + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + + "kitops/pkg/lib/constants" + "kitops/pkg/output" + + "golang.org/x/mod/semver" +) + +const releaseUrl = "https://api.github.com/repos/jozu-ai/kitops/releases/latest" + +// Regexp for a semver version -- taken from https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string +// We've added an optional 'v' to the start (e.g. v1.2.3) since using a 'v' prefix is common (and used, in our case) +// Capture groups are: +// +// [1] - Major version +// [2] - Minor version +// [3] - Bugfix/z-stream version +// [4] - Pre-release identifiers (1.2.3-), if present +// [5] - Build metadata (1.2.3+), if present +var versionTagRegexp = regexp.MustCompile(`^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`) + +type ghReleaseInfo struct { + TagName string `json:"tag_name"` + Prerelease bool `json:"prerelease"` + Draft bool `json:"draft"` + Url string `json:"html_url"` +} + +func CheckForUpdate() { + // If this isn't a release version of kit, don't nag the user unnecessarily + if constants.Version == "unknown" || !versionTagRegexp.MatchString(constants.Version) { + return + } + + resp, err := http.Get(releaseUrl) + if err != nil { + output.Debugf("Failed to check for updates: %s", err) + return + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + output.Debugf("Failed to read GitHub response body: %s", err) + return + } + info := &ghReleaseInfo{} + if err := json.Unmarshal(respBody, info); err != nil { + output.Debugf("Failed to parse GitHub response body: %s", err) + return + } + if info.Prerelease || info.Draft { + // This isn't a full release; for now just don't notify users, even if there is a newer full release we don't know about + return + } + + // The Go semver package requires versions start with a 'v' (contrary to the spec) + currentVersion := fmt.Sprintf("v%s", strings.TrimPrefix(constants.Version, "v")) + latestVersion := fmt.Sprintf("v%s", strings.TrimPrefix(info.TagName, "v")) + if semver.Compare(currentVersion, latestVersion) < 0 { + output.Infof("Note: A new version of Kit is available! You are using Kit %s. The latest version is %s.", currentVersion, latestVersion) + output.Infof(" To see a list of changes, visit %s", info.Url) + output.Infof("") // Add a newline to not confuse it with regular output + } +} From 38cff18e0db9e5524e4f00d36ccbc441a3761adb Mon Sep 17 00:00:00 2001 From: Angel Misevski Date: Thu, 18 Jul 2024 16:11:55 -0400 Subject: [PATCH 2/4] Add ability to disable version notifications Add flag to version subcommand disable update notifications: kit version --show-update-notifications=false Settings is 'saved' by touching a file in $KITOPS_HOME Note, flag is attached to the 'version' subcommand as it seems there is no convenient way to specify it on the root command; it cannot be handled in PersistentPreRun and handling it in the root command's RunE breaks the help text for 'kit' (specifically, the 'usage' field) --- cmd/root.go | 5 +++-- pkg/cmd/version/version.go | 22 ++++++++++++++++++- pkg/lib/constants/consts.go | 17 ++++++++------- pkg/lib/update/update.go | 42 ++++++++++++++++++++++++++++++++++++- 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index dbb679b1..d4ed4462 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -75,8 +75,6 @@ func RunCommand() *cobra.Command { output.SetProgressBars("none") } - update.CheckForUpdate() - configHome, err := getConfigHome(opts) if err != nil { output.Errorf("Failed to read base config directory") @@ -86,6 +84,9 @@ func RunCommand() *cobra.Command { } ctx := context.WithValue(cmd.Context(), constants.ConfigKey{}, configHome) cmd.SetContext(ctx) + + update.CheckForUpdate(configHome) + // At this point, we've parsed the command tree and args; the CLI is being correctly // so we don't want to print usage. Each subcommand should print its error message before // returning diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go index 0ef4653c..32ddbc4d 100644 --- a/pkg/cmd/version/version.go +++ b/pkg/cmd/version/version.go @@ -18,6 +18,7 @@ package version import ( "kitops/pkg/lib/constants" + "kitops/pkg/lib/update" "kitops/pkg/output" "github.com/spf13/cobra" @@ -32,16 +33,35 @@ the version was built from, the build time, and the version of Go it was compiled with.` ) +const versionNotifFlag = "show-update-notifications" + +type versionOpts struct { + shouldShowNotifications bool +} + func VersionCommand() *cobra.Command { + opts := &versionOpts{} cmd := &cobra.Command{ Use: "version", Short: shortDesc, Long: longDesc, Run: func(cmd *cobra.Command, args []string) { - output.Infof("Version: %s\nCommit: %s\nBuilt: %s\nGo version: %s\n", constants.Version, constants.GitCommit, constants.BuildTime, constants.GoVersion) + if cmd.Flags().Changed(versionNotifFlag) { + configHome, ok := cmd.Context().Value(constants.ConfigKey{}).(string) + if !ok { + output.Fatalln("default config path not set on command context") + } + if err := update.SetShowNotifications(configHome, opts.shouldShowNotifications); err != nil { + output.Fatalln(err) + } + } else { + output.Infof("Version: %s\nCommit: %s\nBuilt: %s\nGo version: %s\n", constants.Version, constants.GitCommit, constants.BuildTime, constants.GoVersion) + } }, } + cmd.Flags().BoolVar(&opts.shouldShowNotifications, versionNotifFlag, false, "Enable or disable update notifications for the Kit CLI") + return cmd } diff --git a/pkg/lib/constants/consts.go b/pkg/lib/constants/consts.go index 56d0d177..b6a8c68f 100644 --- a/pkg/lib/constants/consts.go +++ b/pkg/lib/constants/consts.go @@ -34,14 +34,15 @@ const ( IgnoreFileName = ".kitignore" // Constants for the directory structure of kit's cached images and credentials - // Modelkits are stored in /kitops/storage/ and - // credentials are stored in /kitops/credentials.json - DefaultConfigSubdir = "kitops" - StorageSubpath = "storage" - CredentialsSubpath = "credentials.json" - HarnessSubpath = "harness" - HarnessProcessFile = "process.pid" - HarnessLogFile = "harness.log" + // Modelkits are stored in $KITOPS_HOME/storage/ and + // credentials are stored in $KITOPS_HOME/credentials.json + DefaultConfigSubdir = "kitops" + StorageSubpath = "storage" + CredentialsSubpath = "credentials.json" + HarnessSubpath = "harness" + HarnessProcessFile = "process.pid" + HarnessLogFile = "harness.log" + UpdateNotificationsConfigFilename = "disable-update-notifications" // Kitops-specific annotations for modelkit artifacts CliVersionAnnotation = "ml.kitops.modelkit.cli-version" diff --git a/pkg/lib/update/update.go b/pkg/lib/update/update.go index 3689bae2..7ca0e973 100644 --- a/pkg/lib/update/update.go +++ b/pkg/lib/update/update.go @@ -18,9 +18,13 @@ package update import ( "encoding/json" + "errors" "fmt" "io" + "io/fs" "net/http" + "os" + "path/filepath" "regexp" "strings" @@ -50,12 +54,16 @@ type ghReleaseInfo struct { Url string `json:"html_url"` } -func CheckForUpdate() { +func CheckForUpdate(configHome string) { // If this isn't a release version of kit, don't nag the user unnecessarily if constants.Version == "unknown" || !versionTagRegexp.MatchString(constants.Version) { return } + if !shouldShowNotification(configHome) { + return + } + resp, err := http.Get(releaseUrl) if err != nil { output.Debugf("Failed to check for updates: %s", err) @@ -84,6 +92,38 @@ func CheckForUpdate() { if semver.Compare(currentVersion, latestVersion) < 0 { output.Infof("Note: A new version of Kit is available! You are using Kit %s. The latest version is %s.", currentVersion, latestVersion) output.Infof(" To see a list of changes, visit %s", info.Url) + output.Infof(" To disable this notification, use 'kit version --show-update-notifications=false'") output.Infof("") // Add a newline to not confuse it with regular output } } + +func SetShowNotifications(configHome string, shouldShow bool) error { + flagFile := filepath.Join(configHome, constants.UpdateNotificationsConfigFilename) + if shouldShow { + if err := os.Remove(flagFile); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("error enabling update notifications: %w", err) + } + } else { + f, err := os.Create(flagFile) + if err != nil { + if errors.Is(err, fs.ErrExist) { + return nil + } + return fmt.Errorf("error disabling update notifications: %w", err) + } + f.Close() + } + return nil +} + +func shouldShowNotification(configHome string) bool { + flagFile := filepath.Join(configHome, constants.UpdateNotificationsConfigFilename) + _, err := os.Stat(flagFile) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return true + } + output.Debugf("Error checking if update notifications should be shown: %s", err) + } + return false +} From 60420e3572551ace28b4a7407a7c0ebf4bcca2f3 Mon Sep 17 00:00:00 2001 From: Angel Misevski Date: Thu, 18 Jul 2024 17:06:20 -0400 Subject: [PATCH 3/4] Set timeout on update check --- pkg/lib/update/update.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/lib/update/update.go b/pkg/lib/update/update.go index 7ca0e973..4023c1a4 100644 --- a/pkg/lib/update/update.go +++ b/pkg/lib/update/update.go @@ -27,6 +27,7 @@ import ( "path/filepath" "regexp" "strings" + "time" "kitops/pkg/lib/constants" "kitops/pkg/output" @@ -64,7 +65,10 @@ func CheckForUpdate(configHome string) { return } - resp, err := http.Get(releaseUrl) + client := &http.Client{ + Timeout: 1 * time.Second, + } + resp, err := client.Get(releaseUrl) if err != nil { output.Debugf("Failed to check for updates: %s", err) return From ac18d9d3b56623a2bf1f926dc30c6a8eb01e6730 Mon Sep 17 00:00:00 2001 From: Angel Misevski Date: Fri, 19 Jul 2024 11:30:53 -0400 Subject: [PATCH 4/4] Pull getting latest GH release info to a separate function for clarity --- pkg/lib/update/update.go | 41 ++++++++++++++++++++++------------------ 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/pkg/lib/update/update.go b/pkg/lib/update/update.go index 4023c1a4..82055dc7 100644 --- a/pkg/lib/update/update.go +++ b/pkg/lib/update/update.go @@ -60,29 +60,13 @@ func CheckForUpdate(configHome string) { if constants.Version == "unknown" || !versionTagRegexp.MatchString(constants.Version) { return } - if !shouldShowNotification(configHome) { return } - client := &http.Client{ - Timeout: 1 * time.Second, - } - resp, err := client.Get(releaseUrl) - if err != nil { - output.Debugf("Failed to check for updates: %s", err) - return - } - defer resp.Body.Close() - - respBody, err := io.ReadAll(resp.Body) + info, err := getLatestReleaseInfo() if err != nil { - output.Debugf("Failed to read GitHub response body: %s", err) - return - } - info := &ghReleaseInfo{} - if err := json.Unmarshal(respBody, info); err != nil { - output.Debugf("Failed to parse GitHub response body: %s", err) + output.Debugf("Error checking for CLI updates: %s", err) return } if info.Prerelease || info.Draft { @@ -131,3 +115,24 @@ func shouldShowNotification(configHome string) bool { } return false } + +func getLatestReleaseInfo() (*ghReleaseInfo, error) { + client := &http.Client{ + Timeout: 1 * time.Second, + } + resp, err := client.Get(releaseUrl) + if err != nil { + return nil, fmt.Errorf("failed to check for updates: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read GitHub response body: %w", err) + } + info := &ghReleaseInfo{} + if err := json.Unmarshal(respBody, info); err != nil { + return nil, fmt.Errorf("failed to parse GitHub response body: %w", err) + } + return info, nil +}