diff --git a/cli/azd/cmd/middleware/extensions.go b/cli/azd/cmd/middleware/extensions.go index 52faeb551d3..c8671e65a65 100644 --- a/cli/azd/cmd/middleware/extensions.go +++ b/cli/azd/cmd/middleware/extensions.go @@ -5,6 +5,7 @@ package middleware import ( "context" + "errors" "fmt" "log" "os" @@ -16,10 +17,12 @@ import ( "github.com/azure/azure-dev/cli/azd/cmd/actions" "github.com/azure/azure-dev/cli/azd/internal/grpcserver" "github.com/azure/azure-dev/cli/azd/internal/tracing" + "github.com/azure/azure-dev/cli/azd/pkg/exec" "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/ioc" "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/fatih/color" ) @@ -31,6 +34,13 @@ var ( } ) +// extensionStartFailure captures a failed extension along with the nature of the failure. +type extensionStartFailure struct { + extension *extensions.Extension + // timedOut is true when the failure was caused by the ready-wait context deadline being exceeded. + timedOut bool +} + type ExtensionsMiddleware struct { extensionManager *extensions.Manager extensionRunner *extensions.Runner @@ -103,7 +113,7 @@ func (m *ExtensionsMiddleware) Run(ctx context.Context, next NextFn) (*actions.A forceColor := !color.NoColor var wg sync.WaitGroup var mu sync.Mutex - var failedExtensions []*extensions.Extension + var failures []extensionStartFailure // Track total time for all extensions to become ready allExtensionsStartTime := time.Now() @@ -162,7 +172,20 @@ func (m *ExtensionsMiddleware) Run(ctx context.Context, next NextFn) (*actions.A } if _, err := m.extensionRunner.Invoke(ctx, ext, options); err != nil { - m.console.Message(ctx, err.Error()) + // Log full error (including stdout/stderr) for --debug. + log.Printf("extension '%s' invocation failed: %v", ext.Id, err) + + // Show a concise reason to the user (exit code without raw stdout/stderr). + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + m.console.Message(ctx, fmt.Sprintf( + "Extension '%s' failed to start (exit code: %d). Run with --debug for details.", + ext.Id, exitErr.ExitCode)) + } else { + m.console.Message(ctx, fmt.Sprintf( + "Extension '%s' failed to start: %v", + ext.Id, err)) + } ext.Fail(err) } }() @@ -180,7 +203,10 @@ func (m *ExtensionsMiddleware) Run(ctx context.Context, next NextFn) (*actions.A // Track failed extensions for warning display mu.Lock() - failedExtensions = append(failedExtensions, ext) + failures = append(failures, extensionStartFailure{ + extension: ext, + timedOut: errors.Is(err, context.DeadlineExceeded), + }) mu.Unlock() } else { elapsed := time.Since(startTime) @@ -194,17 +220,35 @@ func (m *ExtensionsMiddleware) Run(ctx context.Context, next NextFn) (*actions.A // Check for failed extensions and display warnings - if len(failedExtensions) > 0 { + if len(failures) > 0 { + // Collect just the Extension pointers for the update check. + failedExts := make([]*extensions.Extension, len(failures)) + for i, f := range failures { + failedExts[i] = f.extension + } + + // Check for available updates using only cached data (no network requests in the failure path). + updateWarnings := m.checkUpdatesForExtensions(ctx, failedExts) + for _, w := range updateWarnings { + m.console.MessageUxItem(ctx, w) + } + m.console.Message(ctx, output.WithWarningFormat("WARNING: Extension startup failures detected")) - m.console.Message(ctx, "The following extensions failed to initialize within the timeout period:") - for _, ext := range failedExtensions { - m.console.Message(ctx, fmt.Sprintf(" - %s (%s)", ext.DisplayName, ext.Id)) + m.console.Message(ctx, "The following extensions failed to initialize:") + for _, f := range failures { + m.console.Message(ctx, fmt.Sprintf(" - %s (%s)", f.extension.DisplayName, f.extension.Id)) } m.console.Message(ctx, "") - m.console.Message( - ctx, - "Some features may be unavailable. Increase timeout with AZD_EXT_TIMEOUT= if needed.", - ) + + hasTimeouts := slices.ContainsFunc(failures, func(f extensionStartFailure) bool { return f.timedOut }) + if hasTimeouts { + m.console.Message( + ctx, + "Some features may be unavailable. Increase timeout with AZD_EXT_TIMEOUT= if needed.", + ) + } else { + m.console.Message(ctx, "Some features may be unavailable.") + } m.console.Message(ctx, "") } @@ -215,6 +259,41 @@ func (m *ExtensionsMiddleware) Run(ctx context.Context, next NextFn) (*actions.A return next(ctx) } +// checkUpdatesForExtensions checks if newer versions are available for the given extensions +// using only already-cached registry data. No network requests are made; if the cache for a +// source is expired or missing the extension is simply skipped. +func (m *ExtensionsMiddleware) checkUpdatesForExtensions( + ctx context.Context, + failedExts []*extensions.Extension, +) []*ux.WarningMessage { + cacheManager, err := extensions.NewRegistryCacheManager() + if err != nil { + log.Printf("failed to create cache manager for update check: %v", err) + return nil + } + + checker := extensions.NewUpdateChecker(cacheManager) + + var warnings []*ux.WarningMessage + for _, ext := range failedExts { + // Only consult already-cached data to avoid blocking network I/O in the failure path. + if ext.Source == "" || cacheManager.IsExpiredOrMissing(ctx, ext.Source) { + continue + } + + result, err := checker.CheckForUpdate(ctx, ext) + if err != nil { + log.Printf("failed to check for update for %s: %v", ext.Id, err) + continue + } + if result.HasUpdate { + warnings = append(warnings, extensions.FormatUpdateWarning(result)) + } + } + + return warnings +} + // isDebug checks if AZD_EXT_DEBUG environment variable is set to a truthy value func isDebug() bool { debugValue := os.Getenv("AZD_EXT_DEBUG") diff --git a/cli/azd/pkg/extensions/registry_cache.go b/cli/azd/pkg/extensions/registry_cache.go index 87b0c70342a..3913751ee95 100644 --- a/cli/azd/pkg/extensions/registry_cache.go +++ b/cli/azd/pkg/extensions/registry_cache.go @@ -16,6 +16,7 @@ import ( "strings" "time" + "github.com/Masterminds/semver/v3" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/osutil" ) @@ -181,8 +182,47 @@ func (m *RegistryCacheManager) GetExtensionLatestVersion( if len(ext.Versions) == 0 { return "", fmt.Errorf("extension %s has no versions", extensionId) } - // Latest version is the last element in the Versions slice - return ext.Versions[len(ext.Versions)-1].Version, nil + + // Compare first and last elements to find the highest version. + // If the highest is a prerelease (e.g. "preview"), fall back to the second version. + first := ext.Versions[0].Version + last := ext.Versions[len(ext.Versions)-1].Version + + firstSv, errFirst := semver.NewVersion(first) + lastSv, errLast := semver.NewVersion(last) + if errFirst != nil || errLast != nil { + return "", fmt.Errorf("extension %s has no valid semver versions", extensionId) + } + + latest := first + latestSv := firstSv + if lastSv.GreaterThan(firstSv) { + latest = last + latestSv = lastSv + } + + // If the highest version is a prerelease, walk through subsequent versions + // until we find a stable (non-prerelease) version. + // If all versions are prereleases, return the highest prerelease. + if latestSv.Prerelease() != "" && len(ext.Versions) > 1 { + start := 1 + end := len(ext.Versions) + step := 1 + if lastSv.GreaterThan(firstSv) { + // ascending order: walk backwards from second-to-last + start = len(ext.Versions) - 2 + end = -1 + step = -1 + } + for i := start; i != end; i += step { + sv, err := semver.NewVersion(ext.Versions[i].Version) + if err == nil && sv.Prerelease() == "" { + return ext.Versions[i].Version, nil + } + } + } + + return latest, nil } } diff --git a/cli/azd/pkg/extensions/update_checker.go b/cli/azd/pkg/extensions/update_checker.go index a1da3316c68..a10b23052b3 100644 --- a/cli/azd/pkg/extensions/update_checker.go +++ b/cli/azd/pkg/extensions/update_checker.go @@ -119,17 +119,16 @@ func FormatUpdateWarning(result *UpdateCheckResult) *ux.WarningMessage { return &ux.WarningMessage{ Description: fmt.Sprintf( - "A new version of extension '%s' is available: %s -> %s", - name, - result.InstalledVersion, - result.LatestVersion, + "The following extensions are outdated:\n - %s (installed: %s, latest: %s)", + name, result.InstalledVersion, result.LatestVersion, ), HidePrefix: false, Hints: []string{ - fmt.Sprintf("To upgrade: %s", - output.WithHighLightFormat("azd extension upgrade %s", result.ExtensionId)), - fmt.Sprintf("To upgrade all: %s", + fmt.Sprintf("Fix by running:\n\t%s\n\t%s", + output.WithHighLightFormat("azd extension upgrade %s", result.ExtensionId), output.WithHighLightFormat("azd extension upgrade --all")), + fmt.Sprintf("If you don't use these extensions, you can uninstall them:\n\t%s", + output.WithHighLightFormat("azd extension uninstall %s", result.ExtensionId)), }, } } diff --git a/cli/azd/pkg/extensions/update_checker_test.go b/cli/azd/pkg/extensions/update_checker_test.go index f8335feda8b..59e5f00acf2 100644 --- a/cli/azd/pkg/extensions/update_checker_test.go +++ b/cli/azd/pkg/extensions/update_checker_test.go @@ -154,8 +154,9 @@ func Test_FormatUpdateWarning(t *testing.T) { require.Contains(t, warning.Description, "2.0.0") require.False(t, warning.HidePrefix) require.Len(t, warning.Hints, 2) + require.Contains(t, warning.Hints[0], "azd extension upgrade --all") require.Contains(t, warning.Hints[0], "azd extension upgrade test.extension") - require.Contains(t, warning.Hints[1], "azd extension upgrade --all") + require.Contains(t, warning.Hints[1], "azd extension uninstall test.extension") } func Test_FormatUpdateWarning_NoDisplayName(t *testing.T) { @@ -199,7 +200,8 @@ func Test_UpdateChecker_PrereleaseVersions(t *testing.T) { updateChecker := NewUpdateChecker(cacheManager) - // Installed stable version should see prerelease as update + // When the only newer version is a prerelease, the original code falls back to the latest + // stable version (1.0.0). Since the installed version is already 1.0.0, no update is available. extension := &Extension{ Id: "test.extension", DisplayName: "Test Extension", @@ -209,8 +211,7 @@ func Test_UpdateChecker_PrereleaseVersions(t *testing.T) { result, err := updateChecker.CheckForUpdate(ctx, extension) require.NoError(t, err) - // semver: 2.0.0-beta.1 is considered less than 2.0.0 but greater than 1.0.0 - require.True(t, result.HasUpdate) + require.False(t, result.HasUpdate) } func Test_UpdateChecker_InvalidVersions(t *testing.T) { diff --git a/cli/azd/pkg/extensions/update_integration_test.go b/cli/azd/pkg/extensions/update_integration_test.go index 29824f37721..ac578fb2540 100644 --- a/cli/azd/pkg/extensions/update_integration_test.go +++ b/cli/azd/pkg/extensions/update_integration_test.go @@ -167,8 +167,9 @@ func Test_Integration_UpdateCheck_FullFlow(t *testing.T) { require.Contains(t, warning.Description, "2.0.0") require.False(t, warning.HidePrefix) require.Len(t, warning.Hints, 2) + require.Contains(t, warning.Hints[0], "azd extension upgrade --all") require.Contains(t, warning.Hints[0], "azd extension upgrade test.extension") - require.Contains(t, warning.Hints[1], "azd extension upgrade --all") + require.Contains(t, warning.Hints[1], "azd extension uninstall test.extension") }) }