diff --git a/cli/azd/magefile.go b/cli/azd/magefile.go index 2ff84a0b31e..f56d0f5048a 100644 --- a/cli/azd/magefile.go +++ b/cli/azd/magefile.go @@ -566,15 +566,15 @@ var excludedPlaybackTests = map[string]string{ // Recordings affected by feat/exegraph: the graph-driven up/provision path // introduces legitimate new HTTP interactions (layer hash probes, resource-group // existence checks). Must be re-recorded with live Azure credentials before merge. - "Test_DeploymentStacks": "needs re-record for feat/exegraph graph-driven provision", - "Test_CLI_ProvisionState": "needs re-record for feat/exegraph graph-driven provision", - "Test_CLI_InfraCreateAndDeleteUpperCase": "needs re-record for feat/exegraph graph-driven provision", - "Test_CLI_PreflightQuota_Sub_DefaultCapacity": "stale recording; missing extension registry + resource group interactions", - "Test_CLI_PreflightQuota_Sub_InvalidModelName": "stale recording; missing extension registry + resource group interactions", + "Test_DeploymentStacks": "needs re-record for feat/exegraph graph-driven provision", + "Test_CLI_ProvisionState": "needs re-record for feat/exegraph graph-driven provision", + "Test_CLI_InfraCreateAndDeleteUpperCase": "needs re-record for feat/exegraph graph-driven provision", + "Test_CLI_PreflightQuota_Sub_DefaultCapacity": "stale recording; missing extension registry + resource group interactions", + "Test_CLI_PreflightQuota_Sub_InvalidModelName": "stale recording; missing extension registry + resource group interactions", "Test_CLI_PreflightQuota_Sub_DifferentLocation": "stale recording; missing extension registry + resource group interactions", - "Test_CLI_PreflightQuota_RG_DefaultCapacity": "stale recording; missing extension registry + resource group interactions", - "Test_CLI_PreflightQuota_RG_InvalidVersion": "stale recording; missing extension registry + resource group interactions", - "Test_CLI_PreflightQuota_RG_InvalidModelName": "stale recording; missing extension registry + resource group interactions", + "Test_CLI_PreflightQuota_RG_DefaultCapacity": "stale recording; missing extension registry + resource group interactions", + "Test_CLI_PreflightQuota_RG_InvalidVersion": "stale recording; missing extension registry + resource group interactions", + "Test_CLI_PreflightQuota_RG_InvalidModelName": "stale recording; missing extension registry + resource group interactions", } // discoverPlaybackTests scans the recordings directory for .yaml files and diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index f9a309a10f8..0327f07a8f9 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -2640,6 +2640,8 @@ func (p *BicepProvider) validatePreflight( IsError: result.Severity == PreflightCheckError, DiagnosticID: result.DiagnosticID, Message: result.Message, + Suggestion: result.Suggestion, + Links: result.Links, }) } p.console.MessageUxItem(ctx, report) @@ -2656,8 +2658,7 @@ func (p *BicepProvider) validatePreflight( if report.HasWarnings() { p.console.Message(ctx, "") continueDeployment, promptErr := p.console.Confirm(ctx, input.ConsoleOptions{ - Message: "Preflight validation found warnings that may cause the " + - "deployment to fail. Do you want to continue?", + Message: "Proceed with deployment despite the warnings above?", DefaultValue: true, }) if promptErr != nil { @@ -2755,16 +2756,25 @@ func (p *BicepProvider) checkRoleAssignmentPermissions( Severity: PreflightCheckWarning, DiagnosticID: "role_assignment_missing", Message: fmt.Sprintf( - "the current principal %s does not have permission to create role assignments "+ - "%s on subscription %s. "+ - "The deployment includes role assignments and will fail without this permission. "+ - "Ensure you have the 'Role Based Access Control Administrator', "+ - "'User Access Administrator', 'Owner', or a custom role with "+ - "'Microsoft.Authorization/roleAssignments/write' assigned to your account.", - output.WithHighLightFormat("(%s)", principalId), - output.WithGrayFormat("(Microsoft.Authorization/roleAssignments/write)"), + "Principal %s lacks role assignment"+ + " permissions on subscription %s\n"+ + "The deployment includes role assignments"+ + " and will fail without %s permission.", + output.WithHighLightFormat( + "(%s)", principalId), output.WithHighLightFormat(subscriptionId), + output.WithGrayFormat( + "Microsoft.Authorization/"+ + "roleAssignments/write"), ), + Suggestion: "Ensure you have the" + + " 'Role Based Access Control" + + " Administrator'," + + " 'User Access Administrator'," + + " 'Owner', or a custom role with" + + " 'Microsoft.Authorization/" + + "roleAssignments/write' assigned" + + " to your account.", }}, nil } @@ -2773,14 +2783,17 @@ func (p *BicepProvider) checkRoleAssignmentPermissions( Severity: PreflightCheckWarning, DiagnosticID: "role_assignment_conditional", Message: fmt.Sprintf( - "the current principal %s has conditional permission to create role "+ - "assignments %s on "+ - "subscription %s. The role assignment that grants this permission "+ - "has an ABAC condition that may restrict which roles can be assigned. "+ - "The deployment may fail if the condition does not permit the "+ - "specific role assignments in the template.", - output.WithHighLightFormat("(%s)", principalId), - output.WithGrayFormat("(Microsoft.Authorization/roleAssignments/write)"), + "Principal %s has conditional role"+ + " assignment permissions on"+ + " subscription %s\n"+ + "An ABAC condition may restrict"+ + " which roles can be assigned."+ + " The deployment may fail if the"+ + " condition does not permit the"+ + " specific role assignments in"+ + " the template.", + output.WithHighLightFormat( + "(%s)", principalId), output.WithHighLightFormat(subscriptionId), ), }}, nil @@ -2805,17 +2818,29 @@ func (p *BicepProvider) checkReservedResourceNames( continue } for _, v := range findReservedResourceNameViolations(resource.Name) { - resourceName := output.WithHighLightFormat("%q", resource.Name) - resourceType := output.WithGrayFormat("(%s)", resource.Type) - link := output.WithLinkFormat(docsLink) + resourceName := output.WithHighLightFormat( + "%q", resource.Name) + resourceType := output.WithGrayFormat( + "(%s)", resource.Type) results = append(results, PreflightCheckResult{ Severity: PreflightCheckWarning, DiagnosticID: "reserved_resource_name", Message: fmt.Sprintf( - "resource %s %s %s the reserved word %q. See %s.", - resourceName, resourceType, v.matchType, v.reservedWord, link, + "Resource %s %s %s the"+ + " reserved word %q\n"+ + "Azure does not allow reserved"+ + " words in resource names."+ + " The deployment will fail.", + resourceName, resourceType, + v.matchType, v.reservedWord, ), + Links: []ux.PreflightReportLink{ + { + URL: docsLink, + Title: "Reserved resource name errors", + }, + }, }) } } @@ -2924,16 +2949,25 @@ func (p *BicepProvider) checkAiModelQuota( Severity: PreflightCheckWarning, DiagnosticID: "ai_model_not_found", Message: fmt.Sprintf( - "model %s%s was not found in the AI model "+ - "catalog for %s. The deployment may fail "+ - "if this model is not available. Verify "+ - "the model name, SKU, and version are "+ - "correct. See %s for supported models and regions.", - output.WithHighLightFormat(fmt.Sprintf("%q", dep.ModelName)), + "Model %s%s not found in %s\n"+ + "Model not found in AI model catalog."+ + " Provisioning will likely fail.", + output.WithHighLightFormat( + "%q", dep.ModelName), output.WithGrayFormat(details), output.WithHighLightFormat(loc), - output.WithLinkFormat("https://learn.microsoft.com/azure/ai-services/openai/concepts/models"), ), + Suggestion: "Verify the model name, SKU," + + " and version are correct.", + Links: []ux.PreflightReportLink{ + { + URL: "https://learn.microsoft.com/" + + "azure/ai-services/openai/" + + "concepts/models", + Title: "Azure OpenAI supported" + + " models and regions", + }, + }, }) continue } @@ -2968,20 +3002,55 @@ func (p *BicepProvider) checkAiModelQuota( totalRequired := requiredByUsage[r.usageName] if remaining < totalRequired { reportedUsage[r.usageName] = true + + var suggestion string + if remaining > 0 { + suggestion = fmt.Sprintf( + "Reduce the requested capacity"+ + " to %.0f or change your"+ + " deployment location via %s."+ + " You can also request a quota"+ + " increase in the Azure portal.", + remaining, + output.WithHighLightFormat( + "azd env set"+ + " AZURE_LOCATION "), + ) + } else { + suggestion = fmt.Sprintf( + "No quota is available."+ + " Change your deployment"+ + " location via %s or request"+ + " a quota increase in the"+ + " Azure portal.", + output.WithHighLightFormat( + "azd env set"+ + " AZURE_LOCATION "), + ) + } + results = append(results, PreflightCheckResult{ Severity: PreflightCheckWarning, DiagnosticID: "ai_model_quota_exceeded", Message: fmt.Sprintf( - "insufficient quota for model %s %s in %s. "+ - "Requested capacity: %.0f, remaining quota: %.0f. "+ - "The deployment may fail. Consider reducing capacity, "+ - "selecting a different model, or requesting a quota increase.", - output.WithHighLightFormat("%q", r.dep.ModelName), - output.WithGrayFormat("(SKU: %s)", r.dep.SkuName), + "Insufficient quota for model %s %s"+ + " in %s\n"+ + "Requested: %.0f · Available: %.0f", + output.WithHighLightFormat( + "%q", r.dep.ModelName), + output.WithGrayFormat( + "(SKU: %s)", r.dep.SkuName), output.WithHighLightFormat(loc), totalRequired, remaining, ), + Suggestion: suggestion, + Links: []ux.PreflightReportLink{ + { + URL: "https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal", + Title: "Increase Azure subscription quotas", + }, + }, }) } } diff --git a/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go b/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go index 41d397e30ac..1c61a00c9ac 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go +++ b/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go @@ -18,6 +18,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azure" "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" ) @@ -271,6 +272,11 @@ type PreflightCheckResult struct { DiagnosticID string // Message is a human-readable description of the finding. Message string + // Suggestion is an optional actionable recommendation for resolving the issue. + // It should be dynamically generated with context-specific advice when possible. + Suggestion string + // Links is an optional list of reference links related to the finding. + Links []ux.PreflightReportLink } // validationContext provides the data and utilities available to preflight check functions. diff --git a/cli/azd/pkg/output/ux/preflight_report.go b/cli/azd/pkg/output/ux/preflight_report.go index 7ac957da34d..250b74e7ff9 100644 --- a/cli/azd/pkg/output/ux/preflight_report.go +++ b/cli/azd/pkg/output/ux/preflight_report.go @@ -20,6 +20,19 @@ type PreflightReportItem struct { DiagnosticID string // Message describes the finding. Message string + // Suggestion is an optional actionable recommendation for resolving the issue. + Suggestion string + // Links is an optional list of reference links related to the finding. + Links []PreflightReportLink +} + +// PreflightReportLink represents a reference link attached to a preflight report item. +type PreflightReportLink struct { + // URL is the link target. + URL string + // Title is the display text for terminal hyperlinks (optional). + // In non-terminal output the URL is shown regardless of Title. + Title string } // PreflightReport displays the results of local preflight validation. @@ -40,7 +53,7 @@ func (r *PreflightReport) ToString(currentIndentation string) string { if i > 0 { sb.WriteString("\n") } - sb.WriteString(fmt.Sprintf("%s%s %s", currentIndentation, warningPrefix, w.Message)) + writeItem(&sb, currentIndentation, warningPrefix, w) } if len(warnings) > 0 && len(errors) > 0 { @@ -51,12 +64,46 @@ func (r *PreflightReport) ToString(currentIndentation string) string { if i > 0 { sb.WriteString("\n") } - sb.WriteString(fmt.Sprintf("%s%s %s", currentIndentation, failedPrefix, e.Message)) + writeItem(&sb, currentIndentation, failedPrefix, e) } return sb.String() } +// writeItem renders a single report item with multi-line support. +// The first line is prefixed with the status indicator (e.g. "(!) Warning:"). +// Continuation lines in the message are indented at the same level as the prefix. +func writeItem( + sb *strings.Builder, indent string, prefix string, item PreflightReportItem, +) { + if item.Message == "" { + return + } + lines := strings.Split(item.Message, "\n") + sb.WriteString(fmt.Sprintf("%s%s %s", indent, prefix, lines[0])) + for _, line := range lines[1:] { + sb.WriteString(fmt.Sprintf("\n%s%s", indent, line)) + } + + if item.Suggestion != "" { + sb.WriteString(fmt.Sprintf("\n%s%s %s", + indent, + output.WithHighLightFormat("Suggestion:"), + item.Suggestion)) + } + for _, link := range item.Links { + if link.Title != "" { + sb.WriteString(fmt.Sprintf("\n%s• %s", + indent, + output.WithHyperlink(link.URL, link.Title))) + } else { + sb.WriteString(fmt.Sprintf("\n%s• %s", + indent, + output.WithLinkFormat(link.URL))) + } + } +} + func (r *PreflightReport) MarshalJSON() ([]byte, error) { warnings, errors := r.partition() diff --git a/cli/azd/pkg/output/ux/preflight_report_test.go b/cli/azd/pkg/output/ux/preflight_report_test.go index c74dbd7c535..e0d0e36d590 100644 --- a/cli/azd/pkg/output/ux/preflight_report_test.go +++ b/cli/azd/pkg/output/ux/preflight_report_test.go @@ -5,9 +5,12 @@ package ux import ( "encoding/json" + "strings" "testing" "github.com/stretchr/testify/require" + + "github.com/azure/azure-dev/cli/azd/test/snapshot" ) func TestPreflightReport_EmptyItems(t *testing.T) { @@ -81,6 +84,88 @@ func TestPreflightReport_MarshalJSON(t *testing.T) { require.Contains(t, string(data), "1 error(s)") } +func TestPreflightReport_WarningWithSuggestion(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + Message: "insufficient quota for model gpt-4o", + Suggestion: "Reduce capacity to 140 or change location.", + }, + }, + } + + result := report.ToString(" ") + require.Contains(t, result, "insufficient quota for model gpt-4o") + require.Contains(t, result, "Suggestion:") + require.Contains(t, result, "Reduce capacity to 140 or change location.") + + // Suggestion should appear after the warning message + warnIdx := indexOf(result, "insufficient quota") + suggIdx := indexOf(result, "Reduce capacity") + require.Greater(t, suggIdx, warnIdx, "suggestion should appear after warning message") +} + +func TestPreflightReport_WarningWithLinks(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + Message: "model not found", + Suggestion: "Verify the model name.", + Links: []PreflightReportLink{ + {URL: "https://example.com/models", Title: "Supported models"}, + {URL: "https://example.com/raw-link"}, + }, + }, + }, + } + + result := report.ToString(" ") + require.Contains(t, result, "model not found") + require.Contains(t, result, "Suggestion:") + require.Contains(t, result, "Verify the model name.") + // In non-terminal mode, WithHyperlink falls back to plain URL + require.Contains(t, result, "https://example.com/models") + require.Contains(t, result, "https://example.com/raw-link") +} + +func TestPreflightReport_NoSuggestion(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + {IsError: false, Message: "simple warning"}, + }, + } + + result := report.ToString("") + require.Contains(t, result, "simple warning") + require.NotContains(t, result, "Suggestion:") +} + +func TestPreflightReport_MarshalJSON_Envelope(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + {IsError: false, Message: "w1", Suggestion: "fix it"}, + {IsError: true, Message: "e1"}, + }, + } + + data, err := json.Marshal(report) + require.NoError(t, err) + + // MarshalJSON wraps output in EventEnvelope + var parsed struct { + Type string `json:"type"` + Data struct { + Message string `json:"message"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(data, &parsed)) + require.Equal(t, "consoleMessage", string(parsed.Type)) + require.Contains(t, parsed.Data.Message, "1 warning(s)") + require.Contains(t, parsed.Data.Message, "1 error(s)") +} + func TestPreflightReport_Indentation(t *testing.T) { report := &PreflightReport{ Items: []PreflightReportItem{ @@ -93,6 +178,52 @@ func TestPreflightReport_Indentation(t *testing.T) { require.Contains(t, result, "indented warning") } +func TestPreflightReport_MultiLineMessageIndentation(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + Message: "Model \"gpt-4o\" not found in eastus2\n" + + "Model not found in AI model catalog.", + }, + }, + } + + result := report.ToString(" ") + lines := strings.Split(result, "\n") + require.Len(t, lines, 2) + // First line has the warning prefix + require.Contains(t, lines[0], "(!) Warning:") + require.Contains(t, lines[0], "Model \"gpt-4o\" not found") + // Second line is indented at the same level + require.True(t, strings.HasPrefix(lines[1], " "), + "continuation line should be indented") + require.Contains(t, lines[1], "Model not found in AI model catalog.") +} + +func TestPreflightReport_MultiLineWithSuggestion(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + Message: "Insufficient quota for model \"gpt-4o\" in eastus2\n" + + "Requested: 99999 · Available: 140", + Suggestion: "Reduce capacity to 140.", + }, + }, + } + + result := report.ToString(" ") + // All three parts should be present in order + msgIdx := indexOf(result, "Insufficient quota") + detailIdx := indexOf(result, "Requested: 99999") + suggIdx := indexOf(result, "Reduce capacity") + require.Greater(t, detailIdx, msgIdx, + "detail should appear after title") + require.Greater(t, suggIdx, detailIdx, + "suggestion should appear after detail") +} + // indexOf returns the byte offset of substr in s, or -1 if not found. func indexOf(s, substr string) int { for i := range len(s) - len(substr) + 1 { @@ -102,3 +233,261 @@ func indexOf(s, substr string) int { } return -1 } + +func TestPreflightReport_WriteItem_EdgeCases(t *testing.T) { + tests := []struct { + name string + item PreflightReportItem + contains []string + excludes []string + }{ + { + name: "empty message", + item: PreflightReportItem{Message: ""}, + excludes: []string{"Warning"}, + }, + { + name: "trailing newline in message", + item: PreflightReportItem{ + Message: "title line\n", + }, + contains: []string{"title line"}, + }, + { + name: "consecutive newlines in message", + item: PreflightReportItem{ + Message: "first\n\nthird", + }, + contains: []string{"first", "third"}, + }, + { + name: "nil links slice", + item: PreflightReportItem{ + Message: "msg", + Links: nil, + }, + contains: []string{"msg"}, + excludes: []string{"•"}, + }, + { + name: "empty links slice", + item: PreflightReportItem{ + Message: "msg", + Links: []PreflightReportLink{}, + }, + contains: []string{"msg"}, + excludes: []string{"•"}, + }, + { + name: "empty suggestion string", + item: PreflightReportItem{ + Message: "msg", + Suggestion: "", + }, + contains: []string{"msg"}, + excludes: []string{"Suggestion:"}, + }, + { + name: "message with leading newline", + item: PreflightReportItem{ + Message: "\nleading newline", + }, + contains: []string{"leading newline"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{tt.item}, + } + result := report.ToString(" ") + for _, s := range tt.contains { + require.Contains(t, result, s) + } + for _, s := range tt.excludes { + require.NotContains(t, result, s) + } + }) + } +} + +// Snapshot tests — one per diagnostic type, plus one combined report. +// Update snapshots with: UPDATE_SNAPSHOTS=true go test ./pkg/output/ux/... + +func TestPreflightReport_Snapshot_RoleAssignmentMissing(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + DiagnosticID: "role_assignment_missing", + Message: "Principal (5a3acce7-bcc4-4ebc-b4b3-c3b9f17535cb)" + + " lacks role assignment permissions on" + + " subscription 3819cb9d-0f7c-4284-9e93-220e7fb2367a\n" + + "The deployment includes role assignments" + + " and will fail without" + + " Microsoft.Authorization/roleAssignments/write" + + " permission.", + Suggestion: "Ensure you have the" + + " 'Role Based Access Control Administrator'," + + " 'User Access Administrator'," + + " 'Owner', or a custom role with" + + " 'Microsoft.Authorization/roleAssignments/write'" + + " assigned to your account.", + }, + }, + } + snapshot.SnapshotT(t, report.ToString(" ")) +} + +func TestPreflightReport_Snapshot_RoleAssignmentConditional(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + DiagnosticID: "role_assignment_conditional", + Message: "Principal (5a3acce7-bcc4-4ebc-b4b3-c3b9f17535cb)" + + " has conditional role assignment permissions on" + + " subscription 3819cb9d-0f7c-4284-9e93-220e7fb2367a\n" + + "An ABAC condition may restrict which roles can be assigned." + + " The deployment may fail if the condition does not permit" + + " the specific role assignments in the template.", + }, + }, + } + snapshot.SnapshotT(t, report.ToString(" ")) +} + +func TestPreflightReport_Snapshot_ReservedResourceName(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + DiagnosticID: "reserved_resource_name", + Message: "Resource \"login-server\"" + + " (Microsoft.Web/sites)" + + " contains the reserved word \"login\"\n" + + "Azure does not allow reserved words in" + + " resource names. The deployment will fail.", + Links: []PreflightReportLink{ + { + URL: "https://learn.microsoft.com/azure/" + + "azure-resource-manager/templates/" + + "error-reserved-resource-name", + Title: "Reserved resource name errors", + }, + }, + }, + }, + } + snapshot.SnapshotT(t, report.ToString(" ")) +} + +func TestPreflightReport_Snapshot_AiModelNotFound(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + DiagnosticID: "ai_model_not_found", + Message: "Model \"no-model\" (SKU: GlobalStandard)" + + " not found in eastus2\n" + + "Model not found in AI model catalog." + + " Provisioning will likely fail.", + Suggestion: "Verify the model name, SKU," + + " and version are correct.", + Links: []PreflightReportLink{ + { + URL: "https://learn.microsoft.com/azure/ai-services/openai/concepts/models", + Title: "Azure OpenAI supported models and regions", + }, + }, + }, + }, + } + snapshot.SnapshotT(t, report.ToString(" ")) +} + +func TestPreflightReport_Snapshot_AiModelQuotaExceeded(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + DiagnosticID: "ai_model_quota_exceeded", + Message: "Insufficient quota for model \"gpt-4o\"" + + " (SKU: GlobalStandard) in eastus2\n" + + "Requested: 99999 · Available: 140", + Suggestion: "Reduce the requested capacity to 140" + + " or change your deployment location via" + + " azd env set AZURE_LOCATION ." + + " You can also request a quota increase" + + " in the Azure portal.", + Links: []PreflightReportLink{ + { + URL: "https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal", + Title: "Increase Azure subscription quotas", + }, + }, + }, + }, + } + snapshot.SnapshotT(t, report.ToString(" ")) +} + +func TestPreflightReport_Snapshot_AllWarningsCombined(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + { + IsError: false, + DiagnosticID: "role_assignment_missing", + Message: "Principal (5a3acce7-bcc4-4ebc-b4b3-c3b9f17535cb)" + + " lacks role assignment permissions on" + + " subscription 3819cb9d-0f7c-4284-9e93-220e7fb2367a\n" + + "The deployment includes role assignments" + + " and will fail without" + + " Microsoft.Authorization/roleAssignments/write" + + " permission.", + Suggestion: "Ensure you have the" + + " 'Role Based Access Control Administrator'," + + " 'User Access Administrator'," + + " 'Owner', or a custom role with" + + " 'Microsoft.Authorization/roleAssignments/write'" + + " assigned to your account.", + }, + { + IsError: false, + DiagnosticID: "ai_model_not_found", + Message: "Model \"no-model\" (SKU: GlobalStandard)" + + " not found in eastus2\n" + + "Model not found in AI model catalog." + + " Provisioning will likely fail.", + Suggestion: "Verify the model name, SKU," + + " and version are correct.", + Links: []PreflightReportLink{ + { + URL: "https://learn.microsoft.com/azure/ai-services/openai/concepts/models", + Title: "Azure OpenAI supported models and regions", + }, + }, + }, + { + IsError: false, + DiagnosticID: "ai_model_quota_exceeded", + Message: "Insufficient quota for model \"gpt-4o\"" + + " (SKU: GlobalStandard) in eastus2\n" + + "Requested: 99999 · Available: 140", + Suggestion: "Reduce the requested capacity to 140" + + " or change your deployment location via" + + " azd env set AZURE_LOCATION ." + + " You can also request a quota increase" + + " in the Azure portal.", + Links: []PreflightReportLink{ + { + URL: "https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal", + Title: "Increase Azure subscription quotas", + }, + }, + }, + }, + } + snapshot.SnapshotT(t, report.ToString(" ")) +} diff --git a/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_AiModelNotFound.snap b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_AiModelNotFound.snap new file mode 100644 index 00000000000..aa296239684 --- /dev/null +++ b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_AiModelNotFound.snap @@ -0,0 +1,4 @@ + (!) Warning: Model "no-model" (SKU: GlobalStandard) not found in eastus2 + Model not found in AI model catalog. Provisioning will likely fail. + Suggestion: Verify the model name, SKU, and version are correct. + • https://learn.microsoft.com/azure/ai-services/openai/concepts/models diff --git a/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_AiModelQuotaExceeded.snap b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_AiModelQuotaExceeded.snap new file mode 100644 index 00000000000..808765abdf8 --- /dev/null +++ b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_AiModelQuotaExceeded.snap @@ -0,0 +1,4 @@ + (!) Warning: Insufficient quota for model "gpt-4o" (SKU: GlobalStandard) in eastus2 + Requested: 99999 · Available: 140 + Suggestion: Reduce the requested capacity to 140 or change your deployment location via azd env set AZURE_LOCATION . You can also request a quota increase in the Azure portal. + • https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal diff --git a/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_AllWarningsCombined.snap b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_AllWarningsCombined.snap new file mode 100644 index 00000000000..e7ccd886b0c --- /dev/null +++ b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_AllWarningsCombined.snap @@ -0,0 +1,11 @@ + (!) Warning: Principal (5a3acce7-bcc4-4ebc-b4b3-c3b9f17535cb) lacks role assignment permissions on subscription 3819cb9d-0f7c-4284-9e93-220e7fb2367a + The deployment includes role assignments and will fail without Microsoft.Authorization/roleAssignments/write permission. + Suggestion: Ensure you have the 'Role Based Access Control Administrator', 'User Access Administrator', 'Owner', or a custom role with 'Microsoft.Authorization/roleAssignments/write' assigned to your account. + (!) Warning: Model "no-model" (SKU: GlobalStandard) not found in eastus2 + Model not found in AI model catalog. Provisioning will likely fail. + Suggestion: Verify the model name, SKU, and version are correct. + • https://learn.microsoft.com/azure/ai-services/openai/concepts/models + (!) Warning: Insufficient quota for model "gpt-4o" (SKU: GlobalStandard) in eastus2 + Requested: 99999 · Available: 140 + Suggestion: Reduce the requested capacity to 140 or change your deployment location via azd env set AZURE_LOCATION . You can also request a quota increase in the Azure portal. + • https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal diff --git a/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_ReservedResourceName.snap b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_ReservedResourceName.snap new file mode 100644 index 00000000000..9404f8a8761 --- /dev/null +++ b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_ReservedResourceName.snap @@ -0,0 +1,3 @@ + (!) Warning: Resource "login-server" (Microsoft.Web/sites) contains the reserved word "login" + Azure does not allow reserved words in resource names. The deployment will fail. + • https://learn.microsoft.com/azure/azure-resource-manager/templates/error-reserved-resource-name diff --git a/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_RoleAssignmentConditional.snap b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_RoleAssignmentConditional.snap new file mode 100644 index 00000000000..8a7fbec1b12 --- /dev/null +++ b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_RoleAssignmentConditional.snap @@ -0,0 +1,2 @@ + (!) Warning: Principal (5a3acce7-bcc4-4ebc-b4b3-c3b9f17535cb) has conditional role assignment permissions on subscription 3819cb9d-0f7c-4284-9e93-220e7fb2367a + An ABAC condition may restrict which roles can be assigned. The deployment may fail if the condition does not permit the specific role assignments in the template. diff --git a/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_RoleAssignmentMissing.snap b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_RoleAssignmentMissing.snap new file mode 100644 index 00000000000..eee09419459 --- /dev/null +++ b/cli/azd/pkg/output/ux/testdata/TestPreflightReport_Snapshot_RoleAssignmentMissing.snap @@ -0,0 +1,3 @@ + (!) Warning: Principal (5a3acce7-bcc4-4ebc-b4b3-c3b9f17535cb) lacks role assignment permissions on subscription 3819cb9d-0f7c-4284-9e93-220e7fb2367a + The deployment includes role assignments and will fail without Microsoft.Authorization/roleAssignments/write permission. + Suggestion: Ensure you have the 'Role Based Access Control Administrator', 'User Access Administrator', 'Owner', or a custom role with 'Microsoft.Authorization/roleAssignments/write' assigned to your account. diff --git a/cli/azd/test/functional/preflight_quota_test.go b/cli/azd/test/functional/preflight_quota_test.go index d0584467298..75be7bebd49 100644 --- a/cli/azd/test/functional/preflight_quota_test.go +++ b/cli/azd/test/functional/preflight_quota_test.go @@ -54,8 +54,10 @@ func Test_CLI_PreflightQuota_RG_DefaultCapacity(t *testing.T) { // In this flow, declining the preflight warning is expected to return successfully, // and the output should contain the quota warning. output := result.Stdout + result.Stderr - require.Contains(t, output, "insufficient quota", + require.Contains(t, output, "Insufficient quota", "expected quota exceeded warning in output") + require.Contains(t, output, "Suggestion:", + "expected actionable suggestion in output") } // Test_CLI_PreflightQuota_RG_InvalidModelName verifies a warning when the model name @@ -92,7 +94,7 @@ func Test_CLI_PreflightQuota_RG_InvalidModelName(t *testing.T) { ) require.NoError(t, err) output := result.Stdout + result.Stderr - require.Contains(t, output, "was not found in the AI model catalog", + require.Contains(t, output, "not found in AI model catalog", "expected model-not-found warning for invalid model name") require.Contains(t, output, "gpt-nonexistent-model") } @@ -131,7 +133,7 @@ func Test_CLI_PreflightQuota_RG_InvalidVersion(t *testing.T) { ) require.NoError(t, err) output := result.Stdout + result.Stderr - require.Contains(t, output, "was not found in the AI model catalog", + require.Contains(t, output, "not found in AI model catalog", "expected model-not-found warning for invalid version") } @@ -164,7 +166,7 @@ func Test_CLI_PreflightQuota_Sub_DefaultCapacity(t *testing.T) { ) require.NoError(t, err) output := result.Stdout + result.Stderr - require.Contains(t, output, "insufficient quota", + require.Contains(t, output, "Insufficient quota", "expected quota exceeded warning in output") } @@ -199,7 +201,7 @@ func Test_CLI_PreflightQuota_Sub_InvalidModelName(t *testing.T) { ) require.NoError(t, err) output := result.Stdout + result.Stderr - require.Contains(t, output, "was not found in the AI model catalog", + require.Contains(t, output, "not found in AI model catalog", "expected model-not-found warning for invalid model name") require.Contains(t, output, "gpt-555-turbo") }