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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions cli/azd/pkg/azapi/azure_deployment_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,20 @@ func (e *AzureDeploymentError) Error() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("\n\n%s:\n", e.Title))

// Return the original error string if we can't parse the JSON
if e.Details == nil {
var lines []string
if e.Details != nil {
lines = generateErrorOutput(e.Details)
}

// Fall back to the raw payload when the JSON could not be parsed, and also when
// it parsed into a tree that renders nothing: every node was code-only, blanked
// (DeploymentFailed, ResourceDeploymentFailure), or a wrapper whose message held
// no nested code/message pair. Without this the heading would be the whole error.
if len(lines) == 0 {
sb.WriteString(e.Json)
return sb.String()
}

lines := generateErrorOutput(e.Details)
for _, line := range lines {
sb.WriteString(fmt.Sprintln(output.WithErrorFormat(line)))
}
Expand Down
34 changes: 34 additions & 0 deletions cli/azd/pkg/azapi/azure_deployment_error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,40 @@ func Test_Not_Json_Error(t *testing.T) {
require.Equal(t, "\n\nTitle:\n"+nonJsonError, errorString)
}

// A payload can parse cleanly yet render nothing: nodes carrying only a code,
// codes that are deliberately blanked, or a null body. Falling back to the raw
// payload keeps some cause visible instead of emitting a bare heading.
func Test_Parsed_But_Unrenderable_Error_Falls_Back_To_Json(t *testing.T) {
tests := []struct {
name string
json string
}{
{
name: "code with no message",
json: `{"error":{"code":"ResourceGroupNotFound"}}`,
},
{
name: "blanked code with no details",
json: `{"error":{"code":"DeploymentFailed","message":"At least one operation failed."}}`,
},
{
name: "additionalInfo only",
json: `{"error":{"code":"BadRequest","additionalInfo":[{"type":"QuotaExceeded"}]}}`,
},
{
name: "null body",
json: `null`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
deploymentError := NewAzureDeploymentError("Title", tt.json, DeploymentOperationPreview)
require.Equal(t, "\n\nTitle:\n"+tt.json, deploymentError.Error())
})
}
}

func assertOutputsMatch(t *testing.T, jsonPath string, expectedOutputPath string) {
data, err := os.ReadFile(jsonPath)
if err != nil {
Expand Down
180 changes: 180 additions & 0 deletions cli/azd/pkg/azapi/deployment_error_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
package azapi

import (
"encoding/json"
"fmt"
"strings"
"testing"

"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
"github.com/azure/azure-dev/cli/azd/pkg/errorhandler"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -273,3 +276,180 @@ func TestPipeline_DeploymentErrorLine_LocationNotAvailableForResourceType(t *tes
"Should match LocationNotAvailableForResourceType")
assert.Equal(t, "Resource not available in region.", result.Message)
}

// A failed what-if returns HTTP 200 with the failure in the payload, so the
// preview path builds its error from an armresources.ErrorResponse rather than
// from an HTTP error. The actionable cause sits two levels deep, under a
// top-level code that getErrorsFromMap does NOT blank out (unlike
// DeploymentFailed), so the pipeline must keep searching past the outer node.
// Regression test for https://github.com/Azure/azure-dev/issues/9011.
func TestPipeline_PreviewErrorResponse_NestedQuota(t *testing.T) {
const whatIfBody = `{
"status": "Failed",
"error": {
"code": "InvalidTemplateDeployment",
"message": "The template deployment 'dev-1' is not valid according to the validation procedure. ` +
`The following resource provider(s) - 'Microsoft.Storage/storageAccounts' reported preflight ` +
`validation errors. See inner errors for details.",
"details": [{
"code": "PreflightValidationCheckFailed",
"message": "Preflight validation failed. Please refer to the details for the specific errors.",
"details": [{
"code": "SubscriptionIsOverQuotaForSku",
"target": "stdev1",
"message": "Subscription has reached the maximum number of storage accounts allowed in 'eastus'."
}]
}]
}
}`

var whatIfResult armresources.WhatIfOperationResult
require.NoError(t, json.Unmarshal([]byte(whatIfBody), &whatIfResult))
require.NotNil(t, whatIfResult.Error)

deployErr := NewAzureDeploymentErrorFromResponse(whatIfResult.Error, DeploymentOperationPreview)

// Every level of the ARM error tree must be rendered, especially the leaf cause.
// Asserting the codes in order locks the nesting: a regression that stops
// descending would drop trailing entries rather than fail a substring check.
rendered := deployErr.Error()
var codes []string
for line := range strings.SplitSeq(strings.TrimSpace(rendered), "\n") {
if code, _, found := strings.Cut(line, ":"); found {
codes = append(codes, strings.TrimSpace(code))
}
}
assert.Equal(t, []string{
"Preview Error Details",
"InvalidTemplateDeployment",
"PreflightValidationCheckFailed",
"SubscriptionIsOverQuotaForSku",
}, codes)
assert.Contains(t, rendered, "maximum number of storage accounts")

// Reproduce the production wrapping chain: provisioning.Manager.Preview
// followed by wrapProvisionError's default branch.
wrapped := fmt.Errorf("deployment failed: %w",
fmt.Errorf("error deploying infrastructure: %w", deployErr))

// Exercise the real embedded error_suggestions.yaml rules, not inline ones.
result := errorhandler.NewErrorHandlerPipeline(nil).Process(t.Context(), wrapped)

require.NotNil(t, result,
"Should match SubscriptionIsOverQuotaForSku nested under InvalidTemplateDeployment")
assert.Equal(t, "Your subscription quota for this SKU is exceeded.", result.Message)
assert.Contains(t, result.Suggestion, "Request a quota increase")
}

// Property matching is a case-insensitive substring check, so the rule keyed on
// "InvalidTemplate" also matches ARM's "InvalidTemplateDeployment". Its advice —
// run 'azd provision --preview' — is correct for a deploy but absurd for a
// preview, which is the command already running. The preview guard rule keys on
// AzureDeploymentError.Operation to suppress it there, and must leave every
// other operation untouched.
func TestSuggestions_PreviewNeverAdvisesRunningPreview(t *testing.T) {
// Cause with no dedicated rule, so matching falls through to the generic
// template rules where the bad advice lives.
const armError = `{
"code": "InvalidTemplateDeployment",
"message": "The template deployment is not valid. See inner errors for details.",
"details": [{
"code": "PreflightValidationCheckFailed",
"message": "Preflight validation failed.",
"details": [{
"code": "StorageAccountAlreadyTaken",
"message": "The storage account named storage is already taken."
}]
}]
}`

tests := []struct {
name string
op DeploymentOperation
// wantMessage identifies which rule won, since both rules produce advice.
wantMessage string
wantSelfAdvice bool
}{
{
name: "preview is suppressed",
op: DeploymentOperationPreview,
wantMessage: "Azure validation rejected the deployment template.",
wantSelfAdvice: false,
},
{
// Not self-referential, so the original advice must survive unchanged.
name: "deploy keeps the advice",
op: DeploymentOperationDeploy,
wantMessage: "The deployment template contains errors.",
wantSelfAdvice: true,
},
{
// Validate runs inside provision, not preview, so the guard must not
// widen to it just because it is also a non-deploy operation.
name: "validate keeps the advice",
op: DeploymentOperationValidate,
wantMessage: "The deployment template contains errors.",
wantSelfAdvice: true,
},
}

pipeline := errorhandler.NewErrorHandlerPipeline(nil)

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Preview marshals the ErrorResponse directly; the other paths receive
// the same tree nested under an "error" key. Mirror both real shapes.
body := armError
if tt.op != DeploymentOperationPreview {
body = `{"error":` + armError + `}`
}

deployErr := NewAzureDeploymentError(deploymentErrorTitle(tt.op), body, tt.op)
wrapped := fmt.Errorf("deployment failed: %w",
fmt.Errorf("error deploying infrastructure: %w", deployErr))

result := pipeline.Process(t.Context(), wrapped)

require.NotNil(t, result, "every operation should still get a suggestion")
assert.Equal(t, tt.wantMessage, result.Message)

if tt.wantSelfAdvice {
assert.Contains(t, result.Suggestion, "azd provision --preview")
} else {
assert.NotContains(t, result.Suggestion, "--preview",
"must not tell a --preview run to run --preview")
}
})
}
}

// The preview guard matches on operation alone, so ordering is the only thing
// keeping it from swallowing every preview failure: it must sit after the
// specific error-code rules, which are evaluated first and win. Locking that
// here because the ordering constraint is invisible at the YAML call site.
// This is the issue #9011 behaviour and must not regress.
func TestSuggestions_PreviewGuardDoesNotMaskSpecificCauses(t *testing.T) {
const armError = `{
"code": "InvalidTemplateDeployment",
"message": "The template deployment is not valid. See inner errors for details.",
"details": [{
"code": "PreflightValidationCheckFailed",
"message": "Preflight validation failed.",
"details": [{
"code": "SubscriptionIsOverQuotaForSku",
"message": "Subscription has reached the maximum number of storage accounts."
}]
}]
}`

deployErr := NewAzureDeploymentError(
deploymentErrorTitle(DeploymentOperationPreview), armError, DeploymentOperationPreview)
wrapped := fmt.Errorf("deployment failed: %w",
fmt.Errorf("error deploying infrastructure: %w", deployErr))

result := errorhandler.NewErrorHandlerPipeline(nil).Process(t.Context(), wrapped)

require.NotNil(t, result)
assert.Equal(t, "Your subscription quota for this SKU is exceeded.", result.Message,
"specific quota rule must outrank the generic preview guard")
}
42 changes: 34 additions & 8 deletions cli/azd/pkg/azapi/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ package azapi

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"time"

Expand Down Expand Up @@ -391,18 +393,42 @@ func responseToDeploymentError(title string, respErr *azcore.ResponseError, oper
return NewAzureDeploymentError(title, errorText, operation)
}

// deploymentErrorTitle returns the user-facing heading used when rendering an
// Azure deployment error for the given operation.
func deploymentErrorTitle(operation DeploymentOperation) string {
switch operation {
case DeploymentOperationValidate:
return "Validation Error Details"
case DeploymentOperationPreview:
return "Preview Error Details"
default:
return "Deployment Error Details"
}
}

// Attempts to create an Azure Deployment error from the HTTP response error
func createDeploymentError(err error, operation DeploymentOperation) error {
if responseErr, ok := errors.AsType[*azcore.ResponseError](err); ok {
title := "Deployment Error Details"
switch operation {
case DeploymentOperationValidate:
title = "Validation Error Details"
case DeploymentOperationPreview:
title = "Preview Error Details"
}
return responseToDeploymentError(title, responseErr, operation)
return responseToDeploymentError(deploymentErrorTitle(operation), responseErr, operation)
}

return err
}

// NewAzureDeploymentErrorFromResponse builds an AzureDeploymentError from an ARM
// ErrorResponse carried in a successful (HTTP 200) response body, such as a failed
// what-if operation that reports the failure in the payload rather than as an HTTP
// error. Marshalling back to JSON lets the error reuse the same recursive parser as
// the HTTP error path, so nested details are surfaced and the error-suggestion
// pipeline can match on any DeploymentErrorLine in the tree.
func NewAzureDeploymentErrorFromResponse(
errResp *armresources.ErrorResponse,
operation DeploymentOperation,
) error {
raw, err := json.Marshal(errResp)
if err != nil {
return fmt.Errorf("marshalling deployment error response: %w", err)
}

return NewAzureDeploymentError(deploymentErrorTitle(operation), string(raw), operation)
}
22 changes: 3 additions & 19 deletions cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -1088,25 +1088,9 @@ func (p *BicepProvider) Preview(ctx context.Context) (*provisioning.DeployPrevie
}

if deployPreviewResult.Error != nil {
deploymentErr := *deployPreviewResult.Error
errDetailsList := make([]string, len(deploymentErr.Details))
for index, errDetail := range deploymentErr.Details {
errDetailsList[index] = fmt.Sprintf(
"code: %s, message: %s",
convert.ToValueWithDefault(errDetail.Code, ""),
convert.ToValueWithDefault(errDetail.Message, ""),
)
}

var errDetails string
if len(errDetailsList) > 0 {
errDetails = fmt.Sprintf(" Details: %s", strings.Join(errDetailsList, "\n"))
}
return nil, fmt.Errorf(
"generating preview: error code: %s, message: %s.%s",
convert.ToValueWithDefault(deploymentErr.Code, ""),
convert.ToValueWithDefault(deploymentErr.Message, ""),
errDetails,
return nil, azapi.NewAzureDeploymentErrorFromResponse(
deployPreviewResult.Error,
azapi.DeploymentOperationPreview,
)
}

Expand Down
6 changes: 6 additions & 0 deletions cli/azd/resources/error_suggestions.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ rules:
- url: "https://learn.microsoft.com/azure/container-apps/troubleshooting"
title: "Troubleshoot Container Apps"

- errorType: "AzureDeploymentError"
properties:
Operation: "preview"
message: "Azure validation rejected the deployment template."
suggestion: "Review the nested error codes above for the specific cause."

- errorType: "DeploymentErrorLine"
properties:
Code: "InvalidTemplate"
Expand Down
Loading