diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 7d00b672f28..e457777abfd 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -960,6 +960,9 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult extensionMatches, err := a.extensionManager.FindExtensions(ctx, filterOptions) if err != nil { a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + if _, ok := errors.AsType[*extensions.ExtensionVersionNotFoundError](err); ok { + return nil, wrapDependencyError(err) + } return nil, fmt.Errorf("failed to find extension: %w", err) } @@ -1240,18 +1243,18 @@ func (a *extensionInstallAction) confirmReplace( return true, nil } -// wrapDependencyError augments dependency resolution failures with actionable -// guidance. Other errors pass through unchanged. +// wrapDependencyError augments extension resolution failures with actionable +// guidance when available. Other errors pass through unchanged. func wrapDependencyError(err error) error { - type dependencyErrorWithSuggestion interface { + type errorWithSuggestion interface { error Suggestion() string } - if depErr, ok := errors.AsType[dependencyErrorWithSuggestion](err); ok { + if suggestionErr, ok := errors.AsType[errorWithSuggestion](err); ok { return &internal.ErrorWithSuggestion{ - Err: depErr, - Suggestion: depErr.Suggestion(), + Err: suggestionErr, + Suggestion: suggestionErr.Suggestion(), } } @@ -2433,39 +2436,14 @@ func (a *extensionUpgradeAction) upgradeOneExtension( allMatchOptions.Source = a.flags.source } - versionMismatchError := func() error { - if a.flags.version == "" || strings.EqualFold(a.flags.version, "latest") { - return nil - } - - unversionedOptions := *allMatchOptions - unversionedOptions.Version = "" - unversionedMatches, err := a.extensionManager.FindExtensions( - ctx, &unversionedOptions, - ) - if err != nil { - if isNetworkError(err) { - return fmt.Errorf( - "network error looking up extension %s "+ - "(check your connection and retry): %w", - extensionId, err, - ) - } - return fmt.Errorf( - "failed to find extension %s: %w", extensionId, err, - ) - } - + versionMismatchError := func(unversionedMatches []*extensions.ExtensionMetadata) error { res := extensions.ResolveUpgradeSource( installed, unversionedMatches, a.flags.source, ) if res == nil { - if len(unversionedMatches) > 0 { - return upgradeSourceResolutionError( - extensionId, a.flags.source, installed.Source, - ) - } - return nil + return upgradeSourceResolutionError( + extensionId, a.flags.source, installed.Source, + ) } return upgradeVersionResolutionError( extensionId, a.flags.version, res.NewSource, @@ -2476,6 +2454,9 @@ func (a *extensionUpgradeAction) upgradeOneExtension( ctx, allMatchOptions, ) if err != nil { + if versionErr, ok := errors.AsType[*extensions.ExtensionVersionNotFoundError](err); ok { + return fail(versionMismatchError(versionErr.Matches)) + } if isNetworkError(err) { return fail(fmt.Errorf( "network error looking up extension %s "+ @@ -2488,10 +2469,6 @@ func (a *extensionUpgradeAction) upgradeOneExtension( )) } if len(matches) == 0 { - if err := versionMismatchError(); err != nil { - return fail(err) - } - // Explicit --source miss: neither the requested version nor any other // version of the extension exists in that source. if a.flags.source != "" && !a.flags.all { @@ -2536,9 +2513,6 @@ func (a *extensionUpgradeAction) upgradeOneExtension( installed, matches, a.flags.source, ) if res == nil { - if err := versionMismatchError(); err != nil { - return fail(err) - } return fail(upgradeSourceResolutionError( extensionId, a.flags.source, installed.Source, )) diff --git a/cli/azd/cmd/extension_install_source_test.go b/cli/azd/cmd/extension_install_source_test.go index c876c3d1065..6a061d630a1 100644 --- a/cli/azd/cmd/extension_install_source_test.go +++ b/cli/azd/cmd/extension_install_source_test.go @@ -397,6 +397,37 @@ func TestResolveSourceLocation_NoPromptFileDirectsToSourceAdd(t *testing.T) { } } +func TestExtensionInstall_MissingVersionReportsLatest(t *testing.T) { + t.Parallel() + + action, _ := newBundleInstallTestAction(t) + registryPath := writeRegistryFile(t) + require.NoError(t, action.sourceManager.Add(t.Context(), "local-dev", &extensions.SourceConfig{ + Name: "local-dev", + Type: extensions.SourceKindFile, + Location: registryPath, + })) + action.args = []string{"test.ext"} + action.flags.source = "local-dev" + action.flags.version = "0.1.0" + + _, err := action.Run(t.Context()) + require.Error(t, err) + require.Contains( + t, + err.Error(), + "extension 'test.ext' version '0.1.0' was not found; latest version is '1.0.0'", + ) + + var errWithSuggestion *internal.ErrorWithSuggestion + require.ErrorAs(t, err, &errWithSuggestion) + require.Contains( + t, + errWithSuggestion.Suggestion, + "azd extension install test.ext --version 1.0.0 --source local-dev", + ) +} + func newInstallSourceTestAction(t *testing.T) (*extensionInstallAction, *mocks.MockContext) { t.Helper() diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 7d69a33c011..ac798d1212a 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -97,6 +97,80 @@ func (e *DependencyVersionNotFoundError) Suggestion() string { ) } +// ExtensionVersionNotFoundError indicates an extension exists but does not have +// a version matching the requested version filter. +type ExtensionVersionNotFoundError struct { + // ExtensionId is the id of the extension without a matching version. + ExtensionId string + // Version is the requested version or constraint that could not be matched. + Version string + // Source is the requested source, if one was provided. + Source string + // Matches contains the extension metadata found without the version filter. + Matches []*ExtensionMetadata +} + +func (e *ExtensionVersionNotFoundError) Error() string { + latestVersions := e.latestVersions() + if len(latestVersions) == 1 { + return fmt.Sprintf( + "extension '%s' version '%s' was not found; latest version is '%s'", + e.ExtensionId, e.Version, latestVersions[0].Version, + ) + } + + displayVersions := make([]string, 0, len(latestVersions)) + for _, latest := range latestVersions { + displayVersions = append(displayVersions, fmt.Sprintf("%s: %s", latest.Source, latest.Version)) + } + + return fmt.Sprintf( + "extension '%s' version '%s' was not found; latest versions are %s", + e.ExtensionId, e.Version, strings.Join(displayVersions, ", "), + ) +} + +// Suggestion returns actionable guidance for installing an available version. +func (e *ExtensionVersionNotFoundError) Suggestion() string { + latestVersions := e.latestVersions() + if len(latestVersions) == 1 { + latest := latestVersions[0] + command := fmt.Sprintf("azd extension install %s --version %s", e.ExtensionId, latest.Version) + source := e.Source + if source == "" && !strings.EqualFold(latest.Source, MainRegistryName) { + source = latest.Source + } + if source != "" { + command += fmt.Sprintf(" --source %s", source) + } + + return fmt.Sprintf("Run '%s' to install the latest version.", command) + } + + return "Specify the extension source using the --source flag, or choose an available version." +} + +type extensionLatestVersion struct { + Source string + Version string +} + +func (e *ExtensionVersionNotFoundError) latestVersions() []extensionLatestVersion { + latestVersions := make([]extensionLatestVersion, 0, len(e.Matches)) + for _, match := range e.Matches { + latestVersion := LatestVersion(match.Versions) + if latestVersion == nil { + continue + } + latestVersions = append(latestVersions, extensionLatestVersion{ + Source: match.Source, + Version: latestVersion.Version, + }) + } + + return latestVersions +} + // DependencyAzdVersionIncompatibleError indicates that dependency versions // satisfy the parent's constraint, but none support the running azd version. type DependencyAzdVersionIncompatibleError struct { @@ -329,7 +403,7 @@ func createExtensionFilter(options *FilterOptions) extensionFilterPredicate { } // Check Version filter - extension must have at least one matching version. - if options.Version != "" && options.Version != "latest" { + if hasExplicitVersionFilter(options) { hasVersion := slices.ContainsFunc(extension.Versions, func(version ExtensionVersion) bool { return matchesVersionConstraint(options.Version, version.Version) }) @@ -388,6 +462,10 @@ func createExtensionFilter(options *FilterOptions) extensionFilterPredicate { } } +func hasExplicitVersionFilter(options *FilterOptions) bool { + return options.Version != "" && !strings.EqualFold(options.Version, "latest") +} + // Manager is responsible for managing extensions type Manager struct { sourceManager *SourceManager @@ -505,6 +583,7 @@ func (m *Manager) UpdateInstalled(extension *Extension) error { func (m *Manager) FindExtensions(ctx context.Context, options *FilterOptions) ([]*ExtensionMetadata, error) { allExtensions := []*ExtensionMetadata{} + versionMismatches := []*ExtensionMetadata{} if options == nil { options = &FilterOptions{} @@ -526,6 +605,12 @@ func (m *Manager) FindExtensions(ctx context.Context, options *FilterOptions) ([ // Use the centralized extension filter extensionFilter := createExtensionFilter(filterOptions) + var versionlessFilter extensionFilterPredicate + if filterOptions.Id != "" && hasExplicitVersionFilter(filterOptions) { + versionlessOptions := *filterOptions + versionlessOptions.Version = "" + versionlessFilter = createExtensionFilter(&versionlessOptions) + } var sources []Source var err error @@ -555,6 +640,8 @@ func (m *Manager) FindExtensions(ctx context.Context, options *FilterOptions) ([ for _, extension := range sourceExtensions { if extensionFilter(extension) { filteredExtensions = append(filteredExtensions, extension) + } else if versionlessFilter != nil && versionlessFilter(extension) { + versionMismatches = append(versionMismatches, extension) } } @@ -570,6 +657,15 @@ func (m *Manager) FindExtensions(ctx context.Context, options *FilterOptions) ([ allExtensions = append(allExtensions, filteredExtensions...) } + if len(allExtensions) == 0 && len(versionMismatches) > 0 { + return nil, &ExtensionVersionNotFoundError{ + ExtensionId: filterOptions.Id, + Version: filterOptions.Version, + Source: filterOptions.Source, + Matches: versionMismatches, + } + } + return allExtensions, nil } @@ -693,26 +789,17 @@ func (m *Manager) installInternal( dependencyMatches, err := m.FindExtensions(ctx, dependencyOptions) if err != nil { + if _, ok := errors.AsType[*ExtensionVersionNotFoundError](err); ok { + return nil, &DependencyVersionNotFoundError{ + DependencyId: dependency.Id, + ParentId: extension.Id, + Constraint: dependency.Version, + } + } return nil, fmt.Errorf("failed to find dependency %s: %w", dependency.Id, err) } if len(dependencyMatches) == 0 { - if dependency.Version != "" && !strings.EqualFold(dependency.Version, "latest") { - unconstrainedOptions := *dependencyOptions - unconstrainedOptions.Version = "" - unconstrainedMatches, err := m.FindExtensions(ctx, &unconstrainedOptions) - if err != nil { - return nil, fmt.Errorf("failed to find dependency %s: %w", dependency.Id, err) - } - if len(unconstrainedMatches) > 0 { - return nil, &DependencyVersionNotFoundError{ - DependencyId: dependency.Id, - ParentId: extension.Id, - Constraint: dependency.Version, - } - } - } - return nil, &DependencyNotFoundError{DependencyId: dependency.Id, ParentId: extension.Id} } diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index 010885c49d4..ef04b1d0af1 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -381,6 +381,41 @@ func Test_MatchesVersionConstraint(t *testing.T) { } } +func Test_FindExtensions_MissingVersionReportsLatest(t *testing.T) { + mockContext := mocks.NewMockContext(t.Context()) + createRegistryMocks(mockContext) + + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) + require.NoError(t, err) + + matches, err := manager.FindExtensions(t.Context(), &FilterOptions{ + Id: "test.extension", + Version: "0.1.0", + }) + require.Error(t, err) + require.Nil(t, matches) + + var versionErr *ExtensionVersionNotFoundError + require.ErrorAs(t, err, &versionErr) + require.Equal(t, "test.extension", versionErr.ExtensionId) + require.Equal(t, "0.1.0", versionErr.Version) + require.Contains( + t, + err.Error(), + "extension 'test.extension' version '0.1.0' was not found; latest version is '3.1.0'", + ) + require.Contains( + t, + versionErr.Suggestion(), + "azd extension install test.extension --version 3.1.0", + ) +} + func TestResolveExtensionVersionNil(t *testing.T) { version, err := ResolveExtensionVersion(nil, "", nil)