diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index f1cfa39afdc..eec4e14352b 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -9,6 +9,7 @@ import ( "fmt" "io" "log" + "maps" "os" "path/filepath" "slices" @@ -26,7 +27,6 @@ import ( "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" - "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" @@ -214,6 +214,55 @@ func promptForExtensionChoice( return matches[choice], nil } +// chooseLogicalExtensionCandidates separates the rare choice between different extension IDs from +// source selection. All source candidates for the selected logical extension are preserved so the +// auto-install UX can present them after discovery is complete. +func chooseLogicalExtensionCandidates( + ctx context.Context, + console input.Console, + matches []*extensions.ExtensionMetadata, +) ([]*extensions.ExtensionMetadata, error) { + if len(matches) == 0 { + return nil, fmt.Errorf("no extensions to choose from") + } + + grouped := map[string][]*extensions.ExtensionMetadata{} + for _, match := range matches { + id := strings.ToLower(match.Id) + grouped[id] = append(grouped[id], match) + } + for id := range maps.Keys(grouped) { + slices.SortFunc(grouped[id], func(a, b *extensions.ExtensionMetadata) int { + return strings.Compare(strings.ToLower(a.Source), strings.ToLower(b.Source)) + }) + } + + ids := slices.Sorted(maps.Keys(grouped)) + if len(ids) == 1 { + return grouped[ids[0]], nil + } + + representatives := make([]*extensions.ExtensionMetadata, 0, len(ids)) + for _, id := range ids { + representatives = append(representatives, grouped[id][0]) + } + chosen, err := promptForExtensionChoice(ctx, console, representatives) + if err != nil { + return nil, err + } + return grouped[strings.ToLower(chosen.Id)], nil +} + +func requirementCandidates(requirement projectExtensionRequirement) []*extensions.ExtensionMetadata { + if len(requirement.candidates) > 0 { + return requirement.candidates + } + if requirement.extension == nil { + return nil + } + return []*extensions.ExtensionMetadata{requirement.extension} +} + // isBuiltInCommand checks if the given command is a built-in command by examining // the root command's command tree. This includes both core azd commands and any // installed extensions, preventing auto-install from triggering for known commands. @@ -286,10 +335,10 @@ func tryAutoInstallForPartialNamespace( rootContainer *ioc.NestedContainer, foundCmd *cobra.Command, remainingArgs []string, -) bool { +) (autoInstallResult, error) { if _, isExtensionCmd := foundCmd.Annotations["extension.id"]; isExtensionCmd { // Extension commands handle their own args via DisableFlagParsing - return false + return autoInstallResult{}, nil } var firstRemainingArg string @@ -301,64 +350,44 @@ func tryAutoInstallForPartialNamespace( } if firstRemainingArg == "" || hasSubcommand(foundCmd, firstRemainingArg) { - return false + return autoInstallResult{}, nil } argsForMatching := buildNamespaceArgs(foundCmd, remainingArgs) if len(argsForMatching) == 0 { - return false + return autoInstallResult{}, nil } var extensionManager *extensions.Manager var console input.Console if err := rootContainer.Resolve(&extensionManager); err != nil { log.Printf("failed to resolve extension manager: %v", err) - return false + return autoInstallResult{}, nil } if err := rootContainer.Resolve(&console); err != nil { log.Printf("failed to resolve console: %v", err) - return false + return autoInstallResult{}, nil } extensionMatches, err := checkForMatchingExtensions(ctx, extensionManager, argsForMatching) if err != nil { log.Printf("failed to check for matching extensions: %v", err) - return false + return autoInstallResult{}, nil } if len(extensionMatches) == 0 { - return false - } - - console.Message(ctx, - fmt.Sprintf("Command '%s' was not found, but there's an available extension that provides it\n", - strings.Join(argsForMatching, " "))) - - chosenExtension, err := promptForExtensionChoice(ctx, console, extensionMatches) - if err != nil { - console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - return false - } - if chosenExtension == nil { - return false - } - - installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, *chosenExtension) - if installErr != nil { - console.Message(ctx, installErr.Error()) - return false + return autoInstallResult{}, nil } - return installed -} - -// tryAutoInstallExtension attempts to auto-install an extension if the unknown command matches an available -// extension namespace. Returns true if an extension was found and installed, false otherwise. -func tryAutoInstallExtension( - ctx context.Context, - console input.Console, - extensionManager extensionAutoInstallManager, - extension extensions.ExtensionMetadata) (bool, error) { - return tryAutoInstallExtensionVersion(ctx, console, extensionManager, extension, "") + return autoInstallCommandMatches( + ctx, + console, + extensionManager, + extensionMatches, + fmt.Sprintf( + "Command '%s' isn't available. Install the required extension to use this command.", + strings.Join(argsForMatching, " "), + ), + ) } type extensionAutoInstallManager interface { @@ -378,6 +407,7 @@ func tryAutoInstallExtensionVersion( extensionManager extensionAutoInstallManager, extension extensions.ExtensionMetadata, versionPreference string, + displaySource bool, ) (bool, error) { // Check if the extension is already installed installedExtension, err := extensionManager.GetInstalled(extensions.FilterOptions{ @@ -390,44 +420,39 @@ func tryAutoInstallExtensionVersion( return false, nil } - // Return error if running in CI/CD environment - if resource.IsRunningOnCI() { - return false, - fmt.Errorf( - "Auto-installation is not supported in CI/CD environments.\n"+ - "Run '%s' to install it manually.", - fmt.Sprintf("azd extension install %s", extension.Id)) - } - - console.MessageUxItem(ctx, &ux.WarningMessage{ - Description: "You are about to install an extension!", - }) - console.Message(ctx, fmt.Sprintf("Source: %s", extension.Source)) - console.Message(ctx, fmt.Sprintf("Id: %s", extension.Id)) - console.Message(ctx, fmt.Sprintf("Name: %s", extension.DisplayName)) - console.Message(ctx, fmt.Sprintf("Description: %s", extension.Description)) - - // Ask user for permission to auto-install the extension - shouldInstall, err := console.Confirm(ctx, input.ConsoleOptions{ - DefaultValue: true, - Message: "Confirm installation", - }) + installedBefore, err := extensionManager.ListInstalled() if err != nil { - return false, err + return false, fmt.Errorf("listing installed extensions: %w", err) } - - if !shouldInstall { - return false, nil + preInstalledIds := make(map[string]struct{}, len(installedBefore)) + for id := range installedBefore { + preInstalledIds[id] = struct{}{} } - // Install the extension - console.Message(ctx, fmt.Sprintf("Installing extension '%s'...\n", extension.Id)) - _, err = extensionManager.Install(ctx, &extension, versionPreference) + stepMessage := extensionTaskMessage("Installing", extension.Id) + console.ShowSpinner(ctx, stepMessage, input.Step) + installedVersion, err := extensionManager.Install(ctx, &extension, versionPreference) if err != nil { + console.StopSpinner(ctx, stepMessage, input.StepFailed) return false, fmt.Errorf("failed to install extension: %w", err) } - console.Message(ctx, fmt.Sprintf("Extension '%s' installed successfully!\n", extension.Id)) + stepMessage += output.WithGrayFormat(" (%s)", installedVersion.Version) + if displaySource { + stepMessage += fmt.Sprintf(" from %s", extension.Source) + } + console.StopSpinner(ctx, stepMessage, input.StepDone) + if len(installedVersion.Dependencies) > 0 { + displayInstalledDependencies( + ctx, + console, + extensionManager, + installedVersion.Dependencies, + preInstalledIds, + " ", + map[string]struct{}{extension.Id: {}}, + ) + } return true, nil } @@ -610,6 +635,9 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai result.Err = err return result } + if projectExtensions.declined { + return result + } if projectExtensions.installed { rootCmd = newRootCmdWithoutRegistration(rootContainer) @@ -622,9 +650,22 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai } // Check for partial namespace match (e.g., "ai" found but "ai.agent" not installed) - if installed := tryAutoInstallForPartialNamespace( + partialNamespace, partialErr := tryAutoInstallForPartialNamespace( ctx, rootContainer, foundCmd, originalArgs, - ); installed { + ) + if partialErr != nil { + if resolveErr := rootContainer.Resolve(&console); resolveErr != nil { + fmt.Fprintln(os.Stderr, output.WithErrorFormat("ERROR: %s", partialErr.Error())) + } else { + displayAutoInstallError(ctx, console, partialErr) + } + result.Err = partialErr + return result + } + if partialNamespace.declined { + return result + } + if partialNamespace.installed { // Extension was installed, rebuild command tree and execute rootCmd = newRootCmdWithoutRegistration(rootContainer) result.Err = rootCmd.ExecuteContext(ctx) @@ -694,41 +735,30 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai return result } - console.Message(ctx, - fmt.Sprintf("Your project is using host '%s' which is not supported by default.\n", unsupportedErr.Host)) - - var extensionIdToInstall extensions.ExtensionMetadata - if len(availableExtensionsForHost) == 1 { - extensionIdToInstall = *availableExtensionsForHost[0] - console.Message(ctx, "An extension was found that provides support for this host.") - } else { - console.Message(ctx, "There are multiple extensions that provide support for this host.") - // Multiple matches found, prompt user to choose - chosenExtension, err := promptForExtensionChoice(ctx, console, availableExtensionsForHost) - if err != nil { - console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - result.Err = err - return result - } - extensionIdToInstall = *chosenExtension - } - - installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, extensionIdToInstall) + autoInstall, installErr := autoInstallCommandMatches( + ctx, + console, + extensionManager, + availableExtensionsForHost, + fmt.Sprintf( + "Your project requires support for host '%s'. Install the required extension to continue.", + unsupportedErr.Host, + ), + ) 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()) + displayAutoInstallError(ctx, console, installErr) result.Err = installErr return result } - - if installed { + if autoInstall.declined { + return result + } + if autoInstall.installed { // Extension was installed, build command tree and execute rootCmd := newRootCmdWithoutRegistration(rootContainer) result.Err = rootCmd.ExecuteContext(ctx) return result } - - // The install was declined, so the command's failure stands. result.Err = commandErr return result } @@ -822,34 +852,25 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai log.Panic("failed to resolve console for auto-install:", err) } - console.Message(ctx, - fmt.Sprintf("Command '%s' was not found, but there's an available extension that provides it\n", - strings.Join(argsForMatching, " "))) - - // Prompt user to choose if multiple extensions match - chosenExtension, err := promptForExtensionChoice(ctx, console, extensionMatches) - if err != nil { - console.Message(ctx, fmt.Sprintf("Error selecting extension: %v", err)) - result.Err = rootCmd.ExecuteContext(ctx) - return result - } - - if chosenExtension == nil { - // User cancelled selection, proceed to normal execution - result.Err = rootCmd.ExecuteContext(ctx) - return result - } - - // Try to auto-install the chosen extension - installed, installErr := tryAutoInstallExtension(ctx, console, extensionManager, *chosenExtension) + autoInstall, installErr := autoInstallCommandMatches( + ctx, + console, + extensionManager, + extensionMatches, + fmt.Sprintf( + "Command '%s' isn't available. Install the required extension to use this command.", + strings.Join(argsForMatching, " "), + ), + ) 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()) + displayAutoInstallError(ctx, console, installErr) result.Err = installErr return result } - - if installed { + if autoInstall.declined { + return result + } + if autoInstall.installed { // Extension was installed, build command tree and execute rootCmd := newRootCmdWithoutRegistration(rootContainer) result.Err = rootCmd.ExecuteContext(ctx) diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 19ce1fd34a7..c736f68593a 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -28,9 +28,11 @@ import ( ) type fakeExtensionAutoInstallManager struct { - available []*extensions.ExtensionMetadata - installed map[string]*extensions.Extension - findErr error + available []*extensions.ExtensionMetadata + installed map[string]*extensions.Extension + findErr error + installErr error + installFn func(*extensions.ExtensionMetadata) (*extensions.ExtensionVersion, error) } func (m *fakeExtensionAutoInstallManager) FindExtensions( @@ -91,6 +93,12 @@ func (m *fakeExtensionAutoInstallManager) Install( extension *extensions.ExtensionMetadata, _ string, ) (*extensions.ExtensionVersion, error) { + if m.installErr != nil { + return nil, m.installErr + } + if m.installFn != nil { + return m.installFn(extension) + } version := &extension.Versions[0] m.installed[extension.Id] = &extensions.Extension{ Id: extension.Id, @@ -240,7 +248,7 @@ func TestMissingProjectExtensionsSkipsInstalledProviderAcrossSources(t *testing. require.Empty(t, requirements) } -func TestMissingProjectExtensionsReusesSourceChoiceAcrossProviders(t *testing.T) { +func TestMissingProjectExtensionsPreservesSourceCandidatesAcrossProviders(t *testing.T) { providerVersion := extensions.ExtensionVersion{ Version: "0.7.0", Capabilities: []extensions.CapabilityType{ @@ -273,19 +281,14 @@ func TestMissingProjectExtensionsReusesSourceChoiceAcrossProviders(t *testing.T) }, Infra: provisioning.Options{Provider: "demo"}, } - selectCount := 0 console := mockinput.NewMockConsole() - console.WhenSelect(func(options input.ConsoleOptions) bool { - selectCount++ - return true - }).Respond(0) requirements, err := missingProjectExtensions(t.Context(), console, manager, projectConfig) require.NoError(t, err) require.Len(t, requirements, 1) require.Equal(t, "azd", requirements[0].extension.Source) - require.Equal(t, 1, selectCount) + require.Len(t, requirements[0].candidates, 2) } func TestMissingProjectExtensionsSkipsExtensionPackDependencies(t *testing.T) { @@ -383,6 +386,68 @@ func TestMissingProjectExtensionsSkipsExtensionPackDependencies(t *testing.T) { require.Equal(t, "microsoft.foundry", requirements[0].extension.Id) } +func TestMissingProjectExtensionsNarrowsParentToSourceWhoseDependencyProvidesProvider(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + { + Id: "test.pack", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []extensions.ExtensionDependency{{Id: "test.provider"}}, + }}, + }, + { + Id: "test.pack", + Source: "local", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []extensions.ExtensionDependency{{Id: "test.provider"}}, + }}, + }, + { + Id: "test.provider", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "demo", + Type: extensions.ServiceTargetProviderType, + }}, + }}, + }, + { + Id: "test.provider", + Source: "local", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + }}, + }, + }, + installed: map[string]*extensions.Extension{}, + } + projectConfig := &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{"test.pack": new("1.0.0")}, + }, + Services: map[string]*project.ServiceConfig{"demo": {Host: "demo"}}, + } + + requirements, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + + require.NoError(t, err) + require.Len(t, requirements, 1) + assert.Equal(t, "test.pack", requirements[0].extension.Id) + require.Len(t, requirements[0].candidates, 1) + assert.Equal(t, "azd", requirements[0].candidates[0].Source) +} + // A pack pins the version of its dependency, so a later version of that dependency that publishes // the provider is not installable. Resolution adds no requirement for it and leaves the command to // report the missing provider. @@ -708,6 +773,7 @@ func TestTryAutoInstallExtensionVersionRejectsInstalledVersionConstraint(t *test manager, extensions.ExtensionMetadata{Id: "test.extension"}, ">=2.0.0", + false, ) require.False(t, installed) @@ -1706,8 +1772,10 @@ func Test_TryAutoInstall_NoAnnotation(t *testing.T) { t.Parallel() cmd := &cobra.Command{Use: "root"} container := ioc.NewNestedContainer(nil) - result := tryAutoInstallForPartialNamespace(t.Context(), container, cmd, nil) - assert.False(t, result) + result, err := tryAutoInstallForPartialNamespace(t.Context(), container, cmd, nil) + require.NoError(t, err) + assert.False(t, result.installed) + assert.False(t, result.declined) } func Test_TryAutoInstall_HasSubcommand(t *testing.T) { @@ -1717,8 +1785,10 @@ func Test_TryAutoInstall_HasSubcommand(t *testing.T) { root.AddCommand(child) container := ioc.NewNestedContainer(nil) // The "deploy" command already exists as sub-command, so partial namespace shouldn't trigger - result := tryAutoInstallForPartialNamespace(t.Context(), container, root, []string{"deploy"}) - assert.False(t, result) + result, err := tryAutoInstallForPartialNamespace(t.Context(), container, root, []string{"deploy"}) + require.NoError(t, err) + assert.False(t, result.installed) + assert.False(t, result.declined) } func TestHelpRequested(t *testing.T) { diff --git a/cli/azd/cmd/auto_install_ux.go b/cli/azd/cmd/auto_install_ux.go new file mode 100644 index 00000000000..639fab6d16d --- /dev/null +++ b/cli/azd/cmd/auto_install_ux.go @@ -0,0 +1,673 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "slices" + "strings" + "text/tabwriter" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" + "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/output" +) + +type autoInstallDisplayContext struct { + requiredByProject bool +} + +type autoInstallResult struct { + installed bool + declined bool +} + +type extensionInstallSelection struct { + requirement projectExtensionRequirement + extension *extensions.ExtensionMetadata +} + +func autoInstallCommandMatches( + ctx context.Context, + console input.Console, + extensionManager extensionAutoInstallManager, + matches []*extensions.ExtensionMetadata, + intro string, +) (autoInstallResult, error) { + console.Message(ctx, intro) + console.Message(ctx, "") + candidates, err := chooseLogicalExtensionCandidates(ctx, console, matches) + if err != nil { + return autoInstallResult{}, err + } + + return autoInstallExtensionRequirements( + ctx, + console, + extensionManager, + []projectExtensionRequirement{{ + extension: candidates[0], + candidates: candidates, + }}, + autoInstallDisplayContext{}, + ) +} + +func autoInstallExtensionRequirements( + ctx context.Context, + console input.Console, + extensionManager extensionAutoInstallManager, + requirements []projectExtensionRequirement, + display autoInstallDisplayContext, +) (autoInstallResult, error) { + if len(requirements) == 0 { + return autoInstallResult{}, nil + } + + displayExtensionRequirements(ctx, console, requirements, display) + + if resource.IsRunningOnCI() { + return autoInstallResult{}, manualInstallError( + requirements, + "Auto-installation is not supported in CI/CD environments.", + ) + } + + var selections []extensionInstallSelection + var declined bool + var err error + if console.IsNoPromptMode() { + selections, err = noPromptInstallPlan(requirements) + if err == nil { + console.Message(ctx, "\nNo-prompt mode: installing required extensions automatically.") + } + } else { + selections, declined, err = interactiveInstallPlan(ctx, console, requirements) + } + if err != nil { + return autoInstallResult{}, err + } + if declined { + console.Message(ctx, "\nCanceled: required extension isn't installed.") + return autoInstallResult{declined: true}, nil + } + + selections, err = orderInstallSelections(selections) + if err != nil { + return autoInstallResult{}, err + } + displaySources := installSelectionsUseMultipleSources(selections) + + console.Message(ctx, "") + installedAny := false + for _, selection := range selections { + installed, err := tryAutoInstallExtensionVersion( + ctx, + console, + extensionManager, + *selection.extension, + selection.requirement.versionPreference, + displaySources, + ) + if err != nil { + return autoInstallResult{installed: installedAny}, err + } + installedAny = installedAny || installed + } + if installedAny { + console.Message(ctx, "") + } + + return autoInstallResult{installed: installedAny}, nil +} + +func installSelectionsUseMultipleSources(selections []extensionInstallSelection) bool { + sources := make(map[string]struct{}, len(selections)) + for _, selection := range selections { + sources[strings.ToLower(selection.extension.Source)] = struct{}{} + if len(sources) >= 2 { + return true + } + } + return false +} + +func orderInstallSelections( + selections []extensionInstallSelection, +) ([]extensionInstallSelection, error) { + byID := make(map[string]extensionInstallSelection, len(selections)) + for _, selection := range selections { + byID[strings.ToLower(selection.extension.Id)] = selection + } + + const ( + selectionVisiting = iota + 1 + selectionVisited + ) + state := make(map[string]int, len(selections)) + ordered := make([]extensionInstallSelection, 0, len(selections)) + var visit func(extensionInstallSelection) error + visit = func(selection extensionInstallSelection) error { + id := strings.ToLower(selection.extension.Id) + switch state[id] { + case selectionVisited: + return nil + case selectionVisiting: + // Installation reports dependency cycles. Avoid recursing forever while + // preserving a deterministic plan for the manager to validate. + return nil + } + state[id] = selectionVisiting + + version, err := extensions.ResolveExtensionVersion( + selection.extension, + selection.requirement.versionPreference, + nil, + ) + if err != nil { + return fmt.Errorf("resolving required extension %s: %w", selection.extension.Id, err) + } + for _, dependency := range version.Dependencies { + if dependencySelection, selected := byID[strings.ToLower(dependency.Id)]; selected { + if err := visit(dependencySelection); err != nil { + return err + } + } + } + + state[id] = selectionVisited + ordered = append(ordered, selection) + return nil + } + + for _, selection := range selections { + if err := visit(selection); err != nil { + return nil, err + } + } + return ordered, nil +} + +func displayExtensionRequirements( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, + display autoInstallDisplayContext, +) { + if len(requirements) == 1 { + requirement := requirements[0] + extension := requirement.extension + header := "Extension required: %s" + if display.requiredByProject { + header = "Extension required by azure.yaml: %s" + } + console.Message(ctx, output.WithHighLightFormat(header, extension.DisplayName)) + + var details strings.Builder + tabs := tabwriter.NewWriter(&details, 0, 4, 2, ' ', 0) + fmt.Fprintf(tabs, " ID:\t%s\n", extension.Id) + sourceLabel := "Source" + if len(requirementCandidates(requirement)) > 1 { + sourceLabel = "Sources" + } + fmt.Fprintf(tabs, " %s:\t%s\n", sourceLabel, sourceSummary(requirement, false)) + fmt.Fprintf(tabs, " Description:\t%s\n", extension.Description) + _ = tabs.Flush() + console.Message(ctx, strings.TrimRight(details.String(), "\n")) + console.Message(ctx, "") + return + } + + if display.requiredByProject { + console.Message( + ctx, + output.WithHighLightFormat("%d extensions required by azure.yaml:", len(requirements)), + ) + } else { + console.Message(ctx, output.WithHighLightFormat("%d extensions required:", len(requirements))) + } + console.Message(ctx, "") + + usePluralSource := slices.ContainsFunc(requirements, func(requirement projectExtensionRequirement) bool { + return len(requirementCandidates(requirement)) > 1 + }) + sourceHeading := "Source" + if usePluralSource { + sourceHeading = "Sources" + } + + var table strings.Builder + tabs := tabwriter.NewWriter(&table, 0, 4, 2, ' ', 0) + fmt.Fprintf(tabs, " Extension\tID\t%s\n", sourceHeading) + for _, requirement := range requirements { + fmt.Fprintf( + tabs, + " %s\t%s\t%s\n", + requirement.extension.DisplayName, + requirement.extension.Id, + sourceSummary(requirement, true), + ) + } + _ = tabs.Flush() + tableOutput := strings.TrimRight(table.String(), "\n") + if headerEnd := strings.IndexByte(tableOutput, '\n'); headerEnd >= 0 { + tableOutput = output.WithGrayFormat(tableOutput[:headerEnd]) + tableOutput[headerEnd:] + } else { + tableOutput = output.WithGrayFormat(tableOutput) + } + console.Message(ctx, tableOutput) + console.Message(ctx, "") +} + +func sourceSummary(requirement projectExtensionRequirement, compact bool) string { + candidates := sortedRequirementCandidates(requirement) + names := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + names = append(names, candidate.Source) + } + if !compact || len(names) <= 3 { + return strings.Join(names, ", ") + } + return fmt.Sprintf("%s %s", names[0], output.WithGrayFormat("(+%d more)", len(names)-1)) +} + +func sortedRequirementCandidates( + requirement projectExtensionRequirement, +) []*extensions.ExtensionMetadata { + candidates := slices.Clone(requirementCandidates(requirement)) + recommended := recommendedSourceCandidate(requirement) + slices.SortFunc(candidates, func(a, b *extensions.ExtensionMetadata) int { + switch { + case recommended != nil && a == recommended: + return -1 + case recommended != nil && b == recommended: + return 1 + default: + return strings.Compare(strings.ToLower(a.Source), strings.ToLower(b.Source)) + } + }) + return candidates +} + +func recommendedSourceCandidate( + requirement projectExtensionRequirement, +) *extensions.ExtensionMetadata { + official := slices.DeleteFunc( + slices.Clone(requirementCandidates(requirement)), + func(candidate *extensions.ExtensionMetadata) bool { + return candidate.SourceCategoryOrUnknown() != extensions.SourceCategoryAzd + }, + ) + if len(official) == 1 { + return official[0] + } + + namedAzd := slices.DeleteFunc(official, func(candidate *extensions.ExtensionMetadata) bool { + return !strings.EqualFold(candidate.Source, "azd") + }) + if len(namedAzd) == 1 { + return namedAzd[0] + } + return nil +} + +func noPromptInstallPlan( + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, error) { + for _, requirement := range requirements { + if len(requirementCandidates(requirement)) != 1 { + return nil, manualInstallError( + requirements, + "Required extensions are available from more than one source.", + ) + } + } + + selections := make([]extensionInstallSelection, 0, len(requirements)) + for _, requirement := range requirements { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: requirementCandidates(requirement)[0], + }) + } + return selections, nil +} + +func manualInstallError( + requirements []projectExtensionRequirement, + message string, +) error { + var suggestion strings.Builder + suggestion.WriteString("Install the required extensions manually, then run this command again:") + for _, requirement := range requirements { + candidates := sortedRequirementCandidates(requirement) + if len(candidates) > 1 { + fmt.Fprintf(&suggestion, "\n\nChoose one source for %s:", requirement.extension.Id) + } + for _, candidate := range candidates { + versionArg := "" + if requirement.versionPreference != "" { + version, err := extensions.ResolveExtensionVersion( + candidate, + requirement.versionPreference, + nil, + ) + if err != nil { + return fmt.Errorf("resolving required extension %s: %w", candidate.Id, err) + } + versionArg = fmt.Sprintf(" --version %s", version.Version) + } + fmt.Fprintf( + &suggestion, + "\n azd extension install %s --source %s%s", + candidate.Id, + candidate.Source, + versionArg, + ) + } + } + + return &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("required extension installation needs manual action"), + Message: message, + Suggestion: suggestion.String(), + } +} + +func interactiveInstallPlan( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, bool, error) { + if len(requirements) == 1 { + return interactiveSingleInstallPlan(ctx, console, requirements[0]) + } + return interactiveMultipleInstallPlan(ctx, console, requirements) +} + +func interactiveSingleInstallPlan( + ctx context.Context, + console input.Console, + requirement projectExtensionRequirement, +) ([]extensionInstallSelection, bool, error) { + candidates := requirementCandidates(requirement) + if len(candidates) == 1 { + confirmed, err := console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Install %s?", requirement.extension.DisplayName), + DefaultValue: true, + }) + if err != nil { + return nil, false, err + } + if !confirmed { + return nil, true, nil + } + return []extensionInstallSelection{{requirement: requirement, extension: candidates[0]}}, false, nil + } + + recommended := recommendedSourceCandidate(requirement) + if recommended != nil { + choice, err := console.Select(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Install %s from '%s'", + requirement.extension.DisplayName, + recommended.Source, + ), + Options: []string{ + fmt.Sprintf("Install from '%s' (recommended)", recommended.Source), + "Install from a different source", + "Cancel", + }, + DefaultValue: fmt.Sprintf("Install from '%s' (recommended)", recommended.Source), + EnableFiltering: new(false), + }) + if err != nil { + return nil, false, err + } + switch choice { + case 0: + return []extensionInstallSelection{{requirement: requirement, extension: recommended}}, false, nil + case 1: + selected, err := selectRequirementSource(ctx, console, requirement) + if err != nil { + return nil, false, err + } + return []extensionInstallSelection{{requirement: requirement, extension: selected}}, false, nil + default: + return nil, true, nil + } + } + + confirmed, err := console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Install %s?", requirement.extension.DisplayName), + DefaultValue: true, + }) + if err != nil { + return nil, false, err + } + if !confirmed { + return nil, true, nil + } + selected, err := selectRequirementSource(ctx, console, requirement) + if err != nil { + return nil, false, err + } + return []extensionInstallSelection{{requirement: requirement, extension: selected}}, false, nil +} + +func interactiveMultipleInstallPlan( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, bool, error) { + allSingleSource := !slices.ContainsFunc(requirements, func(requirement projectExtensionRequirement) bool { + return len(requirementCandidates(requirement)) > 1 + }) + if allSingleSource { + confirmed, err := confirmInstallAll(ctx, console, len(requirements)) + if err != nil || !confirmed { + return nil, !confirmed, err + } + return soleSourceSelections(requirements), false, nil + } + + if source, hasCommonRecommendedSource := commonRecommendedSource(requirements); hasCommonRecommendedSource { + choice, err := console.Select(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Install all %d required extensions from '%s'", + len(requirements), + source, + ), + Options: []string{ + fmt.Sprintf("Install all from '%s' (recommended)", source), + "Install all from a different source", + "Cancel", + }, + DefaultValue: fmt.Sprintf("Install all from '%s' (recommended)", source), + EnableFiltering: new(false), + }) + if err != nil { + return nil, false, err + } + switch choice { + case 0: + selections := make([]extensionInstallSelection, 0, len(requirements)) + for _, requirement := range requirements { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: recommendedSourceCandidate(requirement), + }) + } + return selections, false, nil + case 1: + selections, err := selectDifferentSources(ctx, console, requirements) + return selections, false, err + default: + return nil, true, nil + } + } + + confirmed, err := confirmInstallAll(ctx, console, len(requirements)) + if err != nil || !confirmed { + return nil, !confirmed, err + } + selections, err := selectSourcesIndividually(ctx, console, requirements) + return selections, false, err +} + +func commonRecommendedSource(requirements []projectExtensionRequirement) (string, bool) { + if len(requirements) == 0 { + return "", false + } + + first := recommendedSourceCandidate(requirements[0]) + if first == nil { + return "", false + } + for _, requirement := range requirements[1:] { + candidate := recommendedSourceCandidate(requirement) + if candidate == nil || !strings.EqualFold(candidate.Source, first.Source) { + return "", false + } + } + return first.Source, true +} + +func confirmInstallAll(ctx context.Context, console input.Console, count int) (bool, error) { + return console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Install all %d required extensions?", count), + DefaultValue: true, + }) +} + +func soleSourceSelections(requirements []projectExtensionRequirement) []extensionInstallSelection { + selections := make([]extensionInstallSelection, 0, len(requirements)) + for _, requirement := range requirements { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: requirementCandidates(requirement)[0], + }) + } + return selections +} + +func selectDifferentSources( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, error) { + selections := make([]extensionInstallSelection, 0, len(requirements)) + firstAmbiguous := slices.IndexFunc(requirements, func(requirement projectExtensionRequirement) bool { + return len(requirementCandidates(requirement)) > 1 + }) + for _, requirement := range requirements[:firstAmbiguous] { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: requirementCandidates(requirement)[0], + }) + } + + first := requirements[firstAmbiguous] + selected, err := selectRequirementSource( + ctx, + console, + first, + ) + if err != nil { + return nil, err + } + selections = append(selections, extensionInstallSelection{requirement: first, extension: selected}) + + remaining := requirements[firstAmbiguous+1:] + if len(remaining) == 0 { + return selections, nil + } + if slices.ContainsFunc(remaining, func(requirement projectExtensionRequirement) bool { + return candidateForSource(requirement, selected.Source) == nil + }) { + individual, err := selectSourcesIndividually(ctx, console, remaining) + return append(selections, individual...), err + } + + useForRemaining, err := console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Install remaining extensions from '%s'?", selected.Source), + DefaultValue: true, + }) + if err != nil { + return nil, err + } + if useForRemaining { + for _, requirement := range remaining { + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: candidateForSource(requirement, selected.Source), + }) + } + return selections, nil + } + + individual, err := selectSourcesIndividually(ctx, console, remaining) + return append(selections, individual...), err +} + +func selectSourcesIndividually( + ctx context.Context, + console input.Console, + requirements []projectExtensionRequirement, +) ([]extensionInstallSelection, error) { + selections := make([]extensionInstallSelection, 0, len(requirements)) + for _, requirement := range requirements { + candidates := requirementCandidates(requirement) + selected := candidates[0] + var err error + if len(candidates) > 1 { + selected, err = selectRequirementSource(ctx, console, requirement) + if err != nil { + return nil, err + } + } + selections = append(selections, extensionInstallSelection{ + requirement: requirement, + extension: selected, + }) + } + return selections, nil +} + +func selectRequirementSource( + ctx context.Context, + console input.Console, + requirement projectExtensionRequirement, +) (*extensions.ExtensionMetadata, error) { + candidates := sortedRequirementCandidates(requirement) + options := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + options = append(options, candidate.Source) + } + + choice, err := console.Select(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf("Select a source for %s", requirement.extension.DisplayName), + Options: options, + EnableFiltering: new(false), + }) + if err != nil { + return nil, err + } + return candidates[choice], nil +} + +func candidateForSource( + requirement projectExtensionRequirement, + source string, +) *extensions.ExtensionMetadata { + for _, candidate := range requirementCandidates(requirement) { + if strings.EqualFold(candidate.Source, source) { + return candidate + } + } + return nil +} diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go new file mode 100644 index 00000000000..3297e7a5aa5 --- /dev/null +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -0,0 +1,705 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal" + "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/output" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func autoInstallTestExtension( + id string, + name string, + source string, + category extensions.SourceCategory, +) *extensions.ExtensionMetadata { + return &extensions.ExtensionMetadata{ + Id: id, + DisplayName: name, + Description: name + " description", + Source: source, + SourceCategory: category, + Versions: []extensions.ExtensionVersion{{Version: "1.2.3"}}, + } +} + +func autoInstallTestRequirement( + candidates ...*extensions.ExtensionMetadata, +) projectExtensionRequirement { + return projectExtensionRequirement{ + extension: candidates[0], + candidates: candidates, + } +} + +func TestRecommendedSourceCandidate(t *testing.T) { + t.Parallel() + + officialAlias := autoInstallTestExtension( + "demo", + "Demo", + "official", + extensions.SourceCategoryAzd, + ) + azd := autoInstallTestExtension("demo", "Demo", "azd", extensions.SourceCategoryAzd) + local := autoInstallTestExtension("demo", "Demo", "local", extensions.SourceCategoryLocal) + + tests := []struct { + name string + candidates []*extensions.ExtensionMetadata + expected *extensions.ExtensionMetadata + }{ + { + name: "unique official source", + candidates: []*extensions.ExtensionMetadata{local, officialAlias}, + expected: officialAlias, + }, + { + name: "literal azd wins among aliases", + candidates: []*extensions.ExtensionMetadata{officialAlias, azd, local}, + expected: azd, + }, + { + name: "ambiguous official aliases", + candidates: []*extensions.ExtensionMetadata{ + officialAlias, + autoInstallTestExtension("demo", "Demo", "mirror", extensions.SourceCategoryAzd), + }, + }, + { + name: "no official source", + candidates: []*extensions.ExtensionMetadata{local}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + requirement := autoInstallTestRequirement(tt.candidates...) + assert.Same(t, tt.expected, recommendedSourceCandidate(requirement)) + }) + } +} + +func TestSourceSummaryCollapsesFourOrMoreSources(t *testing.T) { + t.Parallel() + + candidates := []*extensions.ExtensionMetadata{ + autoInstallTestExtension("demo", "Demo", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("demo", "Demo", "source-a", extensions.SourceCategoryOther), + autoInstallTestExtension("demo", "Demo", "source-b", extensions.SourceCategoryOther), + autoInstallTestExtension("demo", "Demo", "source-c", extensions.SourceCategoryOther), + autoInstallTestExtension("demo", "Demo", "source-d", extensions.SourceCategoryOther), + } + requirement := autoInstallTestRequirement(candidates...) + + assert.Equal( + t, + "azd "+output.WithGrayFormat("(+4 more)"), + sourceSummary(requirement, true), + ) + assert.Equal( + t, + "azd, source-a, source-b, source-c, source-d", + sourceSummary(requirement, false), + ) + assert.Equal( + t, + "azd, source-a, source-b", + sourceSummary(autoInstallTestRequirement(candidates[:3]...), true), + ) +} + +func TestCommonRecommendedSourceRequiresSameConfiguredName(t *testing.T) { + t.Parallel() + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo", "azd", extensions.SourceCategoryAzd), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage", "official", extensions.SourceCategoryAzd), + ), + } + + source, ok := commonRecommendedSource(requirements) + + assert.False(t, ok) + assert.Empty(t, source) +} + +func TestInteractiveSingleInstallPlan(t *testing.T) { + t.Parallel() + + azd := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + local := autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal) + + t.Run("single source confirms", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + console.WhenConfirm(func(options input.ConsoleOptions) bool { + return options.Message == "Install Demo Extension?" + }).Respond(true) + + selections, declined, err := interactiveSingleInstallPlan( + t.Context(), + console, + autoInstallTestRequirement(azd), + ) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 1) + assert.Same(t, azd, selections[0].extension) + }) + + t.Run("recommended source selected", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Install Demo Extension from 'azd'" && + options.EnableFiltering != nil && !*options.EnableFiltering + }).Respond(0) + + selections, declined, err := interactiveSingleInstallPlan( + t.Context(), + console, + autoInstallTestRequirement(local, azd), + ) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 1) + assert.Same(t, azd, selections[0].extension) + }) + + t.Run("different source selected", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Install Demo Extension from 'azd'" + }).Respond(1) + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Select a source for Demo Extension" && + options.EnableFiltering != nil && !*options.EnableFiltering && + assert.Equal(t, []string{"azd", "local"}, options.Options) + }).Respond(1) + + selections, declined, err := interactiveSingleInstallPlan( + t.Context(), + console, + autoInstallTestRequirement(local, azd), + ) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 1) + assert.Same(t, local, selections[0].extension) + }) + + t.Run("cancel stops the plan", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Install Demo Extension from 'azd'" + }).Respond(2) + + selections, declined, err := interactiveSingleInstallPlan( + t.Context(), + console, + autoInstallTestRequirement(local, azd), + ) + + require.NoError(t, err) + assert.True(t, declined) + assert.Empty(t, selections) + }) +} + +func TestInteractiveMultipleInstallPlanDifferentSourceShortcut(t *testing.T) { + t.Parallel() + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("storage", "Storage Helper", "local", extensions.SourceCategoryLocal), + ), + autoInstallTestRequirement( + autoInstallTestExtension("monitor", "Monitoring Tools", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("monitor", "Monitoring Tools", "local", extensions.SourceCategoryLocal), + ), + } + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Install all 3 required extensions from 'azd'" && + options.EnableFiltering != nil && !*options.EnableFiltering + }).Respond(1) + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Select a source for Demo Extension" + }).Respond(1) + console.WhenConfirm(func(options input.ConsoleOptions) bool { + return options.Message == "Install remaining extensions from 'local'?" + }).Respond(true) + + selections, declined, err := interactiveMultipleInstallPlan(t.Context(), console, requirements) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 3) + for _, selection := range selections { + assert.Equal(t, "local", selection.extension.Source) + } +} + +func TestInteractiveMultipleInstallPlanFallsBackToIndividualSources(t *testing.T) { + t.Parallel() + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("storage", "Storage Helper", "private", extensions.SourceCategoryOther), + ), + } + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Install all 2 required extensions from 'azd'" + }).Respond(1) + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Select a source for Demo Extension" + }).Respond(1) + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Select a source for Storage Helper" + }).Respond(1) + + selections, declined, err := interactiveMultipleInstallPlan(t.Context(), console, requirements) + + require.NoError(t, err) + require.False(t, declined) + require.Len(t, selections, 2) + assert.Equal(t, "local", selections[0].extension.Source) + assert.Equal(t, "private", selections[1].extension.Source) +} + +func TestAutoInstallExtensionRequirementsDeclined(t *testing.T) { + clearAgentEnvVarsForTest(t) + + extension := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + console := mockinput.NewMockConsole() + console.WhenConfirm(func(options input.ConsoleOptions) bool { + return options.Message == "Install Demo Extension?" + }).Respond(false) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(extension)}, + autoInstallDisplayContext{requiredByProject: true}, + ) + + require.NoError(t, err) + assert.True(t, result.declined) + assert.False(t, result.installed) + assert.Contains(t, strings.Join(console.Output(), "\n"), "Canceled: required extension isn't installed.") + assert.Empty(t, manager.installed) +} + +func TestAutoInstallExtensionRequirementsNoPrompt(t *testing.T) { + clearAgentEnvVarsForTest(t) + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "local", extensions.SourceCategoryLocal), + ), + } + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + requirements, + autoInstallDisplayContext{requiredByProject: true}, + ) + + require.NoError(t, err) + assert.True(t, result.installed) + assert.Contains( + t, + strings.Join(console.Output(), "\n"), + "No-prompt mode: installing required extensions automatically.", + ) + require.Len(t, console.SpinnerOps(), 4) + assert.Equal(t, input.StepDone, console.SpinnerOps()[1].Format) + assert.Equal( + t, + "Installing "+output.WithHighLightFormat("demo")+ + output.WithGrayFormat(" (1.2.3)")+" from azd", + console.SpinnerOps()[1].Message, + ) + assert.Equal(t, input.StepDone, console.SpinnerOps()[3].Format) + assert.Equal( + t, + "Installing "+output.WithHighLightFormat("storage")+ + output.WithGrayFormat(" (1.2.3)")+" from local", + console.SpinnerOps()[3].Message, + ) + require.NotEmpty(t, console.Output()) + assert.Empty(t, console.Output()[len(console.Output())-1]) +} + +func TestAutoInstallExtensionRequirementsInstallFailure(t *testing.T) { + clearAgentEnvVarsForTest(t) + + extension := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{}, + installErr: errors.New("download failed"), + } + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(extension)}, + autoInstallDisplayContext{}, + ) + + require.ErrorContains(t, err, "failed to install extension: download failed") + assert.False(t, result.installed) + require.Len(t, console.SpinnerOps(), 2) + assert.Equal(t, input.StepFailed, console.SpinnerOps()[1].Format) + assert.Equal( + t, + "Installing "+output.WithHighLightFormat("demo"), + console.SpinnerOps()[1].Message, + ) +} + +func TestAutoInstallExtensionRequirementsDisplaysInstalledDependencies(t *testing.T) { + clearAgentEnvVarsForTest(t) + + parent := autoInstallTestExtension("parent", "Parent", "azd", extensions.SourceCategoryAzd) + parent.Versions[0].Dependencies = []extensions.ExtensionDependency{{Id: "child", Version: "2.0.0"}} + child := autoInstallTestExtension("child", "Child", "azd", extensions.SourceCategoryAzd) + child.Versions[0].Version = "2.0.0" + child.Versions[0].Dependencies = []extensions.ExtensionDependency{{Id: "grandchild", Version: "3.0.0"}} + grandchild := autoInstallTestExtension("grandchild", "Grandchild", "azd", extensions.SourceCategoryAzd) + grandchild.Versions[0].Version = "3.0.0" + + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{parent, child, grandchild}, + installed: map[string]*extensions.Extension{}, + } + manager.installFn = func(extension *extensions.ExtensionMetadata) (*extensions.ExtensionVersion, error) { + manager.installed[parent.Id] = &extensions.Extension{ + Id: parent.Id, + Version: parent.Versions[0].Version, + Source: parent.Source, + } + manager.installed[child.Id] = &extensions.Extension{ + Id: child.Id, + Version: child.Versions[0].Version, + Source: child.Source, + } + manager.installed[grandchild.Id] = &extensions.Extension{ + Id: grandchild.Id, + Version: grandchild.Versions[0].Version, + Source: grandchild.Source, + } + return &extension.Versions[0], nil + } + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(parent)}, + autoInstallDisplayContext{requiredByProject: true}, + ) + + require.NoError(t, err) + require.True(t, result.installed) + rendered := strings.Join(console.Output(), "\n") + require.Contains(t, rendered, "Installing child dependency") + require.Contains(t, rendered, "(2.0.0)") + require.Contains(t, rendered, "Installing grandchild dependency") + require.Contains(t, rendered, "(3.0.0)") + require.Less(t, strings.Index(rendered, "child dependency"), strings.Index(rendered, "grandchild dependency")) +} + +func TestAutoInstallExtensionRequirementsNoPromptAmbiguous(t *testing.T) { + clearAgentEnvVarsForTest(t) + + azd := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + local := autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal) + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(azd, local)}, + autoInstallDisplayContext{}, + ) + + assert.False(t, result.installed) + suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok) + assert.Contains(t, suggestionErr.Suggestion, "Choose one source for demo:") + assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source azd") + assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source local") + assert.NotContains(t, suggestionErr.Suggestion, "--version") + assert.Empty(t, manager.installed) +} + +func TestManualInstallErrorIncludesResolvedVersion(t *testing.T) { + t.Parallel() + + candidate := autoInstallTestExtension( + "demo", + "Demo Extension", + "azd", + extensions.SourceCategoryAzd, + ) + candidate.Versions = append(candidate.Versions, extensions.ExtensionVersion{Version: "2.0.0"}) + requirement := autoInstallTestRequirement(candidate) + requirement.versionPreference = ">=1.0.0 <2.0.0" + + err := manualInstallError( + []projectExtensionRequirement{requirement}, + "Manual installation required.", + ) + + suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok) + require.Contains( + t, + suggestionErr.Suggestion, + "azd extension install demo --source azd --version 1.2.3", + ) +} + +func TestAutoInstallExtensionRequirementsHonorsSelectedDependencySource(t *testing.T) { + clearAgentEnvVarsForTest(t) + parent := autoInstallTestExtension("parent", "Parent", "azd", extensions.SourceCategoryAzd) + parent.Versions[0].Dependencies = []extensions.ExtensionDependency{{Id: "child"}} + child := autoInstallTestExtension("child", "Child", "local", extensions.SourceCategoryLocal) + + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + var installOrder []string + manager.installFn = func(extension *extensions.ExtensionMetadata) (*extensions.ExtensionVersion, error) { + installOrder = append(installOrder, extension.Id+"@"+extension.Source) + version := &extension.Versions[0] + manager.installed[extension.Id] = &extensions.Extension{ + Id: extension.Id, + Version: version.Version, + Source: extension.Source, + } + return version, nil + } + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{ + autoInstallTestRequirement(parent), + autoInstallTestRequirement(child), + }, + autoInstallDisplayContext{requiredByProject: true}, + ) + + require.NoError(t, err) + require.True(t, result.installed) + require.Equal(t, []string{"child@local", "parent@azd"}, installOrder) +} + +func TestAutoInstallExtensionRequirementsOmitsSourceWhenSelectionUsesOneSource(t *testing.T) { + clearAgentEnvVarsForTest(t) + + azd := autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd) + local := autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal) + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + return options.Message == "Install Demo Extension from 'azd'" + }).Respond(0) + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + result, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + []projectExtensionRequirement{autoInstallTestRequirement(azd, local)}, + autoInstallDisplayContext{}, + ) + + require.NoError(t, err) + assert.True(t, result.installed) + require.Len(t, console.SpinnerOps(), 2) + assert.Equal( + t, + "Installing "+output.WithHighLightFormat("demo")+ + output.WithGrayFormat(" (1.2.3)"), + console.SpinnerOps()[1].Message, + ) +} + +func TestInstallSelectionsUseMultipleSources(t *testing.T) { + t.Parallel() + + selection := func(id string, source string) extensionInstallSelection { + return extensionInstallSelection{ + extension: autoInstallTestExtension(id, id, source, extensions.SourceCategoryOther), + } + } + + tests := []struct { + name string + selections []extensionInstallSelection + expected bool + }{ + {name: "empty"}, + { + name: "single source", + selections: []extensionInstallSelection{selection("one", "azd"), selection("two", "azd")}, + }, + { + name: "source names are case insensitive", + selections: []extensionInstallSelection{selection("one", "azd"), selection("two", "AZD")}, + }, + { + name: "multiple sources", + selections: []extensionInstallSelection{selection("one", "azd"), selection("two", "local")}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tt.expected, installSelectionsUseMultipleSources(tt.selections)) + }) + } +} + +func TestDisplayExtensionRequirements(t *testing.T) { + t.Parallel() + + t.Run("single project requirement", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + displayExtensionRequirements( + t.Context(), + console, + []projectExtensionRequirement{autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + )}, + autoInstallDisplayContext{requiredByProject: true}, + ) + + output := strings.Join(console.Output(), "\n") + require.NotEmpty(t, console.Output()) + assert.NotEmpty(t, console.Output()[0]) + assert.Contains(t, output, "Extension required by azure.yaml: Demo Extension") + assert.Contains(t, output, "ID:") + assert.Contains(t, output, "Source:") + assert.NotContains(t, output, "\nRequired by azure.yaml.") + assert.Empty(t, console.Output()[len(console.Output())-1]) + }) + + t.Run("multiple requirements use sources heading", func(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + displayExtensionRequirements( + t.Context(), + console, + []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + autoInstallTestExtension("demo", "Demo Extension", "local", extensions.SourceCategoryLocal), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "azd", extensions.SourceCategoryAzd), + ), + }, + autoInstallDisplayContext{requiredByProject: true}, + ) + + output := strings.Join(console.Output(), "\n") + require.GreaterOrEqual(t, len(console.Output()), 3) + assert.NotEmpty(t, console.Output()[0]) + assert.Empty(t, console.Output()[1]) + assert.Contains(t, output, "2 extensions required by azure.yaml:") + assert.Contains(t, output, "Extension") + assert.Contains(t, output, "ID") + assert.Contains(t, output, "Sources") + assert.Contains(t, output, "Demo Extension") + assert.Contains(t, output, "azd, local") + assert.Empty(t, console.Output()[len(console.Output())-1]) + }) +} + +func TestAutoInstallExtensionRequirementsCIListsAllRequirements(t *testing.T) { + clearAgentEnvVarsForTest(t) + t.Setenv("CI", "true") + + requirements := []projectExtensionRequirement{ + autoInstallTestRequirement( + autoInstallTestExtension("demo", "Demo Extension", "azd", extensions.SourceCategoryAzd), + ), + autoInstallTestRequirement( + autoInstallTestExtension("storage", "Storage Helper", "local", extensions.SourceCategoryLocal), + ), + } + console := mockinput.NewMockConsole() + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + + _, err := autoInstallExtensionRequirements( + t.Context(), + console, + manager, + requirements, + autoInstallDisplayContext{requiredByProject: true}, + ) + + suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok) + assert.Equal(t, "Auto-installation is not supported in CI/CD environments.", suggestionErr.Message) + assert.Contains(t, suggestionErr.Suggestion, "azd extension install demo --source azd") + assert.Contains(t, suggestionErr.Suggestion, "azd extension install storage --source local") + assert.NotContains(t, suggestionErr.Suggestion, "--version") + assert.Empty(t, manager.installed) +} diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index a8a57708612..fb90d1fb80b 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -933,7 +933,7 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult a.console.Message(ctx, "") } - stepMessage := fmt.Sprintf("Installing %s extension", output.WithHighLightFormat(extensionId)) + stepMessage := extensionTaskMessage("Installing", extensionId) a.console.ShowSpinner(ctx, stepMessage, input.Step) // Check if extension is already installed @@ -2023,7 +2023,7 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu } for _, extensionId := range extensionIds { - stepMessage := fmt.Sprintf("Uninstalling %s extension", output.WithHighLightFormat(extensionId)) + stepMessage := extensionTaskMessage("Uninstalling", extensionId) installed, err := a.extensionManager.GetInstalled(extensions.FilterOptions{ Id: extensionId, @@ -2035,7 +2035,7 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu return nil, fmt.Errorf("failed to get installed extension: %w", err) } - stepMessage += fmt.Sprintf(" (%s)", installed.Version) + stepMessage = extensionTaskMessageWithVersion("Uninstalling", extensionId, installed.Version) a.console.ShowSpinner(ctx, stepMessage, input.Step) if err := a.extensionManager.Uninstall(ctx, extensionId); err != nil { @@ -2358,10 +2358,7 @@ func (a *extensionUpgradeAction) upgradeOneExtension( a.console.Message(ctx, "") } - stepMsg := fmt.Sprintf( - "Updating %s extension", - output.WithHighLightFormat(extensionId), - ) + stepMsg := extensionTaskMessage("Updating", extensionId) if !isJsonOutput { a.console.ShowSpinner(ctx, stepMsg, input.Step) } @@ -2418,9 +2415,9 @@ func (a *extensionUpgradeAction) upgradeOneExtension( baseResult.SkipReason = "installed from a self-contained bundle; " + "reinstall with a newer bundle to update" if !isJsonOutput { - skipMsg := fmt.Sprintf( - "Updating %s extension", - output.WithHighLightFormat(extensionId), + skipMsg := extensionTaskMessage( + "Updating", + extensionId, ) + output.WithGrayFormat( " (Installed from a bundle)", ) @@ -2520,9 +2517,9 @@ func (a *extensionUpgradeAction) upgradeOneExtension( "in any configured registry" } if !isJsonOutput { - skipMsg := fmt.Sprintf( - "Updating %s extension", - output.WithHighLightFormat(extensionId), + skipMsg := extensionTaskMessage( + "Updating", + extensionId, ) + output.WithGrayFormat( " (No longer available in any registry)", ) @@ -2716,13 +2713,12 @@ func (a *extensionUpgradeAction) upgradeOneExtension( } if !isJsonOutput { - doneMsg := fmt.Sprintf( - "Updated %s extension %s", - output.WithHighLightFormat(extensionId), - output.WithGrayFormat( - "(%s \u2192 %s)", - installed.Version, extVersion.Version, - ), + doneMsg := extensionTaskMessage( + "Updated", + extensionId, + ) + " " + output.WithGrayFormat( + "(%s \u2192 %s)", + installed.Version, extVersion.Version, ) a.console.StopSpinner(ctx, doneMsg, input.StepDone) displayDependencyUpgradeResults(ctx, a.console, baseResult.DependencyUpgrades, " ") @@ -2747,7 +2743,7 @@ func (a *extensionUpgradeAction) displayPromotionWarning( ) { a.console.StopSpinner(ctx, stepMsg, input.StepWarning) a.console.Message(ctx, output.WithWarningFormat( - " (!) Warning: Updated %s extension (%s \u2192 %s, %s \u2192 %s registry)", + " (!) Warning: Updated %s (%s \u2192 %s, %s \u2192 %s registry)", output.WithHighLightFormat(extensionId), fromVersion, toVersion, output.WithHighLightFormat(oldSource), @@ -3249,10 +3245,18 @@ func displayExtensionUsageAndExamples( // displayInstalledDependencies renders newly installed and skipped dependencies // as flat rows aligned with the parent step. +type installedDependencyManager interface { + GetInstalled(options extensions.FilterOptions) (*extensions.Extension, error) + FindExtensions( + ctx context.Context, + options *extensions.FilterOptions, + ) ([]*extensions.ExtensionMetadata, error) +} + func displayInstalledDependencies( ctx context.Context, console input.Console, - manager *extensions.Manager, + manager installedDependencyManager, deps []extensions.ExtensionDependency, preInstalledIds map[string]struct{}, indent string, diff --git a/cli/azd/cmd/extension_task.go b/cli/azd/cmd/extension_task.go new file mode 100644 index 00000000000..fe936deeb61 --- /dev/null +++ b/cli/azd/cmd/extension_task.go @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +func extensionTaskMessage(action string, extensionId string) string { + return fmt.Sprintf("%s %s", action, output.WithHighLightFormat(extensionId)) +} + +func extensionTaskMessageWithVersion(action string, extensionId string, version string) string { + return extensionTaskMessage(action, extensionId) + output.WithGrayFormat(" (%s)", version) +} diff --git a/cli/azd/cmd/extension_task_test.go b/cli/azd/cmd/extension_task_test.go new file mode 100644 index 00000000000..7fae9f20745 --- /dev/null +++ b/cli/azd/cmd/extension_task_test.go @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/stretchr/testify/require" +) + +func TestExtensionTaskMessage(t *testing.T) { + t.Parallel() + + require.Equal( + t, + "Installing "+output.WithHighLightFormat("azure.ai.rle"), + extensionTaskMessage("Installing", "azure.ai.rle"), + ) + require.Equal( + t, + "Uninstalling "+output.WithHighLightFormat("azure.ai.rle")+ + output.WithGrayFormat(" (1.2.3)"), + extensionTaskMessageWithVersion("Uninstalling", "azure.ai.rle", "1.2.3"), + ) +} diff --git a/cli/azd/cmd/extension_upgrade_test.go b/cli/azd/cmd/extension_upgrade_test.go index c54b1be2d28..0e61b7f3314 100644 --- a/cli/azd/cmd/extension_upgrade_test.go +++ b/cli/azd/cmd/extension_upgrade_test.go @@ -779,7 +779,7 @@ func TestDisplayPromotionWarning(t *testing.T) { action := &extensionUpgradeAction{console: console} action.displayPromotionWarning( t.Context(), - "Updating test.extension extension", + "Updating test.extension", "test.extension", "1.0.0", "1.1.0", @@ -790,7 +790,7 @@ func TestDisplayPromotionWarning(t *testing.T) { require.Len(t, console.SpinnerOps(), 1) require.Equal(t, input.StepWarning, console.SpinnerOps()[0].Format) rendered := strings.Join(console.Output(), "\n") - require.Contains(t, rendered, "Updated test.extension extension") + require.Contains(t, rendered, "Updated test.extension") require.Contains(t, rendered, "1.0.0") require.Contains(t, rendered, "1.1.0") require.Contains(t, rendered, "promoted from the dev registry") diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index adace1e28f4..e828a0ac3fa 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -1130,7 +1130,7 @@ func (i *initAction) initializeExtensions(ctx context.Context, azdCtx *azdcontex i.console.Message(ctx, "\nInstalling required extensions...") for extensionId, versionConstraint := range projectConfig.RequiredVersions.Extensions { - stepMessage := fmt.Sprintf("Installing %s extension", output.WithHighLightFormat(extensionId)) + stepMessage := extensionTaskMessage("Installing", extensionId) i.console.ShowSpinner(ctx, stepMessage, input.Step) installed, isInstalled := installedExtensions[extensionId] diff --git a/cli/azd/cmd/project_extension_auto_install.go b/cli/azd/cmd/project_extension_auto_install.go index 332bde450bb..bba9764df03 100644 --- a/cli/azd/cmd/project_extension_auto_install.go +++ b/cli/azd/cmd/project_extension_auto_install.go @@ -26,6 +26,7 @@ import ( type projectExtensionRequirement struct { extension *extensions.ExtensionMetadata + candidates []*extensions.ExtensionMetadata versionPreference string explicit bool } @@ -154,7 +155,7 @@ func findExtensionForProvider( lookup providerLookup, capability extensions.CapabilityType, provider string, -) (*extensions.ExtensionMetadata, error) { +) ([]*extensions.ExtensionMetadata, error) { matches, err := extensionManager.FindExtensions(ctx, &extensions.FilterOptions{ Capability: capability, Provider: provider, @@ -168,7 +169,7 @@ func findExtensionForProvider( return nil, candidates.conflictError() } - return promptForExtensionChoice(ctx, console, candidates.installable) + return chooseLogicalExtensionCandidates(ctx, console, candidates.installable) } func uninstalledExtensionMatches( @@ -245,34 +246,81 @@ func resolveExtensionRequirementDependencies( requirements map[string]projectExtensionRequirement, ) map[string]resolvedExtensionDependency { resolved := map[string]resolvedExtensionDependency{} - resolving := map[extensionRef]struct{}{} for _, requirement := range sortedProjectExtensionRequirements(requirements) { - version, err := extensions.ResolveExtensionVersion( - requirement.extension, - requirement.versionPreference, - nil, - ) - if err != nil { - continue - } + var common map[string]resolvedExtensionDependency + for _, candidate := range requirementCandidates(requirement) { + version, err := extensions.ResolveExtensionVersion( + candidate, + requirement.versionPreference, + nil, + ) + if err != nil { + common = map[string]resolvedExtensionDependency{} + break + } - key := newExtensionRef(requirement.extension.Source, requirement.extension.Id) - resolving[key] = struct{}{} - resolveExtensionDependencies( - ctx, - extensionManager, - requirement.extension, - version.Dependencies, - resolved, - resolving, - ) - delete(resolving, key) + candidateResolved := map[string]resolvedExtensionDependency{} + key := newExtensionRef(candidate.Source, candidate.Id) + resolveExtensionDependencies( + ctx, + extensionManager, + candidate, + version.Dependencies, + candidateResolved, + map[extensionRef]struct{}{key: {}}, + ) + if common == nil { + common = candidateResolved + } else { + common = intersectResolvedDependencies(common, candidateResolved) + } + } + for id, dependency := range common { + if _, exists := resolved[id]; !exists { + resolved[id] = dependency + } + } } return resolved } +func intersectResolvedDependencies( + left map[string]resolvedExtensionDependency, + right map[string]resolvedExtensionDependency, +) map[string]resolvedExtensionDependency { + intersection := map[string]resolvedExtensionDependency{} + for id, leftDependency := range left { + rightDependency, exists := right[id] + if !exists { + continue + } + + intersection[id] = resolvedExtensionDependency{ + capabilities: slices.DeleteFunc( + slices.Clone(leftDependency.capabilities), + func(capability extensions.CapabilityType) bool { + return !slices.Contains(rightDependency.capabilities, capability) + }, + ), + providers: slices.DeleteFunc( + slices.Clone(leftDependency.providers), + func(provider extensions.Provider) bool { + return !slices.ContainsFunc( + rightDependency.providers, + func(candidate extensions.Provider) bool { + return candidate.Type == provider.Type && + strings.EqualFold(candidate.Name, provider.Name) + }, + ) + }, + ), + } + } + return intersection +} + func resolveExtensionDependencies( ctx context.Context, extensionManager extensionAutoInstallManager, @@ -437,6 +485,40 @@ func resolvedDependencyProvidesProvider( ) } +func extensionCandidateProvidesProvider( + ctx context.Context, + extensionManager extensionAutoInstallManager, + candidate *extensions.ExtensionMetadata, + versionPreference string, + capability extensions.CapabilityType, + provider string, +) (bool, error) { + version, err := extensions.ResolveExtensionVersion(candidate, versionPreference, nil) + if err != nil { + return false, err + } + if extensionVersionProvidesProvider(version, capability, provider) { + return true, nil + } + + resolved := map[string]resolvedExtensionDependency{} + key := newExtensionRef(candidate.Source, candidate.Id) + resolveExtensionDependencies( + ctx, + extensionManager, + candidate, + version.Dependencies, + resolved, + map[extensionRef]struct{}{key: {}}, + ) + for dependency := range maps.Values(resolved) { + if resolvedDependencyProvidesProvider(dependency, capability, provider) { + return true, nil + } + } + return false, nil +} + // extensionForProvider narrows an extension to every version supplying the provider. Unlike // filterExtensionsForProvider this ignores which version would be selected, so callers can tell an // extension that cannot supply the provider from one whose selected version happens not to. @@ -496,13 +578,15 @@ func missingProjectExtensions( } } - extension, err := promptForExtensionChoice(ctx, console, matches) + candidates, err := chooseLogicalExtensionCandidates(ctx, console, matches) if err != nil { return nil, fmt.Errorf("selecting required extension %s: %w", extensionId, err) } + extension := candidates[0] requirements[extension.Id] = projectExtensionRequirement{ extension: extension, + candidates: candidates, versionPreference: versionPreference, explicit: true, } @@ -518,6 +602,39 @@ func missingProjectExtensions( requirementConflicts := map[string]error{} for _, extensionId := range slices.Sorted(maps.Keys(requirements)) { requirement := requirements[extensionId] + var providingCandidates []*extensions.ExtensionMetadata + for _, candidate := range requirementCandidates(requirement) { + provides, err := extensionCandidateProvidesProvider( + ctx, + extensionManager, + candidate, + requirement.versionPreference, + capability, + provider, + ) + if err != nil { + return fmt.Errorf("resolving required extension %s: %w", extensionId, err) + } + if provides { + providingCandidates = append(providingCandidates, candidate) + } + } + if len(providingCandidates) > 0 { + requirement.candidates = providingCandidates + requirement.extension = providingCandidates[0] + requirements[extensionId] = requirement + return nil + } + + hasProviderVersion := slices.ContainsFunc( + requirementCandidates(requirement), + func(candidate *extensions.ExtensionMetadata) bool { + return len(extensionForProvider(candidate, capability, provider).Versions) > 0 + }, + ) + if !hasProviderVersion { + continue + } selectedVersion, err := extensions.ResolveExtensionVersion( requirement.extension, requirement.versionPreference, @@ -526,13 +643,6 @@ func missingProjectExtensions( if err != nil { return fmt.Errorf("resolving required extension %s: %w", extensionId, err) } - if extensionVersionProvidesProvider(selectedVersion, capability, provider) { - return nil - } - - if len(extensionForProvider(requirement.extension, capability, provider).Versions) == 0 { - continue - } requirementConflicts[strings.ToLower(extensionId)] = fmt.Errorf( "required extension %s version %s does not provide %s %q", extensionId, @@ -549,7 +659,7 @@ func missingProjectExtensions( } } - extension, err := findExtensionForProvider( + candidates, err := findExtensionForProvider( ctx, console, extensionManager, @@ -561,12 +671,20 @@ func missingProjectExtensions( capability, provider, ) - if err != nil || extension == nil { + if err != nil || len(candidates) == 0 { return err } + extension := candidates[0] if requirement, alreadyRequired := requirements[extension.Id]; alreadyRequired { - requirement.extension = extensionForProvider(requirement.extension, capability, provider) - if len(requirement.extension.Versions) == 0 { + requirement.candidates = slices.DeleteFunc( + requirementCandidates(requirement), + func(candidate *extensions.ExtensionMetadata) bool { + return !slices.ContainsFunc(candidates, func(match *extensions.ExtensionMetadata) bool { + return strings.EqualFold(candidate.Source, match.Source) + }) + }, + ) + if len(requirement.candidates) == 0 { return fmt.Errorf( "required extension %s does not provide %s %q", extension.Id, @@ -574,10 +692,12 @@ func missingProjectExtensions( provider, ) } + requirement.extension = requirement.candidates[0] requirements[extension.Id] = requirement } else { requirements[extension.Id] = projectExtensionRequirement{ - extension: extension, + extension: extension, + candidates: candidates, } } return nil @@ -626,6 +746,8 @@ type projectExtensionResult struct { handled bool // installed reports that an extension was installed, so the command tree is out of date. installed bool + // declined reports that the user intentionally stopped before installation. + declined bool } func tryAutoInstallProjectExtensions( @@ -665,22 +787,22 @@ func tryAutoInstallProjectExtensions( return projectExtensionResult{}, nil } - installedAny := false - for _, requirement := range requirements { - installed, err := tryAutoInstallExtensionVersion( - ctx, - console, - extensionManager, - *requirement.extension, - requirement.versionPreference, - ) - if err != nil { - return projectExtensionResult{handled: true, installed: installedAny}, err - } - installedAny = installedAny || installed + result, err := autoInstallExtensionRequirements( + ctx, + console, + extensionManager, + requirements, + autoInstallDisplayContext{requiredByProject: true}, + ) + if err != nil { + return projectExtensionResult{handled: true, installed: result.installed}, err } - return projectExtensionResult{handled: true, installed: installedAny}, nil + return projectExtensionResult{ + handled: true, + installed: result.installed, + declined: result.declined, + }, nil } func displayAutoInstallError(ctx context.Context, console input.Console, err error) { diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index 65a1e6292cd..b8585961e4b 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -291,7 +291,15 @@ Resolution during project commands differs from `azd init` in two ways: - It **does** check installed extensions against the configured constraint, and fails with the conflicting constraint rather than proceeding with an unsatisfying version. - It resolves not only `requiredVersions.extensions` but also the providers the project implies (see below). -Resolution only prompts for extensions that are genuinely missing, and it is skipped when the command renders help instead of running, such as `azd up --help`. Each install is confirmed before it happens; `--no-prompt` accepts that confirmation, matching how the rest of `azd` treats declared configuration in scripts and CI. +Resolution only prompts for extensions that are genuinely missing, and it is skipped when the command renders help instead of running, such as `azd up --help`. + +Before installing anything, `azd` displays the complete set of missing extensions and their available configured sources. A single missing extension is shown with its ID, source or sources, and description. Multiple missing extensions are summarized in a table and confirmed together. + +When the official azd registry is one of several sources, `azd` recommends it but allows another configured source to be selected. For multiple extensions, a source selected for the first extension can be reused for the remaining extensions only when that source publishes every remaining requirement. Otherwise, azd prompts for each ambiguous source separately. Declining installation prints `Canceled: required extension isn't installed.`, stops the requested command before it runs, and exits successfully. + +With an explicit local `--no-prompt`, azd installs automatically only when every missing extension has one eligible source. The sources do not need to be the same. If any extension is available from several sources, azd stops and prints the exact `azd extension install` commands that can resolve the ambiguity. + +Auto-install remains disabled in detected CI/CD environments, including when CI detection enables no-prompt mode automatically. The error lists manual install commands for every missing extension so the pipeline can install them explicitly before rerunning the project command. ### Inferred extension requirements diff --git a/cli/azd/pkg/input/console.go b/cli/azd/pkg/input/console.go index 6164135a019..656eab7559e 100644 --- a/cli/azd/pkg/input/console.go +++ b/cli/azd/pkg/input/console.go @@ -195,6 +195,8 @@ type ConsoleOptions struct { // OptionDetails is an optional field that can be used to provide additional information about the options. OptionDetails []string DefaultValue any + // EnableFiltering controls whether select prompts allow filtering. Nil uses the component default. + EnableFiltering *bool // Prompt-only options IsPassword bool diff --git a/cli/azd/pkg/input/console_ux.go b/cli/azd/pkg/input/console_ux.go index 917e4dc9d60..60a82e125c1 100644 --- a/cli/azd/pkg/input/console_ux.go +++ b/cli/azd/pkg/input/console_ux.go @@ -115,11 +115,12 @@ func newSelectOptions(writer io.Writer, options ConsoleOptions) *uxlib.SelectOpt choices, selectedIndex := selectChoices(options) return &uxlib.SelectOptions{ - Writer: writer, - Message: options.Message, - HelpMessage: options.Help, - Choices: choices, - SelectedIndex: new(selectedIndex), + Writer: writer, + Message: options.Message, + HelpMessage: options.Help, + Choices: choices, + SelectedIndex: new(selectedIndex), + EnableFiltering: options.EnableFiltering, } } diff --git a/cli/azd/pkg/input/console_ux_test.go b/cli/azd/pkg/input/console_ux_test.go index 48b9f3b7b3a..7c644a51227 100644 --- a/cli/azd/pkg/input/console_ux_test.go +++ b/cli/azd/pkg/input/console_ux_test.go @@ -238,9 +238,10 @@ func TestNewPromptOptions(t *testing.T) { func TestNewSelectOptions(t *testing.T) { buf := &bytes.Buffer{} opts := newSelectOptions(buf, ConsoleOptions{ - Message: "message", - Options: []string{"alpha", "beta", "gamma"}, - DefaultValue: "gamma", + Message: "message", + Options: []string{"alpha", "beta", "gamma"}, + DefaultValue: "gamma", + EnableFiltering: new(false), }) require.Equal(t, buf, opts.Writer) @@ -248,6 +249,8 @@ func TestNewSelectOptions(t *testing.T) { require.Len(t, opts.Choices, 3) require.NotNil(t, opts.SelectedIndex) require.Equal(t, 2, *opts.SelectedIndex) + require.NotNil(t, opts.EnableFiltering) + require.False(t, *opts.EnableFiltering) } func TestNewConfirmOptions(t *testing.T) { diff --git a/cli/azd/pkg/ux/select.go b/cli/azd/pkg/ux/select.go index 00f467a00c5..a9d479fecb2 100644 --- a/cli/azd/pkg/ux/select.go +++ b/cli/azd/pkg/ux/select.go @@ -352,9 +352,12 @@ func (p *Select) renderMessage(printer Printer) { printer.Fprintln() + if !p.cancelled && !p.complete { + printer.Fprintln() + } + // Filter if !p.cancelled && !p.complete && *p.options.EnableFiltering { - printer.Fprintln() printer.Fprintf(" Filter: ") if p.filter == "" { diff --git a/cli/azd/pkg/ux/select_test.go b/cli/azd/pkg/ux/select_test.go index ea4df2af296..537430c002c 100644 --- a/cli/azd/pkg/ux/select_test.go +++ b/cli/azd/pkg/ux/select_test.go @@ -7,6 +7,7 @@ import ( "bytes" "fmt" "io" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -87,6 +88,27 @@ func TestSelect_Render_initial(t *testing.T) { assert.Contains(t, output, "Charlie") } +func TestSelect_Render_initial_withoutFilteringSeparatesPromptAndChoices(t *testing.T) { + var buf bytes.Buffer + printer := NewPrinter(&buf) + + s := NewSelect(&SelectOptions{ + Writer: io.Discard, + Message: "Choose", + Choices: []*SelectChoice{{Value: "a", Label: "Alpha"}}, + EnableFiltering: new(false), + }) + + require.NoError(t, s.Render(printer)) + + output := buf.String() + promptEnd := strings.Index(output, "\n") + require.GreaterOrEqual(t, promptEnd, 0) + assert.True(t, strings.HasPrefix(output[promptEnd:], "\n\n"), output) + assert.Contains(t, output, "Alpha") + assert.NotContains(t, output, "Filter:") +} + func TestSelect_Render_complete(t *testing.T) { var buf bytes.Buffer printer := NewPrinter(&buf)