Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 16 additions & 42 deletions cli/azd/cmd/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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,
Expand All @@ -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 "+
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
))
Expand Down
31 changes: 31 additions & 0 deletions cli/azd/cmd/extension_install_source_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
121 changes: 104 additions & 17 deletions cli/azd/pkg/extensions/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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{}
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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}
}

Expand Down
35 changes: 35 additions & 0 deletions cli/azd/pkg/extensions/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down