diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index 3fbb0c1a673..0cd889cd877 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -370,7 +370,8 @@ When `latest` is specified (or the version is omitted), `azd` selects the **high | *"extension X not found"* | The extension ID is not present in any configured source. | Verify your sources with `azd extension source list`. Check the extension ID spelling. | | *"found in multiple sources, specify exact source"* | The extension exists in two or more configured sources. | Use `azd extension install X --source ` to specify which source to use. | | *"no matching version found"* | The version constraint excludes all available versions. | Check available versions with `azd extension show X`. Relax the constraint. | -| *"dependency X not found"* | A recursive dependency declared by the extension is missing from all sources. | Ensure the dependency is published to an accessible source. | +| *"dependency X not found"* | A recursive dependency is not installed and is missing from the parent extension's source. | Publish the dependency to the same source or install it explicitly before installing the parent. | +| *"no version satisfies constraint"* | The dependency exists, but none of its versions match the parent extension's constraint. | Publish a compatible dependency version or update the parent extension's constraint. | | Stale version installed | The source cache has not expired yet, so `azd` is using an older manifest. | Set `AZD_EXTENSION_CACHE_TTL=0s` or delete files in `~/.azd/cache/extensions/`. | ### Diagnostic Steps diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index f5cae401072..9e5dc85745e 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -62,6 +62,24 @@ func (e *DependencyNotFoundError) Error() string { return fmt.Sprintf("dependency %s required by %s was not found", e.DependencyId, e.ParentId) } +// DependencyVersionNotFoundError indicates that a required dependency exists +// but none of its versions satisfy the constraint declared by its parent. +type DependencyVersionNotFoundError struct { + // DependencyId is the id of the dependency without a matching version. + DependencyId string + // ParentId is the id of the extension that declares the dependency. + ParentId string + // Constraint is the version constraint that could not be satisfied. + Constraint string +} + +func (e *DependencyVersionNotFoundError) Error() string { + return fmt.Sprintf( + "dependency %s required by %s was found, but no version satisfies constraint %q", + e.DependencyId, e.ParentId, e.Constraint, + ) +} + // DependencyAmbiguousSourceError indicates that a required dependency of an // extension was found in more than one configured source, so azd cannot decide // which one to use. The caller must disambiguate by specifying an exact source. @@ -630,6 +648,22 @@ func (m *Manager) installInternal( } 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 a3b39450e21..22c2b6e76d1 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -519,6 +519,72 @@ func Test_Install_PackDependency_SemverConstraint(t *testing.T) { require.Equal(t, "0.1.31-preview", installed.Version) } +func Test_Install_PackDependency_ErrorClassification(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + dependency *ExtensionMetadata + expectVersionConstraint bool + }{ + { + name: "dependency not found", + }, + { + name: "dependency version not found", + dependency: &ExtensionMetadata{ + Id: "test.child", + Source: "local", + Versions: []ExtensionVersion{ + {Version: "1.0.0", Artifacts: sampleArtifacts}, + }, + }, + expectVersionConstraint: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + parent := &ExtensionMetadata{ + Id: "test.pack", + Source: "local", + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Dependencies: []ExtensionDependency{ + {Id: "test.child", Version: ">=2.0.0"}, + }, + }, + }, + } + sourceExtensions := []*ExtensionMetadata{parent} + if test.dependency != nil { + sourceExtensions = append(sourceExtensions, test.dependency) + } + + manager := newTestManager(t) + manager.sources = []Source{&mockSource{name: "local", extensions: sourceExtensions}} + + _, err := manager.Install(t.Context(), parent, "") + require.Error(t, err) + if test.expectVersionConstraint { + versionErr, ok := errors.AsType[*DependencyVersionNotFoundError](err) + require.True(t, ok) + require.Equal(t, "test.child", versionErr.DependencyId) + require.Equal(t, "test.pack", versionErr.ParentId) + require.Equal(t, ">=2.0.0", versionErr.Constraint) + require.Contains(t, versionErr.Error(), "was found, but no version satisfies constraint") + return + } + + notFoundErr, ok := errors.AsType[*DependencyNotFoundError](err) + require.True(t, ok) + require.Equal(t, "test.child", notFoundErr.DependencyId) + require.Equal(t, "test.pack", notFoundErr.ParentId) + }) + } +} + func Test_Install_PackDependency_InstalledDependencyMustSatisfyConstraint(t *testing.T) { mockContext := mocks.NewMockContext(t.Context())