diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index eec4e14352b..93f83a29841 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -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, @@ -451,6 +456,7 @@ func tryAutoInstallExtensionVersion( preInstalledIds, " ", map[string]struct{}{extension.Id: {}}, + extension.SourceCategoryOrUnknown(), ) } return true, nil diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index c736f68593a..03cd5e7c8bd 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -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, @@ -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() diff --git a/cli/azd/cmd/auto_install_ux_test.go b/cli/azd/cmd/auto_install_ux_test.go index 3297e7a5aa5..4482bb0d5f6 100644 --- a/cli/azd/cmd/auto_install_ux_test.go +++ b/cli/azd/cmd/auto_install_ux_test.go @@ -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 } @@ -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) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index fb90d1fb80b..8da408dba40 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -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 { @@ -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 { @@ -1106,6 +1108,7 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult preInstalledIds, " ", map[string]struct{}{compatibleExtension.Id: {}}, + compatibleExtension.SourceCategoryOrUnknown(), ) } @@ -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 { @@ -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, )) } @@ -3303,6 +3313,7 @@ func displayInstalledDependencies( displayInstalledDependencies( ctx, console, manager, v.Dependencies, preInstalledIds, indent, visited, + installed.SourceCategoryOrUnknown(), ) break } diff --git a/cli/azd/cmd/project_extension_auto_install.go b/cli/azd/cmd/project_extension_auto_install.go index bba9764df03..bb22e9d8f05 100644 --- a/cli/azd/cmd/project_extension_auto_install.go +++ b/cli/azd/cmd/project_extension_auto_install.go @@ -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 diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index b8585961e4b..7c3a617387e 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -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 `/` (for example, `linux/amd64` or `windows/amd64`). If no exact match is found, it falls back to `` 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`. @@ -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 @@ -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 ` 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 diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 3c600412ec8..fc80081419a 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -48,9 +48,7 @@ var ( ) // DependencyNotFoundError indicates that a required dependency of an extension -// could not be located in the same source as its parent. azd does not perform -// cross-source dependency resolution during install, so the dependency must be -// available in the parent's source or already installed. +// could not be located in the parent source or the main azd registry. type DependencyNotFoundError struct { // DependencyId is the id of the dependency that could not be resolved. DependencyId string @@ -91,8 +89,8 @@ func (e *DependencyVersionNotFoundError) Error() string { // Suggestion returns actionable guidance for resolving the dependency constraint. func (e *DependencyVersionNotFoundError) Suggestion() string { return fmt.Sprintf( - "Install a version of %s that satisfies constraint %q, publish one to the same source as %s, "+ - "or update %s's dependency constraint, then retry.", + "Install a version of %s that satisfies constraint %q before retrying, include a compatible version "+ + "with %s, or update %s's dependency constraint.", e.DependencyId, e.Constraint, e.ParentId, e.ParentId, ) } @@ -167,6 +165,84 @@ func dependencySources(matches []*ExtensionMetadata) []string { return slices.Sorted(maps.Keys(seen)) } +// ResolveDependency selects metadata for a dependency, preferring the parent +// extension's source and falling back to the main azd registry. +func (m *Manager) ResolveDependency( + ctx context.Context, + parent *ExtensionMetadata, + dependency ExtensionDependency, +) (*ExtensionMetadata, error) { + return m.resolveDependency(ctx, parent, dependency, true, nil) +} + +func (m *Manager) resolveDependency( + ctx context.Context, + parent *ExtensionMetadata, + dependency ExtensionDependency, + allowMainRegistryFallback bool, + azdVersion *semver.Version, +) (*ExtensionMetadata, error) { + parentSource := parent.Source + if parentSource == "" { + parentSource = MainRegistryName + } + + sources := []string{parentSource} + if allowMainRegistryFallback && !strings.EqualFold(parentSource, MainRegistryName) { + sources = append(sources, MainRegistryName) + } + + foundWithoutMatchingVersion := false + var incompatibleVersion *ExtensionVersion + for _, source := range sources { + matches, err := m.FindExtensions(ctx, &FilterOptions{ + Id: dependency.Id, + Source: source, + }) + if err != nil { + return nil, fmt.Errorf("failed to find dependency %s: %w", dependency.Id, err) + } + if len(matches) > 1 { + return nil, &DependencyAmbiguousSourceError{ + DependencyId: dependency.Id, + ParentId: parent.Id, + Sources: dependencySources(matches), + } + } + if len(matches) == 0 { + continue + } + + publishedVersion := bestSatisfyingVersion(dependency.Version, matches[0].Versions) + if publishedVersion == nil { + foundWithoutMatchingVersion = true + continue + } + if bestSatisfyingVersionForAzd(dependency.Version, matches[0].Versions, azdVersion) != nil { + return matches[0], nil + } + incompatibleVersion = publishedVersion + } + + if incompatibleVersion != nil { + return nil, &DependencyAzdVersionIncompatibleError{ + DependencyId: dependency.Id, + ParentId: parent.Id, + Constraint: dependency.Version, + RequiredAzdVersion: incompatibleVersion.RequiredAzdVersion, + } + } + + if foundWithoutMatchingVersion { + return nil, &DependencyVersionNotFoundError{ + DependencyId: dependency.Id, + ParentId: parent.Id, + Constraint: dependency.Version, + } + } + return nil, &DependencyNotFoundError{DependencyId: dependency.Id, ParentId: parent.Id} +} + // FilterOptions is used to filter, lookup, and list extensions with various criteria type FilterOptions struct { // Id is used to specify the id of the extension to install @@ -619,6 +695,10 @@ type InstallOptions struct { // extension's own binary (e.g. generating command snapshots) and cannot // guarantee that the registry's dependency graph is internally consistent. SkipDependencies bool + // SkipMainRegistryDependencyFallback prevents dependencies missing from the + // parent source from falling back to the main azd registry. Self-contained + // bundle installs use this to remain isolated from network sources. + SkipMainRegistryDependencyFallback bool } // InstallWithOptions installs an extension using the supplied options. @@ -705,51 +785,21 @@ func (m *Manager) installInternal( continue } - // Find the dependency extension metadata first - dependencyOptions := &FilterOptions{ - Id: dependency.Id, - Version: dependency.Version, - Source: extension.Source, // Use same source as parent extension - } - - dependencyMatches, err := m.FindExtensions(ctx, dependencyOptions) + dependencyMetadata, err := m.resolveDependency( + ctx, + extension, + dependency, + !opts.SkipMainRegistryDependencyFallback, + opts.AzdVersion, + ) if err != nil { - return nil, fmt.Errorf("failed to find dependency %s: %w", dependency.Id, err) + return nil, 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} - } - - if len(dependencyMatches) > 1 { - return nil, &DependencyAmbiguousSourceError{ - DependencyId: dependency.Id, - ParentId: extension.Id, - Sources: dependencySources(dependencyMatches), - } - } - - dependencyMetadata := dependencyMatches[0] - dependencyOpts := InstallOptions{ - VersionPreference: dependency.Version, - AzdVersion: opts.AzdVersion, + VersionPreference: dependency.Version, + AzdVersion: opts.AzdVersion, + SkipMainRegistryDependencyFallback: opts.SkipMainRegistryDependencyFallback, } if _, err := m.installInternal(ctx, dependencyMetadata, dependencyOpts, false, visited); err != nil { if !errors.Is(err, ErrExtensionInstalled) { @@ -948,6 +998,9 @@ type UpgradeOptions struct { // upgrade performs, so `--no-dependencies` behaves the same whether the // extension is being installed fresh or over an existing install. SkipDependencies bool + // SkipMainRegistryDependencyFallback mirrors the InstallOptions behavior for + // the reinstall performed during upgrade. + SkipMainRegistryDependencyFallback bool } // DefaultUpgradeOptions returns UpgradeOptions with dependency upgrades enabled. @@ -1013,9 +1066,10 @@ func (m *Manager) upgradeInternal( // Skip the installed-dependency constraint check: the previous parent has just been // uninstalled and any stale dependency will be reconciled by evaluateDependencyChanges below. extensionVersion, err := m.installInternal(ctx, extension, InstallOptions{ - VersionPreference: opts.VersionPreference, - AzdVersion: opts.AzdVersion, - SkipDependencies: opts.SkipDependencies, + VersionPreference: opts.VersionPreference, + AzdVersion: opts.AzdVersion, + SkipDependencies: opts.SkipDependencies, + SkipMainRegistryDependencyFallback: opts.SkipMainRegistryDependencyFallback, }, true, map[string]struct{}{}) if err != nil { return nil, nil, fmt.Errorf("failed to install extension: %w", err) @@ -1084,13 +1138,24 @@ func (m *Manager) evaluateDependencyChanges( // upgrades, skips, or fails. visited[dep.Id] = struct{}{} - // Dependency upgrades use upgrade-to-best-match semantics. - childMetadata, findErr := m.findDependencyChild(ctx, parentExtension, dep.Id) + // Dependency upgrades use the same parent-source then main-registry + // resolution policy as fresh dependency installs. + childMetadata, findErr := m.resolveDependency( + ctx, + parentExtension, + dep, + !opts.SkipMainRegistryDependencyFallback, + opts.AzdVersion, + ) if findErr != nil { // Without registry data, only fail if the installed version violates the constraint. if matchesVersionConstraint(dep.Version, installed.Version) { continue } + var suggestion string + if suggestionErr, ok := findErr.(interface{ Suggestion() string }); ok { + suggestion = suggestionErr.Suggestion() + } results = append(results, UpgradeResult{ ExtensionId: dep.Id, Status: UpgradeStatusFailed, @@ -1098,6 +1163,7 @@ func (m *Manager) evaluateDependencyChanges( FromSource: installed.Source, FromSourceCategory: installed.SourceCategoryOrUnknown(), Error: findErr, + Suggestion: suggestion, }) continue } @@ -1208,9 +1274,10 @@ func (m *Manager) evaluateDependencyChanges( ) childOpts := UpgradeOptions{ - VersionPreference: dep.Version, - UpgradeDependencies: opts.UpgradeDependencies, - AzdVersion: opts.AzdVersion, + VersionPreference: dep.Version, + UpgradeDependencies: opts.UpgradeDependencies, + AzdVersion: opts.AzdVersion, + SkipMainRegistryDependencyFallback: opts.SkipMainRegistryDependencyFallback, } childVersion, nested, upErr := m.upgradeInternal(childCtx, childMetadata, childOpts, visited) @@ -1236,39 +1303,6 @@ func (m *Manager) evaluateDependencyChanges( return results } -// findDependencyChild locates the child extension metadata to use for a -// dependency upgrade. It prefers the parent's source but falls back to any -// source if the child is not present in the parent's source. -func (m *Manager) findDependencyChild( - ctx context.Context, - parent *ExtensionMetadata, - childId string, -) (*ExtensionMetadata, error) { - opts := &FilterOptions{Id: childId, Source: parent.Source} - matches, err := m.FindExtensions(ctx, opts) - if err != nil { - return nil, fmt.Errorf("failed to find dependency %s: %w", childId, err) - } - if len(matches) == 0 { - // Fall back to any source - matches, err = m.FindExtensions(ctx, &FilterOptions{Id: childId}) - if err != nil { - return nil, fmt.Errorf("failed to find dependency %s: %w", childId, err) - } - } - if len(matches) == 0 { - return nil, fmt.Errorf("dependency %s not found in any registry", childId) - } - if len(matches) > 1 { - return nil, &DependencyAmbiguousSourceError{ - DependencyId: childId, - ParentId: parent.Id, - Sources: dependencySources(matches), - } - } - return matches[0], nil -} - // Helper function to find the artifact for the current OS func findArtifactForCurrentOS(version *ExtensionVersion) (*ExtensionArtifact, error) { if version.Artifacts == nil { @@ -1397,7 +1431,16 @@ func (tm *Manager) ReloadUserConfig() error { func (tm *Manager) getSources(ctx context.Context, filter sourceFilterPredicate) ([]Source, error) { if tm.sources != nil { - return tm.sources, nil + if filter == nil { + return tm.sources, nil + } + return slices.Collect(func(yield func(Source) bool) { + for _, source := range tm.sources { + if filter(&SourceConfig{Name: source.Name()}) && !yield(source) { + return + } + } + }), nil } configs, err := tm.sourceManager.List(ctx) if err != nil { @@ -1409,6 +1452,9 @@ func (tm *Manager) getSources(ctx context.Context, filter sourceFilterPredicate) return nil, fmt.Errorf("failed initializing extension sources: %w", err) } + if filter != nil { + return sources, nil + } tm.sources = sources return tm.sources, nil diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index bdd24f44d51..87f63809267 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -700,6 +700,355 @@ func Test_Install_PackDependency_ErrorClassification(t *testing.T) { } } +func Test_ResolveDependency_SourceFallback(t *testing.T) { + t.Parallel() + + parent := &ExtensionMetadata{Id: "test.pack", Source: "local"} + dependency := ExtensionDependency{Id: "test.child", Version: ">=2.0.0"} + + tests := []struct { + name string + local *ExtensionMetadata + main *ExtensionMetadata + azdVersion *semver.Version + wantSource string + wantVersion bool + wantAzd bool + wantMissing bool + }{ + { + name: "falls back to main registry", + main: &ExtensionMetadata{ + Id: dependency.Id, + Source: MainRegistryName, + Versions: []ExtensionVersion{ + {Version: "2.0.0"}, + }, + }, + wantSource: MainRegistryName, + }, + { + name: "falls back when parent source requires newer azd", + local: &ExtensionMetadata{ + Id: dependency.Id, + Source: parent.Source, + Versions: []ExtensionVersion{ + {Version: "2.1.0", RequiredAzdVersion: ">=2.0.0"}, + }, + }, + main: &ExtensionMetadata{ + Id: dependency.Id, + Source: MainRegistryName, + Versions: []ExtensionVersion{ + {Version: "2.0.0"}, + }, + }, + azdVersion: semver.MustParse("1.0.0"), + wantSource: MainRegistryName, + }, + { + name: "parent source wins", + local: &ExtensionMetadata{ + Id: dependency.Id, + Source: parent.Source, + Versions: []ExtensionVersion{ + {Version: "2.1.0"}, + }, + }, + main: &ExtensionMetadata{ + Id: dependency.Id, + Source: MainRegistryName, + Versions: []ExtensionVersion{ + {Version: "2.2.0"}, + }, + }, + wantSource: parent.Source, + }, + { + name: "falls back when parent version is incompatible", + local: &ExtensionMetadata{ + Id: dependency.Id, + Source: parent.Source, + Versions: []ExtensionVersion{ + {Version: "1.0.0"}, + }, + }, + main: &ExtensionMetadata{ + Id: dependency.Id, + Source: MainRegistryName, + Versions: []ExtensionVersion{ + {Version: "2.0.0"}, + }, + }, + wantSource: MainRegistryName, + }, + { + name: "reports incompatible versions across both sources", + local: &ExtensionMetadata{ + Id: dependency.Id, + Source: parent.Source, + Versions: []ExtensionVersion{ + {Version: "1.0.0"}, + }, + }, + main: &ExtensionMetadata{ + Id: dependency.Id, + Source: MainRegistryName, + Versions: []ExtensionVersion{ + {Version: "1.5.0"}, + }, + }, + wantVersion: true, + }, + { + name: "reports azd incompatibility across both sources", + local: &ExtensionMetadata{ + Id: dependency.Id, + Source: parent.Source, + Versions: []ExtensionVersion{ + {Version: "2.1.0", RequiredAzdVersion: ">=2.0.0"}, + }, + }, + main: &ExtensionMetadata{ + Id: dependency.Id, + Source: MainRegistryName, + Versions: []ExtensionVersion{ + {Version: "2.0.0", RequiredAzdVersion: ">=3.0.0"}, + }, + }, + azdVersion: semver.MustParse("1.0.0"), + wantAzd: true, + }, + { + name: "reports missing from both sources", + wantMissing: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + manager := newTestManager(t) + manager.sources = []Source{} + if test.local != nil { + manager.sources = append(manager.sources, &mockSource{ + name: parent.Source, + extensions: []*ExtensionMetadata{test.local}, + }) + } + if test.main != nil { + manager.sources = append(manager.sources, &mockSource{ + name: MainRegistryName, + extensions: []*ExtensionMetadata{test.main}, + }) + } + + resolved, err := manager.resolveDependency(t.Context(), parent, dependency, true, test.azdVersion) + switch { + case test.wantVersion: + require.ErrorAs(t, err, new(*DependencyVersionNotFoundError)) + case test.wantAzd: + require.ErrorAs(t, err, new(*DependencyAzdVersionIncompatibleError)) + case test.wantMissing: + require.ErrorAs(t, err, new(*DependencyNotFoundError)) + default: + require.NoError(t, err) + require.Equal(t, test.wantSource, resolved.Source) + } + }) + } +} + +func Test_ResolveDependency_SequentialConfiguredSourceLookups(t *testing.T) { + t.Parallel() + + localRegistry := writeExtensionRegistryFile(t, Registry{ + SchemaVersion: CurrentRegistrySchemaVersion, + Extensions: []*ExtensionMetadata{{ + Id: "test.pack", + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.child", Version: "1.0.0"}}, + }}, + }}, + }) + mainRegistry := Registry{ + SchemaVersion: CurrentRegistrySchemaVersion, + Extensions: []*ExtensionMetadata{{ + Id: "test.child", + Versions: []ExtensionVersion{{Version: "1.0.0"}}, + }}, + } + + mockContext := mocks.NewMockContext(t.Context()) + mockContext.HttpClient.When(func(request *http.Request) bool { + return request.URL.String() == extensionRegistryUrl + }).RespondFn(func(request *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, mainRegistry) + }) + cfg, err := mockContext.ConfigManager.Load("") + require.NoError(t, err) + require.NoError(t, cfg.Set("extension.sources.local", &SourceConfig{ + Name: "local", + Type: SourceKindFile, + Location: localRegistry, + })) + require.NoError(t, cfg.Set("extension.sources.azd", &SourceConfig{ + Name: MainRegistryName, + Type: SourceKindUrl, + Location: extensionRegistryUrl, + })) + + 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) + + parents, err := manager.FindExtensions(t.Context(), &FilterOptions{Id: "test.pack", Source: "local"}) + require.NoError(t, err) + require.Len(t, parents, 1) + + resolved, err := manager.ResolveDependency( + t.Context(), + parents[0], + ExtensionDependency{Id: "test.child", Version: "1.0.0"}, + ) + require.NoError(t, err) + require.Equal(t, MainRegistryName, resolved.Source) +} + +func Test_Install_PackDependency_FallsBackToMainRegistry(t *testing.T) { + t.Parallel() + + parent := &ExtensionMetadata{ + Id: "test.pack", + Source: "local", + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.child", Version: "1.0.0"}}, + }}, + } + child := &ExtensionMetadata{ + Id: "test.child", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: "1.0.0"}}, + }}, + } + + manager := newTestManager(t) + manager.sources = []Source{ + &mockSource{name: parent.Source, extensions: []*ExtensionMetadata{parent}}, + &mockSource{name: MainRegistryName, extensions: []*ExtensionMetadata{child}}, + } + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.leaf": { + Id: "test.leaf", + Version: "1.0.0", + Source: MainRegistryName, + }, + })) + manager.installed = nil + + _, err := manager.Install(t.Context(), parent, "") + require.NoError(t, err) + + installed, err := manager.GetInstalled(FilterOptions{Id: child.Id}) + require.NoError(t, err) + require.Equal(t, MainRegistryName, installed.Source) +} + +func Test_Install_PackDependency_FallsBackWhenParentSourceRequiresNewerAzd(t *testing.T) { + t.Parallel() + + parent := &ExtensionMetadata{ + Id: "test.pack", + Source: "local", + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.child", Version: ">=1.5.0"}}, + }}, + } + localChild := &ExtensionMetadata{ + Id: "test.child", + Source: parent.Source, + Versions: []ExtensionVersion{{ + Version: "2.0.0", + RequiredAzdVersion: ">=2.0.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: "1.0.0"}}, + }}, + } + mainChild := &ExtensionMetadata{ + Id: "test.child", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "1.5.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: "1.0.0"}}, + }}, + } + + manager := newTestManager(t) + manager.sources = []Source{ + &mockSource{name: parent.Source, extensions: []*ExtensionMetadata{parent, localChild}}, + &mockSource{name: MainRegistryName, extensions: []*ExtensionMetadata{mainChild}}, + } + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.leaf": { + Id: "test.leaf", + Version: "1.0.0", + Source: MainRegistryName, + }, + })) + manager.installed = nil + + _, err := manager.InstallWithOptions(t.Context(), parent, InstallOptions{ + AzdVersion: semver.MustParse("1.0.0"), + }) + require.NoError(t, err) + + installed, err := manager.GetInstalled(FilterOptions{Id: mainChild.Id}) + require.NoError(t, err) + require.Equal(t, MainRegistryName, installed.Source) + require.Equal(t, "1.5.0", installed.Version) +} + +func Test_Install_PackDependency_BundleDoesNotFallback(t *testing.T) { + t.Parallel() + + parent := &ExtensionMetadata{ + Id: "test.pack", + Source: "test-bundle", + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.child", Version: "1.0.0"}}, + }}, + } + child := &ExtensionMetadata{ + Id: "test.child", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: "1.0.0"}}, + }}, + } + + manager := newTestManager(t) + manager.sources = []Source{ + &mockSource{name: parent.Source, extensions: []*ExtensionMetadata{parent}}, + &mockSource{name: MainRegistryName, extensions: []*ExtensionMetadata{child}}, + } + + _, err := manager.InstallWithOptions(t.Context(), parent, InstallOptions{ + SkipMainRegistryDependencyFallback: true, + }) + require.ErrorAs(t, err, new(*DependencyNotFoundError)) +} + func Test_Install_PackDependency_InstalledDependencyMustSatisfyConstraint(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) @@ -2501,6 +2850,211 @@ func Test_Upgrade_DependencyUpgrade_ReconcilesWhenParentCurrent(t *testing.T) { require.Equal(t, "2.0.0", depUpgrades[0].ToVersion) } +func Test_Upgrade_DependencyUpgrade_FallsBackToMainRegistry(t *testing.T) { + t.Parallel() + + parent := &ExtensionMetadata{ + Id: "test.pack", + Source: "local", + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.child", Version: ">=2.0.0"}}, + }}, + } + localChild := &ExtensionMetadata{ + Id: "test.child", + Source: parent.Source, + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: "1.0.0"}}, + }}, + } + mainChild := &ExtensionMetadata{ + Id: "test.child", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "2.0.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: "1.0.0"}}, + }}, + } + + manager := newTestManager(t) + manager.sources = []Source{ + &mockSource{name: parent.Source, extensions: []*ExtensionMetadata{parent, localChild}}, + &mockSource{name: MainRegistryName, extensions: []*ExtensionMetadata{mainChild}}, + } + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.child": { + Id: "test.child", + Version: "1.0.0", + Source: parent.Source, + }, + "test.leaf": { + Id: "test.leaf", + Version: "1.0.0", + Source: MainRegistryName, + }, + })) + manager.installed = nil + + _, depUpgrades, err := manager.ReconcileDependencies( + t.Context(), + parent, + DefaultUpgradeOptions(""), + ) + require.NoError(t, err) + require.Len(t, depUpgrades, 1) + require.Equal(t, UpgradeStatusUpgraded, depUpgrades[0].Status) + require.Equal(t, MainRegistryName, depUpgrades[0].ToSource) + require.Equal(t, "2.0.0", depUpgrades[0].ToVersion) +} + +func Test_Upgrade_DependencyUpgrade_FallsBackWhenParentSourceRequiresNewerAzd(t *testing.T) { + t.Parallel() + + parent := &ExtensionMetadata{ + Id: "test.pack", + Source: "local", + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.child", Version: ">=1.5.0"}}, + }}, + } + localChild := &ExtensionMetadata{ + Id: "test.child", + Source: parent.Source, + Versions: []ExtensionVersion{{ + Version: "2.0.0", + RequiredAzdVersion: ">=2.0.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: "1.0.0"}}, + }}, + } + mainChild := &ExtensionMetadata{ + Id: "test.child", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "1.5.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: "1.0.0"}}, + }}, + } + + manager := newTestManager(t) + manager.sources = []Source{ + &mockSource{name: parent.Source, extensions: []*ExtensionMetadata{parent, localChild}}, + &mockSource{name: MainRegistryName, extensions: []*ExtensionMetadata{mainChild}}, + } + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.child": { + Id: "test.child", + Version: "1.0.0", + Source: parent.Source, + }, + "test.leaf": { + Id: "test.leaf", + Version: "1.0.0", + Source: MainRegistryName, + }, + })) + manager.installed = nil + + _, depUpgrades, err := manager.ReconcileDependencies( + t.Context(), + parent, + UpgradeOptions{ + UpgradeDependencies: true, + AzdVersion: semver.MustParse("1.0.0"), + }, + ) + require.NoError(t, err) + require.Len(t, depUpgrades, 1) + require.Equal(t, UpgradeStatusUpgraded, depUpgrades[0].Status) + require.Equal(t, MainRegistryName, depUpgrades[0].ToSource) + require.Equal(t, "1.5.0", depUpgrades[0].ToVersion) +} + +func Test_Upgrade_DependencyUpgrade_BundleIsolationPropagatesToNestedDependencies(t *testing.T) { + t.Parallel() + + parent := &ExtensionMetadata{ + Id: "test.pack", + Source: "test-bundle", + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.child", Version: ">=2.0.0"}}, + }}, + } + bundleChild := &ExtensionMetadata{ + Id: "test.child", + Source: parent.Source, + Versions: []ExtensionVersion{ + { + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: "1.0.0"}}, + }, + { + Version: "2.0.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: ">=2.0.0"}}, + }, + }, + } + bundleLeaf := &ExtensionMetadata{ + Id: "test.leaf", + Source: parent.Source, + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.anchor", Version: "1.0.0"}}, + }}, + } + mainLeaf := &ExtensionMetadata{ + Id: "test.leaf", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "2.0.0", + Dependencies: []ExtensionDependency{{Id: "test.anchor", Version: "1.0.0"}}, + }}, + } + + manager := newTestManager(t) + manager.sources = []Source{ + &mockSource{name: parent.Source, extensions: []*ExtensionMetadata{parent, bundleChild, bundleLeaf}}, + &mockSource{name: MainRegistryName, extensions: []*ExtensionMetadata{mainLeaf}}, + } + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.child": { + Id: "test.child", + Version: "1.0.0", + Source: parent.Source, + }, + "test.leaf": { + Id: "test.leaf", + Version: "1.0.0", + Source: parent.Source, + }, + "test.anchor": { + Id: "test.anchor", + Version: "1.0.0", + Source: parent.Source, + }, + })) + manager.installed = nil + + _, depUpgrades, err := manager.ReconcileDependencies(t.Context(), parent, UpgradeOptions{ + UpgradeDependencies: true, + SkipMainRegistryDependencyFallback: true, + }) + require.NoError(t, err) + require.Len(t, depUpgrades, 1) + require.Equal(t, UpgradeStatusUpgraded, depUpgrades[0].Status) + require.Len(t, depUpgrades[0].DependencyUpgrades, 1) + require.Equal(t, UpgradeStatusFailed, depUpgrades[0].DependencyUpgrades[0].Status) + require.ErrorAs(t, depUpgrades[0].DependencyUpgrades[0].Error, new(*DependencyVersionNotFoundError)) + + leaf, err := manager.GetInstalled(FilterOptions{Id: "test.leaf"}) + require.NoError(t, err) + require.Equal(t, "1.0.0", leaf.Version) + require.Equal(t, parent.Source, leaf.Source) +} + func Test_Upgrade_DependencyUpgrade_RefusesToDowngradeOutsideConstraint(t *testing.T) { mockContext := mocks.NewMockContext(t.Context())