cli/azd/main.go (261 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
//go:generate goversioninfo -arm -64
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
azcorelog "github.com/Azure/azure-sdk-for-go/sdk/azcore/log"
"github.com/azure/azure-dev/cli/azd/cmd"
"github.com/azure/azure-dev/cli/azd/internal"
"github.com/azure/azure-dev/cli/azd/internal/telemetry"
"github.com/azure/azure-dev/cli/azd/pkg/config"
"github.com/azure/azure-dev/cli/azd/pkg/installer"
"github.com/azure/azure-dev/cli/azd/pkg/ioc"
"github.com/azure/azure-dev/cli/azd/pkg/oneauth"
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
"github.com/azure/azure-dev/cli/azd/pkg/output"
"github.com/blang/semver/v4"
"github.com/mattn/go-colorable"
"github.com/spf13/pflag"
)
func main() {
ctx := context.Background()
restoreColorMode := colorable.EnableColorsStdout(nil)
defer restoreColorMode()
log.SetFlags(log.LstdFlags | log.Lshortfile)
if isDebugEnabled() {
azcorelog.SetListener(func(event azcorelog.Event, msg string) {
log.Printf("%s: %s\n", event, msg)
})
} else {
log.SetOutput(io.Discard)
}
log.Printf("azd version: %s", internal.Version)
ts := telemetry.GetTelemetrySystem()
latest := make(chan semver.Version)
go fetchLatestVersion(latest)
rootContainer := ioc.NewNestedContainer(nil)
ioc.RegisterInstance(rootContainer, ctx)
cmdErr := cmd.NewRootCmd(false, nil, rootContainer).ExecuteContext(ctx)
oneauth.Shutdown()
if !isJsonOutput() {
if firstNotice := telemetry.FirstNotice(); firstNotice != "" {
fmt.Fprintln(os.Stderr, output.WithWarningFormat(firstNotice))
}
}
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`)
//
// Don't write this message when JSON output is enabled, since in that case we use stderr to return structured
// information about command progress.
if !isJsonOutput() && ok {
if internal.IsDevVersion() {
// 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(internal.VersionInfo().Version) {
var upgradeText string
installedBy := installer.InstalledBy()
if runtime.GOOS == "windows" {
switch installedBy {
case installer.InstallTypePs:
//nolint:lll
upgradeText = "run:\npowershell -ex AllSigned -c \"Invoke-RestMethod 'https://aka.ms/install-azd.ps1' | Invoke-Expression\"\n\nIf the install script was run with custom parameters, ensure that the same parameters are used for the upgrade. For advanced install instructions, see: https://aka.ms/azd/upgrade/windows"
case installer.InstallTypeWinget:
upgradeText = "run:\nwinget upgrade Microsoft.Azd"
case installer.InstallTypeChoco:
upgradeText = "run:\nchoco upgrade azd"
default:
// Also covers "msi" case where the user installed directly
// via MSI
upgradeText = "visit https://aka.ms/azd/upgrade/windows"
}
} else if runtime.GOOS == "linux" {
switch installedBy {
case installer.InstallTypeSh:
//nolint:lll
upgradeText = "run:\ncurl -fsSL https://aka.ms/install-azd.sh | bash\n\nIf the install script was run with custom parameters, ensure that the same parameters are used for the upgrade. For advanced install instructions, see: https://aka.ms/azd/upgrade/linux"
default:
// Also covers "deb" and "rpm" cases which are currently
// documented. When package manager distribution support is
// added, this will need to be updated.
upgradeText = "visit https://aka.ms/azd/upgrade/linux"
}
} else if runtime.GOOS == "darwin" {
switch installedBy {
case installer.InstallTypeBrew:
upgradeText = "run:\nbrew update && brew upgrade azd"
case installer.InstallTypeSh:
//nolint:lll
upgradeText = "run:\ncurl -fsSL https://aka.ms/install-azd.sh | bash\n\nIf the install script was run with custom parameters, ensure that the same parameters are used for the upgrade. For advanced install instructions, see: https://aka.ms/azd/upgrade/mac"
default:
upgradeText = "visit https://aka.ms/azd/upgrade/mac"
}
} else {
// Platform is not recognized, use the generic install link
upgradeText = "visit https://aka.ms/azd/upgrade"
}
fmt.Fprintln(
os.Stderr,
output.WithWarningFormat(
"WARNING: your version of azd is out of date, you have %s and the latest version is %s",
internal.VersionInfo().Version.String(), latestVersion.String()))
fmt.Fprintln(os.Stderr)
fmt.Fprintln(
os.Stderr,
output.WithWarningFormat(`To update to the latest version, %s`,
upgradeText))
}
}
if ts != nil {
err := ts.Shutdown(ctx)
if err != nil {
log.Printf("non-graceful telemetry shutdown: %v\n", err)
}
if ts.EmittedAnyTelemetry() {
err := startBackgroundUploadProcess()
if err != nil {
log.Printf("failed to start background telemetry upload: %v\n", err)
}
}
}
if cmdErr != nil {
os.Exit(1)
}
}
// 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.
configDir, err := config.GetUserConfigDir()
if err != nil {
log.Printf("could not determine config directory: %v, skipping update check", err)
return
}
cacheFilePath := filepath.Join(configDir, 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")
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.UserAgent())
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Printf("failed to fetch 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), osutil.PermissionFile); 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, osutil.PermissionDirectory); 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 flag `-h` of `--help` is within the command, the usage is automatically shown.
// Setting `Usage` to a no-op will hide this extra unwanted output.
flags.Usage = func() {}
_ = flags.Parse(os.Args[1:])
return debug
}
// isJsonOutput checks to see if `--output` was passed with the value `json`
func isJsonOutput() bool {
output := ""
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.StringVarP(&output, "output", "o", "", "")
// if flag `-h` of `--help` is within the command, the usage is automatically shown.
// Setting `Usage` to a no-op will hide this extra unwanted output.
flags.Usage = func() {}
_ = flags.Parse(os.Args[1:])
return output == "json"
}
func readToEndAndClose(r io.ReadCloser) (string, error) {
defer r.Close()
var buf strings.Builder
_, err := io.Copy(&buf, r)
return buf.String(), err
}
func startBackgroundUploadProcess() error {
// The background upload process executable is ourself
execPath, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get current executable path: %w", err)
}
// #nosec G204 - this is not a security issue, we are executing our own binary
cmd := exec.Command(execPath, cmd.TelemetryCommandFlag, cmd.TelemetryUploadCommandFlag)
// Use the location of azd as the cwd for the background uploading process. On windows, when a process is running
// the current working directory is considered in use and can not be deleted. If a user runs `azd` in a directory, we
// do want that directory to be considered in use and locked while the telemetry upload is happening. One example of
// where we see this problem often is in our CI for end to end tests where we run a copy of `azd` that we built in an
// ephemeral directory created by (*testing.T).TempDir(). When the test completes, the testing package attempts to
// clean up the temporary directory, but if the telemetry upload process is still running, the directory can not be
// deleted.
cmd.Dir = filepath.Dir(execPath)
err = cmd.Start()
return err
}