From 49603ff6158fcce4ad6f975450ffee436115bee7 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Wed, 6 Jul 2022 14:44:28 -0700 Subject: [PATCH 1/2] Print a warning when the CLI is out of date This changes adds an update to date check to our CLI. It works by fetching the latest version of the CLI from a well-known location and prints a warning if it is larger. To improve user precived performance, we run the logic to fetch the latest version in parallel with the command the user is running and we cache the value we fetch for 24 hours. If the CLI is out of date, a warning like the following is printed when the command completes: ``` warning: your version of azd is out of date, you have 0.0.1-beta.1686705 but the latest version is 0.0.1-beta.1686706 To update to the latest version, run: powershell -c "Set-ExecutionPolicy Bypass Process -Force; irm 'https://aka.ms/install-azd.ps1' | iex" ``` When run on non windows platforms, the `bash` invocation is printed instead. This behavior may be disabled by setting AZD_SKIP_UPDATE_CHECK to `true`. --- .vscode/cspell-github-user-aliases.txt | 1 + cli/azd/cmd/root.go | 8 - cli/azd/internal/version.go | 2 +- cli/azd/internal/version_test.go | 2 +- cli/azd/main.go | 222 ++++++++++++++++++++++++- cli/azd/pkg/tools/azcli_test.go | 4 +- go.mod | 3 +- go.sum | 2 + 8 files changed, 229 insertions(+), 15 deletions(-) diff --git a/.vscode/cspell-github-user-aliases.txt b/.vscode/cspell-github-user-aliases.txt index 0d8642c2ae3..3c71fe267d3 100644 --- a/.vscode/cspell-github-user-aliases.txt +++ b/.vscode/cspell-github-user-aliases.txt @@ -13,3 +13,4 @@ AlecAivazis pbnj multierr theckman +blang diff --git a/cli/azd/cmd/root.go b/cli/azd/cmd/root.go index 40be1da7700..39bbb8a8a22 100644 --- a/cli/azd/cmd/root.go +++ b/cli/azd/cmd/root.go @@ -5,8 +5,6 @@ package cmd import ( "fmt" - "io" - "log" "os" "github.com/azure/azure-dev/cli/azd/pkg/commands" @@ -55,12 +53,6 @@ For more information, please visit the project page: https://aka.ms/azure-dev/de opts.EnvironmentName = os.Getenv(environment.EnvNameEnvVarName) } - log.SetFlags(log.LstdFlags | log.Lshortfile) - - if !opts.EnableDebugLogging { - log.SetOutput(io.Discard) - } - return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { diff --git a/cli/azd/internal/version.go b/cli/azd/internal/version.go index 37226871da4..c84da474d0b 100644 --- a/cli/azd/internal/version.go +++ b/cli/azd/internal/version.go @@ -9,7 +9,7 @@ import "strings" // It's updated using ldflags in CI. // Example: // -ldflags="-X 'github.com/azure/azure-dev/cli/azd/internal.Version=0.0.1-alpha.1 (commit 8a49ae5ae9ab13beeade35f91ad4b4611c2f5574)'" -var Version = "0.0.0-alpha.0 (commit 0000000000000000000000000000000000000000)" +var Version = "0.0.0-dev.0 (commit 0000000000000000000000000000000000000000)" // GetVersionNumber splits the cmd.Version string to get the // semver for the command. diff --git a/cli/azd/internal/version_test.go b/cli/azd/internal/version_test.go index bf46015c1af..a9d6d639329 100644 --- a/cli/azd/internal/version_test.go +++ b/cli/azd/internal/version_test.go @@ -10,7 +10,7 @@ import ( ) func TestGetVersionNumber(t *testing.T) { - require.Equal(t, "0.0.0-alpha.0", GetVersionNumber()) + require.Equal(t, "0.0.0-dev.0", GetVersionNumber()) orig := Version Version = "invalid" diff --git a/cli/azd/main.go b/cli/azd/main.go index 98c89ac171b..b7dcc5081b8 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -4,14 +4,232 @@ package main import ( + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log" + "net/http" "os" + "os/user" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" "github.com/azure/azure-dev/cli/azd/cmd" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/blang/semver/v4" + "github.com/fatih/color" + "github.com/spf13/pflag" ) func main() { - err := cmd.Execute(os.Args[1:]) - if err != nil { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + if !isDebugEnabled() { + log.SetOutput(io.Discard) + } + + latest := make(chan semver.Version) + go fetchLatestVersion(latest) + + cmdErr := cmd.Execute(os.Args[1:]) + latestVersion, ok := <-latest + + // If we were able to fetch a latest version, check to see if we are up to date and + // print a warning if we are not. Note that we don't print this warning when the CLI version + // is exactly 0.0.0-dev.0, which is a sentinel value used for `internal.Version` when + // a version is not explicitly applied at build time (i.e. dev builds installed with `go install`) + if ok { + curVersion, err := semver.Parse(internal.GetVersionNumber()) + if err != nil { + log.Printf("failed to parse %s as a semver", internal.GetVersionNumber()) + } else if curVersion.Equals(semver.MustParse("0.0.0-dev.0")) { + // This is a dev build (i.e. built using `go install without setting a version`) - don't print a warning in this case + log.Printf("eliding update message for dev build") + } else if latestVersion.GT(curVersion) { + fmt.Printf(color.YellowString("warning: your version of azd is out of date, you have %s and the latest version is %s\n"), curVersion.String(), latestVersion.String()) + fmt.Println() + fmt.Println(color.YellowString(`To update to the latest version, run:`)) + + if runtime.GOOS == "windows" { + fmt.Println(color.YellowString(`powershell -c "Set-ExecutionPolicy Bypass Process -Force; irm 'https://aka.ms/install-azd.ps1' | iex"`)) + } else { + fmt.Println(color.YellowString(`curl -fsSL https://aka.ms/install-azd.sh | bash`)) + } + } + } + if cmdErr != nil { os.Exit(1) } } + +// azdDirectoryPermissions are the permissions to use on the `.azd` folder in the user's home +// directory. +const azdDirectoryPermissions = 0755 + +// updateCheckFilePermissions are the permissions to use on the `update-check.json` file. +const updateCheckFilePermissions = 0644 + +// azdConfigDir is the name of the folder where `azd` writes user wide configuration data. +const azdConfigDir = ".azd" + +// updateCheckCacheFileName is the name of the file created in the azd configuration directory +// which is used to cache version information for our up to date check. +const updateCheckCacheFileName = "update-check.json" + +// fetchLatestVersion fetches the latest version of the CLI and sends the result +// across the version channel, which it then closes. If the latest version can not +// be determined, the channel is closed without writing a value. +func fetchLatestVersion(version chan<- semver.Version) { + defer close(version) + + // Allow the user to skip the update check if they wish, by setting AZD_SKIP_UPDATE_CHECK to + // a truthy value. + if value, has := os.LookupEnv("AZD_SKIP_UPDATE_CHECK"); has { + if setting, err := strconv.ParseBool(value); err == nil && setting { + log.Print("skipping update check since AZD_SKIP_UPDATE_CHECK is true") + return + } else if err != nil { + log.Printf("could not parse value for AZD_SKIP_UPDATE_CHECK a boolean (it was: %s), proceeding with update check", value) + } + } + + // To avoid fetching the latest version of the CLI on every invocation, we cache the result for a period + // of time, in the user's home directory. + user, err := user.Current() + if err != nil { + log.Printf("could not determine current user: %v, skipping update check", err) + return + } + + cacheFilePath := filepath.Join(user.HomeDir, azdConfigDir, updateCheckCacheFileName) + cacheFile, err := os.ReadFile(cacheFilePath) + if err != nil && !errors.Is(err, fs.ErrNotExist) { + log.Printf("error reading update cache file: %v, skipping update check", err) + return + } + + // If we were able to read the update file, try to interpret it and use the cached + // value if it is still valid. Note the `err == nil` guard here ensures we don't run + // this logic when the cache file did not exist (since err will be a form of fs.ErrNotExist) + var cachedLatestVersion *semver.Version + if err == nil { + var cache updateCacheFile + if err := json.Unmarshal(cacheFile, &cache); err == nil { + parsedVersion, parseVersionErr := semver.Parse(cache.Version) + parsedExpiresOn, parseExpiresOnErr := time.Parse(time.RFC3339, cache.ExpiresOn) + + if parseVersionErr == nil && parseExpiresOnErr == nil { + if time.Now().UTC().Before(parsedExpiresOn) { + log.Printf("using cached latest version: %s (expires on: %s)", cache.Version, cache.ExpiresOn) + cachedLatestVersion = &parsedVersion + } else { + log.Printf("ignoring cached latest version, it is out of date") + } + } else { + if parseVersionErr != nil { + log.Printf("failed to parse cached version '%s' as a semver: %v, ignoring cached value", cache.Version, parseVersionErr) + } + if parseExpiresOnErr != nil { + log.Printf("failed to parse cached version expiration time '%s' as a RFC3339 timestamp: %v, ignoring cached value", cache.ExpiresOn, parseExpiresOnErr) + } + } + } else { + log.Printf("could not unmarshal cache file: %v, ignoring cache", err) + } + } + + // If we don't have a cached version we can use, fetch one (and cache it) + if cachedLatestVersion == nil { + log.Print("fetching latest version information for update check") + + res, err := http.Get("https://aka.ms/azure-dev/versions/latest") + if err != nil { + log.Printf("failed to refresh latest version: %v, skipping update check", err) + return + } + body, err := readToEndAndClose(res.Body) + if err != nil { + log.Printf("failed to read response body: %v, skipping update check", err) + return + } + + if res.StatusCode != http.StatusOK { + log.Printf("failed to refresh latest version, http status: %v, body: %v, skipping update check", res.StatusCode, body) + return + } + + // Parse the body of the response as a semver, and if it's valid, cache it. + fetchedVersionText := strings.TrimSpace(body) + fetchedVersion, err := semver.Parse(fetchedVersionText) + if err != nil { + log.Printf("failed to parse latest version '%s' as a semver: %v, skipping update check", fetchedVersionText, err) + return + } + + cachedLatestVersion = &fetchedVersion + + // Write the value back to the cache. Note that on these logging paths for errors we do not return + // eagerly, since we have not yet sent the latest versions across the channel (and we don't want to do that until we've updated + // the cache since reader on the other end of the channel will exit the process after it receives this value and finishes + // the up to date check, possibly while this go-routine is still running) + if err := os.MkdirAll(filepath.Dir(cacheFilePath), azdDirectoryPermissions); err != nil { + log.Printf("failed to create cache folder '%s': %v", filepath.Dir(cacheFilePath), err) + } else { + cacheObject := updateCacheFile{ + Version: fetchedVersionText, + ExpiresOn: time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339), + } + + // The marshal call can not fail, so we ignore the error. + cacheContents, _ := json.Marshal(cacheObject) + + if err := os.WriteFile(cacheFilePath, cacheContents, updateCheckFilePermissions); err != nil { + log.Printf("failed to write update cache file: %v", err) + } else { + log.Printf("updated cache file to version %s (expires on: %s)", cacheObject.Version, cacheObject.ExpiresOn) + } + } + } + + // Publish our value, the defer above will close the channel. + version <- *cachedLatestVersion +} + +type updateCacheFile struct { + // The semver of the latest version the CLI + Version string `json:"version"` + // A time at which this cached value expires, stored as an RFC3339 timestamp + ExpiresOn string `json:"expiresOn"` +} + +// isDebugEnabled checks to see if `--debug` was passed with a truthy +// value. +func isDebugEnabled() bool { + debug := false + flags := pflag.NewFlagSet("", pflag.ContinueOnError) + + // Since we are running this parse logic on the full command line, there may be additional flags + // which we have not defined in our flag set (but would be defined by whatever command we end up + // running). Setting UnknownFlags instructs `flags.Parse` to continue parsing the command line + // even if a flag is not in the flag set (instead of just returning an error saying the flag was not + // found). + flags.ParseErrorsWhitelist.UnknownFlags = true + flags.BoolVar(&debug, "debug", false, "") + + if err := flags.Parse(os.Args[1:]); err != nil { + log.Printf("could not parse flags: %v", err) + } + return debug +} + +func readToEndAndClose(r io.ReadCloser) (string, error) { + defer r.Close() + var buf strings.Builder + _, err := io.Copy(&buf, r) + return buf.String(), err +} diff --git a/cli/azd/pkg/tools/azcli_test.go b/cli/azd/pkg/tools/azcli_test.go index 35b87e94999..84a0f102c88 100644 --- a/cli/azd/pkg/tools/azcli_test.go +++ b/cli/azd/pkg/tools/azcli_test.go @@ -71,7 +71,7 @@ func TestAzCli(t *testing.T) { require.NoError(t, err) require.Equal(t, []string{ - "AZURE_HTTP_USER_AGENT=azdev/0.0.0-alpha.0", + "AZURE_HTTP_USER_AGENT=azdev/0.0.0-dev.0", "AZURE_CORE_COLLECT_TELEMETRY=no", }, env) @@ -136,7 +136,7 @@ func runAndCaptureUserAgent(t *testing.T, azcli *azCli, subscriptionID string) s _, _ = azcli.ListResourceGroupResources(context.Background(), subscriptionID, "ResourceGroupThatDoesNotExist") // The outputted line will look like this: - // DEBUG: cli.azure.cli.core.sdk.policies: 'User-Agent': 'AZURECLI/2.35.0 (MSI) azsdk-python-azure-mgmt-resource/20.0.0 Python/3.10.3 (Windows-10-10.0.22621-SP0) azdev/0.0.0-alpha.0 AZTesting=yes' + // DEBUG: cli.azure.cli.core.sdk.policies: 'User-Agent': 'AZURECLI/2.35.0 (MSI) azsdk-python-azure-mgmt-resource/20.0.0 Python/3.10.3 (Windows-10-10.0.22621-SP0) azdev/0.0.0-dev.0 AZTesting=yes' re := regexp.MustCompile(`'User-Agent':\s+'([^']+)'`) matches := re.FindAllStringSubmatch(stderrBuffer.String(), -1) diff --git a/go.mod b/go.mod index bef992780c5..edf3d684cd9 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,12 @@ go 1.18 require ( github.com/AlecAivazis/survey/v2 v2.3.2 + github.com/blang/semver/v4 v4.0.0 github.com/drone/envsubst v1.0.3 github.com/fatih/color v1.13.0 github.com/joho/godotenv v1.4.0 github.com/magefile/mage v1.12.1 + github.com/mattn/go-colorable v0.1.12 github.com/mattn/go-isatty v0.0.14 github.com/otiai10/copy v1.7.0 github.com/pbnj/go-open v0.1.1 @@ -26,7 +28,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect - github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-runewidth v0.0.13 // indirect github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect github.com/pkg/errors v0.8.1 // indirect diff --git a/go.sum b/go.sum index 37a289a57d0..879e6ee74a1 100644 --- a/go.sum +++ b/go.sum @@ -68,6 +68,8 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= From 20b7feb73f6e6ff4244a36aee48594f6cf9607c1 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Thu, 7 Jul 2022 15:50:44 -0700 Subject: [PATCH 2/2] Update version URL; set User-Agent Move to a new format for the aka.ms link for the latest version that will give us more flexibility when we want to also include channel information as part of the update check. Also, set a custom User Agent as we do on other requests that we make. --- cli/azd/main.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cli/azd/main.go b/cli/azd/main.go index b7dcc5081b8..44e8f7c30c3 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -146,10 +146,16 @@ func fetchLatestVersion(version chan<- semver.Version) { // If we don't have a cached version we can use, fetch one (and cache it) if cachedLatestVersion == nil { log.Print("fetching latest version information for update check") + req, err := http.NewRequest(http.MethodGet, "https://aka.ms/azure-dev/versions/cli/latest", nil) + if err != nil { + log.Printf("failed to create request object: %v, skipping update check", err) + } + + req.Header.Set("User-Agent", internal.FormatUserAgent(nil)) - res, err := http.Get("https://aka.ms/azure-dev/versions/latest") + res, err := http.DefaultClient.Do(req) if err != nil { - log.Printf("failed to refresh latest version: %v, skipping update check", err) + log.Printf("failed to fetch latest version: %v, skipping update check", err) return } body, err := readToEndAndClose(res.Body)