diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 19c0eba16d9..8c7cb879c91 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -10,6 +10,7 @@ import ( "io" "log" "os" + "path/filepath" "slices" "strconv" "strings" @@ -170,18 +171,35 @@ func checkForMatchingExtensions( func promptForExtensionChoice( ctx context.Context, console input.Console, - extensions []*extensions.ExtensionMetadata) (*extensions.ExtensionMetadata, error) { + matches []*extensions.ExtensionMetadata) (*extensions.ExtensionMetadata, error) { - if len(extensions) == 0 { + if len(matches) == 0 { return nil, fmt.Errorf("no extensions to choose from") } - if len(extensions) == 1 { - return extensions[0], nil + if len(matches) == 1 { + return matches[0], nil } - options := make([]string, len(extensions)) - for i, ext := range extensions { + // Under --no-prompt there is no basis for choosing between the matches, and guessing could + // install a different binary than the user expects. `azd extension install` refuses to pick a + // source for the same reason. + if console.IsNoPromptMode() { + choices := make([]string, 0, len(matches)) + for _, ext := range matches { + choices = append(choices, fmt.Sprintf("%s (%s)", ext.Id, ext.Source)) + } + slices.Sort(choices) + + return nil, &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("more than one extension can be installed: %s", strings.Join(choices, ", ")), + Suggestion: "Run 'azd extension install --source ' to select one, " + + "then run this command again.", + } + } + + options := make([]string, len(matches)) + for i, ext := range matches { options[i] = fmt.Sprintf("%s (%s) - %s", ext.DisplayName, ext.Source, ext.Description) } @@ -193,7 +211,7 @@ func promptForExtensionChoice( return nil, err } - return extensions[choice], nil + return matches[choice], nil } // isBuiltInCommand checks if the given command is a built-in command by examining @@ -338,14 +356,37 @@ func tryAutoInstallForPartialNamespace( func tryAutoInstallExtension( ctx context.Context, console input.Console, - extensionManager *extensions.Manager, + extensionManager extensionAutoInstallManager, extension extensions.ExtensionMetadata) (bool, error) { + return tryAutoInstallExtensionVersion(ctx, console, extensionManager, extension, "") +} + +type extensionAutoInstallManager interface { + FindExtensions(ctx context.Context, options *extensions.FilterOptions) ([]*extensions.ExtensionMetadata, error) + GetInstalled(options extensions.FilterOptions) (*extensions.Extension, error) + Install( + ctx context.Context, + extension *extensions.ExtensionMetadata, + versionPreference string, + ) (*extensions.ExtensionVersion, error) + ListInstalled() (map[string]*extensions.Extension, error) +} +func tryAutoInstallExtensionVersion( + ctx context.Context, + console input.Console, + extensionManager extensionAutoInstallManager, + extension extensions.ExtensionMetadata, + versionPreference string, +) (bool, error) { // Check if the extension is already installed - _, err := extensionManager.GetInstalled(extensions.FilterOptions{ + installedExtension, err := extensionManager.GetInstalled(extensions.FilterOptions{ Id: extension.Id, }) if err == nil { + if err := validateInstalledExtensionVersion(installedExtension, versionPreference); err != nil { + return false, err + } return false, nil } @@ -372,7 +413,7 @@ func tryAutoInstallExtension( Message: "Confirm installation", }) if err != nil { - return false, nil + return false, err } if !shouldInstall { @@ -381,7 +422,7 @@ func tryAutoInstallExtension( // Install the extension console.Message(ctx, fmt.Sprintf("Installing extension '%s'...\n", extension.Id)) - _, err = extensionManager.Install(ctx, &extension, "") + _, err = extensionManager.Install(ctx, &extension, versionPreference) if err != nil { return false, fmt.Errorf("failed to install extension: %w", err) } @@ -452,6 +493,59 @@ type ExecuteResult struct { LatestVersion <-chan *update.VersionInfo } +// projectDirExists reports whether the directory azd will run in already exists. An empty cwd means +// the caller's own directory. A --cwd that PersistentPreRunE still has to create holds no project. +func projectDirExists(cwd string) bool { + if cwd == "" { + return true + } + + _, err := os.Stat(cwd) + return err == nil +} + +// newRootCmdForExecution builds the root command, constructing it from --cwd when one was supplied +// so that cached AzdContext and ProjectConfig state resolves against the requested project. Cobra's +// PersistentPreRunE performs the real directory change during execution, so the caller's directory +// is restored before returning. globalOpts.Cwd is normalized to an absolute path. +func newRootCmdForExecution( + rootContainer *ioc.NestedContainer, + globalOpts *internal.GlobalCommandOptions, +) (cmd *cobra.Command, err error) { + if globalOpts.Cwd == "" { + return NewRootCmd(false, nil, rootContainer), nil + } + + absoluteCwd, err := filepath.Abs(globalOpts.Cwd) + if err != nil { + return nil, fmt.Errorf("resolving cwd: %w", err) + } + globalOpts.Cwd = absoluteCwd + + if _, statErr := os.Stat(absoluteCwd); os.IsNotExist(statErr) { + // PersistentPreRunE owns prompting for and creating a missing --cwd directory. + return NewRootCmd(false, nil, rootContainer), nil + } else if statErr != nil { + return nil, fmt.Errorf("checking cwd: %w", statErr) + } + + previousCwd, err := os.Getwd() + if err != nil { + return nil, fmt.Errorf("getting current directory: %w", err) + } + if err := os.Chdir(absoluteCwd); err != nil { + return nil, fmt.Errorf("changing directory to %s: %w", absoluteCwd, err) + } + defer func() { + // Deferred so the process never keeps the temporary directory after a failure. + if restoreErr := os.Chdir(previousCwd); restoreErr != nil && err == nil { + cmd, err = nil, fmt.Errorf("restoring current directory: %w", restoreErr) + } + }() + + return NewRootCmd(false, nil, rootContainer), nil +} + // ExecuteWithAutoInstall executes the command and handles auto-installation of extensions for unknown commands. func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContainer) *ExecuteResult { result := &ExecuteResult{} @@ -473,7 +567,12 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai // Creating the RootCmd takes care of registering common dependencies in rootContainer. // The command tree will retrieve globalOpts from the container via its FlagsResolver. - rootCmd := NewRootCmd(false, nil, rootContainer) + rootCmd, err := newRootCmdForExecution(rootContainer, globalOpts) + if err != nil { + fmt.Fprintln(os.Stderr, output.WithErrorFormat("ERROR: %s", err.Error())) + result.Err = err + return result + } var extensionManager *extensions.Manager var console input.Console @@ -495,24 +594,61 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai result.LatestVersion = startUpdateCheck(ctx) } + projectExtensions := projectExtensionResult{} + // A --cwd that does not exist yet holds no project, so resolving now would pick up the + // caller's unrelated project instead. + if projectDirExists(globalOpts.Cwd) { + projectExtensions, err = tryAutoInstallProjectExtensions( + ctx, rootContainer, foundCmd, originalArgs, + ) + if err != nil { + if resolveErr := rootContainer.Resolve(&console); resolveErr != nil { + fmt.Fprintln(os.Stderr, output.WithErrorFormat("ERROR: %s", err.Error())) + } else { + displayAutoInstallError(ctx, console, err) + } + result.Err = err + return result + } + + if projectExtensions.installed { + rootCmd = newRootCmdWithoutRegistration(rootContainer) + foundCmd, originalArgs, err = rootCmd.Find(os.Args[1:]) + if err != nil { + result.Err = err + return result + } + } + } + // Check for partial namespace match (e.g., "ai" found but "ai.agent" not installed) if installed := tryAutoInstallForPartialNamespace( ctx, rootContainer, foundCmd, originalArgs, ); installed { // Extension was installed, rebuild command tree and execute - rootCmd = NewRootCmd(false, nil, rootContainer) + rootCmd = newRootCmdWithoutRegistration(rootContainer) result.Err = rootCmd.ExecuteContext(ctx) return result } - // Known command, proceed with normal execution - err := rootCmd.ExecuteContext(ctx) + // Known command, proceed with normal execution. The failure is held separately because the + // auto-install below declares its own err, and every path out of here has to report it. + commandErr := rootCmd.ExecuteContext(ctx) // Only attempt service-host auto-install when the command failed with that specific error. // Other command errors (for example, unsupported output formats) should be returned directly. - unsupportedErr, ok := errors.AsType[*project.UnsupportedServiceHostError](err) + unsupportedErr, ok := errors.AsType[*project.UnsupportedServiceHostError](commandErr) if !ok { - result.Err = err + result.Err = commandErr + return result + } + if projectExtensions.handled { + if resolveErr := rootContainer.Resolve(&console); resolveErr != nil { + fmt.Fprintln(os.Stderr, unsupportedErr.ErrorMessage) + } else { + console.Message(ctx, unsupportedErr.ErrorMessage) + } + result.Err = commandErr return result } @@ -529,17 +665,32 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai Provider: requiredHost, }) if err != nil { - // Do not fail if we couldn't check for extensions - just proceed to normal execution + // Do not fail if we couldn't check for extensions - just report the command's own failure log.Println("Error: check for extensions. Skipping auto-install:", err) console.Message(ctx, unsupportedErr.ErrorMessage) + result.Err = commandErr return result } - // Note: We don't need to filter or check which extensions are installed. - // If any of these extensions would be installed, the auto-install wouldn't have been triggered because - // there would be at least one extensions providing the capability and provider. + installedExtensions, err := extensionManager.ListInstalled() + if err != nil { + log.Println("Error: list installed extensions. Skipping auto-install:", err) + console.Message(ctx, unsupportedErr.ErrorMessage) + // Auto-install could not run, so the command's own failure stands. + result.Err = commandErr + return result + } + // Offer only the extensions whose selected version supplies the host and that are not + // already installed. + availableExtensionsForHost = filterExtensionsForProvider( + availableExtensionsForHost, + extensions.ServiceTargetProviderCapability, + requiredHost, + ) + availableExtensionsForHost = uninstalledExtensionMatches(availableExtensionsForHost, installedExtensions) if len(availableExtensionsForHost) == 0 { - // did not find an extension with the capability, just print the original error message + // Nothing can be installed to supply the host, so the command's failure stands. console.Message(ctx, unsupportedErr.ErrorMessage) + result.Err = commandErr return result } @@ -572,12 +723,13 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if installed { // Extension was installed, build command tree and execute - rootCmd := NewRootCmd(false, nil, rootContainer) + rootCmd := newRootCmdWithoutRegistration(rootContainer) result.Err = rootCmd.ExecuteContext(ctx) return result } - result.Err = err + // The install was declined, so the command's failure stands. + result.Err = commandErr return result } @@ -699,7 +851,7 @@ func ExecuteWithAutoInstall(ctx context.Context, rootContainer *ioc.NestedContai if installed { // Extension was installed, build command tree and execute - rootCmd := NewRootCmd(false, nil, rootContainer) + rootCmd := newRootCmdWithoutRegistration(rootContainer) result.Err = rootCmd.ExecuteContext(ctx) return result } diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 2b738c66096..62e52059992 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -4,7 +4,12 @@ package cmd import ( + "context" + "errors" "fmt" + "maps" + "path/filepath" + "slices" "strings" "testing" @@ -15,11 +20,749 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" ) +type fakeExtensionAutoInstallManager struct { + available []*extensions.ExtensionMetadata + installed map[string]*extensions.Extension + findErr error +} + +func (m *fakeExtensionAutoInstallManager) FindExtensions( + _ context.Context, + options *extensions.FilterOptions, +) ([]*extensions.ExtensionMetadata, error) { + if m.findErr != nil { + return nil, m.findErr + } + + var matches []*extensions.ExtensionMetadata + for _, extension := range m.available { + if options.Id != "" && extension.Id != options.Id { + continue + } + if options.Source != "" && !strings.EqualFold(extension.Source, options.Source) { + continue + } + if options.Version != "" { + if _, err := extensions.ResolveExtensionVersion(extension, options.Version, nil); err != nil { + continue + } + } + hasCapability := slices.ContainsFunc(extension.Versions, func(version extensions.ExtensionVersion) bool { + return slices.Contains(version.Capabilities, options.Capability) + }) + if options.Capability != "" && !hasCapability { + continue + } + if options.Provider != "" { + selectedVersion, err := extensions.ResolveExtensionVersion(extension, options.Version, nil) + if err != nil { + continue + } + hasProvider := slices.ContainsFunc(selectedVersion.Providers, func(provider extensions.Provider) bool { + return provider.Name == options.Provider + }) + if !hasProvider { + continue + } + } + matches = append(matches, extension) + } + return matches, nil +} + +func (m *fakeExtensionAutoInstallManager) GetInstalled( + options extensions.FilterOptions, +) (*extensions.Extension, error) { + if extension, ok := m.installed[options.Id]; ok { + return extension, nil + } + return nil, fmt.Errorf("extension not installed") +} + +func (m *fakeExtensionAutoInstallManager) Install( + _ context.Context, + extension *extensions.ExtensionMetadata, + _ string, +) (*extensions.ExtensionVersion, error) { + version := &extension.Versions[0] + m.installed[extension.Id] = &extensions.Extension{ + Id: extension.Id, + Version: version.Version, + } + return version, nil +} + +func (m *fakeExtensionAutoInstallManager) ListInstalled() (map[string]*extensions.Extension, error) { + return m.installed, nil +} + +func TestMissingProjectExtensions(t *testing.T) { + versionConstraint := ">=1.0.0-beta.4" + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + { + Id: "azure.ai.projects", + Versions: []extensions.ExtensionVersion{ + { + Version: "2.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "azure.ai.project", + Type: extensions.ServiceTargetProviderType, + }}, + }, + { + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "azure.ai.project", + Type: extensions.ServiceTargetProviderType, + }}, + }, + }, + }, + { + Id: "azure.ai.agents", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "azure.ai.agent", + Type: extensions.ServiceTargetProviderType, + }}, + }}, + }, + { + Id: "microsoft.foundry", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ProvisioningProviderCapability}, + Providers: []extensions.Provider{{ + Name: "microsoft.foundry", + Type: extensions.ProvisioningProviderType, + }}, + }}, + }, + }, + installed: map[string]*extensions.Extension{}, + } + projectConfig := &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{ + "microsoft.foundry": new(versionConstraint), + }, + }, + Services: map[string]*project.ServiceConfig{ + "project": {Host: "azure.ai.project"}, + "agent": {Host: "azure.ai.agent"}, + }, + Infra: provisioning.Options{Provider: "microsoft.foundry"}, + } + + requirements, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + require.NoError(t, err) + require.Len(t, requirements, 3) + assert.Equal(t, "microsoft.foundry", requirements[0].extension.Id) + assert.Equal(t, versionConstraint, requirements[0].versionPreference) + assert.Equal(t, "azure.ai.agents", requirements[1].extension.Id) + assert.Equal(t, "azure.ai.projects", requirements[2].extension.Id) + // Provider resolution leaves the published versions intact so installation selects the current + // release rather than an older one that happens to publish the provider. + require.Len(t, requirements[2].extension.Versions, 2) + selectedVersion, err := extensions.ResolveExtensionVersion(requirements[2].extension, "", nil) + require.NoError(t, err) + assert.Equal(t, "2.0.0", selectedVersion.Version) +} + +func TestMissingProjectExtensionsSkipsInstalledProviderAcrossSources(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + { + Id: "microsoft.azd.demo", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "0.7.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "demo", + Type: extensions.ServiceTargetProviderType, + }}, + }}, + }, + { + Id: "microsoft.azd.demo", + Source: "local", + Versions: []extensions.ExtensionVersion{{ + Version: "0.7.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "demo", + Type: extensions.ServiceTargetProviderType, + }}, + }}, + }, + }, + installed: map[string]*extensions.Extension{ + "microsoft.azd.demo": { + Id: "microsoft.azd.demo", + Version: "0.3.0", + Source: "azd", + }, + }, + } + projectConfig := &project.ProjectConfig{ + Services: map[string]*project.ServiceConfig{ + "demo": {Host: "demo"}, + }, + } + + // The mock has no Select response. The test panics if source selection is prompted. + requirements, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + + require.NoError(t, err) + require.Empty(t, requirements) +} + +func TestMissingProjectExtensionsReusesSourceChoiceAcrossProviders(t *testing.T) { + providerVersion := extensions.ExtensionVersion{ + Version: "0.7.0", + Capabilities: []extensions.CapabilityType{ + extensions.ServiceTargetProviderCapability, + extensions.ProvisioningProviderCapability, + }, + Providers: []extensions.Provider{ + {Name: "demo", Type: extensions.ServiceTargetProviderType}, + {Name: "demo", Type: extensions.ProvisioningProviderType}, + }, + } + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + { + Id: "microsoft.azd.demo", + Source: "azd", + Versions: []extensions.ExtensionVersion{providerVersion}, + }, + { + Id: "microsoft.azd.demo", + Source: "local", + Versions: []extensions.ExtensionVersion{providerVersion}, + }, + }, + installed: map[string]*extensions.Extension{}, + } + projectConfig := &project.ProjectConfig{ + Services: map[string]*project.ServiceConfig{ + "demo": {Host: "demo"}, + }, + Infra: provisioning.Options{Provider: "demo"}, + } + selectCount := 0 + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { + selectCount++ + return true + }).Respond(0) + + requirements, err := missingProjectExtensions(t.Context(), console, manager, projectConfig) + + require.NoError(t, err) + require.Len(t, requirements, 1) + require.Equal(t, "azd", requirements[0].extension.Source) + require.Equal(t, 1, selectCount) +} + +func TestMissingProjectExtensionsSkipsExtensionPackDependencies(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + { + Id: "microsoft.foundry", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []extensions.ExtensionDependency{ + {Id: "microsoft.foundry.bundle"}, + }, + }}, + }, + { + Id: "microsoft.foundry.bundle", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []extensions.ExtensionDependency{ + {Id: "azure.ai.agents"}, + {Id: "azure.ai.projects"}, + }, + }}, + }, + { + Id: "azure.ai.agents", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{ + extensions.ServiceTargetProviderCapability, + extensions.ProvisioningProviderCapability, + }, + Providers: []extensions.Provider{ + {Name: "azure.ai.agent", Type: extensions.ServiceTargetProviderType}, + {Name: "microsoft.foundry", Type: extensions.ProvisioningProviderType}, + }, + }}, + }, + { + Id: "azure.ai.agents", + Source: "local", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{ + extensions.ServiceTargetProviderCapability, + extensions.ProvisioningProviderCapability, + }, + Providers: []extensions.Provider{ + {Name: "azure.ai.agent", Type: extensions.ServiceTargetProviderType}, + {Name: "microsoft.foundry", Type: extensions.ProvisioningProviderType}, + }, + }}, + }, + { + Id: "azure.ai.projects", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "azure.ai.project", + Type: extensions.ServiceTargetProviderType, + }}, + }}, + }, + }, + installed: map[string]*extensions.Extension{}, + } + projectConfig := &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{ + "microsoft.foundry": new("1.0.0"), + }, + }, + Services: map[string]*project.ServiceConfig{ + "agent": {Host: "azure.ai.agent"}, + "project": {Host: "azure.ai.project"}, + }, + Infra: provisioning.Options{Provider: "microsoft.foundry"}, + } + + // The mock has no Select response. The test panics if a pack dependency prompts for a source. + requirements, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + + require.NoError(t, err) + require.Len(t, requirements, 1) + require.Equal(t, "microsoft.foundry", requirements[0].extension.Id) +} + +// A pack pins the version of its dependency, so a later version of that dependency that publishes +// the provider is not installable. Resolution adds no requirement for it and leaves the command to +// report the missing provider. +func TestMissingProjectExtensionsSkipsPinnedDependencyWithoutProvider(t *testing.T) { + newManager := func(installed map[string]*extensions.Extension) *fakeExtensionAutoInstallManager { + return &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + { + Id: "test.pack", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.provider", Version: "1.0.0"}, + }, + }}, + }, + { + Id: "test.provider", + Source: "azd", + Versions: []extensions.ExtensionVersion{ + {Version: "1.0.0"}, + { + Version: "2.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "demo", + Type: extensions.ServiceTargetProviderType, + }}, + }, + }, + }, + }, + installed: installed, + } + } + projectConfig := &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{ + "test.pack": new("1.0.0"), + }, + }, + Services: map[string]*project.ServiceConfig{ + "demo": {Host: "demo"}, + }, + } + + tests := map[string]map[string]*extensions.Extension{ + "dependency resolved from the registry": {}, + "dependency already installed": { + "test.provider": {Id: "test.provider", Version: "1.0.0"}, + }, + } + + for name, installed := range tests { + t.Run(name, func(t *testing.T) { + // The mock has no Select response, so prompting for the provider would fail the test. + requirements, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + newManager(installed), + projectConfig, + ) + + require.NoError(t, err) + require.Len(t, requirements, 1) + assert.Equal(t, "test.pack", requirements[0].extension.Id) + }) + } +} + +func TestMissingProjectExtensionsIgnoresSplitProviderMetadata(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + { + Id: "test.provider", + Source: "azd", + Versions: []extensions.ExtensionVersion{ + { + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + }, + { + Version: "2.0.0", + Providers: []extensions.Provider{{ + Name: "demo", + Type: extensions.ServiceTargetProviderType, + }}, + }, + }, + }, + }, + installed: map[string]*extensions.Extension{}, + } + projectConfig := &project.ProjectConfig{ + Services: map[string]*project.ServiceConfig{ + "demo": {Host: "demo"}, + }, + } + + // The mock has no Select response. No single version provides both the capability and provider. + requirements, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + + require.NoError(t, err) + require.Empty(t, requirements) +} + +func TestMissingProjectExtensionsInstalledIdIsCaseInsensitive(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{ + "microsoft.foundry": { + Id: "microsoft.foundry", + Version: "1.0.0", + }, + }, + } + projectConfig := &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{ + "Microsoft.Foundry": new("1.0.0"), + }, + }, + } + + requirements, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + + require.NoError(t, err) + require.Empty(t, requirements) +} + +func TestMissingProjectExtensionsRejectsInstalledVersionConstraint(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{ + "test.extension": { + Id: "test.extension", + Version: "1.0.0", + }, + }, + } + projectConfig := &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{ + "test.extension": new(">=2.0.0"), + }, + }, + } + + _, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + + require.EqualError( + t, + err, + `installed extension test.extension version 1.0.0 does not satisfy constraint ">=2.0.0"`, + ) +} + +func TestMissingProjectExtensionsRejectsExplicitVersionWithoutProvider(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + { + Id: "test.extension", + Source: "azd", + Versions: []extensions.ExtensionVersion{ + {Version: "1.0.0"}, + { + Version: "2.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "demo", + Type: extensions.ServiceTargetProviderType, + }}, + }, + }, + }, + }, + installed: map[string]*extensions.Extension{}, + } + projectConfig := &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{ + "test.extension": new("1.0.0"), + }, + }, + Services: map[string]*project.ServiceConfig{ + "demo": {Host: "demo"}, + }, + } + + _, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + + require.EqualError( + t, + err, + `required extension test.extension version 1.0.0 does not provide service-target-provider "demo"`, + ) +} + +func TestMissingProjectExtensionsPropagatesProviderLookupError(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{}, + findErr: fmt.Errorf("registry unavailable"), + } + projectConfig := &project.ProjectConfig{ + Services: map[string]*project.ServiceConfig{ + "demo": {Host: "demo"}, + }, + } + + _, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + + require.ErrorContains(t, err, `finding extension for provider "demo": registry unavailable`) +} + +func TestNewRootCmdForExecutionUsesCwd(t *testing.T) { + currentDir := t.TempDir() + targetDir := t.TempDir() + require.NoError(t, project.Save( + t.Context(), + &project.ProjectConfig{Name: "current-project"}, + filepath.Join(currentDir, "azure.yaml"), + )) + require.NoError(t, project.Save( + t.Context(), + &project.ProjectConfig{Name: "target-project"}, + filepath.Join(targetDir, "azure.yaml"), + )) + t.Chdir(currentDir) + + container := ioc.NewNestedContainer(nil) + ioc.RegisterInstance(container, context.WithoutCancel(t.Context())) + globalOpts := &internal.GlobalCommandOptions{Cwd: targetDir} + ioc.RegisterInstance(container, globalOpts) + _, err := newRootCmdForExecution(container, globalOpts) + require.NoError(t, err) + + var projectConfig *project.ProjectConfig + require.NoError(t, container.Resolve(&projectConfig)) + require.Equal(t, "target-project", projectConfig.Name) +} + +// A --cwd that cobra still has to create holds no project, so extension resolution must not fall +// back to the caller's unrelated project. +func TestProjectDirExists(t *testing.T) { + t.Parallel() + + assert.True(t, projectDirExists(""), "an empty cwd means the caller's own directory") + assert.True(t, projectDirExists(t.TempDir())) + assert.False(t, projectDirExists(filepath.Join(t.TempDir(), "not-created-yet"))) +} + +func TestExtensionVersionProvidesProviderMatchesType(t *testing.T) { + version := &extensions.ExtensionVersion{ + Capabilities: []extensions.CapabilityType{ + extensions.ServiceTargetProviderCapability, + extensions.ProvisioningProviderCapability, + }, + Providers: []extensions.Provider{ + {Name: "service", Type: extensions.ServiceTargetProviderType}, + {Name: "infra", Type: extensions.ProvisioningProviderType}, + }, + } + + require.True(t, extensionVersionProvidesProvider( + version, + extensions.ServiceTargetProviderCapability, + "service", + )) + require.True(t, extensionVersionProvidesProvider( + version, + extensions.ProvisioningProviderCapability, + "infra", + )) + require.False(t, extensionVersionProvidesProvider( + version, + extensions.ServiceTargetProviderCapability, + "infra", + )) + require.False(t, extensionVersionProvidesProvider( + version, + extensions.ProvisioningProviderCapability, + "service", + )) +} + +func TestTryAutoInstallExtensionVersionRejectsInstalledVersionConstraint(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{ + "test.extension": { + Id: "test.extension", + Version: "1.0.0", + }, + }, + } + + installed, err := tryAutoInstallExtensionVersion( + t.Context(), + mockinput.NewMockConsole(), + manager, + extensions.ExtensionMetadata{Id: "test.extension"}, + ">=2.0.0", + ) + + require.False(t, installed) + require.EqualError( + t, + err, + `installed extension test.extension version 1.0.0 does not satisfy constraint ">=2.0.0"`, + ) +} + +func TestDisplayAutoInstallError(t *testing.T) { + t.Run("RegularError", func(t *testing.T) { + console := mockinput.NewMockConsole() + + displayAutoInstallError(t.Context(), console, fmt.Errorf("install failed")) + + require.Contains(t, strings.Join(console.Output(), "\n"), "ERROR: install failed") + }) + + t.Run("ErrorWithSuggestion", func(t *testing.T) { + console := mockinput.NewMockConsole() + + displayAutoInstallError(t.Context(), console, &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("install failed"), + Message: "The required extension could not be installed.", + Suggestion: "Check the extension version and retry.", + }) + + output := strings.Join(console.Output(), "\n") + require.Contains(t, output, "ERROR: The required extension could not be installed.") + require.Contains(t, output, "Suggestion: Check the extension version and retry.") + }) +} + +func TestProjectCommandSupportsExtensionAutoInstall(t *testing.T) { + root := &cobra.Command{Use: "azd"} + up := &cobra.Command{Use: "up"} + show := &cobra.Command{Use: "show"} + extension := &cobra.Command{Use: "agent", Annotations: map[string]string{"extension.id": "azure.ai.agents"}} + env := &cobra.Command{Use: "env"} + refresh := &cobra.Command{Use: "refresh"} + infra := &cobra.Command{Use: "infra"} + generate := &cobra.Command{Use: "generate", Aliases: []string{"gen", "synth"}} + env.AddCommand(refresh) + infra.AddCommand(generate) + root.AddCommand(up, show, extension, env, infra) + + assert.True(t, projectCommandSupportsExtensionAutoInstall(up)) + assert.True(t, projectCommandSupportsExtensionAutoInstall(refresh)) + assert.False(t, projectCommandSupportsExtensionAutoInstall(show)) + assert.False(t, projectCommandSupportsExtensionAutoInstall(generate)) + assert.False(t, projectCommandSupportsExtensionAutoInstall(extension)) + assert.False(t, projectCommandSupportsExtensionAutoInstall(env)) +} + func TestFindFirstNonFlagArg(t *testing.T) { t.Parallel() // Mock flags that take values for testing @@ -810,6 +1553,25 @@ func Test_PromptForExtensionChoice_Single(t *testing.T) { assert.Equal(t, "my.ext", result.Id) } +// Choosing between matches requires the user, and guessing could install a different binary than +// they expect, so --no-prompt reports the ambiguity with a way to resolve it instead of failing +// with a bare "prompt required". +func Test_PromptForExtensionChoice_Multiple_NoPrompt(t *testing.T) { + t.Parallel() + console := mockinput.NewMockConsole() + console.SetNoPromptMode(true) + + _, err := promptForExtensionChoice(t.Context(), console, []*extensions.ExtensionMetadata{ + {Id: "my.ext", DisplayName: "My Ext", Source: "local"}, + {Id: "my.ext", DisplayName: "My Ext", Source: "azd"}, + }) + + suggestErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok, "expected an ErrorWithSuggestion") + assert.Contains(t, suggestErr.Error(), "my.ext (azd), my.ext (local)") + assert.Contains(t, suggestErr.Suggestion, "azd extension install --source ") +} + func Test_PromptForExtensionChoice_Multiple_SelectFirst(t *testing.T) { t.Parallel() exts := []*extensions.ExtensionMetadata{ @@ -867,3 +1629,430 @@ func Test_TryAutoInstall_HasSubcommand(t *testing.T) { result := tryAutoInstallForPartialNamespace(t.Context(), container, root, []string{"deploy"}) assert.False(t, result) } + +func TestHelpRequested(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args []string + expected bool + }{ + {name: "no args", args: nil, expected: false}, + {name: "valid flag", args: []string{"--environment", "dev"}, expected: false}, + {name: "help long", args: []string{"--help"}, expected: true}, + {name: "help short", args: []string{"-h"}, expected: true}, + {name: "help after args", args: []string{"api", "--help"}, expected: true}, + {name: "docs", args: []string{"--docs"}, expected: true}, + {name: "positional after terminator", args: []string{"--", "--help"}, expected: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, test.expected, helpRequested(test.args)) + }) + } +} + +// resolveExtensionRequirementDependencies exists so that resolution can tell an extension will be +// pulled in by another rather than prompting for it separately. It is best effort, so a dependency +// it cannot resolve is omitted instead of failing the command. +func TestResolveExtensionRequirementDependencies(t *testing.T) { + t.Parallel() + + newManager := func(packDependencies []extensions.ExtensionDependency) *fakeExtensionAutoInstallManager { + return &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{}, + available: []*extensions.ExtensionMetadata{ + { + Id: "demo.pack", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Dependencies: packDependencies, + }}, + }, + { + Id: "demo.b", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []extensions.ExtensionDependency{{Id: "demo.c"}}, + }}, + }, + { + Id: "demo.c", + Source: "azd", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{ + Name: "demo", + Type: extensions.ServiceTargetProviderType, + }}, + }}, + }, + }, + } + } + + resolve := func(manager *fakeExtensionAutoInstallManager) map[string]resolvedExtensionDependency { + return resolveExtensionRequirementDependencies( + t.Context(), + manager, + map[string]projectExtensionRequirement{ + "demo.pack": {extension: manager.available[0], explicit: true}, + }, + ) + } + + t.Run("resolves transitively", func(t *testing.T) { + t.Parallel() + + resolved := resolve(newManager([]extensions.ExtensionDependency{{Id: "demo.b"}})) + + 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() + + resolved := resolve(newManager([]extensions.ExtensionDependency{ + {Id: "demo.b"}, + {Id: "demo.missing"}, + })) + + assert.Equal(t, []string{"demo.b", "demo.c"}, slices.Sorted(maps.Keys(resolved))) + }) + + t.Run("terminates on a dependency cycle", func(t *testing.T) { + t.Parallel() + + manager := newManager([]extensions.ExtensionDependency{{Id: "demo.b"}}) + // demo.c depends back on demo.b. + manager.available[2].Versions[0].Dependencies = []extensions.ExtensionDependency{{Id: "demo.b"}} + + assert.Equal(t, []string{"demo.b", "demo.c"}, slices.Sorted(maps.Keys(resolve(manager)))) + }) +} + +func TestProviderLookupPartition(t *testing.T) { + t.Parallel() + + metadata := func(id string) *extensions.ExtensionMetadata { + return &extensions.ExtensionMetadata{Id: id} + } + conflict := fmt.Errorf("demo.conflict version 1.0.0 does not provide host %q", "demo.host") + lookup := providerLookup{ + installed: map[string]*extensions.Extension{ + // Installed ids are matched case-insensitively. + "demo.installed": {Id: "demo.installed", Version: "1.0.0"}, + }, + resolvedDependencies: map[string]resolvedExtensionDependency{ + "demo.dependency": {}, + // An extension that is both an unsatisfiable requirement and a pack dependency. + "demo.both": {}, + }, + requirementConflicts: map[string]error{ + "demo.conflict": conflict, + "demo.both": conflict, + }, + } + + candidates := lookup.partition([]*extensions.ExtensionMetadata{ + metadata("demo.available"), + metadata("Demo.Installed"), + metadata("demo.dependency"), + metadata("demo.conflict"), + metadata("demo.both"), + }) + + require.Len(t, candidates.installable, 1) + assert.Equal(t, "demo.available", candidates.installable[0].Id) + // A requirement conflict takes precedence over the same extension being a pack dependency. + assert.Equal(t, []string{"demo.both", "demo.conflict"}, slices.Sorted(maps.Keys(candidates.requirementConflicts))) + + t.Run("reports a requirement conflict", func(t *testing.T) { + t.Parallel() + + require.ErrorIs(t, candidates.conflictError(), conflict) + }) + + t.Run("reports no error when the provider is simply unavailable", func(t *testing.T) { + t.Parallel() + + empty := providerLookup{}.partition(nil) + assert.Empty(t, empty.installable) + assert.NoError(t, empty.conflictError()) + }) +} + +// TestMissingProjectExtensionsSkipsBuiltInProviders asserts that a project using only providers azd +// implements itself never consults the extension registry. +func TestMissingProjectExtensionsSkipsBuiltInProviders(t *testing.T) { + t.Parallel() + + manager := &fakeExtensionAutoInstallManager{ + findErr: errors.New("the extension registry must not be consulted for built-in providers"), + } + + services := map[string]*project.ServiceConfig{} + for _, host := range project.BuiltInServiceTargetKinds() { + services[string(host)] = &project.ServiceConfig{Host: host} + } + + for _, provider := range provisioning.BuiltInProviderKinds() { + t.Run(string(provider), func(t *testing.T) { + t.Parallel() + + projectConfig := &project.ProjectConfig{ + Services: services, + Infra: provisioning.Options{Provider: provider}, + } + + requirements, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + projectConfig, + ) + require.NoError(t, err) + assert.Empty(t, requirements) + }) + } +} + +// TestMissingProjectExtensionsResolvesUnknownProviders is the counterpart to the built-in skip: a +// host or provider azd does not implement must still resolve to an extension. +func TestMissingProjectExtensionsResolvesUnknownProviders(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + projectConfig *project.ProjectConfig + }{ + { + name: "unknown host", + projectConfig: &project.ProjectConfig{ + Services: map[string]*project.ServiceConfig{"api": {Host: "demo.host"}}, + Infra: provisioning.Options{Provider: provisioning.Bicep}, + }, + }, + { + name: "host differing only by case is not the built-in", + projectConfig: &project.ProjectConfig{ + Services: map[string]*project.ServiceConfig{"api": {Host: "ContainerApp"}}, + Infra: provisioning.Options{Provider: provisioning.Bicep}, + }, + }, + { + name: "unknown provisioning provider", + projectConfig: &project.ProjectConfig{ + Services: map[string]*project.ServiceConfig{"api": {Host: project.ContainerAppTarget}}, + Infra: provisioning.Options{Provider: "demo.provider"}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + manager := &fakeExtensionAutoInstallManager{findErr: errors.New("registry unavailable")} + _, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + test.projectConfig, + ) + require.ErrorContains(t, err, "registry unavailable") + }) + } +} + +// A publisher can move a provider to a different extension, leaving the versions that carried it +// behind. Only the extension whose selected version supplies the provider is a candidate, so no +// choice prompt is shown. +func TestMissingProjectExtensionsIgnoresSupersededProviderVersions(t *testing.T) { + foundry := []extensions.Provider{ + {Type: extensions.ProvisioningProviderType, Name: "microsoft.foundry"}, + } + manager := &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{}, + available: []*extensions.ExtensionMetadata{ + { + Id: "azure.ai.agents", + Versions: []extensions.ExtensionVersion{ + { + Version: "1.0.0-beta.6", + Capabilities: []extensions.CapabilityType{extensions.ProvisioningProviderCapability}, + Providers: foundry, + }, + {Version: "1.0.0-beta.7"}, + }, + }, + { + Id: "azure.ai.projects", + Versions: []extensions.ExtensionVersion{ + { + Version: "1.0.0-beta.3", + Capabilities: []extensions.CapabilityType{extensions.ProvisioningProviderCapability}, + Providers: foundry, + }, + }, + }, + }, + } + + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { return true }). + RespondFn(func(options input.ConsoleOptions) (any, error) { + return nil, errors.New("no extension choice should be required") + }) + + requirements, err := missingProjectExtensions(t.Context(), console, manager, &project.ProjectConfig{ + Infra: provisioning.Options{Provider: "microsoft.foundry"}, + }) + require.NoError(t, err) + require.Len(t, requirements, 1) + require.Equal(t, "azure.ai.projects", requirements[0].extension.Id) +} + +// A provider satisfied by an installed extension is not resolved again, so a project whose +// requirements are already met never re-prompts even when other extensions publish the provider. +func TestMissingProjectExtensionsSkipsProviderSuppliedByInstalledExtension(t *testing.T) { + foundry := []extensions.Provider{ + {Type: extensions.ProvisioningProviderType, Name: "microsoft.foundry"}, + } + foundryVersion := extensions.ExtensionVersion{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ProvisioningProviderCapability}, + Providers: foundry, + } + manager := &fakeExtensionAutoInstallManager{ + available: []*extensions.ExtensionMetadata{ + {Id: "azure.ai.agents", Versions: []extensions.ExtensionVersion{foundryVersion}}, + {Id: "azure.ai.projects", Versions: []extensions.ExtensionVersion{foundryVersion}}, + }, + // A satisfied provider must resolve without contacting a registry, so an unreachable + // source cannot fail a project whose extensions are already installed. + findErr: errors.New("the extension registry must not be consulted for installed providers"), + installed: map[string]*extensions.Extension{ + "azure.ai.projects": { + Id: "azure.ai.projects", + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ProvisioningProviderCapability}, + Providers: foundry, + }, + }, + } + + console := mockinput.NewMockConsole() + console.WhenSelect(func(options input.ConsoleOptions) bool { return true }). + RespondFn(func(options input.ConsoleOptions) (any, error) { + return nil, errors.New("no extension choice should be required") + }) + + requirements, err := missingProjectExtensions(t.Context(), console, manager, &project.ProjectConfig{ + Infra: provisioning.Options{Provider: "microsoft.foundry"}, + }) + require.NoError(t, err) + require.Empty(t, requirements) +} + +func TestFilterExtensionsForProvider(t *testing.T) { + provisioningDemo := extensions.ExtensionVersion{ + Version: "2.0.0", + Capabilities: []extensions.CapabilityType{extensions.ProvisioningProviderCapability}, + Providers: []extensions.Provider{{Type: extensions.ProvisioningProviderType, Name: "demo"}}, + } + serviceTargetDemo := extensions.ExtensionVersion{ + Version: "1.0.0", + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{{Type: extensions.ServiceTargetProviderType, Name: "demo"}}, + } + + current := &extensions.ExtensionMetadata{ + Id: "current", + Versions: []extensions.ExtensionVersion{provisioningDemo, serviceTargetDemo}, + } + superseded := &extensions.ExtensionMetadata{ + Id: "superseded", + Versions: []extensions.ExtensionVersion{serviceTargetDemo, {Version: "3.0.0"}}, + } + + t.Run("keeps extensions whose selected version provides the provider", func(t *testing.T) { + filtered := filterExtensionsForProvider( + []*extensions.ExtensionMetadata{current, superseded}, + extensions.ProvisioningProviderCapability, + "demo", + ) + require.Len(t, filtered, 1) + assert.Equal(t, "current", filtered[0].Id) + assert.Len(t, filtered[0].Versions, 2, "published versions should not be narrowed") + }) + + t.Run("ignores versions other than the selected one", func(t *testing.T) { + filtered := filterExtensionsForProvider( + []*extensions.ExtensionMetadata{current}, + extensions.ServiceTargetProviderCapability, + "demo", + ) + assert.Empty(t, filtered, "only the superseded 1.0.0 publishes the service target") + }) + + t.Run("requires the provider type to match the capability", func(t *testing.T) { + filtered := filterExtensionsForProvider( + []*extensions.ExtensionMetadata{{ + Id: "provisioning.only", + Versions: []extensions.ExtensionVersion{provisioningDemo}, + }}, + extensions.ServiceTargetProviderCapability, + "demo", + ) + assert.Empty(t, filtered) + }) +} + +// Both errors are deterministic and user-fixable, so they carry a suggestion that +// displayAutoInstallError renders. The suggested commands must be ones azd accepts: --version +// rejects constraints, so the constraint conflict must not tell the user to pass one. +func TestProjectExtensionErrorsCarrySuggestions(t *testing.T) { + t.Run("installed version conflicts with constraint", func(t *testing.T) { + err := validateInstalledExtensionVersion( + &extensions.Extension{Id: "microsoft.foundry", Version: "0.5.0"}, + ">=1.0.0", + ) + + suggestErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok, "expected an ErrorWithSuggestion") + assert.Contains(t, suggestErr.Error(), "does not satisfy constraint") + assert.Contains(t, suggestErr.Suggestion, "azd extension upgrade microsoft.foundry") + assert.NotContains(t, suggestErr.Suggestion, "--version >=1.0.0") + }) + + t.Run("required extension is not published", func(t *testing.T) { + manager := &fakeExtensionAutoInstallManager{installed: map[string]*extensions.Extension{}} + _, err := missingProjectExtensions( + t.Context(), + mockinput.NewMockConsole(), + manager, + &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{"does.not.exist": nil}, + }, + }, + ) + + suggestErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok, "expected an ErrorWithSuggestion") + assert.Contains(t, suggestErr.Error(), "required extension does.not.exist not found") + assert.Contains(t, suggestErr.Suggestion, "azd extension source list") + }) +} diff --git a/cli/azd/cmd/project_extension_auto_install.go b/cli/azd/cmd/project_extension_auto_install.go new file mode 100644 index 00000000000..bb30dca74d3 --- /dev/null +++ b/cli/azd/cmd/project_extension_auto_install.go @@ -0,0 +1,699 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "cmp" + "context" + "errors" + "fmt" + "log" + "maps" + "slices" + "strings" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" + "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/spf13/cobra" +) + +type projectExtensionRequirement struct { + extension *extensions.ExtensionMetadata + versionPreference string + explicit bool +} + +type resolvedExtensionDependency struct { + capabilities []extensions.CapabilityType + providers []extensions.Provider +} + +// extensionRef identifies an extension within a registry source, normalized for case-insensitive +// comparison. In-flight extensions are tracked by source and id because the same id can be +// published by more than one source, while resolved selections are keyed by id alone to match how +// installation reuses whatever is already installed. +type extensionRef struct { + source string + id string +} + +func newExtensionRef(source string, id string) extensionRef { + return extensionRef{source: strings.ToLower(source), id: strings.ToLower(id)} +} + +// projectCommandSupportsExtensionAutoInstall reports whether a command resolves a provider that an +// extension can supply. Commands that only read azure.yaml do not, so they never install anything. +func projectCommandSupportsExtensionAutoInstall(cmd *cobra.Command) bool { + if _, isExtensionCommand := cmd.Annotations["extension.id"]; isExtensionCommand { + return false + } + + path := getCommandPath(cmd) + if len(path) == 0 { + return false + } + + switch path[0] { + case "up", "provision", "deploy", "package", "restore", "down": + return true + case "env": + return len(path) > 1 && path[1] == "refresh" + default: + return false + } +} + +// helpRequested reports whether args ask cobra to render help or reference documentation instead of +// running the command. rootCmd.Find resolves a command path without applying cobra's help +// short-circuit, so extension resolution would otherwise install extensions for `azd up --help`. +func helpRequested(args []string) bool { + for _, arg := range args { + if arg == "--" { + // Everything after this is positional. + return false + } + if arg == "-h" || arg == "--help" || arg == "--docs" { + return true + } + } + + return false +} + +// providerLookup carries the state needed to decide which of the extensions publishing a provider +// can actually be installed to supply it. The maps are keyed by lowercase extension id, matching +// the case-insensitive comparison the extension manager applies to extension ids. +type providerLookup struct { + installed map[string]*extensions.Extension + // resolvedDependencies holds extensions that installation will pull in as pack dependencies. + resolvedDependencies map[string]resolvedExtensionDependency + // requirementConflicts holds explicit requirements whose constrained version cannot supply + // the provider, mapped to the conflict to report. + requirementConflicts map[string]error +} + +// providerCandidates partitions the extensions publishing a provider into those that can be +// installed and the conflicts worth reporting when none can. +type providerCandidates struct { + installable []*extensions.ExtensionMetadata + requirementConflicts map[string]error +} + +// partition splits matches into installable candidates and the conflicts that explain the rest. +// Extensions that are already installed, or that installation will pull in as a dependency, are +// dropped silently: installing them cannot supply a provider their selected version does not have. +func (l providerLookup) partition(matches []*extensions.ExtensionMetadata) providerCandidates { + candidates := providerCandidates{requirementConflicts: map[string]error{}} + + for _, extension := range matches { + extensionId := strings.ToLower(extension.Id) + + if conflict, hasConflict := l.requirementConflicts[extensionId]; hasConflict { + candidates.requirementConflicts[extension.Id] = conflict + continue + } + if _, isDependency := l.resolvedDependencies[extensionId]; isDependency { + continue + } + if _, isInstalled := installedExtensionById(l.installed, extension.Id); isInstalled { + continue + } + + candidates.installable = append(candidates.installable, extension) + } + + return candidates +} + +// conflictError reports why no candidate can supply the provider, or nil when the provider is +// simply unavailable. Conflicts are reported by lowest extension id so the message is stable. +func (c providerCandidates) conflictError() error { + if len(c.requirementConflicts) == 0 { + return nil + } + + extensionId := slices.Sorted(maps.Keys(c.requirementConflicts))[0] + return c.requirementConflicts[extensionId] +} + +// findExtensionForProvider selects an installable extension that supplies the given provider, +// prompting when more than one is available. It returns a nil extension and a nil error when +// nothing can be installed to supply the provider, and an error when a required extension is +// pinned to a version that cannot. +func findExtensionForProvider( + ctx context.Context, + console input.Console, + extensionManager extensionAutoInstallManager, + lookup providerLookup, + capability extensions.CapabilityType, + provider string, +) (*extensions.ExtensionMetadata, error) { + matches, err := extensionManager.FindExtensions(ctx, &extensions.FilterOptions{ + Capability: capability, + Provider: provider, + }) + if err != nil { + return nil, fmt.Errorf("finding extension for provider %q: %w", provider, err) + } + + candidates := lookup.partition(filterExtensionsForProvider(matches, capability, provider)) + if len(candidates.installable) == 0 { + return nil, candidates.conflictError() + } + + return promptForExtensionChoice(ctx, console, candidates.installable) +} + +func uninstalledExtensionMatches( + matches []*extensions.ExtensionMetadata, + installed map[string]*extensions.Extension, +) []*extensions.ExtensionMetadata { + return slices.DeleteFunc(slices.Clone(matches), func(extension *extensions.ExtensionMetadata) bool { + _, isInstalled := installedExtensionById(installed, extension.Id) + return isInstalled + }) +} + +func installedExtensionById( + installed map[string]*extensions.Extension, + extensionId string, +) (*extensions.Extension, bool) { + for installedId, extension := range installed { + if strings.EqualFold(installedId, extensionId) { + return extension, true + } + } + return nil, false +} + +// versionSatisfiesConstraint reports whether an already selected extension version satisfies a +// declared semver constraint. An empty constraint matches any version. +func versionSatisfiesConstraint(extensionId string, version string, constraint string) bool { + if constraint == "" { + return true + } + + metadata := &extensions.ExtensionMetadata{ + Id: extensionId, + Versions: []extensions.ExtensionVersion{{Version: version}}, + } + _, err := extensions.ResolveExtensionVersion(metadata, constraint, nil) + return err == nil +} + +func validateInstalledExtensionVersion( + installed *extensions.Extension, + versionPreference string, +) error { + if versionSatisfiesConstraint(installed.Id, installed.Version, versionPreference) { + return nil + } + + // --version only accepts an exact version, so the constraint cannot be passed through. + return &internal.ErrorWithSuggestion{ + Err: fmt.Errorf( + "installed extension %s version %s does not satisfy constraint %q", + installed.Id, + installed.Version, + versionPreference, + ), + Suggestion: fmt.Sprintf( + "Run 'azd extension upgrade %s' to move to the latest version, or "+ + "'azd extension install %s --version ' to select an exact version "+ + "that satisfies %q.", + installed.Id, + installed.Id, + versionPreference, + ), + } +} + +// resolveExtensionRequirementDependencies walks the dependencies of the required extensions so +// resolution can tell that installing one of them will pull in another. It is best effort: a +// dependency it cannot resolve is left out, which at worst prompts for an extension installation +// would have supplied anyway, and installation reports any genuine problem itself. +func resolveExtensionRequirementDependencies( + ctx context.Context, + extensionManager extensionAutoInstallManager, + requirements map[string]projectExtensionRequirement, +) map[string]resolvedExtensionDependency { + resolved := map[string]resolvedExtensionDependency{} + resolving := map[extensionRef]struct{}{} + + for _, requirement := range sortedProjectExtensionRequirements(requirements) { + version, err := extensions.ResolveExtensionVersion( + requirement.extension, + requirement.versionPreference, + nil, + ) + if err != nil { + continue + } + + key := newExtensionRef(requirement.extension.Source, requirement.extension.Id) + resolving[key] = struct{}{} + resolveExtensionDependencies( + ctx, + extensionManager, + requirement.extension, + version.Dependencies, + resolved, + resolving, + ) + delete(resolving, key) + } + + return resolved +} + +func resolveExtensionDependencies( + ctx context.Context, + extensionManager extensionAutoInstallManager, + parent *extensions.ExtensionMetadata, + dependencies []extensions.ExtensionDependency, + resolved map[string]resolvedExtensionDependency, + resolving map[extensionRef]struct{}, +) { + for _, dependency := range dependencies { + key := newExtensionRef(parent.Source, dependency.Id) + // Guards against a cycle in the registry metadata. + if _, isResolving := resolving[key]; isResolving { + continue + } + dependencyId := strings.ToLower(dependency.Id) + if _, isResolved := resolved[dependencyId]; isResolved { + continue + } + + // Installation reuses a compatible installed dependency instead of the registry selection. + installedDependency, err := extensionManager.GetInstalled(extensions.FilterOptions{Id: dependency.Id}) + if err == nil && installedDependency != nil && + versionSatisfiesConstraint(dependency.Id, installedDependency.Version, dependency.Version) { + resolved[dependencyId] = resolvedExtensionDependency{ + capabilities: installedDependency.Capabilities, + providers: installedDependency.Providers, + } + 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 { + continue + } + + dependencyExtension := matches[0] + version, err := extensions.ResolveExtensionVersion(dependencyExtension, dependency.Version, nil) + if err != nil { + continue + } + resolved[dependencyId] = resolvedExtensionDependency{ + capabilities: version.Capabilities, + providers: version.Providers, + } + + resolving[key] = struct{}{} + resolveExtensionDependencies( + ctx, + extensionManager, + dependencyExtension, + version.Dependencies, + resolved, + resolving, + ) + delete(resolving, key) + } +} + +// installedProvidesProvider reports whether an installed extension already supplies the provider, +// in which case nothing needs to be installed for it. +func installedProvidesProvider( + installed map[string]*extensions.Extension, + capability extensions.CapabilityType, + providerName string, +) bool { + for extension := range maps.Values(installed) { + if extensionProvidesProvider(extension.Capabilities, extension.Providers, capability, providerName) { + return true + } + } + + return false +} + +func extensionProvidesProvider( + capabilities []extensions.CapabilityType, + providers []extensions.Provider, + capability extensions.CapabilityType, + providerName string, +) bool { + expectedType, hasProviderType := providerTypeForCapability(capability) + if !hasProviderType || !slices.Contains(capabilities, capability) { + return false + } + + return slices.ContainsFunc(providers, func(provider extensions.Provider) bool { + return provider.Type == expectedType && strings.EqualFold(provider.Name, providerName) + }) +} + +func providerTypeForCapability(capability extensions.CapabilityType) (extensions.ProviderType, bool) { + switch capability { + case extensions.ServiceTargetProviderCapability: + return extensions.ServiceTargetProviderType, true + case extensions.ProvisioningProviderCapability: + return extensions.ProvisioningProviderType, true + default: + return "", false + } +} + +// providerIsBuiltIn reports whether azd itself implements the named provider. Core registers these +// unconditionally, so no extension is ever required to supply them and the registry need not be +// consulted, which would otherwise let an unreachable source fail an ordinary project. Matching is +// case sensitive because the runtime resolves providers by their exact name. +func providerIsBuiltIn(capability extensions.CapabilityType, provider string) bool { + switch capability { + case extensions.ServiceTargetProviderCapability: + return slices.Contains(project.BuiltInServiceTargetKinds(), project.ServiceTargetKind(provider)) + case extensions.ProvisioningProviderCapability: + return slices.Contains(provisioning.BuiltInProviderKinds(), provisioning.ProviderKind(provider)) + default: + return false + } +} + +// filterExtensionsForProvider keeps the extensions whose selected version supplies the provider. +// Earlier versions are ignored: a publisher that drops a provider in a later release has superseded +// the versions carrying it, so installing one would be a downgrade. +func filterExtensionsForProvider( + matches []*extensions.ExtensionMetadata, + capability extensions.CapabilityType, + providerName string, +) []*extensions.ExtensionMetadata { + filtered := make([]*extensions.ExtensionMetadata, 0, len(matches)) + for _, extension := range matches { + selectedVersion, err := extensions.ResolveExtensionVersion(extension, "", nil) + if err != nil { + continue + } + if extensionVersionProvidesProvider(selectedVersion, capability, providerName) { + filtered = append(filtered, extension) + } + } + return filtered +} + +func extensionVersionProvidesProvider( + version *extensions.ExtensionVersion, + capability extensions.CapabilityType, + providerName string, +) bool { + return extensionProvidesProvider(version.Capabilities, version.Providers, capability, providerName) +} + +func resolvedDependencyProvidesProvider( + dependency resolvedExtensionDependency, + capability extensions.CapabilityType, + providerName string, +) bool { + return extensionProvidesProvider( + dependency.capabilities, + dependency.providers, + capability, + providerName, + ) +} + +// extensionForProvider narrows an extension to every version supplying the provider. Unlike +// filterExtensionsForProvider this ignores which version would be selected, so callers can tell an +// extension that cannot supply the provider from one whose selected version happens not to. +func extensionForProvider( + extension *extensions.ExtensionMetadata, + capability extensions.CapabilityType, + providerName string, +) *extensions.ExtensionMetadata { + filtered := *extension + filtered.Versions = slices.DeleteFunc(slices.Clone(extension.Versions), func(version extensions.ExtensionVersion) bool { + return !extensionVersionProvidesProvider(&version, capability, providerName) + }) + return &filtered +} + +func missingProjectExtensions( + ctx context.Context, + console input.Console, + extensionManager extensionAutoInstallManager, + projectConfig *project.ProjectConfig, +) ([]projectExtensionRequirement, error) { + installed, err := extensionManager.ListInstalled() + if err != nil { + return nil, fmt.Errorf("listing installed extensions: %w", err) + } + + requirements := map[string]projectExtensionRequirement{} + if projectConfig.RequiredVersions != nil { + for _, extensionId := range slices.Sorted(maps.Keys(projectConfig.RequiredVersions.Extensions)) { + versionPreference := "" + if constraint := projectConfig.RequiredVersions.Extensions[extensionId]; constraint != nil { + versionPreference = *constraint + } + if installedExtension, isInstalled := installedExtensionById(installed, extensionId); isInstalled { + if err := validateInstalledExtensionVersion(installedExtension, versionPreference); err != nil { + return nil, err + } + continue + } + + matches, err := extensionManager.FindExtensions(ctx, &extensions.FilterOptions{ + Id: extensionId, + Version: versionPreference, + }) + if err != nil { + return nil, fmt.Errorf("finding required extension %s: %w", extensionId, err) + } + if len(matches) == 0 { + return nil, &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("required extension %s not found", extensionId), + Suggestion: fmt.Sprintf( + "Check the extension id and version constraint declared under "+ + "requiredVersions.extensions in azure.yaml, then run "+ + "'azd extension source list' to confirm a configured source publishes %s.", + extensionId, + ), + } + } + + extension, err := promptForExtensionChoice(ctx, console, matches) + if err != nil { + return nil, fmt.Errorf("selecting required extension %s: %w", extensionId, err) + } + + requirements[extension.Id] = projectExtensionRequirement{ + extension: extension, + versionPreference: versionPreference, + explicit: true, + } + } + } + + addProvider := func(capability extensions.CapabilityType, provider string) error { + if provider == "" || providerIsBuiltIn(capability, provider) || + installedProvidesProvider(installed, capability, provider) { + return nil + } + + requirementConflicts := map[string]error{} + for _, extensionId := range slices.Sorted(maps.Keys(requirements)) { + requirement := requirements[extensionId] + selectedVersion, err := extensions.ResolveExtensionVersion( + requirement.extension, + requirement.versionPreference, + nil, + ) + if err != nil { + return fmt.Errorf("resolving required extension %s: %w", extensionId, err) + } + if extensionVersionProvidesProvider(selectedVersion, capability, provider) { + return nil + } + + if len(extensionForProvider(requirement.extension, capability, provider).Versions) == 0 { + continue + } + requirementConflicts[strings.ToLower(extensionId)] = fmt.Errorf( + "required extension %s version %s does not provide %s %q", + extensionId, + selectedVersion.Version, + capability, + provider, + ) + } + + resolvedDependencies := resolveExtensionRequirementDependencies(ctx, extensionManager, requirements) + for dependency := range maps.Values(resolvedDependencies) { + if resolvedDependencyProvidesProvider(dependency, capability, provider) { + return nil + } + } + + extension, err := findExtensionForProvider( + ctx, + console, + extensionManager, + providerLookup{ + installed: installed, + resolvedDependencies: resolvedDependencies, + requirementConflicts: requirementConflicts, + }, + capability, + provider, + ) + if err != nil || extension == nil { + return err + } + if requirement, alreadyRequired := requirements[extension.Id]; alreadyRequired { + requirement.extension = extensionForProvider(requirement.extension, capability, provider) + if len(requirement.extension.Versions) == 0 { + return fmt.Errorf( + "required extension %s does not provide %s %q", + extension.Id, + capability, + provider, + ) + } + requirements[extension.Id] = requirement + } else { + requirements[extension.Id] = projectExtensionRequirement{ + extension: extension, + } + } + return nil + } + + for _, serviceName := range slices.Sorted(maps.Keys(projectConfig.Services)) { + if err := addProvider( + extensions.ServiceTargetProviderCapability, + string(projectConfig.Services[serviceName].Host), + ); err != nil { + return nil, err + } + } + + for _, infra := range projectConfig.Infra.GetLayers() { + if err := addProvider(extensions.ProvisioningProviderCapability, string(infra.Provider)); err != nil { + return nil, err + } + } + + return sortedProjectExtensionRequirements(requirements), nil +} + +func sortedProjectExtensionRequirements( + requirements map[string]projectExtensionRequirement, +) []projectExtensionRequirement { + result := slices.Collect(maps.Values(requirements)) + slices.SortFunc(result, func(a, b projectExtensionRequirement) int { + if a.explicit != b.explicit { + if a.explicit { + return -1 + } + return 1 + } + return cmp.Compare(a.extension.Id, b.extension.Id) + }) + + return result +} + +// projectExtensionResult reports what resolution did, so the caller knows whether to rebuild the +// command tree and whether the legacy unsupported-host fallback still has work to do. +type projectExtensionResult struct { + // handled reports that resolution owned the project's provider requirements, so the legacy + // unsupported-host fallback must not prompt for them again. + handled bool + // installed reports that an extension was installed, so the command tree is out of date. + installed bool +} + +func tryAutoInstallProjectExtensions( + ctx context.Context, + rootContainer *ioc.NestedContainer, + foundCmd *cobra.Command, + args []string, +) (projectExtensionResult, error) { + if !projectCommandSupportsExtensionAutoInstall(foundCmd) { + return projectExtensionResult{}, nil + } + + if helpRequested(args) { + return projectExtensionResult{}, nil + } + + var projectConfig *project.ProjectConfig + if err := rootContainer.Resolve(&projectConfig); err != nil { + log.Printf("skipping project extension auto-install: %v", err) + return projectExtensionResult{}, nil + } + + var extensionManager *extensions.Manager + if err := rootContainer.Resolve(&extensionManager); err != nil { + return projectExtensionResult{}, fmt.Errorf("resolving extension manager: %w", err) + } + var console input.Console + if err := rootContainer.Resolve(&console); err != nil { + return projectExtensionResult{}, fmt.Errorf("resolving console: %w", err) + } + + requirements, err := missingProjectExtensions(ctx, console, extensionManager, projectConfig) + if err != nil { + return projectExtensionResult{}, err + } + if len(requirements) == 0 { + return projectExtensionResult{}, nil + } + + installedAny := false + for _, requirement := range requirements { + installed, err := tryAutoInstallExtensionVersion( + ctx, + console, + extensionManager, + *requirement.extension, + requirement.versionPreference, + ) + if err != nil { + return projectExtensionResult{handled: true, installed: installedAny}, err + } + installedAny = installedAny || installed + } + + return projectExtensionResult{handled: true, installed: installedAny}, nil +} + +func displayAutoInstallError(ctx context.Context, console input.Console, err error) { + if suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err); ok { + console.Message(ctx, "") + console.MessageUxItem(ctx, &ux.ErrorWithSuggestion{ + Err: suggestionErr.Err, + Message: suggestionErr.Message, + Suggestion: suggestionErr.Suggestion, + Links: suggestionErr.Links, + }) + return + } + + console.Message(ctx, output.WithErrorFormat("\nERROR: %s", err.Error())) +} diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index 4343912d62f..3fbb0c1a673 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -1,676 +1,700 @@ -# Extension Resolution and Versioning - -This document describes how the Azure Developer CLI (`azd`) resolves extensions from configured sources, selects versions using semantic versioning constraints, checks compatibility with the running `azd` version, and installs artifacts for the current platform. It also provides semantic versioning guidance for extension authors and troubleshooting steps for common issues. - -## Extension Sources - -### Source Types - -Extension sources are manifests that describe the extensions available for installation. Each source has a name, a type, and a location. `azd` supports two configurable source types: - -| Type | Location | Description | -|------|----------|-------------| -| `url` | HTTP/HTTPS endpoint | Remote JSON manifest fetched over the network. | -| `file` | Local filesystem path | Local JSON file, useful for development and offline scenarios. | - -In addition, extensions installed from a [self-contained bundle](#self-contained-bundles) are tagged with a reserved `bundle` source. `bundle` is not a configurable source type and never appears in `azd extension source list` — it simply marks an extension that has no live registry to track updates against. Such extensions are listed with their `bundle` source in `azd extension list` and are skipped by `azd extension upgrade`. The name `bundle` is reserved, so it cannot be used as a user-configured source name. - -Sources are configured in `~/.azd/config.json`. You can manage them with the following commands: - -```bash -# List configured sources -azd extension source list - -# Add a URL-based source -azd extension source add -n my-source -t url -l "https://example.com/extensions.json" - -# Add a file-based source -azd extension source add -n local-dev -t file -l "/path/to/registry.json" - -# Remove a source -azd extension source remove my-source -``` - -### Default Source - -When no sources are configured, `azd` automatically creates a default source: - -| Property | Value | -|----------|-------| -| Name | `azd` | -| Type | `url` | -| Location | `https://aka.ms/azd/extensions/registry` | - -If you remove this source, you can re-add it manually: - -```bash -azd extension source add -n azd -t url -l "https://aka.ms/azd/extensions/registry" -``` - -### Source Ordering - -Sources are sorted **alphabetically by name** — not by insertion order. This means a source named `"alpha"` is always consulted before `"beta"`, regardless of when each was added. - -## Resolution Algorithm - -When you run a command like `azd extension install `, `azd` resolves the extension through the following steps: - -### 1. Load and Sort Sources - -All configured sources are loaded from `~/.azd/config.json` and sorted alphabetically by name. If no sources exist, the default `"azd"` source is created automatically. - -### 2. Search Across Sources - -`azd` searches every source for extensions matching the requested ID. There is **no failover** behavior — if a source is unreachable (network error, missing file), the operation fails immediately with an error. `azd` does not skip unreachable sources and continue to the next one. - -### 3. Handle Conflicts - -If the same extension ID exists in **two or more sources**, `azd` handles the conflict differently depending on the mode: - -- **Interactive mode** — `azd` prompts the user to choose which source to install from. -- **Non-interactive mode** (`--no-prompt` or CI environments) — `azd` returns an error: - - ``` - The extension was found in multiple sources. - ``` - -To avoid the prompt or error, specify the source explicitly: - -```bash -azd extension install --source -``` - -There is no priority or merge logic between sources — the `--source` flag is the only way to disambiguate programmatically. - -## Version Constraints - -### Constraint Syntax - -Version constraints differ between the CLI and `azure.yaml`: - -#### CLI `--version` flag - -The `azd extension install --version` flag accepts only an **exact version string** or **`latest`** (the default when omitted): - -```bash -# Install an exact version -azd extension install my.extension --version 1.0.0 - -# Install the latest version (default) -azd extension install my.extension --version latest -azd extension install my.extension -``` - -#### `azure.yaml` `requiredVersions.extensions` - -The `requiredVersions.extensions` section in `azure.yaml` supports the full semver constraint syntax provided by the [Masterminds semver](https://github.com/Masterminds/semver) library: - -| Syntax | Example | Matches | -|--------|---------|---------| -| Exact | `1.0.0` | Only `1.0.0` | -| Caret | `^1.2.3` | `>=1.2.3, <2.0.0` | -| Tilde | `~1.2.3` | `>=1.2.3, <1.3.0` | -| Range | `>=1.0.0,<2.0.0` | Explicit lower and upper bounds | -| Latest | `latest` or omitted | Highest available version | - -```yaml -requiredVersions: - extensions: - azure.ai.agents: ">=1.0.0" - microsoft.azd.demo: "latest" - my.custom.extension: "^2.0.0" -``` - -### Version Selection - -When multiple versions satisfy the constraint, `azd` selects the **highest** matching version. For example, if versions `1.0.0`, `1.1.0`, and `1.2.0` are available and the constraint is `^1.0.0`, version `1.2.0` is installed. - -## azd Version Compatibility - -### `requiredAzdVersion` Field - -Each extension version can declare a minimum `azd` version via the `requiredAzdVersion` field in its metadata. This field accepts any semver constraint expression (for example, `">= 1.24.0"`). - -When `azd` resolves versions, it filters them into compatible and incompatible sets based on the running `azd` version: - -- **Compatible**: the running `azd` version satisfies the `requiredAzdVersion` constraint. -- **Incompatible**: the running `azd` version does not satisfy the constraint. - -### Behavior - -- `azd` filters out all versions whose `requiredAzdVersion` constraint is not satisfied by the running `azd` version, then selects the **highest remaining compatible version** that also matches the user's version constraint. -- If a **newer incompatible version** exists beyond the selected version, `azd` shows a **warning** suggesting the user upgrade `azd`. -- If **no compatible versions** remain after filtering, the install **fails** with guidance to upgrade `azd`. The install also fails if the user explicitly requests a specific version that is incompatible. -- If `requiredAzdVersion` is **empty or cannot be parsed**, the version is treated as compatible (fail-open). This ensures that extensions without the field remain installable. - -## Install Flow - -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. -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`. -6. **Extract** — Unpack the artifact based on its file type: - - `.zip` — extracted as a ZIP archive - - `.tar.gz` — extracted as a gzipped tar archive - - Other — treated as a raw binary and copied directly -7. **Set permissions** — On Unix-like systems, set the executable permission on the extension binary. -8. **Update configuration** — Record the installed extension and version in `~/.azd/config.json` under the `extension.installed` section. - -### Re-installing over an existing extension - -`azd extension install ` keys off the extension **id**, so installing an id that is already present is handled based on whether the **source** is changing and on the version relationship. `--force` bypasses all of these guards. - -When the source is **not** changing (same source as the installed extension): - -- **Same version** — a no-op; the install is skipped. -- **Newer version** — upgraded in place. -- **Older version** — a downgrade; `azd` **prompts for confirmation** before replacing the newer install with an older one. Declining skips the install. In `--no-prompt` mode `azd` skips with guidance to pass `--force`, and `--force` proceeds without prompting. - -When the source **is** changing (for example installing a bundle build over a registry build, or vice versa), the artifacts may differ, so `azd` does not silently proceed, no-op, or block a downgrade. Instead it **prompts for confirmation** before replacing the installed extension. The prompt states the version transition explicitly — *Reinstall*, *Upgrade to ``*, or *Downgrade to ``* — and the target source. Declining skips the install; confirming reinstalls and re-points the extension to the new source. In `--no-prompt` mode `azd` skips with guidance to pass `--force`, and `--force` proceeds without prompting. - -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). - -## Self-Contained Bundles - -A **self-contained bundle** is a single portable `.zip` that contains a well-known `registry.json` plus the extension artifacts it references. It lets you share a one-off build (for example, a PR build or an internal extension) without hosting a registry or making the artifacts reachable over the network — the recipient runs a single command to install everything from the file. - -### Producing a bundle - -Extension authors create a bundle with the `azd x` developer extension: - -```bash -azd x pack --bundle -``` - -This builds the platform artifacts and emits a single `_.zip` whose root contains a `registry.json` and an `artifacts/` directory. The registry's artifact URLs are **relative** (for example, `artifacts/my-ext-linux-amd64.tar.gz`), and each artifact carries an embedded `sha256` checksum. Extension packs (which have no binaries of their own) are supported as registry-only bundles. - -### Installing a bundle - -Consumers install a bundle by passing its path to `azd extension install`: - -```bash -azd extension install ./my-ext_1.0.0.zip -``` - -The install flow treats the bundle as an **installer, not a registry** — nothing about the bundle persists as a configured source once installation finishes: - -1. **Extract** the bundle into a temporary directory. -2. **Register an ephemeral source** that reads the extracted `registry.json` and rewrites each relative artifact URL to an absolute path anchored inside the extracted directory. This is what allows the standard install flow — including checksum validation — to resolve the bundled artifacts unchanged. Relative paths that escape the bundle directory are rejected. The source name is transient and is never surfaced to the user. -3. **Install** the bundled extension through the normal install path. Bundles are produced per extension by `azd x pack --bundle`, so a bundle declares a single extension. -4. **Clean up** — once the extension is installed, `azd` re-points it to the reserved `bundle` source, removes the ephemeral source, and deletes the temporary extraction directory. The only durable state left behind is the installed extension itself (its binary under `~/.azd/extensions//` and its `extension.installed` record). - -### Lifecycle of a bundle-installed extension - -Because a bundle does not register a lasting source, a bundle-installed extension is tracked under the reserved `bundle` source: - -- `azd extension list` shows it with its `bundle` source and a normal `✓ Up to date` status. It has no "latest" version to compare against, so no update is ever reported. -- `azd extension upgrade` skips bundle-installed extensions with a note that they were installed from a self-contained bundle. -- `azd extension source list` does **not** show an entry for the bundle — there is no leftover source to clean up. - -To update a bundle-installed extension, install a newer bundle: - -```bash -azd extension install ./my-ext_2.0.0.zip -``` - -To switch a bundle-installed extension back to a registry-tracked one, install it explicitly from a configured source: - -```bash -azd extension install --source -``` - -### Trust model - -Bundles run arbitrary extension binaries on your machine. The embedded `sha256` checksums protect the **integrity** of each artifact within the bundle (they guarantee the bytes were not altered after packing), but bundles are **not signed** — there is no verification of the publisher's identity. Only install bundles you obtained from a source you trust. - -## Declaring Extensions in `azure.yaml` - -Projects can declare required extensions and version constraints in `azure.yaml`. When `azd init` runs, it reads this configuration and installs each extension automatically. - -### Format - -```yaml -requiredVersions: - extensions: - azure.ai.agents: ">=1.0.0" - microsoft.azd.demo: "latest" - my.custom.extension: "^2.0.0" -``` - -Each entry maps an extension ID to a version constraint string. The same constraint syntax described in [Version Constraints](#version-constraints) applies here. - -### Behavior - -- When `azd init` runs, it reads the `requiredVersions.extensions` map and installs each extension with the specified constraint. -- If the constraint value is `null` or empty, `"latest"` is used (the highest available version is installed). -- If an extension is already installed (any version), `azd init` **skips it** — it does not check whether the installed version satisfies the configured constraint. -- `azd init` does **not** apply `requiredAzdVersion` compatibility filtering (unlike `azd extension install`). - -> **Note:** These are known limitations in the current implementation and may be addressed in future versions: -> -> - `azd init` does not check whether an already-installed extension satisfies the configured version constraint. -> - `azd init` does not apply `requiredAzdVersion` compatibility filtering. -> - Dependency (transitive) installation calls `Install()` directly without passing through `requiredAzdVersion` compatibility filtering, so a dependency may be installed even if its `requiredAzdVersion` is not satisfied by the running `azd` version. - -## Caching - -### Cache Location - -`azd` caches source manifests locally to avoid fetching them on every operation: - -``` -~/.azd/cache/extensions/.json -``` - -Each source has its own cache file. The filename is derived from the source name by lowercasing it and replacing any characters outside `[a-zA-Z0-9._-]` with `_`. For example, a source named `"My Source!"` would be cached as `my_source_.json`. - -### Default TTL - -The cache has a default time-to-live (TTL) of **4 hours**. After the TTL expires, the next operation that needs the source manifest triggers a fresh HTTP fetch. - -### Overriding the TTL - -Set the `AZD_EXTENSION_CACHE_TTL` environment variable to override the default TTL. The value uses Go `time.Duration` format: - -```bash -# Disable caching entirely (always fetch fresh) -export AZD_EXTENSION_CACHE_TTL=0s - -# Set a 30-minute TTL -export AZD_EXTENSION_CACHE_TTL=30m - -# Set a 1-hour TTL -export AZD_EXTENSION_CACHE_TTL=1h -``` - -To clear the cache manually, delete the files in `~/.azd/cache/extensions/`. - -## Semantic Versioning Guidance - -Extension authors should follow [Semantic Versioning 2.0.0](https://semver.org/) when publishing new versions. Consistent versioning enables consumers to use constraint expressions (caret `^`, tilde `~`, ranges) and trust that updates within a range will not break their workflow. - -### Major Version Bump (Breaking Changes) - -Increment the **major** version when you make incompatible changes. Examples: - -- Remove or rename a CLI command or subcommand -- Remove or rename a CLI flag -- Change an output schema in a breaking way (remove fields, change types) -- Change a required input format incompatibly -- Drop support for an OS or architecture -- Remove a declared capability - -### Minor Version Bump (New Features) - -Increment the **minor** version when you add functionality in a backward-compatible manner. Examples: - -- Add a new CLI command or subcommand -- Add a new CLI flag to an existing command -- Add new fields to an output schema -- Add a new lifecycle event handler -- Add support for a new OS or architecture -- Add a new capability - -### Patch Version Bump (Fixes) - -Increment the **patch** version for backward-compatible bug fixes. Examples: - -- Fix a bug in existing behavior -- Improve performance without changing the API -- Update documentation -- Update dependencies with no user-facing API change - -### Pre-release Versions - -Use pre-release suffixes for testing before a stable release: - -``` -2.0.0-alpha.1 -2.0.0-beta.1 -2.0.0-rc.1 -``` - -When `latest` is specified (or the version is omitted), `azd` selects the **highest semantic version**, which can be a pre-release if it sorts higher than the latest stable version. For semver range constraints in `azure.yaml`, pre-release versions are generally excluded unless the constraint itself explicitly includes a pre-release identifier. - -## Troubleshooting - -### Common Errors - -| Error | Cause | Fix | -|-------|-------|-----| -| *"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. | -| 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 - -1. **Check configured sources:** - - ```bash - azd extension source list - ``` - -2. **Inspect available versions for an extension:** - - ```bash - azd extension show - ``` - -3. **Force a fresh source fetch:** - - ```bash - export AZD_EXTENSION_CACHE_TTL=0s - azd extension install - ``` - -4. **Install from a specific source:** - - ```bash - azd extension install --source - ``` - -## Dev/Experimental Extension Registry - -The dev (experimental) registry is a separate extension source for bleeding-edge, pre-release, and community-contributed extensions that have not yet been promoted to the official `azd` registry. It lives alongside the main registry in the `azure-dev` repository and is served via a dedicated aka.ms link. While `azd` and `dev` are the official source names, the extension source system supports adding custom sources with any name via `azd extension source add`. - -| Property | Main Registry | Dev Registry | -|----------|---------------|--------------| -| URL | `https://aka.ms/azd/extensions/registry` | `https://aka.ms/azd/extensions/registry/dev` | -| Source file | `cli/azd/extensions/registry.json` | `cli/azd/extensions/registry.dev.json` | -| Source name | `azd` (built-in default) | `dev` (official dev registry) | -| Signed binaries | Yes | **No** | -| Support | Covered by Azure support | **Not covered** | - -### Experimental vs. Main Registry Criteria - -The following criteria determine whether an extension belongs in the dev registry or the main registry: - -| Criteria | Main (azd) | Experimental (dev) | -|----------|------------|-------------------| -| **Binary signing** | Signed builds | Unsigned builds | -| **Stability** | Stable releases | Preview, alpha, beta, or pre-release versions | -| **Vetting** | Vetted by the azd team; meets quality bar | Community contributions not yet reviewed; internal experiments | -| **API surface** | Follows [semver guidance](#semantic-versioning-guidance) | May change between versions without notice | -| **Availability** | Maintained with deprecation process | May be removed without notice | - -An extension can exist in **both** registries simultaneously. For example, the main registry may contain version `1.2.0` while the dev registry contains `2.0.0-beta.1`. This allows authors to publish stable releases through the main registry while testing upcoming versions through the dev registry. - -### Stability Expectations - -> [!CAUTION] -> Extensions in the dev registry come with **no stability guarantees**. - -When using experimental extensions, expect: - -- **Breaking changes** between versions without prior notice -- **Removal** of extensions from the registry without deprecation -- **No Azure support** — experimental extensions are not covered by any Azure support plan -- **Unsigned binaries** — your system may show security warnings when running them -- **Rough edges** — incomplete documentation, missing error messages, and untested edge cases - -The dev registry is intended for early adopters, extension authors testing pre-release builds, and internal teams validating extensions before official publication. - -### Adding the Dev Registry - -The dev registry is **not** configured by default. To opt in: - -```bash -# Add the dev registry as a source named "dev" -azd extension source add -n dev -t url -l "https://aka.ms/azd/extensions/registry/dev" -``` - -Verify it was added: - -```bash -azd extension source list -``` - -You should see both `azd` (the built-in default) and `dev` listed. - -To remove the dev registry later: - -```bash -azd extension source remove dev -``` - -### Installing Experimental Extensions - -Once the dev source is configured, you can browse and install experimental extensions: - -```bash -# List all available extensions (from all configured sources) -azd extension list --available - -# Install an extension from the dev registry explicitly -azd extension install my.experimental.extension --source dev - -# Install a specific pre-release version -azd extension install my.experimental.extension --version 2.0.0-beta.1 --source dev -``` - -If an extension exists in both the `azd` and `dev` sources and you do not specify `--source`, `azd` will prompt you to choose (in interactive mode) or return an error (in non-interactive mode). See [Handle Conflicts](#3-handle-conflicts) for details. - -### Upgrade and Dev→Main Promotion - -When you run `azd extension upgrade`, extensions installed from the dev registry are evaluated for **one-way promotion** to the main registry. Promotion occurs automatically when: - -1. **The extension is no longer in the dev registry** — it was removed from `registry.dev.json` after being promoted to `registry.json`. -2. **The main registry has a newer version** — the latest version in the main registry is strictly greater than the latest version in the dev registry. - -When promotion happens, the extension's stored source switches from `dev` to `azd`. This is a one-way operation — extensions are never demoted from the main registry back to the dev registry. - -> [!NOTE] -> If the main and dev registries have the **same** latest version, the extension stays on its current (dev) source. Equal versions are source-sticky. - -The upgrade priority chain is: - -1. **Explicit `--source` flag** — always wins if provided -2. **Stored source** — the source the extension was originally installed from -3. **Main registry fallback** — `azd` checks the main registry for promotion opportunities - -Promotion events are tracked via `ext.promote` telemetry. Upgrade events (regardless of promotion) are tracked via `ext.upgrade`. - -#### Example: Dev→Main Promotion in Action - -```bash -# Install from dev registry -azd extension install my.extension --source dev - -# Later, the extension graduates to the main registry with a newer version. -# Running upgrade will auto-promote: -azd extension upgrade my.extension -# Output: my.extension upgraded from 1.0.0-beta.2 (dev) → 1.0.0 (azd) -``` - -### Submitting an Extension to the Dev Registry - -To publish an extension to the dev registry, submit a pull request to the [azure-dev](https://github.com/Azure/azure-dev) repository that adds your extension entry to `cli/azd/extensions/registry.dev.json`. - -#### Requirements - -Your extension entry must: - -1. **Pass schema validation** — The entry must conform to the [registry schema](https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/registry.schema.json). CI validates this automatically via `ext-registry-ci.yml`. -2. **Include all required metadata:** - - `id` — Unique identifier (lowercase, alphanumeric, dots, and hyphens: `^[a-z0-9-.]+$`) - - `namespace` — Classification namespace - - `displayName` — Human-readable name - - `description` — Brief description of the extension's purpose - - `versions` — At least one version entry with `version`, `capabilities`, `usage`, `examples`, and `artifacts` -3. **Include checksums for all artifacts** — Each artifact must declare a `checksum` with an `algorithm` (`sha256` or `sha512`) and `value`. -4. **Provide platform artifacts** — At minimum, include artifacts for `linux/amd64`, `darwin/amd64`, `darwin/arm64`, and `windows/amd64`. - -#### Example Entry - -```json -{ - "id": "my.experimental.extension", - "namespace": "my", - "displayName": "My Experimental Extension", - "description": "An experimental extension for testing new features.", - "versions": [ - { - "version": "0.1.0", - "capabilities": ["custom-commands"], - "usage": "azd my-command [options]", - "examples": [ - { - "name": "basic-usage", - "description": "Run my-command with a flag.", - "usage": "azd my-command --flag value" - } - ], - "artifacts": { - "linux/amd64": { - "url": "https://github.com/my-org/my-ext/releases/download/v0.1.0/my-ext-linux-amd64.tar.gz", - "checksum": { - "algorithm": "sha256", - "value": "abc123..." - } - }, - "darwin/amd64": { - "url": "https://github.com/my-org/my-ext/releases/download/v0.1.0/my-ext-darwin-amd64.tar.gz", - "checksum": { - "algorithm": "sha256", - "value": "bcd234..." - } - }, - "darwin/arm64": { - "url": "https://github.com/my-org/my-ext/releases/download/v0.1.0/my-ext-darwin-arm64.tar.gz", - "checksum": { - "algorithm": "sha256", - "value": "def456..." - } - }, - "windows/amd64": { - "url": "https://github.com/my-org/my-ext/releases/download/v0.1.0/my-ext-windows-amd64.zip", - "checksum": { - "algorithm": "sha256", - "value": "789ghi..." - } - } - } - } - ] -} -``` - -#### Review Process - -- A maintainer will review your PR for schema compliance, metadata completeness, and artifact accessibility. -- There is no formal quality gate for the dev registry — it is intentionally lower-friction than the main registry. -- Extensions that mature and meet the [main registry criteria](#experimental-vs-main-registry-criteria) can be promoted via a separate PR to `registry.json`. - -### Troubleshooting Multi-Registry Scenarios - -#### Extension exists in both registries - -When the same extension ID is present in both `azd` and `dev`: - -- **Interactive mode** — `azd` prompts you to choose which source to install from. -- **Non-interactive mode** — `azd` fails with `"found in multiple sources"`. -- **Resolution** — Use `--source` to specify explicitly: - - ```bash - azd extension install my.extension --source dev - azd extension install my.extension --source azd - ``` - -#### Source ordering affects resolution - -Sources are sorted **alphabetically by name**. With the default naming (`azd` and `dev`), `azd` is consulted first because `"azd"` sorts before `"dev"`. If you name your dev source `"aaa-dev"`, it would be consulted first. The name only affects the order in which sources are searched — it does not affect upgrade or promotion behavior. - -#### Stale cache after registry updates - -If a recently published extension does not appear, the local cache may not have expired yet: - -```bash -# Force a fresh fetch by setting TTL to zero -export AZD_EXTENSION_CACHE_TTL=0s # Linux/macOS -$env:AZD_EXTENSION_CACHE_TTL = "0s" # PowerShell - -# Then retry -azd extension list --available -``` - -Or clear the cache manually: - -```bash -# Linux/macOS -rm -rf ~/.azd/cache/extensions/ - -# PowerShell -Remove-Item -Recurse -Force "$env:USERPROFILE\.azd\cache\extensions\" -``` - -#### Unreachable dev source blocks all operations - -If the dev registry URL is unreachable (network issue, DNS failure), operations that load sources will **fail** rather than skip the unreachable source. To unblock yourself, remove the dev source temporarily: - -```bash -azd extension source remove dev -``` - -## Nightly Extension Registry - -The nightly registry contains **automatically built, always-latest** development snapshots of first-party extensions. Each scheduled pipeline run rebuilds an extension from `main`, signs the Windows and macOS binaries, uploads them to an always-latest storage folder, and updates a single entry in the nightly registry. Installing a nightly always gives you the most recent nightly build available at that time. - -| Property | Main Registry | Nightly Registry | -|----------|---------------|------------------| -| URL | `https://aka.ms/azd/extensions/registry` | `https://raw.githubusercontent.com/Azure/azure-dev/nightly/cli/azd/extensions/registry.nightly.json` | -| Source file | `cli/azd/extensions/registry.json` (on `main`) | `cli/azd/extensions/registry.nightly.json` (on the `nightly` branch) | -| Source name | `azd` (built-in default) | `nightly` (opt-in) | -| Version shape | `1.2.3` | `1.2.3-nightly.` (or `1.2.3-preview.nightly.`) | -| Signed binaries | Yes | Windows/macOS signed; Linux unsigned | -| History retained | Yes | No — only the latest nightly per extension | -| Support | Covered by Azure support | **Not covered** | - -> [!CAUTION] -> Nightly extensions are built from `main` and come with **no stability guarantees**. Only the current nightly version is retained - older nightly versions are not installable. - -### Adding the Nightly Registry - -The nightly registry must be added, manually. To opt in: - -```bash -# Add the nightly registry as a source named "nightly" -azd extension source add -n nightly -t url -l "https://raw.githubusercontent.com/Azure/azure-dev/nightly/cli/azd/extensions/registry.nightly.json" -``` - -Then, to install a nightly-built extension: - -```bash -azd extension install --source nightly -``` - -To remove the nightly registry later: - -```bash -azd extension source remove nightly -``` - -### Upgrade and Nightly→Main Promotion - -Nightly versions use semver prerelease labels, so the standard `azd extension upgrade` flow works: - -- A newer nightly (higher build id, or a higher base version) supersedes an older one, so `azd extension upgrade` pulls the latest nightly. -- When the extension ships a **stable** release whose base version matches your nightly (for example stable `1.2.3` versus `1.2.3-nightly.200`), the stable release outranks the nightly and you are **automatically promoted** to the `azd` registry on your next upgrade. - -> [!NOTE] -> If your nightly was built from a **prerelease** base (for example `1.2.3-preview.nightly.60`), it sorts **above** the matching stable prerelease `1.2.3-preview`. In that case you are not promoted until the stable registry advances to a higher base version. This is expected semver precedence behavior. - -## Related Documentation - -| Document | Description | -|----------|-------------| -| [Extension Framework](./extension-framework.md) | Architecture overview, source and extension management commands, developing extensions. | -| [Extension SDK Reference](./extension-sdk-reference.md) | Complete API reference for the `azdext` SDK helpers. | -| [Extension End-to-End Walkthrough](./extension-e2e-walkthrough.md) | Build a complete extension from scratch. | -| [Extension Style Guide](./extensions-style-guide.md) | Design guidelines for command integration, flags, and discoverability. | +# Extension Resolution and Versioning + +This document describes how the Azure Developer CLI (`azd`) resolves extensions from configured sources, selects versions using semantic versioning constraints, checks compatibility with the running `azd` version, and installs artifacts for the current platform. It also provides semantic versioning guidance for extension authors and troubleshooting steps for common issues. + +## Extension Sources + +### Source Types + +Extension sources are manifests that describe the extensions available for installation. Each source has a name, a type, and a location. `azd` supports two configurable source types: + +| Type | Location | Description | +|------|----------|-------------| +| `url` | HTTP/HTTPS endpoint | Remote JSON manifest fetched over the network. | +| `file` | Local filesystem path | Local JSON file, useful for development and offline scenarios. | + +In addition, extensions installed from a [self-contained bundle](#self-contained-bundles) are tagged with a reserved `bundle` source. `bundle` is not a configurable source type and never appears in `azd extension source list` — it simply marks an extension that has no live registry to track updates against. Such extensions are listed with their `bundle` source in `azd extension list` and are skipped by `azd extension upgrade`. The name `bundle` is reserved, so it cannot be used as a user-configured source name. + +Sources are configured in `~/.azd/config.json`. You can manage them with the following commands: + +```bash +# List configured sources +azd extension source list + +# Add a URL-based source +azd extension source add -n my-source -t url -l "https://example.com/extensions.json" + +# Add a file-based source +azd extension source add -n local-dev -t file -l "/path/to/registry.json" + +# Remove a source +azd extension source remove my-source +``` + +### Default Source + +When no sources are configured, `azd` automatically creates a default source: + +| Property | Value | +|----------|-------| +| Name | `azd` | +| Type | `url` | +| Location | `https://aka.ms/azd/extensions/registry` | + +If you remove this source, you can re-add it manually: + +```bash +azd extension source add -n azd -t url -l "https://aka.ms/azd/extensions/registry" +``` + +### Source Ordering + +Sources are sorted **alphabetically by name** — not by insertion order. This means a source named `"alpha"` is always consulted before `"beta"`, regardless of when each was added. + +## Resolution Algorithm + +When you run a command like `azd extension install `, `azd` resolves the extension through the following steps: + +### 1. Load and Sort Sources + +All configured sources are loaded from `~/.azd/config.json` and sorted alphabetically by name. If no sources exist, the default `"azd"` source is created automatically. + +### 2. Search Across Sources + +`azd` searches every source for extensions matching the requested ID. There is **no failover** behavior — if a source is unreachable (network error, missing file), the operation fails immediately with an error. `azd` does not skip unreachable sources and continue to the next one. + +### 3. Handle Conflicts + +If the same extension ID exists in **two or more sources**, `azd` handles the conflict differently depending on the mode: + +- **Interactive mode** — `azd` prompts the user to choose which source to install from. +- **Non-interactive mode** (`--no-prompt` or CI environments) — `azd` returns an error: + + ``` + The extension was found in multiple sources. + ``` + +To avoid the prompt or error, specify the source explicitly: + +```bash +azd extension install --source +``` + +There is no priority or merge logic between sources — the `--source` flag is the only way to disambiguate programmatically. + +## Version Constraints + +### Constraint Syntax + +Version constraints differ between the CLI and `azure.yaml`: + +#### CLI `--version` flag + +The `azd extension install --version` flag accepts only an **exact version string** or **`latest`** (the default when omitted): + +```bash +# Install an exact version +azd extension install my.extension --version 1.0.0 + +# Install the latest version (default) +azd extension install my.extension --version latest +azd extension install my.extension +``` + +#### `azure.yaml` `requiredVersions.extensions` + +The `requiredVersions.extensions` section in `azure.yaml` supports the full semver constraint syntax provided by the [Masterminds semver](https://github.com/Masterminds/semver) library: + +| Syntax | Example | Matches | +|--------|---------|---------| +| Exact | `1.0.0` | Only `1.0.0` | +| Caret | `^1.2.3` | `>=1.2.3, <2.0.0` | +| Tilde | `~1.2.3` | `>=1.2.3, <1.3.0` | +| Range | `>=1.0.0,<2.0.0` | Explicit lower and upper bounds | +| Latest | `latest` or omitted | Highest available version | + +```yaml +requiredVersions: + extensions: + azure.ai.agents: ">=1.0.0" + microsoft.azd.demo: "latest" + my.custom.extension: "^2.0.0" +``` + +### Version Selection + +When multiple versions satisfy the constraint, `azd` selects the **highest** matching version. For example, if versions `1.0.0`, `1.1.0`, and `1.2.0` are available and the constraint is `^1.0.0`, version `1.2.0` is installed. + +## azd Version Compatibility + +### `requiredAzdVersion` Field + +Each extension version can declare a minimum `azd` version via the `requiredAzdVersion` field in its metadata. This field accepts any semver constraint expression (for example, `">= 1.24.0"`). + +When `azd` resolves versions, it filters them into compatible and incompatible sets based on the running `azd` version: + +- **Compatible**: the running `azd` version satisfies the `requiredAzdVersion` constraint. +- **Incompatible**: the running `azd` version does not satisfy the constraint. + +### Behavior + +- `azd` filters out all versions whose `requiredAzdVersion` constraint is not satisfied by the running `azd` version, then selects the **highest remaining compatible version** that also matches the user's version constraint. +- If a **newer incompatible version** exists beyond the selected version, `azd` shows a **warning** suggesting the user upgrade `azd`. +- If **no compatible versions** remain after filtering, the install **fails** with guidance to upgrade `azd`. The install also fails if the user explicitly requests a specific version that is incompatible. +- If `requiredAzdVersion` is **empty or cannot be parsed**, the version is treated as compatible (fail-open). This ensures that extensions without the field remain installable. + +## Install Flow + +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. +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`. +6. **Extract** — Unpack the artifact based on its file type: + - `.zip` — extracted as a ZIP archive + - `.tar.gz` — extracted as a gzipped tar archive + - Other — treated as a raw binary and copied directly +7. **Set permissions** — On Unix-like systems, set the executable permission on the extension binary. +8. **Update configuration** — Record the installed extension and version in `~/.azd/config.json` under the `extension.installed` section. + +### Re-installing over an existing extension + +`azd extension install ` keys off the extension **id**, so installing an id that is already present is handled based on whether the **source** is changing and on the version relationship. `--force` bypasses all of these guards. + +When the source is **not** changing (same source as the installed extension): + +- **Same version** — a no-op; the install is skipped. +- **Newer version** — upgraded in place. +- **Older version** — a downgrade; `azd` **prompts for confirmation** before replacing the newer install with an older one. Declining skips the install. In `--no-prompt` mode `azd` skips with guidance to pass `--force`, and `--force` proceeds without prompting. + +When the source **is** changing (for example installing a bundle build over a registry build, or vice versa), the artifacts may differ, so `azd` does not silently proceed, no-op, or block a downgrade. Instead it **prompts for confirmation** before replacing the installed extension. The prompt states the version transition explicitly — *Reinstall*, *Upgrade to ``*, or *Downgrade to ``* — and the target source. Declining skips the install; confirming reinstalls and re-points the extension to the new source. In `--no-prompt` mode `azd` skips with guidance to pass `--force`, and `--force` proceeds without prompting. + +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). + +## Self-Contained Bundles + +A **self-contained bundle** is a single portable `.zip` that contains a well-known `registry.json` plus the extension artifacts it references. It lets you share a one-off build (for example, a PR build or an internal extension) without hosting a registry or making the artifacts reachable over the network — the recipient runs a single command to install everything from the file. + +### Producing a bundle + +Extension authors create a bundle with the `azd x` developer extension: + +```bash +azd x pack --bundle +``` + +This builds the platform artifacts and emits a single `_.zip` whose root contains a `registry.json` and an `artifacts/` directory. The registry's artifact URLs are **relative** (for example, `artifacts/my-ext-linux-amd64.tar.gz`), and each artifact carries an embedded `sha256` checksum. Extension packs (which have no binaries of their own) are supported as registry-only bundles. + +### Installing a bundle + +Consumers install a bundle by passing its path to `azd extension install`: + +```bash +azd extension install ./my-ext_1.0.0.zip +``` + +The install flow treats the bundle as an **installer, not a registry** — nothing about the bundle persists as a configured source once installation finishes: + +1. **Extract** the bundle into a temporary directory. +2. **Register an ephemeral source** that reads the extracted `registry.json` and rewrites each relative artifact URL to an absolute path anchored inside the extracted directory. This is what allows the standard install flow — including checksum validation — to resolve the bundled artifacts unchanged. Relative paths that escape the bundle directory are rejected. The source name is transient and is never surfaced to the user. +3. **Install** the bundled extension through the normal install path. Bundles are produced per extension by `azd x pack --bundle`, so a bundle declares a single extension. +4. **Clean up** — once the extension is installed, `azd` re-points it to the reserved `bundle` source, removes the ephemeral source, and deletes the temporary extraction directory. The only durable state left behind is the installed extension itself (its binary under `~/.azd/extensions//` and its `extension.installed` record). + +### Lifecycle of a bundle-installed extension + +Because a bundle does not register a lasting source, a bundle-installed extension is tracked under the reserved `bundle` source: + +- `azd extension list` shows it with its `bundle` source and a normal `✓ Up to date` status. It has no "latest" version to compare against, so no update is ever reported. +- `azd extension upgrade` skips bundle-installed extensions with a note that they were installed from a self-contained bundle. +- `azd extension source list` does **not** show an entry for the bundle — there is no leftover source to clean up. + +To update a bundle-installed extension, install a newer bundle: + +```bash +azd extension install ./my-ext_2.0.0.zip +``` + +To switch a bundle-installed extension back to a registry-tracked one, install it explicitly from a configured source: + +```bash +azd extension install --source +``` + +### Trust model + +Bundles run arbitrary extension binaries on your machine. The embedded `sha256` checksums protect the **integrity** of each artifact within the bundle (they guarantee the bytes were not altered after packing), but bundles are **not signed** — there is no verification of the publisher's identity. Only install bundles you obtained from a source you trust. + +## Declaring Extensions in `azure.yaml` + +Projects can declare required extensions and version constraints in `azure.yaml`. `azd init` reads this configuration and installs each extension automatically, and the project commands listed below re-check it before they run. + +### Format + +```yaml +requiredVersions: + extensions: + azure.ai.agents: ">=1.0.0" + microsoft.azd.demo: "latest" + my.custom.extension: "^2.0.0" +``` + +Each entry maps an extension ID to a version constraint string. The same constraint syntax described in [Version Constraints](#version-constraints) applies here. + +### Behavior during `azd init` + +- When `azd init` runs, it reads the `requiredVersions.extensions` map and installs each extension with the specified constraint. +- If the constraint value is `null` or empty, `"latest"` is used (the highest available version is installed). +- If an extension is already installed (any version), `azd init` **skips it** — it does not check whether the installed version satisfies the configured constraint. +- `azd init` does **not** apply `requiredAzdVersion` compatibility filtering (unlike `azd extension install`). + +> **Note:** These are known limitations in the current implementation and may be addressed in future versions: +> +> - `azd init` does not check whether an already-installed extension satisfies the configured version constraint. +> - `azd init` does not apply `requiredAzdVersion` compatibility filtering. +> - Dependency (transitive) installation calls `Install()` directly without passing through `requiredAzdVersion` compatibility filtering, so a dependency may be installed even if its `requiredAzdVersion` is not satisfied by the running `azd` version. + +### Behavior during project commands + +Cloning a repository or editing `azure.yaml` by hand skips `azd init`, so the project commands that resolve a provider check for extensions again before running: `up`, `provision`, `deploy`, `package`, `restore`, `down` and `env refresh`. + +Resolution during project commands differs from `azd init` in two ways: + +- It **does** check installed extensions against the configured constraint, and fails with the conflicting constraint rather than proceeding with an unsatisfying version. +- It resolves not only `requiredVersions.extensions` but also the providers the project implies (see below). + +Resolution only prompts for extensions that are genuinely missing, and it is skipped when the command renders help instead of running, such as `azd up --help`. Each install is confirmed before it happens; `--no-prompt` accepts that confirmation, matching how the rest of `azd` treats declared configuration in scripts and CI. + +### Inferred extension requirements + +Beyond the explicit `requiredVersions.extensions` list, project commands infer requirements from the providers the project uses: + +- Each `services..host` value must be supplied by an extension declaring the `service-target-provider` capability for that host. +- Each `infra.provider` value (including entries under `infra.layers`) must be supplied by an extension declaring the `provisioning-provider` capability for that provider. + +Providers that `azd` implements itself are never resolved through an extension and never contact a registry: the built-in hosts (`appservice`, `containerapp`, `function`, `staticwebapp`, `aks`, `ai.endpoint`) and the built-in provisioning providers (`bicep`, `terraform`). + +When several extensions publish the same provider, `azd` prompts for the one to install. When an extension is already selected by `requiredVersions.extensions`, or is pulled in as a dependency of one, that version is used instead of installing another extension. If a version selected that way cannot supply the provider, `azd` reports the conflicting constraint rather than installing a second extension that the first would override. + +An extension only qualifies when the version `azd` would select publishes the provider. Older versions are not considered: a publisher that moves a provider to a different extension supersedes the versions that carried it, so `azd` never installs an earlier version to satisfy a provider. A provider that an installed extension already supplies is not resolved again. + +## Caching + +### Cache Location + +`azd` caches source manifests locally to avoid fetching them on every operation: + +``` +~/.azd/cache/extensions/.json +``` + +Each source has its own cache file. The filename is derived from the source name by lowercasing it and replacing any characters outside `[a-zA-Z0-9._-]` with `_`. For example, a source named `"My Source!"` would be cached as `my_source_.json`. + +### Default TTL + +The cache has a default time-to-live (TTL) of **4 hours**. After the TTL expires, the next operation that needs the source manifest triggers a fresh HTTP fetch. + +### Overriding the TTL + +Set the `AZD_EXTENSION_CACHE_TTL` environment variable to override the default TTL. The value uses Go `time.Duration` format: + +```bash +# Disable caching entirely (always fetch fresh) +export AZD_EXTENSION_CACHE_TTL=0s + +# Set a 30-minute TTL +export AZD_EXTENSION_CACHE_TTL=30m + +# Set a 1-hour TTL +export AZD_EXTENSION_CACHE_TTL=1h +``` + +To clear the cache manually, delete the files in `~/.azd/cache/extensions/`. + +## Semantic Versioning Guidance + +Extension authors should follow [Semantic Versioning 2.0.0](https://semver.org/) when publishing new versions. Consistent versioning enables consumers to use constraint expressions (caret `^`, tilde `~`, ranges) and trust that updates within a range will not break their workflow. + +### Major Version Bump (Breaking Changes) + +Increment the **major** version when you make incompatible changes. Examples: + +- Remove or rename a CLI command or subcommand +- Remove or rename a CLI flag +- Change an output schema in a breaking way (remove fields, change types) +- Change a required input format incompatibly +- Drop support for an OS or architecture +- Remove a declared capability + +### Minor Version Bump (New Features) + +Increment the **minor** version when you add functionality in a backward-compatible manner. Examples: + +- Add a new CLI command or subcommand +- Add a new CLI flag to an existing command +- Add new fields to an output schema +- Add a new lifecycle event handler +- Add support for a new OS or architecture +- Add a new capability + +### Patch Version Bump (Fixes) + +Increment the **patch** version for backward-compatible bug fixes. Examples: + +- Fix a bug in existing behavior +- Improve performance without changing the API +- Update documentation +- Update dependencies with no user-facing API change + +### Pre-release Versions + +Use pre-release suffixes for testing before a stable release: + +``` +2.0.0-alpha.1 +2.0.0-beta.1 +2.0.0-rc.1 +``` + +When `latest` is specified (or the version is omitted), `azd` selects the **highest semantic version**, which can be a pre-release if it sorts higher than the latest stable version. For semver range constraints in `azure.yaml`, pre-release versions are generally excluded unless the constraint itself explicitly includes a pre-release identifier. + +## Troubleshooting + +### Common Errors + +| Error | Cause | Fix | +|-------|-------|-----| +| *"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. | +| 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 + +1. **Check configured sources:** + + ```bash + azd extension source list + ``` + +2. **Inspect available versions for an extension:** + + ```bash + azd extension show + ``` + +3. **Force a fresh source fetch:** + + ```bash + export AZD_EXTENSION_CACHE_TTL=0s + azd extension install + ``` + +4. **Install from a specific source:** + + ```bash + azd extension install --source + ``` + +## Dev/Experimental Extension Registry + +The dev (experimental) registry is a separate extension source for bleeding-edge, pre-release, and community-contributed extensions that have not yet been promoted to the official `azd` registry. It lives alongside the main registry in the `azure-dev` repository and is served via a dedicated aka.ms link. While `azd` and `dev` are the official source names, the extension source system supports adding custom sources with any name via `azd extension source add`. + +| Property | Main Registry | Dev Registry | +|----------|---------------|--------------| +| URL | `https://aka.ms/azd/extensions/registry` | `https://aka.ms/azd/extensions/registry/dev` | +| Source file | `cli/azd/extensions/registry.json` | `cli/azd/extensions/registry.dev.json` | +| Source name | `azd` (built-in default) | `dev` (official dev registry) | +| Signed binaries | Yes | **No** | +| Support | Covered by Azure support | **Not covered** | + +### Experimental vs. Main Registry Criteria + +The following criteria determine whether an extension belongs in the dev registry or the main registry: + +| Criteria | Main (azd) | Experimental (dev) | +|----------|------------|-------------------| +| **Binary signing** | Signed builds | Unsigned builds | +| **Stability** | Stable releases | Preview, alpha, beta, or pre-release versions | +| **Vetting** | Vetted by the azd team; meets quality bar | Community contributions not yet reviewed; internal experiments | +| **API surface** | Follows [semver guidance](#semantic-versioning-guidance) | May change between versions without notice | +| **Availability** | Maintained with deprecation process | May be removed without notice | + +An extension can exist in **both** registries simultaneously. For example, the main registry may contain version `1.2.0` while the dev registry contains `2.0.0-beta.1`. This allows authors to publish stable releases through the main registry while testing upcoming versions through the dev registry. + +### Stability Expectations + +> [!CAUTION] +> Extensions in the dev registry come with **no stability guarantees**. + +When using experimental extensions, expect: + +- **Breaking changes** between versions without prior notice +- **Removal** of extensions from the registry without deprecation +- **No Azure support** — experimental extensions are not covered by any Azure support plan +- **Unsigned binaries** — your system may show security warnings when running them +- **Rough edges** — incomplete documentation, missing error messages, and untested edge cases + +The dev registry is intended for early adopters, extension authors testing pre-release builds, and internal teams validating extensions before official publication. + +### Adding the Dev Registry + +The dev registry is **not** configured by default. To opt in: + +```bash +# Add the dev registry as a source named "dev" +azd extension source add -n dev -t url -l "https://aka.ms/azd/extensions/registry/dev" +``` + +Verify it was added: + +```bash +azd extension source list +``` + +You should see both `azd` (the built-in default) and `dev` listed. + +To remove the dev registry later: + +```bash +azd extension source remove dev +``` + +### Installing Experimental Extensions + +Once the dev source is configured, you can browse and install experimental extensions: + +```bash +# List all available extensions (from all configured sources) +azd extension list --available + +# Install an extension from the dev registry explicitly +azd extension install my.experimental.extension --source dev + +# Install a specific pre-release version +azd extension install my.experimental.extension --version 2.0.0-beta.1 --source dev +``` + +If an extension exists in both the `azd` and `dev` sources and you do not specify `--source`, `azd` will prompt you to choose (in interactive mode) or return an error (in non-interactive mode). See [Handle Conflicts](#3-handle-conflicts) for details. + +### Upgrade and Dev→Main Promotion + +When you run `azd extension upgrade`, extensions installed from the dev registry are evaluated for **one-way promotion** to the main registry. Promotion occurs automatically when: + +1. **The extension is no longer in the dev registry** — it was removed from `registry.dev.json` after being promoted to `registry.json`. +2. **The main registry has a newer version** — the latest version in the main registry is strictly greater than the latest version in the dev registry. + +When promotion happens, the extension's stored source switches from `dev` to `azd`. This is a one-way operation — extensions are never demoted from the main registry back to the dev registry. + +> [!NOTE] +> If the main and dev registries have the **same** latest version, the extension stays on its current (dev) source. Equal versions are source-sticky. + +The upgrade priority chain is: + +1. **Explicit `--source` flag** — always wins if provided +2. **Stored source** — the source the extension was originally installed from +3. **Main registry fallback** — `azd` checks the main registry for promotion opportunities + +Promotion events are tracked via `ext.promote` telemetry. Upgrade events (regardless of promotion) are tracked via `ext.upgrade`. + +#### Example: Dev→Main Promotion in Action + +```bash +# Install from dev registry +azd extension install my.extension --source dev + +# Later, the extension graduates to the main registry with a newer version. +# Running upgrade will auto-promote: +azd extension upgrade my.extension +# Output: my.extension upgraded from 1.0.0-beta.2 (dev) → 1.0.0 (azd) +``` + +### Submitting an Extension to the Dev Registry + +To publish an extension to the dev registry, submit a pull request to the [azure-dev](https://github.com/Azure/azure-dev) repository that adds your extension entry to `cli/azd/extensions/registry.dev.json`. + +#### Requirements + +Your extension entry must: + +1. **Pass schema validation** — The entry must conform to the [registry schema](https://github.com/Azure/azure-dev/blob/main/cli/azd/extensions/registry.schema.json). CI validates this automatically via `ext-registry-ci.yml`. +2. **Include all required metadata:** + - `id` — Unique identifier (lowercase, alphanumeric, dots, and hyphens: `^[a-z0-9-.]+$`) + - `namespace` — Classification namespace + - `displayName` — Human-readable name + - `description` — Brief description of the extension's purpose + - `versions` — At least one version entry with `version`, `capabilities`, `usage`, `examples`, and `artifacts` +3. **Include checksums for all artifacts** — Each artifact must declare a `checksum` with an `algorithm` (`sha256` or `sha512`) and `value`. +4. **Provide platform artifacts** — At minimum, include artifacts for `linux/amd64`, `darwin/amd64`, `darwin/arm64`, and `windows/amd64`. + +#### Example Entry + +```json +{ + "id": "my.experimental.extension", + "namespace": "my", + "displayName": "My Experimental Extension", + "description": "An experimental extension for testing new features.", + "versions": [ + { + "version": "0.1.0", + "capabilities": ["custom-commands"], + "usage": "azd my-command [options]", + "examples": [ + { + "name": "basic-usage", + "description": "Run my-command with a flag.", + "usage": "azd my-command --flag value" + } + ], + "artifacts": { + "linux/amd64": { + "url": "https://github.com/my-org/my-ext/releases/download/v0.1.0/my-ext-linux-amd64.tar.gz", + "checksum": { + "algorithm": "sha256", + "value": "abc123..." + } + }, + "darwin/amd64": { + "url": "https://github.com/my-org/my-ext/releases/download/v0.1.0/my-ext-darwin-amd64.tar.gz", + "checksum": { + "algorithm": "sha256", + "value": "bcd234..." + } + }, + "darwin/arm64": { + "url": "https://github.com/my-org/my-ext/releases/download/v0.1.0/my-ext-darwin-arm64.tar.gz", + "checksum": { + "algorithm": "sha256", + "value": "def456..." + } + }, + "windows/amd64": { + "url": "https://github.com/my-org/my-ext/releases/download/v0.1.0/my-ext-windows-amd64.zip", + "checksum": { + "algorithm": "sha256", + "value": "789ghi..." + } + } + } + } + ] +} +``` + +#### Review Process + +- A maintainer will review your PR for schema compliance, metadata completeness, and artifact accessibility. +- There is no formal quality gate for the dev registry — it is intentionally lower-friction than the main registry. +- Extensions that mature and meet the [main registry criteria](#experimental-vs-main-registry-criteria) can be promoted via a separate PR to `registry.json`. + +### Troubleshooting Multi-Registry Scenarios + +#### Extension exists in both registries + +When the same extension ID is present in both `azd` and `dev`: + +- **Interactive mode** — `azd` prompts you to choose which source to install from. +- **Non-interactive mode** — `azd` fails with `"found in multiple sources"`. +- **Resolution** — Use `--source` to specify explicitly: + + ```bash + azd extension install my.extension --source dev + azd extension install my.extension --source azd + ``` + +#### Source ordering affects resolution + +Sources are sorted **alphabetically by name**. With the default naming (`azd` and `dev`), `azd` is consulted first because `"azd"` sorts before `"dev"`. If you name your dev source `"aaa-dev"`, it would be consulted first. The name only affects the order in which sources are searched — it does not affect upgrade or promotion behavior. + +#### Stale cache after registry updates + +If a recently published extension does not appear, the local cache may not have expired yet: + +```bash +# Force a fresh fetch by setting TTL to zero +export AZD_EXTENSION_CACHE_TTL=0s # Linux/macOS +$env:AZD_EXTENSION_CACHE_TTL = "0s" # PowerShell + +# Then retry +azd extension list --available +``` + +Or clear the cache manually: + +```bash +# Linux/macOS +rm -rf ~/.azd/cache/extensions/ + +# PowerShell +Remove-Item -Recurse -Force "$env:USERPROFILE\.azd\cache\extensions\" +``` + +#### Unreachable dev source blocks all operations + +If the dev registry URL is unreachable (network issue, DNS failure), operations that load sources will **fail** rather than skip the unreachable source. To unblock yourself, remove the dev source temporarily: + +```bash +azd extension source remove dev +``` + +## Nightly Extension Registry + +The nightly registry contains **automatically built, always-latest** development snapshots of first-party extensions. Each scheduled pipeline run rebuilds an extension from `main`, signs the Windows and macOS binaries, uploads them to an always-latest storage folder, and updates a single entry in the nightly registry. Installing a nightly always gives you the most recent nightly build available at that time. + +| Property | Main Registry | Nightly Registry | +|----------|---------------|------------------| +| URL | `https://aka.ms/azd/extensions/registry` | `https://raw.githubusercontent.com/Azure/azure-dev/nightly/cli/azd/extensions/registry.nightly.json` | +| Source file | `cli/azd/extensions/registry.json` (on `main`) | `cli/azd/extensions/registry.nightly.json` (on the `nightly` branch) | +| Source name | `azd` (built-in default) | `nightly` (opt-in) | +| Version shape | `1.2.3` | `1.2.3-nightly.` (or `1.2.3-preview.nightly.`) | +| Signed binaries | Yes | Windows/macOS signed; Linux unsigned | +| History retained | Yes | No — only the latest nightly per extension | +| Support | Covered by Azure support | **Not covered** | + +> [!CAUTION] +> Nightly extensions are built from `main` and come with **no stability guarantees**. Only the current nightly version is retained - older nightly versions are not installable. + +### Adding the Nightly Registry + +The nightly registry must be added, manually. To opt in: + +```bash +# Add the nightly registry as a source named "nightly" +azd extension source add -n nightly -t url -l "https://raw.githubusercontent.com/Azure/azure-dev/nightly/cli/azd/extensions/registry.nightly.json" +``` + +Then, to install a nightly-built extension: + +```bash +azd extension install --source nightly +``` + +To remove the nightly registry later: + +```bash +azd extension source remove nightly +``` + +### Upgrade and Nightly→Main Promotion + +Nightly versions use semver prerelease labels, so the standard `azd extension upgrade` flow works: + +- A newer nightly (higher build id, or a higher base version) supersedes an older one, so `azd extension upgrade` pulls the latest nightly. +- When the extension ships a **stable** release whose base version matches your nightly (for example stable `1.2.3` versus `1.2.3-nightly.200`), the stable release outranks the nightly and you are **automatically promoted** to the `azd` registry on your next upgrade. + +> [!NOTE] +> If your nightly was built from a **prerelease** base (for example `1.2.3-preview.nightly.60`), it sorts **above** the matching stable prerelease `1.2.3-preview`. In that case you are not promoted until the stable registry advances to a higher base version. This is expected semver precedence behavior. + +## Related Documentation + +| Document | Description | +|----------|-------------| +| [Extension Framework](./extension-framework.md) | Architecture overview, source and extension management commands, developing extensions. | +| [Extension SDK Reference](./extension-sdk-reference.md) | Complete API reference for the `azdext` SDK helpers. | +| [Extension End-to-End Walkthrough](./extension-e2e-walkthrough.md) | Build a complete extension from scratch. | +| [Extension Style Guide](./extensions-style-guide.md) | Design guidelines for command integration, flags, and discoverability. | diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 65fae091afc..f5cae401072 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -220,13 +220,17 @@ func bestSatisfyingVersionForAzd( return bestSatisfyingVersion(expr, compatible) } -// resolveExtensionVersion selects the best published version of extension that satisfies +// ResolveExtensionVersion selects the best published version of extension that satisfies // versionPreference and is compatible with azdVersion, or returns a descriptive error. -func resolveExtensionVersion( +func ResolveExtensionVersion( extension *ExtensionMetadata, versionPreference string, azdVersion *semver.Version, ) (*ExtensionVersion, error) { + if extension == nil { + return nil, fmt.Errorf("extension metadata cannot be nil") + } + selected := bestSatisfyingVersionForAzd(versionPreference, extension.Versions, azdVersion) if selected != nil { return selected, nil @@ -296,12 +300,16 @@ func createExtensionFilter(options *FilterOptions) extensionFilterPredicate { } } - // Check Provider filter - extension must have at least one version with a provider matching the specified name + // Check Provider filter - the version that would be selected must publish the provider. + // Matching any version would surface extensions whose current release dropped the provider, + // and installing one would silently pick a superseded version. if options.Provider != "" { - hasProvider := slices.ContainsFunc(extension.Versions, func(version ExtensionVersion) bool { - return slices.ContainsFunc(version.Providers, func(provider Provider) bool { - return strings.EqualFold(provider.Name, options.Provider) - }) + selectedVersion, err := ResolveExtensionVersion(extension, options.Version, nil) + if err != nil { + return false + } + hasProvider := slices.ContainsFunc(selectedVersion.Providers, func(provider Provider) bool { + return strings.EqualFold(provider.Name, options.Provider) }) if !hasProvider { return false @@ -575,7 +583,7 @@ func (m *Manager) installInternal( } // Resolve to the latest published version that satisfies the preference. - selectedVersion, err := resolveExtensionVersion(extension, opts.VersionPreference, opts.AzdVersion) + selectedVersion, err := ResolveExtensionVersion(extension, opts.VersionPreference, opts.AzdVersion) if err != nil { return nil, err } @@ -871,7 +879,7 @@ func (m *Manager) ReconcileDependencies( return nil, nil, fmt.Errorf("extension metadata cannot be nil") } - selectedVersion, err := resolveExtensionVersion(extension, opts.VersionPreference, opts.AzdVersion) + selectedVersion, err := ResolveExtensionVersion(extension, opts.VersionPreference, opts.AzdVersion) if err != nil { return nil, nil, err } diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index 8ea9d337809..a3b39450e21 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -381,6 +381,13 @@ func Test_MatchesVersionConstraint(t *testing.T) { } } +func TestResolveExtensionVersionNil(t *testing.T) { + version, err := ResolveExtensionVersion(nil, "", nil) + + require.Nil(t, version) + require.EqualError(t, err, "extension metadata cannot be nil") +} + func Test_CreateExtensionFilter_VersionConstraints(t *testing.T) { ext := &ExtensionMetadata{ Id: "test.constraints", @@ -417,6 +424,38 @@ func Test_CreateExtensionFilter_VersionConstraints(t *testing.T) { } } +func Test_CreateExtensionFilter_ProviderUsesSelectedVersion(t *testing.T) { + foundry := []Provider{{Type: ProvisioningProviderType, Name: "microsoft.foundry"}} + ext := &ExtensionMetadata{ + Id: "test.provider", + Versions: []ExtensionVersion{ + {Version: "1.0.0-beta.6", Providers: foundry}, + {Version: "1.0.0-beta.7"}, + }, + } + + testCases := []struct { + Name string + Version string + Match bool + }{ + {Name: "selected version dropped the provider", Version: "", Match: false}, + {Name: "pinned to a version that provides it", Version: "1.0.0-beta.6", Match: true}, + {Name: "constraint resolves to a version without it", Version: ">=1.0.0-beta.6", Match: false}, + {Name: "constraint excludes the version without it", Version: "<1.0.0-beta.7", Match: true}, + } + + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + filter := createExtensionFilter(&FilterOptions{ + Version: tc.Version, + Provider: "microsoft.foundry", + }) + require.Equal(t, tc.Match, filter(ext)) + }) + } +} + func Test_Install_PackDependency_SemverConstraint(t *testing.T) { mockContext := mocks.NewMockContext(t.Context()) diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index 86fd15b504f..e57798d7a27 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -26,6 +26,19 @@ const ( Test ProviderKind = "test" ) +// Arm, Pulumi and Test are omitted because azd defines the kinds but registers no implementation. +// Keep in sync with the providers registered in pkg/azd. +var builtInProviderKinds = []ProviderKind{ + Bicep, + Terraform, +} + +// BuiltInProviderKinds returns the provisioning providers implemented by azd itself, as opposed to +// those supplied by an extension. +func BuiltInProviderKinds() []ProviderKind { + return builtInProviderKinds +} + type Mode string const (