diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 8ff24b1daef..35378326fca 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -45,6 +45,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/entraid" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "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/helm" @@ -650,6 +651,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterSingleton(containerregistry.NewRemoteBuildManager) container.MustRegisterSingleton(keyvault.NewKeyVaultService) container.MustRegisterSingleton(storage.NewFileShareService) + container.MustRegisterSingleton(errorhandler.NewErrorSuggestionService) container.MustRegisterScoped(project.NewContainerHelper) container.MustRegisterScoped(func(serviceLocator ioc.ServiceLocator) *lazy.Lazy[*project.ContainerHelper] { diff --git a/cli/azd/cmd/middleware/error.go b/cli/azd/cmd/middleware/error.go index f059845afec..783b2f78a96 100644 --- a/cli/azd/cmd/middleware/error.go +++ b/cli/azd/cmd/middleware/error.go @@ -18,6 +18,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/llm" "github.com/azure/azure-dev/cli/azd/pkg/output" @@ -28,12 +29,13 @@ import ( ) type ErrorMiddleware struct { - options *Options - console input.Console - agentFactory *agent.AgentFactory - global *internal.GlobalCommandOptions - featuresManager *alpha.FeatureManager - userConfigManager config.UserConfigManager + options *Options + console input.Console + agentFactory *agent.AgentFactory + global *internal.GlobalCommandOptions + featuresManager *alpha.FeatureManager + userConfigManager config.UserConfigManager + errorSuggestionService *errorhandler.ErrorSuggestionService } func NewErrorMiddleware( @@ -42,14 +44,16 @@ func NewErrorMiddleware( global *internal.GlobalCommandOptions, featuresManager *alpha.FeatureManager, userConfigManager config.UserConfigManager, + errorSuggestionService *errorhandler.ErrorSuggestionService, ) Middleware { return &ErrorMiddleware{ - options: options, - console: console, - agentFactory: agentFactory, - global: global, - featuresManager: featuresManager, - userConfigManager: userConfigManager, + options: options, + console: console, + agentFactory: agentFactory, + global: global, + featuresManager: featuresManager, + userConfigManager: userConfigManager, + errorSuggestionService: errorSuggestionService, } } @@ -64,14 +68,6 @@ func (e *ErrorMiddleware) displayAgentResponse(ctx context.Context, response str } func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionResult, error) { - // Short-circuit agentic error handling in non-interactive scenarios: - // - LLM feature is disabled - // - User specified --no-prompt (non-interactive mode) - // - Running in CI/CD environment where user interaction is not possible - if !e.featuresManager.IsEnabled(llm.FeatureLlm) || e.global.NoPrompt || resource.IsRunningOnCI() { - return next(ctx) - } - actionResult, err := next(ctx) // Stop the spinner always to un-hide cursor @@ -81,10 +77,29 @@ func (e *ErrorMiddleware) Run(ctx context.Context, next NextFn) (*actions.Action return actionResult, err } - // Error already has a suggestion, no need for AI + // Check if error already has a suggestion var suggestionErr *internal.ErrorWithSuggestion if errors.As(err, &suggestionErr) { - e.console.Message(ctx, suggestionErr.Suggestion) + // Already has a suggestion, return as-is + return actionResult, err + } + + // Try to match error against known patterns and wrap with suggestion + if suggestion := e.errorSuggestionService.FindSuggestion(err.Error()); suggestion != nil { + wrappedErr := &internal.ErrorWithSuggestion{ + Err: err, + Message: suggestion.Message, + Suggestion: suggestion.Suggestion, + DocUrl: suggestion.DocUrl, + } + return actionResult, wrappedErr + } + + // Short-circuit agentic error handling in non-interactive scenarios: + // - LLM feature is disabled + // - User specified --no-prompt (non-interactive mode) + // - Running in CI/CD environment where user interaction is not possible + if !e.featuresManager.IsEnabled(llm.FeatureLlm) || e.global.NoPrompt || resource.IsRunningOnCI() { return actionResult, err } diff --git a/cli/azd/cmd/middleware/error_test.go b/cli/azd/cmd/middleware/error_test.go index 9c600e613ad..8ccb0de4d77 100644 --- a/cli/azd/cmd/middleware/error_test.go +++ b/cli/azd/cmd/middleware/error_test.go @@ -13,6 +13,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/errorhandler" "github.com/azure/azure-dev/cli/azd/pkg/llm" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" @@ -30,6 +31,7 @@ func Test_ErrorMiddleware_SuccessNoError(t *testing.T) { NoPrompt: false, } userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + errorSuggestionService := errorhandler.NewErrorSuggestionService() middleware := NewErrorMiddleware( &Options{Name: "test"}, mockContext.Console, @@ -37,6 +39,7 @@ func Test_ErrorMiddleware_SuccessNoError(t *testing.T) { global, featureManager, userConfigManager, + errorSuggestionService, ) nextFn := func(ctx context.Context) (*actions.ActionResult, error) { return &actions.ActionResult{ @@ -61,6 +64,7 @@ func Test_ErrorMiddleware_LLMAlphaFeatureDisabled(t *testing.T) { NoPrompt: false, } userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + errorSuggestionService := errorhandler.NewErrorSuggestionService() middleware := NewErrorMiddleware( &Options{Name: "test"}, mockContext.Console, @@ -68,6 +72,7 @@ func Test_ErrorMiddleware_LLMAlphaFeatureDisabled(t *testing.T) { global, featureManager, userConfigManager, + errorSuggestionService, ) testError := errors.New("test error") @@ -77,12 +82,13 @@ func Test_ErrorMiddleware_LLMAlphaFeatureDisabled(t *testing.T) { result, err := middleware.Run(*mockContext.Context, nextFn) + // Should return error without AI intervention in no-prompt mode require.Error(t, err) require.Nil(t, result) require.Equal(t, testError, err) } -func Test_ErrorMiddleware_NoPromptMode(t *testing.T) { +func Test_ErrorMiddleware_ChildAction(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) cfg := config.NewConfig(map[string]any{ "alpha": map[string]any{ @@ -91,9 +97,10 @@ func Test_ErrorMiddleware_NoPromptMode(t *testing.T) { }) featureManager := alpha.NewFeaturesManagerWithConfig(cfg) global := &internal.GlobalCommandOptions{ - NoPrompt: true, // Non-interactive mode + NoPrompt: false, } userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + errorSuggestionService := errorhandler.NewErrorSuggestionService() middleware := NewErrorMiddleware( &Options{Name: "test"}, mockContext.Console, @@ -101,22 +108,27 @@ func Test_ErrorMiddleware_NoPromptMode(t *testing.T) { global, featureManager, userConfigManager, + errorSuggestionService, ) - testError := errors.New("test error") nextFn := func(ctx context.Context) (*actions.ActionResult, error) { return nil, testError } - result, err := middleware.Run(*mockContext.Context, nextFn) + // Mark context as child action + ctx := WithChildAction(*mockContext.Context) + result, err := middleware.Run(ctx, nextFn) - // Should return error without AI intervention in no-prompt mode require.Error(t, err) require.Nil(t, result) require.Equal(t, testError, err) } -func Test_ErrorMiddleware_ChildAction(t *testing.T) { +func Test_ErrorMiddleware_ErrorWithSuggestion(t *testing.T) { + if os.Getenv("TF_BUILD") != "" || os.Getenv("GITHUB_ACTIONS") != "" || os.Getenv("CI") != "" { + t.Skip("Skipping test in CI/CD environment") + } + mockContext := mocks.NewMockContext(context.Background()) cfg := config.NewConfig(map[string]any{ "alpha": map[string]any{ @@ -128,6 +140,7 @@ func Test_ErrorMiddleware_ChildAction(t *testing.T) { NoPrompt: false, } userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + errorSuggestionService := errorhandler.NewErrorSuggestionService() middleware := NewErrorMiddleware( &Options{Name: "test"}, mockContext.Console, @@ -135,37 +148,39 @@ func Test_ErrorMiddleware_ChildAction(t *testing.T) { global, featureManager, userConfigManager, + errorSuggestionService, ) - testError := errors.New("test error") + + // Create error with suggestion + testErr := errors.New("test error") + suggestionErr := &internal.ErrorWithSuggestion{ + Err: testErr, + Suggestion: "Suggested fix", + } nextFn := func(ctx context.Context) (*actions.ActionResult, error) { - return nil, testError + return nil, suggestionErr } - // Mark context as child action - ctx := WithChildAction(*mockContext.Context) - result, err := middleware.Run(ctx, nextFn) + result, err := middleware.Run(*mockContext.Context, nextFn) require.Error(t, err) require.Nil(t, result) - require.Equal(t, testError, err) -} -func Test_ErrorMiddleware_ErrorWithSuggestion(t *testing.T) { - if os.Getenv("TF_BUILD") != "" || os.Getenv("GITHUB_ACTIONS") != "" || os.Getenv("CI") != "" { - t.Skip("Skipping test in CI/CD environment") - } + // Verify the error with suggestion is returned as-is (not modified) + var returnedSuggestionErr *internal.ErrorWithSuggestion + require.True(t, errors.As(err, &returnedSuggestionErr), "Expected ErrorWithSuggestion to be returned") + require.Equal(t, "Suggested fix", returnedSuggestionErr.Suggestion) +} +func Test_ErrorMiddleware_PatternMatchingSuggestion(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) - cfg := config.NewConfig(map[string]any{ - "alpha": map[string]any{ - string(llm.FeatureLlm): "on", - }, - }) + cfg := config.NewEmptyConfig() featureManager := alpha.NewFeaturesManagerWithConfig(cfg) global := &internal.GlobalCommandOptions{ NoPrompt: false, } userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + errorSuggestionService := errorhandler.NewErrorSuggestionService() middleware := NewErrorMiddleware( &Options{Name: "test"}, mockContext.Console, @@ -173,16 +188,13 @@ func Test_ErrorMiddleware_ErrorWithSuggestion(t *testing.T) { global, featureManager, userConfigManager, + errorSuggestionService, ) - // Create error with suggestion - testErr := errors.New("test error") - suggestionErr := &internal.ErrorWithSuggestion{ - Err: testErr, - Suggestion: "Suggested fix", - } + // Create an error that matches a known pattern (quota error) + quotaError := errors.New("Deployment failed: QuotaExceeded for resource") nextFn := func(ctx context.Context) (*actions.ActionResult, error) { - return nil, suggestionErr + return nil, quotaError } result, err := middleware.Run(*mockContext.Context, nextFn) @@ -190,16 +202,47 @@ func Test_ErrorMiddleware_ErrorWithSuggestion(t *testing.T) { require.Error(t, err) require.Nil(t, result) - // Check that suggestion was displayed - consoleOutput := mockContext.Console.Output() - foundSuggestion := false - for _, message := range consoleOutput { - if message == "Suggested fix" { - foundSuggestion = true - break - } + // Verify the error was wrapped with a suggestion + var suggestionErr *internal.ErrorWithSuggestion + require.True(t, errors.As(err, &suggestionErr), "Expected error to be wrapped with suggestion") + require.Contains(t, suggestionErr.Suggestion, "quota") + require.NotEmpty(t, suggestionErr.DocUrl, "Expected a documentation URL") +} + +func Test_ErrorMiddleware_NoPatternMatch(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + cfg := config.NewEmptyConfig() + featureManager := alpha.NewFeaturesManagerWithConfig(cfg) + global := &internal.GlobalCommandOptions{ + NoPrompt: true, // Use no-prompt mode to avoid AI processing + } + userConfigManager := config.NewUserConfigManager(mockContext.ConfigManager) + errorSuggestionService := errorhandler.NewErrorSuggestionService() + middleware := NewErrorMiddleware( + &Options{Name: "test"}, + mockContext.Console, + nil, + global, + featureManager, + userConfigManager, + errorSuggestionService, + ) + + // Create an error that doesn't match any pattern + unknownError := errors.New("some completely unique error xyz123abc") + nextFn := func(ctx context.Context) (*actions.ActionResult, error) { + return nil, unknownError } - require.True(t, foundSuggestion, "No suggestion displayed for ErrorWithSuggestion") + + result, err := middleware.Run(*mockContext.Context, nextFn) + + require.Error(t, err) + require.Nil(t, result) + + // Verify the error was NOT wrapped with a suggestion + var suggestionErr *internal.ErrorWithSuggestion + require.False(t, errors.As(err, &suggestionErr), "Expected error NOT to be wrapped with suggestion") + require.Equal(t, unknownError, err) } func Test_ExtractSuggestedSolutions(t *testing.T) { diff --git a/cli/azd/cmd/middleware/ux.go b/cli/azd/cmd/middleware/ux.go index f6287cb6630..65f4ae6455a 100644 --- a/cli/azd/cmd/middleware/ux.go +++ b/cli/azd/cmd/middleware/ux.go @@ -46,33 +46,43 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes if err != nil { var suggestionErr *internal.ErrorWithSuggestion var errorWithTraceId *internal.ErrorWithTraceId + + // For specific errors, we silent the output display here and let the caller handle it + var unsupportedErr *project.UnsupportedServiceHostError + var extensionRunErr *extensions.ExtensionRunError + if errors.As(err, &extensionRunErr) { + return actionResult, err + } + + // Use ErrorWithSuggestion for errors with suggestions (better UX) + if errors.As(err, &suggestionErr) { + displayErr := &ux.ErrorWithSuggestion{ + Err: suggestionErr.Err, + Message: suggestionErr.Message, + Suggestion: suggestionErr.Suggestion, + DocUrl: suggestionErr.DocUrl, + } + m.console.MessageUxItem(ctx, displayErr) + return actionResult, err + } + + // Build error message for errors without suggestions errorMessage := &strings.Builder{} - // WriteString never returns an error errorMessage.WriteString(output.WithErrorFormat("\nERROR: %s", err.Error())) if errors.As(err, &errorWithTraceId) { errorMessage.WriteString(output.WithErrorFormat("\nTraceID: %s", errorWithTraceId.TraceId)) } - if errors.As(err, &suggestionErr) { - errorMessage.WriteString("\n" + suggestionErr.Suggestion) - } - errMessage := errorMessage.String() - // For specific errors, we silent the output display here and let the caller handle it - var unsupportedErr *project.UnsupportedServiceHostError - var extensionRunErr *extensions.ExtensionRunError - if errors.As(err, &extensionRunErr) { - return actionResult, err - } else if errors.As(err, &unsupportedErr) { + if errors.As(err, &unsupportedErr) { // set the error message so the caller can use it if needed unsupportedErr.ErrorMessage = errMessage return actionResult, err - } else { - m.console.Message(ctx, errMessage) } + m.console.Message(ctx, errMessage) } if actionResult != nil && actionResult.Message != nil { diff --git a/cli/azd/docs/error-suggestions.md b/cli/azd/docs/error-suggestions.md new file mode 100644 index 00000000000..4cc289e9a64 --- /dev/null +++ b/cli/azd/docs/error-suggestions.md @@ -0,0 +1,220 @@ +# Error Suggestions + +Azure Developer CLI includes a pattern-based error suggestion system that provides user-friendly messaging for common errors. When users encounter well-known errors (quota limits, authentication failures, missing tools, etc.), azd displays: + +1. A **user-friendly message** explaining what went wrong +2. An **actionable suggestion** for next steps +3. A **documentation link** for more information +4. The **original error** (in grey) for technical reference + +## How It Works + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Error Occurs │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ ErrorMiddleware checks error message against patterns in │ +│ resources/error_suggestions.yaml │ +└─────────────────────────────────────────────────────────────────┘ + │ + ┌───────────────┴───────────────┐ + │ │ + ▼ ▼ +┌─────────────────────────┐ ┌─────────────────────────────────┐ +│ Pattern Matched │ │ No Pattern Match │ +│ Wrap error with │ │ Return original error │ +│ ErrorWithSuggestion │ │ (may go to AI if enabled) │ +└─────────────────────────┘ └─────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ UxMiddleware displays: │ +│ 1. User-friendly message (ERROR: ...) │ +│ 2. Actionable suggestion (Suggestion: ...) │ +│ 3. Documentation link (Learn more: ...) │ +│ 4. Original error in grey (technical details) │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Example Output + +When a user hits a quota error, instead of seeing a wall of red text, they see: + +``` +ERROR: Your Azure subscription has reached a resource quota limit. + +Suggestion: Request a quota increase through the Azure portal, or try deploying to a different region. +Learn more: https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal + +Deployment failed: QuotaExceeded for resource type Microsoft.Compute/virtualMachines in location eastus... +``` + +The original error is shown in grey at the bottom for users who need the technical details. + +## Adding New Error Patterns + +The error patterns are defined in [`resources/error_suggestions.yaml`](../resources/error_suggestions.yaml). This file is designed to be easily editable by anyone—no Go programming knowledge required. + +### Basic Structure + +```yaml +rules: + - patterns: + - "error text to match" + - "another error text" + message: "User-friendly explanation of what went wrong." + suggestion: "Actionable next steps to fix the issue." + docUrl: "https://learn.microsoft.com/..." # optional +``` + +### Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `patterns` | Yes | List of strings to match against error messages | +| `message` | Yes | User-friendly explanation of what went wrong | +| `suggestion` | Yes | Actionable next steps for the user | +| `docUrl` | No | Link to relevant documentation | + +### Pattern Types + +#### 1. Simple Substring Matching (Default) + +The simplest pattern type. Matches if the error message contains the pattern text anywhere. **Matching is case-insensitive**. + +```yaml +- patterns: + - "quota exceeded" # Matches "QuotaExceeded", "QUOTA EXCEEDED", etc. + - "QuotaExceeded" # Also matches case-insensitively + message: "Your Azure subscription has reached a resource quota limit." + suggestion: "Request a quota increase through the Azure portal." +``` + +#### 2. Regular Expression Patterns + +For more complex matching, prefix the pattern with `regex:`. This enables full regular expression support. + +```yaml +- patterns: + - "regex:(?i)authorization.*failed" # (?i) = case-insensitive flag + - "regex:BCP\\d{3}" # Matches BCP001, BCP123, etc. + message: "You don't have permission to perform this operation." + suggestion: "Check your Azure RBAC role assignments." +``` + +**Common regex patterns:** + +| Pattern | Meaning | +|---------|---------| +| `(?i)` | Case-insensitive matching | +| `.*` | Match any characters | +| `\\d+` | Match one or more digits | +| `\\d{3}` | Match exactly 3 digits | +| `(foo|bar)` | Match "foo" or "bar" | +| `\\s+` | Match whitespace | + +**Note:** In YAML, backslashes must be escaped as `\\`. + +### Rule Evaluation + +- **First match wins**: Rules are evaluated in order from top to bottom +- **Order matters**: Place more specific patterns before general ones +- **Multiple patterns per rule**: If any pattern in a rule matches, that rule wins + +### Best Practices + +1. **Keep messages simple**: The message explains what went wrong in plain language + + ```yaml + # ❌ Too technical + message: "QuotaExceeded error for Microsoft.Compute/virtualMachines" + + # ✅ User-friendly + message: "Your Azure subscription has reached a resource quota limit." + ``` + +2. **Make suggestions actionable**: Tell users exactly what to do + + ```yaml + # ❌ Vague + suggestion: "Fix the authentication issue." + + # ✅ Actionable + suggestion: "Run 'azd auth login' to sign in again." + ``` + +3. **Include documentation links**: Help users learn more + + ```yaml + docUrl: "https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-auth-login" + ``` + +4. **Group related patterns**: Combine patterns that should have the same response + + ```yaml + - patterns: + - "AADSTS" # Azure AD error codes + - "regex:(?i)authentication.*failed" + - "regex:(?i)invalid.*credentials" + message: "Authentication with Azure failed." + suggestion: "Run 'azd auth login' to sign in again." + ``` + +5. **Test your patterns**: Use the unit tests to verify patterns work correctly + + ```go + // In pkg/errorhandler/matcher_test.go + func TestMyNewPattern(t *testing.T) { + service := NewErrorSuggestionService() + result := service.FindSuggestion("your error message here") + assert.NotNil(t, result) + assert.NotEmpty(t, result.Message) + assert.NotEmpty(t, result.Suggestion) + } + ``` + +## File Location + +| File | Purpose | +|------|---------| +| `resources/error_suggestions.yaml` | Error patterns and suggestions (edit this!) | +| `pkg/errorhandler/types.go` | Go types for the YAML structure | +| `pkg/errorhandler/matcher.go` | Pattern matching engine | +| `pkg/errorhandler/service.go` | Service that loads and matches patterns | +| `pkg/output/ux/error_with_suggestion.go` | UX component for displaying errors | + +## Complete Example + +Here's an example of adding a new error pattern for a hypothetical "disk space" error: + +```yaml +# Add this to resources/error_suggestions.yaml + + # ============================================================================ + # Disk Space Errors + # ============================================================================ + - patterns: + - "no space left on device" + - "disk quota exceeded" + - "regex:(?i)insufficient.*disk.*space" + - "ENOSPC" + message: "Your disk is full." + suggestion: "Free up space by removing unused Docker images ('docker system prune') or clearing temporary files." + docUrl: "https://docs.docker.com/config/pruning/" +``` + +After adding the pattern: + +1. Run `go build` to ensure the YAML is valid +2. Run `go test ./pkg/errorhandler/...` to verify patterns load correctly +3. Test with a real error scenario if possible + +## Architecture Notes + +- **Embedded resource**: The YAML file is embedded into the azd binary at build time using Go's `//go:embed` directive +- **Lazy loading**: Patterns are loaded once on first use and cached +- **Regex caching**: Compiled regular expressions are cached for performance +- **No external dependencies**: Pattern matching works offline with no network calls diff --git a/cli/azd/internal/errors.go b/cli/azd/internal/errors.go index 7d51f54a035..5665642047a 100644 --- a/cli/azd/internal/errors.go +++ b/cli/azd/internal/errors.go @@ -3,10 +3,16 @@ package internal -// ErrorWithSuggestion is a custom error type that includes a suggestion for the user +// ErrorWithSuggestion is a custom error type that includes user-friendly messaging type ErrorWithSuggestion struct { + // Err is the original underlying error + Err error + // Message is a user-friendly explanation of what went wrong + Message string + // Suggestion is actionable next steps to resolve the issue Suggestion string - Err error + // DocUrl is an optional link to documentation + DocUrl string } // Error returns the error message diff --git a/cli/azd/pkg/errorhandler/matcher.go b/cli/azd/pkg/errorhandler/matcher.go new file mode 100644 index 00000000000..65959089281 --- /dev/null +++ b/cli/azd/pkg/errorhandler/matcher.go @@ -0,0 +1,68 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package errorhandler + +import ( + "regexp" + "strings" +) + +const regexPrefix = "regex:" + +// PatternMatcher handles matching error messages against patterns. +type PatternMatcher struct { + // compiledPatterns caches compiled regex patterns for performance + compiledPatterns map[string]*regexp.Regexp +} + +// NewPatternMatcher creates a new PatternMatcher instance. +func NewPatternMatcher() *PatternMatcher { + return &PatternMatcher{ + compiledPatterns: make(map[string]*regexp.Regexp), + } +} + +// Match checks if the given error message matches any of the patterns. +// Returns true if any pattern matches. +// +// Pattern types: +// - Simple string: case-insensitive substring match +// - "regex:pattern": regular expression match +func (m *PatternMatcher) Match(errorMessage string, patterns []string) bool { + lowerErrorMessage := strings.ToLower(errorMessage) + + for _, pattern := range patterns { + if m.matchPattern(errorMessage, lowerErrorMessage, pattern) { + return true + } + } + + return false +} + +// matchPattern checks if a single pattern matches the error message. +func (m *PatternMatcher) matchPattern(errorMessage, lowerErrorMessage, pattern string) bool { + if strings.HasPrefix(pattern, regexPrefix) { + return m.matchRegex(errorMessage, pattern[len(regexPrefix):]) + } + + // Simple case-insensitive substring match + return strings.Contains(lowerErrorMessage, strings.ToLower(pattern)) +} + +// matchRegex compiles (with caching) and matches a regex pattern against the error message. +func (m *PatternMatcher) matchRegex(errorMessage, pattern string) bool { + re, ok := m.compiledPatterns[pattern] + if !ok { + var err error + re, err = regexp.Compile(pattern) + if err != nil { + // Invalid regex pattern - skip this pattern + return false + } + m.compiledPatterns[pattern] = re + } + + return re.MatchString(errorMessage) +} diff --git a/cli/azd/pkg/errorhandler/matcher_test.go b/cli/azd/pkg/errorhandler/matcher_test.go new file mode 100644 index 00000000000..14632f0d955 --- /dev/null +++ b/cli/azd/pkg/errorhandler/matcher_test.go @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package errorhandler + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPatternMatcher_SimpleSubstring(t *testing.T) { + matcher := NewPatternMatcher() + + tests := []struct { + name string + errorMessage string + patterns []string + expected bool + }{ + { + name: "exact match", + errorMessage: "quota exceeded", + patterns: []string{"quota exceeded"}, + expected: true, + }, + { + name: "case insensitive match", + errorMessage: "QUOTA EXCEEDED", + patterns: []string{"quota exceeded"}, + expected: true, + }, + { + name: "substring match", + errorMessage: "Error: quota exceeded for subscription", + patterns: []string{"quota exceeded"}, + expected: true, + }, + { + name: "no match", + errorMessage: "some other error", + patterns: []string{"quota exceeded"}, + expected: false, + }, + { + name: "multiple patterns first matches", + errorMessage: "QuotaExceeded error", + patterns: []string{"QuotaExceeded", "quota exceeded"}, + expected: true, + }, + { + name: "multiple patterns second matches", + errorMessage: "quota exceeded error", + patterns: []string{"QuotaExceeded", "quota exceeded"}, + expected: true, + }, + { + name: "empty patterns", + errorMessage: "some error", + patterns: []string{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matcher.Match(tt.errorMessage, tt.patterns) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPatternMatcher_Regex(t *testing.T) { + matcher := NewPatternMatcher() + + tests := []struct { + name string + errorMessage string + patterns []string + expected bool + }{ + { + name: "regex match", + errorMessage: "Authorization failed for user", + patterns: []string{"regex:(?i)authorization.*failed"}, + expected: true, + }, + { + name: "regex case insensitive flag", + errorMessage: "AUTHORIZATION FAILED", + patterns: []string{"regex:(?i)authorization.*failed"}, + expected: true, + }, + { + name: "regex no match", + errorMessage: "some other error", + patterns: []string{"regex:(?i)authorization.*failed"}, + expected: false, + }, + { + name: "regex with numbers", + errorMessage: "Error BCP123: invalid syntax", + patterns: []string{"regex:BCP\\d{3}"}, + expected: true, + }, + { + name: "invalid regex is skipped", + errorMessage: "some error", + patterns: []string{"regex:[invalid"}, + expected: false, + }, + { + name: "mixed patterns regex first", + errorMessage: "quota limit reached", + patterns: []string{"regex:(?i)quota.*limit", "QuotaExceeded"}, + expected: true, + }, + { + name: "mixed patterns simple first", + errorMessage: "QuotaExceeded", + patterns: []string{"QuotaExceeded", "regex:(?i)quota.*limit"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matcher.Match(tt.errorMessage, tt.patterns) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestPatternMatcher_RegexCaching(t *testing.T) { + matcher := NewPatternMatcher() + pattern := "regex:test\\d+" + + // First call compiles the regex + result1 := matcher.Match("test123", []string{pattern}) + assert.True(t, result1) + + // Second call should use cached regex + result2 := matcher.Match("test456", []string{pattern}) + assert.True(t, result2) + + // Verify cache has the entry + assert.Len(t, matcher.compiledPatterns, 1) +} + +func TestErrorSuggestionService_FindSuggestion(t *testing.T) { + service := NewErrorSuggestionService() + + tests := []struct { + name string + errorMessage string + expectMatch bool + expectDocUrl bool + expectMessage bool + suggestionPart string + }{ + { + name: "quota error matches", + errorMessage: "Deployment failed: QuotaExceeded for resource", + expectMatch: true, + expectDocUrl: true, + expectMessage: true, + suggestionPart: "quota", + }, + { + name: "auth error matches", + errorMessage: "AADSTS50076: authentication required", + expectMatch: true, + expectDocUrl: true, + expectMessage: true, + suggestionPart: "azd auth login", + }, + { + name: "bicep error matches", + errorMessage: "BCP035: The specified value is not valid", + expectMatch: true, + expectDocUrl: true, + expectMessage: true, + suggestionPart: ".bicep", + }, + { + name: "unknown error no match", + errorMessage: "some completely unknown error xyz123", + expectMatch: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := service.FindSuggestion(tt.errorMessage) + if tt.expectMatch { + assert.NotNil(t, result) + assert.Contains(t, result.Suggestion, tt.suggestionPart) + if tt.expectDocUrl { + assert.NotEmpty(t, result.DocUrl) + } + if tt.expectMessage { + assert.NotEmpty(t, result.Message) + } + } else { + assert.Nil(t, result) + } + }) + } +} + +func TestErrorSuggestionService_FirstMatchWins(t *testing.T) { + service := NewErrorSuggestionService() + + // An error that could match multiple patterns should return the first match + // "quota exceeded" and "OperationNotAllowed" are in the same rule + result := service.FindSuggestion("OperationNotAllowed: quota exceeded") + + assert.NotNil(t, result) + assert.Contains(t, result.Suggestion, "quota") +} diff --git a/cli/azd/pkg/errorhandler/service.go b/cli/azd/pkg/errorhandler/service.go new file mode 100644 index 00000000000..84c3c4e499e --- /dev/null +++ b/cli/azd/pkg/errorhandler/service.go @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package errorhandler + +import ( + "log" + "sync" + + "github.com/azure/azure-dev/cli/azd/resources" + "github.com/braydonk/yaml" +) + +var ( + config *ErrorSuggestionsConfig + configOnce sync.Once +) + +// loadConfig loads the error suggestions configuration from the embedded YAML file. +func loadConfig() *ErrorSuggestionsConfig { + configOnce.Do(func() { + config = &ErrorSuggestionsConfig{} + if err := yaml.Unmarshal(resources.ErrorSuggestions, config); err != nil { + log.Panicf("failed to unmarshal error_suggestions.yaml: %v", err) + } + }) + return config +} + +// ErrorSuggestionService provides error message matching against known error patterns. +type ErrorSuggestionService struct { + config *ErrorSuggestionsConfig + matcher *PatternMatcher +} + +// NewErrorSuggestionService creates a new ErrorSuggestionService. +func NewErrorSuggestionService() *ErrorSuggestionService { + return &ErrorSuggestionService{ + config: loadConfig(), + matcher: NewPatternMatcher(), + } +} + +// FindSuggestion checks if the error message matches any known error patterns. +// Returns a MatchedSuggestion if a match is found, or nil if no match. +// Rules are evaluated in order; the first match wins. +func (s *ErrorSuggestionService) FindSuggestion(errorMessage string) *MatchedSuggestion { + for _, rule := range s.config.Rules { + if s.matcher.Match(errorMessage, rule.Patterns) { + return &MatchedSuggestion{ + Message: rule.Message, + Suggestion: rule.Suggestion, + DocUrl: rule.DocUrl, + } + } + } + return nil +} diff --git a/cli/azd/pkg/errorhandler/types.go b/cli/azd/pkg/errorhandler/types.go new file mode 100644 index 00000000000..a25d23b5ef2 --- /dev/null +++ b/cli/azd/pkg/errorhandler/types.go @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package errorhandler + +// ErrorSuggestionRule defines a single rule that maps error patterns to an actionable suggestion. +type ErrorSuggestionRule struct { + // Patterns is a list of strings to match against error messages. + // Simple strings are matched as case-insensitive substrings. + // Prefix a pattern with "regex:" to use regular expression matching. + Patterns []string `yaml:"patterns"` + + // Message is a user-friendly error message that explains what went wrong. + // This replaces the cryptic system error with something readable. + Message string `yaml:"message"` + + // Suggestion is the actionable next steps for the user to resolve the issue. + Suggestion string `yaml:"suggestion"` + + // DocUrl is an optional link to documentation for more information. + DocUrl string `yaml:"docUrl,omitempty"` +} + +// ErrorSuggestionsConfig is the root structure for the error_suggestions.yaml file. +type ErrorSuggestionsConfig struct { + // Rules is the ordered list of error suggestion rules. + // Rules are evaluated in order; the first match wins. + Rules []ErrorSuggestionRule `yaml:"rules"` +} + +// MatchedSuggestion represents a successful match of an error to a suggestion rule. +type MatchedSuggestion struct { + // Message is a user-friendly error message. + Message string + + // Suggestion is the actionable next steps for the user. + Suggestion string + + // DocUrl is an optional documentation link. + DocUrl string +} diff --git a/cli/azd/pkg/output/ux/error_with_suggestion.go b/cli/azd/pkg/output/ux/error_with_suggestion.go new file mode 100644 index 00000000000..1d5ca1c559d --- /dev/null +++ b/cli/azd/pkg/output/ux/error_with_suggestion.go @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ux + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +// ErrorWithSuggestion displays an error with user-friendly messaging. +// Layout: +// 1. User-friendly message (what went wrong) +// 2. Suggestion (actionable next steps) +// 3. Doc link (optional, for more info) +// 4. Original error (grey, de-emphasized technical details) +type ErrorWithSuggestion struct { + // Err is the original underlying error + Err error + + // Message is a user-friendly explanation of what went wrong + Message string + + // Suggestion is actionable next steps to resolve the issue + Suggestion string + + // DocUrl is an optional link to documentation for more information + DocUrl string +} + +func (e *ErrorWithSuggestion) ToString(currentIndentation string) string { + var sb strings.Builder + + // 1. User-friendly message (or fall back to raw error if no message) + errorMsg := e.Message + if errorMsg == "" && e.Err != nil { + errorMsg = e.Err.Error() + } + sb.WriteString(output.WithErrorFormat("%sERROR: %s", currentIndentation, errorMsg)) + sb.WriteString("\n") + + // 2. Suggestion (actionable next steps) + if e.Suggestion != "" { + sb.WriteString(fmt.Sprintf("\n%s%s %s\n", + currentIndentation, + output.WithHighLightFormat("Suggestion:"), + e.Suggestion)) + } + + // 3. Documentation link (if provided) + if e.DocUrl != "" { + sb.WriteString(fmt.Sprintf("%s%s %s\n", + currentIndentation, + output.WithGrayFormat("Learn more:"), + output.WithLinkFormat(e.DocUrl))) + } + + // 4. Original error in grey (technical details, de-emphasized) + if e.Message != "" && e.Err != nil { + sb.WriteString(fmt.Sprintf("\n%s%s\n", + currentIndentation, + output.WithGrayFormat(e.Err.Error()))) + } + + return sb.String() +} + +func (e *ErrorWithSuggestion) MarshalJSON() ([]byte, error) { + errStr := "" + if e.Err != nil { + errStr = e.Err.Error() + } + + result := struct { + Error string `json:"error"` + Message string `json:"message,omitempty"` + Suggestion string `json:"suggestion,omitempty"` + DocUrl string `json:"docUrl,omitempty"` + }{ + Error: errStr, + Message: e.Message, + Suggestion: e.Suggestion, + DocUrl: e.DocUrl, + } + + return json.Marshal(result) +} diff --git a/cli/azd/pkg/output/ux/error_with_suggestion_test.go b/cli/azd/pkg/output/ux/error_with_suggestion_test.go new file mode 100644 index 00000000000..a60fd5d5ccf --- /dev/null +++ b/cli/azd/pkg/output/ux/error_with_suggestion_test.go @@ -0,0 +1,148 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ux + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrorWithSuggestion_ToString_AllFields(t *testing.T) { + err := &ErrorWithSuggestion{ + Err: errors.New("QuotaExceeded: raw error details here"), + Message: "Your subscription has reached a quota limit.", + Suggestion: "Request a quota increase through the Azure portal.", + DocUrl: "https://learn.microsoft.com/azure/quotas/", + } + + result := err.ToString("") + + // Message should be the ERROR line + assert.Contains(t, result, "ERROR:") + assert.Contains(t, result, "Your subscription has reached a quota limit") + + // Suggestion should be present + assert.Contains(t, result, "Suggestion:") + assert.Contains(t, result, "Request a quota increase") + + // Doc link should be present + assert.Contains(t, result, "Learn more:") + assert.Contains(t, result, "https://learn.microsoft.com/azure/quotas/") + + // Raw error should be at the end + assert.Contains(t, result, "QuotaExceeded: raw error details") +} + +func TestErrorWithSuggestion_ToString_WithoutDocUrl(t *testing.T) { + err := &ErrorWithSuggestion{ + Err: errors.New("some raw error"), + Message: "Something went wrong.", + Suggestion: "Try this fix.", + } + + result := err.ToString("") + + assert.Contains(t, result, "ERROR:") + assert.Contains(t, result, "Something went wrong") + assert.Contains(t, result, "Suggestion:") + assert.Contains(t, result, "Try this fix") + assert.NotContains(t, result, "Learn more:") + // Raw error should still be shown + assert.Contains(t, result, "some raw error") +} + +func TestErrorWithSuggestion_ToString_WithoutMessage(t *testing.T) { + err := &ErrorWithSuggestion{ + Err: errors.New("raw error only"), + Suggestion: "Try this.", + } + + result := err.ToString("") + + // Should fall back to showing the raw error as the ERROR line + assert.Contains(t, result, "ERROR:") + assert.Contains(t, result, "raw error only") + assert.Contains(t, result, "Suggestion:") +} + +func TestErrorWithSuggestion_ToString_MessageOnly(t *testing.T) { + err := &ErrorWithSuggestion{ + Err: errors.New("raw error"), + Message: "User-friendly message", + } + + result := err.ToString("") + + assert.Contains(t, result, "ERROR:") + assert.Contains(t, result, "User-friendly message") + // Raw error should be shown in grey at the end + assert.Contains(t, result, "raw error") +} + +func TestErrorWithSuggestion_MarshalJSON(t *testing.T) { + err := &ErrorWithSuggestion{ + Err: errors.New("test error"), + Message: "test message", + Suggestion: "test suggestion", + DocUrl: "https://example.com", + } + + data, marshalErr := json.Marshal(err) + require.NoError(t, marshalErr) + + var result map[string]string + require.NoError(t, json.Unmarshal(data, &result)) + + assert.Equal(t, "test error", result["error"]) + assert.Equal(t, "test message", result["message"]) + assert.Equal(t, "test suggestion", result["suggestion"]) + assert.Equal(t, "https://example.com", result["docUrl"]) +} + +func TestErrorWithSuggestion_MarshalJSON_OmitsEmpty(t *testing.T) { + err := &ErrorWithSuggestion{ + Err: errors.New("test error"), + Message: "test message", + } + + data, marshalErr := json.Marshal(err) + require.NoError(t, marshalErr) + + var result map[string]interface{} + require.NoError(t, json.Unmarshal(data, &result)) + + assert.Equal(t, "test error", result["error"]) + assert.Equal(t, "test message", result["message"]) + // These should be omitted when empty + _, hasSuggestion := result["suggestion"] + _, hasDocUrl := result["docUrl"] + assert.False(t, hasSuggestion) + assert.False(t, hasDocUrl) +} + +func TestErrorWithSuggestion_WithIndentation(t *testing.T) { + err := &ErrorWithSuggestion{ + Err: errors.New("test error"), + Message: "test message", + Suggestion: "test suggestion", + DocUrl: "https://example.com", + } + + result := err.ToString(" ") + + // Lines should respect indentation (allowing for ANSI codes at start) + lines := strings.Split(result, "\n") + for _, line := range lines { + if len(line) > 0 { + // Either starts with indentation or ANSI escape code + assert.True(t, strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\x1b"), + "Line should be indented: %q", line) + } + } +} diff --git a/cli/azd/resources/error_suggestions.yaml b/cli/azd/resources/error_suggestions.yaml new file mode 100644 index 00000000000..6926fd44a86 --- /dev/null +++ b/cli/azd/resources/error_suggestions.yaml @@ -0,0 +1,279 @@ +# Error Suggestions Configuration +# ================================ +# This file maps well-known error patterns to user-friendly messages and actionable suggestions. +# Rules are evaluated in order; the first matching rule wins. +# +# Fields: +# - patterns: List of strings/regex to match against error messages +# - message: User-friendly explanation of what went wrong +# - suggestion: Actionable next steps to resolve the issue +# - docUrl: Optional link to documentation +# +# Pattern Types: +# - Simple string: Case-insensitive substring match (e.g., "quota exceeded") +# - Regex pattern: Prefix with "regex:" for regular expression (e.g., "regex:(?i)error.*code") +# +# Example: +# - patterns: +# - "some error text" +# - "regex:pattern\\d+" +# message: "A brief, user-friendly explanation of the error." +# suggestion: "Clear instruction on how to fix the issue." +# docUrl: "https://learn.microsoft.com/..." + +rules: + - patterns: + - "parsing project file" + message: "Your azure.yaml file is invalid" + suggestion: "Check the syntax of your azure.yaml file and fix any errors." + docUrl: "https://aka.ms/azure-dev/azure-yaml" + # ============================================================================ + # Quota and Capacity Errors + # ============================================================================ + - patterns: + - "QuotaExceeded" + - "quota exceeded" + - "exceeds quota" + - "OperationNotAllowed" + - "regex:(?i)quota.*limit" + message: "Your Azure subscription has reached a resource quota limit." + suggestion: "Request a quota increase through the Azure portal, or try deploying to a different region." + docUrl: "https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal" + + - patterns: + - "SkuNotAvailable" + - "regex:(?i)sku.*not available" + - "regex:(?i)requested.*size.*not available" + message: "The requested resource size is not available in the selected region." + suggestion: "Try a different region or select a different VM/resource size." + docUrl: "https://learn.microsoft.com/azure/azure-resource-manager/troubleshooting/error-sku-not-available" + + - patterns: + - "ZonalAllocationFailed" + - "AllocationFailed" + - "OverconstrainedAllocationRequest" + message: "Azure could not allocate the requested resources in this region." + suggestion: "Try a different VM size, availability zone, or region." + docUrl: "https://learn.microsoft.com/azure/azure-resource-manager/troubleshooting/error-resource-quota" + + # ============================================================================ + # Authentication and Authorization Errors + # ============================================================================ + - patterns: + - "AADSTS" + - "regex:(?i)authentication.*failed" + - "regex:(?i)invalid.*credentials" + message: "Authentication with Azure failed." + suggestion: "Run 'azd auth login' to sign in again." + docUrl: "https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-auth-login" + + - patterns: + - "AuthorizationFailed" + - "regex:(?i)authorization.*failed" + - "does not have authorization" + - "PrincipalNotFound" + message: "You don't have permission to perform this operation." + suggestion: "Ensure your account has the appropriate Azure RBAC role assigned." + docUrl: "https://learn.microsoft.com/azure/role-based-access-control/troubleshooting" + + - patterns: + - "LinkedAuthorizationFailed" + - "regex:(?i)client.*does not have authorization" + message: "You don't have permission to link these resources." + suggestion: "Ensure you have 'Owner' or 'User Access Administrator' role on the target resources." + docUrl: "https://learn.microsoft.com/azure/role-based-access-control/built-in-roles" + + - patterns: + - "InvalidAuthenticationToken" + - "ExpiredAuthenticationToken" + - "TokenExpired" + message: "Your authentication token has expired." + suggestion: "Run 'azd auth login' to sign in again." + docUrl: "https://learn.microsoft.com/azure/developer/azure-developer-cli/reference#azd-auth-login" + + # ============================================================================ + # Subscription and Tenant Errors + # ============================================================================ + - patterns: + - "SubscriptionNotFound" + - "InvalidSubscriptionId" + - "regex:(?i)subscription.*not found" + message: "The specified Azure subscription was not found." + suggestion: "Verify the subscription ID is correct and that you have access to it." + docUrl: "https://learn.microsoft.com/azure/azure-resource-manager/troubleshooting/error-not-found" + + - patterns: + - "InvalidTenant" + - "TenantNotFound" + - "regex:(?i)tenant.*not found" + message: "The specified Azure AD tenant was not found." + suggestion: "Check that you're signed into the correct Azure AD tenant with 'azd auth login'." + + # ============================================================================ + # Bicep and ARM Template Errors + # ============================================================================ + - patterns: + - "BCP" + - "regex:BCP\\d{3}" + message: "Your Bicep template has an error." + suggestion: "Review the error message for the specific issue and line number in your .bicep file." + docUrl: "https://learn.microsoft.com/azure/azure-resource-manager/bicep/bicep-error-codes" + + - patterns: + - "InvalidTemplate" + - "regex:(?i)template.*invalid" + - "regex:(?i)invalid.*template" + message: "Your deployment template is invalid." + suggestion: "Validate the template syntax and parameter values." + docUrl: "https://learn.microsoft.com/azure/azure-resource-manager/troubleshooting/common-deployment-errors" + + - patterns: + - "InvalidTemplateDeployment" + - "DeploymentFailed" + message: "The Azure deployment failed." + suggestion: "Check the deployment logs in the Azure portal for detailed error information." + docUrl: "https://learn.microsoft.com/azure/azure-resource-manager/troubleshooting/common-deployment-errors" + + - patterns: + - "MissingRequiredParameter" + - "regex:(?i)parameter.*required" + - "regex:(?i)missing.*parameter" + message: "A required deployment parameter is missing." + suggestion: "Ensure all required parameters are specified in your azure.yaml or environment configuration." + + # ============================================================================ + # Resource Errors + # ============================================================================ + - patterns: + - "ResourceNotFound" + - "regex:(?i)resource.*not found" + - "regex:(?i)could not find.*resource" + message: "The specified Azure resource was not found." + suggestion: "Verify the resource name, type, and resource group are correct." + docUrl: "https://learn.microsoft.com/azure/azure-resource-manager/troubleshooting/error-not-found" + + - patterns: + - "ResourceGroupNotFound" + - "regex:(?i)resource group.*not found" + message: "The resource group does not exist." + suggestion: "Create it with 'az group create' or check the name for typos." + docUrl: "https://learn.microsoft.com/cli/azure/group#az-group-create" + + - patterns: + - "Conflict" + - "ResourceAlreadyExists" + - "regex:(?i)already exists" + message: "A resource with the same name already exists." + suggestion: "Use a different name or delete the existing resource first." + + - patterns: + - "ResourceGroupBeingDeleted" + - "regex:(?i)resource group.*being deleted" + message: "The resource group is currently being deleted." + suggestion: "Wait for deletion to complete or use a different resource group." + + # ============================================================================ + # Network Errors + # ============================================================================ + - patterns: + - "regex:(?i)connection.*refused" + - "regex:(?i)connection.*timed out" + - "ETIMEDOUT" + - "ECONNREFUSED" + message: "A network connection failed." + suggestion: "Check your network connectivity and any firewall or proxy settings." + + - patterns: + - "regex:(?i)dns.*resolution" + - "ENOTFOUND" + - "getaddrinfo" + message: "DNS resolution failed." + suggestion: "Verify the hostname is correct and your DNS settings are configured properly." + + # ============================================================================ + # Container and Docker Errors + # ============================================================================ + - patterns: + - "regex:(?i)docker.*not found" + - "regex:(?i)docker.*is not running" + - "regex:(?i)cannot connect to.*docker" + message: "Docker is not running or not installed." + suggestion: "Start Docker Desktop or install Docker." + docUrl: "https://docs.docker.com/get-docker/" + + - patterns: + - "ImagePullBackOff" + - "ErrImagePull" + - "regex:(?i)failed to pull.*image" + message: "Failed to pull the container image." + suggestion: "Verify the image name, tag, and registry credentials." + + # ============================================================================ + # Azure Container Apps Errors + # ============================================================================ + - patterns: + - "ContainerAppOperationError" + - "regex:(?i)container app.*failed" + message: "Azure Container Apps deployment failed." + suggestion: "Check the container configuration and application logs in the Azure portal." + docUrl: "https://learn.microsoft.com/azure/container-apps/troubleshooting" + + # ============================================================================ + # Azure Kubernetes Service Errors + # ============================================================================ + - patterns: + - "regex:(?i)kubectl.*not found" + message: "kubectl is not installed." + suggestion: "Install kubectl to manage your Kubernetes cluster." + docUrl: "https://kubernetes.io/docs/tasks/tools/" + + - patterns: + - "regex:(?i)kubeconfig.*not found" + - "regex:(?i)no.*cluster.*credentials" + message: "Kubernetes credentials are not configured." + suggestion: "Run 'az aks get-credentials' to configure kubectl for your cluster." + docUrl: "https://learn.microsoft.com/cli/azure/aks#az-aks-get-credentials" + + # ============================================================================ + # Tool and Dependency Errors + # ============================================================================ + - patterns: + - "regex:(?i)az.*not found" + - "regex:(?i)azure cli.*not installed" + message: "Azure CLI is not installed." + suggestion: "Install Azure CLI to continue." + docUrl: "https://learn.microsoft.com/cli/azure/install-azure-cli" + + - patterns: + - "regex:(?i)terraform.*not found" + message: "Terraform is not installed." + suggestion: "Install Terraform to use Terraform-based infrastructure." + docUrl: "https://developer.hashicorp.com/terraform/install" + + - patterns: + - "regex:(?i)node.*not found" + - "regex:(?i)npm.*not found" + message: "Node.js is not installed." + suggestion: "Install Node.js to build and run JavaScript/TypeScript applications." + docUrl: "https://nodejs.org/en/download/" + + - patterns: + - "regex:(?i)python.*not found" + - "regex:(?i)pip.*not found" + message: "Python is not installed." + suggestion: "Install Python to build and run Python applications." + docUrl: "https://www.python.org/downloads/" + + - patterns: + - "regex:(?i)dotnet.*not found" + message: ".NET SDK is not installed." + suggestion: "Install .NET SDK to build and run .NET applications." + docUrl: "https://dotnet.microsoft.com/download" + + - patterns: + - "regex:(?i)java.*not found" + - "regex:(?i)maven.*not found" + - "regex:(?i)gradle.*not found" + message: "Java or build tools are not installed." + suggestion: "Install JDK and Maven/Gradle for Java development." + docUrl: "https://learn.microsoft.com/java/openjdk/download" diff --git a/cli/azd/resources/resources.go b/cli/azd/resources/resources.go index c3b7ef201f4..7e3ea8544fa 100644 --- a/cli/azd/resources/resources.go +++ b/cli/azd/resources/resources.go @@ -36,3 +36,6 @@ var AiPythonApp embed.FS //go:embed pipeline/* var PipelineFiles embed.FS + +//go:embed error_suggestions.yaml +var ErrorSuggestions []byte