diff --git a/cli/azd/cmd/extensions.go b/cli/azd/cmd/extensions.go index e2956b0177f..9184cca1368 100644 --- a/cli/azd/cmd/extensions.go +++ b/cli/azd/cmd/extensions.go @@ -249,6 +249,16 @@ func (a *extensionAction) Run(ctx context.Context) (*actions.ActionResult, error // Update warning is shown via defer above (runs after invoke completes) if invokeErr != nil { + // Check if the extension reported a structured error via gRPC. + // This gives us a typed LocalError/ServiceError for telemetry classification + // instead of just a generic exit-code error. + if reportedErr := extension.GetReportedError(); reportedErr != nil { + // Wrap both errors so the chain contains both: + // - reportedErr (LocalError/ServiceError) for telemetry classification + // - invokeErr (ExtensionRunError) for UX middleware handling + return nil, fmt.Errorf("%w: %w", reportedErr, invokeErr) + } + return nil, invokeErr } diff --git a/cli/azd/cmd/extensions_error_transport_test.go b/cli/azd/cmd/extensions_error_transport_test.go new file mode 100644 index 00000000000..71be22c80da --- /dev/null +++ b/cli/azd/cmd/extensions_error_transport_test.go @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "errors" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/stretchr/testify/require" +) + +func TestExtensionReportedError(t *testing.T) { + t.Run("NoErrorReported", func(t *testing.T) { + ext := &extensions.Extension{Id: "test.ext"} + require.Nil(t, ext.GetReportedError()) + }) + + t.Run("LocalErrorReported", func(t *testing.T) { + ext := &extensions.Extension{Id: "test.ext"} + localErr := &azdext.LocalError{ + Message: "invalid config", + Code: "invalid_config", + Category: azdext.LocalErrorCategoryValidation, + Suggestion: "Fix the config file", + } + ext.SetReportedError(localErr) + + reported := ext.GetReportedError() + require.NotNil(t, reported) + + var gotLocal *azdext.LocalError + require.True(t, errors.As(reported, &gotLocal)) + require.Equal(t, "invalid_config", gotLocal.Code) + require.Equal(t, azdext.LocalErrorCategoryValidation, gotLocal.Category) + require.Equal(t, "Fix the config file", gotLocal.Suggestion) + }) + + t.Run("ServiceErrorReported", func(t *testing.T) { + ext := &extensions.Extension{Id: "test.ext"} + svcErr := &azdext.ServiceError{ + Message: "not found", + ErrorCode: "NotFound", + StatusCode: 404, + ServiceName: "test.service.com", + } + ext.SetReportedError(svcErr) + + reported := ext.GetReportedError() + require.NotNil(t, reported) + + var gotSvc *azdext.ServiceError + require.True(t, errors.As(reported, &gotSvc)) + require.Equal(t, "NotFound", gotSvc.ErrorCode) + require.Equal(t, 404, gotSvc.StatusCode) + }) +} diff --git a/cli/azd/cmd/middleware/ux.go b/cli/azd/cmd/middleware/ux.go index 31c24c4470d..793ad969fe3 100644 --- a/cli/azd/cmd/middleware/ux.go +++ b/cli/azd/cmd/middleware/ux.go @@ -11,6 +11,7 @@ import ( "github.com/azure/azure-dev/cli/azd/cmd/actions" "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/azdext" "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" @@ -49,12 +50,10 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes // 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) + // Use ErrorWithSuggestion for errors with suggestions (better UX). + // This catches errors wrapped by the error pipeline's YAML rules + // or other host code that already created an ErrorWithSuggestion. if errors.As(err, &suggestionErr) { displayErr := &ux.ErrorWithSuggestion{ Err: suggestionErr.Err, @@ -62,10 +61,36 @@ func (m *UxMiddleware) Run(ctx context.Context, next NextFn) (*actions.ActionRes Suggestion: suggestionErr.Suggestion, Links: suggestionErr.Links, } + m.console.Message(ctx, "") m.console.MessageUxItem(ctx, displayErr) return actionResult, err } + // Bridge extension errors (LocalError/ServiceError) with suggestions to rich UX. + // Covers both CLI extension commands and gRPC service target errors. + if suggestion := azdext.ErrorSuggestion(err); suggestion != "" { + message := azdext.ErrorMessage(err) + if message == "" { + message = err.Error() + } + displayErr := &ux.ErrorWithSuggestion{ + Message: message, + Suggestion: suggestion, + } + m.console.Message(ctx, "") + m.console.MessageUxItem(ctx, displayErr) + return actionResult, err + } + + // ExtensionRunError without suggestion + var extensionRunErr *extensions.ExtensionRunError + if errors.As(err, &extensionRunErr) { + if message := azdext.ErrorMessage(err); message != "" { + m.console.Message(ctx, output.WithErrorFormat("\nERROR: %s", message)) + } + return actionResult, err + } + // Build error message for errors without suggestions errorMessage := &strings.Builder{} errorMessage.WriteString(output.WithErrorFormat("\nERROR: %s", err.Error())) diff --git a/cli/azd/docs/extensions/extension-framework.md b/cli/azd/docs/extensions/extension-framework.md index be08a031c90..9b8369e7eb9 100644 --- a/cli/azd/docs/extensions/extension-framework.md +++ b/cli/azd/docs/extensions/extension-framework.md @@ -1124,6 +1124,68 @@ To enable interaction with `azd` from within the extension, the extension must l The gRPC client must also include an `authorization` parameter with the value from `AZD_ACCESS_TOKEN`; otherwise, requests will fail due to invalid authorization. Extensions must declare their supported capabilities within the extension registry otherwise certain service operations may fail with a permission denied error. +### Structured Error Reporting (Custom Commands) + +For custom command execution, extensions can provide richer telemetry by reporting a structured error to +the azd host via the `ExtensionService.ReportError` gRPC call before exiting with a non-zero status code. + +For Go extensions, the recommended approach is to use `azdext.Run`, which handles context creation, structured error +reporting, suggestion rendering, and `os.Exit` automatically: + +```go +func main() { + azdext.Run(cmd.NewRootCommand()) +} +``` + +Alternatively, you can use `azdext.ReportError` directly for lower-level control: + +```go +func main() { + ctx := azdext.NewContext() + ctx = azdext.WithAccessToken(ctx) + rootCmd := cmd.NewRootCommand() + + if err := rootCmd.ExecuteContext(ctx); err != nil { + _ = azdext.ReportError(ctx, err) + os.Exit(1) + } +} +``` + +Use typed extension errors to control telemetry mapping: + +```go +return &azdext.LocalError{ + Message: "invalid configuration: missing required field 'name'", + Code: "invalid_config", + Category: "validation", + Suggestion: "Ensure your azure.yaml has a 'name' field defined.", +} +``` + +```go +return &azdext.ServiceError{ + Message: "request failed", + ErrorCode: "TooManyRequests", + StatusCode: 429, + ServiceName: "openai.azure.com", + Suggestion: "Wait a moment and retry the request, or increase your rate limit.", +} +``` + +Current telemetry result code conventions: + +- Service errors: `ext.service..` +- Local domain categories: + - `validation` -> `ext.validation.` + - `auth` -> `ext.auth.` + - `dependency` -> `ext.dependency.` + - `compatibility` -> `ext.compatibility.` + - `user` -> `ext.user.` + - `internal` -> `ext.internal.` +- Unknown local categories fall back to `ext.local.`. + #### Go For extensions built using Go, the `azdext` package provides an `AzdClient` which acts as the gRPC client. diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/gh_url_parse.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/gh_url_parse.go index e69e4232c47..1ac9b9d35d6 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/gh_url_parse.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/gh_url_parse.go @@ -5,6 +5,8 @@ package cmd import ( "fmt" + "net/url" + "strings" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" @@ -34,6 +36,26 @@ including blob, tree, raw, and API URLs. Handles branch names containing slashes // Get the GitHub URL from the first argument githubUrl := args[0] + parsed, parseErr := url.ParseRequestURI(githubUrl) + if parseErr != nil || parsed == nil { + return &azdext.LocalError{ + Message: "invalid GitHub URL: expected an absolute URL", + Code: "invalid_github_url", + Category: azdext.LocalErrorCategoryValidation, + Suggestion: "Use a full URL like https://github.com/Azure/azure-dev", + } + } + + scheme := strings.ToLower(parsed.Scheme) + if scheme != "http" && scheme != "https" { + return &azdext.LocalError{ + Message: "invalid GitHub URL scheme: supported schemes are http and https", + Code: "unsupported_url_scheme", + Category: azdext.LocalErrorCategoryValidation, + Suggestion: "Change the URL to start with https://", + } + } + // Call the ParseGitHubUrl RPC method response, err := azdClient.Project().ParseGitHubUrl(ctx, &azdext.ParseGitHubUrlRequest{ Url: githubUrl, diff --git a/cli/azd/extensions/microsoft.azd.demo/main.go b/cli/azd/extensions/microsoft.azd.demo/main.go index b013bd28a0b..eb4598919c0 100644 --- a/cli/azd/extensions/microsoft.azd.demo/main.go +++ b/cli/azd/extensions/microsoft.azd.demo/main.go @@ -4,27 +4,10 @@ package main import ( - "context" - "os" - "github.com/azure/azure-dev/cli/azd/extensions/microsoft.azd.demo/internal/cmd" - "github.com/fatih/color" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -func init() { - forceColorVal, has := os.LookupEnv("FORCE_COLOR") - if has && forceColorVal == "1" { - color.NoColor = false - } -} - func main() { - // Execute the root command - ctx := context.Background() - rootCmd := cmd.NewRootCommand() - - if err := rootCmd.ExecuteContext(ctx); err != nil { - color.Red("Error: %v", err) - os.Exit(1) - } + azdext.Run(cmd.NewRootCommand()) } diff --git a/cli/azd/grpc/proto/errors.proto b/cli/azd/grpc/proto/errors.proto index 7ba02f4eb43..cd7bb5cff4b 100644 --- a/cli/azd/grpc/proto/errors.proto +++ b/cli/azd/grpc/proto/errors.proto @@ -1,39 +1,47 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -syntax = "proto3"; - -package azdext; - -option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; - -// ErrorOrigin indicates where an error originated from. -// This helps with telemetry categorization and error handling strategies. -enum ErrorOrigin { - ERROR_ORIGIN_UNSPECIFIED = 0; // Default/unknown origin - ERROR_ORIGIN_LOCAL = 1; // Local errors: config, filesystem, auth state, validation, etc. - ERROR_ORIGIN_SERVICE = 2; // HTTP/gRPC upstream service errors - ERROR_ORIGIN_TOOL = 3; // Subprocess, plugin, or external tool errors -} - -// ServiceErrorDetail contains structured error information from an HTTP/gRPC service. -// Used when ErrorOrigin is ERROR_ORIGIN_SERVICE. -message ServiceErrorDetail { - string error_code = 1; // Error code from the service (e.g., "Conflict", "NotFound", "RateLimitExceeded") - int32 status_code = 2; // HTTP status code (e.g., 409, 404, 500) - string service_name = 3; // Service host/name for telemetry (e.g., "ai.azure.com", "management.azure.com") -} - -// ExtensionError is a unified error message that can represent errors from different sources. -// It provides structured error information for telemetry and error handling. -message ExtensionError { - string message = 2; // Human-readable error message - string details = 3; // Additional error details - ErrorOrigin origin = 4; // Where the error originated from - - // Source-specific structured details. Only one should be set based on origin. - oneof source { - ServiceErrorDetail service_error = 10; - // ToolErrorDetail tool_error = 11; // Reserved for future use - // LocalErrorDetail local_error = 12; // Reserved for future use - } -} +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +syntax = "proto3"; + +package azdext; + +option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; + +// ErrorOrigin indicates where an error originated from. +// This helps with telemetry categorization and error handling strategies. +enum ErrorOrigin { + ERROR_ORIGIN_UNSPECIFIED = 0; // Default/unknown origin + ERROR_ORIGIN_LOCAL = 1; // Local errors: config, filesystem, auth state, validation, etc. + ERROR_ORIGIN_SERVICE = 2; // HTTP/gRPC upstream service errors + ERROR_ORIGIN_TOOL = 3; // Subprocess, plugin, or external tool errors +} + +// ServiceErrorDetail contains structured error information from an HTTP/gRPC service. +// Used when ErrorOrigin is ERROR_ORIGIN_SERVICE. +message ServiceErrorDetail { + string error_code = 1; // Error code from the service (e.g., "Conflict", "NotFound", "RateLimitExceeded") + int32 status_code = 2; // HTTP status code (e.g., 409, 404, 500) + string service_name = 3; // Service host/name for telemetry (e.g., "ai.azure.com", "management.azure.com") +} + +// LocalErrorDetail contains structured error information for local/validation failures. +// Used when ErrorOrigin is ERROR_ORIGIN_LOCAL. +message LocalErrorDetail { + string code = 1; // Extension-defined error code (e.g., "invalid_config") + string category = 2; // Error category (e.g., "user", "validation", "dependency", "internal") +} + +// ExtensionError is a unified error message that can represent errors from different sources. +// It provides structured error information for telemetry and error handling. +message ExtensionError { + reserved 1, 3; // Reserved for future use; do not reuse. + string message = 2; // Human-readable error message + ErrorOrigin origin = 4; // Where the error originated from + string suggestion = 5; // Optional user-facing suggestion for resolving the error + + // Source-specific structured details. Only one should be set based on origin. + oneof source { + ServiceErrorDetail service_error = 10; + LocalErrorDetail local_error = 11; + // ToolErrorDetail tool_error = 12; + } +} diff --git a/cli/azd/grpc/proto/extension.proto b/cli/azd/grpc/proto/extension.proto index 3542e661c18..c0265091170 100644 --- a/cli/azd/grpc/proto/extension.proto +++ b/cli/azd/grpc/proto/extension.proto @@ -1,17 +1,29 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. -syntax = "proto3"; - -package azdext; - -option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; - -// Top-level service for extension registration and lifecycle -service ExtensionService { - // Signal that the extension is done registering all capabilities - rpc Ready(ReadyRequest) returns (ReadyResponse); -} - -message ReadyRequest {} - -message ReadyResponse {} +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +syntax = "proto3"; + +package azdext; + +option go_package = "github.com/azure/azure-dev/cli/azd/pkg/azdext"; + +import "errors.proto"; + +// Top-level service for extension registration and lifecycle +service ExtensionService { + // Signal that the extension is done registering all capabilities + rpc Ready(ReadyRequest) returns (ReadyResponse); + + // Report a structured error from the extension back to the host. + // Called by the extension SDK before the process exits on failure. + rpc ReportError(ReportErrorRequest) returns (ReportErrorResponse); +} + +message ReadyRequest {} + +message ReadyResponse {} + +message ReportErrorRequest { + ExtensionError error = 1; +} + +message ReportErrorResponse {} diff --git a/cli/azd/internal/cmd/errors.go b/cli/azd/internal/cmd/errors.go index 1b5f20b1238..fa71179a988 100644 --- a/cli/azd/internal/cmd/errors.go +++ b/cli/azd/internal/cmd/errors.go @@ -14,6 +14,7 @@ import ( "net" "path/filepath" "reflect" + "regexp" "strings" "github.com/AlecAivazis/survey/v2/terminal" @@ -41,6 +42,7 @@ func MapError(err error, span tracing.Span) { var armDeployErr *azapi.AzureDeploymentError var authFailedErr *auth.AuthFailedError var extServiceErr *azdext.ServiceError + var extLocalErr *azdext.LocalError // external tool errors var toolExecErr *exec.ExitError @@ -112,8 +114,6 @@ func MapError(err error, span tracing.Span) { operation = "deployment" } errCode = fmt.Sprintf("service.arm.%s.failed", operation) - } else if errors.As(err, &extensionRunErr) { - errCode = "ext.run.failed" } else if errors.As(err, &extServiceErr) { // Handle structured service errors from extensions if extServiceErr.StatusCode > 0 && extServiceErr.ServiceName != "" { @@ -130,6 +130,18 @@ func MapError(err error, span tracing.Span) { } else { errCode = "ext.service.failed" } + } else if errors.As(err, &extLocalErr) { + domain := string(azdext.NormalizeLocalErrorCategory(extLocalErr.Category)) + code := normalizeCodeSegment(extLocalErr.Code, "failed") + + errDetails = append(errDetails, + fields.ErrCategory.String(domain), + fields.ErrCode.String(code), + ) + + errCode = fmt.Sprintf("ext.%s.%s", domain, code) + } else if errors.As(err, &extensionRunErr) { + errCode = "ext.run.failed" } else if errors.As(err, &toolExecErr) { toolName := "other" cmdName := cmdAsName(toolExecErr.Cmd) @@ -323,3 +335,24 @@ func cmdAsName(cmd string) string { return strings.ToLower(cmd) } + +var ( + codeSegmentRegex = regexp.MustCompile(`[^a-z0-9_]+`) + codeSegmentReplacer = strings.NewReplacer("-", "_", ".", "_") +) + +func normalizeCodeSegment(value string, fallback string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return fallback + } + + value = codeSegmentReplacer.Replace(value) + value = codeSegmentRegex.ReplaceAllString(value, "_") + value = strings.Trim(value, "_") + if value == "" { + return fallback + } + + return value +} diff --git a/cli/azd/internal/cmd/errors_test.go b/cli/azd/internal/cmd/errors_test.go index 95438bf7075..2d18aaa46b8 100644 --- a/cli/azd/internal/cmd/errors_test.go +++ b/cli/azd/internal/cmd/errors_test.go @@ -190,7 +190,6 @@ func Test_MapError(t *testing.T) { name: "WithExtServiceError", err: &azdext.ServiceError{ Message: "Rate limit exceeded", - Details: "Too many requests", ErrorCode: "RateLimitExceeded", StatusCode: 429, ServiceName: "openai.azure.com", @@ -248,6 +247,45 @@ func Test_MapError(t *testing.T) { fields.ErrType.String("*errors.errorString"), }, }, + { + name: "WithExtLocalError", + err: &azdext.LocalError{ + Message: "invalid manifest", + Code: "Invalid-Config", + Category: azdext.LocalErrorCategoryValidation, + }, + wantErrReason: "ext.validation.invalid_config", + wantErrDetails: []attribute.KeyValue{ + fields.ErrorKey(fields.ErrCategory.Key).String("validation"), + fields.ErrorKey(fields.ErrCode.Key).String("invalid_config"), + }, + }, + { + name: "WithExtLocalErrorUnknownCategory", + err: &azdext.LocalError{ + Message: "some local failure", + Code: "Something-Bad", + Category: azdext.LocalErrorCategory("custom"), + }, + wantErrReason: "ext.local.something_bad", + wantErrDetails: []attribute.KeyValue{ + fields.ErrorKey(fields.ErrCategory.Key).String("local"), + fields.ErrorKey(fields.ErrCode.Key).String("something_bad"), + }, + }, + { + name: "WithExtLocalErrorAuthDomain", + err: &azdext.LocalError{ + Message: "token expired", + Code: "token_expired", + Category: azdext.LocalErrorCategoryAuth, + }, + wantErrReason: "ext.auth.token_expired", + wantErrDetails: []attribute.KeyValue{ + fields.ErrorKey(fields.ErrCategory.Key).String("auth"), + fields.ErrorKey(fields.ErrCode.Key).String("token_expired"), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cli/azd/internal/grpcserver/extension_service.go b/cli/azd/internal/grpcserver/extension_service.go index 77b503ebabc..1d4f2c54dc9 100644 --- a/cli/azd/internal/grpcserver/extension_service.go +++ b/cli/azd/internal/grpcserver/extension_service.go @@ -50,3 +50,27 @@ func (s *ExtensionService) Ready(ctx context.Context, req *azdext.ReadyRequest) return &azdext.ReadyResponse{}, nil } + +// ReportError receives a structured error from the extension and stores it +// so the host can retrieve it after the extension process exits. +func (s *ExtensionService) ReportError( + ctx context.Context, req *azdext.ReportErrorRequest, +) (*azdext.ReportErrorResponse, error) { + extensionClaims, err := extensions.GetClaimsFromContext(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get extension claims: %w", err) + } + + extension, err := s.extensionManager.GetInstalled(extensions.FilterOptions{ + Id: extensionClaims.Subject, + }) + if err != nil { + return nil, status.Errorf(codes.FailedPrecondition, "failed to get extension: %s", err.Error()) + } + + if req.GetError() != nil { + extension.SetReportedError(azdext.UnwrapError(req.GetError())) + } + + return &azdext.ReportErrorResponse{}, nil +} diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 32b6d039840..19673db2367 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -319,6 +319,13 @@ const ServiceNameAzd = "azd" // Error related fields var ( + // Error category that classifies an error. + ErrCategory = AttributeKey{ + Key: attribute.Key("error.category"), + Classification: SystemMetadata, + Purpose: PerformanceAndHealth, + } + // Error code that describes an error. ErrCode = AttributeKey{ Key: attribute.Key("error.code"), diff --git a/cli/azd/internal/tracing/fields/key.go b/cli/azd/internal/tracing/fields/key.go index 2a318afab5c..c376c7fa3d6 100644 --- a/cli/azd/internal/tracing/fields/key.go +++ b/cli/azd/internal/tracing/fields/key.go @@ -45,7 +45,11 @@ func Sha256Hash(val string) string { return hash } -// ErrorKey returns a new Key with "error." prefix appended. +// ErrorKey returns a new Key with "error." prefix prepended. +// Keys that already have the "error." prefix are returned as-is. func ErrorKey(k attribute.Key) attribute.Key { + if strings.HasPrefix(string(k), "error.") { + return k + } return attribute.Key("error." + string(k)) } diff --git a/cli/azd/internal/tracing/fields/key_test.go b/cli/azd/internal/tracing/fields/key_test.go new file mode 100644 index 00000000000..fa36368f3ee --- /dev/null +++ b/cli/azd/internal/tracing/fields/key_test.go @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package fields + +import ( + "testing" + + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" +) + +func TestErrorKey(t *testing.T) { + tests := []struct { + name string + key attribute.Key + want attribute.Key + }{ + {"PrependsPrefixToNonErrorKey", "service.name", "error.service.name"}, + {"SkipsPrefixForErrorKey", "error.code", "error.code"}, + {"SkipsPrefixForNestedErrorKey", "error.category", "error.category"}, + {"PrependsPrefixToEmptyKey", "", "error."}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.Equal(t, tt.want, ErrorKey(tt.key)) + }) + } +} diff --git a/cli/azd/pkg/azdext/azd_client.go b/cli/azd/pkg/azdext/azd_client.go index d42a2608536..22557bd687f 100644 --- a/cli/azd/pkg/azdext/azd_client.go +++ b/cli/azd/pkg/azdext/azd_client.go @@ -174,7 +174,7 @@ func (c *AzdClient) Container() ContainerServiceClient { } // Extension returns the extension service client. -func (c *AzdClient) extensionService() ExtensionServiceClient { +func (c *AzdClient) Extension() ExtensionServiceClient { if c.extensionClient == nil { c.extensionClient = NewExtensionServiceClient(c.connection) } diff --git a/cli/azd/pkg/azdext/errors.pb.go b/cli/azd/pkg/azdext/errors.pb.go index ce3e177a5ab..73e58b9b090 100644 --- a/cli/azd/pkg/azdext/errors.pb.go +++ b/cli/azd/pkg/azdext/errors.pb.go @@ -140,18 +140,73 @@ func (x *ServiceErrorDetail) GetServiceName() string { return "" } +// LocalErrorDetail contains structured error information for local/validation failures. +// Used when ErrorOrigin is ERROR_ORIGIN_LOCAL. +type LocalErrorDetail struct { + state protoimpl.MessageState `protogen:"open.v1"` + Code string `protobuf:"bytes,1,opt,name=code,proto3" json:"code,omitempty"` // Extension-defined error code (e.g., "invalid_config") + Category string `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"` // Error category (e.g., "user", "validation", "dependency", "internal") + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LocalErrorDetail) Reset() { + *x = LocalErrorDetail{} + mi := &file_errors_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LocalErrorDetail) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LocalErrorDetail) ProtoMessage() {} + +func (x *LocalErrorDetail) ProtoReflect() protoreflect.Message { + mi := &file_errors_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LocalErrorDetail.ProtoReflect.Descriptor instead. +func (*LocalErrorDetail) Descriptor() ([]byte, []int) { + return file_errors_proto_rawDescGZIP(), []int{1} +} + +func (x *LocalErrorDetail) GetCode() string { + if x != nil { + return x.Code + } + return "" +} + +func (x *LocalErrorDetail) GetCategory() string { + if x != nil { + return x.Category + } + return "" +} + // ExtensionError is a unified error message that can represent errors from different sources. // It provides structured error information for telemetry and error handling. type ExtensionError struct { - state protoimpl.MessageState `protogen:"open.v1"` - Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // Human-readable error message - Details string `protobuf:"bytes,3,opt,name=details,proto3" json:"details,omitempty"` // Additional error details - Origin ErrorOrigin `protobuf:"varint,4,opt,name=origin,proto3,enum=azdext.ErrorOrigin" json:"origin,omitempty"` // Where the error originated from + state protoimpl.MessageState `protogen:"open.v1"` + Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` // Human-readable error message + Origin ErrorOrigin `protobuf:"varint,4,opt,name=origin,proto3,enum=azdext.ErrorOrigin" json:"origin,omitempty"` // Where the error originated from + Suggestion string `protobuf:"bytes,5,opt,name=suggestion,proto3" json:"suggestion,omitempty"` // Optional user-facing suggestion for resolving the error // Source-specific structured details. Only one should be set based on origin. // // Types that are valid to be assigned to Source: // // *ExtensionError_ServiceError + // *ExtensionError_LocalError Source isExtensionError_Source `protobuf_oneof:"source"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -159,7 +214,7 @@ type ExtensionError struct { func (x *ExtensionError) Reset() { *x = ExtensionError{} - mi := &file_errors_proto_msgTypes[1] + mi := &file_errors_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -171,7 +226,7 @@ func (x *ExtensionError) String() string { func (*ExtensionError) ProtoMessage() {} func (x *ExtensionError) ProtoReflect() protoreflect.Message { - mi := &file_errors_proto_msgTypes[1] + mi := &file_errors_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -184,7 +239,7 @@ func (x *ExtensionError) ProtoReflect() protoreflect.Message { // Deprecated: Use ExtensionError.ProtoReflect.Descriptor instead. func (*ExtensionError) Descriptor() ([]byte, []int) { - return file_errors_proto_rawDescGZIP(), []int{1} + return file_errors_proto_rawDescGZIP(), []int{2} } func (x *ExtensionError) GetMessage() string { @@ -194,18 +249,18 @@ func (x *ExtensionError) GetMessage() string { return "" } -func (x *ExtensionError) GetDetails() string { +func (x *ExtensionError) GetOrigin() ErrorOrigin { if x != nil { - return x.Details + return x.Origin } - return "" + return ErrorOrigin_ERROR_ORIGIN_UNSPECIFIED } -func (x *ExtensionError) GetOrigin() ErrorOrigin { +func (x *ExtensionError) GetSuggestion() string { if x != nil { - return x.Origin + return x.Suggestion } - return ErrorOrigin_ERROR_ORIGIN_UNSPECIFIED + return "" } func (x *ExtensionError) GetSource() isExtensionError_Source { @@ -224,6 +279,15 @@ func (x *ExtensionError) GetServiceError() *ServiceErrorDetail { return nil } +func (x *ExtensionError) GetLocalError() *LocalErrorDetail { + if x != nil { + if x, ok := x.Source.(*ExtensionError_LocalError); ok { + return x.LocalError + } + } + return nil +} + type isExtensionError_Source interface { isExtensionError_Source() } @@ -232,8 +296,14 @@ type ExtensionError_ServiceError struct { ServiceError *ServiceErrorDetail `protobuf:"bytes,10,opt,name=service_error,json=serviceError,proto3,oneof"` } +type ExtensionError_LocalError struct { + LocalError *LocalErrorDetail `protobuf:"bytes,11,opt,name=local_error,json=localError,proto3,oneof"` // ToolErrorDetail tool_error = 12; +} + func (*ExtensionError_ServiceError) isExtensionError_Source() {} +func (*ExtensionError_LocalError) isExtensionError_Source() {} + var File_errors_proto protoreflect.FileDescriptor const file_errors_proto_rawDesc = "" + @@ -244,14 +314,21 @@ const file_errors_proto_rawDesc = "" + "error_code\x18\x01 \x01(\tR\terrorCode\x12\x1f\n" + "\vstatus_code\x18\x02 \x01(\x05R\n" + "statusCode\x12!\n" + - "\fservice_name\x18\x03 \x01(\tR\vserviceName\"\xbe\x01\n" + + "\fservice_name\x18\x03 \x01(\tR\vserviceName\"B\n" + + "\x10LocalErrorDetail\x12\x12\n" + + "\x04code\x18\x01 \x01(\tR\x04code\x12\x1a\n" + + "\bcategory\x18\x02 \x01(\tR\bcategory\"\x8d\x02\n" + "\x0eExtensionError\x12\x18\n" + - "\amessage\x18\x02 \x01(\tR\amessage\x12\x18\n" + - "\adetails\x18\x03 \x01(\tR\adetails\x12+\n" + - "\x06origin\x18\x04 \x01(\x0e2\x13.azdext.ErrorOriginR\x06origin\x12A\n" + + "\amessage\x18\x02 \x01(\tR\amessage\x12+\n" + + "\x06origin\x18\x04 \x01(\x0e2\x13.azdext.ErrorOriginR\x06origin\x12\x1e\n" + + "\n" + + "suggestion\x18\x05 \x01(\tR\n" + + "suggestion\x12A\n" + "\rservice_error\x18\n" + - " \x01(\v2\x1a.azdext.ServiceErrorDetailH\x00R\fserviceErrorB\b\n" + - "\x06source*t\n" + + " \x01(\v2\x1a.azdext.ServiceErrorDetailH\x00R\fserviceError\x12;\n" + + "\vlocal_error\x18\v \x01(\v2\x18.azdext.LocalErrorDetailH\x00R\n" + + "localErrorB\b\n" + + "\x06sourceJ\x04\b\x01\x10\x02J\x04\b\x03\x10\x04*t\n" + "\vErrorOrigin\x12\x1c\n" + "\x18ERROR_ORIGIN_UNSPECIFIED\x10\x00\x12\x16\n" + "\x12ERROR_ORIGIN_LOCAL\x10\x01\x12\x18\n" + @@ -271,20 +348,22 @@ func file_errors_proto_rawDescGZIP() []byte { } var file_errors_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_errors_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_errors_proto_goTypes = []any{ (ErrorOrigin)(0), // 0: azdext.ErrorOrigin (*ServiceErrorDetail)(nil), // 1: azdext.ServiceErrorDetail - (*ExtensionError)(nil), // 2: azdext.ExtensionError + (*LocalErrorDetail)(nil), // 2: azdext.LocalErrorDetail + (*ExtensionError)(nil), // 3: azdext.ExtensionError } var file_errors_proto_depIdxs = []int32{ 0, // 0: azdext.ExtensionError.origin:type_name -> azdext.ErrorOrigin 1, // 1: azdext.ExtensionError.service_error:type_name -> azdext.ServiceErrorDetail - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 2, // 2: azdext.ExtensionError.local_error:type_name -> azdext.LocalErrorDetail + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name } func init() { file_errors_proto_init() } @@ -292,8 +371,9 @@ func file_errors_proto_init() { if File_errors_proto != nil { return } - file_errors_proto_msgTypes[1].OneofWrappers = []any{ + file_errors_proto_msgTypes[2].OneofWrappers = []any{ (*ExtensionError_ServiceError)(nil), + (*ExtensionError_LocalError)(nil), } type x struct{} out := protoimpl.TypeBuilder{ @@ -301,7 +381,7 @@ func file_errors_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_errors_proto_rawDesc), len(file_errors_proto_rawDesc)), NumEnums: 1, - NumMessages: 2, + NumMessages: 3, NumExtensions: 0, NumServices: 0, }, diff --git a/cli/azd/pkg/azdext/extension.pb.go b/cli/azd/pkg/azdext/extension.pb.go index 69494a2cff9..9ba2c933f0d 100644 --- a/cli/azd/pkg/azdext/extension.pb.go +++ b/cli/azd/pkg/azdext/extension.pb.go @@ -96,15 +96,99 @@ func (*ReadyResponse) Descriptor() ([]byte, []int) { return file_extension_proto_rawDescGZIP(), []int{1} } +type ReportErrorRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Error *ExtensionError `protobuf:"bytes,1,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportErrorRequest) Reset() { + *x = ReportErrorRequest{} + mi := &file_extension_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportErrorRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportErrorRequest) ProtoMessage() {} + +func (x *ReportErrorRequest) ProtoReflect() protoreflect.Message { + mi := &file_extension_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportErrorRequest.ProtoReflect.Descriptor instead. +func (*ReportErrorRequest) Descriptor() ([]byte, []int) { + return file_extension_proto_rawDescGZIP(), []int{2} +} + +func (x *ReportErrorRequest) GetError() *ExtensionError { + if x != nil { + return x.Error + } + return nil +} + +type ReportErrorResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ReportErrorResponse) Reset() { + *x = ReportErrorResponse{} + mi := &file_extension_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ReportErrorResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ReportErrorResponse) ProtoMessage() {} + +func (x *ReportErrorResponse) ProtoReflect() protoreflect.Message { + mi := &file_extension_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ReportErrorResponse.ProtoReflect.Descriptor instead. +func (*ReportErrorResponse) Descriptor() ([]byte, []int) { + return file_extension_proto_rawDescGZIP(), []int{3} +} + var File_extension_proto protoreflect.FileDescriptor const file_extension_proto_rawDesc = "" + "\n" + - "\x0fextension.proto\x12\x06azdext\"\x0e\n" + + "\x0fextension.proto\x12\x06azdext\x1a\ferrors.proto\"\x0e\n" + "\fReadyRequest\"\x0f\n" + - "\rReadyResponse2H\n" + + "\rReadyResponse\"B\n" + + "\x12ReportErrorRequest\x12,\n" + + "\x05error\x18\x01 \x01(\v2\x16.azdext.ExtensionErrorR\x05error\"\x15\n" + + "\x13ReportErrorResponse2\x90\x01\n" + "\x10ExtensionService\x124\n" + - "\x05Ready\x12\x14.azdext.ReadyRequest\x1a\x15.azdext.ReadyResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" + "\x05Ready\x12\x14.azdext.ReadyRequest\x1a\x15.azdext.ReadyResponse\x12F\n" + + "\vReportError\x12\x1a.azdext.ReportErrorRequest\x1a\x1b.azdext.ReportErrorResponseB/Z-github.com/azure/azure-dev/cli/azd/pkg/azdextb\x06proto3" var ( file_extension_proto_rawDescOnce sync.Once @@ -118,19 +202,25 @@ func file_extension_proto_rawDescGZIP() []byte { return file_extension_proto_rawDescData } -var file_extension_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_extension_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_extension_proto_goTypes = []any{ - (*ReadyRequest)(nil), // 0: azdext.ReadyRequest - (*ReadyResponse)(nil), // 1: azdext.ReadyResponse + (*ReadyRequest)(nil), // 0: azdext.ReadyRequest + (*ReadyResponse)(nil), // 1: azdext.ReadyResponse + (*ReportErrorRequest)(nil), // 2: azdext.ReportErrorRequest + (*ReportErrorResponse)(nil), // 3: azdext.ReportErrorResponse + (*ExtensionError)(nil), // 4: azdext.ExtensionError } var file_extension_proto_depIdxs = []int32{ - 0, // 0: azdext.ExtensionService.Ready:input_type -> azdext.ReadyRequest - 1, // 1: azdext.ExtensionService.Ready:output_type -> azdext.ReadyResponse - 1, // [1:2] is the sub-list for method output_type - 0, // [0:1] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 4, // 0: azdext.ReportErrorRequest.error:type_name -> azdext.ExtensionError + 0, // 1: azdext.ExtensionService.Ready:input_type -> azdext.ReadyRequest + 2, // 2: azdext.ExtensionService.ReportError:input_type -> azdext.ReportErrorRequest + 1, // 3: azdext.ExtensionService.Ready:output_type -> azdext.ReadyResponse + 3, // 4: azdext.ExtensionService.ReportError:output_type -> azdext.ReportErrorResponse + 3, // [3:5] is the sub-list for method output_type + 1, // [1:3] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_extension_proto_init() } @@ -138,13 +228,14 @@ func file_extension_proto_init() { if File_extension_proto != nil { return } + file_errors_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_extension_proto_rawDesc), len(file_extension_proto_rawDesc)), NumEnums: 0, - NumMessages: 2, + NumMessages: 4, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/azd/pkg/azdext/extension_error.go b/cli/azd/pkg/azdext/extension_error.go index 28b45eef2fa..5c61a00ea5b 100644 --- a/cli/azd/pkg/azdext/extension_error.go +++ b/cli/azd/pkg/azdext/extension_error.go @@ -5,7 +5,6 @@ package azdext import ( "errors" - "fmt" "github.com/Azure/azure-sdk-for-go/sdk/azcore" ) @@ -15,21 +14,36 @@ import ( type ServiceError struct { // Message is the human-readable error message Message string - // Details contains additional error details - Details string // ErrorCode is the error code from the service (e.g., "Conflict", "NotFound") ErrorCode string // StatusCode is the HTTP status code (e.g., 409, 404, 500) StatusCode int // ServiceName is the service host/name for telemetry (e.g., "ai.azure.com") ServiceName string + // Suggestion contains optional user-facing remediation guidance. + Suggestion string } -// Error implements the error interface +// LocalError represents non-service extension errors, such as validation/config failures. +type LocalError struct { + // Message is the human-readable error message + Message string + // Code is an extension-defined machine-readable error code (lowercase snake_case, e.g. "missing_subscription_id"). + // It appears in telemetry as the last segment of ext... + Code string + // Category classifies the local error (for example: user, validation, dependency) + Category LocalErrorCategory + // Suggestion contains optional user-facing remediation guidance. + Suggestion string +} + +// Error implements the error interface. +func (e *LocalError) Error() string { + return e.Message +} + +// Error implements the error interface. func (e *ServiceError) Error() string { - if e.Details != "" { - return fmt.Sprintf("%s: %s", e.Message, e.Details) - } return e.Message } @@ -49,7 +63,7 @@ func WrapError(err error) *ExtensionError { var extServiceErr *ServiceError if errors.As(err, &extServiceErr) { extErr.Message = extServiceErr.Message - extErr.Details = extServiceErr.Details + extErr.Suggestion = extServiceErr.Suggestion extErr.Origin = ErrorOrigin_ERROR_ORIGIN_SERVICE extErr.Source = &ExtensionError_ServiceError{ ServiceError: &ServiceErrorDetail{ @@ -62,6 +76,21 @@ func WrapError(err error) *ExtensionError { return extErr } + var extLocalErr *LocalError + if errors.As(err, &extLocalErr) { + normalizedCategory := NormalizeLocalErrorCategory(extLocalErr.Category) + extErr.Message = extLocalErr.Message + extErr.Suggestion = extLocalErr.Suggestion + extErr.Origin = ErrorOrigin_ERROR_ORIGIN_LOCAL + extErr.Source = &ExtensionError_LocalError{ + LocalError: &LocalErrorDetail{ + Code: extLocalErr.Code, + Category: string(normalizedCategory), + }, + } + return extErr + } + // Try to detect Azure SDK errors var respErr *azcore.ResponseError if errors.As(err, &respErr) { @@ -94,16 +123,37 @@ func UnwrapError(msg *ExtensionError) error { if svcErr := msg.GetServiceError(); svcErr != nil { return &ServiceError{ Message: msg.GetMessage(), - Details: msg.GetDetails(), ErrorCode: svcErr.GetErrorCode(), StatusCode: int(svcErr.GetStatusCode()), ServiceName: svcErr.GetServiceName(), + Suggestion: msg.GetSuggestion(), + } + } + + if localErr := msg.GetLocalError(); localErr != nil { + normalizedCategory := ParseLocalErrorCategory(localErr.GetCategory()) + return &LocalError{ + Message: msg.GetMessage(), + Code: localErr.GetCode(), + Category: normalizedCategory, + Suggestion: msg.GetSuggestion(), } } - // Return a generic service error with just the message/details - return &ServiceError{ - Message: msg.GetMessage(), - Details: msg.GetDetails(), + if msg.GetOrigin() == ErrorOrigin_ERROR_ORIGIN_LOCAL { + return &LocalError{ + Message: msg.GetMessage(), + Category: LocalErrorCategoryLocal, + Suggestion: msg.GetSuggestion(), + } } + + if msg.GetOrigin() == ErrorOrigin_ERROR_ORIGIN_SERVICE { + return &ServiceError{ + Message: msg.GetMessage(), + Suggestion: msg.GetSuggestion(), + } + } + + return errors.New(msg.GetMessage()) } diff --git a/cli/azd/pkg/azdext/extension_error_category.go b/cli/azd/pkg/azdext/extension_error_category.go new file mode 100644 index 00000000000..48251102e5a --- /dev/null +++ b/cli/azd/pkg/azdext/extension_error_category.go @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "slices" + "strings" +) + +// LocalErrorCategory is the canonical category type for extension local errors. +// Keep values aligned with telemetry ResultCode families in internal/cmd/errors.go. +type LocalErrorCategory string + +const ( + LocalErrorCategoryValidation LocalErrorCategory = "validation" + LocalErrorCategoryAuth LocalErrorCategory = "auth" + LocalErrorCategoryDependency LocalErrorCategory = "dependency" + LocalErrorCategoryCompatibility LocalErrorCategory = "compatibility" + LocalErrorCategoryUser LocalErrorCategory = "user" + LocalErrorCategoryInternal LocalErrorCategory = "internal" + LocalErrorCategoryLocal LocalErrorCategory = "local" +) + +// knownCategories lists all recognized local error categories (excluding the fallback LocalErrorCategoryLocal). +var knownCategories = []LocalErrorCategory{ + LocalErrorCategoryValidation, + LocalErrorCategoryAuth, + LocalErrorCategoryDependency, + LocalErrorCategoryCompatibility, + LocalErrorCategoryUser, + LocalErrorCategoryInternal, +} + +// NormalizeLocalErrorCategory validates a typed category value, returning the canonical constant. +// Unknown values are collapsed to LocalErrorCategoryLocal. +func NormalizeLocalErrorCategory(category LocalErrorCategory) LocalErrorCategory { + normalized := LocalErrorCategory(strings.ToLower(strings.TrimSpace(string(category)))) + if slices.Contains(knownCategories, normalized) { + return normalized + } + return LocalErrorCategoryLocal +} + +// ParseLocalErrorCategory parses a raw category string (e.g. from proto deserialization) +// into its canonical LocalErrorCategory constant. Unknown values map to LocalErrorCategoryLocal. +func ParseLocalErrorCategory(category string) LocalErrorCategory { + return NormalizeLocalErrorCategory(LocalErrorCategory(category)) +} diff --git a/cli/azd/pkg/azdext/extension_error_category_test.go b/cli/azd/pkg/azdext/extension_error_category_test.go new file mode 100644 index 00000000000..a6281d6f69b --- /dev/null +++ b/cli/azd/pkg/azdext/extension_error_category_test.go @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import "testing" + +func TestNormalizeLocalErrorCategory(t *testing.T) { + tests := []struct { + name string + input LocalErrorCategory + expected LocalErrorCategory + }{ + {name: "validation", input: "validation", expected: LocalErrorCategoryValidation}, + {name: "auth", input: "auth", expected: LocalErrorCategoryAuth}, + {name: "dependency", input: "dependency", expected: LocalErrorCategoryDependency}, + {name: "compatibility", input: "compatibility", expected: LocalErrorCategoryCompatibility}, + {name: "user", input: "user", expected: LocalErrorCategoryUser}, + {name: "internal", input: "internal", expected: LocalErrorCategoryInternal}, + {name: "dot variant", input: "dependency.error", expected: LocalErrorCategoryLocal}, + {name: "dash variant", input: "compatibility-check", expected: LocalErrorCategoryLocal}, + {name: "unknown", input: "custom", expected: LocalErrorCategoryLocal}, + {name: "empty", input: "", expected: LocalErrorCategoryLocal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := NormalizeLocalErrorCategory(tt.input); got != tt.expected { + t.Fatalf("NormalizeLocalErrorCategory(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} + +func TestParseLocalErrorCategory(t *testing.T) { + tests := []struct { + name string + input string + expected LocalErrorCategory + }{ + {name: "known", input: "validation", expected: LocalErrorCategoryValidation}, + {name: "unknown", input: "custom", expected: LocalErrorCategoryLocal}, + {name: "empty", input: "", expected: LocalErrorCategoryLocal}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ParseLocalErrorCategory(tt.input); got != tt.expected { + t.Fatalf("ParseLocalErrorCategory(%q) = %q, want %q", tt.input, got, tt.expected) + } + }) + } +} diff --git a/cli/azd/pkg/azdext/extension_error_report.go b/cli/azd/pkg/azdext/extension_error_report.go new file mode 100644 index 00000000000..1573fa06ae1 --- /dev/null +++ b/cli/azd/pkg/azdext/extension_error_report.go @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "context" + "fmt" + "os" +) + +// ReportError sends a structured extension error to the azd host via gRPC. +// It creates a temporary gRPC client using the AZD_SERVER environment variable. +// Returns nil if AZD_SERVER is not set (extension running outside azd). +func ReportError(ctx context.Context, err error) error { + if err == nil { + return nil + } + + server := os.Getenv("AZD_SERVER") + if server == "" { + return fmt.Errorf("AZD_SERVER not set") + } + + extErr := WrapError(err) + if extErr == nil { + return nil + } + + client, clientErr := NewAzdClient(WithAddress(server)) + if clientErr != nil { + return fmt.Errorf("create gRPC client for error report: %w", clientErr) + } + defer client.Close() + + req := &ReportErrorRequest{Error: extErr} + if _, rpcErr := client.Extension().ReportError(ctx, req); rpcErr != nil { + return fmt.Errorf("report error via gRPC: %w", rpcErr) + } + + return nil +} diff --git a/cli/azd/pkg/azdext/extension_error_report_test.go b/cli/azd/pkg/azdext/extension_error_report_test.go new file mode 100644 index 00000000000..1b31956f2bf --- /dev/null +++ b/cli/azd/pkg/azdext/extension_error_report_test.go @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestWrapUnwrapErrorRoundTrip(t *testing.T) { + t.Run("LocalError", func(t *testing.T) { + original := &LocalError{ + Message: "invalid config", + Code: "invalid_config", + Category: LocalErrorCategoryValidation, + Suggestion: "Add missing field in config", + } + + proto := WrapError(original) + require.NotNil(t, proto) + + unwrapped := UnwrapError(proto) + require.NotNil(t, unwrapped) + + var localErr *LocalError + require.ErrorAs(t, unwrapped, &localErr) + require.Equal(t, "invalid_config", localErr.Code) + require.Equal(t, LocalErrorCategoryValidation, localErr.Category) + require.Equal(t, "Add missing field in config", localErr.Suggestion) + }) + + t.Run("NilError", func(t *testing.T) { + proto := WrapError(nil) + require.Nil(t, proto) + }) +} diff --git a/cli/azd/pkg/azdext/extension_error_test.go b/cli/azd/pkg/azdext/extension_error_test.go index 20881c1c551..12c7f95ffba 100644 --- a/cli/azd/pkg/azdext/extension_error_test.go +++ b/cli/azd/pkg/azdext/extension_error_test.go @@ -32,24 +32,21 @@ func TestExtensionError_RoundTrip(t *testing.T) { assert.Equal(t, ErrorOrigin_ERROR_ORIGIN_UNSPECIFIED, protoErr.GetOrigin()) assert.Nil(t, protoErr.GetSource()) - // Unspecified origin falls back to ExtServiceError - var svcErr *ServiceError - require.ErrorAs(t, goErr, &svcErr) - assert.Equal(t, "simple error", svcErr.Message) + assert.Equal(t, "simple error", goErr.Error()) }, }, { name: "ExtServiceError", inputErr: &ServiceError{ Message: "Rate limit exceeded", - Details: "Too many requests", ErrorCode: "RateLimitExceeded", StatusCode: 429, ServiceName: "openai.azure.com", + Suggestion: "Retry with exponential backoff", }, verify: func(t *testing.T, protoErr *ExtensionError, goErr error) { assert.Equal(t, "Rate limit exceeded", protoErr.GetMessage()) - assert.Equal(t, "Too many requests", protoErr.GetDetails()) + assert.Equal(t, "Retry with exponential backoff", protoErr.GetSuggestion()) assert.Equal(t, ErrorOrigin_ERROR_ORIGIN_SERVICE, protoErr.GetOrigin()) svcDetail := protoErr.GetServiceError() @@ -61,10 +58,34 @@ func TestExtensionError_RoundTrip(t *testing.T) { var svcErr *ServiceError require.ErrorAs(t, goErr, &svcErr) assert.Equal(t, "Rate limit exceeded", svcErr.Message) - assert.Equal(t, "Too many requests", svcErr.Details) assert.Equal(t, "RateLimitExceeded", svcErr.ErrorCode) assert.Equal(t, 429, svcErr.StatusCode) assert.Equal(t, "openai.azure.com", svcErr.ServiceName) + assert.Equal(t, "Retry with exponential backoff", svcErr.Suggestion) + }, + }, + { + name: "ExtLocalError", + inputErr: &LocalError{ + Message: "invalid config", + Code: "invalid_config", + Category: LocalErrorCategoryValidation, + Suggestion: "Add the missing required field", + }, + verify: func(t *testing.T, protoErr *ExtensionError, goErr error) { + assert.Equal(t, ErrorOrigin_ERROR_ORIGIN_LOCAL, protoErr.GetOrigin()) + assert.Equal(t, "Add the missing required field", protoErr.GetSuggestion()) + + localDetail := protoErr.GetLocalError() + require.NotNil(t, localDetail) + assert.Equal(t, "invalid_config", localDetail.GetCode()) + assert.Equal(t, "validation", localDetail.GetCategory()) + + var localErr *LocalError + require.ErrorAs(t, goErr, &localErr) + assert.Equal(t, "invalid_config", localErr.Code) + assert.Equal(t, LocalErrorCategoryValidation, localErr.Category) + assert.Equal(t, "Add the missing required field", localErr.Suggestion) }, }, { diff --git a/cli/azd/pkg/azdext/extension_grpc.pb.go b/cli/azd/pkg/azdext/extension_grpc.pb.go index f238e4448e1..8c743f5db13 100644 --- a/cli/azd/pkg/azdext/extension_grpc.pb.go +++ b/cli/azd/pkg/azdext/extension_grpc.pb.go @@ -22,7 +22,8 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - ExtensionService_Ready_FullMethodName = "/azdext.ExtensionService/Ready" + ExtensionService_Ready_FullMethodName = "/azdext.ExtensionService/Ready" + ExtensionService_ReportError_FullMethodName = "/azdext.ExtensionService/ReportError" ) // ExtensionServiceClient is the client API for ExtensionService service. @@ -33,6 +34,9 @@ const ( type ExtensionServiceClient interface { // Signal that the extension is done registering all capabilities Ready(ctx context.Context, in *ReadyRequest, opts ...grpc.CallOption) (*ReadyResponse, error) + // Report a structured error from the extension back to the host. + // Called by the extension SDK before the process exits on failure. + ReportError(ctx context.Context, in *ReportErrorRequest, opts ...grpc.CallOption) (*ReportErrorResponse, error) } type extensionServiceClient struct { @@ -53,6 +57,16 @@ func (c *extensionServiceClient) Ready(ctx context.Context, in *ReadyRequest, op return out, nil } +func (c *extensionServiceClient) ReportError(ctx context.Context, in *ReportErrorRequest, opts ...grpc.CallOption) (*ReportErrorResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ReportErrorResponse) + err := c.cc.Invoke(ctx, ExtensionService_ReportError_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // ExtensionServiceServer is the server API for ExtensionService service. // All implementations must embed UnimplementedExtensionServiceServer // for forward compatibility. @@ -61,6 +75,9 @@ func (c *extensionServiceClient) Ready(ctx context.Context, in *ReadyRequest, op type ExtensionServiceServer interface { // Signal that the extension is done registering all capabilities Ready(context.Context, *ReadyRequest) (*ReadyResponse, error) + // Report a structured error from the extension back to the host. + // Called by the extension SDK before the process exits on failure. + ReportError(context.Context, *ReportErrorRequest) (*ReportErrorResponse, error) mustEmbedUnimplementedExtensionServiceServer() } @@ -74,6 +91,9 @@ type UnimplementedExtensionServiceServer struct{} func (UnimplementedExtensionServiceServer) Ready(context.Context, *ReadyRequest) (*ReadyResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method Ready not implemented") } +func (UnimplementedExtensionServiceServer) ReportError(context.Context, *ReportErrorRequest) (*ReportErrorResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ReportError not implemented") +} func (UnimplementedExtensionServiceServer) mustEmbedUnimplementedExtensionServiceServer() {} func (UnimplementedExtensionServiceServer) testEmbeddedByValue() {} @@ -113,6 +133,24 @@ func _ExtensionService_Ready_Handler(srv interface{}, ctx context.Context, dec f return interceptor(ctx, in, info, handler) } +func _ExtensionService_ReportError_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ReportErrorRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(ExtensionServiceServer).ReportError(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: ExtensionService_ReportError_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(ExtensionServiceServer).ReportError(ctx, req.(*ReportErrorRequest)) + } + return interceptor(ctx, in, info, handler) +} + // ExtensionService_ServiceDesc is the grpc.ServiceDesc for ExtensionService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -124,6 +162,10 @@ var ExtensionService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Ready", Handler: _ExtensionService_Ready_Handler, }, + { + MethodName: "ReportError", + Handler: _ExtensionService_ReportError_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "extension.proto", diff --git a/cli/azd/pkg/azdext/extension_host.go b/cli/azd/pkg/azdext/extension_host.go index 5131090628d..21db530e592 100644 --- a/cli/azd/pkg/azdext/extension_host.go +++ b/cli/azd/pkg/azdext/extension_host.go @@ -330,7 +330,7 @@ func (er *ExtensionHost) Run(ctx context.Context) error { } func callReady(ctx context.Context, client *AzdClient) error { - _, err := client.extensionService().Ready(ctx, &ReadyRequest{}) + _, err := client.Extension().Ready(ctx, &ReadyRequest{}) if err == nil { return nil } diff --git a/cli/azd/pkg/azdext/extension_host_test.go b/cli/azd/pkg/azdext/extension_host_test.go index 418e9683533..19fa6ba6d77 100644 --- a/cli/azd/pkg/azdext/extension_host_test.go +++ b/cli/azd/pkg/azdext/extension_host_test.go @@ -32,6 +32,15 @@ func (m *MockExtensionServiceClient) Ready( return args.Get(0).(*ReadyResponse), args.Error(1) } +func (m *MockExtensionServiceClient) ReportError( + ctx context.Context, + in *ReportErrorRequest, + opts ...grpc.CallOption, +) (*ReportErrorResponse, error) { + args := m.Called(ctx, in, opts) + return args.Get(0).(*ReportErrorResponse), args.Error(1) +} + // MockServiceTargetRegistrar implements serviceTargetRegistrar using testify/mock type MockServiceTargetRegistrar struct { mock.Mock diff --git a/cli/azd/pkg/azdext/run.go b/cli/azd/pkg/azdext/run.go new file mode 100644 index 00000000000..7938ab8fea2 --- /dev/null +++ b/cli/azd/pkg/azdext/run.go @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "context" + "errors" + "fmt" + "log" + "os" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +// RunOption configures the behavior of [Run]. +type RunOption func(*runConfig) + +type runConfig struct { + preExecute func(ctx context.Context, cmd *cobra.Command) error +} + +// WithPreExecute registers a hook that runs after context creation but before +// command execution. If the hook returns a non-nil error, Run prints it and +// exits. This is useful for extensions that need special setup such as +// dual-mode host detection or working-directory changes. +func WithPreExecute(fn func(ctx context.Context, cmd *cobra.Command) error) RunOption { + return func(c *runConfig) { c.preExecute = fn } +} + +// Run is the standard entry point for azd extensions. It handles all lifecycle +// boilerplate that every extension needs: +// - FORCE_COLOR environment variable → color.NoColor +// - cobra SilenceErrors (Run controls error output) +// - Context creation with tracing propagation +// - gRPC access token injection via [WithAccessToken] +// - Command execution +// - Structured error reporting via gRPC ReportError +// - Error + suggestion display +// - os.Exit on failure +// +// A typical extension main.go becomes: +// +// func main() { +// azdext.Run(cmd.NewRootCommand()) +// } +func Run(rootCmd *cobra.Command, opts ...RunOption) { + if v, ok := os.LookupEnv("FORCE_COLOR"); ok && v == "1" { + color.NoColor = false + } + + rootCmd.SilenceErrors = true + + ctx := NewContext() + ctx = WithAccessToken(ctx) + + var cfg runConfig + for _, o := range opts { + o(&cfg) + } + + if cfg.preExecute != nil { + if err := cfg.preExecute(ctx, rootCmd); err != nil { + if reportErr := ReportError(ctx, err); reportErr != nil { + log.Printf("warning: failed to report structured error: %v", reportErr) + printError(err) + } + + os.Exit(1) + } + } + + if err := rootCmd.ExecuteContext(ctx); err != nil { + if reportErr := ReportError(ctx, err); reportErr != nil { + log.Printf("warning: failed to report structured error: %v", reportErr) + printError(err) + } + + os.Exit(1) + } +} + +func printError(err error) { + redStderr := color.New(color.FgRed) + redStderr.EnableColor() + redStderr.Fprintf(os.Stderr, "Error: %v\n", err) + + if s := ErrorSuggestion(err); s != "" { + if !strings.HasPrefix(s, "Suggestion: ") { + s = "Suggestion: " + s + } + fmt.Fprintln(os.Stderr, s) + } +} + +// ErrorSuggestion extracts the Suggestion field from a [LocalError] or [ServiceError]. +// Returns an empty string if the error has no suggestion. +func ErrorSuggestion(err error) string { + var localErr *LocalError + if errors.As(err, &localErr) && localErr.Suggestion != "" { + return localErr.Suggestion + } + + var svcErr *ServiceError + if errors.As(err, &svcErr) && svcErr.Suggestion != "" { + return svcErr.Suggestion + } + + return "" +} + +// ErrorMessage extracts the user-friendly Message field from a [LocalError] or [ServiceError]. +// Returns an empty string if the error is not an extension error type. +func ErrorMessage(err error) string { + var localErr *LocalError + if errors.As(err, &localErr) && localErr.Message != "" { + return localErr.Message + } + + var svcErr *ServiceError + if errors.As(err, &svcErr) && svcErr.Message != "" { + return svcErr.Message + } + + return "" +} diff --git a/cli/azd/pkg/extensions/extension.go b/cli/azd/pkg/extensions/extension.go index 43d1d2370a0..a83c4e0a4b9 100644 --- a/cli/azd/pkg/extensions/extension.go +++ b/cli/azd/pkg/extensions/extension.go @@ -34,6 +34,9 @@ type Extension struct { readySignal chan error // consolidated channel, buffered with capacity 1 readyOnce sync.Once // ensures signal is sent only once initialized bool + + reportedError error // structured error reported by the extension via gRPC + errorMu sync.Mutex // guards reportedError } // init initializes the extension's buffers and signals. @@ -114,3 +117,17 @@ func (e *Extension) StdErr() *output.DynamicMultiWriter { e.ensureInit() return e.stderr } + +// SetReportedError stores a structured error reported by the extension via gRPC. +func (e *Extension) SetReportedError(err error) { + e.errorMu.Lock() + defer e.errorMu.Unlock() + e.reportedError = err +} + +// GetReportedError returns the structured error reported by the extension, if any. +func (e *Extension) GetReportedError() error { + e.errorMu.Lock() + defer e.errorMu.Unlock() + return e.reportedError +}