diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 6a5e95a1805..59cacdea4e1 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -438,6 +438,7 @@ type extensionAutoInstallManager interface { opts extensions.InstallOptions, ) (*extensions.ExtensionVersion, error) ListInstalled() (map[string]*extensions.Extension, error) + MarkExplicitlyInstalled(id string) error } func tryAutoInstallExtensionVersion( @@ -456,6 +457,13 @@ func tryAutoInstallExtensionVersion( if err := validateInstalledExtensionVersion(installedExtension, versionPreference); err != nil { return false, err } + // The project requires this extension in its own right, so a record that only a + // pack pulled in becomes explicit and survives when that pack is uninstalled. + if installedExtension.InstalledAsDependency { + if err := extensionManager.MarkExplicitlyInstalled(extension.Id); err != nil { + return false, fmt.Errorf("marking extension %s as explicitly installed: %w", extension.Id, err) + } + } return false, nil } diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 536f12f86a7..5c2c4612d6b 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -197,6 +197,15 @@ func (m *fakeExtensionAutoInstallManager) ListInstalled() (map[string]*extension return m.installed, nil } +func (m *fakeExtensionAutoInstallManager) MarkExplicitlyInstalled(id string) error { + installed, ok := m.installed[id] + if !ok { + return extensions.ErrInstalledExtensionNotFound + } + installed.InstalledAsDependency = false + return nil +} + func TestMissingProjectExtensions(t *testing.T) { versionConstraint := ">=1.0.0-beta.4" manager := &fakeExtensionAutoInstallManager{ @@ -2468,3 +2477,64 @@ func TestProjectExtensionErrorsCarrySuggestions(t *testing.T) { assert.Contains(t, suggestErr.Suggestion, "azd extension source list") }) } + +func TestTryAutoInstallExtensionVersionPromotesDependencyInstalledExtension(t *testing.T) { + t.Parallel() + + // The project requires an extension that a pack pulled in earlier; it must survive the + // pack's removal from now on. + manager := &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{ + "azure.ai.agents": {Id: "azure.ai.agents", Version: "1.0.0", InstalledAsDependency: true}, + }, + } + + installed, err := tryAutoInstallExtensionVersion( + t.Context(), + mockinput.NewMockConsole(), + manager, + extensions.ExtensionMetadata{Id: "azure.ai.agents"}, + "", + false, + ) + require.NoError(t, err) + require.False(t, installed, "already installed, so nothing is downloaded") + require.False(t, manager.installed["azure.ai.agents"].InstalledAsDependency) +} + +func TestMissingProjectExtensionsPromotesOnlyExplicitRequirements(t *testing.T) { + t.Parallel() + + // Only the named requiredVersions entry becomes explicit. Reusing an inferred provider + // does not change its ownership, including on repeated project commands. + manager := &fakeExtensionAutoInstallManager{ + installed: map[string]*extensions.Extension{ + "azure.ai.projects": { + Id: "azure.ai.projects", Version: "1.0.0", InstalledAsDependency: true, + }, + "azure.ai.agents": { + Id: "azure.ai.agents", Version: "1.0.0", InstalledAsDependency: true, + Capabilities: []extensions.CapabilityType{extensions.ServiceTargetProviderCapability}, + Providers: []extensions.Provider{ + {Name: "azure.ai.agent", Type: extensions.ServiceTargetProviderType}, + }, + }, + }, + } + projectConfig := &project.ProjectConfig{ + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{"azure.ai.projects": nil}, + }, + Services: map[string]*project.ServiceConfig{ + "agent": {Host: "azure.ai.agent"}, + }, + } + + for range 2 { + requirements, err := missingProjectExtensions(t.Context(), mockinput.NewMockConsole(), manager, projectConfig) + require.NoError(t, err) + require.Empty(t, requirements, "everything the project needs is already installed") + require.False(t, manager.installed["azure.ai.projects"].InstalledAsDependency) + require.True(t, manager.installed["azure.ai.agents"].InstalledAsDependency) + } +} diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index a2262b77abb..fed6bfaa9e3 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -77,10 +77,8 @@ from an unregistered location show the location itself in the SOURCE column.`, Command: &cobra.Command{ Use: "show ", Short: "Show details for a specific extension.", - Long: `Show details for a specific extension from a registered extension source. - -The --source flag accepts a registered source name or registry location (URL or -file path). Locations are queried read-only and are not registered.`, + Long: `Includes version compatibility, dependencies, and installed dependents. +Uses installed metadata when no registry lists the extension, or a registry lookup fails without --source.`, }, OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, DefaultFormat: output.NoneFormat, @@ -112,8 +110,13 @@ installs aren't tracked for updates; install a newer bundle to update.`, // azd extension uninstall group.Add("uninstall", &actions.ActionDescriptorOptions{ Command: &cobra.Command{ - Use: "uninstall [extension-id]", + Use: "uninstall [extension-id...]", Short: "Uninstall specified extensions.", + Long: `Also removes unused dependency installs after confirmation. +Use --no-dependencies to keep them; --no-prompt accepts their removal. + +Required extensions cannot be removed unless their dependents are also removed +or --force is set.`, }, ActionResolver: newExtensionUninstallAction, FlagsResolver: newExtensionUninstallFlags, @@ -125,27 +128,11 @@ installs aren't tracked for updates; install a newer bundle to update.`, Use: "update [extension-id]", Aliases: []string{"upgrade"}, Short: "Update installed extensions to the latest version.", - Long: `Update one or more installed extensions. - -By default, uses the stored registry source for each extension. If the stored -source is unavailable, falls back to the main (azd) registry. Extensions that -were installed from a non-main registry (e.g., dev) are automatically promoted -to the main registry when a newer version is available there. - -Use --source to override the registry source for the update. It accepts a -registered source name or registry location (URL or file path); locations are -registered first and the updated extension's stored source is updated. Because -registration is interactive, locations are rejected under --no-prompt. Use --all -to update all installed extensions in a single batch; failures in one extension -do not prevent the remaining extensions from being updated. - -When updating an extension that has dependencies, any installed -dependencies are automatically updated too, to the highest version -satisfying the extension's declared constraints. Use ---no-dependency-updates to opt out and update only the named -extension. - -Use --output json for a structured report of all update results.`, + Long: `Also updates installed dependencies to compatible versions unless +--no-dependency-updates is set. + +Failures return a nonzero exit code. Other extensions continue updating; +completed updates are kept.`, }, OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, DefaultFormat: output.NoneFormat, @@ -593,21 +580,116 @@ func newExtensionShowAction( } } +// extensionShowItem is the `azd extension show` view model. Field names are the +// `--output json` contract and follow the camelCase convention of other azd commands. type extensionShowItem struct { - Id string - Name string - Website string - Source string - Namespace string - Description string - Tags []string - LatestVersion string - InstalledVersion string - AvailableVersions []string - Usage string - Examples []extensions.ExtensionExample - Providers []extensions.Provider - Capabilities []extensions.CapabilityType + Id string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Website string `json:"website,omitempty"` + Source string `json:"source"` + Namespace string `json:"namespace,omitempty"` + Tags []string `json:"tags,omitempty"` + + // LatestVersion is the highest published version regardless of azd compatibility. + // Empty when the extension is only known from its installed record. + LatestVersion string `json:"latestVersion,omitempty"` + // LatestCompatibleVersion is the highest published version the running azd can install. + LatestCompatibleVersion string `json:"latestCompatibleVersion,omitempty"` + // RequiresAzd is the azd version constraint declared by the latest version. + RequiresAzd string `json:"requiresAzd,omitempty"` + // OtherVersions lists the remaining published versions, newest first. + OtherVersions []string `json:"otherVersions,omitempty"` + + InstalledVersion string `json:"installedVersion,omitempty"` + // InstalledSource is set when the extension is installed from a source other than the + // one being described, in which case update state is not reported. + InstalledSource string `json:"installedSource,omitempty"` + InstalledAsDependency bool `json:"installedAsDependency,omitempty"` + // UpdateAvailable is true when a newer version compatible with the running azd exists. + UpdateAvailable bool `json:"updateAvailable,omitempty"` + + Dependencies []extensionShowDependency `json:"dependencies,omitempty"` + RequiredBy []extensionShowDependent `json:"requiredBy,omitempty"` + Capabilities []extensions.CapabilityType `json:"capabilities,omitempty"` + Providers []extensions.Provider `json:"providers,omitempty"` + Usage string `json:"usage,omitempty"` + Examples []extensions.ExtensionExample `json:"examples,omitempty"` + + // azdVersion, latestIncompatible, and newerIncompatible drive the compatibility + // annotations in Display. latestIncompatible means the latest release cannot run on this + // azd (Requires azd row); newerIncompatible additionally means it is newer than what is + // installed (Installed row). + azdVersion string + latestIncompatible bool + newerIncompatible bool +} + +// extensionShowDependency is one declared dependency together with its installed state. +type extensionShowDependency struct { + Id string `json:"id"` + // Version is the declared constraint. Empty means any version. + Version string `json:"version,omitempty"` + InstalledVersion string `json:"installedVersion,omitempty"` + // Satisfied is true when the dependency is installed at a version matching the constraint. + Satisfied bool `json:"satisfied"` +} + +// extensionShowDependent is an installed extension that declares a dependency on the shown one. +type extensionShowDependent struct { + Id string `json:"id"` + Version string `json:"version"` +} + +// installedSummary describes the installed state with at most one annotation, in priority +// order: foreign source, compatible update, newer incompatible release. +func (t *extensionShowItem) installedSummary() string { + if t.InstalledVersion == "" { + return "Not installed" + } + + summary := t.InstalledVersion + switch { + case t.InstalledSource != "": + summary += fmt.Sprintf(" (from %s)", t.InstalledSource) + case t.UpdateAvailable: + summary += fmt.Sprintf(" (update available: %s)", t.LatestCompatibleVersion) + case t.newerIncompatible: + summary += fmt.Sprintf(" (%s requires a newer azd)", t.LatestVersion) + } + return summary +} + +// requiresAzdSummary annotates the latest version's azd constraint when the running azd +// cannot use it. +func (t *extensionShowItem) requiresAzdSummary() string { + if !t.latestIncompatible || t.azdVersion == "" { + return t.RequiresAzd + } + if t.LatestCompatibleVersion != "" { + return fmt.Sprintf( + "%s (not compatible with azd %s; latest compatible is %s)", + t.RequiresAzd, t.azdVersion, t.LatestCompatibleVersion, + ) + } + return fmt.Sprintf("%s (not compatible with azd %s)", t.RequiresAzd, t.azdVersion) +} + +// summary renders the constraint and installed state of a dependency row. +func (d extensionShowDependency) summary() string { + constraint := d.Version + if constraint == "" { + constraint = "any version" + } + + switch { + case d.InstalledVersion == "": + return constraint + " (not installed)" + case d.Satisfied: + return fmt.Sprintf("%s (installed %s)", constraint, d.InstalledVersion) + default: + return fmt.Sprintf("%s (installed %s, outside constraint)", constraint, d.InstalledVersion) + } } func (t *extensionShowItem) Display(writer io.Writer) error { @@ -648,38 +730,68 @@ func (t *extensionShowItem) Display(writer io.Writer) error { return err } - // Extension Information section + // Extension Information section. Rows without a value are omitted rather than shown blank. extensionInfo := [][]string{ {"Id", ":", t.Id}, {"Name", ":", t.Name}, {"Description", ":", t.Description}, {"Source", ":", t.Source}, - {"Namespace", ":", t.Namespace}, + } + if t.Namespace != "" { + extensionInfo = append(extensionInfo, []string{"Namespace", ":", t.Namespace}) } if t.Website != "" { extensionInfo = append(extensionInfo, []string{"Website", ":", t.Website}) } + if len(t.Tags) > 0 { + extensionInfo = append(extensionInfo, []string{"Tags", ":", strings.Join(t.Tags, ", ")}) + } if err := writeSection("Extension Information", extensionInfo); err != nil { return err } // Version Information section versionInfo := [][]string{ - {"Latest Version", ":", t.LatestVersion}, - {"Installed Version", ":", t.InstalledVersion}, + {"Installed", ":", t.installedSummary()}, } - // Only add Available Versions if there are any - if len(t.AvailableVersions) > 0 { - versionInfo = append(versionInfo, []string{"Available Versions", ":", strings.Join(t.AvailableVersions, ", ")}) + if t.InstalledAsDependency { + versionInfo = append(versionInfo, []string{"Installed as", ":", "Dependency"}) } - // Only add Tags if they are defined - if len(t.Tags) > 0 { - versionInfo = append(versionInfo, []string{"Tags", ":", strings.Join(t.Tags, ", ")}) + if t.LatestVersion != "" { + versionInfo = append(versionInfo, []string{"Latest", ":", t.LatestVersion}) + } + if t.RequiresAzd != "" { + versionInfo = append(versionInfo, []string{"Requires azd", ":", t.requiresAzdSummary()}) + } + if len(t.OtherVersions) > 0 { + versionInfo = append(versionInfo, []string{"Other Versions", ":", strings.Join(t.OtherVersions, ", ")}) } if err := writeSection("Version Information", versionInfo); err != nil { return err } + // Dependencies section - only if the shown version declares any + if len(t.Dependencies) > 0 { + dependencyRows := [][]string{} + for _, dependency := range t.Dependencies { + dependencyRows = append(dependencyRows, []string{dependency.Id, ":", dependency.summary()}) + } + if err := writeSection("Dependencies", dependencyRows); err != nil { + return err + } + } + + // Required By section - only if installed extensions depend on this one + if len(t.RequiredBy) > 0 { + dependentRows := [][]string{} + for _, dependent := range t.RequiredBy { + dependentRows = append(dependentRows, []string{dependent.Id, ":", dependent.Version}) + } + if err := writeSection("Required By", dependentRows); err != nil { + return err + } + } + // Capabilities section - only if there are capabilities if len(t.Capabilities) > 0 { capabilityRows := [][]string{} @@ -703,12 +815,14 @@ func (t *extensionShowItem) Display(writer io.Writer) error { } } - // Usage section - usageRows := [][]string{ - {"", "", t.Usage}, - } - if err := writeSection("Usage", usageRows); err != nil { - return err + // Usage section - only if the extension has one (packs typically do not) + if strings.TrimSpace(t.Usage) != "" { + usageRows := [][]string{ + {"", "", t.Usage}, + } + if err := writeSection("Usage", usageRows); err != nil { + return err + } } // Examples section - only if there are examples @@ -740,6 +854,11 @@ func (a *extensionShowAction) Run(ctx context.Context) (*actions.ActionResult, e } } extensionId := a.args[0] + installedExtension, err := a.extensionManager.GetInstalled(extensions.FilterOptions{Id: extensionId}) + if err != nil && !errors.Is(err, extensions.ErrInstalledExtensionNotFound) { + return nil, fmt.Errorf("failed to get installed extension: %w", err) + } + filterOptions := &extensions.FilterOptions{ Source: a.flags.source, Id: extensionId, @@ -759,46 +878,34 @@ func (a *extensionShowAction) Run(ctx context.Context) (*actions.ActionResult, e } extensionMatches, err := a.extensionManager.FindExtensions(ctx, filterOptions) - if err != nil { - return nil, fmt.Errorf("failed to find extension: %w", err) + if ctxErr := ctx.Err(); ctxErr != nil { + return nil, ctxErr } - - registryExtension, err := selectDistinctExtension(ctx, a.console, extensionId, extensionMatches, a.flags.global) if err != nil { - return nil, err - } - - latestVersion := extensions.LatestVersion(registryExtension.Versions) - - var otherVersions []string - for _, version := range registryExtension.Versions { - if version.Version != latestVersion.Version { - otherVersions = append(otherVersions, version.Version) + if installedExtension == nil || a.flags.source != "" || + errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, fmt.Errorf("failed to find extension: %w", err) } + a.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: "Could not load extension registry information. Showing installed metadata only.", + }) + extensionMatches = nil } - extensionDetails := extensionShowItem{ - Id: registryExtension.Id, - Name: registryExtension.DisplayName, - Website: registryExtension.Website, - Source: registryExtension.Source, - Namespace: registryExtension.Namespace, - Description: registryExtension.Description, - Tags: registryExtension.Tags, - LatestVersion: latestVersion.Version, - AvailableVersions: otherVersions, - Usage: latestVersion.Usage, - Examples: latestVersion.Examples, - Providers: latestVersion.Providers, - Capabilities: latestVersion.Capabilities, - InstalledVersion: "N/A", + // Without registry metadata, describe the installed record unless --source requested a different source. + var registryExtension *extensions.ExtensionMetadata + installedOnly := len(extensionMatches) == 0 && installedExtension != nil && + (a.flags.source == "" || strings.EqualFold(a.flags.source, installedExtension.Source)) + if !installedOnly { + registryExtension, err = a.selectRegistryExtension(ctx, extensionId, extensionMatches, installedExtension) + if err != nil { + return nil, err + } } - installedExtension, err := a.extensionManager.GetInstalled( - extensions.FilterOptions{Id: extensionId}, - ) - if err == nil && installedExtension.Source == extensionDetails.Source { - extensionDetails.InstalledVersion = installedExtension.Version + extensionDetails, err := a.buildShowItem(extensionId, registryExtension, installedExtension) + if err != nil { + return nil, err } var formatErr error @@ -812,6 +919,171 @@ func (a *extensionShowAction) Run(ctx context.Context) (*actions.ActionResult, e return nil, formatErr } +// selectRegistryExtension picks the registry entry to describe. The source the extension is +// installed from wins when it is among the matches, so an installed extension listed by several +// sources is described without prompting. Otherwise the user chooses, as for install. +func (a *extensionShowAction) selectRegistryExtension( + ctx context.Context, + extensionId string, + matches []*extensions.ExtensionMetadata, + installed *extensions.Extension, +) (*extensions.ExtensionMetadata, error) { + if installed != nil { + for _, match := range matches { + if strings.EqualFold(match.Source, installed.Source) { + return match, nil + } + } + } + return selectDistinctExtension(ctx, a.console, extensionId, matches, a.flags.global) +} + +// buildShowItem assembles the view model from the registry entry (when there is one) and the +// installed record (when the extension is installed). Either may be nil, but not both. +func (a *extensionShowAction) buildShowItem( + extensionId string, + registryExtension *extensions.ExtensionMetadata, + installed *extensions.Extension, +) (*extensionShowItem, error) { + item := &extensionShowItem{Id: extensionId} + var dependencies []extensions.ExtensionDependency + + if registryExtension != nil { + item.Id = registryExtension.Id + item.Name = registryExtension.DisplayName + item.Description = registryExtension.Description + item.Website = registryExtension.Website + item.Source = registryExtension.Source + item.Namespace = registryExtension.Namespace + item.Tags = registryExtension.Tags + + if latest := extensions.LatestVersion(registryExtension.Versions); latest != nil { + item.LatestVersion = latest.Version + item.RequiresAzd = latest.RequiredAzdVersion + item.OtherVersions = otherVersionsNewestFirst(registryExtension.Versions, latest.Version) + item.Usage = latest.Usage + item.Examples = latest.Examples + item.Providers = latest.Providers + item.Capabilities = latest.Capabilities + dependencies = latest.Dependencies + + if azdVersion := a.extensionManager.AzdVersion(); azdVersion != nil { + compat := extensions.FilterCompatibleVersions(registryExtension.Versions, azdVersion) + item.azdVersion = azdVersion.String() + item.latestIncompatible = compat.HasNewerIncompatible + item.newerIncompatible = compat.HasNewerIncompatible + if compat.LatestCompatible != nil { + item.LatestCompatibleVersion = compat.LatestCompatible.Version + } + } else { + // Without a compatibility policy (dev builds, IgnoreAzdCompatibility) every + // published version is installable. + item.LatestCompatibleVersion = latest.Version + } + } + } + + if installed != nil { + item.InstalledVersion = installed.Version + item.InstalledAsDependency = installed.InstalledAsDependency + + switch { + case registryExtension == nil: + item.Id = installed.Id + item.Name = installed.DisplayName + item.Description = installed.Description + item.Source = installed.Source + item.Namespace = installed.Namespace + item.Usage = installed.Usage + item.Capabilities = installed.Capabilities + item.Providers = installed.Providers + case !strings.EqualFold(installed.Source, registryExtension.Source): + item.InstalledSource = installed.Source + default: + applyShowUpdateState(item, installed.Version) + } + + // The installed snapshot governs uninstall behavior, so it is what show explains. + // Records that predate the snapshot fall back to the registry entry for the installed + // version; the latest version's declaration says nothing about what is installed. + dependencies = installed.Dependencies + if len(dependencies) == 0 && registryExtension != nil && + strings.EqualFold(installed.Source, registryExtension.Source) { + if release := extensions.FindVersion(registryExtension.Versions, installed.Version); release != nil { + dependencies = release.Dependencies + } + } + } + + for _, dependency := range dependencies { + row := extensionShowDependency{Id: dependency.Id, Version: dependency.Version} + dependencyInstalled, err := a.extensionManager.GetInstalled(extensions.FilterOptions{Id: dependency.Id}) + if err == nil && dependencyInstalled != nil { + row.InstalledVersion = dependencyInstalled.Version + row.Satisfied = extensions.SatisfiesConstraint(dependency.Version, dependencyInstalled.Version) + } + item.Dependencies = append(item.Dependencies, row) + } + + dependents, err := a.extensionManager.InstalledDependents(item.Id) + if err != nil { + return nil, fmt.Errorf("failed to list dependent extensions: %w", err) + } + for _, dependent := range dependents { + item.RequiredBy = append(item.RequiredBy, extensionShowDependent{ + Id: dependent.Id, + Version: dependent.Version, + }) + } + + return item, nil +} + +// applyShowUpdateState derives the installed-row annotations for an extension installed from +// the source being described. Non-semver tags have no ordering, so they report no update. +func applyShowUpdateState(item *extensionShowItem, installedVersion string) { + installedSemver, err := semver.NewVersion(installedVersion) + if err != nil { + item.newerIncompatible = false + return + } + + if candidate, err := semver.NewVersion(item.LatestCompatibleVersion); err == nil { + item.UpdateAvailable = candidate.GreaterThan(installedSemver) + } + + // The installed row only mentions an incompatible release that is newer than what is + // installed; the Requires azd row keeps reporting the latest release's compatibility. + if item.newerIncompatible { + latestSemver, err := semver.NewVersion(item.LatestVersion) + item.newerIncompatible = err == nil && latestSemver.GreaterThan(installedSemver) + } +} + +// otherVersionsNewestFirst lists every published version except latest, newest first. Tags +// that do not parse as semver keep their published order after the semver releases. +func otherVersionsNewestFirst(versions []extensions.ExtensionVersion, latest string) []string { + var releases []*semver.Version + var tags []string + for _, version := range versions { + if version.Version == latest { + continue + } + if release, err := semver.NewVersion(version.Version); err == nil { + releases = append(releases, release) + } else { + tags = append(tags, version.Version) + } + } + slices.SortFunc(releases, func(a, b *semver.Version) int { return b.Compare(a) }) + + others := make([]string, 0, len(releases)+len(tags)) + for _, release := range releases { + others = append(others, release.Original()) + } + return append(others, tags...) +} + type extensionInstallFlags struct { version string source string @@ -1035,8 +1307,20 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult if !a.flags.force { if sameSource { if installedExtension.Version == targetVersion { - stepMessage += output.WithGrayFormat( - " (version %s already installed)", installedExtension.Version) + skipNote := fmt.Sprintf(" (version %s already installed)", installedExtension.Version) + // Asking for a dependency by name promotes it to an explicit install, so it + // survives when the extensions that pulled it in are uninstalled. + if installedExtension.InstalledAsDependency { + if err := a.extensionManager.MarkExplicitlyInstalled(extensionId); err != nil { + a.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return nil, err + } + skipNote = fmt.Sprintf( + " (version %s already installed, marked as explicitly installed)", + installedExtension.Version, + ) + } + stepMessage += output.WithGrayFormat("%s", skipNote) a.console.StopSpinner(ctx, stepMessage, input.StepSkipped) continue } @@ -1082,12 +1366,16 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult // Use upgrade logic for existing installations a.console.ShowSpinner(ctx, stepMessage, input.Step) - extensionVersion, _, err = a.extensionManager.Upgrade( + // The user asked for this extension by name, so the reinstall records it as explicit + // even when the previous record was only a dependency install. + var dependencyResults []extensions.UpgradeResult + extensionVersion, dependencyResults, err = a.extensionManager.Upgrade( ctx, selectedExtension, extensions.UpgradeOptions{ VersionPreference: a.flags.version, UpgradeDependencies: !a.flags.noDependencies, SkipDependencies: a.flags.noDependencies, SkipMainRegistryDependencyFallback: a.bundleSourceName != "", + PromoteToExplicit: true, }, ) if err != nil { @@ -1098,6 +1386,11 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult stepMessage += output.WithGrayFormat(" (%s)", extensionVersion.Version) a.console.StopSpinner(ctx, stepMessage, input.StepDone) + if dependencyErr := dependencyUpgradeError(dependencyResults); dependencyErr != nil { + displayDependencyUpgradeResults(ctx, a.console, dependencyResults, " ") + return nil, fmt.Errorf("failed to update dependencies for extension %s: %w", extensionId, dependencyErr) + } + } else { // Extension not installed - proceed with fresh install a.console.ShowSpinner(ctx, stepMessage, input.Step) @@ -1972,12 +2265,18 @@ func inferSourceKind(location string) (extensions.SourceKind, bool) { // azd extension uninstall type extensionUninstallFlags struct { - all bool + all bool + force bool + noDependencies bool } func newExtensionUninstallFlags(cmd *cobra.Command) *extensionUninstallFlags { flags := &extensionUninstallFlags{} cmd.Flags().BoolVar(&flags.all, "all", false, "Uninstall all installed extensions") + cmd.Flags().BoolVarP(&flags.force, "force", "f", false, + "Uninstall even if other installed extensions depend on it") + cmd.Flags().BoolVar(&flags.noDependencies, "no-dependencies", false, + "Keep dependencies installed for the removed extensions") return flags } @@ -2022,7 +2321,7 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu a.console.MessageUxItem(ctx, &ux.MessageTitle{ Title: "Uninstall an azd extension (azd extension uninstall)", - TitleNote: "Uninstalls the specified extension from the local machine", + TitleNote: "Uninstalls the specified extensions from the local machine", }) extensionIds := a.args @@ -2031,37 +2330,83 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu if err != nil { return nil, fmt.Errorf("failed to list installed extensions: %w", err) } - - extensionIds = make([]string, 0, len(installed)) - for name := range installed { - extensionIds = append(extensionIds, name) - } - } - - if len(extensionIds) == 0 { - return nil, &internal.ErrorWithSuggestion{ - Err: internal.ErrNoExtensionsAvailable, - Suggestion: "No extensions are currently installed. Run 'azd extension list' to verify.", + if len(installed) == 0 { + return nil, &internal.ErrorWithSuggestion{ + Err: internal.ErrNoExtensionsAvailable, + Suggestion: "No extensions are currently installed. Run 'azd extension list' to verify.", + } } + extensionIds = slices.Sorted(maps.Keys(installed)) } for _, extensionId := range extensionIds { - stepMessage := extensionTaskMessage("Uninstalling", extensionId) + // A blank id would match an arbitrary installed record, so reject it outright. + if strings.TrimSpace(extensionId) == "" { + return nil, &internal.ErrorWithSuggestion{ + Err: extensions.ErrEmptyExtensionId, + Suggestion: "Run 'azd extension uninstall ' with the id of an installed extension.", + } + } - installed, err := a.extensionManager.GetInstalled(extensions.FilterOptions{ - Id: extensionId, - }) - if err != nil { + // Report an unknown id as a failed step, as the per-extension loop always has, before + // any planning takes place. + if _, err := a.extensionManager.GetInstalled(extensions.FilterOptions{Id: extensionId}); err != nil { + stepMessage := extensionTaskMessage("Uninstalling", extensionId) a.console.ShowSpinner(ctx, stepMessage, input.Step) a.console.StopSpinner(ctx, stepMessage, input.StepFailed) return nil, fmt.Errorf("failed to get installed extension: %w", err) } + } + + // Nothing is removed until the whole request is known to be safe (or forced). With --all + // every extension is in the removal set, so nothing can block it or be orphaned by it. + plan, err := a.extensionManager.PlanUninstall(extensionIds, extensions.UninstallPlanOptions{ + KeepDependencies: a.flags.noDependencies, + IgnoreDependents: a.flags.force, + }) + if err != nil { + return nil, internal.WrapErrorWithSuggestion(err) + } + + // Removing more than the user named deserves a look first. Declining keeps the + // dependencies exactly as they are, still recorded as dependency installs. + var keptDependencies []*extensions.Extension + if len(plan.Orphaned) > 0 { + remove, err := a.confirmDependencyRemoval(ctx, plan) + if err != nil { + return nil, err + } + if !remove { + keptDependencies = plan.Orphaned + plan, err = a.extensionManager.PlanUninstall(extensionIds, extensions.UninstallPlanOptions{ + KeepDependencies: true, + IgnoreDependents: a.flags.force, + }) + if err != nil { + return nil, internal.WrapErrorWithSuggestion(err) + } + } + } - stepMessage = extensionTaskMessageWithVersion("Uninstalling", extensionId, installed.Version) + // Use the final plan: keeping dependencies can introduce blockers that only --force bypasses. + for _, extensionId := range slices.Sorted(maps.Keys(plan.Blocked)) { + a.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: fmt.Sprintf( + "%s is required by %s, which may stop working without it.", + extensionId, strings.Join(plan.Blocked[extensionId], ", "), + ), + }) + } + if len(plan.Blocked) > 0 { + a.console.Message(ctx, "") + } + + for _, target := range plan.Targets { + stepMessage := extensionTaskMessageWithVersion("Uninstalling", target.Id, target.Version) a.console.ShowSpinner(ctx, stepMessage, input.Step) - if err := a.extensionManager.Uninstall(ctx, extensionId); err != nil { + if err := a.uninstall(ctx, target); err != nil { a.console.StopSpinner(ctx, stepMessage, input.StepFailed) return nil, fmt.Errorf("failed to uninstall extension: %w", err) } @@ -2069,6 +2414,41 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu a.console.StopSpinner(ctx, stepMessage, input.StepDone) } + // Dependencies render flat under the targets, mirroring how install lists them. + for _, orphan := range plan.Orphaned { + if err := a.uninstall(ctx, orphan); err != nil { + a.console.Message(ctx, dependencyUninstallRow( + output.WithErrorFormat("(x) Failed:"), orphan, "no longer required")) + return nil, fmt.Errorf("failed to uninstall dependency: %w", err) + } + a.console.Message(ctx, dependencyUninstallRow( + output.WithSuccessFormat("(✓) Done:"), orphan, "no longer required")) + } + for _, kept := range keptDependencies { + a.console.Message(ctx, dependencyUninstallRow(output.WithGrayFormat("(-) Skipped:"), kept, "kept")) + } + for _, retained := range plan.Retained { + // A record without the dependency flag was installed by name or predates dependency + // tracking; azd cannot tell which, so the reason states only what is known. + reason := "not installed as a dependency" + if len(retained.RequiredBy) > 0 { + reason = "required by " + strings.Join(retained.RequiredBy, ", ") + } + a.console.Message(ctx, dependencyUninstallRow( + output.WithGrayFormat("(-) Skipped:"), retained.Extension, reason)) + } + if len(keptDependencies) > 0 { + ids := make([]string, 0, len(keptDependencies)) + for _, kept := range keptDependencies { + ids = append(ids, kept.Id) + } + a.console.Message(ctx, "") + a.console.Message(ctx, fmt.Sprintf( + "Run %s to remove them later.", + output.WithHighLightFormat("azd extension uninstall %s", strings.Join(ids, " ")), + )) + } + return &actions.ActionResult{ Message: &actions.ResultMessage{ Header: "Extension(s) uninstalled successfully", @@ -2076,6 +2456,69 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu }, nil } +// confirmDependencyRemoval previews the additional removals before asking once. +func (a *extensionUninstallAction) confirmDependencyRemoval( + ctx context.Context, + plan *extensions.UninstallPlan, +) (bool, error) { + targets := make([]string, 0, len(plan.Targets)) + for _, target := range plan.Targets { + targets = append(targets, target.Id) + } + + a.console.Message(ctx, "") + a.console.Message(ctx, fmt.Sprintf( + "Uninstalling %s will leave these dependencies unused:", + strings.Join(targets, ", "), + )) + for _, orphan := range plan.Orphaned { + a.console.Message(ctx, fmt.Sprintf( + " • %s %s", + output.WithHighLightFormat(orphan.Id), + output.WithGrayFormat("(%s)", orphan.Version), + )) + } + a.console.Message(ctx, "") + + question := "Remove this dependency?" + if len(plan.Orphaned) > 1 { + question = fmt.Sprintf("Remove these %d dependencies?", len(plan.Orphaned)) + } + remove, err := a.console.Confirm(ctx, input.ConsoleOptions{ + Message: question, + DefaultValue: true, + }) + if err != nil { + return false, fmt.Errorf("confirming dependency removal: %w", err) + } + a.console.Message(ctx, "") + return remove, nil +} + +// uninstall removes one extension and records the outcome in telemetry. +func (a *extensionUninstallAction) uninstall(ctx context.Context, extension *extensions.Extension) error { + ctx, span := tracing.Start(ctx, events.ExtensionUninstallEvent) + span.SetAttributes( + fields.ExtensionId.String(extension.Id), + fields.ExtensionVersion.String(extension.Version), + fields.ExtensionSourceCategory.String(string(extension.SourceCategoryOrUnknown())), + ) + + err := a.extensionManager.Uninstall(ctx, extension.Id) + span.EndWithStatus(err) + return err +} + +// dependencyUninstallRow renders one dependency outcome aligned under the parent step. +func dependencyUninstallRow(status string, extension *extensions.Extension, reason string) string { + return fmt.Sprintf( + " %s Uninstalling %s dependency %s", + status, + output.WithHighLightFormat(extension.Id), + output.WithGrayFormat("(%s, %s)", extension.Version, reason), + ) +} + type extensionUpgradeFlags struct { version string source string @@ -2577,6 +3020,13 @@ func (a *extensionUpgradeAction) upgradeOneExtension( return baseResult } + // A record that predates dependency tracking learns its snapshot from the registry entry + // for the installed version, whatever the rest of this update decides to do. + installedRelease := findPublishedExtensionVersion(matches, installed.Source, installed.Version) + if err := a.extensionManager.BackfillDependencies(installed.Id, installed.Source, installedRelease); err != nil { + return fail(err) + } + var selectedExt *extensions.ExtensionMetadata var isPromotion bool var oldSource, newSource string @@ -2855,6 +3305,20 @@ func (a *extensionUpgradeAction) displayPromotionWarning( )) } +// dependencyUpgradeError preserves causes from failed dependencies, including nested results. +func dependencyUpgradeError(results []extensions.UpgradeResult) error { + var failures []error + for _, result := range results { + if result.Status == extensions.UpgradeStatusFailed && result.Error != nil { + failures = append(failures, fmt.Errorf("dependency %s: %w", result.ExtensionId, result.Error)) + } + if err := dependencyUpgradeError(result.DependencyUpgrades); err != nil { + failures = append(failures, err) + } + } + return errors.Join(failures...) +} + // displayDependencyUpgradeResults renders dependency upgrades as flat rows. func displayDependencyUpgradeResults( ctx context.Context, @@ -2981,12 +3445,19 @@ func displayUpgradeSummary( "%d failed", summary.Failed, )) } + if failed := summary.DependencyUpgradesByStatus[extensions.UpgradeStatusFailed]; failed > 0 { + noun := "dependencies" + if failed == 1 { + noun = "dependency" + } + parts = append(parts, output.WithErrorFormat("%d %s failed", failed, noun)) + } if len(parts) > 0 { console.Message(ctx, " "+strings.Join(parts, ", ")) } - if summary.Failed > 0 { + if summary.HasFailures() { console.Message(ctx, "") console.Message(ctx, fmt.Sprintf( " Run '%s' to retry failed extensions.", @@ -2998,17 +3469,38 @@ func displayUpgradeSummary( } // upgradeActionResult builds the ActionResult and error from batch -// upgrade results. Returns a non-nil error when any extension failed. +// upgrade results. Returns a non-nil error when any extension or dependency failed. func upgradeActionResult( results []extensions.UpgradeResult, ) (*actions.ActionResult, error) { summary := extensions.NewUpgradeSummary(results) + var failures []string if summary.Failed > 0 { - return nil, fmt.Errorf( + failures = append(failures, fmt.Sprintf( "%d of %d extensions failed to update", summary.Failed, summary.Total, - ) + )) + } + if failed := summary.DependencyUpgradesByStatus[extensions.UpgradeStatusFailed]; failed > 0 { + noun := "dependencies" + if failed == 1 { + noun = "dependency" + } + failures = append(failures, fmt.Sprintf("%d extension %s failed to update", failed, noun)) + } + if len(failures) > 0 { + message := strings.Join(failures, "\n") + var dependencyErrors []error + for _, result := range results { + if dependencyErr := dependencyUpgradeError(result.DependencyUpgrades); dependencyErr != nil { + dependencyErrors = append(dependencyErrors, dependencyErr) + } + } + if dependencyErr := errors.Join(dependencyErrors...); dependencyErr != nil { + return nil, fmt.Errorf("%s: %w", message, dependencyErr) + } + return nil, errors.New(message) } return &actions.ActionResult{ diff --git a/cli/azd/cmd/extension_show_test.go b/cli/azd/cmd/extension_show_test.go new file mode 100644 index 00000000000..5645900b8b5 --- /dev/null +++ b/cli/azd/cmd/extension_show_test.go @@ -0,0 +1,540 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/Masterminds/semver/v3" + "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/input" + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/stretchr/testify/require" +) + +const showTestRegistryURL = "https://test.example.com/show-registry.json" + +// runShowJSON runs `azd extension show --output json` and decodes the result. +func runShowJSON( + t *testing.T, + manager *extensions.Manager, + sourceManager *extensions.SourceManager, + extensionId string, +) extensionShowItem { + t.Helper() + + var buf bytes.Buffer + action := &extensionShowAction{ + args: []string{extensionId}, + flags: &extensionShowFlags{global: &internal.GlobalCommandOptions{NoPrompt: true}}, + console: mockinput.NewMockConsole(), + formatter: &output.JsonFormatter{}, + writer: &buf, + sourceManager: sourceManager, + extensionManager: manager, + } + _, err := action.Run(t.Context()) + require.NoError(t, err) + + var item extensionShowItem + require.NoError(t, json.Unmarshal(buf.Bytes(), &item)) + return item +} + +func TestExtensionShowAction_ExplainsDependenciesAndDependents(t *testing.T) { + t.Parallel() + + mockCtx := mocks.NewMockContext(t.Context()) + registry := testRegistry(&extensions.ExtensionMetadata{ + Id: "azure.ai.agents", + Source: "test", + DisplayName: "Agents", + Namespace: "ai.agent", + Versions: []extensions.ExtensionVersion{ + { + Version: "1.0.0", + Dependencies: []extensions.ExtensionDependency{{Id: "azure.ai.projects", Version: "~1.0.0"}}, + }, + { + Version: "1.1.0", + RequiredAzdVersion: ">=1.0.0", + Dependencies: []extensions.ExtensionDependency{{Id: "azure.ai.projects", Version: "~1.0.0"}}, + }, + {Version: "2.0.0", RequiredAzdVersion: ">=9.0.0"}, + }, + }) + installed := map[string]*extensions.Extension{ + "microsoft.foundry": { + Id: "microsoft.foundry", Version: "1.0.0", Source: "test", + Dependencies: []extensions.ExtensionDependency{{Id: "azure.ai.agents", Version: "~1.0.0"}}, + }, + "azure.ai.agents": { + Id: "azure.ai.agents", Version: "1.0.0", Source: "test", InstalledAsDependency: true, + Dependencies: []extensions.ExtensionDependency{{Id: "azure.ai.projects", Version: "~1.0.0"}}, + }, + "azure.ai.projects": { + Id: "azure.ai.projects", Version: "2.0.0", Source: "test", InstalledAsDependency: true, + }, + } + manager, sourceManager := createUpgradeTestManagerWithOptions( + t, mockCtx, installed, showTestRegistryURL, registry, + extensions.ManagerOptions{AzdVersion: semver.MustParse("1.5.0")}, + ) + + item := runShowJSON(t, manager, sourceManager, "azure.ai.agents") + require.Equal(t, "azure.ai.agents", item.Id) + require.Equal(t, "test", item.Source) + require.Equal(t, "1.0.0", item.InstalledVersion) + require.True(t, item.InstalledAsDependency) + require.Equal(t, "2.0.0", item.LatestVersion) + require.Equal(t, "1.1.0", item.LatestCompatibleVersion) + require.True(t, item.UpdateAvailable) + require.Equal(t, ">=9.0.0", item.RequiresAzd) + require.Equal(t, []string{"1.1.0", "1.0.0"}, item.OtherVersions) + require.Equal(t, []extensionShowDependency{ + {Id: "azure.ai.projects", Version: "~1.0.0", InstalledVersion: "2.0.0", Satisfied: false}, + }, item.Dependencies) + require.Equal(t, []extensionShowDependent{{Id: "microsoft.foundry", Version: "1.0.0"}}, item.RequiredBy) + + // Dependency rows come from the installed snapshot, which the registry cannot override. + projects := runShowJSON(t, manager, sourceManager, "azure.ai.projects") + require.Empty(t, projects.LatestVersion, "not listed by any source") + require.Equal(t, []extensionShowDependent{{Id: "azure.ai.agents", Version: "1.0.0"}}, projects.RequiredBy) +} + +func TestExtensionShowAction_JSONKeys(t *testing.T) { + t.Parallel() + + // Decoding into the tagged struct would accept the old PascalCase keys, so the contract + // is checked on the raw object: camelCase names and no empty fields. + mockCtx := mocks.NewMockContext(t.Context()) + installed := map[string]*extensions.Extension{ + "ext-a": {Id: "ext-a", Version: "1.0.0", Source: "test", InstalledAsDependency: true}, + } + manager, sourceManager := createUpgradeTestManager( + t, mockCtx, installed, showTestRegistryURL, + testRegistry(&extensions.ExtensionMetadata{ + Id: "ext-a", + Source: "test", + DisplayName: "A", + Versions: []extensions.ExtensionVersion{{Version: "1.0.0"}}, + }), + ) + + var buf bytes.Buffer + action := &extensionShowAction{ + args: []string{"ext-a"}, + flags: &extensionShowFlags{global: &internal.GlobalCommandOptions{NoPrompt: true}}, + console: mockinput.NewMockConsole(), + formatter: &output.JsonFormatter{}, + writer: &buf, + sourceManager: sourceManager, + extensionManager: manager, + } + _, err := action.Run(t.Context()) + require.NoError(t, err) + + var raw map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &raw)) + require.Equal(t, "ext-a", raw["id"]) + require.Equal(t, "1.0.0", raw["installedVersion"]) + require.Equal(t, "1.0.0", raw["latestVersion"]) + require.Equal(t, true, raw["installedAsDependency"]) + for _, legacyKey := range []string{"Id", "Name", "InstalledVersion", "LatestVersion", "AvailableVersions"} { + require.NotContains(t, raw, legacyKey) + } + for _, emptyKey := range []string{"website", "namespace", "tags", "otherVersions", "dependencies", "requiredBy"} { + require.NotContains(t, raw, emptyKey, "empty fields are omitted") + } +} + +func TestExtensionShowAction_InstalledWithoutRegistryEntry(t *testing.T) { + t.Parallel() + + mockCtx := mocks.NewMockContext(t.Context()) + installed := map[string]*extensions.Extension{ + "bundled.ext": { + Id: "bundled.ext", DisplayName: "Bundled", Description: "From a bundle", + Version: "0.1.0", Source: extensions.BundleSourceName, Namespace: "bundled", Usage: "azd bundled", + Capabilities: []extensions.CapabilityType{extensions.CustomCommandCapability}, + Dependencies: []extensions.ExtensionDependency{{Id: "other.ext"}}, + }, + "other.ext": { + Id: "other.ext", DisplayName: "Other", Version: "0.1.0", + Source: extensions.BundleSourceName, InstalledAsDependency: true, + }, + } + manager, sourceManager := createUpgradeTestManager(t, mockCtx, installed, showTestRegistryURL, testRegistry()) + + item := runShowJSON(t, manager, sourceManager, "BUNDLED.EXT") + require.Equal(t, "bundled.ext", item.Id, "the record's id wins over the typed casing") + require.Equal(t, "Bundled", item.Name) + require.Equal(t, "From a bundle", item.Description) + require.Equal(t, extensions.BundleSourceName, item.Source) + require.Equal(t, "bundled", item.Namespace) + require.Equal(t, "0.1.0", item.InstalledVersion) + require.Empty(t, item.LatestVersion) + require.Equal(t, "azd bundled", item.Usage) + require.Equal(t, []extensions.CapabilityType{extensions.CustomCommandCapability}, item.Capabilities) + require.Equal(t, []extensionShowDependency{ + {Id: "other.ext", InstalledVersion: "0.1.0", Satisfied: true}, + }, item.Dependencies) + + other := runShowJSON(t, manager, sourceManager, "other.ext") + require.True(t, other.InstalledAsDependency) + require.Equal(t, []extensionShowDependent{{Id: "bundled.ext", Version: "0.1.0"}}, other.RequiredBy) +} + +func TestExtensionShowAction_RegistryLookupFailure(t *testing.T) { + t.Parallel() + + for _, tt := range []struct { + name string + installedSource string + source string + json bool + canceled bool + }{ + {name: "installed_text", installedSource: extensions.BundleSourceName}, + {name: "installed_json", installedSource: "test", json: true}, + {name: "not_installed"}, + {name: "explicit_source", installedSource: "test", source: "test"}, + {name: "canceled", installedSource: extensions.BundleSourceName, canceled: true}, + } { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + mockCtx := mocks.NewMockContext(ctx) + installed := map[string]*extensions.Extension{} + if tt.installedSource != "" { + installed["test.ext"] = &extensions.Extension{ + Id: "test.ext", DisplayName: "Installed extension", Version: "1.0.0", Source: tt.installedSource, + InstalledAsDependency: true, + Dependencies: []extensions.ExtensionDependency{{Id: "test.child"}}, + } + } + manager, sourceManager := createUpgradeTestManager( + t, mockCtx, installed, showTestRegistryURL, extensions.Registry{SchemaVersion: "2.0"}, + ) + + var stdout, stderr bytes.Buffer + var formatter output.Formatter = &output.NoneFormatter{} + consoleWriter := &stdout + if tt.json { + formatter = &output.JsonFormatter{} + consoleWriter = &stderr + } + console := input.NewConsole(true, false, input.Writers{Output: consoleWriter}, input.ConsoleHandles{ + Stdin: &bytes.Buffer{}, Stdout: &stdout, Stderr: &stderr, + }, formatter, nil) + action := &extensionShowAction{ + args: []string{"test.ext"}, + flags: &extensionShowFlags{ + source: tt.source, global: &internal.GlobalCommandOptions{NoPrompt: true}, + }, + console: console, formatter: formatter, writer: &stdout, + sourceManager: sourceManager, extensionManager: manager, + } + if tt.canceled { + cancel() + } + + _, err := action.Run(ctx) + if tt.canceled { + require.ErrorIs(t, err, context.Canceled) + } else if tt.installedSource == "" || tt.source != "" { + require.ErrorAs(t, err, new(*extensions.ErrUnsupportedRegistrySchema)) + } else { + require.NoError(t, err) + require.Contains(t, consoleWriter.String(), "Showing installed metadata only.") + if tt.json { + var item extensionShowItem + require.NoError(t, json.Unmarshal(stdout.Bytes(), &item)) + require.Equal(t, "test.ext", item.Id) + require.Equal(t, "1.0.0", item.InstalledVersion) + require.True(t, item.InstalledAsDependency) + require.Empty(t, item.LatestVersion) + require.False(t, item.UpdateAvailable) + require.Equal(t, []extensionShowDependency{{Id: "test.child"}}, item.Dependencies) + } else { + require.Contains(t, stdout.String(), "Installed extension") + require.Contains(t, stdout.String(), "1.0.0") + require.Contains(t, stdout.String(), "test.child") + } + return + } + require.Empty(t, stdout.String()) + require.Empty(t, stderr.String()) + }) + } +} + +func TestExtensionShowAction_PrefersInstalledSource(t *testing.T) { + t.Parallel() + + mockCtx := mocks.NewMockContext(t.Context()) + installed := map[string]*extensions.Extension{ + "dup.ext": {Id: "dup.ext", Version: "1.0.0", Source: "other"}, + } + manager, sourceManager := createUpgradeTestManagerWithSources( + t, mockCtx, installed, + map[string]upgradeTestSource{ + "test": { + url: showTestRegistryURL, + registry: testRegistry(testExtMeta("dup.ext", "1.0.0", "test")), + }, + "other": { + url: "https://other.example.com/registry.json", + registry: testRegistry(testExtMeta("dup.ext", "1.0.0", "other")), + }, + }, + extensions.ManagerOptions{}, + ) + + // Under --no-prompt two matching sources fail unless the installed source is chosen. + item := runShowJSON(t, manager, sourceManager, "dup.ext") + require.Equal(t, "other", item.Source) + require.Equal(t, "1.0.0", item.InstalledVersion) + require.Empty(t, item.InstalledSource) +} + +func TestExtensionShowAction_InstalledFromAnotherSource(t *testing.T) { + t.Parallel() + + mockCtx := mocks.NewMockContext(t.Context()) + installed := map[string]*extensions.Extension{ + "ext-a": {Id: "ext-a", Version: "1.0.0", Source: "removed-registry"}, + } + manager, sourceManager := createUpgradeTestManager( + t, mockCtx, installed, showTestRegistryURL, testRegistry(testExtMeta("ext-a", "2.0.0", "test")), + ) + + item := runShowJSON(t, manager, sourceManager, "ext-a") + require.Equal(t, "test", item.Source) + require.Equal(t, "1.0.0", item.InstalledVersion) + require.Equal(t, "removed-registry", item.InstalledSource) + require.False(t, item.UpdateAvailable, "update state is only reported against the installed source") +} + +func TestExtensionShowAction_LegacyDependenciesUseInstalledSource(t *testing.T) { + t.Parallel() + for _, source := range []string{"test", "other"} { + t.Run(source, func(t *testing.T) { + t.Parallel() + mockCtx := mocks.NewMockContext(t.Context()) + manager, sourceManager := createUpgradeTestManager( + t, mockCtx, + map[string]*extensions.Extension{ + "test.ext": {Id: "test.ext", Version: "1.0.0", Source: source}, + }, + showTestRegistryURL, + testRegistry(&extensions.ExtensionMetadata{ + Id: "test.ext", Source: "test", + Versions: []extensions.ExtensionVersion{{ + Version: "1.0.0", Dependencies: []extensions.ExtensionDependency{{Id: "test.leaf"}}, + }}, + }), + ) + item := runShowJSON(t, manager, sourceManager, "test.ext") + if source == "test" { + require.Equal(t, []extensionShowDependency{{Id: "test.leaf"}}, item.Dependencies) + } else { + require.Empty(t, item.Dependencies, "another source's release does not describe the installed extension") + } + }) + } +} + +func TestExtensionShowAction_SourceFilterIsNotBypassedByInstalledRecord(t *testing.T) { + t.Parallel() + + mockCtx := mocks.NewMockContext(t.Context()) + installed := map[string]*extensions.Extension{ + "ext-a": {Id: "ext-a", Version: "1.0.0", Source: "azd"}, + } + manager, sourceManager := createUpgradeTestManager( + t, mockCtx, installed, showTestRegistryURL, testRegistry(testExtMeta("other-ext", "1.0.0", "test")), + ) + + var buf bytes.Buffer + action := &extensionShowAction{ + args: []string{"ext-a"}, + flags: &extensionShowFlags{source: "test", global: &internal.GlobalCommandOptions{NoPrompt: true}}, + console: mockinput.NewMockConsole(), + formatter: &output.JsonFormatter{}, + writer: &buf, + sourceManager: sourceManager, + extensionManager: manager, + } + + // The requested source does not carry ext-a; the installed record must not stand in for it. + _, err := action.Run(t.Context()) + require.ErrorContains(t, err, "no extensions found") +} + +func TestExtensionShowAction_UpdateStateWithoutCompatibilityPolicy(t *testing.T) { + t.Parallel() + + mockCtx := mocks.NewMockContext(t.Context()) + installed := map[string]*extensions.Extension{ + "ext-a": {Id: "ext-a", Version: "1.0.0", Source: "test"}, + } + manager, sourceManager := createUpgradeTestManagerWithOptions( + t, mockCtx, installed, showTestRegistryURL, + testRegistry(&extensions.ExtensionMetadata{ + Id: "ext-a", + Source: "test", + Versions: []extensions.ExtensionVersion{ + {Version: "1.0.0"}, + {Version: "2.0.0", RequiredAzdVersion: ">=9.0.0"}, + }, + }), + extensions.ManagerOptions{IgnoreAzdCompatibility: true}, + ) + + // Dev builds have no azd version to check against: every release is installable. + item := runShowJSON(t, manager, sourceManager, "ext-a") + require.True(t, item.UpdateAvailable) + require.Equal(t, "2.0.0", item.LatestCompatibleVersion) +} + +func TestExtensionShowItem_Display_Layout(t *testing.T) { + t.Parallel() + + t.Run("installed_with_update_and_dependencies", func(t *testing.T) { + t.Parallel() + item := &extensionShowItem{ + Id: "azure.ai.agents", + Name: "Agents", + Description: "Agents extension", + Source: "azd", + Tags: []string{"ai"}, + LatestVersion: "2.0.0", + LatestCompatibleVersion: "1.1.0", + RequiresAzd: ">=9.0.0", + OtherVersions: []string{"1.1.0", "1.0.0"}, + InstalledVersion: "1.0.0", + InstalledAsDependency: true, + UpdateAvailable: true, + Dependencies: []extensionShowDependency{ + {Id: "azure.ai.projects", Version: "~1.0.0", InstalledVersion: "2.0.0"}, + {Id: "azure.ai.inspector", InstalledVersion: "1.0.0", Satisfied: true}, + {Id: "azure.ai.skills", Version: "^1.0.0"}, + }, + RequiredBy: []extensionShowDependent{{Id: "microsoft.foundry", Version: "1.0.0"}}, + azdVersion: "1.5.0", + latestIncompatible: true, + newerIncompatible: true, + } + + var buf bytes.Buffer + require.NoError(t, item.Display(&buf)) + out := buf.String() + + require.Contains(t, out, "Tags") + require.NotContains(t, out, "Namespace") + require.NotContains(t, out, "Website") + require.NotContains(t, out, "Usage") + require.Contains(t, out, "1.0.0 (update available: 1.1.0)") + require.Contains(t, out, "Installed as") + require.Contains(t, out, "Dependency") + require.Contains(t, out, ">=9.0.0 (not compatible with azd 1.5.0; latest compatible is 1.1.0)") + require.Contains(t, out, "Other Versions") + require.Contains(t, out, "1.1.0, 1.0.0") + require.Contains(t, out, "Dependencies") + require.Contains(t, out, "~1.0.0 (installed 2.0.0, outside constraint)") + require.Contains(t, out, "any version (installed 1.0.0)") + require.Contains(t, out, "^1.0.0 (not installed)") + require.Contains(t, out, "Required By") + require.Contains(t, out, "microsoft.foundry") + }) + + t.Run("not_installed_pack", func(t *testing.T) { + t.Parallel() + item := &extensionShowItem{ + Id: "microsoft.foundry", + Name: "Foundry", + Description: "Pack", + Source: "azd", + LatestVersion: "1.0.0", + } + + var buf bytes.Buffer + require.NoError(t, item.Display(&buf)) + out := buf.String() + + require.Contains(t, out, "Not installed") + require.NotContains(t, out, "N/A") + require.NotContains(t, out, "Usage") + require.NotContains(t, out, "Requires azd") + require.NotContains(t, out, "Other Versions") + }) + + t.Run("installed_latest_is_incompatible", func(t *testing.T) { + t.Parallel() + item := &extensionShowItem{ + Id: "ext-a", + Name: "A", + Description: "A", + Source: "azd", + LatestVersion: "2.0.0", + LatestCompatibleVersion: "1.1.0", + RequiresAzd: ">=9.0.0", + InstalledVersion: "2.0.0", + azdVersion: "1.5.0", + latestIncompatible: true, + } + + var buf bytes.Buffer + require.NoError(t, item.Display(&buf)) + out := buf.String() + + // The installed row has nothing newer to mention, but the constraint is still explained. + require.Contains(t, out, ">=9.0.0 (not compatible with azd 1.5.0; latest compatible is 1.1.0)") + require.NotContains(t, out, "requires a newer azd") + }) + + t.Run("installed_as_dependency", func(t *testing.T) { + t.Parallel() + item := &extensionShowItem{ + Id: "azure.ai.projects", + Name: "Projects", + Description: "Projects extension", + Source: "azd", + LatestVersion: "1.0.0", + InstalledVersion: "1.0.0", + InstalledAsDependency: true, + } + + var buf bytes.Buffer + require.NoError(t, item.Display(&buf)) + require.Contains(t, buf.String(), "Installed as") + require.Contains(t, buf.String(), "Dependency") + }) + + t.Run("installed_from_other_source", func(t *testing.T) { + t.Parallel() + item := &extensionShowItem{ + Id: "ext-a", + Name: "A", + Description: "A", + Source: "test", + LatestVersion: "2.0.0", + InstalledVersion: "1.0.0", + InstalledSource: "removed-registry", + } + + var buf bytes.Buffer + require.NoError(t, item.Display(&buf)) + require.Contains(t, buf.String(), "1.0.0 (from removed-registry)") + }) +} diff --git a/cli/azd/cmd/extension_test.go b/cli/azd/cmd/extension_test.go index 9451f8a9035..581262f18e7 100644 --- a/cli/azd/cmd/extension_test.go +++ b/cli/azd/cmd/extension_test.go @@ -383,6 +383,17 @@ func TestDisplayUpgradeSummary(t *testing.T) { "1 skipped", }, }, + { + name: "dependency_failure_preserves_parent_success", + results: []extensions.UpgradeResult{{ + Status: extensions.UpgradeStatusUpgraded, + DependencyUpgrades: []extensions.UpgradeResult{ + {Status: extensions.UpgradeStatusFailed}, + {Status: extensions.UpgradeStatusFailed}, + }, + }}, + wantMsgs: []string{"1 updated", "2 dependencies failed", "azd extension update "}, + }, } for _, tt := range tests { @@ -447,6 +458,26 @@ func TestUpgradeActionResult(t *testing.T) { ) }) + t.Run("nested_dependency_failure_returns_error", func(t *testing.T) { + t.Parallel() + results := []extensions.UpgradeResult{{ + Status: extensions.UpgradeStatusUpgraded, + DependencyUpgrades: []extensions.UpgradeResult{{ + Status: extensions.UpgradeStatusUpgraded, + DependencyUpgrades: []extensions.UpgradeResult{{ + ExtensionId: "test.leaf", + Status: extensions.UpgradeStatusFailed, + Error: fmt.Errorf("updating dependency: %w", context.Canceled), + }}, + }}, + }} + result, err := upgradeActionResult(results) + require.Nil(t, result) + require.ErrorContains(t, err, "1 extension dependency failed to update") + require.ErrorContains(t, err, "dependency test.leaf:") + require.ErrorIs(t, err, context.Canceled) + }) + t.Run( "partial_failure_returns_error", func(t *testing.T) { @@ -1144,18 +1175,18 @@ func Test_ExtensionShowItem_Display_Minimal(t *testing.T) { func Test_ExtensionShowItem_Display_AllFields(t *testing.T) { t.Parallel() item := &extensionShowItem{ - Id: "full.ext", - Name: "Full Extension", - Description: "Full desc", - Source: "custom-src", - Namespace: "full", - Website: "https://example.com", - LatestVersion: "2.0.0", - InstalledVersion: "1.0.0", - AvailableVersions: []string{"1.0.0", "1.5.0", "2.0.0"}, - Tags: []string{"tool", "testing"}, - Usage: "azd full do-thing", - Capabilities: []extensions.CapabilityType{"mcp"}, + Id: "full.ext", + Name: "Full Extension", + Description: "Full desc", + Source: "custom-src", + Namespace: "full", + Website: "https://example.com", + LatestVersion: "2.0.0", + InstalledVersion: "1.0.0", + OtherVersions: []string{"1.5.0", "1.0.0"}, + Tags: []string{"tool", "testing"}, + Usage: "azd full do-thing", + Capabilities: []extensions.CapabilityType{"mcp"}, Providers: []extensions.Provider{ {Name: "prov1", Type: "host", Description: "Provider 1"}, }, diff --git a/cli/azd/cmd/extension_uninstall_test.go b/cli/azd/cmd/extension_uninstall_test.go new file mode 100644 index 00000000000..465bee81f6a --- /dev/null +++ b/cli/azd/cmd/extension_uninstall_test.go @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "maps" + "slices" + "strings" + "testing" + + "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/input" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/stretchr/testify/require" +) + +func uninstallTestRecord(id, version string, asDependency bool, dependencies ...string) *extensions.Extension { + record := &extensions.Extension{ + Id: id, + Version: version, + Source: "test", + InstalledAsDependency: asDependency, + } + for _, dependency := range dependencies { + record.Dependencies = append(record.Dependencies, extensions.ExtensionDependency{Id: dependency}) + } + return record +} + +// uninstallTestInstall mirrors the microsoft.foundry pack shape: an explicit pack whose +// dependencies were installed for it, where agents itself requires inspector and projects. +func uninstallTestInstall() map[string]*extensions.Extension { + return map[string]*extensions.Extension{ + "microsoft.foundry": uninstallTestRecord("microsoft.foundry", "1.0.0", false, + "azure.ai.agents", "azure.ai.projects", "azure.ai.inspector", "azure.ai.skills"), + "azure.ai.agents": uninstallTestRecord("azure.ai.agents", "2.0.0", true, + "azure.ai.inspector", "azure.ai.projects"), + "azure.ai.projects": uninstallTestRecord("azure.ai.projects", "3.0.0", true), + "azure.ai.inspector": uninstallTestRecord("azure.ai.inspector", "4.0.0", true), + "azure.ai.skills": uninstallTestRecord("azure.ai.skills", "5.0.0", true), + } +} + +func newUninstallTestAction( + t *testing.T, + installed map[string]*extensions.Extension, + flags extensionUninstallFlags, + args ...string, +) (*extensionUninstallAction, *mockinput.MockConsole) { + t.Helper() + t.Setenv("AZD_CONFIG_DIR", t.TempDir()) + + mockCtx := mocks.NewMockContext(t.Context()) + manager, _ := createUpgradeTestManager( + t, mockCtx, installed, "https://test.example.com/registry.json", testRegistry(), + ) + console := mockinput.NewMockConsole() + + return &extensionUninstallAction{ + args: args, + flags: &flags, + console: console, + extensionManager: manager, + }, console +} + +func remainingInstalledIds(t *testing.T, manager *extensions.Manager) []string { + t.Helper() + installed, err := manager.ListInstalled() + require.NoError(t, err) + return slices.Sorted(maps.Keys(installed)) +} + +func TestExtensionUninstallAction_BlockedByDependents(t *testing.T) { + action, _ := newUninstallTestAction(t, uninstallTestInstall(), extensionUninstallFlags{}, "azure.ai.projects") + + _, err := action.Run(t.Context()) + require.ErrorContains(t, err, + "extension azure.ai.projects is required by installed extensions: azure.ai.agents, microsoft.foundry") + suggestionErr, ok := errors.AsType[*internal.ErrorWithSuggestion](err) + require.True(t, ok) + require.Contains(t, suggestionErr.Suggestion, "azd extension uninstall azure.ai.agents microsoft.foundry") + require.Contains(t, suggestionErr.Suggestion, "--force") + require.Len(t, remainingInstalledIds(t, action.extensionManager), 5, "nothing is removed when blocked") +} + +func TestExtensionUninstallAction_ForceWarnsAboutDependents(t *testing.T) { + action, console := newUninstallTestAction( + t, uninstallTestInstall(), extensionUninstallFlags{force: true}, "azure.ai.projects", + ) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + require.Equal(t, + []string{"azure.ai.agents", "azure.ai.inspector", "azure.ai.skills", "microsoft.foundry"}, + remainingInstalledIds(t, action.extensionManager), + ) + messages := console.Output() + require.Len(t, messages, 3) + require.Contains(t, messages[1], + "azure.ai.projects is required by azure.ai.agents, microsoft.foundry") + require.Empty(t, messages[2], "separate warnings from uninstall progress") +} + +func TestExtensionUninstallAction_PackRemovesOrphanedDependencies(t *testing.T) { + action, console := newUninstallTestAction(t, uninstallTestInstall(), extensionUninstallFlags{}, "microsoft.foundry") + console.WhenConfirm(func(input.ConsoleOptions) bool { return true }).Respond(true) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + require.Empty(t, remainingInstalledIds(t, action.extensionManager)) + + output := strings.Join(console.Output(), "\n") + require.Contains(t, output, + "Uninstalling microsoft.foundry will leave these dependencies unused:") + require.Contains(t, output, "Remove these 4 dependencies?") + for _, id := range []string{"azure.ai.agents", "azure.ai.projects", "azure.ai.inspector", "azure.ai.skills"} { + require.Contains(t, output, " • "+id+" ") + } + require.Contains(t, output, "no longer required") +} + +func TestExtensionUninstallAction_DeclinedDependencyRemovalKeepsThem(t *testing.T) { + action, console := newUninstallTestAction(t, uninstallTestInstall(), extensionUninstallFlags{}, "microsoft.foundry") + console.WhenConfirm(func(input.ConsoleOptions) bool { return true }).Respond(false) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + require.Equal(t, + []string{"azure.ai.agents", "azure.ai.inspector", "azure.ai.projects", "azure.ai.skills"}, + remainingInstalledIds(t, action.extensionManager), + ) + + // Declining is not a claim of ownership: the records stay dependency installs. + installed, err := action.extensionManager.ListInstalled() + require.NoError(t, err) + require.True(t, installed["azure.ai.agents"].InstalledAsDependency) + + output := strings.Join(console.Output(), "\n") + require.Contains(t, output, "kept") + require.Contains(t, output, + "azd extension uninstall azure.ai.agents azure.ai.projects azure.ai.inspector azure.ai.skills") +} + +func TestExtensionUninstallAction_NoPromptWithoutOrphans(t *testing.T) { + // Nothing beyond the named target goes, so there is nothing to confirm. + installed := uninstallTestInstall() + action, console := newUninstallTestAction(t, installed, extensionUninstallFlags{force: true}, "azure.ai.skills") + + _, err := action.Run(t.Context()) + require.NoError(t, err) + require.NotContains(t, strings.Join(console.Output(), "\n"), "Remove ") +} + +func TestExtensionUninstallAction_RetainedDependenciesAreExplained(t *testing.T) { + installed := uninstallTestInstall() + installed["azure.ai.agents"].InstalledAsDependency = false + action, console := newUninstallTestAction(t, installed, extensionUninstallFlags{}, "microsoft.foundry") + console.WhenConfirm(func(input.ConsoleOptions) bool { return true }).Respond(true) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + require.Equal(t, + []string{"azure.ai.agents", "azure.ai.inspector", "azure.ai.projects"}, + remainingInstalledIds(t, action.extensionManager), + ) + + output := strings.Join(console.Output(), "\n") + require.Contains(t, output, "not installed as a dependency") + require.Contains(t, output, "required by azure.ai.agents") + require.Contains(t, output, "Remove this dependency?") + require.NotContains(t, output, "these 1") +} + +func TestExtensionUninstallAction_DeclinedRemovalRevalidatesDependents(t *testing.T) { + for _, force := range []bool{false, true} { + name := "blocked" + if force { + name = "forced" + } + t.Run(name, func(t *testing.T) { + action, console := newUninstallTestAction(t, map[string]*extensions.Extension{ + "test.parent": uninstallTestRecord("test.parent", "1.0.0", false, "test.child"), + "test.child": uninstallTestRecord("test.child", "1.0.0", true, "test.parent"), + }, extensionUninstallFlags{force: force}, "test.parent") + console.WhenConfirm(func(input.ConsoleOptions) bool { return true }).Respond(false) + + _, err := action.Run(t.Context()) + if !force { + require.ErrorContains(t, err, "test.parent is required by installed extensions: test.child") + require.Len(t, remainingInstalledIds(t, action.extensionManager), 2) + return + } + require.NoError(t, err) + require.Equal(t, []string{"test.child"}, remainingInstalledIds(t, action.extensionManager)) + require.Contains(t, strings.Join(console.Output(), "\n"), "test.parent is required by test.child") + }) + } +} + +func TestExtensionUninstallAction_NoDependencies(t *testing.T) { + action, _ := newUninstallTestAction( + t, uninstallTestInstall(), extensionUninstallFlags{noDependencies: true}, "microsoft.foundry", + ) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + require.Equal(t, + []string{"azure.ai.agents", "azure.ai.inspector", "azure.ai.projects", "azure.ai.skills"}, + remainingInstalledIds(t, action.extensionManager), + ) +} + +func TestExtensionUninstallAction_All(t *testing.T) { + action, _ := newUninstallTestAction(t, uninstallTestInstall(), extensionUninstallFlags{all: true}) + + _, err := action.Run(t.Context()) + require.NoError(t, err) + require.Empty(t, remainingInstalledIds(t, action.extensionManager)) +} + +func TestExtensionUninstallAction_RejectsBlankId(t *testing.T) { + // An unset shell variable yields an empty argument; it must not match an arbitrary record. + action, _ := newUninstallTestAction(t, uninstallTestInstall(), extensionUninstallFlags{}, "") + + _, err := action.Run(t.Context()) + require.ErrorIs(t, err, extensions.ErrEmptyExtensionId) + require.Len(t, remainingInstalledIds(t, action.extensionManager), 5) +} + +func TestExtensionUninstallAction_NotInstalled(t *testing.T) { + action, _ := newUninstallTestAction(t, uninstallTestInstall(), extensionUninstallFlags{}, "missing") + + _, err := action.Run(t.Context()) + require.ErrorContains(t, err, "failed to get installed extension") + require.Len(t, remainingInstalledIds(t, action.extensionManager), 5) +} diff --git a/cli/azd/cmd/extension_upgrade_test.go b/cli/azd/cmd/extension_upgrade_test.go index cef1bb840d6..b4a5f1cce15 100644 --- a/cli/azd/cmd/extension_upgrade_test.go +++ b/cli/azd/cmd/extension_upgrade_test.go @@ -16,6 +16,7 @@ import ( "github.com/Masterminds/semver/v3" "github.com/azure/azure-dev/cli/azd/internal" + cmdinternal "github.com/azure/azure-dev/cli/azd/internal/cmd" "github.com/azure/azure-dev/cli/azd/internal/tracing/events" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" "github.com/azure/azure-dev/cli/azd/pkg/config" @@ -26,6 +27,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/azure/azure-dev/cli/azd/test/mocks/mocktracing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" @@ -964,6 +966,35 @@ func TestExtensionLifecycleTelemetrySpans(t *testing.T) { } }) + // The tracing package wires its tracer to the first provider set in the process, so every + // span assertion in this package shares this recorder rather than installing its own. + t.Run("UninstallUsesPersistedCategory", func(t *testing.T) { + const sourceName = "private-source" + action, _ := newUninstallTestAction(t, map[string]*extensions.Extension{ + "ext-a": { + Id: "ext-a", + Version: "1.0.0", + Source: sourceName, + SourceCategory: extensions.SourceCategoryDev, + }, + }, extensionUninstallFlags{}, "ext-a") + + _, err := action.Run(t.Context()) + require.NoError(t, err) + + span := extensionEndedSpan(t, recorder, events.ExtensionUninstallEvent) + attributes := span.Attributes() + require.Equal(t, "ext-a", + extensionSpanAttribute(t, attributes, fields.ExtensionId.Key).Value.AsString()) + require.Equal(t, "1.0.0", + extensionSpanAttribute(t, attributes, fields.ExtensionVersion.Key).Value.AsString()) + require.Equal(t, string(extensions.SourceCategoryDev), + extensionSpanAttribute(t, attributes, fields.ExtensionSourceCategory.Key).Value.AsString()) + for _, attr := range attributes { + require.NotContains(t, attr.Value.String(), sourceName) + } + }) + t.Run("PromotionUsesFixedCategories", func(t *testing.T) { emitPromotionEvent( t.Context(), @@ -1175,6 +1206,140 @@ func TestUpgradeAction_AllWithSourceSkipsExtensionsOutsideSource(t *testing.T) { require.Equal(t, "extension not available in source 'test'", report.Extensions[0].SkipReason) } +func TestExtensionCommands_ReportDependencyFailuresAfterParentUpdate(t *testing.T) { + for _, command := range []string{"install", "update", "update-json"} { + t.Run(command, func(t *testing.T) { + t.Setenv("AZD_CONFIG_DIR", t.TempDir()) + mockCtx := mocks.NewMockContext(t.Context()) + manager, sourceManager := createUpgradeTestManager( + t, mockCtx, + map[string]*extensions.Extension{ + "test.pack": {Id: "test.pack", Version: "1.0.0", Source: "test"}, + "test.child": {Id: "test.child", Version: "1.0.0", Source: "test"}, + }, + "https://test.example.com/dependency-failure-registry.json", + testRegistry( + &extensions.ExtensionMetadata{ + Id: "test.pack", Source: "test", + Versions: []extensions.ExtensionVersion{{ + Version: "2.0.0", + Dependencies: []extensions.ExtensionDependency{ + {Id: "test.child", Version: ">=2.0.0"}, + }, + }}, + }, + testExtMeta("test.child", "1.0.0", "test"), + ), + ) + console := mockinput.NewMockConsole() + var buf bytes.Buffer + var commandErr error + if command == "install" { + action := &extensionInstallAction{ + args: []string{"test.pack"}, + flags: &extensionInstallFlags{ + global: &internal.GlobalCommandOptions{NoPrompt: true}, + }, + console: console, sourceManager: sourceManager, extensionManager: manager, + } + result, err := action.Run(t.Context()) + require.ErrorContains(t, err, "failed to update dependencies for extension test.pack") + require.Nil(t, result) + commandErr = err + } else { + var formatter output.Formatter = &output.NoneFormatter{} + if command == "update-json" { + formatter = &output.JsonFormatter{} + } + action := &extensionUpgradeAction{ + args: []string{"test.pack"}, + flags: &extensionUpgradeFlags{ + global: &internal.GlobalCommandOptions{NoPrompt: true}, + }, + console: console, sourceManager: sourceManager, extensionManager: manager, + formatter: formatter, writer: &buf, + } + result, err := action.Run(t.Context()) + require.ErrorContains(t, err, "1 extension dependency failed to update") + require.Nil(t, result) + commandErr = err + } + + dependencyErr, ok := errors.AsType[*extensions.DependencyVersionNotFoundError](commandErr) + require.True(t, ok, "the command must preserve the dependency error for classification") + require.Equal(t, "test.child", dependencyErr.DependencyId) + require.Equal(t, "test.pack", dependencyErr.ParentId) + require.Equal(t, ">=2.0.0", dependencyErr.Constraint) + + span := &mocktracing.Span{} + cmdinternal.MapError(commandErr, span) + causeSpan := &mocktracing.Span{} + cmdinternal.MapError(dependencyErr, causeSpan) + require.Equal(t, causeSpan.Status.Description, span.Status.Description) + require.NotEqual(t, "internal.unclassified", span.Status.Description) + + parent, err := manager.GetInstalled(extensions.FilterOptions{Id: "test.pack"}) + require.NoError(t, err) + require.Equal(t, "2.0.0", parent.Version, "the successful parent update is not rolled back") + if command == "update-json" { + var report struct { + Extensions []struct { + Status string + DependencyUpgrades []struct{ Name, Status, Error string } + } + Summary extensions.UpgradeSummary + } + require.NoError(t, json.Unmarshal(buf.Bytes(), &report)) + require.Len(t, report.Extensions, 1) + require.Equal(t, "upgraded", report.Extensions[0].Status) + require.Len(t, report.Extensions[0].DependencyUpgrades, 1) + require.Equal(t, "failed", report.Extensions[0].DependencyUpgrades[0].Status) + require.Equal(t, "test.child", report.Extensions[0].DependencyUpgrades[0].Name) + require.NotEmpty(t, report.Extensions[0].DependencyUpgrades[0].Error) + require.Equal(t, 1, report.Summary.Upgraded) + require.Zero(t, report.Summary.Failed, "top-level counters keep their existing meaning") + } else { + rendered := strings.Join(console.Output(), "\n") + require.Contains(t, rendered, "(x) Failed: Updating test.child dependency") + if command == "update" { + require.Contains(t, rendered, "1 updated") + require.Contains(t, rendered, "1 dependency failed") + } + } + }) + } +} + +func TestDependencyUpgradeError(t *testing.T) { + t.Parallel() + + childErr := &extensions.DependencyVersionNotFoundError{ + DependencyId: "test.child", ParentId: "test.pack", Constraint: ">=2.0.0", + } + nestedErr := fmt.Errorf("saving dependency metadata: %w", context.Canceled) + results := []extensions.UpgradeResult{ + {ExtensionId: "test.child", Status: extensions.UpgradeStatusFailed, Error: childErr}, + { + ExtensionId: "test.parent", Status: extensions.UpgradeStatusUpgraded, + DependencyUpgrades: []extensions.UpgradeResult{{ + ExtensionId: "test.nested", Status: extensions.UpgradeStatusFailed, Error: nestedErr, + }}, + }, + } + + err := dependencyUpgradeError(results) + require.ErrorIs(t, err, childErr) + require.ErrorIs(t, err, nestedErr) + require.ErrorIs(t, err, context.Canceled) + require.ErrorContains(t, err, "dependency test.child:") + require.ErrorContains(t, err, "dependency test.nested:") + require.Nil(t, dependencyUpgradeError(nil)) + require.Nil(t, dependencyUpgradeError([]extensions.UpgradeResult{ + {Status: extensions.UpgradeStatusUpgraded}, + {Status: extensions.UpgradeStatusSkipped}, + })) +} + // --------------------------------------------------------------------------- // isNetworkError tests // --------------------------------------------------------------------------- diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index 6d0ac7018d3..dcfc43e3f57 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -1128,23 +1128,36 @@ func (i *initAction) initializeExtensions(ctx context.Context, azdCtx *azdcontex return nil } - installedExtensions, err := i.extensionsManager.ListInstalled() - if err != nil { - return fmt.Errorf("listing installed extensions: %w", err) - } - i.console.Message(ctx, "\nInstalling required extensions...") for extensionId, versionConstraint := range projectConfig.RequiredVersions.Extensions { stepMessage := extensionTaskMessage("Installing", extensionId) i.console.ShowSpinner(ctx, stepMessage, input.Step) - installed, isInstalled := installedExtensions[extensionId] - if isInstalled { - stepMessage += output.WithGrayFormat(" (version %s already installed)", installed.Version) + // Look the record up each time: an extension pack installed earlier in this loop may + // have pulled in a later entry as a dependency. + installed, err := i.extensionsManager.GetInstalled(extensions.FilterOptions{Id: extensionId}) + if err == nil { + skipNote := fmt.Sprintf(" (version %s already installed)", installed.Version) + // The project names this extension, so a record that only a pack pulled in + // becomes explicit and survives when that pack is uninstalled. + if installed.InstalledAsDependency { + if err := i.extensionsManager.MarkExplicitlyInstalled(extensionId); err != nil { + i.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return fmt.Errorf("marking extension %s as explicitly installed: %w", extensionId, err) + } + skipNote = fmt.Sprintf( + " (version %s already installed, marked as explicitly installed)", installed.Version, + ) + } + stepMessage += output.WithGrayFormat("%s", skipNote) i.console.StopSpinner(ctx, stepMessage, input.StepSkipped) continue } + if !errors.Is(err, extensions.ErrInstalledExtensionNotFound) { + i.console.StopSpinner(ctx, stepMessage, input.StepFailed) + return fmt.Errorf("checking installed extension %s: %w", extensionId, err) + } installConstraint := "latest" if versionConstraint != nil { diff --git a/cli/azd/cmd/init_test.go b/cli/azd/cmd/init_test.go index c877708b865..a97a5035420 100644 --- a/cli/azd/cmd/init_test.go +++ b/cli/azd/cmd/init_test.go @@ -1955,3 +1955,84 @@ func Test_SelectDistinctExtension_NoPrompt(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "found in multiple sources") } + +func TestInitializeExtensionsPromotesDependencyInstalledExtension(t *testing.T) { + const registryURL = "https://test.example.com/init-registry.json" + + mockCtx := mocks.NewMockContext(t.Context()) + manager, _ := createUpgradeTestManager( + t, + mockCtx, + map[string]*extensions.Extension{ + "test.pack": { + Id: "test.pack", + Version: "1.0.0", + Source: "test", + Dependencies: []extensions.ExtensionDependency{{Id: "test.child"}}, + }, + "test.child": { + Id: "test.child", + Version: "1.0.0", + Source: "test", + InstalledAsDependency: true, + }, + }, + registryURL, + extensions.Registry{SchemaVersion: extensions.CurrentRegistrySchemaVersion}, + ) + azdCtx := azdcontext.NewAzdContextWithDirectory(t.TempDir()) + require.NoError(t, project.Save(t.Context(), &project.ProjectConfig{ + Name: "test-project", + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{"test.child": nil}, + }, + }, azdCtx.ProjectPath())) + action := &initAction{ + console: mockCtx.Console, + extensionsManager: manager, + flags: &initFlags{global: &internal.GlobalCommandOptions{}}, + } + + // The project names test.child directly, so the record a pack pulled in becomes explicit. + require.NoError(t, action.initializeExtensions(t.Context(), azdCtx)) + child, err := manager.GetInstalled(extensions.FilterOptions{Id: "test.child"}) + require.NoError(t, err) + require.False(t, child.InstalledAsDependency) +} + +func TestInitializeExtensionsReturnsInstalledConfigError(t *testing.T) { + const registryURL = "https://test.example.com/init-registry.json" + + mockCtx := mocks.NewMockContext(t.Context()) + cfg := config.NewEmptyConfig() + require.NoError(t, cfg.Set("extension.installed", "invalid")) + mockCtx.ConfigManager.WithConfig(cfg) + + manager, _ := createUpgradeTestManager( + t, + mockCtx, + nil, + registryURL, + extensions.Registry{SchemaVersion: extensions.CurrentRegistrySchemaVersion}, + ) + azdCtx := azdcontext.NewAzdContextWithDirectory(t.TempDir()) + require.NoError(t, project.Save(t.Context(), &project.ProjectConfig{ + Name: "test-project", + RequiredVersions: &project.RequiredVersions{ + Extensions: map[string]*string{"test.extension": nil}, + }, + }, azdCtx.ProjectPath())) + action := &initAction{ + console: mockCtx.Console, + extensionsManager: manager, + flags: &initFlags{global: &internal.GlobalCommandOptions{}}, + } + + err := action.initializeExtensions(t.Context(), azdCtx) + require.ErrorContains(t, err, "checking installed extension test.extension") + require.ErrorContains(t, err, "failed to get extensions section") + require.Equal(t, []mockinput.SpinnerOp{ + {Op: mockinput.SpinnerOpShow, Message: "Installing test.extension", Format: input.Step}, + {Op: mockinput.SpinnerOpStop, Message: "Installing test.extension", Format: input.StepFailed}, + }, mockCtx.Console.SpinnerOps()) +} diff --git a/cli/azd/cmd/project_extension_auto_install.go b/cli/azd/cmd/project_extension_auto_install.go index a1c26548cde..288664ad307 100644 --- a/cli/azd/cmd/project_extension_auto_install.go +++ b/cli/azd/cmd/project_extension_auto_install.go @@ -386,15 +386,30 @@ func installedProvidesProvider( capability extensions.CapabilityType, providerName string, ) bool { - for extension := range maps.Values(installed) { + for _, extension := range installed { if extensionProvidesProvider(extension.Capabilities, extension.Providers, capability, providerName) { return true } } - return false } +// promoteProjectRequiredExtension marks an installed extension the project requires as an +// explicit install, so a record that only a pack pulled in survives when that pack is +// uninstalled. Explicit records are left untouched. +func promoteProjectRequiredExtension( + extensionManager extensionAutoInstallManager, + installed *extensions.Extension, +) error { + if !installed.InstalledAsDependency { + return nil + } + if err := extensionManager.MarkExplicitlyInstalled(installed.Id); err != nil { + return fmt.Errorf("marking extension %s as explicitly installed: %w", installed.Id, err) + } + return nil +} + func extensionProvidesProvider( capabilities []extensions.CapabilityType, providers []extensions.Provider, @@ -513,6 +528,11 @@ func missingProjectExtensions( if err := validateInstalledExtensionVersion(installedExtension, versionPreference); err != nil { return nil, err } + // The project requires this extension in its own right, so a record that only + // a pack pulled in becomes explicit and survives when that pack is uninstalled. + if err := promoteProjectRequiredExtension(extensionManager, installedExtension); err != nil { + return nil, err + } continue } @@ -553,8 +573,11 @@ func missingProjectExtensions( } addProvider := func(capability extensions.CapabilityType, provider string) error { - if provider == "" || providerIsBuiltIn(capability, provider) || - installedProvidesProvider(installed, capability, provider) { + if provider == "" || providerIsBuiltIn(capability, provider) { + return nil + } + // Reusing an inferred provider does not make it an explicitly requested installation. + if installedProvidesProvider(installed, capability, provider) { return nil } diff --git a/cli/azd/cmd/telemetry_test.go b/cli/azd/cmd/telemetry_test.go index dad49710406..400c9f6f6a4 100644 --- a/cli/azd/cmd/telemetry_test.go +++ b/cli/azd/cmd/telemetry_test.go @@ -25,6 +25,7 @@ import ( func TestTelemetryEventConstants(t *testing.T) { t.Parallel() require.Equal(t, "ext.update", events.ExtensionUpdateEvent) + require.Equal(t, "ext.uninstall", events.ExtensionUninstallEvent) } // TestTelemetryFieldConstants verifies that all telemetry field constants added for @@ -1275,21 +1276,22 @@ func TestCommandTelemetryCoverage(t *testing.T) { "extension show", // extension.source.kind // extension.source.category "extension source add", - "extension update", // extension.source.kind + extension update spans - "hooks run", // hooks.name, hooks.type - "infra generate", // infra.provider - "init", // init.method, appinit.* fields - "package", // (via hooks middleware) - "pipeline config", // pipeline.provider, pipeline.auth - "provision", // infra.provider (resolved provider, via provisioning manager) - "restore", // (via hooks middleware) - "tool check", // tool.check.updates_available - "tool install", // tool.id(s), tool.dry_run, tool.install.* aggregate + per-tool fields - "tool show", // tool.id - "tool uninstall", // tool.id(s), tool.dry_run, tool.install.* aggregate + per-tool fields - "tool update", // tool.id(s), tool.dry_run, tool.install.* aggregate + tool.update.* versions - "up", // infra.provider (via provisioning manager; composes provision+deploy) - "update", // update.* fields + "extension uninstall", // ext.uninstall span per attempted extension removal + "extension update", // extension.source.kind + extension update spans + "hooks run", // hooks.name, hooks.type + "infra generate", // infra.provider + "init", // init.method, appinit.* fields + "package", // (via hooks middleware) + "pipeline config", // pipeline.provider, pipeline.auth + "provision", // infra.provider (resolved provider, via provisioning manager) + "restore", // (via hooks middleware) + "tool check", // tool.check.updates_available + "tool install", // tool.id(s), tool.dry_run, tool.install.* aggregate + per-tool fields + "tool show", // tool.id + "tool uninstall", // tool.id(s), tool.dry_run, tool.install.* aggregate + per-tool fields + "tool update", // tool.id(s), tool.dry_run, tool.install.* aggregate + tool.update.* versions + "up", // infra.provider (via provisioning manager; composes provision+deploy) + "update", // update.* fields } // Commands that rely ONLY on global middleware telemetry (command name, flags, diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 5cdfe89245b..8341ac47f4e 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6155,11 +6155,13 @@ const completionSpec: Fig.Spec = { isOptional: true, }, { - name: 'args...', + name: 'args', isOptional: true, + isVariadic: true, }, { - name: 'script-args...', + name: 'script-args', + isVariadic: true, }, ], }, @@ -6323,10 +6325,20 @@ const completionSpec: Fig.Spec = { name: ['--all'], description: 'Uninstall all installed extensions', }, + { + name: ['--force', '-f'], + description: 'Uninstall even if other installed extensions depend on it', + isDangerous: true, + }, + { + name: ['--no-dependencies'], + description: 'Keep dependencies installed for the removed extensions', + }, ], args: { name: 'extension-id', isOptional: true, + isVariadic: true, generators: azdGenerators.listInstalledExtensions, }, }, @@ -6863,8 +6875,9 @@ const completionSpec: Fig.Spec = { }, ], args: { - name: 'tool-name...', + name: 'tool-name', isOptional: true, + isVariadic: true, }, }, { @@ -6902,8 +6915,9 @@ const completionSpec: Fig.Spec = { }, ], args: { - name: 'tool-name...', + name: 'tool-name', isOptional: true, + isVariadic: true, }, }, { @@ -6930,8 +6944,9 @@ const completionSpec: Fig.Spec = { }, ], args: { - name: 'tool-name...', + name: 'tool-name', isOptional: true, + isVariadic: true, }, }, ], diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap index 4609e8efd12..cc4dd529bc2 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap @@ -2,10 +2,12 @@ Uninstall specified extensions. Usage - azd extension uninstall [extension-id] [flags] + azd extension uninstall [extension-id...] [flags] Flags - --all : Uninstall all installed extensions + --all : Uninstall all installed extensions + -f, --force : Uninstall even if other installed extensions depend on it + --no-dependencies : Keep dependencies installed for the removed extensions Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index d863e402938..c985ce9c6bd 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -123,7 +123,11 @@ Lists matching extensions from one or more extension sources. #### `azd extension show [flags]` -Shows detailed information for a specific extension, including description, tags, versions, and installation status. +Shows versions, compatibility, dependencies, and installed dependents. Dependency installs have a separate `Installed as: Dependency` row. + +Prefers the installed source when several sources match. If no registry lists the extension, uses installed metadata. Legacy dependency details require matching source and version metadata. + +If registry lookup returns an error and no `--source` was specified, installed extensions are shown from local metadata with a warning. Registry-only fields, such as the latest available version, are omitted. Cancellation and explicit source requests do not use this fallback. - `-s, --source` Uses a registered source name or registry location (URL or file path). Locations are queried read-only and are not registered. @@ -144,20 +148,24 @@ Installs one or more extensions from any configured extension source. #### `azd extension uninstall [flags]` -Uninstalls one or more previously installed extensions. +Removes the requested extensions and, after confirmation, their unused dependency installs. `--no-prompt` accepts dependency removal. Required extensions are blocked unless their dependents are also removed or `--force` is set. - `--all` Removes all installed extensions when specified. +- `-f, --force` Bypasses dependency protection with a warning. +- `--no-dependencies` Keeps dependency installs. + +See [uninstall flow and ownership](./extension-resolution-and-versioning.md#uninstall-flow) for the full rules. #### `azd extension update ` > Aliased as `azd extension upgrade` for backward compatibility. -Updates one or more extensions to the latest versions. +Updates extensions and their installed dependencies to compatible versions. Uses each extension's stored source or the main registry, unless `--source` overrides it. See [source promotion](./extension-resolution-and-versioning.md#update-and-devmain-promotion) and [update results](./extension-resolution-and-versioning.md#update-results) for source selection and failure reporting. - `--all` Updates all previously installed extensions when specified. - `-v, --version` Updates a specified extension to an exact version, if provided. -- `-s, --source` Specifies the source used for the update. In addition to registered source names, this accepts a registry location (URL or file path). `azd` registers the location as a source before resolving the extension, updates the extension's stored source after a successful update, and rejects locations under `--no-prompt`; add the source first with `azd extension source add`. -- `--no-dependency-updates` Skips updating dependencies declared by extension packs. +- `-s, --source` Uses a source name or registry URL/file. New locations require interactive registration; existing locations are reused. +- `--no-dependency-updates` Keeps installed dependency versions. ## Developing Extensions @@ -1240,6 +1248,8 @@ Pack manifests must include at least one dependency. They may omit `capabilities Updating a pack updates the pack and, by default, reconciles installed dependencies to the highest published versions that satisfy the pack's declared dependency constraints. This dependency reconciliation still runs when the pack itself is already current, because an unchanged pack can point to a dependency range with newer matching versions. Users can disable automatic dependency updates with `azd extension update --no-dependency-updates`. +Uninstalling a pack also removes its unused dependency installs after confirmation. Explicit and shared installations stay. See [uninstall flow](./extension-resolution-and-versioning.md#uninstall-flow) for ownership, confirmation, and protection rules. + #### Provider Registration When your extension provides custom service targets or framework services, declare them in the `providers` section: diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index fd9aeb433ba..5b876ac47a5 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -177,7 +177,7 @@ Once a version is resolved, installation proceeds through these steps: - `.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. +8. **Update configuration**. Save the version, dependencies, and `installedAsDependency` flag under `extension.installed` in `~/.azd/config.json`. See [installed metadata](#installed-metadata) for ownership rules. ### Re-installing over an existing extension @@ -195,6 +195,30 @@ Because each bundle install registers a unique transient source, installing from For registry-backed installs, a required dependency must resolve from the parent's source or the main `azd` registry. For self-contained bundles, it must resolve from the bundle itself. If the dependency is not already installed and cannot be resolved from the applicable sources, the install fails with actionable guidance. +## Uninstall flow + +`azd extension uninstall ` validates the complete removal against installed records, without registry access. Other installed extensions that require a target block removal unless they are also removed or `--force` is set. + +Targets are removed in request order, followed by their unused dependency installs, including transitive dependencies. Explicit and shared installations stay. + +Before removing unused dependencies, azd lists them and asks once. `--no-prompt` accepts removal. Declining keeps those dependencies installed, lists them as kept, and prints a command to remove them later. Normal cleanup lists removed and retained dependencies with reasons. + +`--no-dependencies` keeps dependencies installed without listing them individually. Both this flag and declining cleanup still enforce the required-extension checks. + +Unused dependency cycles downstream of a removed target can remain installed because their members still require each other. Remove a cycle by naming its members together, for example `azd extension uninstall extension.a extension.b`. The normal required-extension checks still apply. + +`azd extension uninstall --all` removes every installed extension. + +### Installed metadata + +Explicit installs, named `requiredVersions.extensions` entries, and independently auto-installed providers are kept when a pack is removed. Reusing an existing inferred provider does not change ownership. Updates preserve ownership. + +Legacy records are never auto-removed and have no known dependencies. Update backfills the target and directly reconciled children only when metadata matches the installed source and version. Deeper records gain metadata when directly updated, reconciled, or reinstalled. Ownership is never guessed. + +## Update results + +Any extension or dependency failure returns a nonzero exit code, including with `--output json`. This includes metadata save failures. Completed updates are kept. The JSON summary counts top-level extensions; dependency outcomes appear in `dependencyUpgrades`. The same failure rule applies when installing over an existing extension. + ## 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 — the recipient runs a single command to install everything from the file, or from a single link when the `.zip` is hosted somewhere reachable. diff --git a/cli/azd/docs/tracing-in-azd.md b/cli/azd/docs/tracing-in-azd.md index 78b28edfccb..19ec497b8e6 100644 --- a/cli/azd/docs/tracing-in-azd.md +++ b/cli/azd/docs/tracing-in-azd.md @@ -82,10 +82,13 @@ adding new events for extension and hook lifecycle telemetry. | ----- | --------- | -------------------- | ---------- | | `ext.run` | Running an installed extension command through `azd`. | Command attributes such as `cmd.entry`, `cmd.flags`, `cmd.args.count`, plus `extension.installed` on the root span. | `name=ext.run`, `cmd.entry=cmd.ai.chat`, `cmd.flags=["model"]`, `cmd.args.count=0` | | `ext.install` | Installing one extension version. | `extension.id`, `extension.version`, and `extension.source.category`. On failure the span uses OpenTelemetry status `Error`; `EndWithStatus` derives the status description from the error type. | `name=ext.install`, `extension.id=microsoft.azd.ai`, `extension.version=1.2.0`, `extension.source.category=azd`, `status=Ok` | -| `ext.update` | Updating one extension attempt. | `extension.id`, `extension.version.from`, `extension.version.to`, `extension.source.category`, `extension.update.duration_ms`, `extension.update.outcome`. | `name=ext.update`, `extension.id=microsoft.azd.ai`, `extension.version.from=1.1.0`, `extension.version.to=1.2.0`, `extension.source.category=azd`, `extension.update.outcome=updated` | +| `ext.update` | Updating one extension attempt. Failed dependency reconciliation emits a failed child span with `extension.id` and `extension.dependency_of`. | `extension.id`, `extension.version.from`, `extension.version.to`, `extension.source.category`, `extension.update.duration_ms`, `extension.update.outcome`. | `name=ext.update`, `extension.id=microsoft.azd.ai`, `extension.version.from=1.1.0`, `extension.version.to=1.2.0`, `extension.source.category=azd`, `extension.update.outcome=updated` | +| `ext.uninstall` | One extension removal attempt, requested by name or triggered by unused dependency cleanup. | `extension.id`, `extension.version`, and `extension.source.category`. Status records success or failure. | `name=ext.uninstall`, `extension.id=microsoft.azd.ai`, `extension.version=1.2.0`, `extension.source.category=azd`, `status=Ok` | | `ext.promote` | Promoting an extension registry entry, such as dev to main. | `extension.id`, `extension.version.from`, `extension.version.to`, `extension.source.category.from`, `extension.source.category.to`. | `name=ext.promote`, `extension.id=microsoft.azd.ai`, `extension.source.category.from=dev`, `extension.source.category.to=azd`, `status=Ok` | | `hooks.exec` | Executing a project, layer, or service lifecycle hook. | `hooks.name`, `hooks.type`, `hooks.kind`; status description uses hook-specific codes such as `hook.validation_failed`. | `name=hooks.exec`, `hooks.name=predeploy`, `hooks.type=service`, `hooks.kind=sh`, `status=Ok` | +`ext.uninstall` combines requested removals and automatic dependency cleanup without a field that distinguishes them. Internal removals during updates do not emit this event. + ### Extension Attributes Extension telemetry attributes are defined in [`fields.go`](../internal/tracing/fields/fields.go). @@ -98,7 +101,7 @@ Extension telemetry attributes are defined in [`fields.go`](../internal/tracing/ | `extension.installed.source.category` | Installed extension source categories, each formatted as `id@category`. | `["microsoft.azd.ai@azd"]` | | `extension.version.from` | Version before an update or promotion. | `1.1.0` | | `extension.version.to` | Version after an update or promotion. | `1.2.0` | -| `extension.source.category` | Fixed source category used for an install, update, or source registration. | `azd` | +| `extension.source.category` | Fixed source category used for an install, update, uninstall, or source registration. | `azd` | | `extension.source.category.from` | Fixed source category before a promotion. | `dev` | | `extension.source.category.to` | Fixed source category after a promotion. | `azd` | | `extension.update.duration_ms` | Update duration in milliseconds. | `1532` | diff --git a/cli/azd/internal/figspec/spec_builder.go b/cli/azd/internal/figspec/spec_builder.go index 122256ff44e..4d36b595827 100644 --- a/cli/azd/internal/figspec/spec_builder.go +++ b/cli/azd/internal/figspec/spec_builder.go @@ -305,6 +305,8 @@ func (sb *SpecBuilder) generateCommandArgs(cmd *cobra.Command, ctx *CommandConte for _, part := range useParts[1:] { isOptional := strings.HasPrefix(part, "[") && strings.HasSuffix(part, "]") argName := strings.Trim(part, "[]<>") + isVariadic := strings.HasSuffix(argName, "...") + argName = strings.TrimSuffix(argName, "...") if strings.HasPrefix(argName, "-") { continue // Skip flags @@ -313,6 +315,7 @@ func (sb *SpecBuilder) generateCommandArgs(cmd *cobra.Command, ctx *CommandConte arg := Arg{ Name: argName, IsOptional: isOptional, + IsVariadic: isVariadic, } if sb.generatorProvider != nil { diff --git a/cli/azd/internal/figspec/spec_builder_test.go b/cli/azd/internal/figspec/spec_builder_test.go index 32d5d50f9b8..7d9a74ffe40 100644 --- a/cli/azd/internal/figspec/spec_builder_test.go +++ b/cli/azd/internal/figspec/spec_builder_test.go @@ -740,3 +740,19 @@ func (m *mockExtensionProvider) HasMetadataCapability(extensionId string) bool { func (m *mockExtensionProvider) LoadMetadata(extensionId string) (*extensions.ExtensionCommandMetadata, error) { return m.metadata, m.loadErr } + +func TestGenerateCommandArgs_Variadic(t *testing.T) { + sb := &SpecBuilder{} + + args := sb.generateCommandArgs( + &cobra.Command{Use: "remove [name...]"}, + &CommandContext{CommandPath: "azd remove"}, + ) + require.Equal(t, []Arg{{Name: "name", IsOptional: true, IsVariadic: true}}, args) + + args = sb.generateCommandArgs( + &cobra.Command{Use: "show "}, + &CommandContext{CommandPath: "azd show"}, + ) + require.Equal(t, []Arg{{Name: "name"}}, args) +} diff --git a/cli/azd/internal/figspec/types.go b/cli/azd/internal/figspec/types.go index cd869c09269..bf2bffdd2be 100644 --- a/cli/azd/internal/figspec/types.go +++ b/cli/azd/internal/figspec/types.go @@ -61,6 +61,8 @@ type Arg struct { Name string Description string IsOptional bool + // IsVariadic marks an argument that accepts several values (`[name...]` in Use). + IsVariadic bool Suggestions []string // Generator is a single TypeScript generator expression (e.g. "azdGenerators.listEnvironments"). Generator string diff --git a/cli/azd/internal/figspec/typescript_renderer.go b/cli/azd/internal/figspec/typescript_renderer.go index 68e8e78bc38..d20ed0363cf 100644 --- a/cli/azd/internal/figspec/typescript_renderer.go +++ b/cli/azd/internal/figspec/typescript_renderer.go @@ -224,6 +224,10 @@ func renderArgs(args []Arg, indentLevel int) string { lines = append(lines, fmt.Sprintf("%s\tisOptional: true,", indent)) } + if arg.IsVariadic { + lines = append(lines, fmt.Sprintf("%s\tisVariadic: true,", indent)) + } + if len(arg.Suggestions) > 0 { suggestions := make([]string, len(arg.Suggestions)) for i, s := range arg.Suggestions { diff --git a/cli/azd/internal/tracing/events/events.go b/cli/azd/internal/tracing/events/events.go index 238fe929696..e665baee777 100644 --- a/cli/azd/internal/tracing/events/events.go +++ b/cli/azd/internal/tracing/events/events.go @@ -35,6 +35,9 @@ const ( ExtensionInstallEvent = "ext.install" // ExtensionUpdateEvent tracks a single extension update attempt. ExtensionUpdateEvent = "ext.update" + // ExtensionUninstallEvent tracks a single extension uninstall attempt by + // `azd extension uninstall`, including attempts for dependencies removed alongside it. + ExtensionUninstallEvent = "ext.uninstall" // ExtensionPromoteEvent tracks a registry promotion (e.g., dev → main). ExtensionPromoteEvent = "ext.promote" // ExtensionUsageEvent carries one usage event an extension reported diff --git a/cli/azd/pkg/extensions/extension.go b/cli/azd/pkg/extensions/extension.go index 7a7f683b682..4ea0c10a688 100644 --- a/cli/azd/pkg/extensions/extension.go +++ b/cli/azd/pkg/extensions/extension.go @@ -28,6 +28,15 @@ type Extension struct { Providers []Provider `json:"providers,omitempty"` McpConfig *McpConfig `json:"mcp,omitempty"` LastUpdateWarning string `json:"lastUpdateWarning,omitempty"` + // Dependencies is the dependency list declared by the installed version, recorded at + // install time so dependency checks work without registry access (bundles, delisted + // extensions). Records written before this field existed have none. + Dependencies []ExtensionDependency `json:"dependencies,omitempty"` + // InstalledAsDependency is true when the extension was installed only because another + // installed extension required it. Explicit installs leave it false, and + // Manager.MarkExplicitlyInstalled clears it when the user later asks for the + // extension directly. + InstalledAsDependency bool `json:"installedAsDependency,omitempty"` stdin *bytes.Buffer stdout *output.DynamicMultiWriter diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 42a4addc262..241ee603737 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -286,6 +286,12 @@ func IsVersionRange(expr string) bool { hasWildcardPart } +// SatisfiesConstraint reports whether an installed version satisfies a declared dependency +// constraint. Empty, "latest", semver constraints, and exact non-semver tags are supported. +func SatisfiesConstraint(constraint, version string) bool { + return matchesVersionConstraint(constraint, version) +} + // matchesVersionConstraint reports whether candidate satisfies expr. // Empty, "latest", semver constraints, and exact non-semver tags are supported. func matchesVersionConstraint(expr, candidate string) bool { @@ -643,22 +649,35 @@ func (m *Manager) IsOfficialRegistrySource(ctx context.Context, name string) (bo // UpdateInstalled updates an installed extension's metadata in the config func (m *Manager) UpdateInstalled(extension *Extension) error { - extensions, err := m.ListInstalled() + return m.updateInstalled(extension.Id, func(*Extension) *Extension { return extension }) +} + +// updateInstalled transforms freshly decoded metadata and invalidates the cache after saving. +func (m *Manager) updateInstalled(id string, update func(*Extension) *Extension) error { + var installed map[string]*Extension + _, err := m.userConfig.GetSection(installedConfigKey, &installed) if err != nil { return fmt.Errorf("failed to list installed extensions: %w", err) } - if _, exists := extensions[extension.Id]; !exists { + current, exists := installed[id] + if !exists { return ErrInstalledExtensionNotFound } - extensions[extension.Id] = extension - - if err := m.userConfig.Set(installedConfigKey, extensions); err != nil { + installed[id] = update(current) + previous, _ := m.userConfig.Get(installedConfigKey) + if err := m.userConfig.Set(installedConfigKey, installed); err != nil { return fmt.Errorf("failed to set extensions section: %w", err) } if err := m.configManager.Save(m.userConfig); err != nil { + if restoreErr := m.userConfig.Set(installedConfigKey, previous); restoreErr != nil { + return errors.Join( + fmt.Errorf("failed to save user config: %w", err), + fmt.Errorf("failed to restore installed extension metadata: %w", restoreErr), + ) + } return fmt.Errorf("failed to save user config: %w", err) } @@ -773,18 +792,21 @@ func (m *Manager) InstallWithOptions( extension *ExtensionMetadata, opts InstallOptions, ) (*ExtensionVersion, error) { - return m.installInternal(ctx, extension, opts, false, map[string]struct{}{}) + return m.installInternal(ctx, extension, opts, false, false, map[string]struct{}{}) } // installInternal installs an extension and its dependencies. // skipDependencyValidation bypasses the installed-dependency constraint check; it is set by the // upgrade flow so dependency reconciliation can run after the parent has been reinstalled. +// asDependency records that the extension is being installed only because another extension +// requires it; the flag is preserved across upgrades and consulted by PlanUninstall. // visited contains the ids currently in flight, which prevents dependency cycles. func (m *Manager) installInternal( ctx context.Context, extension *ExtensionMetadata, opts InstallOptions, skipDependencyValidation bool, + asDependency bool, visited map[string]struct{}, ) (extVersion *ExtensionVersion, err error) { if extension == nil { @@ -866,7 +888,7 @@ func (m *Manager) installInternal( VersionPreference: dependency.Version, SkipMainRegistryDependencyFallback: opts.SkipMainRegistryDependencyFallback, } - if _, err := m.installInternal(ctx, dependencyMetadata, dependencyOpts, false, visited); err != nil { + if _, err := m.installInternal(ctx, dependencyMetadata, dependencyOpts, false, true, visited); err != nil { if !errors.Is(err, ErrExtensionInstalled) { return nil, fmt.Errorf("failed to install dependency: %w", err) } @@ -980,6 +1002,10 @@ func (m *Manager) installInternal( SourceCategory: extension.SourceCategoryOrUnknown(), Providers: selectedVersion.Providers, McpConfig: selectedVersion.McpConfig, + // Declared dependencies are recorded even when SkipDependencies is set: they describe + // the installed version, and uninstall planning relies on them to protect dependencies. + Dependencies: slices.Clone(selectedVersion.Dependencies), + InstalledAsDependency: asDependency, } if err := m.userConfig.Set(installedConfigKey, extensions); err != nil { @@ -1011,6 +1037,11 @@ func (m *Manager) installInternal( // Uninstall uninstalls an extension by name. func (m *Manager) Uninstall(ctx context.Context, id string) error { + // An empty id would match an arbitrary installed record. + if strings.TrimSpace(id) == "" { + return ErrEmptyExtensionId + } + // Get the installed extension extension, err := m.GetInstalled(FilterOptions{Id: id}) if err != nil { @@ -1064,6 +1095,9 @@ type UpgradeOptions struct { // SkipMainRegistryDependencyFallback mirrors the InstallOptions behavior for // the reinstall performed during upgrade. SkipMainRegistryDependencyFallback bool + // PromoteToExplicit clears dependency ownership for an explicit reinstall. + // Updates leave it false to preserve ownership. + PromoteToExplicit bool } // DefaultUpgradeOptions returns UpgradeOptions with dependency upgrades enabled. @@ -1109,11 +1143,65 @@ func (m *Manager) ReconcileDependencies( return selectedVersion, nil, nil } + if err := m.BackfillDependencies(extension.Id, extension.Source, selectedVersion); err != nil { + return nil, nil, err + } + visited := map[string]struct{}{extension.Id: {}} results := m.evaluateDependencyChanges(ctx, extension, selectedVersion, opts, visited) return selectedVersion, results, nil } +// BackfillDependencies records the dependency snapshot on an installed record that predates +// dependency tracking. It only applies to metadata from the installed source and version when +// the record has no snapshot. Ownership is never inferred. +func (m *Manager) BackfillDependencies(id, source string, version *ExtensionVersion) error { + if version == nil { + return nil + } + installed, err := m.GetInstalled(FilterOptions{Id: id}) + if errors.Is(err, ErrInstalledExtensionNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("failed to get installed extension %s: %w", id, err) + } + if len(installed.Dependencies) > 0 || + !strings.EqualFold(installed.Source, source) || + installed.Version != version.Version || + len(version.Dependencies) == 0 { + return nil + } + + if err := m.updateInstalled(installed.Id, func(record *Extension) *Extension { + record.Dependencies = slices.Clone(version.Dependencies) + return record + }); err != nil { + return fmt.Errorf("failed to record dependencies for %s: %w", id, err) + } + return nil +} + +// MarkExplicitlyInstalled prevents automatic removal as an unused dependency. +// Repeated calls are a no-op. +func (m *Manager) MarkExplicitlyInstalled(id string) error { + installed, err := m.GetInstalled(FilterOptions{Id: id}) + if err != nil { + return err + } + if !installed.InstalledAsDependency { + return nil + } + + if err := m.updateInstalled(installed.Id, func(record *Extension) *Extension { + record.InstalledAsDependency = false + return record + }); err != nil { + return fmt.Errorf("failed to mark %s as explicitly installed: %w", id, err) + } + return nil +} + // upgradeInternal performs the reinstall and any dependency upgrades. // visited prevents dependency cycles. func (m *Manager) upgradeInternal( @@ -1122,6 +1210,11 @@ func (m *Manager) upgradeInternal( opts UpgradeOptions, visited map[string]struct{}, ) (*ExtensionVersion, []UpgradeResult, error) { + asDependency := false + if installed, err := m.GetInstalled(FilterOptions{Id: extension.Id}); err == nil && installed != nil { + asDependency = installed.InstalledAsDependency && !opts.PromoteToExplicit + } + if err := m.Uninstall(ctx, extension.Id); err != nil { return nil, nil, fmt.Errorf("failed to uninstall extension: %w", err) } @@ -1132,7 +1225,7 @@ func (m *Manager) upgradeInternal( VersionPreference: opts.VersionPreference, SkipDependencies: opts.SkipDependencies, SkipMainRegistryDependencyFallback: opts.SkipMainRegistryDependencyFallback, - }, true, map[string]struct{}{}) + }, true, asDependency, map[string]struct{}{}) if err != nil { return nil, nil, fmt.Errorf("failed to install extension: %w", err) } @@ -1162,17 +1255,58 @@ func (m *Manager) evaluateDependencyChanges( ) []UpgradeResult { var results []UpgradeResult - for _, dep := range parentVersion.Dependencies { - if dep.Version == "" { - continue - } + // These failures occur before an actual upgrade starts its own span. + recordFailure := func(result UpgradeResult) { + _, span := tracing.Start(ctx, events.ExtensionUpdateEvent) + span.SetAttributes( + fields.ExtensionId.String(result.ExtensionId), + fields.ExtensionDependencyOf.String(parentExtension.Id), + fields.ExtensionSourceCategory.String(string(result.FromSourceCategory)), + ) + span.EndWithStatus(result.Error) + results = append(results, result) + } + for _, dep := range parentVersion.Dependencies { installed, err := m.GetInstalled(FilterOptions{Id: dep.Id}) if err != nil || installed == nil { // Not installed — handled by the parent's Install dependency loop. continue } + // Dependency upgrades use the same parent-source then main-registry + // resolution policy as fresh dependency installs. + childMetadata, findErr := m.resolveDependency( + ctx, + parentExtension, + dep, + !opts.SkipMainRegistryDependencyFallback, + m.azdVersion, + ) + + // Direct children that predate dependency tracking learn their snapshot here, before + // any version logic. Deeper legacy records are backfilled when they are directly + // reconciled, updated, or reinstalled. + if findErr == nil { + installedRelease := FindVersion(childMetadata.Versions, installed.Version) + if err := m.BackfillDependencies(dep.Id, childMetadata.Source, installedRelease); err != nil { + recordFailure(UpgradeResult{ + ExtensionId: dep.Id, + Status: UpgradeStatusFailed, + FromVersion: installed.Version, + FromSource: installed.Source, + FromSourceCategory: installed.SourceCategoryOrUnknown(), + Error: err, + }) + continue + } + } + + // An unconstrained dependency has nothing to reconcile against. + if dep.Version == "" { + continue + } + // Respect a sibling's compatible choice; otherwise surface a conflict. if _, seen := visited[dep.Id]; seen { if matchesVersionConstraint(dep.Version, installed.Version) { @@ -1183,7 +1317,7 @@ func (m *Manager) evaluateDependencyChanges( "dependency already pinned it to %s", dep.Id, parentExtension.Id, dep.Version, installed.Version, ) - results = append(results, UpgradeResult{ + recordFailure(UpgradeResult{ ExtensionId: dep.Id, Status: UpgradeStatusFailed, FromVersion: installed.Version, @@ -1200,15 +1334,6 @@ func (m *Manager) evaluateDependencyChanges( // upgrades, skips, or fails. visited[dep.Id] = struct{}{} - // Dependency upgrades use the same parent-source then main-registry - // resolution policy as fresh dependency installs. - childMetadata, findErr := m.resolveDependency( - ctx, - parentExtension, - dep, - !opts.SkipMainRegistryDependencyFallback, - m.azdVersion, - ) if findErr != nil { // Without registry data, only fail if the installed version violates the constraint. if matchesVersionConstraint(dep.Version, installed.Version) { @@ -1218,7 +1343,7 @@ func (m *Manager) evaluateDependencyChanges( if suggestionErr, ok := findErr.(interface{ Suggestion() string }); ok { suggestion = suggestionErr.Suggestion() } - results = append(results, UpgradeResult{ + recordFailure(UpgradeResult{ ExtensionId: dep.Id, Status: UpgradeStatusFailed, FromVersion: installed.Version, @@ -1257,7 +1382,7 @@ func (m *Manager) evaluateDependencyChanges( resultErr = compatibilityErr suggestion = compatibilityErr.Suggestion() } - results = append(results, UpgradeResult{ + recordFailure(UpgradeResult{ ExtensionId: dep.Id, Status: UpgradeStatusFailed, FromVersion: installed.Version, @@ -1364,6 +1489,16 @@ func (m *Manager) evaluateDependencyChanges( return results } +// FindVersion returns the release with the given version tag, or nil. +func FindVersion(versions []ExtensionVersion, version string) *ExtensionVersion { + for i := range versions { + if versions[i].Version == version { + return &versions[i] + } + } + return nil +} + // Helper function to find the artifact for the current OS func findArtifactForCurrentOS(version *ExtensionVersion) (*ExtensionArtifact, error) { if version.Artifacts == nil { diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index 85ffdf67369..411ff1ae5d0 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -31,6 +31,7 @@ import ( "github.com/stretchr/testify/require" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" tracesdk "go.opentelemetry.io/otel/sdk/trace" "go.opentelemetry.io/otel/sdk/trace/tracetest" ) @@ -207,11 +208,7 @@ func Test_List_Install_Uninstall_Flow(t *testing.T) { } func TestInstallEmitsSourceCategoryTelemetry(t *testing.T) { - recorder := tracetest.NewSpanRecorder() - provider := tracesdk.NewTracerProvider(tracesdk.WithSpanProcessor(recorder)) - previousProvider := otel.GetTracerProvider() - otel.SetTracerProvider(provider) - t.Cleanup(func() { otel.SetTracerProvider(previousProvider) }) + recorder := recordExtensionTelemetry(t) mockContext := mocks.NewMockContext(t.Context()) createRegistryMocks(mockContext) @@ -258,6 +255,72 @@ func TestInstallEmitsSourceCategoryTelemetry(t *testing.T) { } } +// tracing caches its tracer, so tests share a provider and register separate recorders. +var extensionTelemetryProvider = tracesdk.NewTracerProvider() + +func recordExtensionTelemetry(t *testing.T) *tracetest.SpanRecorder { + t.Helper() + t.Setenv("AZD_CONFIG_DIR", t.TempDir()) + recorder := tracetest.NewSpanRecorder() + extensionTelemetryProvider.RegisterSpanProcessor(recorder) + previousProvider := otel.GetTracerProvider() + otel.SetTracerProvider(extensionTelemetryProvider) + t.Cleanup(func() { + extensionTelemetryProvider.UnregisterSpanProcessor(recorder) + otel.SetTracerProvider(previousProvider) + }) + return recorder +} + +func requireDependencyUpdateSpan( + t *testing.T, + recorder *tracetest.SpanRecorder, + id, parentID string, + status codes.Code, +) tracesdk.ReadOnlySpan { + t.Helper() + var matches []tracesdk.ReadOnlySpan + for _, span := range recorder.Ended() { + if span.Name() != events.ExtensionUpdateEvent { + continue + } + attributes := span.Attributes() + if extensionTelemetryAttribute(t, attributes, fields.ExtensionId.Key).Value.AsString() == id && + extensionTelemetryAttribute(t, attributes, fields.ExtensionDependencyOf.Key).Value.AsString() == parentID { + matches = append(matches, span) + } + } + require.Len(t, matches, 1, "expected exactly one update span for %s required by %s", id, parentID) + require.Equal(t, status, matches[0].Status().Code) + if status == codes.Error { + require.NotEmpty(t, matches[0].Status().Description) + chain := extensionTelemetryAttribute(t, matches[0].Attributes(), fields.ErrChainTypes.Key) + require.NotEmpty(t, chain.Value.AsStringSlice()) + } + return matches[0] +} + +func Test_Upgrade_DependencyUpgrade_InstallFailureTelemetry(t *testing.T) { + recorder := recordExtensionTelemetry(t) + pack, child := packWithLeaf("1.0.0", "1.0.0", "2.0.0") + pack.Versions[0].Dependencies[0].Version = ">=2.0.0" + child.Versions[1].Artifacts = map[string]ExtensionArtifact{"unsupported-platform": {}} + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, extensions: []*ExtensionMetadata{pack, child}, + }) + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + pack.Id: installedRecord(pack.Id, "1.0.0", false, child.Id), + child.Id: installedRecord(child.Id, "1.0.0", true), + })) + + _, results, err := manager.ReconcileDependencies(t.Context(), pack, DefaultUpgradeOptions("")) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, UpgradeStatusFailed, results[0].Status) + require.ErrorContains(t, results[0].Error, "failed to find artifact for current OS") + requireDependencyUpdateSpan(t, recorder, child.Id, pack.Id, codes.Error) +} + func extensionTelemetryAttribute( t *testing.T, attributes []attribute.KeyValue, @@ -3062,7 +3125,7 @@ func Test_Upgrade_DependencyUpgrade_FallsBackWhenParentSourceRequiresNewerAzd(t } func Test_Upgrade_DependencyUpgrade_BundleIsolationPropagatesToNestedDependencies(t *testing.T) { - t.Parallel() + recorder := recordExtensionTelemetry(t) parent := &ExtensionMetadata{ Id: "test.pack", @@ -3138,6 +3201,10 @@ func Test_Upgrade_DependencyUpgrade_BundleIsolationPropagatesToNestedDependencie require.Equal(t, UpgradeStatusFailed, depUpgrades[0].DependencyUpgrades[0].Status) require.ErrorAs(t, depUpgrades[0].DependencyUpgrades[0].Error, new(*DependencyVersionNotFoundError)) + childSpan := requireDependencyUpdateSpan(t, recorder, "test.child", "test.pack", codes.Ok) + leafSpan := requireDependencyUpdateSpan(t, recorder, "test.leaf", "test.child", codes.Error) + require.Equal(t, childSpan.SpanContext().SpanID(), leafSpan.Parent().SpanID()) + leaf, err := manager.GetInstalled(FilterOptions{Id: "test.leaf"}) require.NoError(t, err) require.Equal(t, "1.0.0", leaf.Version) @@ -3224,6 +3291,7 @@ func Test_Upgrade_DependencyUpgrade_RefusesToDowngradeOutsideConstraint(t *testi } func Test_Upgrade_DependencyUpgrade_NoPublishedVersionSatisfiesConstraint(t *testing.T) { + recorder := recordExtensionTelemetry(t) mockContext := mocks.NewMockContext(t.Context()) registry := Registry{ @@ -3290,9 +3358,11 @@ func Test_Upgrade_DependencyUpgrade_NoPublishedVersionSatisfiesConstraint(t *tes require.Contains(t, depUpgrades[0].Suggestion, "test.child") require.Contains(t, depUpgrades[0].Suggestion, ">=2.0.0") require.Contains(t, depUpgrades[0].Suggestion, "test.pack") + requireDependencyUpdateSpan(t, recorder, "test.child", "test.pack", codes.Error) } func Test_Upgrade_DependencyUpgrade_RequiresNewerAzd(t *testing.T) { + recorder := recordExtensionTelemetry(t) mockContext := mocks.NewMockContext(t.Context()) registry := Registry{ @@ -3372,6 +3442,7 @@ func Test_Upgrade_DependencyUpgrade_RequiresNewerAzd(t *testing.T) { require.Equal(t, ">=2.0.0", compatibilityErr.RequiredAzdVersion) require.Contains(t, depUpgrades[0].Suggestion, "Use an azd version") require.Contains(t, depUpgrades[0].Suggestion, ">=2.0.0") + requireDependencyUpdateSpan(t, recorder, "test.child", "test.pack", codes.Error) } func TestDependencyAzdVersionIncompatibleError_UpperBoundSuggestion(t *testing.T) { @@ -3932,6 +4003,7 @@ func Test_Install_DependencyCycle_Bounded(t *testing.T) { } func Test_Upgrade_DependencyUpgrade_ConstraintConflict(t *testing.T) { + recorder := recordExtensionTelemetry(t) mockContext := mocks.NewMockContext(t.Context()) // Pack A v2 depends on { B: ">=2.0.0", C: ">=2.0.0" }. @@ -4047,6 +4119,9 @@ func Test_Upgrade_DependencyUpgrade_ConstraintConflict(t *testing.T) { require.Error(t, cEntry.Error) require.Contains(t, cEntry.Error.Error(), "constraint conflict") require.Contains(t, cEntry.Error.Error(), "leaf.c") + requireDependencyUpdateSpan(t, recorder, "pack.b", "pack.a", codes.Ok) + requireDependencyUpdateSpan(t, recorder, "leaf.c", "pack.b", codes.Ok) + requireDependencyUpdateSpan(t, recorder, "leaf.c", "pack.a", codes.Error) } // Test_Upgrade_DependencyUpgrade_NoOpStillPinsForSiblings exercises the case diff --git a/cli/azd/pkg/extensions/uninstall.go b/cli/azd/pkg/extensions/uninstall.go new file mode 100644 index 00000000000..61e498ad434 --- /dev/null +++ b/cli/azd/pkg/extensions/uninstall.go @@ -0,0 +1,219 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "errors" + "fmt" + "maps" + "slices" + "strings" +) + +// ErrEmptyExtensionId indicates that an extension id was blank. A blank id would otherwise match +// an arbitrary installed record, because the installed filter treats an empty id as a wildcard. +var ErrEmptyExtensionId = errors.New("extension id cannot be empty") + +// ExtensionRequiredError indicates that one or more extensions cannot be uninstalled because +// other installed extensions declare a dependency on them. +type ExtensionRequiredError struct { + // Blocked maps each requested extension id to the sorted ids of the installed extensions + // that require it. + Blocked map[string][]string +} + +func (e *ExtensionRequiredError) Error() string { + ids := slices.Sorted(maps.Keys(e.Blocked)) + if len(ids) == 1 { + return fmt.Sprintf( + "extension %s is required by installed extensions: %s", + ids[0], strings.Join(e.Blocked[ids[0]], ", "), + ) + } + + var builder strings.Builder + builder.WriteString("extensions are required by other installed extensions:") + for _, id := range ids { + fmt.Fprintf(&builder, "\n %s: required by %s", id, strings.Join(e.Blocked[id], ", ")) + } + return builder.String() +} + +// Suggestion returns actionable guidance for removing the dependents first. +func (e *ExtensionRequiredError) Suggestion() string { + return fmt.Sprintf( + "Run 'azd extension uninstall %s' to remove the dependents first, or pass --force to remove it anyway.", + strings.Join(e.dependents(), " "), + ) +} + +// dependents returns the sorted, de-duplicated ids of every extension that blocks the request. +func (e *ExtensionRequiredError) dependents() []string { + seen := map[string]struct{}{} + for _, dependents := range e.Blocked { + for _, dependent := range dependents { + seen[dependent] = struct{}{} + } + } + return slices.Sorted(maps.Keys(seen)) +} + +// UninstallPlanOptions controls how PlanUninstall treats dependencies and dependents. +type UninstallPlanOptions struct { + // KeepDependencies leaves dependencies that were installed only for the removed + // extensions in place instead of removing them once nothing requires them. + KeepDependencies bool + // IgnoreDependents allows removal even when other installed extensions require a target. + IgnoreDependents bool +} + +// RetainedDependency is a dependency of the removed extensions that stays installed. +type RetainedDependency struct { + Extension *Extension + // RequiredBy lists the remaining installed extensions that still require the dependency, + // sorted by id. It is empty when the dependency stays because its record is not marked as + // a dependency install (installed by name, or written before dependency tracking existed). + RequiredBy []string +} + +// UninstallPlan describes the effect of uninstalling a set of extensions. +type UninstallPlan struct { + // Targets are the requested extensions, in request order. + Targets []*Extension + // Orphaned lists dependencies that were installed only for the removed extensions and + // that nothing requires once they are gone, parents before children. + Orphaned []*Extension + // Retained lists dependencies of the removed extensions that stay installed, sorted by id. + Retained []RetainedDependency + // Blocked maps a target id to the sorted ids of installed extensions outside the removal + // set that require it. It is only populated when UninstallPlanOptions.IgnoreDependents is + // set; otherwise a blocked request fails with ExtensionRequiredError instead of a plan. + Blocked map[string][]string +} + +// InstalledDependents returns the installed extensions whose recorded dependencies include +// the given id, sorted by id. Records that predate dependency tracking never appear. +func (m *Manager) InstalledDependents(id string) ([]*Extension, error) { + installed, err := m.ListInstalled() + if err != nil { + return nil, err + } + + var dependents []*Extension + for _, extension := range installed { + if dependsOn(extension, id) { + dependents = append(dependents, extension) + } + } + slices.SortFunc(dependents, func(a, b *Extension) int { + return strings.Compare(a.Id, b.Id) + }) + return dependents, nil +} + +// PlanUninstall computes what uninstalling the given extensions would remove and what stops +// it, using only the installed records. It performs no removal and no registry lookups. A +// request that other installed extensions block fails with ExtensionRequiredError unless +// UninstallPlanOptions.IgnoreDependents is set. +func (m *Manager) PlanUninstall(ids []string, opts UninstallPlanOptions) (*UninstallPlan, error) { + installed, err := m.ListInstalled() + if err != nil { + return nil, fmt.Errorf("failed to list installed extensions: %w", err) + } + + plan := &UninstallPlan{} + removal := map[string]struct{}{} + for _, id := range ids { + if strings.TrimSpace(id) == "" { + return nil, ErrEmptyExtensionId + } + extension, err := m.GetInstalled(FilterOptions{Id: id}) + if err != nil { + return nil, fmt.Errorf("failed to get installed extension: %w", err) + } + if _, seen := removal[extension.Id]; seen { + continue + } + removal[extension.Id] = struct{}{} + plan.Targets = append(plan.Targets, extension) + } + + // dependentsOutsideRemoval lists the installed extensions that require id and are not + // themselves being removed. The removal set grows as orphans are found, so it is + // evaluated lazily. + dependentsOutsideRemoval := func(id string) []string { + var dependents []string + for _, extension := range installed { + if _, removing := removal[extension.Id]; removing { + continue + } + if dependsOn(extension, id) { + dependents = append(dependents, extension.Id) + } + } + slices.Sort(dependents) + return dependents + } + + // Queue removed dependencies to revisit shared children as their dependents leave + // (A -> B, A -> C, C -> B). Expand removals before checking blockers so a dependency + // in a cycle with a target (A -> B -> A) can leave with it. + considered := map[string]*Extension{} + if !opts.KeepDependencies { + queue := slices.Clone(plan.Targets) + for i := 0; i < len(queue); i++ { + for _, dependency := range queue[i].Dependencies { + if strings.TrimSpace(dependency.Id) == "" { + continue + } + dependencyExtension, err := m.GetInstalled(FilterOptions{Id: dependency.Id}) + if err != nil || dependencyExtension == nil { + continue + } + if _, removing := removal[dependencyExtension.Id]; removing { + continue + } + considered[dependencyExtension.Id] = dependencyExtension + if !dependencyExtension.InstalledAsDependency || + len(dependentsOutsideRemoval(dependencyExtension.Id)) > 0 { + continue + } + + removal[dependencyExtension.Id] = struct{}{} + delete(considered, dependencyExtension.Id) + plan.Orphaned = append(plan.Orphaned, dependencyExtension) + queue = append(queue, dependencyExtension) + } + } + } + + blocked := map[string][]string{} + for _, target := range plan.Targets { + if dependents := dependentsOutsideRemoval(target.Id); len(dependents) > 0 { + blocked[target.Id] = dependents + } + } + if len(blocked) > 0 { + if !opts.IgnoreDependents { + return nil, &ExtensionRequiredError{Blocked: blocked} + } + plan.Blocked = blocked + } + + for _, id := range slices.Sorted(maps.Keys(considered)) { + plan.Retained = append(plan.Retained, RetainedDependency{ + Extension: considered[id], + RequiredBy: dependentsOutsideRemoval(id), + }) + } + + return plan, nil +} + +// dependsOn reports whether the installed extension declares a dependency on id. +func dependsOn(extension *Extension, id string) bool { + return slices.ContainsFunc(extension.Dependencies, func(dependency ExtensionDependency) bool { + return strings.EqualFold(dependency.Id, id) + }) +} diff --git a/cli/azd/pkg/extensions/uninstall_test.go b/cli/azd/pkg/extensions/uninstall_test.go new file mode 100644 index 00000000000..25f9f7f508c --- /dev/null +++ b/cli/azd/pkg/extensions/uninstall_test.go @@ -0,0 +1,704 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package extensions + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/test/mocks" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/codes" +) + +// installedRecord builds an installed extension record with a dependency snapshot. +func installedRecord(id, version string, asDependency bool, dependencies ...string) *Extension { + record := &Extension{ + Id: id, + Version: version, + Source: MainRegistryName, + InstalledAsDependency: asDependency, + } + for _, dependency := range dependencies { + record.Dependencies = append(record.Dependencies, ExtensionDependency{Id: dependency}) + } + return record +} + +// foundryShapedInstall mirrors the microsoft.foundry pack: an explicit pack over dependencies +// installed for it, where agents itself requires inspector and projects. +func foundryShapedInstall() map[string]*Extension { + return map[string]*Extension{ + "microsoft.foundry": installedRecord("microsoft.foundry", "1.0.0", false, + "azure.ai.agents", "azure.ai.projects", "azure.ai.inspector", "azure.ai.skills"), + "azure.ai.agents": installedRecord("azure.ai.agents", "2.0.0", true, "azure.ai.inspector", "azure.ai.projects"), + "azure.ai.projects": installedRecord("azure.ai.projects", "3.0.0", true), + "azure.ai.inspector": installedRecord("azure.ai.inspector", "4.0.0", true), + "azure.ai.skills": installedRecord("azure.ai.skills", "5.0.0", true), + } +} + +func newPlanTestManager(t *testing.T, installed map[string]*Extension) *Manager { + t.Helper() + + manager := newTestManager(t) + require.NoError(t, manager.userConfig.Set(installedConfigKey, installed)) + manager.installed = nil + return manager +} + +func extensionIds(extensions []*Extension) []string { + ids := make([]string, 0, len(extensions)) + for _, extension := range extensions { + ids = append(ids, extension.Id) + } + return ids +} + +func Test_PlanUninstall_PackRemovesOrphanedDependencies(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + plan, err := manager.PlanUninstall([]string{"microsoft.foundry"}, UninstallPlanOptions{}) + require.NoError(t, err) + require.Equal(t, []string{"microsoft.foundry"}, extensionIds(plan.Targets)) + require.Equal(t, + []string{"azure.ai.agents", "azure.ai.projects", "azure.ai.inspector", "azure.ai.skills"}, + extensionIds(plan.Orphaned), + ) + require.Empty(t, plan.Retained) + require.Empty(t, plan.Blocked) +} + +func Test_PlanUninstall_SharedDependenciesStayWithExplicitSibling(t *testing.T) { + t.Parallel() + installed := foundryShapedInstall() + installed["azure.ai.agents"].InstalledAsDependency = false + manager := newPlanTestManager(t, installed) + + plan, err := manager.PlanUninstall([]string{"microsoft.foundry"}, UninstallPlanOptions{}) + require.NoError(t, err) + require.Equal(t, []string{"azure.ai.skills"}, extensionIds(plan.Orphaned)) + + require.Len(t, plan.Retained, 3) + require.Equal(t, "azure.ai.agents", plan.Retained[0].Extension.Id) + require.Empty(t, plan.Retained[0].RequiredBy, "explicit installs are retained without dependents") + require.Equal(t, "azure.ai.inspector", plan.Retained[1].Extension.Id) + require.Equal(t, []string{"azure.ai.agents"}, plan.Retained[1].RequiredBy) + require.Equal(t, "azure.ai.projects", plan.Retained[2].Extension.Id) + require.Equal(t, []string{"azure.ai.agents"}, plan.Retained[2].RequiredBy) +} + +func Test_PlanUninstall_DependencyFreedBySiblingRemovedLater(t *testing.T) { + t.Parallel() + // A -> B, A -> C, C -> B: B is first kept because C needs it, then freed once C goes. + manager := newPlanTestManager(t, map[string]*Extension{ + "a": installedRecord("a", "1.0.0", false, "b", "c"), + "b": installedRecord("b", "1.0.0", true), + "c": installedRecord("c", "1.0.0", true, "b"), + }) + + plan, err := manager.PlanUninstall([]string{"a"}, UninstallPlanOptions{}) + require.NoError(t, err) + require.Equal(t, []string{"c", "b"}, extensionIds(plan.Orphaned)) + require.Empty(t, plan.Retained) +} + +func Test_PlanUninstall_CycleWithDependencyInstalledExtension(t *testing.T) { + t.Parallel() + // The upgrade flow tolerates cycles, so a -> b -> a can exist on disk. b was installed + // only for a, so removing a takes b along instead of b blocking the removal. + manager := newPlanTestManager(t, map[string]*Extension{ + "a": installedRecord("a", "1.0.0", false, "b"), + "b": installedRecord("b", "1.0.0", true, "a"), + }) + + plan, err := manager.PlanUninstall([]string{"a"}, UninstallPlanOptions{}) + require.NoError(t, err) + require.Empty(t, plan.Blocked) + require.Equal(t, []string{"b"}, extensionIds(plan.Orphaned)) + + // With dependencies kept, b stays and its declared need for a is a real block. + _, err = manager.PlanUninstall([]string{"a"}, UninstallPlanOptions{KeepDependencies: true}) + requiredErr, ok := errors.AsType[*ExtensionRequiredError](err) + require.True(t, ok) + require.Equal(t, []string{"b"}, requiredErr.Blocked["a"]) +} + +func Test_PlanUninstall_BlockedByDependents(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + plan, err := manager.PlanUninstall([]string{"azure.ai.projects"}, UninstallPlanOptions{}) + require.Nil(t, plan, "a blocked request yields no plan") + requiredErr, ok := errors.AsType[*ExtensionRequiredError](err) + require.True(t, ok) + require.Equal(t, + map[string][]string{"azure.ai.projects": {"azure.ai.agents", "microsoft.foundry"}}, + requiredErr.Blocked, + ) + require.EqualError(t, err, + "extension azure.ai.projects is required by installed extensions: azure.ai.agents, microsoft.foundry") + require.Contains(t, requiredErr.Suggestion(), "azd extension uninstall azure.ai.agents microsoft.foundry") + require.Contains(t, requiredErr.Suggestion(), "--force") +} + +func Test_PlanUninstall_BlockedReportsEveryTarget(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + _, err := manager.PlanUninstall( + []string{"azure.ai.projects", "azure.ai.inspector"}, UninstallPlanOptions{}, + ) + requiredErr, ok := errors.AsType[*ExtensionRequiredError](err) + require.True(t, ok) + require.Len(t, requiredErr.Blocked, 2) + require.Contains(t, err.Error(), "azure.ai.inspector: required by azure.ai.agents, microsoft.foundry") + require.Contains(t, err.Error(), "azure.ai.projects: required by azure.ai.agents, microsoft.foundry") + require.Equal(t, []string{"azure.ai.agents", "microsoft.foundry"}, requiredErr.dependents()) +} + +func Test_PlanUninstall_IgnoreDependents(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + plan, err := manager.PlanUninstall( + []string{"azure.ai.projects"}, UninstallPlanOptions{IgnoreDependents: true}, + ) + require.NoError(t, err) + require.Equal(t, []string{"azure.ai.projects"}, extensionIds(plan.Targets)) + require.Equal(t, []string{"azure.ai.agents", "microsoft.foundry"}, plan.Blocked["azure.ai.projects"]) + require.Empty(t, plan.Orphaned) +} + +func Test_PlanUninstall_TargetsUnblockEachOther(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + plan, err := manager.PlanUninstall( + []string{"microsoft.foundry", "azure.ai.agents"}, UninstallPlanOptions{}, + ) + require.NoError(t, err) + require.Empty(t, plan.Blocked) + require.Equal(t, []string{"microsoft.foundry", "azure.ai.agents"}, extensionIds(plan.Targets)) + require.Equal(t, + []string{"azure.ai.projects", "azure.ai.inspector", "azure.ai.skills"}, + extensionIds(plan.Orphaned), + ) +} + +func Test_PlanUninstall_KeepDependencies(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + plan, err := manager.PlanUninstall( + []string{"microsoft.foundry"}, UninstallPlanOptions{KeepDependencies: true}, + ) + require.NoError(t, err) + require.Equal(t, []string{"microsoft.foundry"}, extensionIds(plan.Targets)) + require.Empty(t, plan.Orphaned) + require.Empty(t, plan.Retained) +} + +func Test_PlanUninstall_LegacyRecordsNeverRemovedOrBlocking(t *testing.T) { + t.Parallel() + // Records written before dependency tracking carry neither a snapshot nor a flag. + manager := newPlanTestManager(t, map[string]*Extension{ + "microsoft.foundry": {Id: "microsoft.foundry", Version: "1.0.0", Source: MainRegistryName}, + "azure.ai.agents": {Id: "azure.ai.agents", Version: "2.0.0", Source: MainRegistryName}, + }) + + plan, err := manager.PlanUninstall([]string{"microsoft.foundry"}, UninstallPlanOptions{}) + require.NoError(t, err) + require.Equal(t, []string{"microsoft.foundry"}, extensionIds(plan.Targets)) + require.Empty(t, plan.Orphaned) + + plan, err = manager.PlanUninstall([]string{"azure.ai.agents"}, UninstallPlanOptions{}) + require.NoError(t, err) + require.Empty(t, plan.Blocked) +} + +func Test_PlanUninstall_LegacyDependencyStaysWhenParentHasSnapshot(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, map[string]*Extension{ + "microsoft.foundry": installedRecord("microsoft.foundry", "1.0.0", false, "azure.ai.agents"), + "azure.ai.agents": {Id: "azure.ai.agents", Version: "2.0.0", Source: MainRegistryName}, + }) + + plan, err := manager.PlanUninstall([]string{"microsoft.foundry"}, UninstallPlanOptions{}) + require.NoError(t, err) + require.Empty(t, plan.Orphaned) + require.Len(t, plan.Retained, 1) + require.Equal(t, "azure.ai.agents", plan.Retained[0].Extension.Id) + require.Empty(t, plan.Retained[0].RequiredBy) +} + +func Test_PlanUninstall_RejectsBlankId(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + // A blank id matches an arbitrary installed record through the installed filter. + _, err := manager.PlanUninstall([]string{" "}, UninstallPlanOptions{}) + require.ErrorIs(t, err, ErrEmptyExtensionId) + require.ErrorIs(t, manager.Uninstall(t.Context(), ""), ErrEmptyExtensionId) + + // A blank dependency id in a snapshot is ignored rather than resolved to a random record. + installed := foundryShapedInstall() + installed["microsoft.foundry"].Dependencies = append( + installed["microsoft.foundry"].Dependencies, ExtensionDependency{Id: ""}, + ) + manager = newPlanTestManager(t, installed) + plan, err := manager.PlanUninstall([]string{"microsoft.foundry"}, UninstallPlanOptions{}) + require.NoError(t, err) + require.Len(t, plan.Orphaned, 4) +} + +func Test_PlanUninstall_UnknownExtension(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + _, err := manager.PlanUninstall([]string{"missing"}, UninstallPlanOptions{}) + require.ErrorIs(t, err, ErrInstalledExtensionNotFound) +} + +func Test_PlanUninstall_DuplicateIds(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + plan, err := manager.PlanUninstall( + []string{"microsoft.foundry", "MICROSOFT.FOUNDRY"}, UninstallPlanOptions{}, + ) + require.NoError(t, err) + require.Len(t, plan.Targets, 1) +} + +func Test_InstalledDependents(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, foundryShapedInstall()) + + dependents, err := manager.InstalledDependents("Azure.AI.Projects") + require.NoError(t, err) + require.Equal(t, []string{"azure.ai.agents", "microsoft.foundry"}, extensionIds(dependents)) + + dependents, err = manager.InstalledDependents("microsoft.foundry") + require.NoError(t, err) + require.Empty(t, dependents) +} + +// newInstallTestManager builds a manager over the given sources whose artifact downloads are +// served by the mock HTTP client and whose install directory is a temp dir. +func newInstallTestManager(t *testing.T, sources ...Source) *Manager { + t.Helper() + t.Setenv("AZD_CONFIG_DIR", t.TempDir()) + + mockContext := mocks.NewMockContext(t.Context()) + mockContext.HttpClient.When(func(request *http.Request) bool { + return strings.HasPrefix(request.URL.String(), "https://aka.ms/azd/extensions/registry/") + }).RespondFn(func(request *http.Request) (*http.Response, error) { + return mocks.CreateHttpResponseWithBody(request, http.StatusOK, []byte("test data")) + }) + + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + sourceManager := NewSourceManager(mockContext.Container, userConfigManager, mockContext.HttpClient) + lazyRunner := lazy.NewLazy(func() (*Runner, error) { + return NewRunner(mockContext.CommandRunner), nil + }) + manager, err := NewManager(userConfigManager, sourceManager, lazyRunner, mockContext.HttpClient) + require.NoError(t, err) + manager.sources = sources + return manager +} + +// packWithLeaf returns a pack that depends on a leaf extension published at the given versions. +func packWithLeaf(packVersion string, leafVersions ...string) (*ExtensionMetadata, *ExtensionMetadata) { + pack := &ExtensionMetadata{ + Id: "test.pack", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: packVersion, + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: ">=1.0.0"}}, + }}, + } + leaf := &ExtensionMetadata{Id: "test.leaf", Source: MainRegistryName} + for _, version := range leafVersions { + leaf.Versions = append(leaf.Versions, ExtensionVersion{Version: version, Artifacts: sampleArtifacts}) + } + return pack, leaf +} + +func Test_Install_RecordsDependenciesAndOwnership(t *testing.T) { + pack, leaf := packWithLeaf("1.0.0", "1.0.0") + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, + extensions: []*ExtensionMetadata{pack, leaf}, + }) + + _, err := manager.Install(t.Context(), pack, "") + require.NoError(t, err) + + packRecord, err := manager.GetInstalled(FilterOptions{Id: "test.pack"}) + require.NoError(t, err) + require.False(t, packRecord.InstalledAsDependency) + require.Equal(t, []ExtensionDependency{{Id: "test.leaf", Version: ">=1.0.0"}}, packRecord.Dependencies) + + leafRecord, err := manager.GetInstalled(FilterOptions{Id: "test.leaf"}) + require.NoError(t, err) + require.True(t, leafRecord.InstalledAsDependency) + require.Empty(t, leafRecord.Dependencies) +} + +func Test_Install_SkipDependencies_StillRecordsDeclaredDependencies(t *testing.T) { + pack, leaf := packWithLeaf("1.0.0", "1.0.0") + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, + extensions: []*ExtensionMetadata{pack, leaf}, + }) + + _, err := manager.InstallWithOptions(t.Context(), pack, InstallOptions{SkipDependencies: true}) + require.NoError(t, err) + + packRecord, err := manager.GetInstalled(FilterOptions{Id: "test.pack"}) + require.NoError(t, err) + require.Equal(t, []ExtensionDependency{{Id: "test.leaf", Version: ">=1.0.0"}}, packRecord.Dependencies) + + _, err = manager.GetInstalled(FilterOptions{Id: "test.leaf"}) + require.ErrorIs(t, err, ErrInstalledExtensionNotFound) +} + +func Test_Upgrade_PreservesOwnershipOfDependencies(t *testing.T) { + pack, leaf := packWithLeaf("1.0.0", "1.0.0", "2.0.0") + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, + extensions: []*ExtensionMetadata{pack, leaf}, + }) + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.pack": installedRecord("test.pack", "1.0.0", false, "test.leaf"), + "test.leaf": installedRecord("test.leaf", "1.0.0", true), + })) + manager.installed = nil + + _, results, err := manager.Upgrade(t.Context(), pack, DefaultUpgradeOptions("")) + require.NoError(t, err) + require.Len(t, results, 1) + require.Equal(t, UpgradeStatusUpgraded, results[0].Status) + require.Equal(t, "2.0.0", results[0].ToVersion) + + leafRecord, err := manager.GetInstalled(FilterOptions{Id: "test.leaf"}) + require.NoError(t, err) + require.True(t, leafRecord.InstalledAsDependency, "a dependency update keeps the dependency flag") + + packRecord, err := manager.GetInstalled(FilterOptions{Id: "test.pack"}) + require.NoError(t, err) + require.False(t, packRecord.InstalledAsDependency) + require.Equal(t, []ExtensionDependency{{Id: "test.leaf", Version: ">=1.0.0"}}, packRecord.Dependencies) +} + +func Test_Upgrade_PromoteToExplicit(t *testing.T) { + pack, leaf := packWithLeaf("1.0.0", "1.0.0", "2.0.0") + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, + extensions: []*ExtensionMetadata{pack, leaf}, + }) + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.leaf": installedRecord("test.leaf", "1.0.0", true), + })) + manager.installed = nil + + // `azd extension install test.leaf` over a dependency-installed record. + _, _, err := manager.Upgrade(t.Context(), leaf, UpgradeOptions{PromoteToExplicit: true}) + require.NoError(t, err) + + leafRecord, err := manager.GetInstalled(FilterOptions{Id: "test.leaf"}) + require.NoError(t, err) + require.Equal(t, "2.0.0", leafRecord.Version) + require.False(t, leafRecord.InstalledAsDependency) +} + +func Test_Upgrade_BackfillsDependencySnapshotOfCurrentChildren(t *testing.T) { + // pack -> child -> leaf, every record written before dependency tracking existed and + // already at the published version, so the update reinstalls only the pack. + pack := &ExtensionMetadata{ + Id: "test.pack", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.child", Version: ">=1.0.0"}}, + }}, + } + child := &ExtensionMetadata{ + Id: "test.child", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Artifacts: sampleArtifacts, + Dependencies: []ExtensionDependency{{Id: "test.leaf", Version: ">=1.0.0"}}, + }}, + } + leaf := &ExtensionMetadata{ + Id: "test.leaf", + Source: MainRegistryName, + Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: sampleArtifacts}}, + } + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, + extensions: []*ExtensionMetadata{pack, child, leaf}, + }) + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.pack": {Id: "test.pack", Version: "1.0.0", Source: MainRegistryName}, + "test.child": {Id: "test.child", Version: "1.0.0", Source: MainRegistryName}, + "test.leaf": {Id: "test.leaf", Version: "1.0.0", Source: MainRegistryName}, + })) + manager.installed = nil + + _, results, err := manager.Upgrade(t.Context(), pack, DefaultUpgradeOptions("")) + require.NoError(t, err) + require.Empty(t, results, "every child is already current") + + childRecord, err := manager.GetInstalled(FilterOptions{Id: "test.child"}) + require.NoError(t, err) + require.Equal(t, []ExtensionDependency{{Id: "test.leaf", Version: ">=1.0.0"}}, childRecord.Dependencies) + require.False(t, childRecord.InstalledAsDependency, "backfill never guesses ownership") + + // The uninstall planner now protects the leaf through the child's snapshot. + _, err = manager.PlanUninstall([]string{"test.leaf"}, UninstallPlanOptions{}) + requiredErr, ok := errors.AsType[*ExtensionRequiredError](err) + require.True(t, ok) + require.Equal(t, []string{"test.child"}, requiredErr.Blocked["test.leaf"]) +} + +func Test_Upgrade_BackfillsUnconstrainedChildren(t *testing.T) { + // A dependency declared without a version constraint has nothing to reconcile, but a + // legacy record for it still needs its snapshot. + pack := &ExtensionMetadata{ + Id: "test.pack", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.child"}}, + }}, + } + child := &ExtensionMetadata{ + Id: "test.child", + Source: MainRegistryName, + Versions: []ExtensionVersion{{ + Version: "1.0.0", + Artifacts: sampleArtifacts, + Dependencies: []ExtensionDependency{{Id: "test.leaf"}}, + }}, + } + leaf := &ExtensionMetadata{ + Id: "test.leaf", + Source: MainRegistryName, + Versions: []ExtensionVersion{{Version: "1.0.0", Artifacts: sampleArtifacts}}, + } + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, + extensions: []*ExtensionMetadata{pack, child, leaf}, + }) + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.pack": {Id: "test.pack", Version: "1.0.0", Source: MainRegistryName}, + "test.child": {Id: "test.child", Version: "1.0.0", Source: MainRegistryName}, + "test.leaf": {Id: "test.leaf", Version: "1.0.0", Source: MainRegistryName}, + })) + manager.installed = nil + + _, results, err := manager.Upgrade(t.Context(), pack, DefaultUpgradeOptions("")) + require.NoError(t, err) + require.Empty(t, results) + + childRecord, err := manager.GetInstalled(FilterOptions{Id: "test.child"}) + require.NoError(t, err) + require.Equal(t, []ExtensionDependency{{Id: "test.leaf"}}, childRecord.Dependencies) +} + +func Test_ReconcileDependencies_BackfillsLegacyDependencySnapshot(t *testing.T) { + pack, leaf := packWithLeaf("1.0.0", "1.0.0") + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, + extensions: []*ExtensionMetadata{pack, leaf}, + }) + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.pack": {Id: "test.pack", Version: "1.0.0", Source: MainRegistryName}, + "test.leaf": {Id: "test.leaf", Version: "1.0.0", Source: MainRegistryName}, + })) + manager.installed = nil + + _, results, err := manager.ReconcileDependencies(t.Context(), pack, DefaultUpgradeOptions("")) + require.NoError(t, err) + require.Empty(t, results) + + packRecord, err := manager.GetInstalled(FilterOptions{Id: "test.pack"}) + require.NoError(t, err) + require.Equal(t, []ExtensionDependency{{Id: "test.leaf", Version: ">=1.0.0"}}, packRecord.Dependencies) + require.False(t, packRecord.InstalledAsDependency, "backfill never guesses ownership") + + leafRecord, err := manager.GetInstalled(FilterOptions{Id: "test.leaf"}) + require.NoError(t, err) + require.False(t, leafRecord.InstalledAsDependency) +} + +func Test_BackfillDependencies_ReturnsInstalledConfigError(t *testing.T) { + t.Parallel() + manager := newTestManager(t) + require.NoError(t, manager.userConfig.Set(installedConfigKey, "invalid")) + manager.installed = nil + + err := manager.BackfillDependencies("test.pack", MainRegistryName, &ExtensionVersion{ + Version: "1.0.0", + Dependencies: []ExtensionDependency{{Id: "test.leaf"}}, + }) + require.ErrorContains(t, err, "failed to get installed extension test.pack") + require.ErrorContains(t, err, "failed to get extensions section") +} + +func Test_MarkExplicitlyInstalled(t *testing.T) { + t.Parallel() + manager := newPlanTestManager(t, map[string]*Extension{ + "test.leaf": installedRecord("test.leaf", "1.0.0", true), + }) + + require.NoError(t, manager.MarkExplicitlyInstalled("test.leaf")) + record, err := manager.GetInstalled(FilterOptions{Id: "test.leaf"}) + require.NoError(t, err) + require.False(t, record.InstalledAsDependency) + + require.NoError(t, manager.MarkExplicitlyInstalled("test.leaf")) + require.ErrorIs(t, manager.MarkExplicitlyInstalled("missing"), ErrInstalledExtensionNotFound) +} + +type backfillSaveFailure struct { + config.UserConfigManager + extensionID string + err error +} + +func (m *backfillSaveFailure) Save(cfg config.Config) error { + var installed map[string]*Extension + if _, err := cfg.GetSection(installedConfigKey, &installed); err != nil { + return err + } + if record := installed[m.extensionID]; record != nil && + (len(record.Dependencies) > 0 || !record.InstalledAsDependency) && m.err != nil { + return m.err + } + return m.UserConfigManager.Save(cfg) +} + +func Test_InstalledMetadata_SaveFailurePreservesState(t *testing.T) { + for _, operation := range []string{"backfill", "promote"} { + t.Run(operation, func(t *testing.T) { + manager := newInstallTestManager(t) + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + "test.child": installedRecord("test.child", "1.0.0", true), + })) + persistent := config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())) + require.NoError(t, persistent.Save(manager.userConfig)) + saveErr := errors.New("metadata write failed") + failing := &backfillSaveFailure{UserConfigManager: persistent, extensionID: "test.child", err: saveErr} + manager.configManager = failing + + original, err := manager.GetInstalled(FilterOptions{Id: "test.child"}) + require.NoError(t, err) + update := func() error { + if operation == "promote" { + return manager.MarkExplicitlyInstalled("test.child") + } + return manager.BackfillDependencies("test.child", MainRegistryName, &ExtensionVersion{ + Version: "1.0.0", Dependencies: []ExtensionDependency{{Id: "test.leaf"}}, + }) + } + require.ErrorIs(t, update(), saveErr) + + cached, err := manager.GetInstalled(FilterOptions{Id: "test.child"}) + require.NoError(t, err) + require.Same(t, original, cached) + require.Empty(t, cached.Dependencies) + require.True(t, cached.InstalledAsDependency) + + onDisk, err := persistent.Load() + require.NoError(t, err) + for _, cfg := range []config.Config{manager.userConfig, onDisk} { + var installed map[string]*Extension + _, err := cfg.GetSection(installedConfigKey, &installed) + require.NoError(t, err) + require.Contains(t, installed, "test.child") + require.Empty(t, installed["test.child"].Dependencies) + require.True(t, installed["test.child"].InstalledAsDependency) + } + + failing.err = nil + require.NoError(t, update()) + onDisk, err = persistent.Load() + require.NoError(t, err) + var installed map[string]*Extension + _, err = onDisk.GetSection(installedConfigKey, &installed) + require.NoError(t, err) + require.Contains(t, installed, "test.child") + if operation == "promote" { + require.False(t, installed["test.child"].InstalledAsDependency) + } else { + require.Equal(t, []ExtensionDependency{{Id: "test.leaf"}}, installed["test.child"].Dependencies) + } + }) + } +} + +func Test_ReconcileDependencies_ReportsChildBackfillSaveFailure(t *testing.T) { + for _, constraint := range []string{"", ">=1.0.0"} { + t.Run("constraint="+constraint, func(t *testing.T) { + recorder := recordExtensionTelemetry(t) + pack, child := packWithLeaf("1.0.0", "1.0.0") + pack.Versions[0].Dependencies[0].Version = constraint + child.Versions[0].Dependencies = []ExtensionDependency{{Id: "test.grandchild"}} + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, extensions: []*ExtensionMetadata{pack, child}, + }) + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + pack.Id: installedRecord(pack.Id, "1.0.0", false, child.Id), + child.Id: installedRecord(child.Id, "1.0.0", true), + })) + persistent := config.NewUserConfigManager(config.NewFileConfigManager(config.NewManager())) + require.NoError(t, persistent.Save(manager.userConfig)) + saveErr := errors.New("child metadata write failed") + manager.configManager = &backfillSaveFailure{ + UserConfigManager: persistent, extensionID: child.Id, err: saveErr, + } + + version, results, err := manager.ReconcileDependencies(t.Context(), pack, DefaultUpgradeOptions("")) + require.NoError(t, err, "parent reconciliation succeeded") + require.Equal(t, "1.0.0", version.Version) + require.Len(t, results, 1) + require.Equal(t, child.Id, results[0].ExtensionId) + require.Equal(t, UpgradeStatusFailed, results[0].Status) + require.ErrorIs(t, results[0].Error, saveErr) + require.True(t, NewUpgradeSummary(results).HasFailures()) + require.ErrorContains(t, results[0].Error, "failed to record dependencies") + requireDependencyUpdateSpan(t, recorder, child.Id, pack.Id, codes.Error) + }) + } +} + +func Test_ReconcileDependencies_DoesNotBackfillFromAnotherSource(t *testing.T) { + pack, child := packWithLeaf("1.0.0", "1.0.0") + child.Versions[0].Dependencies = []ExtensionDependency{{Id: "wrong.leaf"}} + manager := newInstallTestManager(t, &mockSource{ + name: MainRegistryName, extensions: []*ExtensionMetadata{pack, child}, + }) + require.NoError(t, manager.userConfig.Set(installedConfigKey, map[string]*Extension{ + pack.Id: installedRecord(pack.Id, "1.0.0", false, child.Id), + child.Id: {Id: child.Id, Version: "1.0.0", Source: "dev"}, + })) + + _, results, err := manager.ReconcileDependencies(t.Context(), pack, DefaultUpgradeOptions("")) + require.NoError(t, err) + require.Empty(t, results) + record, err := manager.GetInstalled(FilterOptions{Id: child.Id}) + require.NoError(t, err) + require.Equal(t, "dev", record.Source) + require.Empty(t, record.Dependencies, "the azd release cannot describe a dev installation") +} diff --git a/cli/azd/pkg/extensions/upgrade_result.go b/cli/azd/pkg/extensions/upgrade_result.go index cff5ab3e76f..481f48e621a 100644 --- a/cli/azd/pkg/extensions/upgrade_result.go +++ b/cli/azd/pkg/extensions/upgrade_result.go @@ -64,9 +64,8 @@ type UpgradeResult struct { // Suggestion is an optional actionable hint shown alongside SkipReason or Error // in interactive output. Not serialized to JSON; may contain ANSI formatting. Suggestion string - // DependencyUpgrades captures upgrade results for dependent extensions - // that were upgraded as a side effect of upgrading this extension. Empty - // for leaf extensions or when --no-dependency-updates is set. + // DependencyUpgrades captures dependency reconciliation results, including + // skipped upgrades and failed metadata writes when dependency updates are disabled. DependencyUpgrades []UpgradeResult } @@ -117,6 +116,11 @@ type UpgradeSummary struct { DependencyUpgradesByStatus map[UpgradeStatus]int `json:"-"` } +// HasFailures reports whether a top-level extension or any dependency failed. +func (s UpgradeSummary) HasFailures() bool { + return s.Failed > 0 || s.DependencyUpgradesByStatus[UpgradeStatusFailed] > 0 +} + // NewUpgradeSummary computes aggregate counts from a slice of UpgradeResult. // Top-level status counters exclude dependency upgrades. func NewUpgradeSummary(results []UpgradeResult) UpgradeSummary { diff --git a/cli/azd/pkg/extensions/upgrade_result_test.go b/cli/azd/pkg/extensions/upgrade_result_test.go index 6e134c844b6..a04119bd5ff 100644 --- a/cli/azd/pkg/extensions/upgrade_result_test.go +++ b/cli/azd/pkg/extensions/upgrade_result_test.go @@ -199,6 +199,7 @@ func TestNewUpgradeSummary(t *testing.T) { assert.Equal(t, 0, s.Skipped) assert.Equal(t, 0, s.Promoted) assert.Equal(t, 0, s.Failed) + assert.False(t, s.HasFailures()) }) t.Run("mixed_results", func(t *testing.T) { @@ -217,6 +218,7 @@ func TestNewUpgradeSummary(t *testing.T) { assert.Equal(t, 1, s.Skipped) assert.Equal(t, 1, s.Promoted) assert.Equal(t, 2, s.Failed) + assert.True(t, s.HasFailures()) }) t.Run("all_upgraded", func(t *testing.T) { diff --git a/docs/architecture/telemetry.md b/docs/architecture/telemetry.md index d607598b12c..5604a105f75 100644 --- a/docs/architecture/telemetry.md +++ b/docs/architecture/telemetry.md @@ -182,7 +182,7 @@ flowchart LR - Validation: `ext.validation.*` - Auth: `ext.auth.*` - Dependency: `ext.dependency.*` -- Extension lifecycle events: `ext.install`, `ext.update`, `ext.promote` +- Extension lifecycle events: `ext.install`, `ext.update`, `ext.uninstall`, `ext.promote` - Extensions published to the official registry can report **usage events** via `TelemetryService.ReportUsage` after going through a privacy review. Each event becomes an `ext.usage` span sharing the command's trace, carrying diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index fec98bca253..cccaecd2512 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -73,9 +73,12 @@ Commands follow the pattern `cmd.` where spaces become dots. | `ext.run` | Extension command execution | | `ext.install` | Extension installation | | `ext.update` | Extension update attempt | +| `ext.uninstall` | Single extension uninstall attempt, by name or as a no-longer-required dependency | | `ext.promote` | Registry promotion (e.g., dev → main) | | `ext.usage` | Usage event reported by an extension through the telemetry service (official-registry extensions only) | +`ext.uninstall` intentionally combines requested removals and automatic unused-dependency cleanup. No event field distinguishes these cases. Internal removals during updates do not emit this event. + ### Agent & Copilot Events | Event | Description | @@ -486,7 +489,7 @@ Emitted at provision start by the `microsoft.foundry` provisioning provider (the | `extension.version.from` | string | Version before an update or promotion (`ext.update`, `ext.promote`) | | `extension.version.to` | string | Version after an update or promotion (`ext.update`, `ext.promote`) | | `extension.source` | string | Registry source used for an update and admission check for `ext.usage` | -| `extension.source.category` | string | Fixed source category: `azd`, `dev`, `nightly`, `local`, `bundle`, `other`, or `unknown` (`ext.install`, `ext.update`, `azd extension source add`) | +| `extension.source.category` | string | Fixed source category: `azd`, `dev`, `nightly`, `local`, `bundle`, `other`, or `unknown` (`ext.install`, `ext.update`, `ext.uninstall`, `azd extension source add`) | | `extension.source.kind` | string | Kind of `--source` argument: `none`, `registered`, or `location` (`azd extension list`, `show`, `install`, `update`) | | `extension.source.category.from` | string | Fixed source category before a promotion (`ext.promote`) | | `extension.source.category.to` | string | Fixed source category after a promotion (`ext.promote`) | @@ -811,7 +814,7 @@ How to find telemetry for a given feature area. Start here if you know the featu | **Provisioning (IaC)** | `cmd.provision`, `cmd.up`, `cmd.down`, `arm.deploy.*`, `arm.validate.*` | `infra.provider` (`bicep`/`terraform`/`arm`/`pulumi`/custom; slice of each distinct provider for multi-layer projects) | Provision success, ARM errors, duration | | **Authentication** | `cmd.auth.login` | `auth.method` | Auth method usage, failure rates | | **CI/CD Pipelines** | `cmd.pipeline.config` | `pipeline.provider` | Pipeline setup adoption | -| **Extensions** | `ext.run`, `cmd.*`, `ext.install`, `ext.update`, `ext.usage` | `extension.id`, `extension.version`, `extension.installed`, `extension.event` (lifecycle hooks), `error.chain.types`, `error.extension.cause_types`, `error.mapper.source.type`, `error.mapper.destination.type`, `error.tool.name`, dynamic `ext.*` fields | Extension adoption, command and lifecycle-hook errors, and usage events | +| **Extensions** | `ext.run`, `cmd.*`, `ext.install`, `ext.update`, `ext.uninstall`, `ext.usage` | `extension.id`, `extension.version`, `extension.installed`, `extension.event` (lifecycle hooks), `error.chain.types`, `error.extension.cause_types`, `error.mapper.source.type`, `error.mapper.destination.type`, `error.tool.name`, dynamic `ext.*` fields | Extension adoption, command and lifecycle-hook errors, and usage events | | **MCP** | `mcp.` | `mcp.client.name`, `mcp.client.version` | Tool usage by client | | **Agentic (Copilot)** | `copilot.initialize`, `copilot.session` | `copilot.mode`, `copilot.init.model`, `copilot.message.*` | Session counts, token usage | | **Agent Troubleshooting** | `agent.troubleshoot` | `agent.fix.attempts` | Auto-fix adoption, retry counts | diff --git a/docs/specs/metrics-audit/feature-telemetry-matrix.md b/docs/specs/metrics-audit/feature-telemetry-matrix.md index ce03e42b947..a8135e1636b 100644 --- a/docs/specs/metrics-audit/feature-telemetry-matrix.md +++ b/docs/specs/metrics-audit/feature-telemetry-matrix.md @@ -30,7 +30,7 @@ These commands emit attributes or events beyond the global middleware span. |---------|---------------------|-------| | `init` | `init.method` (template / app / project / environment / copilot), `appinit.detected.databases`, `appinit.detected.services`, `appinit.confirmed.databases`, `appinit.confirmed.services`, `appinit.modify_add.count`, `appinit.modify_remove.count`, `appinit.lastStep` | Comprehensive coverage via `SetUsageAttributes` and `repository/app_init.go` | | `update` | `update.installMethod`, `update.channel`, `update.fromVersion`, `update.toVersion`, `update.result` | Result codes cover success, failure, and skip reasons | -| Extensions (dynamic) | `extension.id`, `extension.version`, `extension.event`, `extension.version.from`, `extension.version.to`, `extension.source.category`, `extension.source.kind`, `extension.source.category.from`, `extension.source.category.to`, `extension.installed.source.category`, `extension.dependency_of`, `extension.dependency_update_count`, `extension.update.outcome`, `extension.update.duration_ms`, `error.chain.types`, `error.extension.cause_types`, `error.mapper.source.type`, `error.mapper.destination.type` + trace-context propagation to child process | Covers `ext.run`, `ext.install`, `ext.update`, `ext.promote`, source registration, installed inventory, and failed-invocation attribution without emitting source names or locations | +| Extensions (dynamic) | `extension.id`, `extension.version`, `extension.event`, `extension.version.from`, `extension.version.to`, `extension.source.category`, `extension.source.kind`, `extension.source.category.from`, `extension.source.category.to`, `extension.installed.source.category`, `extension.dependency_of`, `extension.dependency_update_count`, `extension.update.outcome`, `extension.update.duration_ms`, `error.chain.types`, `error.extension.cause_types`, `error.mapper.source.type`, `error.mapper.destination.type` + trace-context propagation to child process | Covers `ext.run`, `ext.install`, `ext.update`, `ext.uninstall`, `ext.promote`, source registration, installed inventory, and failed-invocation attribution without emitting source names or locations | | `mcp start` | Per-tool spans via `tracing.Start` with `mcp.client.name`, `mcp.client.version` | MCP event prefix `mcp.*` | | `tool install` / `tool update` / `tool uninstall` / `tool check` / `tool list` / `tool show` | `tool.id`, `tool.ids`, `tool.dry_run`, `tool.install.strategy`, `tool.install.success`, `tool.install.success_count`, `tool.install.failure_count`, `tool.install.failed_ids`, `tool.install.duration_ms`, `tool.update.from_version`, `tool.update.to_version`, `tool.check.updates_available` | Comprehensive coverage in `cli/azd/cmd/tool.go`; install/update emit `tools.pack.build` spans for pack-based tools | | `copilot` (agent) | `copilot.initialize` event (model + reasoning config), `copilot.session` event (session create/resume) | Emitted from `internal/agent/copilot_agent.go`; covers the experimental copilot agent surface | @@ -87,7 +87,7 @@ These commands emit attributes or events beyond the global middleware span. | **Copilot Consent** | | | | | | | `copilot consent` | `list`, `revoke`, `grant` | ✅ | ❌ | ❌ | Low priority | | **Extension Management** | | | | | | -| `extension` | `list`, `show`, `install`, `uninstall`, `update` | ✅ | ✅ | ✅ | Covered by `extension.*` fields and `ext.install`, `ext.update`, `ext.promote` events; `extension.source.kind` tracks `--source` argument kind for list/show/install/update | +| `extension` | `list`, `show`, `install`, `uninstall`, `update` | ✅ | ✅ | ✅ | Covered by `extension.*` fields and `ext.install`, `ext.update`, `ext.uninstall`, `ext.promote` events; `extension.source.kind` tracks `--source` argument kind for list/show/install/update; one `ext.uninstall` span per attempted extension removal covers dependency-aware uninstall | | `extension source` | `list`, `add`, `remove`, `validate` | ✅ | ✅ | ❌ | `source add` emits the fixed `extension.source.category` on the command span; other operations rely on global command telemetry and do not emit configured values | | **Init** | | | | | | | `init` | — | ✅ | ✅ | ✅ | Comprehensive coverage via `appinit.*` fields | diff --git a/docs/specs/metrics-audit/telemetry-schema.md b/docs/specs/metrics-audit/telemetry-schema.md index cb5dc26a765..f4353fb17d3 100644 --- a/docs/specs/metrics-audit/telemetry-schema.md +++ b/docs/specs/metrics-audit/telemetry-schema.md @@ -18,6 +18,7 @@ OpenTelemetry span name or event name. | `ExtensionRunEvent` | `ext.run` | Extension execution event | | `ExtensionInstallEvent` | `ext.install` | Extension install/upgrade event | | `ExtensionUpdateEvent` | `ext.update` | Single extension update attempt | +| `ExtensionUninstallEvent` | `ext.uninstall` | Single extension uninstall attempt, by name or as a no-longer-required dependency | | `ExtensionPromoteEvent` | `ext.promote` | Extension registry promotion (e.g., dev → main) | | `ExtensionUsageEvent` | `ext.usage` | One usage event reported by an extension through the telemetry service | | `CopilotInitializeEvent` | `copilot.initialize` | Copilot initialization event |