Skip to content
16 changes: 8 additions & 8 deletions cli/azd/magefile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
141 changes: 105 additions & 36 deletions cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand All @@ -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
Expand All @@ -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",
},
},
})
}
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 <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 <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",
},
},
})
}
}
Expand Down
6 changes: 6 additions & 0 deletions cli/azd/pkg/infra/provisioning/bicep/local_preflight.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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.
Expand Down
51 changes: 49 additions & 2 deletions cli/azd/pkg/output/ux/preflight_report.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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:] {
Comment thread
vhvb1989 marked this conversation as resolved.
sb.WriteString(fmt.Sprintf("\n%s%s", indent, line))
Comment thread
vhvb1989 marked this conversation as resolved.
}

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()

Expand Down
Loading
Loading