Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
6 changes: 6 additions & 0 deletions cli/azd/cmd/auto_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,11 @@ func tryAutoInstallForPartialNamespace(
type extensionAutoInstallManager interface {
FindExtensions(ctx context.Context, options *extensions.FilterOptions) ([]*extensions.ExtensionMetadata, error)
GetInstalled(options extensions.FilterOptions) (*extensions.Extension, error)
ResolveDependency(
ctx context.Context,
parent *extensions.ExtensionMetadata,
dependency extensions.ExtensionDependency,
) (*extensions.ExtensionMetadata, error)
Install(
ctx context.Context,
extension *extensions.ExtensionMetadata,
Expand Down Expand Up @@ -451,6 +456,7 @@ func tryAutoInstallExtensionVersion(
preInstalledIds,
" ",
map[string]struct{}{extension.Id: {}},
extension.SourceCategoryOrUnknown(),
)
}
return true, nil
Expand Down
45 changes: 45 additions & 0 deletions cli/azd/cmd/auto_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,35 @@ func (m *fakeExtensionAutoInstallManager) GetInstalled(
return nil, fmt.Errorf("extension not installed")
}

func (m *fakeExtensionAutoInstallManager) ResolveDependency(
ctx context.Context,
parent *extensions.ExtensionMetadata,
dependency extensions.ExtensionDependency,
) (*extensions.ExtensionMetadata, error) {
parentSource := parent.Source
if parentSource == "" {
parentSource = extensions.MainRegistryName
}
sources := []string{parentSource}
if !strings.EqualFold(parentSource, extensions.MainRegistryName) {
sources = append(sources, extensions.MainRegistryName)
}
for _, source := range sources {
matches, err := m.FindExtensions(ctx, &extensions.FilterOptions{
Id: dependency.Id,
Version: dependency.Version,
Source: source,
})
if err != nil {
return nil, err
}
if len(matches) == 1 {
return matches[0], nil
}
}
return nil, fmt.Errorf("dependency not found")
}

func (m *fakeExtensionAutoInstallManager) Install(
_ context.Context,
extension *extensions.ExtensionMetadata,
Expand Down Expand Up @@ -1881,6 +1910,22 @@ func TestResolveExtensionRequirementDependencies(t *testing.T) {
))
})

t.Run("resolves dependencies from main registry fallback", func(t *testing.T) {
t.Parallel()

manager := newManager([]extensions.ExtensionDependency{{Id: "demo.b"}})
manager.available[0].Source = "local"

resolved := resolve(manager)

assert.Equal(t, []string{"demo.b", "demo.c"}, slices.Sorted(maps.Keys(resolved)))
assert.True(t, resolvedDependencyProvidesProvider(
resolved["demo.c"],
extensions.ServiceTargetProviderCapability,
"demo",
))
})

t.Run("omits a dependency it cannot resolve", func(t *testing.T) {
t.Parallel()

Expand Down
68 changes: 59 additions & 9 deletions cli/azd/cmd/auto_install_ux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -421,19 +421,22 @@ func TestAutoInstallExtensionRequirementsDisplaysInstalledDependencies(t *testin
}
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,
Id: parent.Id,
Version: parent.Versions[0].Version,
Source: parent.Source,
SourceCategory: parent.SourceCategory,
}
manager.installed[child.Id] = &extensions.Extension{
Id: child.Id,
Version: child.Versions[0].Version,
Source: child.Source,
Id: child.Id,
Version: child.Versions[0].Version,
Source: child.Source,
SourceCategory: child.SourceCategory,
}
manager.installed[grandchild.Id] = &extensions.Extension{
Id: grandchild.Id,
Version: grandchild.Versions[0].Version,
Source: grandchild.Source,
Id: grandchild.Id,
Version: grandchild.Versions[0].Version,
Source: grandchild.Source,
SourceCategory: grandchild.SourceCategory,
}
return &extension.Versions[0], nil
}
Expand All @@ -453,9 +456,56 @@ func TestAutoInstallExtensionRequirementsDisplaysInstalledDependencies(t *testin
require.Contains(t, rendered, "(2.0.0)")
require.Contains(t, rendered, "Installing grandchild dependency")
require.Contains(t, rendered, "(3.0.0)")
require.NotContains(t, rendered, "from azd")
require.Less(t, strings.Index(rendered, "child dependency"), strings.Index(rendered, "grandchild dependency"))
}

func TestAutoInstallExtensionRequirementsDisplaysMainRegistryDependencyFallback(t *testing.T) {
clearAgentEnvVarsForTest(t)

parent := autoInstallTestExtension("parent", "Parent", "local", extensions.SourceCategoryLocal)
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) {
for _, installed := range []*extensions.ExtensionMetadata{parent, child, grandchild} {
manager.installed[installed.Id] = &extensions.Extension{
Id: installed.Id,
Version: installed.Versions[0].Version,
Source: installed.Source,
SourceCategory: installed.SourceCategory,
}
}
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) from azd")
require.Contains(t, rendered, "Installing grandchild dependency")
require.NotContains(t, rendered, "(3.0.0) from azd")
}

func TestAutoInstallExtensionRequirementsNoPromptAmbiguous(t *testing.T) {
clearAgentEnvVarsForTest(t)

Expand Down
27 changes: 19 additions & 8 deletions cli/azd/cmd/extension.go
Original file line number Diff line number Diff line change
Expand Up @@ -1063,10 +1063,11 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult
a.console.ShowSpinner(ctx, stepMessage, input.Step)
extensionVersion, _, err = a.extensionManager.Upgrade(
ctx, compatibleExtension, extensions.UpgradeOptions{
VersionPreference: a.flags.version,
UpgradeDependencies: !a.flags.noDependencies,
SkipDependencies: a.flags.noDependencies,
AzdVersion: azdVersion,
VersionPreference: a.flags.version,
UpgradeDependencies: !a.flags.noDependencies,
SkipDependencies: a.flags.noDependencies,
AzdVersion: azdVersion,
SkipMainRegistryDependencyFallback: a.bundleSourceName != "",
},
)
if err != nil {
Expand All @@ -1084,9 +1085,10 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult
ctx,
compatibleExtension,
extensions.InstallOptions{
VersionPreference: a.flags.version,
AzdVersion: azdVersion,
SkipDependencies: a.flags.noDependencies,
VersionPreference: a.flags.version,
AzdVersion: azdVersion,
SkipDependencies: a.flags.noDependencies,
SkipMainRegistryDependencyFallback: a.bundleSourceName != "",
},
)
if err != nil {
Expand All @@ -1106,6 +1108,7 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult
preInstalledIds,
" ",
map[string]struct{}{compatibleExtension.Id: {}},
compatibleExtension.SourceCategoryOrUnknown(),
)
}

Expand Down Expand Up @@ -3261,6 +3264,7 @@ func displayInstalledDependencies(
preInstalledIds map[string]struct{},
indent string,
visited map[string]struct{},
parentSourceCategory extensions.SourceCategory,
) {
for _, dep := range deps {
if _, seen := visited[dep.Id]; seen {
Expand All @@ -3282,12 +3286,18 @@ func displayInstalledDependencies(
output.WithGrayFormat("(%s, already installed)", installed.Version),
))
} else {
sourceSuffix := ""
if installed.SourceCategoryOrUnknown() == extensions.SourceCategoryAzd &&
parentSourceCategory != extensions.SourceCategoryAzd {
sourceSuffix = fmt.Sprintf(" from %s", extensions.MainRegistryName)
}
console.Message(ctx, fmt.Sprintf(
"%s%s Installing %s dependency %s",
"%s%s Installing %s dependency %s%s",
indent,
output.WithSuccessFormat("(\u2713) Done:"),
output.WithHighLightFormat(installed.Id),
output.WithGrayFormat("(%s)", installed.Version),
sourceSuffix,
))
}

Expand All @@ -3303,6 +3313,7 @@ func displayInstalledDependencies(
displayInstalledDependencies(
ctx, console, manager, v.Dependencies,
preInstalledIds, indent, visited,
installed.SourceCategoryOrUnknown(),
)
break
}
Expand Down
11 changes: 2 additions & 9 deletions cli/azd/cmd/project_extension_auto_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,18 +351,11 @@ func resolveExtensionDependencies(
continue
}

matches, err := extensionManager.FindExtensions(ctx, &extensions.FilterOptions{
Id: dependency.Id,
Version: dependency.Version,
Source: parent.Source,
})
// More than one match means several sources publish the dependency, which installation
// rejects as ambiguous rather than choosing between them.
if err != nil || len(matches) != 1 {
dependencyExtension, err := extensionManager.ResolveDependency(ctx, parent, dependency)
if err != nil {
continue
}

dependencyExtension := matches[0]
version, err := extensions.ResolveExtensionVersion(dependencyExtension, dependency.Version, nil)
if err != nil {
continue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ When `azd` resolves versions, it filters them into compatible and incompatible s
Once a version is resolved, installation proceeds through these steps:

1. **Resolve version** — Apply the version constraint against available versions, filter by `azd` compatibility, and select the highest match.
2. **Resolve dependencies** — If the extension declares dependencies, resolve each one recursively from the **same source as the parent extension**. Cross-source dependency resolution is not performed. Dependencies use the declared version constraint (or `latest`) but do **not** go through `azd` version compatibility filtering — `requiredAzdVersion` checks are only applied to the top-level extension. Passing `--no-dependencies` skips this step entirely: only the named extension is installed, its declared dependencies are neither resolved nor installed, and the installed-dependency version constraints are not enforced. This is intended for callers that only need the extension's own binary (for example, generating command snapshots) and cannot guarantee the registry's dependency graph is internally consistent.
2. **Resolve dependencies** — If the extension declares dependencies, resolve each one recursively from the **same source as the parent extension**, then fall back to the main `azd` registry when that source has no version satisfying the dependency constraint. Other configured sources are not searched. Self-contained bundles do not use the fallback because all of their dependencies must be included in the bundle. Dependencies use the declared version constraint (or `latest`) and are filtered by their `requiredAzdVersion` compatibility with the running `azd` version. Passing `--no-dependencies` skips this step entirely: only the named extension is installed, its declared dependencies are neither resolved nor installed, and the installed-dependency version constraints are not enforced. This is intended for callers that only need the extension's own binary (for example, generating command snapshots) and cannot guarantee the registry's dependency graph is internally consistent.
3. **Match platform artifact** — Find the artifact for the current OS and architecture. `azd` first looks for `<os>/<arch>` (for example, `linux/amd64` or `windows/amd64`). If no exact match is found, it falls back to `<os>` only (for example, `linux` or `windows`).
4. **Download** — Fetch the artifact from its URL (HTTP/HTTPS) or copy from a local file path.
5. **Validate checksum** — Verify the downloaded file against the published checksum. Supported algorithms are `sha256` and `sha512`.
Expand All @@ -191,7 +191,7 @@ When the source **is** changing (for example installing a bundle build over a re

Because each bundle install registers a unique transient source, installing from **any** bundle over an already-installed extension is always treated as a source change — so it prompts even when the bundled version matches the installed one (the two builds may not be byte-identical).

If a required dependency cannot be resolved from the parent's source and is not already installed, the install fails with an actionable error directing you to install the dependency first (consistent with the no cross-source dependency resolution behavior described above).
For registry-backed installs, a required dependency must resolve from the parent's source or the main `azd` registry. For self-contained bundles, it must resolve from the bundle itself. If the dependency is not already installed and cannot be resolved from the applicable sources, the install fails with actionable guidance.

## Self-Contained Bundles

Expand Down Expand Up @@ -403,8 +403,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 <name>` 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 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. |
| *"dependency X not found"* | A recursive dependency is not installed and is missing from the applicable sources: the parent source and main `azd` registry for registry-backed installs, or the bundle for a bundle install. | Include the dependency in the parent source or bundle, publish it to `azd` for a registry-backed install, or install it explicitly before installing the parent. |
| *"no version satisfies constraint"* | The applicable sources contain the dependency, but none of its versions match the parent extension's constraint. | Include or publish a compatible dependency version, install one explicitly, 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
Expand Down
Loading
Loading