Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 90 additions & 11 deletions cli/azd/cmd/middleware/extensions.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package middleware

import (
"context"
"errors"
"fmt"
"log"
"os"
Expand All @@ -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"
)

Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
}()
Expand All @@ -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)
Expand All @@ -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=<seconds> 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=<seconds> if needed.",
)
} else {
m.console.Message(ctx, "Some features may be unavailable.")
}
m.console.Message(ctx, "")
}

Expand All @@ -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")
Expand Down
44 changes: 42 additions & 2 deletions cli/azd/pkg/extensions/registry_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}
}

Expand Down
13 changes: 6 additions & 7 deletions cli/azd/pkg/extensions/update_checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have a meeting with @hyoshis to discuss more on UI. Will update this after meeting.

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)),
},
}
}
9 changes: 5 additions & 4 deletions cli/azd/pkg/extensions/update_checker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
Expand All @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion cli/azd/pkg/extensions/update_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}

Expand Down