From 72615b75988acf7a9cd123d88fb80826a0cca93a Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 00:30:59 +0000 Subject: [PATCH 01/18] feat(extensions): dependency-aware uninstall and ownership tracking Installed records now store the installed version's dependency list and whether the extension was installed as a dependency. Installs by name mark a record explicit; updates preserve the flag and backfill older records. azd extension uninstall plans the removal from those records: it fails when other installed extensions require a target (--force overrides), removes dependencies that are no longer required (--no-dependencies keeps them), and lists each removed or kept dependency with the reason. azd extension show gains Dependencies and Required By sections, compatibility and update annotations, an installed-record fallback, and camelCase JSON keys. Adds the ext.uninstall telemetry event and updates docs and snapshots. --- cli/azd/cmd/auto_install.go | 8 + cli/azd/cmd/auto_install_test.go | 33 + cli/azd/cmd/extension.go | 610 +++++++++++++++--- cli/azd/cmd/extension_show_test.go | 374 +++++++++++ cli/azd/cmd/extension_test.go | 24 +- cli/azd/cmd/extension_uninstall_test.go | 208 ++++++ cli/azd/cmd/extension_upgrade_test.go | 29 + cli/azd/cmd/init.go | 24 +- cli/azd/cmd/init_test.go | 44 ++ cli/azd/cmd/telemetry_test.go | 32 +- cli/azd/cmd/testdata/TestFigSpec.ts | 9 + .../TestUsage-azd-extension-uninstall.snap | 4 +- .../docs/extensions/extension-framework.md | 8 +- .../extension-resolution-and-versioning.md | 14 +- cli/azd/internal/tracing/events/events.go | 3 + cli/azd/pkg/extensions/extension.go | 9 + cli/azd/pkg/extensions/manager.go | 102 ++- cli/azd/pkg/extensions/uninstall.go | 223 +++++++ cli/azd/pkg/extensions/uninstall_test.go | 492 ++++++++++++++ docs/reference/telemetry-data.md | 5 +- .../metrics-audit/feature-telemetry-matrix.md | 4 +- docs/specs/metrics-audit/telemetry-schema.md | 1 + 22 files changed, 2132 insertions(+), 128 deletions(-) create mode 100644 cli/azd/cmd/extension_show_test.go create mode 100644 cli/azd/cmd/extension_uninstall_test.go create mode 100644 cli/azd/pkg/extensions/uninstall.go create mode 100644 cli/azd/pkg/extensions/uninstall_test.go 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..1422eef1cf4 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,27 @@ 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) +} diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index a2262b77abb..e80b540cdee 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -114,6 +114,13 @@ installs aren't tracked for updates; install a newer bundle to update.`, Command: &cobra.Command{ Use: "uninstall [extension-id]", Short: "Uninstall specified extensions.", + Long: `Uninstall one or more installed extensions. + +Dependencies that were installed for the removed extensions and are no longer +required are listed and removed after confirmation; --no-prompt proceeds and +--no-dependencies keeps them. Uninstalling an extension that other installed +extensions require fails unless --force is set or the dependents are +uninstalled in the same command. Use --all to remove every installed extension.`, }, ActionResolver: newExtensionUninstallAction, FlagsResolver: newExtensionUninstallFlags, @@ -593,21 +600,118 @@ 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, dependency install. +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) + case t.InstalledAsDependency: + summary += " (installed as a dependency)" + } + 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 +752,65 @@ 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.LatestVersion != "" { + versionInfo = append(versionInfo, []string{"Latest", ":", t.LatestVersion}) } - // Only add Tags if they are defined - if len(t.Tags) > 0 { - versionInfo = append(versionInfo, []string{"Tags", ":", strings.Join(t.Tags, ", ")}) + 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 +834,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 @@ -763,42 +896,27 @@ func (a *extensionShowAction) Run(ctx context.Context) (*actions.ActionResult, e return nil, fmt.Errorf("failed to find extension: %w", err) } - registryExtension, err := selectDistinctExtension(ctx, a.console, extensionId, extensionMatches, a.flags.global) + installedExtension, err := a.extensionManager.GetInstalled(extensions.FilterOptions{Id: extensionId}) if err != nil { - return nil, err + installedExtension = nil } - latestVersion := extensions.LatestVersion(registryExtension.Versions) - - var otherVersions []string - for _, version := range registryExtension.Versions { - if version.Version != latestVersion.Version { - otherVersions = append(otherVersions, version.Version) + // An installed extension that no configured source lists (bundle install, delisted, or + // source removed) is described from its installed record alone, unless --source asked + // for a specific source that does not carry it. + 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 } } - 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", - } - - 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 +930,170 @@ 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 { + 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 +1317,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 +1376,15 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult // Use upgrade logic for existing installations a.console.ShowSpinner(ctx, stepMessage, input.Step) + // 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. extensionVersion, _, 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 { @@ -1972,12 +2269,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 when other installed extensions depend on the extension") + cmd.Flags().BoolVar(&flags.noDependencies, "no-dependencies", false, + "Uninstall only the specified extension(s), keeping dependencies that were installed for them") return flags } @@ -2022,7 +2325,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 +2334,73 @@ 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) + } + + // Blocked targets only reach this point under --force; say what is being left behind. + 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], ", "), + ), + }) + } - stepMessage = extensionTaskMessageWithVersion("Uninstalling", extensionId, installed.Version) + // 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.Orphaned, nil + } + } + + 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 +2408,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 +2450,71 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu }, nil } +// confirmDependencyRemoval lists the dependencies that would go along with the targets and +// asks once. The default is to remove them, and --no-prompt takes the default: they are, by +// construction, needed only by what is being removed. +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( + "The following dependencies were installed for %s and are no longer required:", + 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 as well?" + if len(plan.Orphaned) > 1 { + question = fmt.Sprintf("Remove these %d dependencies as well?", 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 +3016,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, installedRelease); err != nil { + return fail(err) + } + var selectedExt *extensions.ExtensionMetadata var isPromotion bool var oldSource, newSource string diff --git a/cli/azd/cmd/extension_show_test.go b/cli/azd/cmd/extension_show_test.go new file mode 100644 index 00000000000..8164b09378e --- /dev/null +++ b/cli/azd/cmd/extension_show_test.go @@ -0,0 +1,374 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bytes" + "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/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_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_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_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, ">=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(), "1.0.0 (installed as a 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..61a96de0511 100644 --- a/cli/azd/cmd/extension_test.go +++ b/cli/azd/cmd/extension_test.go @@ -1144,18 +1144,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..d2e770636b1 --- /dev/null +++ b/cli/azd/cmd/extension_uninstall_test.go @@ -0,0 +1,208 @@ +// 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), + ) + require.Contains(t, strings.Join(console.Output(), "\n"), + "azure.ai.projects is required by azure.ai.agents, microsoft.foundry") +} + +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, "Remove these 4 dependencies as well?") + 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"), "as well?") +} + +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") +} + +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..acf5754d894 100644 --- a/cli/azd/cmd/extension_upgrade_test.go +++ b/cli/azd/cmd/extension_upgrade_test.go @@ -964,6 +964,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.Emit(), sourceName) + } + }) + t.Run("PromotionUsesFixedCategories", func(t *testing.T) { emitPromotionEvent( t.Context(), diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index 6d0ac7018d3..f17c9bff827 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -1128,20 +1128,28 @@ 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. + if installed, err := i.extensionsManager.GetInstalled(extensions.FilterOptions{Id: extensionId}); 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 } diff --git a/cli/azd/cmd/init_test.go b/cli/azd/cmd/init_test.go index c877708b865..e613ffa6cc6 100644 --- a/cli/azd/cmd/init_test.go +++ b/cli/azd/cmd/init_test.go @@ -1955,3 +1955,47 @@ 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) +} diff --git a/cli/azd/cmd/telemetry_test.go b/cli/azd/cmd/telemetry_test.go index dad49710406..f30d8a6610d 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 removed extension + "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..367ff058d58 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6323,6 +6323,15 @@ const completionSpec: Fig.Spec = { name: ['--all'], description: 'Uninstall all installed extensions', }, + { + name: ['--force', '-f'], + description: 'Uninstall even when other installed extensions depend on the extension', + isDangerous: true, + }, + { + name: ['--no-dependencies'], + description: 'Uninstall only the specified extension(s), keeping dependencies that were installed for them', + }, ], args: { name: 'extension-id', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap index 4609e8efd12..fae61782af4 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap @@ -5,7 +5,9 @@ Usage azd extension uninstall [extension-id] [flags] Flags - --all : Uninstall all installed extensions + --all : Uninstall all installed extensions + -f, --force : Uninstall even when other installed extensions depend on the extension + --no-dependencies : Uninstall only the specified extension(s), keeping dependencies that were installed for them 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..fa91a1b9d32 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -123,7 +123,7 @@ 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 details for a specific extension: description, tags, versions, installation status, azd compatibility, declared dependencies with their installed state, and the installed extensions that require it. An installed extension that no source lists (for example, a bundle install) is shown from its installed record, and one listed by several sources is shown from the source it was installed from. - `-s, --source` Uses a registered source name or registry location (URL or file path). Locations are queried read-only and are not registered. @@ -144,9 +144,11 @@ Installs one or more extensions from any configured extension source. #### `azd extension uninstall [flags]` -Uninstalls one or more previously installed extensions. +Uninstalls one or more installed extensions. Dependencies that were installed for them and are no longer required are listed and removed after a confirmation (`--no-prompt` proceeds). Uninstalling an extension that other installed extensions require fails before anything is removed, unless the dependents are named in the same command. - `--all` Removes all installed extensions when specified. +- `-f, --force` Removes the extension even when other installed extensions depend on it, and warns which ones. +- `--no-dependencies` Keeps the dependencies that were installed for the removed extensions. #### `azd extension update ` @@ -1240,6 +1242,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 removes the pack and, after confirmation, every dependency, including transitive ones, that was installed for it and that nothing else requires. `azd` records on each installed extension whether it was requested by name or pulled in as a dependency, together with the installed version's dependency list, so no registry access is needed. A dependency is kept when it was installed by name (`azd extension install `, `azd init`, or project auto-install on a dependency-installed extension marks it as explicit) or when another installed extension still requires it, and the reason is shown. Uninstalling a dependency while a pack or another extension requires it fails unless `--force` is passed. `azd extension show ` lists an extension's dependencies and the installed extensions that require it. + #### 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..b96e49de9d3 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** - Record the installed extension and version in `~/.azd/config.json` under the `extension.installed` section. The record also stores the installed version's dependency list and an `installedAsDependency` flag. Installs by name (`azd extension install`, `azd init`, project auto-install) leave the flag unset and clear it on a record a pack pulled in earlier; updates preserve it. ### Re-installing over an existing extension @@ -195,6 +195,18 @@ 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 ` plans the whole removal from the installed records before removing anything, without querying a registry. + +1. **Check dependents** - Any installed extension whose recorded dependencies include a requested id, and that is not itself being removed, blocks the request. `azd` fails with the list of dependents and a suggestion to uninstall them first. `--force` proceeds and warns which dependents are left without the extension. +2. **Remove the requested extensions** - In the order given. +3. **Remove orphaned dependencies** - A dependency of a removed extension is removed when it was installed as a dependency and no remaining extension requires it. Removed dependencies are walked in turn, so transitive dependencies are covered and a dependency first kept for a sibling is freed once that sibling goes. `azd` lists the dependencies it is about to remove and asks once; the default answer is yes and `--no-prompt` takes it. Declining keeps them, still recorded as dependency installs, with the command to remove them later. Kept dependencies are listed with the reason. `--no-dependencies` skips this step entirely. + +`azd extension uninstall --all` removes every installed extension. + +Records written before dependency tracking carry neither the dependency list nor the flag. They are treated as installs by name with no known dependencies: never removed as orphans and never blocking. `azd extension update` records the dependency list on such records, even when nothing is updated, so existing installs gain dependent protection after one update. Ownership is never guessed. + ## 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/internal/tracing/events/events.go b/cli/azd/internal/tracing/events/events.go index 238fe929696..89fdc158662 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 the removal of a single extension by + // `azd extension uninstall`, including 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..1cdaf48f8d3 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 { @@ -773,18 +779,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 +875,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 +989,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 +1024,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 +1082,11 @@ type UpgradeOptions struct { // SkipMainRegistryDependencyFallback mirrors the InstallOptions behavior for // the reinstall performed during upgrade. SkipMainRegistryDependencyFallback bool + // PromoteToExplicit records the extension as explicitly installed even when the + // existing record was only a dependency install. `azd extension install ` sets + // it because the user named the extension; updates leave it unset so ownership is + // preserved. + PromoteToExplicit bool } // DefaultUpgradeOptions returns UpgradeOptions with dependency upgrades enabled. @@ -1109,11 +1132,59 @@ func (m *Manager) ReconcileDependencies( return selectedVersion, nil, nil } + if err := m.BackfillDependencies(extension.Id, 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 when the record is at the supplied version and has no +// snapshot yet, so an update that keeps the extension current still teaches uninstall planning +// about its graph. Ownership is never inferred. +func (m *Manager) BackfillDependencies(id string, version *ExtensionVersion) error { + if version == nil { + return nil + } + installed, err := m.GetInstalled(FilterOptions{Id: id}) + if err != nil || installed == nil { + return nil + } + if len(installed.Dependencies) > 0 || + installed.Version != version.Version || + len(version.Dependencies) == 0 { + return nil + } + + installed.Dependencies = slices.Clone(version.Dependencies) + if err := m.UpdateInstalled(installed); err != nil { + return fmt.Errorf("failed to record dependencies for %s: %w", id, err) + } + return nil +} + +// MarkExplicitlyInstalled records that the user asked for an extension directly, so it is no +// longer removed along with the extensions that originally pulled it in. It is a no-op for +// extensions that are already explicit. +func (m *Manager) MarkExplicitlyInstalled(id string) error { + installed, err := m.GetInstalled(FilterOptions{Id: id}) + if err != nil { + return err + } + if !installed.InstalledAsDependency { + return nil + } + + installed.InstalledAsDependency = false + if err := m.UpdateInstalled(installed); 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 +1193,14 @@ func (m *Manager) upgradeInternal( opts UpgradeOptions, visited map[string]struct{}, ) (*ExtensionVersion, []UpgradeResult, error) { + // An update must not change who asked for the extension: a dependency-installed + // extension stays removable with its parents, and an explicit one stays explicit. + // Only an install by name (PromoteToExplicit) turns a dependency into an explicit record. + 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 +1211,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) } @@ -1230,6 +1309,13 @@ func (m *Manager) evaluateDependencyChanges( continue } + // Children that predate dependency tracking learn their snapshot here, so one update + // of the parent protects the whole tree even when every child is already current. + installedRelease := FindVersion(childMetadata.Versions, installed.Version) + if err := m.BackfillDependencies(dep.Id, installedRelease); err != nil { + log.Printf("Warning: %v", err) + } + bestVersion := bestSatisfyingVersionForAzd(dep.Version, childMetadata.Versions, m.azdVersion) if bestVersion == nil { // If no published version matches, keep a compatible installed version. @@ -1364,6 +1450,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/uninstall.go b/cli/azd/pkg/extensions/uninstall.go new file mode 100644 index 00000000000..5769476ed39 --- /dev/null +++ b/cli/azd/pkg/extensions/uninstall.go @@ -0,0 +1,223 @@ +// 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 + } + + 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 + } + + if opts.KeepDependencies { + return plan, nil + } + + // Walk the dependencies of everything being removed. A dependency joins the removal set + // when it was installed as a dependency and nothing outside the removal set requires it. + // Removed extensions are appended to the queue so their own dependencies are visited, + // which also re-examines a dependency that was first kept because of a sibling that is + // removed later (A -> B, A -> C, C -> B). + considered := map[string]*Extension{} + 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) + } + } + + 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..07433d8add1 --- /dev/null +++ b/cli/azd/pkg/extensions/uninstall_test.go @@ -0,0 +1,492 @@ +// 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" +) + +// 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_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{}) + require.Error(t, err) + var requiredErr *ExtensionRequiredError + require.ErrorAs(t, err, &requiredErr) + require.Equal(t, []string{"test.child"}, requiredErr.Blocked["test.leaf"]) +} + +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_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) +} diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index fec98bca253..c06eb98ffab 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -73,6 +73,7 @@ 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` | Removal of one extension by `azd extension uninstall`, 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) | @@ -486,7 +487,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 +812,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..1d424e5950b 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 removed extension 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..5c168cc285e 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` | Removal of one extension by `azd extension uninstall`, 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 | From 0474c26afa24e7cf6e8290d06ff9b1cc0fe848e6 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 00:57:23 +0000 Subject: [PATCH 02/18] fix(extensions): address review findings for dependency-aware uninstall - Promote project-required extensions during auto-install discovery, where already-installed requirements and installed providers are skipped, so a record a pack pulled in survives the pack's removal. - Backfill a legacy child's dependency snapshot before the unconstrained dependency check, so parents declaring dependencies without a version still protect the tree after one update. - Compute the orphan closure before evaluating dependents, so a dependency-installed extension in a cycle with a target leaves with it instead of blocking it. - Assert the show command's JSON keys on the raw object, since decoding into the tagged struct accepts the old PascalCase names. - Advertise multiple ids in the uninstall usage string and mark `[name...]` arguments variadic in the fig spec instead of leaving the dots in the name. - Use errors.AsType in the backfill test. --- cli/azd/cmd/auto_install_test.go | 35 +++++++++ cli/azd/cmd/extension.go | 2 +- cli/azd/cmd/extension_show_test.go | 46 ++++++++++++ cli/azd/cmd/project_extension_auto_install.go | 49 +++++++++++-- cli/azd/cmd/testdata/TestFigSpec.ts | 16 +++-- .../TestUsage-azd-extension-uninstall.snap | 2 +- cli/azd/internal/figspec/spec_builder.go | 3 + cli/azd/internal/figspec/spec_builder_test.go | 16 +++++ cli/azd/internal/figspec/types.go | 2 + .../internal/figspec/typescript_renderer.go | 4 ++ cli/azd/pkg/extensions/manager.go | 45 ++++++------ cli/azd/pkg/extensions/uninstall.go | 72 +++++++++---------- cli/azd/pkg/extensions/uninstall_test.go | 71 +++++++++++++++++- 13 files changed, 290 insertions(+), 73 deletions(-) diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 1422eef1cf4..15c9e73ea61 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -2501,3 +2501,38 @@ func TestTryAutoInstallExtensionVersionPromotesDependencyInstalledExtension(t *t require.False(t, installed, "already installed, so nothing is downloaded") require.False(t, manager.installed["azure.ai.agents"].InstalledAsDependency) } + +func TestMissingProjectExtensionsPromotesInstalledDependencyRecords(t *testing.T) { + t.Parallel() + + // Both a requiredVersions entry and a provider requirement are satisfied by extensions + // that a pack pulled in earlier. Discovery promotes them without touching a registry. + 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"}, + }, + } + + 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.False(t, manager.installed["azure.ai.agents"].InstalledAsDependency) +} diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index e80b540cdee..e9cd700abe6 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -112,7 +112,7 @@ 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: `Uninstall one or more installed extensions. diff --git a/cli/azd/cmd/extension_show_test.go b/cli/azd/cmd/extension_show_test.go index 8164b09378e..c9f6040308b 100644 --- a/cli/azd/cmd/extension_show_test.go +++ b/cli/azd/cmd/extension_show_test.go @@ -107,6 +107,52 @@ func TestExtensionShowAction_ExplainsDependenciesAndDependents(t *testing.T) { 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() diff --git a/cli/azd/cmd/project_extension_auto_install.go b/cli/azd/cmd/project_extension_auto_install.go index a1c26548cde..418867e6f6e 100644 --- a/cli/azd/cmd/project_extension_auto_install.go +++ b/cli/azd/cmd/project_extension_auto_install.go @@ -381,18 +381,39 @@ func resolveExtensionDependencies( // installedProvidesProvider reports whether an installed extension already supplies the provider, // in which case nothing needs to be installed for it. -func installedProvidesProvider( +// installedProviderExtensions returns the installed extensions that publish the provider, +// sorted by id. +func installedProviderExtensions( installed map[string]*extensions.Extension, capability extensions.CapabilityType, providerName string, -) bool { - for extension := range maps.Values(installed) { +) []*extensions.Extension { + var providers []*extensions.Extension + for _, extension := range installed { if extensionProvidesProvider(extension.Capabilities, extension.Providers, capability, providerName) { - return true + providers = append(providers, extension) } } + slices.SortFunc(providers, func(a, b *extensions.Extension) int { + return strings.Compare(a.Id, b.Id) + }) + return providers +} - 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( @@ -513,6 +534,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 +579,17 @@ 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 + } + // An installed provider satisfies the requirement; the project needs that extension + // in its own right, so a dependency-installed record becomes explicit. + if providers := installedProviderExtensions(installed, capability, provider); len(providers) > 0 { + for _, extension := range providers { + if err := promoteProjectRequiredExtension(extensionManager, extension); err != nil { + return err + } + } return nil } diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 367ff058d58..0489dd76cec 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, }, ], }, @@ -6336,6 +6338,7 @@ const completionSpec: Fig.Spec = { args: { name: 'extension-id', isOptional: true, + isVariadic: true, generators: azdGenerators.listInstalledExtensions, }, }, @@ -6872,8 +6875,9 @@ const completionSpec: Fig.Spec = { }, ], args: { - name: 'tool-name...', + name: 'tool-name', isOptional: true, + isVariadic: true, }, }, { @@ -6911,8 +6915,9 @@ const completionSpec: Fig.Spec = { }, ], args: { - name: 'tool-name...', + name: 'tool-name', isOptional: true, + isVariadic: true, }, }, { @@ -6939,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 fae61782af4..b34ced84c1d 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap @@ -2,7 +2,7 @@ Uninstall specified extensions. Usage - azd extension uninstall [extension-id] [flags] + azd extension uninstall [extension-id...] [flags] Flags --all : Uninstall all installed extensions 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/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 1cdaf48f8d3..bfc4cb10209 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -1242,16 +1242,37 @@ func (m *Manager) evaluateDependencyChanges( var results []UpgradeResult for _, dep := range parentVersion.Dependencies { - if dep.Version == "" { - continue - } - 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, + ) + + // Children that predate dependency tracking learn their snapshot here, before any + // version logic, so one update of the parent protects the whole tree even when every + // child is already current or declared without a constraint. + if findErr == nil { + installedRelease := FindVersion(childMetadata.Versions, installed.Version) + if err := m.BackfillDependencies(dep.Id, installedRelease); err != nil { + log.Printf("Warning: %v", err) + } + } + + // 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) { @@ -1279,15 +1300,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) { @@ -1309,13 +1321,6 @@ func (m *Manager) evaluateDependencyChanges( continue } - // Children that predate dependency tracking learn their snapshot here, so one update - // of the parent protects the whole tree even when every child is already current. - installedRelease := FindVersion(childMetadata.Versions, installed.Version) - if err := m.BackfillDependencies(dep.Id, installedRelease); err != nil { - log.Printf("Warning: %v", err) - } - bestVersion := bestSatisfyingVersionForAzd(dep.Version, childMetadata.Versions, m.azdVersion) if bestVersion == nil { // If no published version matches, keep a compatible installed version. diff --git a/cli/azd/pkg/extensions/uninstall.go b/cli/azd/pkg/extensions/uninstall.go index 5769476ed39..20691c3bca0 100644 --- a/cli/azd/pkg/extensions/uninstall.go +++ b/cli/azd/pkg/extensions/uninstall.go @@ -156,6 +156,42 @@ func (m *Manager) PlanUninstall(ids []string, opts UninstallPlanOptions) (*Unins return dependents } + // Walk the dependencies of everything being removed. A dependency joins the removal set + // when it was installed as a dependency and nothing outside the removal set requires it. + // Removed extensions are appended to the queue so their own dependencies are visited, + // which also re-examines a dependency that was first kept because of a sibling that is + // removed later (A -> B, A -> C, C -> B). This runs before the dependents check so that a + // dependency-installed extension in a cycle with a target (A -> B -> A) leaves with it + // instead of blocking 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 { @@ -169,42 +205,6 @@ func (m *Manager) PlanUninstall(ids []string, opts UninstallPlanOptions) (*Unins plan.Blocked = blocked } - if opts.KeepDependencies { - return plan, nil - } - - // Walk the dependencies of everything being removed. A dependency joins the removal set - // when it was installed as a dependency and nothing outside the removal set requires it. - // Removed extensions are appended to the queue so their own dependencies are visited, - // which also re-examines a dependency that was first kept because of a sibling that is - // removed later (A -> B, A -> C, C -> B). - considered := map[string]*Extension{} - 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) - } - } - for _, id := range slices.Sorted(maps.Keys(considered)) { plan.Retained = append(plan.Retained, RetainedDependency{ Extension: considered[id], diff --git a/cli/azd/pkg/extensions/uninstall_test.go b/cli/azd/pkg/extensions/uninstall_test.go index 07433d8add1..a395874edbd 100644 --- a/cli/azd/pkg/extensions/uninstall_test.go +++ b/cli/azd/pkg/extensions/uninstall_test.go @@ -108,6 +108,27 @@ func Test_PlanUninstall_DependencyFreedBySiblingRemovedLater(t *testing.T) { 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()) @@ -444,12 +465,56 @@ func Test_Upgrade_BackfillsDependencySnapshotOfCurrentChildren(t *testing.T) { // The uninstall planner now protects the leaf through the child's snapshot. _, err = manager.PlanUninstall([]string{"test.leaf"}, UninstallPlanOptions{}) - require.Error(t, err) - var requiredErr *ExtensionRequiredError - require.ErrorAs(t, err, &requiredErr) + 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{ From 7dbd37545413664f4a2171669fd2959e3a994cc0 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 17:58:52 +0000 Subject: [PATCH 03/18] fix(extensions): preserve init config errors Fail fast when the installed extension configuration cannot be read, and narrow the legacy dependency backfill contract to directly reconciled records. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/init.go | 7 +++- cli/azd/cmd/init_test.go | 37 +++++++++++++++++++ .../extension-resolution-and-versioning.md | 2 +- cli/azd/pkg/extensions/manager.go | 6 +-- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index f17c9bff827..dcfc43e3f57 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -1136,7 +1136,8 @@ func (i *initAction) initializeExtensions(ctx context.Context, azdCtx *azdcontex // Look the record up each time: an extension pack installed earlier in this loop may // have pulled in a later entry as a dependency. - if installed, err := i.extensionsManager.GetInstalled(extensions.FilterOptions{Id: extensionId}); err == nil { + 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. @@ -1153,6 +1154,10 @@ func (i *initAction) initializeExtensions(ctx context.Context, azdCtx *azdcontex 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 e613ffa6cc6..a97a5035420 100644 --- a/cli/azd/cmd/init_test.go +++ b/cli/azd/cmd/init_test.go @@ -1999,3 +1999,40 @@ func TestInitializeExtensionsPromotesDependencyInstalledExtension(t *testing.T) 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/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index b96e49de9d3..a929e16e3a3 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -205,7 +205,7 @@ For registry-backed installs, a required dependency must resolve from the parent `azd extension uninstall --all` removes every installed extension. -Records written before dependency tracking carry neither the dependency list nor the flag. They are treated as installs by name with no known dependencies: never removed as orphans and never blocking. `azd extension update` records the dependency list on such records, even when nothing is updated, so existing installs gain dependent protection after one update. Ownership is never guessed. +Records written before dependency tracking carry neither the dependency list nor the flag. They are treated as installs by name with no known dependencies: never removed as orphans and never blocking. `azd extension update` records the dependency list on the extension being updated and on installed dependencies it directly reconciles, even when their versions do not change. Deeper legacy records gain their snapshot when they are directly updated, reconciled, or reinstalled. Ownership is never guessed. ## Self-Contained Bundles diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index bfc4cb10209..5fbefc3e4ac 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -1258,9 +1258,9 @@ func (m *Manager) evaluateDependencyChanges( m.azdVersion, ) - // Children that predate dependency tracking learn their snapshot here, before any - // version logic, so one update of the parent protects the whole tree even when every - // child is already current or declared without a constraint. + // 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, installedRelease); err != nil { From 5e6907f53c498bb0a6032099663ea0fec3889788 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 18:07:27 +0000 Subject: [PATCH 04/18] fix(extensions): refine uninstall dependency preview Render orphaned dependencies as a bulleted operation preview and use a direct counted confirmation prompt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 11 ++++++----- cli/azd/cmd/extension_uninstall_test.go | 6 ++++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index e9cd700abe6..748ae4ad4e4 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -2464,22 +2464,23 @@ func (a *extensionUninstallAction) confirmDependencyRemoval( a.console.Message(ctx, "") a.console.Message(ctx, fmt.Sprintf( - "The following dependencies were installed for %s and are no longer required:", + "After uninstalling %s, no installed extension will require these dependencies:", strings.Join(targets, ", "), )) for _, orphan := range plan.Orphaned { a.console.Message(ctx, fmt.Sprintf( - " %s %s", + " • %s %s", output.WithHighLightFormat(orphan.Id), output.WithGrayFormat("(%s)", orphan.Version), )) } a.console.Message(ctx, "") - question := "Remove this dependency as well?" - if len(plan.Orphaned) > 1 { - question = fmt.Sprintf("Remove these %d dependencies as well?", len(plan.Orphaned)) + noun := "dependency" + if len(plan.Orphaned) != 1 { + noun = "dependencies" } + question := fmt.Sprintf("Remove %d %s?", len(plan.Orphaned), noun) remove, err := a.console.Confirm(ctx, input.ConsoleOptions{ Message: question, DefaultValue: true, diff --git a/cli/azd/cmd/extension_uninstall_test.go b/cli/azd/cmd/extension_uninstall_test.go index d2e770636b1..a83d02941ae 100644 --- a/cli/azd/cmd/extension_uninstall_test.go +++ b/cli/azd/cmd/extension_uninstall_test.go @@ -112,9 +112,11 @@ func TestExtensionUninstallAction_PackRemovesOrphanedDependencies(t *testing.T) require.Empty(t, remainingInstalledIds(t, action.extensionManager)) output := strings.Join(console.Output(), "\n") - require.Contains(t, output, "Remove these 4 dependencies as well?") + require.Contains(t, output, + "After uninstalling microsoft.foundry, no installed extension will require these dependencies:") + require.Contains(t, output, "Remove 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, " • "+id+" ") } require.Contains(t, output, "no longer required") } From b934393d9d5499b09d2335624eaaf41f2ec5b67f Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 18:09:35 +0000 Subject: [PATCH 05/18] fix(extensions): clarify dependency confirmation Refer directly to the dependency preview in the uninstall confirmation prompt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 2 +- cli/azd/cmd/extension_uninstall_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 748ae4ad4e4..7e638cb6610 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -2480,7 +2480,7 @@ func (a *extensionUninstallAction) confirmDependencyRemoval( if len(plan.Orphaned) != 1 { noun = "dependencies" } - question := fmt.Sprintf("Remove %d %s?", len(plan.Orphaned), noun) + question := fmt.Sprintf("Remove these %d %s?", len(plan.Orphaned), noun) remove, err := a.console.Confirm(ctx, input.ConsoleOptions{ Message: question, DefaultValue: true, diff --git a/cli/azd/cmd/extension_uninstall_test.go b/cli/azd/cmd/extension_uninstall_test.go index a83d02941ae..7883d2a7cd8 100644 --- a/cli/azd/cmd/extension_uninstall_test.go +++ b/cli/azd/cmd/extension_uninstall_test.go @@ -114,7 +114,7 @@ func TestExtensionUninstallAction_PackRemovesOrphanedDependencies(t *testing.T) output := strings.Join(console.Output(), "\n") require.Contains(t, output, "After uninstalling microsoft.foundry, no installed extension will require these dependencies:") - require.Contains(t, output, "Remove 4 dependencies?") + 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+" ") } From bc135b4bd442da08ef15897d12cc439d85e01b34 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 18:11:54 +0000 Subject: [PATCH 06/18] docs(telemetry): describe uninstall attempts accurately Align the event comment and telemetry references with the span, which records both successful and failed extension uninstall attempts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/telemetry_test.go | 2 +- cli/azd/internal/tracing/events/events.go | 4 ++-- docs/reference/telemetry-data.md | 2 +- docs/specs/metrics-audit/feature-telemetry-matrix.md | 2 +- docs/specs/metrics-audit/telemetry-schema.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/cli/azd/cmd/telemetry_test.go b/cli/azd/cmd/telemetry_test.go index f30d8a6610d..400c9f6f6a4 100644 --- a/cli/azd/cmd/telemetry_test.go +++ b/cli/azd/cmd/telemetry_test.go @@ -1276,7 +1276,7 @@ func TestCommandTelemetryCoverage(t *testing.T) { "extension show", // extension.source.kind // extension.source.category "extension source add", - "extension uninstall", // ext.uninstall span per removed extension + "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 diff --git a/cli/azd/internal/tracing/events/events.go b/cli/azd/internal/tracing/events/events.go index 89fdc158662..e665baee777 100644 --- a/cli/azd/internal/tracing/events/events.go +++ b/cli/azd/internal/tracing/events/events.go @@ -35,8 +35,8 @@ const ( ExtensionInstallEvent = "ext.install" // ExtensionUpdateEvent tracks a single extension update attempt. ExtensionUpdateEvent = "ext.update" - // ExtensionUninstallEvent tracks the removal of a single extension by - // `azd extension uninstall`, including dependencies removed alongside it. + // 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" diff --git a/docs/reference/telemetry-data.md b/docs/reference/telemetry-data.md index c06eb98ffab..32440e4e46b 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -73,7 +73,7 @@ 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` | Removal of one extension by `azd extension uninstall`, by name or as a no-longer-required dependency | +| `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) | diff --git a/docs/specs/metrics-audit/feature-telemetry-matrix.md b/docs/specs/metrics-audit/feature-telemetry-matrix.md index 1d424e5950b..a8135e1636b 100644 --- a/docs/specs/metrics-audit/feature-telemetry-matrix.md +++ b/docs/specs/metrics-audit/feature-telemetry-matrix.md @@ -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.uninstall`, `ext.promote` events; `extension.source.kind` tracks `--source` argument kind for list/show/install/update; one `ext.uninstall` span per removed extension covers dependency-aware uninstall | +| `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 5c168cc285e..f4353fb17d3 100644 --- a/docs/specs/metrics-audit/telemetry-schema.md +++ b/docs/specs/metrics-audit/telemetry-schema.md @@ -18,7 +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` | Removal of one extension by `azd extension uninstall`, by name or as a no-longer-required dependency | +| `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 | From 9de24c94453970ce87f3062fa1427d2b4f9f369c Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 18:20:11 +0000 Subject: [PATCH 07/18] Fix lint warning --- cli/azd/cmd/extension_upgrade_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/cmd/extension_upgrade_test.go b/cli/azd/cmd/extension_upgrade_test.go index acf5754d894..475b86adb7f 100644 --- a/cli/azd/cmd/extension_upgrade_test.go +++ b/cli/azd/cmd/extension_upgrade_test.go @@ -989,7 +989,7 @@ func TestExtensionLifecycleTelemetrySpans(t *testing.T) { require.Equal(t, string(extensions.SourceCategoryDev), extensionSpanAttribute(t, attributes, fields.ExtensionSourceCategory.Key).Value.AsString()) for _, attr := range attributes { - require.NotContains(t, attr.Value.Emit(), sourceName) + require.NotContains(t, attr.Value.String(), sourceName) } }) From a4a4ca3579e85088a9cb4caab884035e00129797 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 18:24:45 +0000 Subject: [PATCH 08/18] fix(extensions): surface dependency backfill errors Ignore only missing installed records during dependency snapshot backfill and return malformed configuration errors to the caller. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/extensions/manager.go | 5 ++++- cli/azd/pkg/extensions/uninstall_test.go | 14 ++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 5fbefc3e4ac..cb12fb50284 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -1150,9 +1150,12 @@ func (m *Manager) BackfillDependencies(id string, version *ExtensionVersion) err return nil } installed, err := m.GetInstalled(FilterOptions{Id: id}) - if err != nil || installed == nil { + 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 || installed.Version != version.Version || len(version.Dependencies) == 0 { diff --git a/cli/azd/pkg/extensions/uninstall_test.go b/cli/azd/pkg/extensions/uninstall_test.go index a395874edbd..0704748b6b6 100644 --- a/cli/azd/pkg/extensions/uninstall_test.go +++ b/cli/azd/pkg/extensions/uninstall_test.go @@ -541,6 +541,20 @@ func Test_ReconcileDependencies_BackfillsLegacyDependencySnapshot(t *testing.T) 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", &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{ From 9e83eb8f18ad681e80a4c8d9f3738818ed26cb1d Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 23:06:36 +0000 Subject: [PATCH 09/18] fix(extensions): preserve dependency state and report failures Report dependency failures without hiding successful parent updates. Apply ownership and backfill edits to separate metadata snapshots and require matching installed sources and versions. Replan after declined dependency removal, preserve ownership of reused inferred providers, and clarify show output and confirmation prompts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/auto_install_test.go | 18 +-- cli/azd/cmd/extension.go | 85 +++++++---- cli/azd/cmd/extension_show_test.go | 34 ++++- cli/azd/cmd/extension_test.go | 27 ++++ cli/azd/cmd/extension_uninstall_test.go | 30 +++- cli/azd/cmd/extension_upgrade_test.go | 88 ++++++++++++ cli/azd/cmd/project_extension_auto_install.go | 24 +--- .../docs/extensions/extension-framework.md | 6 +- .../extension-resolution-and-versioning.md | 10 +- cli/azd/pkg/extensions/manager.go | 57 +++++--- cli/azd/pkg/extensions/uninstall_test.go | 132 +++++++++++++++++- cli/azd/pkg/extensions/upgrade_result.go | 10 +- cli/azd/pkg/extensions/upgrade_result_test.go | 2 + 13 files changed, 445 insertions(+), 78 deletions(-) diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 15c9e73ea61..5c2c4612d6b 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -2502,11 +2502,11 @@ func TestTryAutoInstallExtensionVersionPromotesDependencyInstalledExtension(t *t require.False(t, manager.installed["azure.ai.agents"].InstalledAsDependency) } -func TestMissingProjectExtensionsPromotesInstalledDependencyRecords(t *testing.T) { +func TestMissingProjectExtensionsPromotesOnlyExplicitRequirements(t *testing.T) { t.Parallel() - // Both a requiredVersions entry and a provider requirement are satisfied by extensions - // that a pack pulled in earlier. Discovery promotes them without touching a registry. + // 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": { @@ -2530,9 +2530,11 @@ func TestMissingProjectExtensionsPromotesInstalledDependencyRecords(t *testing.T }, } - 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.False(t, manager.installed["azure.ai.agents"].InstalledAsDependency) + 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 7e638cb6610..b9a757102b1 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -152,6 +152,9 @@ satisfying the extension's declared constraints. Use --no-dependency-updates to opt out and update only the named extension. +The command returns an error if any extension or dependency fails. Completed +updates are not rolled back. + Use --output json for a structured report of all update results.`, }, OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, @@ -662,7 +665,7 @@ type extensionShowDependent struct { } // installedSummary describes the installed state with at most one annotation, in priority -// order: foreign source, compatible update, newer incompatible release, dependency install. +// order: foreign source, compatible update, newer incompatible release. func (t *extensionShowItem) installedSummary() string { if t.InstalledVersion == "" { return "Not installed" @@ -676,8 +679,6 @@ func (t *extensionShowItem) installedSummary() string { summary += fmt.Sprintf(" (update available: %s)", t.LatestCompatibleVersion) case t.newerIncompatible: summary += fmt.Sprintf(" (%s requires a newer azd)", t.LatestVersion) - case t.InstalledAsDependency: - summary += " (installed as a dependency)" } return summary } @@ -776,6 +777,9 @@ func (t *extensionShowItem) Display(writer io.Writer) error { versionInfo := [][]string{ {"Installed", ":", t.installedSummary()}, } + if t.InstalledAsDependency { + versionInfo = append(versionInfo, []string{"Installed as", ":", "Dependency"}) + } if t.LatestVersion != "" { versionInfo = append(versionInfo, []string{"Latest", ":", t.LatestVersion}) } @@ -1018,7 +1022,8 @@ func (a *extensionShowAction) buildShowItem( // 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 { + 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 } @@ -1378,7 +1383,8 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult a.console.ShowSpinner(ctx, stepMessage, input.Step) // 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. - extensionVersion, _, err = a.extensionManager.Upgrade( + var dependencyResults []extensions.UpgradeResult + extensionVersion, dependencyResults, err = a.extensionManager.Upgrade( ctx, selectedExtension, extensions.UpgradeOptions{ VersionPreference: a.flags.version, UpgradeDependencies: !a.flags.noDependencies, @@ -1395,6 +1401,11 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult stepMessage += output.WithGrayFormat(" (%s)", extensionVersion.Version) a.console.StopSpinner(ctx, stepMessage, input.StepDone) + if extensions.NewUpgradeSummary(dependencyResults).HasFailures() { + displayDependencyUpgradeResults(ctx, a.console, dependencyResults, " ") + return nil, fmt.Errorf("failed to update dependencies for extension %s", extensionId) + } + } else { // Extension not installed - proceed with fresh install a.console.ShowSpinner(ctx, stepMessage, input.Step) @@ -2373,16 +2384,6 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu return nil, internal.WrapErrorWithSuggestion(err) } - // Blocked targets only reach this point under --force; say what is being left behind. - 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], ", "), - ), - }) - } - // 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 @@ -2392,10 +2393,27 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu return nil, err } if !remove { - keptDependencies, plan.Orphaned = plan.Orphaned, nil + 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) + } } } + // 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], ", "), + ), + }) + } + for _, target := range plan.Targets { stepMessage := extensionTaskMessageWithVersion("Uninstalling", target.Id, target.Version) a.console.ShowSpinner(ctx, stepMessage, input.Step) @@ -2476,11 +2494,10 @@ func (a *extensionUninstallAction) confirmDependencyRemoval( } a.console.Message(ctx, "") - noun := "dependency" - if len(plan.Orphaned) != 1 { - noun = "dependencies" + question := "Remove this dependency?" + if len(plan.Orphaned) > 1 { + question = fmt.Sprintf("Remove these %d dependencies?", len(plan.Orphaned)) } - question := fmt.Sprintf("Remove these %d %s?", len(plan.Orphaned), noun) remove, err := a.console.Confirm(ctx, input.ConsoleOptions{ Message: question, DefaultValue: true, @@ -3020,7 +3037,7 @@ func (a *extensionUpgradeAction) upgradeOneExtension( // 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, installedRelease); err != nil { + if err := a.extensionManager.BackfillDependencies(installed.Id, installed.Source, installedRelease); err != nil { return fail(err) } @@ -3428,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.", @@ -3445,17 +3469,28 @@ 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 []error if summary.Failed > 0 { - return nil, fmt.Errorf( + failures = append(failures, fmt.Errorf( "%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.Errorf("%d extension %s failed to update", failed, noun)) + } + if err := errors.Join(failures...); err != nil { + return nil, err } return &actions.ActionResult{ diff --git a/cli/azd/cmd/extension_show_test.go b/cli/azd/cmd/extension_show_test.go index c9f6040308b..59c3076599e 100644 --- a/cli/azd/cmd/extension_show_test.go +++ b/cli/azd/cmd/extension_show_test.go @@ -237,6 +237,35 @@ func TestExtensionShowAction_InstalledFromAnotherSource(t *testing.T) { 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() @@ -328,6 +357,8 @@ func TestExtensionShowItem_Display_Layout(t *testing.T) { 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") @@ -398,7 +429,8 @@ func TestExtensionShowItem_Display_Layout(t *testing.T) { var buf bytes.Buffer require.NoError(t, item.Display(&buf)) - require.Contains(t, buf.String(), "1.0.0 (installed as a dependency)") + require.Contains(t, buf.String(), "Installed as") + require.Contains(t, buf.String(), "Dependency") }) t.Run("installed_from_other_source", func(t *testing.T) { diff --git a/cli/azd/cmd/extension_test.go b/cli/azd/cmd/extension_test.go index 61a96de0511..0c9c0030178 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,22 @@ 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{{ + Status: extensions.UpgradeStatusFailed, + }}, + }}, + }} + result, err := upgradeActionResult(results) + require.Nil(t, result) + require.EqualError(t, err, "1 extension dependency failed to update") + }) + t.Run( "partial_failure_returns_error", func(t *testing.T) { diff --git a/cli/azd/cmd/extension_uninstall_test.go b/cli/azd/cmd/extension_uninstall_test.go index 7883d2a7cd8..c3968b5428d 100644 --- a/cli/azd/cmd/extension_uninstall_test.go +++ b/cli/azd/cmd/extension_uninstall_test.go @@ -150,7 +150,7 @@ func TestExtensionUninstallAction_NoPromptWithoutOrphans(t *testing.T) { _, err := action.Run(t.Context()) require.NoError(t, err) - require.NotContains(t, strings.Join(console.Output(), "\n"), "as well?") + require.NotContains(t, strings.Join(console.Output(), "\n"), "Remove ") } func TestExtensionUninstallAction_RetainedDependenciesAreExplained(t *testing.T) { @@ -169,6 +169,34 @@ func TestExtensionUninstallAction_RetainedDependenciesAreExplained(t *testing.T) 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) { diff --git a/cli/azd/cmd/extension_upgrade_test.go b/cli/azd/cmd/extension_upgrade_test.go index 475b86adb7f..90092d167d4 100644 --- a/cli/azd/cmd/extension_upgrade_test.go +++ b/cli/azd/cmd/extension_upgrade_test.go @@ -1204,6 +1204,94 @@ 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 + 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) + } 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) + } + + 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") + } + } + }) + } +} + // --------------------------------------------------------------------------- // isNetworkError tests // --------------------------------------------------------------------------- diff --git a/cli/azd/cmd/project_extension_auto_install.go b/cli/azd/cmd/project_extension_auto_install.go index 418867e6f6e..288664ad307 100644 --- a/cli/azd/cmd/project_extension_auto_install.go +++ b/cli/azd/cmd/project_extension_auto_install.go @@ -381,23 +381,17 @@ func resolveExtensionDependencies( // installedProvidesProvider reports whether an installed extension already supplies the provider, // in which case nothing needs to be installed for it. -// installedProviderExtensions returns the installed extensions that publish the provider, -// sorted by id. -func installedProviderExtensions( +func installedProvidesProvider( installed map[string]*extensions.Extension, capability extensions.CapabilityType, providerName string, -) []*extensions.Extension { - var providers []*extensions.Extension +) bool { for _, extension := range installed { if extensionProvidesProvider(extension.Capabilities, extension.Providers, capability, providerName) { - providers = append(providers, extension) + return true } } - slices.SortFunc(providers, func(a, b *extensions.Extension) int { - return strings.Compare(a.Id, b.Id) - }) - return providers + return false } // promoteProjectRequiredExtension marks an installed extension the project requires as an @@ -582,14 +576,8 @@ func missingProjectExtensions( if provider == "" || providerIsBuiltIn(capability, provider) { return nil } - // An installed provider satisfies the requirement; the project needs that extension - // in its own right, so a dependency-installed record becomes explicit. - if providers := installedProviderExtensions(installed, capability, provider); len(providers) > 0 { - for _, extension := range providers { - if err := promoteProjectRequiredExtension(extensionManager, extension); err != nil { - return err - } - } + // Reusing an inferred provider does not make it an explicitly requested installation. + if installedProvidesProvider(installed, capability, provider) { return nil } diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index fa91a1b9d32..c302c9a96ff 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -123,7 +123,7 @@ Lists matching extensions from one or more extension sources. #### `azd extension show [flags]` -Shows details for a specific extension: description, tags, versions, installation status, azd compatibility, declared dependencies with their installed state, and the installed extensions that require it. An installed extension that no source lists (for example, a bundle install) is shown from its installed record, and one listed by several sources is shown from the source it was installed from. +Shows details for a specific extension: description, tags, versions, installation status, azd compatibility, declared dependencies with their installed state, and the installed extensions that require it. Dependency installs have an `Installed as: Dependency` row that remains visible alongside update notices. An installed extension that no source lists (for example, a bundle install) is shown from its installed record, and one listed by several sources is shown from the source it was installed from. Legacy dependency details fall back only to metadata matching the installed source and version. - `-s, --source` Uses a registered source name or registry location (URL or file path). Locations are queried read-only and are not registered. @@ -156,6 +156,8 @@ Uninstalls one or more installed extensions. Dependencies that were installed fo Updates one or more extensions to the latest versions. +The command returns a nonzero exit code if any extension or dependency fails, including a failure to save dependency metadata. Successful updates are not rolled back. JSON output preserves each individual result; top-level summary counts exclude dependency results. + - `--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`. @@ -1242,7 +1244,7 @@ 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 removes the pack and, after confirmation, every dependency, including transitive ones, that was installed for it and that nothing else requires. `azd` records on each installed extension whether it was requested by name or pulled in as a dependency, together with the installed version's dependency list, so no registry access is needed. A dependency is kept when it was installed by name (`azd extension install `, `azd init`, or project auto-install on a dependency-installed extension marks it as explicit) or when another installed extension still requires it, and the reason is shown. Uninstalling a dependency while a pack or another extension requires it fails unless `--force` is passed. `azd extension show ` lists an extension's dependencies and the installed extensions that require it. +Uninstalling a pack removes the pack and, after confirmation, every dependency, including transitive ones, that was installed for it and that nothing else requires. `azd` records on each installed extension whether it was requested by name or pulled in as a dependency, together with the installed version's dependency list, so no registry access is needed. Explicit installs and named `requiredVersions.extensions` entries mark an extension as explicit, so it stays when the pack is removed. Reusing an installed extension as an inferred provider does not change its ownership. A dependency also stays when another installed extension requires it, and the reason is shown. Uninstalling a required dependency fails unless `--force` is passed, including when a user declines removal of a dependent in the confirmation prompt. `azd extension show ` lists an extension's dependencies and the installed extensions that require it. #### Provider Registration diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index a929e16e3a3..7d0b88c8334 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. The record also stores the installed version's dependency list and an `installedAsDependency` flag. Installs by name (`azd extension install`, `azd init`, project auto-install) leave the flag unset and clear it on a record a pack pulled in earlier; updates preserve it. +8. **Update configuration** - Record the installed extension and version in `~/.azd/config.json` under the `extension.installed` section. The record also stores the installed version's dependency list and an `installedAsDependency` flag. Explicit installs and named `requiredVersions.extensions` entries clear the flag on an extension a pack pulled in earlier; updates preserve it. Auto-installing a provider independently records an explicit install, but reusing an already-installed provider does not change its ownership. ### Re-installing over an existing extension @@ -201,11 +201,15 @@ For registry-backed installs, a required dependency must resolve from the parent 1. **Check dependents** - Any installed extension whose recorded dependencies include a requested id, and that is not itself being removed, blocks the request. `azd` fails with the list of dependents and a suggestion to uninstall them first. `--force` proceeds and warns which dependents are left without the extension. 2. **Remove the requested extensions** - In the order given. -3. **Remove orphaned dependencies** - A dependency of a removed extension is removed when it was installed as a dependency and no remaining extension requires it. Removed dependencies are walked in turn, so transitive dependencies are covered and a dependency first kept for a sibling is freed once that sibling goes. `azd` lists the dependencies it is about to remove and asks once; the default answer is yes and `--no-prompt` takes it. Declining keeps them, still recorded as dependency installs, with the command to remove them later. Kept dependencies are listed with the reason. `--no-dependencies` skips this step entirely. +3. **Remove orphaned dependencies** - A dependency of a removed extension is removed when it was installed as a dependency and no remaining extension requires it. Removed dependencies are walked in turn, so transitive dependencies are covered and a dependency first kept for a sibling is freed once that sibling goes. `azd` lists the dependencies it is about to remove and asks once; the default answer is yes and `--no-prompt` takes it. Declining keeps them, still recorded as dependency installs, and replans with the same safety rules as `--no-dependencies`: a kept dependency that requires a target blocks its removal unless `--force` is set. Otherwise, azd removes the targets and prints the command to remove the kept dependencies later. Kept dependencies are listed with the reason. `--no-dependencies` skips automatic dependency removal entirely. `azd extension uninstall --all` removes every installed extension. -Records written before dependency tracking carry neither the dependency list nor the flag. They are treated as installs by name with no known dependencies: never removed as orphans and never blocking. `azd extension update` records the dependency list on the extension being updated and on installed dependencies it directly reconciles, even when their versions do not change. Deeper legacy records gain their snapshot when they are directly updated, reconciled, or reinstalled. Ownership is never guessed. +Records written before dependency tracking carry neither the dependency list nor the flag. They are treated as installs by name with no known dependencies: never removed as orphans and never blocking. `azd extension update` records the dependency list on the extension being updated and on installed dependencies it directly reconciles, even when their versions do not change, using only metadata matching the installed source and version. Deeper legacy records gain their snapshot when they are directly updated, reconciled, or reinstalled. Missing metadata is left unknown, and ownership is never guessed. + +### Update failures + +If an extension or dependency fails to update or save its metadata, `azd extension update` returns a nonzero exit code, including with `--output json`. Successful updates remain installed and retain their individual success status. The JSON summary counts top-level extensions; nested dependency failures appear in `dependencyUpgrades`. Installing over an existing extension also reports dependency failures instead of reporting a successful installation. ## Self-Contained Bundles diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index cb12fb50284..32bf61156c4 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -649,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) } @@ -1132,7 +1145,7 @@ func (m *Manager) ReconcileDependencies( return selectedVersion, nil, nil } - if err := m.BackfillDependencies(extension.Id, selectedVersion); err != nil { + if err := m.BackfillDependencies(extension.Id, extension.Source, selectedVersion); err != nil { return nil, nil, err } @@ -1142,10 +1155,9 @@ func (m *Manager) ReconcileDependencies( } // BackfillDependencies records the dependency snapshot on an installed record that predates -// dependency tracking. It only applies when the record is at the supplied version and has no -// snapshot yet, so an update that keeps the extension current still teaches uninstall planning -// about its graph. Ownership is never inferred. -func (m *Manager) BackfillDependencies(id string, version *ExtensionVersion) error { +// 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 } @@ -1157,13 +1169,16 @@ func (m *Manager) BackfillDependencies(id string, version *ExtensionVersion) err 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 } - installed.Dependencies = slices.Clone(version.Dependencies) - if err := m.UpdateInstalled(installed); err != 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 @@ -1181,8 +1196,10 @@ func (m *Manager) MarkExplicitlyInstalled(id string) error { return nil } - installed.InstalledAsDependency = false - if err := m.UpdateInstalled(installed); err != 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 @@ -1266,8 +1283,16 @@ func (m *Manager) evaluateDependencyChanges( // reconciled, updated, or reinstalled. if findErr == nil { installedRelease := FindVersion(childMetadata.Versions, installed.Version) - if err := m.BackfillDependencies(dep.Id, installedRelease); err != nil { - log.Printf("Warning: %v", err) + if err := m.BackfillDependencies(dep.Id, childMetadata.Source, installedRelease); err != nil { + results = append(results, UpgradeResult{ + ExtensionId: dep.Id, + Status: UpgradeStatusFailed, + FromVersion: installed.Version, + FromSource: installed.Source, + FromSourceCategory: installed.SourceCategoryOrUnknown(), + Error: err, + }) + continue } } diff --git a/cli/azd/pkg/extensions/uninstall_test.go b/cli/azd/pkg/extensions/uninstall_test.go index 0704748b6b6..bb43ec2eb33 100644 --- a/cli/azd/pkg/extensions/uninstall_test.go +++ b/cli/azd/pkg/extensions/uninstall_test.go @@ -547,7 +547,7 @@ func Test_BackfillDependencies_ReturnsInstalledConfigError(t *testing.T) { require.NoError(t, manager.userConfig.Set(installedConfigKey, "invalid")) manager.installed = nil - err := manager.BackfillDependencies("test.pack", &ExtensionVersion{ + err := manager.BackfillDependencies("test.pack", MainRegistryName, &ExtensionVersion{ Version: "1.0.0", Dependencies: []ExtensionDependency{{Id: "test.leaf"}}, }) @@ -569,3 +569,133 @@ func Test_MarkExplicitlyInstalled(t *testing.T) { 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) { + 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") + }) + } +} + +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) { From 1cf6be3666f3e6d699c57fde309c6dc99c275391 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 23:17:10 +0000 Subject: [PATCH 10/18] docs(extensions): shorten help and lifecycle guidance Keep command synopses focused on defaults and failure behavior. Shorten flag descriptions and cleanup prompts, consolidate lifecycle rules in the reference, and remove repeated explanations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 65 ++++++------------- cli/azd/cmd/extension_uninstall_test.go | 2 +- cli/azd/cmd/testdata/TestFigSpec.ts | 12 ++-- .../TestUsage-azd-extension-uninstall.snap | 4 +- .../TestUsage-azd-extension-update.snap | 8 +-- .../cmd/testdata/TestUsage-azd-extension.snap | 2 +- .../docs/extensions/extension-framework.md | 22 ++++--- .../extension-resolution-and-versioning.md | 22 ++++--- cli/azd/pkg/extensions/manager.go | 14 ++-- cli/azd/pkg/extensions/uninstall.go | 10 +-- 10 files changed, 66 insertions(+), 95 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index b9a757102b1..f59ca136d23 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.`, }, OutputFormats: []output.Format{output.JsonFormat, output.NoneFormat}, DefaultFormat: output.NoneFormat, @@ -114,13 +112,11 @@ installs aren't tracked for updates; install a newer bundle to update.`, Command: &cobra.Command{ Use: "uninstall [extension-id...]", Short: "Uninstall specified extensions.", - Long: `Uninstall one or more installed extensions. + Long: `Also removes unused dependency installs after confirmation. +Use --no-dependencies to keep them; --no-prompt accepts their removal. -Dependencies that were installed for the removed extensions and are no longer -required are listed and removed after confirmation; --no-prompt proceeds and ---no-dependencies keeps them. Uninstalling an extension that other installed -extensions require fails unless --force is set or the dependents are -uninstalled in the same command. Use --all to remove every installed extension.`, +Required extensions cannot be removed unless their dependents are also removed +or --force is set.`, }, ActionResolver: newExtensionUninstallAction, FlagsResolver: newExtensionUninstallFlags, @@ -131,31 +127,12 @@ uninstalled in the same command. Use --all to remove every installed extension.` Command: &cobra.Command{ 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. - -The command returns an error if any extension or dependency fails. Completed -updates are not rolled back. - -Use --output json for a structured report of all update results.`, + Short: "Update installed extensions.", + 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, @@ -2289,9 +2266,9 @@ 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 when other installed extensions depend on the extension") + "Remove extensions required by other installed extensions") cmd.Flags().BoolVar(&flags.noDependencies, "no-dependencies", false, - "Uninstall only the specified extension(s), keeping dependencies that were installed for them") + "Keep dependencies installed for the removed extensions") return flags } @@ -2468,9 +2445,7 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu }, nil } -// confirmDependencyRemoval lists the dependencies that would go along with the targets and -// asks once. The default is to remove them, and --no-prompt takes the default: they are, by -// construction, needed only by what is being removed. +// confirmDependencyRemoval previews the additional removals before asking once. func (a *extensionUninstallAction) confirmDependencyRemoval( ctx context.Context, plan *extensions.UninstallPlan, @@ -2482,7 +2457,7 @@ func (a *extensionUninstallAction) confirmDependencyRemoval( a.console.Message(ctx, "") a.console.Message(ctx, fmt.Sprintf( - "After uninstalling %s, no installed extension will require these dependencies:", + "Uninstalling %s will leave these dependencies unused:", strings.Join(targets, ", "), )) for _, orphan := range plan.Orphaned { @@ -2545,14 +2520,14 @@ func newExtensionUpgradeFlags(cmd *cobra.Command, global *internal.GlobalCommand flags := &extensionUpgradeFlags{ global: global, } - cmd.Flags().StringVarP(&flags.version, "version", "v", "", "The version of the extension to update to") + cmd.Flags().StringVarP(&flags.version, "version", "v", "", "Exact version to install; defaults to latest.") cmd.Flags().StringVarP(&flags.source, "source", "s", "", - "The registered source name or registry location (URL or file path) to use for updates.") + "Source name or registry URL/file. New sources require interactive mode.") cmd.Flags().BoolVar(&flags.all, "all", false, "Update all installed extensions") cmd.Flags().BoolVar(&flags.noDependencyUpdates, "no-dependency-updates", false, - "Do not update dependencies when updating an extension that has dependencies") + "Keep installed dependency versions") cmd.Flags().BoolVar(&flags.noDependencyUpdates, "no-dependency-upgrades", false, - "Do not update dependencies when updating an extension that has dependencies") + "Keep installed dependency versions") _ = cmd.Flags().MarkHidden("no-dependency-upgrades") return flags diff --git a/cli/azd/cmd/extension_uninstall_test.go b/cli/azd/cmd/extension_uninstall_test.go index c3968b5428d..b8bcbfe5826 100644 --- a/cli/azd/cmd/extension_uninstall_test.go +++ b/cli/azd/cmd/extension_uninstall_test.go @@ -113,7 +113,7 @@ func TestExtensionUninstallAction_PackRemovesOrphanedDependencies(t *testing.T) output := strings.Join(console.Output(), "\n") require.Contains(t, output, - "After uninstalling microsoft.foundry, no installed extension will require these dependencies:") + "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+" ") diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 0489dd76cec..199f8502077 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6327,12 +6327,12 @@ const completionSpec: Fig.Spec = { }, { name: ['--force', '-f'], - description: 'Uninstall even when other installed extensions depend on the extension', + description: 'Remove extensions required by other installed extensions', isDangerous: true, }, { name: ['--no-dependencies'], - description: 'Uninstall only the specified extension(s), keeping dependencies that were installed for them', + description: 'Keep dependencies installed for the removed extensions', }, ], args: { @@ -6344,7 +6344,7 @@ const completionSpec: Fig.Spec = { }, { name: ['update', 'upgrade'], - description: 'Update installed extensions to the latest version.', + description: 'Update installed extensions.', options: [ { name: ['--all'], @@ -6352,11 +6352,11 @@ const completionSpec: Fig.Spec = { }, { name: ['--no-dependency-updates'], - description: 'Do not update dependencies when updating an extension that has dependencies', + description: 'Keep installed dependency versions', }, { name: ['--source', '-s'], - description: 'The registered source name or registry location (URL or file path) to use for updates.', + description: 'Source name or registry URL/file. New sources require interactive mode.', args: [ { name: 'source', @@ -6365,7 +6365,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--version', '-v'], - description: 'The version of the extension to update to', + description: 'Exact version to install; defaults to latest.', args: [ { name: 'version', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap index b34ced84c1d..2a209c2fb5e 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap @@ -6,8 +6,8 @@ Usage Flags --all : Uninstall all installed extensions - -f, --force : Uninstall even when other installed extensions depend on the extension - --no-dependencies : Uninstall only the specified extension(s), keeping dependencies that were installed for them + -f, --force : Remove extensions required by other installed extensions + --no-dependencies : Keep dependencies installed for the removed extensions Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-update.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-update.snap index ab1af89192c..fb8c5964c8d 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-update.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-update.snap @@ -1,14 +1,14 @@ -Update installed extensions to the latest version. +Update installed extensions. Usage azd extension update [extension-id] [flags] Flags --all : Update all installed extensions - --no-dependency-updates : Do not update dependencies when updating an extension that has dependencies - -s, --source string : The registered source name or registry location (URL or file path) to use for updates. - -v, --version string : The version of the extension to update to + --no-dependency-updates : Keep installed dependency versions + -s, --source string : Source name or registry URL/file. New sources require interactive mode. + -v, --version string : Exact version to install; defaults to latest. Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap index 42e82948f3a..196dd943067 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap @@ -10,7 +10,7 @@ Available Commands show : Show details for a specific extension. source : View and manage extension sources uninstall : Uninstall specified extensions. - update : Update installed extensions to the latest version. + update : Update installed 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 c302c9a96ff..962f2a19200 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -123,7 +123,9 @@ Lists matching extensions from one or more extension sources. #### `azd extension show [flags]` -Shows details for a specific extension: description, tags, versions, installation status, azd compatibility, declared dependencies with their installed state, and the installed extensions that require it. Dependency installs have an `Installed as: Dependency` row that remains visible alongside update notices. An installed extension that no source lists (for example, a bundle install) is shown from its installed record, and one listed by several sources is shown from the source it was installed from. Legacy dependency details fall back only to metadata matching the installed source and version. +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. - `-s, --source` Uses a registered source name or registry location (URL or file path). Locations are queried read-only and are not registered. @@ -144,24 +146,24 @@ Installs one or more extensions from any configured extension source. #### `azd extension uninstall [flags]` -Uninstalls one or more installed extensions. Dependencies that were installed for them and are no longer required are listed and removed after a confirmation (`--no-prompt` proceeds). Uninstalling an extension that other installed extensions require fails before anything is removed, unless the dependents are named in the same command. +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` Removes the extension even when other installed extensions depend on it, and warns which ones. -- `--no-dependencies` Keeps the dependencies that were installed for the removed extensions. +- `-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. - -The command returns a nonzero exit code if any extension or dependency fails, including a failure to save dependency metadata. Successful updates are not rolled back. JSON output preserves each individual result; top-level summary counts exclude dependency results. +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 @@ -1244,7 +1246,7 @@ 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 removes the pack and, after confirmation, every dependency, including transitive ones, that was installed for it and that nothing else requires. `azd` records on each installed extension whether it was requested by name or pulled in as a dependency, together with the installed version's dependency list, so no registry access is needed. Explicit installs and named `requiredVersions.extensions` entries mark an extension as explicit, so it stays when the pack is removed. Reusing an installed extension as an inferred provider does not change its ownership. A dependency also stays when another installed extension requires it, and the reason is shown. Uninstalling a required dependency fails unless `--force` is passed, including when a user declines removal of a dependent in the confirmation prompt. `azd extension show ` lists an extension's dependencies and the installed extensions that require it. +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 diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index 7d0b88c8334..24027a18e5e 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. The record also stores the installed version's dependency list and an `installedAsDependency` flag. Explicit installs and named `requiredVersions.extensions` entries clear the flag on an extension a pack pulled in earlier; updates preserve it. Auto-installing a provider independently records an explicit install, but reusing an already-installed provider does not change its ownership. +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,21 +195,25 @@ 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 +## Uninstall flow -`azd extension uninstall ` plans the whole removal from the installed records before removing anything, without querying a registry. +`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. -1. **Check dependents** - Any installed extension whose recorded dependencies include a requested id, and that is not itself being removed, blocks the request. `azd` fails with the list of dependents and a suggestion to uninstall them first. `--force` proceeds and warns which dependents are left without the extension. -2. **Remove the requested extensions** - In the order given. -3. **Remove orphaned dependencies** - A dependency of a removed extension is removed when it was installed as a dependency and no remaining extension requires it. Removed dependencies are walked in turn, so transitive dependencies are covered and a dependency first kept for a sibling is freed once that sibling goes. `azd` lists the dependencies it is about to remove and asks once; the default answer is yes and `--no-prompt` takes it. Declining keeps them, still recorded as dependency installs, and replans with the same safety rules as `--no-dependencies`: a kept dependency that requires a target blocks its removal unless `--force` is set. Otherwise, azd removes the targets and prints the command to remove the kept dependencies later. Kept dependencies are listed with the reason. `--no-dependencies` skips automatic dependency removal entirely. +Targets are removed in request order, followed by their unused dependency installs, including transitive dependencies. Explicit and shared installations stay. + +Before removing dependencies, azd lists them and asks once. `--no-prompt` accepts removal; `--no-dependencies` or declining keeps them. Keeping dependencies must still pass the required-extension checks. Kept dependencies are listed with reasons and, after a declined cleanup, a command to remove them later. `azd extension uninstall --all` removes every installed extension. -Records written before dependency tracking carry neither the dependency list nor the flag. They are treated as installs by name with no known dependencies: never removed as orphans and never blocking. `azd extension update` records the dependency list on the extension being updated and on installed dependencies it directly reconciles, even when their versions do not change, using only metadata matching the installed source and version. Deeper legacy records gain their snapshot when they are directly updated, reconciled, or reinstalled. Missing metadata is left unknown, and ownership is never guessed. +### 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 failures +## Update results -If an extension or dependency fails to update or save its metadata, `azd extension update` returns a nonzero exit code, including with `--output json`. Successful updates remain installed and retain their individual success status. The JSON summary counts top-level extensions; nested dependency failures appear in `dependencyUpgrades`. Installing over an existing extension also reports dependency failures instead of reporting a successful installation. +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 diff --git a/cli/azd/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index 32bf61156c4..b9a7bb44c46 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -1095,10 +1095,8 @@ type UpgradeOptions struct { // SkipMainRegistryDependencyFallback mirrors the InstallOptions behavior for // the reinstall performed during upgrade. SkipMainRegistryDependencyFallback bool - // PromoteToExplicit records the extension as explicitly installed even when the - // existing record was only a dependency install. `azd extension install ` sets - // it because the user named the extension; updates leave it unset so ownership is - // preserved. + // PromoteToExplicit clears dependency ownership for an explicit reinstall. + // Updates leave it false to preserve ownership. PromoteToExplicit bool } @@ -1184,9 +1182,8 @@ func (m *Manager) BackfillDependencies(id, source string, version *ExtensionVers return nil } -// MarkExplicitlyInstalled records that the user asked for an extension directly, so it is no -// longer removed along with the extensions that originally pulled it in. It is a no-op for -// extensions that are already explicit. +// 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 { @@ -1213,9 +1210,6 @@ func (m *Manager) upgradeInternal( opts UpgradeOptions, visited map[string]struct{}, ) (*ExtensionVersion, []UpgradeResult, error) { - // An update must not change who asked for the extension: a dependency-installed - // extension stays removable with its parents, and an explicit one stays explicit. - // Only an install by name (PromoteToExplicit) turns a dependency into an explicit record. asDependency := false if installed, err := m.GetInstalled(FilterOptions{Id: extension.Id}); err == nil && installed != nil { asDependency = installed.InstalledAsDependency && !opts.PromoteToExplicit diff --git a/cli/azd/pkg/extensions/uninstall.go b/cli/azd/pkg/extensions/uninstall.go index 20691c3bca0..61e498ad434 100644 --- a/cli/azd/pkg/extensions/uninstall.go +++ b/cli/azd/pkg/extensions/uninstall.go @@ -156,13 +156,9 @@ func (m *Manager) PlanUninstall(ids []string, opts UninstallPlanOptions) (*Unins return dependents } - // Walk the dependencies of everything being removed. A dependency joins the removal set - // when it was installed as a dependency and nothing outside the removal set requires it. - // Removed extensions are appended to the queue so their own dependencies are visited, - // which also re-examines a dependency that was first kept because of a sibling that is - // removed later (A -> B, A -> C, C -> B). This runs before the dependents check so that a - // dependency-installed extension in a cycle with a target (A -> B -> A) leaves with it - // instead of blocking it. + // 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) From 92afadaf09b15fe28357fc58ff2b5a29d334f5aa Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 23:22:59 +0000 Subject: [PATCH 11/18] docs(extensions): preserve existing snapshot-visible help Restore pre-existing update command and flag descriptions while keeping the concise long descriptions and new uninstall help. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 10 +++++----- cli/azd/cmd/testdata/TestFigSpec.ts | 8 ++++---- .../cmd/testdata/TestUsage-azd-extension-update.snap | 8 ++++---- cli/azd/cmd/testdata/TestUsage-azd-extension.snap | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index f59ca136d23..ff88aff50b1 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -127,7 +127,7 @@ or --force is set.`, Command: &cobra.Command{ Use: "update [extension-id]", Aliases: []string{"upgrade"}, - Short: "Update installed extensions.", + Short: "Update installed extensions to the latest version.", Long: `Also updates installed dependencies to compatible versions unless --no-dependency-updates is set. @@ -2520,14 +2520,14 @@ func newExtensionUpgradeFlags(cmd *cobra.Command, global *internal.GlobalCommand flags := &extensionUpgradeFlags{ global: global, } - cmd.Flags().StringVarP(&flags.version, "version", "v", "", "Exact version to install; defaults to latest.") + cmd.Flags().StringVarP(&flags.version, "version", "v", "", "The version of the extension to update to") cmd.Flags().StringVarP(&flags.source, "source", "s", "", - "Source name or registry URL/file. New sources require interactive mode.") + "The registered source name or registry location (URL or file path) to use for updates.") cmd.Flags().BoolVar(&flags.all, "all", false, "Update all installed extensions") cmd.Flags().BoolVar(&flags.noDependencyUpdates, "no-dependency-updates", false, - "Keep installed dependency versions") + "Do not update dependencies when updating an extension that has dependencies") cmd.Flags().BoolVar(&flags.noDependencyUpdates, "no-dependency-upgrades", false, - "Keep installed dependency versions") + "Do not update dependencies when updating an extension that has dependencies") _ = cmd.Flags().MarkHidden("no-dependency-upgrades") return flags diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 199f8502077..95753f229c6 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6344,7 +6344,7 @@ const completionSpec: Fig.Spec = { }, { name: ['update', 'upgrade'], - description: 'Update installed extensions.', + description: 'Update installed extensions to the latest version.', options: [ { name: ['--all'], @@ -6352,11 +6352,11 @@ const completionSpec: Fig.Spec = { }, { name: ['--no-dependency-updates'], - description: 'Keep installed dependency versions', + description: 'Do not update dependencies when updating an extension that has dependencies', }, { name: ['--source', '-s'], - description: 'Source name or registry URL/file. New sources require interactive mode.', + description: 'The registered source name or registry location (URL or file path) to use for updates.', args: [ { name: 'source', @@ -6365,7 +6365,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--version', '-v'], - description: 'Exact version to install; defaults to latest.', + description: 'The version of the extension to update to', args: [ { name: 'version', diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-update.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-update.snap index fb8c5964c8d..ab1af89192c 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-update.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-update.snap @@ -1,14 +1,14 @@ -Update installed extensions. +Update installed extensions to the latest version. Usage azd extension update [extension-id] [flags] Flags --all : Update all installed extensions - --no-dependency-updates : Keep installed dependency versions - -s, --source string : Source name or registry URL/file. New sources require interactive mode. - -v, --version string : Exact version to install; defaults to latest. + --no-dependency-updates : Do not update dependencies when updating an extension that has dependencies + -s, --source string : The registered source name or registry location (URL or file path) to use for updates. + -v, --version string : The version of the extension to update to Global Flags -C, --cwd string : Sets the current working directory. diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap index 196dd943067..42e82948f3a 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension.snap @@ -10,7 +10,7 @@ Available Commands show : Show details for a specific extension. source : View and manage extension sources uninstall : Uninstall specified extensions. - update : Update installed extensions. + update : Update installed extensions to the latest version. Global Flags -C, --cwd string : Sets the current working directory. From 4440c9dfb78d462b4d09fc4c31d5850caec5409c Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Fri, 4 Sep 2026 23:51:52 +0000 Subject: [PATCH 12/18] fix(extensions): preserve dependency install error causes Wrap failed dependency results, including nested failures, instead of returning an opaque summary error. Cover error unwrapping and telemetry classification without changing the bare-error guard. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 18 +++++++++-- cli/azd/cmd/extension_upgrade_test.go | 44 +++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index ff88aff50b1..5e18ba69a35 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -1378,9 +1378,9 @@ func (a *extensionInstallAction) Run(ctx context.Context) (*actions.ActionResult stepMessage += output.WithGrayFormat(" (%s)", extensionVersion.Version) a.console.StopSpinner(ctx, stepMessage, input.StepDone) - if extensions.NewUpgradeSummary(dependencyResults).HasFailures() { + if dependencyErr := dependencyUpgradeError(dependencyResults); dependencyErr != nil { displayDependencyUpgradeResults(ctx, a.console, dependencyResults, " ") - return nil, fmt.Errorf("failed to update dependencies for extension %s", extensionId) + return nil, fmt.Errorf("failed to update dependencies for extension %s: %w", extensionId, dependencyErr) } } else { @@ -3294,6 +3294,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, diff --git a/cli/azd/cmd/extension_upgrade_test.go b/cli/azd/cmd/extension_upgrade_test.go index 90092d167d4..ed1908ac941 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" @@ -1242,6 +1244,18 @@ func TestExtensionCommands_ReportDependencyFailuresAfterParentUpdate(t *testing. result, err := action.Run(t.Context()) require.ErrorContains(t, err, "failed to update dependencies for extension test.pack") require.Nil(t, result) + dependencyErr, ok := errors.AsType[*extensions.DependencyVersionNotFoundError](err) + 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(err, 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) } else { var formatter output.Formatter = &output.NoneFormatter{} if command == "update-json" { @@ -1292,6 +1306,36 @@ func TestExtensionCommands_ReportDependencyFailuresAfterParentUpdate(t *testing. } } +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 // --------------------------------------------------------------------------- From c8133f4655b4281f4bc9afe7fe5f7a932525dc3b Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 8 Sep 2026 16:58:25 +0000 Subject: [PATCH 13/18] fix(extensions): clarify forced uninstall output Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 5 ++++- cli/azd/cmd/extension_uninstall_test.go | 5 ++++- cli/azd/cmd/testdata/TestFigSpec.ts | 2 +- cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap | 2 +- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 5e18ba69a35..6ed0185bdc5 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -2266,7 +2266,7 @@ 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, - "Remove extensions required by other installed extensions") + "Uninstall even if other installed extensions depend on it") cmd.Flags().BoolVar(&flags.noDependencies, "no-dependencies", false, "Keep dependencies installed for the removed extensions") @@ -2390,6 +2390,9 @@ func (a *extensionUninstallAction) Run(ctx context.Context) (*actions.ActionResu ), }) } + if len(plan.Blocked) > 0 { + a.console.Message(ctx, "") + } for _, target := range plan.Targets { stepMessage := extensionTaskMessageWithVersion("Uninstalling", target.Id, target.Version) diff --git a/cli/azd/cmd/extension_uninstall_test.go b/cli/azd/cmd/extension_uninstall_test.go index b8bcbfe5826..465bee81f6a 100644 --- a/cli/azd/cmd/extension_uninstall_test.go +++ b/cli/azd/cmd/extension_uninstall_test.go @@ -99,8 +99,11 @@ func TestExtensionUninstallAction_ForceWarnsAboutDependents(t *testing.T) { []string{"azure.ai.agents", "azure.ai.inspector", "azure.ai.skills", "microsoft.foundry"}, remainingInstalledIds(t, action.extensionManager), ) - require.Contains(t, strings.Join(console.Output(), "\n"), + 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) { diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 95753f229c6..8341ac47f4e 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -6327,7 +6327,7 @@ const completionSpec: Fig.Spec = { }, { name: ['--force', '-f'], - description: 'Remove extensions required by other installed extensions', + description: 'Uninstall even if other installed extensions depend on it', isDangerous: true, }, { diff --git a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap index 2a209c2fb5e..cc4dd529bc2 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-extension-uninstall.snap @@ -6,7 +6,7 @@ Usage Flags --all : Uninstall all installed extensions - -f, --force : Remove extensions required by other installed extensions + -f, --force : Uninstall even if other installed extensions depend on it --no-dependencies : Keep dependencies installed for the removed extensions Global Flags From 75f03a81d96c890231833e66c1d5f23de884cf08 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 8 Sep 2026 17:19:34 +0000 Subject: [PATCH 14/18] fix(extensions): preserve dependency update error causes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 9 +++++++++ cli/azd/cmd/extension_test.go | 8 ++++++-- cli/azd/cmd/extension_upgrade_test.go | 28 +++++++++++++++------------ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 6ed0185bdc5..fb57f70c5ee 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -3482,6 +3482,15 @@ func upgradeActionResult( failures = append(failures, fmt.Errorf("%d extension %s failed to update", failed, noun)) } if err := errors.Join(failures...); err != nil { + 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("%v: %w", err, dependencyErr) + } return nil, err } diff --git a/cli/azd/cmd/extension_test.go b/cli/azd/cmd/extension_test.go index 0c9c0030178..581262f18e7 100644 --- a/cli/azd/cmd/extension_test.go +++ b/cli/azd/cmd/extension_test.go @@ -465,13 +465,17 @@ func TestUpgradeActionResult(t *testing.T) { DependencyUpgrades: []extensions.UpgradeResult{{ Status: extensions.UpgradeStatusUpgraded, DependencyUpgrades: []extensions.UpgradeResult{{ - Status: extensions.UpgradeStatusFailed, + ExtensionId: "test.leaf", + Status: extensions.UpgradeStatusFailed, + Error: fmt.Errorf("updating dependency: %w", context.Canceled), }}, }}, }} result, err := upgradeActionResult(results) require.Nil(t, result) - require.EqualError(t, err, "1 extension dependency failed to update") + 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( diff --git a/cli/azd/cmd/extension_upgrade_test.go b/cli/azd/cmd/extension_upgrade_test.go index ed1908ac941..b4a5f1cce15 100644 --- a/cli/azd/cmd/extension_upgrade_test.go +++ b/cli/azd/cmd/extension_upgrade_test.go @@ -1233,6 +1233,7 @@ func TestExtensionCommands_ReportDependencyFailuresAfterParentUpdate(t *testing. ) console := mockinput.NewMockConsole() var buf bytes.Buffer + var commandErr error if command == "install" { action := &extensionInstallAction{ args: []string{"test.pack"}, @@ -1244,18 +1245,7 @@ func TestExtensionCommands_ReportDependencyFailuresAfterParentUpdate(t *testing. result, err := action.Run(t.Context()) require.ErrorContains(t, err, "failed to update dependencies for extension test.pack") require.Nil(t, result) - dependencyErr, ok := errors.AsType[*extensions.DependencyVersionNotFoundError](err) - 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(err, 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) + commandErr = err } else { var formatter output.Formatter = &output.NoneFormatter{} if command == "update-json" { @@ -1272,8 +1262,22 @@ func TestExtensionCommands_ReportDependencyFailuresAfterParentUpdate(t *testing. 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") From 92e945e7c59905c8aa168f7894aa302d0b8ae6b0 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 8 Sep 2026 17:25:03 +0000 Subject: [PATCH 15/18] fix(extensions): separate update summaries from error causes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index fb57f70c5ee..811f0fd344f 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -3467,9 +3467,9 @@ func upgradeActionResult( ) (*actions.ActionResult, error) { summary := extensions.NewUpgradeSummary(results) - var failures []error + var failures []string if summary.Failed > 0 { - failures = append(failures, fmt.Errorf( + failures = append(failures, fmt.Sprintf( "%d of %d extensions failed to update", summary.Failed, summary.Total, )) @@ -3479,9 +3479,10 @@ func upgradeActionResult( if failed == 1 { noun = "dependency" } - failures = append(failures, fmt.Errorf("%d extension %s failed to update", failed, noun)) + failures = append(failures, fmt.Sprintf("%d extension %s failed to update", failed, noun)) } - if err := errors.Join(failures...); err != nil { + if len(failures) > 0 { + message := strings.Join(failures, "\n") var dependencyErrors []error for _, result := range results { if dependencyErr := dependencyUpgradeError(result.DependencyUpgrades); dependencyErr != nil { @@ -3489,9 +3490,9 @@ func upgradeActionResult( } } if dependencyErr := errors.Join(dependencyErrors...); dependencyErr != nil { - return nil, fmt.Errorf("%v: %w", err, dependencyErr) + return nil, fmt.Errorf("%s: %w", message, dependencyErr) } - return nil, err + return nil, errors.New(message) } return &actions.ActionResult{ From c8aff6c7d87acb37f19bc7ff9e835a0d84fa1262 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 8 Sep 2026 17:36:03 +0000 Subject: [PATCH 16/18] docs(extensions): clarify uninstall cleanup boundaries Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../docs/extensions/extension-resolution-and-versioning.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/azd/docs/extensions/extension-resolution-and-versioning.md b/cli/azd/docs/extensions/extension-resolution-and-versioning.md index 24027a18e5e..5b876ac47a5 100644 --- a/cli/azd/docs/extensions/extension-resolution-and-versioning.md +++ b/cli/azd/docs/extensions/extension-resolution-and-versioning.md @@ -201,7 +201,11 @@ For registry-backed installs, a required dependency must resolve from the parent Targets are removed in request order, followed by their unused dependency installs, including transitive dependencies. Explicit and shared installations stay. -Before removing dependencies, azd lists them and asks once. `--no-prompt` accepts removal; `--no-dependencies` or declining keeps them. Keeping dependencies must still pass the required-extension checks. Kept dependencies are listed with reasons and, after a declined cleanup, a command to remove them later. +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. From 02dbbbf5256d3a306dcb7c17c22ecc896c2b8cde Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Tue, 8 Sep 2026 17:51:02 +0000 Subject: [PATCH 17/18] fix(extensions): show installed metadata when registry lookup fails Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/cmd/extension.go | 26 ++++-- cli/azd/cmd/extension_show_test.go | 88 +++++++++++++++++++ .../docs/extensions/extension-framework.md | 2 + 3 files changed, 107 insertions(+), 9 deletions(-) diff --git a/cli/azd/cmd/extension.go b/cli/azd/cmd/extension.go index 811f0fd344f..fed6bfaa9e3 100644 --- a/cli/azd/cmd/extension.go +++ b/cli/azd/cmd/extension.go @@ -78,7 +78,7 @@ from an unregistered location show the location itself in the SOURCE column.`, Use: "show ", Short: "Show details for a specific extension.", Long: `Includes version compatibility, dependencies, and installed dependents. -Uses installed metadata when no registry lists the extension.`, +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, @@ -854,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, @@ -873,18 +878,21 @@ 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 } - - installedExtension, err := a.extensionManager.GetInstalled(extensions.FilterOptions{Id: extensionId}) if err != nil { - installedExtension = nil + 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 } - // An installed extension that no configured source lists (bundle install, delisted, or - // source removed) is described from its installed record alone, unless --source asked - // for a specific source that does not carry it. + // 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)) diff --git a/cli/azd/cmd/extension_show_test.go b/cli/azd/cmd/extension_show_test.go index 59c3076599e..5645900b8b5 100644 --- a/cli/azd/cmd/extension_show_test.go +++ b/cli/azd/cmd/extension_show_test.go @@ -5,12 +5,14 @@ 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" @@ -190,6 +192,92 @@ func TestExtensionShowAction_InstalledWithoutRegistryEntry(t *testing.T) { 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() diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index 962f2a19200..c985ce9c6bd 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -127,6 +127,8 @@ Shows versions, compatibility, dependencies, and installed dependents. Dependenc 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. #### `azd extension install [flags]` From f295ae2b070127450fd013ede8dd6a2b0bab417d Mon Sep 17 00:00:00 2001 From: Jeffrey Chen Date: Wed, 9 Sep 2026 18:42:37 +0000 Subject: [PATCH 18/18] fix: record dependency update telemetry failures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 23d3b019-1349-42b5-8227-1c4f4b387c99 --- cli/azd/docs/tracing-in-azd.md | 7 +- cli/azd/pkg/extensions/manager.go | 20 ++++-- cli/azd/pkg/extensions/manager_test.go | 87 ++++++++++++++++++++++-- cli/azd/pkg/extensions/uninstall_test.go | 3 + docs/architecture/telemetry.md | 2 +- docs/reference/telemetry-data.md | 2 + 6 files changed, 108 insertions(+), 13 deletions(-) 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/pkg/extensions/manager.go b/cli/azd/pkg/extensions/manager.go index b9a7bb44c46..241ee603737 100644 --- a/cli/azd/pkg/extensions/manager.go +++ b/cli/azd/pkg/extensions/manager.go @@ -1255,6 +1255,18 @@ func (m *Manager) evaluateDependencyChanges( ) []UpgradeResult { var results []UpgradeResult + // 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 { @@ -1278,7 +1290,7 @@ func (m *Manager) evaluateDependencyChanges( if findErr == nil { installedRelease := FindVersion(childMetadata.Versions, installed.Version) if err := m.BackfillDependencies(dep.Id, childMetadata.Source, installedRelease); err != nil { - results = append(results, UpgradeResult{ + recordFailure(UpgradeResult{ ExtensionId: dep.Id, Status: UpgradeStatusFailed, FromVersion: installed.Version, @@ -1305,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, @@ -1331,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, @@ -1370,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, 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_test.go b/cli/azd/pkg/extensions/uninstall_test.go index bb43ec2eb33..25f9f7f508c 100644 --- a/cli/azd/pkg/extensions/uninstall_test.go +++ b/cli/azd/pkg/extensions/uninstall_test.go @@ -13,6 +13,7 @@ import ( "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. @@ -650,6 +651,7 @@ func Test_InstalledMetadata_SaveFailurePreservesState(t *testing.T) { 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"}} @@ -676,6 +678,7 @@ func Test_ReconcileDependencies_ReportsChildBackfillSaveFailure(t *testing.T) { 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) }) } } 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 32440e4e46b..cccaecd2512 100644 --- a/docs/reference/telemetry-data.md +++ b/docs/reference/telemetry-data.md @@ -77,6 +77,8 @@ Commands follow the pattern `cmd.` where spaces become dots. | `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 |