diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 32e0aaf77d3..4f6b9d2896c 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -5,6 +5,7 @@ words: - Authenticode - gofmt - golangci + - lightspeed - toplevel - azcloud - azdext diff --git a/cli/azd/cmd/actions/action_descriptor.go b/cli/azd/cmd/actions/action_descriptor.go index d6b0550b7a6..9b8807a0d70 100644 --- a/cli/azd/cmd/actions/action_descriptor.go +++ b/cli/azd/cmd/actions/action_descriptor.go @@ -159,6 +159,12 @@ type commandGroupAnnotationKey string const ( // cmdGrouperKey is an annotation key that is added as part of a cobra annotations for assigning commands to a group. cmdGrouperKey commandGroupAnnotationKey = "commandGrouper" + + // AnnotationLightspeed is a cobra annotation key that marks a command as lightspeed. + // Lightspeed commands skip non-essential background work (update checks) so the + // process can exit as fast as possible. Set automatically by CobraBuilder when + // ActionDescriptorOptions.Lightspeed is true. + AnnotationLightspeed = "azd.lightspeed" ) // GetGroupCommandAnnotation check if there is a grouping annotation for the command. Returns the annotation value as an @@ -195,6 +201,11 @@ type ActionDescriptorOptions struct { GroupingOptions CommandGroupOptions // Whether or not the command requires a principal login RequireLogin bool + // Whether the command should execute as fast as possible, skipping + // non-essential background work like update checks. + // Use for programmatic/hidden commands (e.g., auth token) where + // blocking process exit is unacceptable. + Lightspeed bool } // Completion function used for cobra command flag completion diff --git a/cli/azd/cmd/auth.go b/cli/azd/cmd/auth.go index edc7c160ccf..49043930756 100644 --- a/cli/azd/cmd/auth.go +++ b/cli/azd/cmd/auth.go @@ -26,6 +26,7 @@ func authActions(root *actions.ActionDescriptor) *actions.ActionDescriptor { ActionResolver: newAuthTokenAction, OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, DefaultFormat: output.NoneFormat, + Lightspeed: true, }) group.Add("login", &actions.ActionDescriptorOptions{ diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 73b0f698f3b..e9cf5d82e3c 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -13,16 +13,20 @@ import ( "slices" "strconv" "strings" + "time" + "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" + "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/pkg/update" "github.com/spf13/cobra" "github.com/spf13/pflag" ) @@ -385,14 +389,79 @@ func tryAutoInstallExtension( return true, nil } +// startUpdateCheck launches a background goroutine that checks for a newer +// version of azd and returns a channel that will receive the result. +// The caller should read from the returned channel after command execution. +func startUpdateCheck(ctx context.Context) <-chan *update.VersionInfo { + ch := make(chan *update.VersionInfo, 1) + + // Allow the user to skip the update check by setting AZD_SKIP_UPDATE_CHECK. + 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") + close(ch) + return ch + } else if err != nil { + log.Printf("could not parse value for AZD_SKIP_UPDATE_CHECK as a boolean "+ + "(it was: %s), proceeding with update check", value) + } + } + + bgCtx, bgCancel := context.WithTimeout(ctx, 60*time.Second) + + go func() { + defer close(ch) + defer bgCancel() + + configMgr := config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())) + userConfig, err := configMgr.Load() + if err != nil { + userConfig = config.NewEmptyConfig() + } + + cfg := update.LoadUpdateConfig(userConfig) + + mgr := update.NewManager(nil, nil) + versionInfo, err := mgr.CheckForUpdate(bgCtx, cfg, false) + if err != nil { + log.Printf("failed to check for updates: %v, skipping update check", err) + return + } + + ch <- versionInfo + }() + + return ch +} + +// ExecuteResult holds the outcome of ExecuteWithAutoInstall, including +// metadata about the executed command that callers may need for post-execution +// decisions (e.g., whether to wait for the background update check). +type ExecuteResult struct { + // Err is the error returned by the command execution, if any. + Err error + // IsLightspeed is true when the executed command was marked as Lightspeed. + // Callers should skip non-essential post-execution work (update checks, banners) + // for lightspeed commands so the process can exit quickly. + IsLightspeed bool + // LatestVersion receives the result of the background update check. + // Nil when the update check was not started (lightspeed commands). + // When the check is skipped via AZD_SKIP_UPDATE_CHECK, the returned channel + // is closed without a value. + LatestVersion <-chan *update.VersionInfo +} + // ExecuteWithAutoInstall executes the command and handles auto-installation of extensions for unknown commands. -func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) error { +func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) *ExecuteResult { + result := &ExecuteResult{} + // Parse global flags BEFORE creating the command tree. // This allows us to access flag values (like --no-prompt, --debug) early for auto-install logic. // This also enables the global options to be set in the container for support during extension framework callbacks. globalOpts := &internal.GlobalCommandOptions{} if err := ParseGlobalFlags(os.Args[1:], globalOpts); err != nil { - return fmt.Errorf("Warning: failed to parse global flags: %w", err) + result.Err = fmt.Errorf("Warning: failed to parse global flags: %w", err) + return result } // Register GlobalCommandOptions as a singleton in the container BEFORE building the command tree. @@ -411,13 +480,26 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // This allows us to determine if a subcommand was provided or not or if the command is unknown. foundCmd, originalArgs, err := rootCmd.Find(os.Args[1:]) if err == nil { + // Detect lightspeed commands from the cobra annotation set by CobraBuilder. + result.IsLightspeed = foundCmd.Annotations[actions.AnnotationLightspeed] == "true" + + // Start the background update check AFTER command identification. + // Lightspeed commands (e.g., auth token) skip the update check entirely + // so the process can exit as fast as possible — this prevents the + // AzureDeveloperCLICredential 10-second subprocess timeout from being + // hit when the update check is slow (laptop wake, DNS stalls, etc.). + if !result.IsLightspeed { + result.LatestVersion = startUpdateCheck(ctx) + } + // Check for partial namespace match (e.g., "ai" found but "ai.agent" not installed) if installed := tryAutoInstallForPartialNamespace( ctx, rootContainer, foundCmd, originalArgs, ); installed { // Extension was installed, rebuild command tree and execute rootCmd = NewRootCmd(false, nil, rootContainer) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } // Known command, proceed with normal execution @@ -427,7 +509,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Other command errors (for example, unsupported output formats) should be returned directly. unsupportedErr, ok := errors.AsType[*project.UnsupportedServiceHostError](err) if !ok { - return err + result.Err = err + return result } if err := rootContainer.Resolve(&extensionManager); err != nil { @@ -446,7 +529,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Do not fail if we couldn't check for extensions - just proceed to normal execution log.Println("Error: check for extensions. Skipping auto-install:", err) console.Message(ctx, unsupportedErr.ErrorMessage) - return nil + return result } // Note: We don't need to filter or check which extensions are installed. // If any of these extensions would be installed, the auto-install wouldn't have been triggered because @@ -454,7 +537,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if len(availableExtensionsForHost) == 0 { // did not find an extension with the capability, just print the original error message console.Message(ctx, unsupportedErr.ErrorMessage) - return nil + return result } console.Message(ctx, @@ -470,7 +553,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai chosenExtension, err := promptForExtensionChoice(ctx, console, availableExtensionsForHost) if err != nil { console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - return err + result.Err = err + return result } extensionIdToInstall = *chosenExtension } @@ -479,18 +563,24 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if installErr != nil { // Error needs to be printed here or else it will be hidden b/c the error printing is handled inside runtime console.Message(ctx, installErr.Error()) - return installErr + result.Err = installErr + return result } if installed { // Extension was installed, build command tree and execute rootCmd := NewRootCmd(false, nil, rootContainer) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } - return err + result.Err = err + return result } + // Unknown command path — always start the update check since these aren't lightspeed. + result.LatestVersion = startUpdateCheck(ctx) + // Extract flags that take values from the root command flagsWithValues := extractFlagsWithValues(rootCmd) @@ -502,7 +592,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Check if this is a built-in command first (includes core commands and installed extensions) if isBuiltInCommand(rootCmd, unknownCommand) { // This is a built-in command, proceed with normal execution without checking for extensions - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } if err := rootContainer.Resolve(&extensionManager); err != nil { @@ -516,12 +607,14 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if unknownCommand == "login" { console.Message(ctx, "Error: The 'azd login' command has been removed.") console.Message(ctx, "Please use 'azd auth login' instead.") - return fmt.Errorf("unknown command 'login'") + result.Err = fmt.Errorf("unknown command 'login'") + return result } if unknownCommand == "logout" { console.Message(ctx, "Error: The 'azd logout' command has been removed.") console.Message(ctx, "Please use 'azd auth logout' instead.") - return fmt.Errorf("unknown command 'logout'") + result.Err = fmt.Errorf("unknown command 'logout'") + return result } // If unknown flags were found before a non-built-in command, return an error with helpful guidance @@ -540,7 +633,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai unknownCommand) console.Message(ctx, errorMsg) - return fmt.Errorf("unknown flags before command: %s", flagsList) + result.Err = fmt.Errorf("unknown flags before command: %s", flagsList) + return result } // Get all remaining arguments starting from the command for namespace matching @@ -563,7 +657,8 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if err != nil { // Do not fail if we couldn't check for extensions - just proceed to normal execution log.Println("Error: check for extensions. Skipping auto-install:", err) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } if len(extensionMatches) > 0 { @@ -580,12 +675,14 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai chosenExtension, err := promptForExtensionChoice(ctx, console, extensionMatches) if err != nil { console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } if chosenExtension == nil { // User cancelled selection, proceed to normal execution - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } // Try to auto-install the chosen extension @@ -593,19 +690,22 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if installErr != nil { // Error needs to be printed here or else it will be hidden b/c the error printing is handled inside runtime console.Message(ctx, installErr.Error()) - return installErr + result.Err = installErr + return result } if installed { // Extension was installed, build command tree and execute rootCmd := NewRootCmd(false, nil, rootContainer) - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } } } // Normal execution path - either no args, no matching extension, or user declined install - return rootCmd.ExecuteContext(ctx) + result.Err = rootCmd.ExecuteContext(ctx) + return result } // CreateGlobalFlagSet creates a new flag set with all global flags defined. diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index e72f9811e38..55f8b758d29 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -111,9 +111,9 @@ func TestExecuteWithAutoInstall_ReturnsCommandErrorWithoutPanicForOutputFlags(t os.Stderr = originalStderr }() - var execErr error + var execResult *ExecuteResult require.NotPanics(t, func() { - execErr = ExecuteWithAutoInstall(t.Context(), rootContainer) + execResult = ExecuteWithAutoInstall(t.Context(), rootContainer) }) require.NoError(t, stderrWriter.Close()) @@ -121,9 +121,64 @@ func TestExecuteWithAutoInstall_ReturnsCommandErrorWithoutPanicForOutputFlags(t stderrBytes, err := io.ReadAll(stderrReader) require.NoError(t, err) - require.ErrorContains(t, execErr, "unsupported format 'unknown'") + require.ErrorContains(t, execResult.Err, "unsupported format 'unknown'") require.NotContains(t, string(stderrBytes), "panic:") require.Contains(t, string(stderrBytes), "Error: unsupported format 'unknown'") + + // auth token is a lightspeed command — verify the flag is set even when the command fails + require.True(t, execResult.IsLightspeed, "auth token should be detected as a lightspeed command") +} + +func TestExecuteWithAutoInstall_LightspeedDetection(t *testing.T) { + originalArgs := os.Args + defer func() { + os.Args = originalArgs + }() + + clearAgentEnvVarsForTest(t) + agentdetect.ResetDetection() + defer agentdetect.ResetDetection() + + tests := []struct { + name string + args []string + expectLightspeed bool + }{ + { + name: "auth_token_is_lightspeed", + // Use --output unknown to fail before touching the credential stack. + // We only care about IsLightspeed detection, not actual token acquisition. + args: []string{"azd", "auth", "token", "--output", "unknown"}, + expectLightspeed: true, + }, + { + name: "version_is_not_lightspeed", + args: []string{"azd", "version"}, + expectLightspeed: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Prevent non-lightspeed commands from making real HTTP update check calls. + t.Setenv("AZD_SKIP_UPDATE_CHECK", "true") + os.Args = tt.args + rootContainer := ioc.NewNestedContainer(nil) + result := ExecuteWithAutoInstall(t.Context(), rootContainer) + require.Equal(t, tt.expectLightspeed, result.IsLightspeed) + + if tt.expectLightspeed { + // Lightspeed commands must NOT start the update check goroutine. + require.Nil(t, result.LatestVersion, + "lightspeed commands should not start the update check") + } else { + // Non-lightspeed commands should always start the update check + // (even when AZD_SKIP_UPDATE_CHECK short-circuits it). + require.NotNil(t, result.LatestVersion, + "non-lightspeed commands should start the update check") + } + }) + } } // TestAgentDetectionIntegration tests the full agent detection integration flow. diff --git a/cli/azd/cmd/cobra_builder.go b/cli/azd/cmd/cobra_builder.go index 0aaadc09857..89f9843e03c 100644 --- a/cli/azd/cmd/cobra_builder.go +++ b/cli/azd/cmd/cobra_builder.go @@ -297,6 +297,15 @@ func (cb *CobraBuilder) bindCommand(cmd *cobra.Command, descriptor *actions.Acti actions.SetGroupCommandAnnotation(cmd, descriptor.Options.GroupingOptions.RootLevelHelp) } + // Propagate lightspeed flag to cobra annotations so it can be detected + // after rootCmd.Find() without needing access to the ActionDescriptor. + if descriptor.Options != nil && descriptor.Options.Lightspeed { + if cmd.Annotations == nil { + cmd.Annotations = make(map[string]string) + } + cmd.Annotations[actions.AnnotationLightspeed] = "true" + } + // `generateCmdHelp` sets a default help section when `descriptor.Options.HelpOptions` is nil. // This call ensures all commands gets the same help formatting. cmd.SetHelpTemplate(generateCmdHelp(cmd, generateCmdHelpOptions{ diff --git a/cli/azd/cmd/cobra_builder_test.go b/cli/azd/cmd/cobra_builder_test.go index 2cf6663ea5a..2e724f3e186 100644 --- a/cli/azd/cmd/cobra_builder_test.go +++ b/cli/azd/cmd/cobra_builder_test.go @@ -381,3 +381,41 @@ func (m *testMiddlewareB) Run(ctx context.Context, nextFn middleware.NextFn) (*a return nextFn(ctx) } + +func Test_LightspeedAnnotation(t *testing.T) { + t.Parallel() + + container := ioc.NewNestedContainer(nil) + ioc.RegisterInstance(container, &internal.GlobalCommandOptions{}) + builder := NewCobraBuilder(container) + + root := actions.NewActionDescriptor("root", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{Use: "root"}, + }) + + // Add a lightspeed child command + root.Add("fast", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{Use: "fast"}, + ActionResolver: newTestAction, + Lightspeed: true, + }) + + // Add a normal child command + root.Add("normal", &actions.ActionDescriptorOptions{ + Command: &cobra.Command{Use: "normal"}, + ActionResolver: newTestAction, + }) + + rootCmd, err := builder.BuildCommand(root) + require.NoError(t, err) + + // Verify lightspeed annotation is set on the fast command + fastCmd, _, err := rootCmd.Find([]string{"fast"}) + require.NoError(t, err) + require.Equal(t, "true", fastCmd.Annotations[actions.AnnotationLightspeed]) + + // Verify lightspeed annotation is NOT set on the normal command + normalCmd, _, err := rootCmd.Find([]string{"normal"}) + require.NoError(t, err) + require.NotEqual(t, "true", normalCmd.Annotations[actions.AnnotationLightspeed]) +} diff --git a/cli/azd/main.go b/cli/azd/main.go index 38bdacb1820..7d1d732bf08 100644 --- a/cli/azd/main.go +++ b/cli/azd/main.go @@ -60,10 +60,6 @@ func main() { showedElevationWarning := false - latest := make(chan *update.VersionInfo) - bgCtx, bgCancel := context.WithTimeout(context.Background(), 60*time.Second) - go fetchLatestVersion(bgCtx, latest) - rootContainer := ioc.NewNestedContainer(nil) ctx = context.WithoutCancel(ctx) @@ -72,8 +68,11 @@ func main() { // Register the context for singleton resolution ioc.RegisterInstance(rootContainer, ctx) - // Execute command with auto-installation support for extensions - cmdErr := cmd.ExecuteWithAutoInstall(ctx, rootContainer) + // Execute command with auto-installation support for extensions. + // The update check goroutine is started inside ExecuteWithAutoInstall, + // after the command is identified — lightspeed commands skip it entirely. + execResult := cmd.ExecuteWithAutoInstall(ctx, rootContainer) + cmdErr := execResult.Err oneauth.Shutdown() @@ -83,8 +82,15 @@ func main() { } } - versionInfo, ok := <-latest - bgCancel() + // Wait for the background update check if one was started. + // For lightspeed commands, LatestVersion is nil (no goroutine was started). + var versionInfo *update.VersionInfo + if execResult.LatestVersion != nil { + v, ok := <-execResult.LatestVersion + if ok { + versionInfo = v + } + } // 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. Non-production builds (dev builds via `go install` and @@ -92,7 +98,7 @@ func main() { // // 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 && !suppressUpdateBanner() && !showedElevationWarning { + if versionInfo != nil && !isJsonOutput() && !suppressUpdateBanner() && !showedElevationWarning { if internal.IsNonProdVersion() { log.Printf("eliding update message for non-production build") } else if versionInfo.HasUpdate { @@ -148,43 +154,6 @@ func main() { } } -// fetchLatestVersion checks for a newer version of the CLI using the user's -// configured channel and sends the result across the channel, which it then closes. -// If the latest version can not be determined, the channel is closed without writing a value. -func fetchLatestVersion(ctx context.Context, result chan<- *update.VersionInfo) { - defer close(result) - - // 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) - } - } - - // Load user config to determine channel - configMgr := config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())) - userConfig, err := configMgr.Load() - if err != nil { - userConfig = config.NewEmptyConfig() - } - - cfg := update.LoadUpdateConfig(userConfig) - - mgr := update.NewManager(nil, nil) - versionInfo, err := mgr.CheckForUpdate(ctx, cfg, false) - if err != nil { - log.Printf("failed to check for updates: %v, skipping update check", err) - return - } - - result <- versionInfo -} - // isDebugEnabled checks to see if `--debug` was passed with a truthy // value. func isDebugEnabled() bool {