diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 8eab625aff1..272607279f4 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -697,6 +697,7 @@ func registerCommonDependencies(container *ioc.NestedContainer) { // Tools container.MustRegisterSingleton(azapi.NewResourceService) + container.MustRegisterSingleton(azapi.NewPermissionsService) container.MustRegisterSingleton(docker.NewCli) container.MustRegisterSingleton(dotnet.NewCli) container.MustRegisterSingleton(git.NewCli) diff --git a/cli/azd/docs/design/local-preflight-validation.md b/cli/azd/docs/design/local-preflight-validation.md new file mode 100644 index 00000000000..fc30e257bec --- /dev/null +++ b/cli/azd/docs/design/local-preflight-validation.md @@ -0,0 +1,226 @@ +# Local Preflight Validation + +Local preflight validation is a client-side check that runs automatically before every `azd provision` deployment. It analyzes the compiled ARM template and a Bicep deployment snapshot to detect common issues — such as missing permissions. + +## When It Runs + +The preflight pipeline executes inside `BicepProvider.Deploy()`, after the Bicep module has been compiled and parameters resolved, but **before** the template is sent to Azure for server-side validation or deployment. + +``` +azd provision + │ + ├── Compile Bicep module → ARM template + parameters + ├── ► Local preflight validation ← runs here + │ ├── Parse ARM template (schema, contentVersion, resources) + │ ├── Generate Bicep snapshot (resolved resource graph) + │ ├── Analyze resources (derive properties) + │ └── Run registered check functions + ├── Server-side preflight (Azure ValidatePreflight API) + └── Deploy +``` + +The user can disable preflight entirely by setting `provision.preflight` to `"off"` in their azd user configuration: + +```bash +azd config set provision.preflight off +``` + +## Bicep Snapshots + +Local preflight depends on the `bicep snapshot` command (available in modern Bicep CLI versions). The snapshot produces a **fully resolved deployment graph**: all template expressions are evaluated, conditions are applied, copy loops are expanded, and nested deployments are flattened into a single flat list of predicted resources. + +### Why Snapshots Instead of Manual Parsing + +An ARM template as compiled by `bicep build` still contains unresolved expressions like `"[parameters('location')]"`, conditional resources, and nested deployment modules. Manually parsing these would require reimplementing the ARM expression evaluator. The Bicep snapshot command does this natively and returns the final, concrete set of resources that would be deployed. + +Advantages of snapshots over manual template parsing: + +| Aspect | Manual Parsing | Bicep Snapshot | +|---|---|---| +| Expression resolution | Not possible (e.g. `[concat(...)]`) | Fully resolved | +| Nested deployments | Must recursively extract | Flattened automatically | +| Conditional resources | Cannot evaluate `condition` expressions | Excluded when false | +| Copy loops | Cannot expand `copy` blocks | Expanded to individual resources | +| Resource IDs | Symbolic names only | Resolved resource IDs | + +### How Snapshots Are Generated + +1. **Determine the parameters file.** If the module path is a `.bicepparam` file, it is used directly. Otherwise, a temporary `.bicepparam` file is generated from the resolved ARM parameters using `generateBicepParam()`. This file is placed next to the source `.bicep` module so that relative `using` paths resolve correctly. + +2. **Build snapshot options.** The deployment target (`infra.Deployment`) provides scope information: + - **Subscription-scoped deployments** → `--subscription-id` and `--location` flags. + - **Resource-group-scoped deployments** → `--subscription-id` and `--resource-group` flags. + +3. **Invoke `bicep snapshot`.** The Bicep CLI generates a `.snapshot.json` file. azd reads it into memory and deletes the temporary file. + +4. **Parse the snapshot.** The JSON output contains a `predictedResources` array — each entry is a fully resolved resource with type, apiVersion, name, location, properties, and so on. + +``` +┌──────────────────────────┐ +│ main.bicep │ +│ + parameters │ +└──────────┬───────────────┘ + │ + generateBicepParam() + │ + ▼ +┌──────────────────────────┐ +│ preflight-*.bicepparam │ (temporary) +└──────────┬───────────────┘ + │ + bicep snapshot --subscription-id ... --resource-group ... + │ + ▼ +┌──────────────────────────┐ +│ preflight-*.snapshot.json│ +│ { │ +│ "predictedResources": │ +│ [ │ +│ { type, name, ... } │ +│ { type, name, ... } │ +│ ] │ +│ } │ +└──────────────────────────┘ +``` + +## Check Pipeline + +The preflight system uses a pluggable pipeline of check functions. Each check receives a `validationContext` containing: + +- **`Console`** — for user interaction (prompts, messages). +- **`Props`** — derived properties from the resource analysis (e.g. `HasRoleAssignments`). +- **`ResourcesSnapshot`** — the raw JSON from `bicep snapshot`. +- **`SnapshotResources`** — the parsed `[]armTemplateResource` list from the snapshot. + +Checks are registered via `AddCheck()` before calling `validate()`. They run in registration order and each returns either: + +- `nil` — nothing to report (check passed). +- `*PreflightCheckResult` with a `Severity` (`PreflightCheckWarning` or `PreflightCheckError`) and a `Message`. + +### Adding a New Check + +To add a new preflight check: + +```go +localPreflight.AddCheck(func(ctx context.Context, valCtx *validationContext) (*PreflightCheckResult, error) { + // Inspect valCtx.SnapshotResources, valCtx.Props, etc. + for _, res := range valCtx.SnapshotResources { + if strings.EqualFold(res.Type, "Microsoft.SomeProvider/problematicResource") { + return &PreflightCheckResult{ + Severity: PreflightCheckWarning, + Message: "This resource type requires additional configuration.", + }, nil + } + } + return nil, nil // nothing to report +}) +``` + +### Built-in Checks + +| Check | What It Does | Severity | +|---|---|---| +| Role assignment permissions | Detects `Microsoft.Authorization/roleAssignments` in the snapshot and verifies the current principal has `roleAssignments/write` permission on the subscription. | Warning | + +## UX Presentation + +Results are displayed using the `PreflightReport` UX component (`pkg/output/ux/preflight_report.go`), which implements the standard `UxItem` interface. The report groups and orders findings: all warnings appear first, followed by all errors. Each entry is prefixed with the standard azd status icons. + +## Scenarios + +### Scenario 1: No Issues Found + +All registered checks pass. No output is printed from the preflight step. The deployment proceeds directly to server-side validation and then Azure deployment. + +``` +Validating deployment (✓) Done: + +Creating/Updating resources ... +``` + +### Scenario 2: Warnings Only + +One or more checks return warnings but no errors. The warnings are displayed and the user is prompted to continue. The default selection is **Yes** — pressing Enter continues the deployment. + +``` +Validating deployment + +(!) Warning: the current principal (abc-123) does not have permission +to create role assignments (Microsoft.Authorization/roleAssignments/write) +on subscription sub-456. The deployment includes role assignments and +will fail without this permission. + +? Preflight validation found warnings that may cause the deployment + to fail. Do you want to continue? (Y/n) +``` + +If the user confirms (or accepts the default), deployment proceeds normally. If the user declines, the operation is aborted with a zero exit code (an intentional abort, not a failure). + +### Scenario 3: Errors Only + +One or more checks return errors. The errors are displayed and the deployment is **immediately aborted** — the user is not prompted. The CLI exits with a zero exit code. + +``` +Validating deployment + +(x) Failed: critical configuration error detected in template + +preflight validation detected errors, deployment aborted +``` + +Note: the exit code is **zero** because the preflight validation **successfully** detected problems and intentionally aborted the deployment. This is not an unexpected internal failure — the CLI completed its task (validating and reporting errors) without encountering any execution errors itself. + +### Scenario 4: Warnings and Errors + +When the report contains both warnings and errors, warnings are listed first and errors second. Because errors are present the deployment is aborted immediately — the warning prompt is skipped. + +``` +Validating deployment + +(!) Warning: the current principal does not have permission to create +role assignments on this subscription. + +(x) Failed: required parameter 'storageAccountName' is missing from +the deployment. + +preflight validation detected errors, deployment aborted +``` + +### Scenario 5: Check Function Returns an Error + +If a check function itself fails (returns a Go `error` rather than a `*PreflightCheckResult`), this is treated as an infrastructure failure. The CLI reports it as a hard error and exits with a non-zero code. This is distinct from a check returning a result with `PreflightCheckError` severity — that case means "we successfully detected a problem in the template", while an error return means "something went wrong while trying to run the check". + +``` +ERROR: local preflight validation failed: preflight check failed: +``` + +## Exit Code Behavior + +The exit code distinguishes between **successful operation** (the CLI did what it was supposed to do) and **internal failure** (the CLI could not complete its task). + +Preflight validation detecting errors and aborting the deployment is a **successful outcome** — the CLI performed the validation and correctly prevented a bad deployment. Only failures in the validation machinery itself produce a non-zero exit code. + +| Outcome | Exit Code | Rationale | +|---|---|---| +| No issues | 0 | Deployment proceeds and succeeds. | +| Warnings only, user continues | 0 | User acknowledged warnings; deployment proceeds. | +| Warnings only, user declines | 0 | User chose to abort; intentional, not a failure. | +| Errors detected | 0 | Validation successfully detected problems and aborted the deployment. | +| Check function error | 1 | Internal failure running a check (the `validate` function returned a non-nil error). | + +## File Layout + +``` +pkg/ +├── infra/provisioning/bicep/ +│ ├── local_preflight.go # Core pipeline, ARM types, parseTemplate, analyzeResources +│ ├── local_preflight_test.go # Unit tests for parsing, analysis, check pipeline +│ ├── role_assignment_check_test.go # Tests for the role assignment check +│ ├── generate_bicep_param_test.go # Tests for .bicepparam generation +│ └── bicep_provider.go # validatePreflight() integration, checkRoleAssignmentPermissions +├── output/ux/ +│ ├── preflight_report.go # PreflightReport UxItem +│ └── preflight_report_test.go # Tests for PreflightReport +└── tools/bicep/ + └── bicep.go # Snapshot() method, SnapshotOptions builder +``` diff --git a/cli/azd/pkg/azapi/permissions.go b/cli/azd/pkg/azapi/permissions.go new file mode 100644 index 00000000000..9f02b3097fa --- /dev/null +++ b/cli/azd/pkg/azapi/permissions.go @@ -0,0 +1,260 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azapi + +import ( + "context" + "fmt" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" + "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2" + "github.com/azure/azure-dev/cli/azd/pkg/account" +) + +// PermissionsService checks whether the current user has specific Azure RBAC permissions +// at a given scope (e.g. subscription). +// It works by listing the role assignments for the principal and then inspecting the role +// definitions to determine whether a required action (such as +// "Microsoft.Authorization/roleAssignments/write") is allowed. +type PermissionsService struct { + credentialProvider account.SubscriptionCredentialProvider + armClientOptions *arm.ClientOptions +} + +// NewPermissionsService creates a new PermissionsService. +func NewPermissionsService( + credentialProvider account.SubscriptionCredentialProvider, + armClientOptions *arm.ClientOptions, +) *PermissionsService { + return &PermissionsService{ + credentialProvider: credentialProvider, + armClientOptions: armClientOptions, + } +} + +// HasRequiredPermissions checks whether the given principal has all the specified +// permissions at the subscription scope. Each required permission should be an Azure +// resource provider action string such as +// "Microsoft.Authorization/roleAssignments/write". +// +// The check is performed by: +// 1. Listing all role assignments for the principal on the subscription. +// 2. Retrieving the role definition for each assignment. +// 3. Checking that the required actions are included in the allowed actions +// and not excluded by NotActions. +func (s *PermissionsService) HasRequiredPermissions( + ctx context.Context, + subscriptionId string, + principalId string, + requiredActions []string, +) (bool, error) { + credential, err := s.credentialProvider.CredentialForSubscription(ctx, subscriptionId) + if err != nil { + return false, fmt.Errorf("getting credential for subscription %s: %w", subscriptionId, err) + } + + // Create a role assignments client to list the principal's role assignments at subscription scope. + roleAssignmentsClient, err := armauthorization.NewRoleAssignmentsClient( + subscriptionId, credential, s.armClientOptions, + ) + if err != nil { + return false, fmt.Errorf("creating role assignments client: %w", err) + } + + // Create a role definitions client to retrieve the definition for each assignment. + roleDefinitionsClient, err := armauthorization.NewRoleDefinitionsClient(credential, s.armClientOptions) + if err != nil { + return false, fmt.Errorf("creating role definitions client: %w", err) + } + + // Collect all role definition IDs assigned to this principal at subscription scope. + // Use assignedTo() filter which is supported by the API and also captures + // role assignments inherited through group membership. + subscriptionScope := fmt.Sprintf("/subscriptions/%s", subscriptionId) + filter := fmt.Sprintf("assignedTo('%s')", principalId) + pager := roleAssignmentsClient.NewListForScopePager( + subscriptionScope, + &armauthorization.RoleAssignmentsClientListForScopeOptions{ + Filter: to.Ptr(filter), + }, + ) + + roleDefinitionIDs := []string{} + for pager.More() { + page, err := pager.NextPage(ctx) + if err != nil { + return false, fmt.Errorf("listing role assignments for principal %s: %w", principalId, err) + } + for _, ra := range page.Value { + if ra.Properties != nil && ra.Properties.RoleDefinitionID != nil { + roleDefinitionIDs = append(roleDefinitionIDs, *ra.Properties.RoleDefinitionID) + } + } + } + + if len(roleDefinitionIDs) == 0 { + return false, nil + } + + // Build the set of allowed actions from all role definitions. + allowedActions, err := s.collectAllowedActions(ctx, roleDefinitionsClient, roleDefinitionIDs) + if err != nil { + return false, err + } + + // Check that every required action is covered. + for _, required := range requiredActions { + if !isActionAllowed(required, allowedActions) { + return false, nil + } + } + + return true, nil +} + +// allowedActionSet represents the collected allowed and denied actions from role definitions. +type allowedActionSet struct { + actions []string + notActions []string +} + +// collectAllowedActions retrieves the allowed/denied actions from all specified role definitions. +func (s *PermissionsService) collectAllowedActions( + ctx context.Context, + client *armauthorization.RoleDefinitionsClient, + roleDefinitionIDs []string, +) (*allowedActionSet, error) { + result := &allowedActionSet{} + + for _, rdID := range roleDefinitionIDs { + resp, err := client.GetByID(ctx, rdID, nil) + if err != nil { + return nil, fmt.Errorf("getting role definition %s: %w", rdID, err) + } + + if resp.Properties == nil || resp.Properties.Permissions == nil { + continue + } + + for _, perm := range resp.Properties.Permissions { + if perm.Actions != nil { + for _, action := range perm.Actions { + if action != nil { + result.actions = append(result.actions, *action) + } + } + } + if perm.NotActions != nil { + for _, notAction := range perm.NotActions { + if notAction != nil { + result.notActions = append(result.notActions, *notAction) + } + } + } + } + } + + return result, nil +} + +// isActionAllowed checks whether the given action is matched by any allowed action +// and not excluded by any NotAction. Action matching supports the wildcard "*". +func isActionAllowed(requiredAction string, actions *allowedActionSet) bool { + matched := false + for _, action := range actions.actions { + if actionMatches(action, requiredAction) { + matched = true + break + } + } + + if !matched { + return false + } + + // Check if any NotAction excludes this action. + for _, notAction := range actions.notActions { + if actionMatches(notAction, requiredAction) { + return false + } + } + + return true +} + +// actionMatches checks whether a pattern (which may contain '*' wildcards) matches the target action. +// Azure RBAC uses case-insensitive matching. The wildcard '*' matches any sequence of characters. +// Common patterns: "*" (matches everything), "Microsoft.Authorization/*", +// "Microsoft.Authorization/roleAssignments/*", "Microsoft.Authorization/roleAssignments/write", +// "Microsoft.Authorization/*/Write". +func actionMatches(pattern, target string) bool { + pattern = strings.ToLower(pattern) + target = strings.ToLower(target) + + // Simple equality. + if pattern == target { + return true + } + + // Full wildcard. + if pattern == "*" { + return true + } + + // Handle patterns with '*' wildcards by splitting into segments and matching each in order. + if strings.Contains(pattern, "*") { + return wildcardMatch(pattern, target) + } + + return false +} + +// wildcardMatch matches target against a pattern that may contain one or more '*' wildcards, +// where each '*' matches zero or more characters. +// +// The algorithm: +// - The first segment (before the first '*') must be a strict prefix of target. +// - The last segment (after the last '*') must be a strict suffix of target. +// - All middle segments must appear in order in the remaining portion of target. +func wildcardMatch(pattern, target string) bool { + segments := strings.Split(pattern, "*") + + // First segment must match as a prefix (unless empty, meaning pattern starts with '*'). + lo := 0 + if segments[0] != "" { + if !strings.HasPrefix(target, segments[0]) { + return false + } + lo = len(segments[0]) + } + + // Last segment must match as a suffix (unless empty, meaning pattern ends with '*'). + hi := len(target) + if lastSeg := segments[len(segments)-1]; lastSeg != "" { + if !strings.HasSuffix(target, lastSeg) { + return false + } + hi = len(target) - len(lastSeg) + if hi < lo { + return false + } + } + + // Middle segments must appear in order within target[lo:hi]. + pos := lo + for _, seg := range segments[1 : len(segments)-1] { + if seg == "" { + continue + } + idx := strings.Index(target[pos:hi], seg) + if idx == -1 { + return false + } + pos += idx + len(seg) + } + + return true +} diff --git a/cli/azd/pkg/azapi/permissions_test.go b/cli/azd/pkg/azapi/permissions_test.go new file mode 100644 index 00000000000..eedb834900f --- /dev/null +++ b/cli/azd/pkg/azapi/permissions_test.go @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestActionMatches(t *testing.T) { + tests := []struct { + name string + pattern string + target string + want bool + }{ + { + name: "exact match", + pattern: "Microsoft.Authorization/roleAssignments/write", + target: "Microsoft.Authorization/roleAssignments/write", + want: true, + }, + { + name: "case insensitive match", + pattern: "microsoft.authorization/roleassignments/write", + target: "Microsoft.Authorization/roleAssignments/write", + want: true, + }, + { + name: "full wildcard", + pattern: "*", + target: "Microsoft.Authorization/roleAssignments/write", + want: true, + }, + { + name: "trailing wildcard match", + pattern: "Microsoft.Authorization/*", + target: "Microsoft.Authorization/roleAssignments/write", + want: true, + }, + { + name: "trailing wildcard at resource level", + pattern: "Microsoft.Authorization/roleAssignments/*", + target: "Microsoft.Authorization/roleAssignments/write", + want: true, + }, + { + name: "no match different provider", + pattern: "Microsoft.Storage/*", + target: "Microsoft.Authorization/roleAssignments/write", + want: false, + }, + { + name: "no match different action", + pattern: "Microsoft.Authorization/roleAssignments/read", + target: "Microsoft.Authorization/roleAssignments/write", + want: false, + }, + { + name: "empty pattern", + pattern: "", + target: "Microsoft.Authorization/roleAssignments/write", + want: false, + }, + { + name: "multi-wildcard match any provider", + pattern: "*/roleAssignments/write", + target: "Microsoft.Authorization/roleAssignments/write", + want: true, + }, + { + name: "multi-wildcard match any resource type", + pattern: "Microsoft.*/roleAssignments/*", + target: "Microsoft.Authorization/roleAssignments/write", + want: true, + }, + { + name: "multi-wildcard match all segments", + pattern: "*/*/*", + target: "Microsoft.Authorization/roleAssignments/write", + want: true, + }, + { + name: "multi-wildcard no match different provider prefix", + pattern: "Microsoft.*/roleAssignments/*", + target: "NotMicrosoft.Authorization/roleAssignments/write", + want: false, + }, + { + name: "multi-wildcard no match missing segment", + pattern: "Microsoft.*/roleAssignments/*", + target: "Microsoft.Authorization/write", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := actionMatches(tt.pattern, tt.target) + require.Equal(t, tt.want, got) + }) + } +} + +func TestIsActionAllowed(t *testing.T) { + tests := []struct { + name string + requiredAction string + actions *allowedActionSet + want bool + }{ + { + name: "allowed by exact match", + requiredAction: "Microsoft.Authorization/roleAssignments/write", + actions: &allowedActionSet{ + actions: []string{"Microsoft.Authorization/roleAssignments/write"}, + notActions: nil, + }, + want: true, + }, + { + name: "allowed by wildcard", + requiredAction: "Microsoft.Authorization/roleAssignments/write", + actions: &allowedActionSet{ + actions: []string{"*"}, + notActions: nil, + }, + want: true, + }, + { + name: "denied by NotActions", + requiredAction: "Microsoft.Authorization/roleAssignments/write", + actions: &allowedActionSet{ + actions: []string{"*"}, + notActions: []string{"Microsoft.Authorization/roleAssignments/write"}, + }, + want: false, + }, + { + name: "denied by NotActions wildcard", + requiredAction: "Microsoft.Authorization/roleAssignments/write", + actions: &allowedActionSet{ + actions: []string{"*"}, + notActions: []string{"Microsoft.Authorization/*"}, + }, + want: false, + }, + { + name: "not matched at all", + requiredAction: "Microsoft.Authorization/roleAssignments/write", + actions: &allowedActionSet{ + actions: []string{"Microsoft.Storage/*"}, + notActions: nil, + }, + want: false, + }, + { + name: "empty actions", + requiredAction: "Microsoft.Authorization/roleAssignments/write", + actions: &allowedActionSet{ + actions: nil, + notActions: nil, + }, + want: false, + }, + { + name: "allowed by provider wildcard not blocked", + requiredAction: "Microsoft.Authorization/roleAssignments/write", + actions: &allowedActionSet{ + actions: []string{"Microsoft.Authorization/*"}, + notActions: []string{"Microsoft.Storage/*"}, + }, + want: true, + }, + { + name: "Contributor role pattern - allowed by star but blocked by NotActions", + requiredAction: "Microsoft.Authorization/roleAssignments/write", + actions: &allowedActionSet{ + actions: []string{"*"}, + notActions: []string{ + "Microsoft.Authorization/*/Delete", + "Microsoft.Authorization/*/Write", + "Microsoft.Authorization/elevateAccess/Action", + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isActionAllowed(tt.requiredAction, tt.actions) + require.Equal(t, tt.want, got) + }) + } +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index bff5f33f362..ceebfab0816 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -38,6 +38,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" + "github.com/azure/azure-dev/cli/azd/pkg/ioc" "github.com/azure/azure-dev/cli/azd/pkg/keyvault" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" @@ -84,7 +85,7 @@ type BicepProvider struct { keyvaultService keyvault.KeyVaultService subscriptionManager *account.SubscriptionsManager aiModelService *ai.AiModelService - userConfigManager config.UserConfigManager + serviceLocator ioc.ServiceLocator // Internal state // compileBicepResult is cached to avoid recompiling the same bicep file multiple times in the same azd run. @@ -683,8 +684,9 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, // Check if preflight validation is disabled via config skipPreflight := false - if p.userConfigManager != nil { - if userConfig, err := p.userConfigManager.Load(); err == nil { + var userConfigManager config.UserConfigManager + if err := p.serviceLocator.Resolve(&userConfigManager); err == nil { + if userConfig, err := userConfigManager.Load(); err == nil { if val, exists := userConfig.GetString("provision.preflight"); exists && val == "off" { skipPreflight = true } @@ -693,9 +695,10 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, if !skipPreflight { p.console.ShowSpinner(ctx, "Validating deployment", input.Step) - preflightErr := p.validatePreflight( + abort, preflightErr := p.validatePreflight( ctx, deployment, + p.path, planned.RawArmTemplate, planned.Parameters, deploymentTags, @@ -705,6 +708,12 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, p.console.StopSpinner(ctx, "Validating deployment", input.StepFailed) return nil, preflightErr } + if abort { + // Preflight detected issues and the deployment was intentionally aborted. + // This is a successful operation (exit code 0), not an internal failure. + p.console.StopSpinner(ctx, "Validating deployment", input.StepSkipped) + return &provisioning.DeployResult{SkippedReason: provisioning.PreflightAbortedSkipped}, nil + } p.console.StopSpinner(ctx, "", input.StepDone) } @@ -2127,15 +2136,127 @@ func (p *BicepProvider) convertToDeployment(bicepTemplate azure.ArmTemplate) pro return result } +// validatePreflight runs client-side preflight validation on the ARM template. +// It returns (abort, err) where: +// - abort=true, err=nil: checks detected issues and the deployment should be skipped (exit code 0). +// - abort=false, err!=nil: the validation itself failed to run (exit code 1). +// - abort=false, err=nil: validation passed, proceed with deployment. func (p *BicepProvider) validatePreflight( ctx context.Context, target infra.Deployment, + modulePath string, armTemplate azure.RawArmTemplate, armParameters azure.ArmParameters, tags map[string]*string, options map[string]any, -) error { - return target.ValidatePreflight(ctx, armTemplate, armParameters, tags, options) +) (bool, error) { + // Run local preflight validation before sending to Azure. + // Local validation catches common issues without requiring a network round-trip. + localPreflight := newLocalArmPreflight(modulePath, p.bicepCli, target) + + // Register the role assignment permission check so it runs as part of the + // local preflight pipeline. The check inspects whether the template contains + // Microsoft.Authorization/roleAssignments and, if so, verifies the current + // principal has the required write permission. + localPreflight.AddCheck(p.checkRoleAssignmentPermissions) + + results, err := localPreflight.validate(ctx, p.console, armTemplate, armParameters) + if err != nil { + return false, fmt.Errorf("local preflight validation failed: %w", err) + } + + // Build a UX report from the preflight results and display it. + if len(results) > 0 { + report := &ux.PreflightReport{} + for _, result := range results { + report.Items = append(report.Items, ux.PreflightReportItem{ + IsError: result.Severity == PreflightCheckError, + Message: result.Message, + }) + } + p.console.MessageUxItem(ctx, report) + + if report.HasErrors() { + // Errors were already displayed by the UX report above. The validation + // successfully detected problems and the deployment is intentionally aborted. + // This is not an internal failure, so no error is returned (exit code 0). + p.console.Message(ctx, "Preflight validation detected errors, deployment aborted.") + return true, nil + } + + if report.HasWarnings() { + 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?", + DefaultValue: true, + }) + if promptErr != nil { + return false, fmt.Errorf("prompting for preflight confirmation: %w", promptErr) + } + if !continueDeployment { + // User chose not to continue — this is an intentional abort, not a failure. + return true, nil + } + } + } + + return false, target.ValidatePreflight(ctx, armTemplate, armParameters, tags, options) +} + +// checkRoleAssignmentPermissions is a PreflightCheckFn that verifies the current principal +// has Microsoft.Authorization/roleAssignments/write permission when the template contains +// role assignments. The PermissionsService is resolved lazily via the service locator so it +// is only instantiated when actually needed. +func (p *BicepProvider) checkRoleAssignmentPermissions( + ctx context.Context, valCtx *validationContext, +) (*PreflightCheckResult, error) { + if !valCtx.Props.HasRoleAssignments { + return nil, nil + } + + var permissionsService *azapi.PermissionsService + if err := p.serviceLocator.Resolve(&permissionsService); err != nil { + log.Printf( + "could not resolve PermissionsService, skipping role assignment permission check: %v", err) + return nil, nil + } + + principalId, err := p.curPrincipal.CurrentPrincipalId(ctx) + if err != nil { + log.Printf( + "could not determine current principal, skipping role assignment permission check: %v", err) + return nil, nil + } + + subscriptionId := p.env.GetSubscriptionId() + requiredActions := []string{ + "Microsoft.Authorization/roleAssignments/write", + } + + hasPermission, err := permissionsService.HasRequiredPermissions( + ctx, subscriptionId, principalId, requiredActions, + ) + if err != nil { + log.Printf("error checking role assignment permissions, skipping check: %v", err) + return nil, nil + } + + if !hasPermission { + return &PreflightCheckResult{ + Severity: PreflightCheckWarning, + Message: fmt.Sprintf( + "the current principal (%s) does not have permission to create role assignments "+ + "(Microsoft.Authorization/roleAssignments/write) on subscription %s. "+ + "The deployment includes role assignments and will fail without this permission. "+ + "Ensure you have the 'User Access Administrator', 'Owner', or a custom role with "+ + "'Microsoft.Authorization/roleAssignments/write' assigned to your account.", + principalId, + subscriptionId, + ), + }, nil + } + + return nil, nil } // Deploys the specified Bicep module and parameters with the selected provisioning scope (subscription vs resource group) @@ -2546,7 +2667,7 @@ func NewBicepProvider( cloud *cloud.Cloud, subscriptionManager *account.SubscriptionsManager, aiModelService *ai.AiModelService, - userConfigManager config.UserConfigManager, + serviceLocator ioc.ServiceLocator, ) provisioning.Provider { return &BicepProvider{ envManager: envManager, @@ -2563,7 +2684,7 @@ func NewBicepProvider( portalUrlBase: cloud.PortalUrlBase, subscriptionManager: subscriptionManager, aiModelService: aiModelService, - userConfigManager: userConfigManager, + serviceLocator: serviceLocator, } } diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go index 684e69eb8f4..3f825a8f095 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go @@ -452,7 +452,7 @@ func createBicepProvider(t *testing.T, mockContext *mocks.MockContext) *BicepPro cloud.AzurePublic(), nil, nil, - nil, + mockContext.Container, ) err := provider.Initialize(*mockContext.Context, projectDir, options) @@ -1128,7 +1128,7 @@ func TestUserDefinedTypes(t *testing.T) { cloud.AzurePublic(), nil, nil, - nil, + mockContext.Container, ) bicepProvider, gooCast := provider.(*BicepProvider) require.True(t, gooCast) @@ -1782,7 +1782,7 @@ func createBicepProviderWithEnv( cloud.AzurePublic(), nil, nil, - nil, + mockContext.Container, ) err := provider.Initialize(*mockContext.Context, projectDir, options) diff --git a/cli/azd/pkg/infra/provisioning/bicep/generate_bicep_param_test.go b/cli/azd/pkg/infra/provisioning/bicep/generate_bicep_param_test.go new file mode 100644 index 00000000000..cf13d41cb7b --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/bicep/generate_bicep_param_test.go @@ -0,0 +1,154 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package bicep + +import ( + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azure" + "github.com/stretchr/testify/require" +) + +func TestGenerateBicepParam_EmptyParams(t *testing.T) { + result := generateBicepParam("main.bicep", azure.ArmParameters{}) + + expected := "using 'main.bicep'\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_StringParam(t *testing.T) { + params := azure.ArmParameters{ + "location": {Value: "eastus2"}, + } + + result := generateBicepParam("main.bicep", params) + + expected := "using 'main.bicep'\n\nparam location = 'eastus2'\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_MultipleParamsSorted(t *testing.T) { + params := azure.ArmParameters{ + "location": {Value: "eastus2"}, + "environmentName": {Value: "dev"}, + "sku": {Value: "Standard"}, + } + + result := generateBicepParam("infra/main.bicep", params) + + expected := "using 'infra/main.bicep'\n\n" + + "param environmentName = 'dev'\n\n" + + "param location = 'eastus2'\n\n" + + "param sku = 'Standard'\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_BoolParam(t *testing.T) { + params := azure.ArmParameters{ + "enableLogging": {Value: true}, + "debugMode": {Value: false}, + } + + result := generateBicepParam("main.bicep", params) + + expected := "using 'main.bicep'\n\n" + + "param debugMode = false\n\n" + + "param enableLogging = true\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_NumericParam(t *testing.T) { + params := azure.ArmParameters{ + "count": {Value: float64(3)}, + } + + result := generateBicepParam("main.bicep", params) + + expected := "using 'main.bicep'\n\nparam count = 3\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_NullParam(t *testing.T) { + params := azure.ArmParameters{ + "optionalValue": {Value: nil}, + } + + result := generateBicepParam("main.bicep", params) + + expected := "using 'main.bicep'\n\nparam optionalValue = null\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_ArrayParam(t *testing.T) { + params := azure.ArmParameters{ + "zones": {Value: []any{"1", "2", "3"}}, + } + + result := generateBicepParam("main.bicep", params) + + expected := "using 'main.bicep'\n\nparam zones = [\n '1'\n '2'\n '3'\n]\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_ObjectParam(t *testing.T) { + params := azure.ArmParameters{ + "tags": {Value: map[string]any{ + "env": "prod", + "project": "myapp", + }}, + } + + result := generateBicepParam("main.bicep", params) + + expected := "using 'main.bicep'\n\nparam tags = {\n env: 'prod'\n project: 'myapp'\n}\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_SkipsKeyVaultReferences(t *testing.T) { + params := azure.ArmParameters{ + "location": {Value: "eastus2"}, + "secret": { + KeyVaultReference: &azure.KeyVaultParameterReference{ + KeyVault: azure.KeyVaultReference{ + ID: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.KeyVault/vaults/myvault", + }, + SecretName: "mySecret", + }, + }, + } + + result := generateBicepParam("main.bicep", params) + + expected := "using 'main.bicep'\n\nparam location = 'eastus2'\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_StringWithSingleQuotes(t *testing.T) { + params := azure.ArmParameters{ + "message": {Value: "it's a test"}, + } + + result := generateBicepParam("main.bicep", params) + + expected := "using 'main.bicep'\n\nparam message = 'it''s a test'\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_EmptyObject(t *testing.T) { + params := azure.ArmParameters{ + "config": {Value: map[string]any{}}, + } + + result := generateBicepParam("main.bicep", params) + + expected := "using 'main.bicep'\n\nparam config = {}\n" + require.Equal(t, expected, result) +} + +func TestGenerateBicepParam_NilParams(t *testing.T) { + result := generateBicepParam("main.bicep", nil) + + expected := "using 'main.bicep'\n" + require.Equal(t, expected, result) +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go b/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go new file mode 100644 index 00000000000..b9208082e53 --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/bicep/local_preflight.go @@ -0,0 +1,506 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package bicep + +import ( + "context" + "encoding/json" + "fmt" + "log" + "maps" + "os" + "path/filepath" + "slices" + "strings" + + "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/tools/bicep" +) + +// armTemplateResource represents a single resource declaration within an ARM template. +// It follows the schema defined at: +// https://learn.microsoft.com/azure/azure-resource-manager/templates/resource-declaration +type armTemplateResource struct { + // Type is the resource type including namespace (e.g. "Microsoft.Storage/storageAccounts"). + Type string `json:"type"` + // APIVersion is the REST API version to use for the resource (e.g. "2023-01-01"). + APIVersion string `json:"apiVersion"` + // Name is the name of the resource, may contain ARM template expressions. + Name string `json:"name"` + // Location is the deployment location for the resource. + Location string `json:"location,omitempty"` + // Tags are resource tags. Stored as json.RawMessage because the value can be either + // a map[string]string literal or an ARM expression string (e.g. "[variables('tags')]"). + Tags json.RawMessage `json:"tags,omitempty"` + // DependsOn lists symbolic names or resource IDs of resources that must be deployed first. + DependsOn []string `json:"dependsOn,omitempty"` + // Kind is the resource kind (e.g. "StorageV2" for storage or "app,linux" for web apps). + Kind string `json:"kind,omitempty"` + // SKU is the pricing tier / SKU for the resource. + SKU *armTemplateSKU `json:"sku,omitempty"` + // Plan is the marketplace plan for the resource. + Plan *armTemplatePlan `json:"plan,omitempty"` + // Identity is the managed identity configuration for the resource. + Identity *armTemplateIdentity `json:"identity,omitempty"` + // Properties is the resource-specific configuration. + Properties json.RawMessage `json:"properties,omitempty"` + // Condition is an expression that evaluates to true/false controlling whether the resource is deployed. + Condition any `json:"condition,omitempty"` + // Copy defines iteration for deploying multiple instances. + Copy *armTemplateCopy `json:"copy,omitempty"` + // Comments are optional authoring comments. + Comments string `json:"comments,omitempty"` + // Scope is used when deploying extension resources or cross-scope resources. + Scope string `json:"scope,omitempty"` + // Resources are child resources nested inside this resource declaration. + // Uses armTemplateResources to handle both array and symbolic-name map formats. + Resources armTemplateResources `json:"resources,omitempty"` + // Zones lists Availability Zones for the resource (e.g. ["1","2","3"]). + Zones []string `json:"zones,omitempty"` +} + +// armTemplateSKU represents the SKU block of an ARM resource. +type armTemplateSKU struct { + Name string `json:"name"` + Tier string `json:"tier,omitempty"` + Size string `json:"size,omitempty"` + Family string `json:"family,omitempty"` + Capacity *int `json:"capacity,omitempty"` +} + +// armTemplatePlan represents a marketplace plan block. +type armTemplatePlan struct { + Name string `json:"name"` + Publisher string `json:"publisher,omitempty"` + Product string `json:"product,omitempty"` + PromotionCode string `json:"promotionCode,omitempty"` + Version string `json:"version,omitempty"` +} + +// armTemplateIdentity represents the managed identity configuration. +type armTemplateIdentity struct { + Type string `json:"type"` + UserAssignedIdentities map[string]armTemplateIdentityRef `json:"userAssignedIdentities,omitempty"` +} + +// armTemplateIdentityRef is an entry in userAssignedIdentities (value is typically empty). +type armTemplateIdentityRef struct { + ClientID string `json:"clientId,omitempty"` + PrincipalID string `json:"principalId,omitempty"` +} + +// armTemplateCopy describes the copy/iteration loop for a resource. +type armTemplateCopy struct { + Name string `json:"name"` + Count any `json:"count"` // can be int or expression string + Mode string `json:"mode,omitempty"` // "serial" or "parallel" (default) + BatchSize *int `json:"batchSize,omitempty"` // used when mode is "serial" +} + +// armTemplateVariable represents a variable value in the template. Variables can hold any JSON type. +type armTemplateVariable = json.RawMessage + +// armTemplateFunction represents a user-defined function in an ARM template. +type armTemplateFunction struct { + Namespace string `json:"namespace"` + Members map[string]armTemplateMember `json:"members"` +} + +// armTemplateMember represents a single member in a user-defined function namespace. +type armTemplateMember struct { + Parameters []armTemplateMemberParameter `json:"parameters,omitempty"` + Output armTemplateMemberOutput `json:"output"` +} + +// armTemplateMemberParameter is a parameter declaration inside a user-defined function. +type armTemplateMemberParameter struct { + Name string `json:"name"` + Type string `json:"type"` +} + +// armTemplateMemberOutput is the output declaration of a user-defined function. +type armTemplateMemberOutput struct { + Type string `json:"type"` + Value any `json:"value"` +} + +// armTemplateParameterDef is the parser's own representation of an ARM template parameter definition. +// This is intentionally separate from azure.ArmTemplateParameterDefinition to keep the parser self-contained. +type armTemplateParameterDef struct { + Type string `json:"type"` + DefaultValue any `json:"defaultValue,omitempty"` + AllowedValues []any `json:"allowedValues,omitempty"` + MinValue *int `json:"minValue,omitempty"` + MaxValue *int `json:"maxValue,omitempty"` + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Metadata map[string]json.RawMessage `json:"metadata,omitempty"` + Description string `json:"-"` // extracted from metadata +} + +// armTemplateOutputDef represents an output declaration in the ARM template. +type armTemplateOutputDef struct { + Type string `json:"type"` + Value any `json:"value"` + Condition any `json:"condition,omitempty"` + Copy *armTemplateCopy `json:"copy,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// armTemplateResources is a custom type that handles both ARM template resource formats: +// - Traditional (languageVersion 1.0): resources is a JSON array of resource objects. +// - Symbolic name (languageVersion 2.0): resources is a JSON object keyed by symbolic names. +// +// Bicep compiles modules using languageVersion 2.0, so both formats must be supported. +type armTemplateResources []armTemplateResource + +// UnmarshalJSON implements custom unmarshalling to handle both array and map resource formats. +func (r *armTemplateResources) UnmarshalJSON(data []byte) error { + // Try array format first (traditional ARM templates) + var arr []armTemplateResource + if err := json.Unmarshal(data, &arr); err == nil { + *r = arr + return nil + } + + // Try map/object format (languageVersion 2.0 symbolic name resources) + var m map[string]armTemplateResource + if err := json.Unmarshal(data, &m); err == nil { + resources := make([]armTemplateResource, 0, len(m)) + for _, res := range m { + resources = append(resources, res) + } + *r = resources + return nil + } + + return fmt.Errorf("resources must be a JSON array or object, got: %.40s", string(data)) +} + +// armTemplate is the parser's own comprehensive representation of a full ARM/JSON deployment template. +// Follows https://learn.microsoft.com/azure/azure-resource-manager/templates/syntax +type armTemplate struct { + Schema string `json:"$schema"` + ContentVersion string `json:"contentVersion"` + LanguageVersion string `json:"languageVersion,omitempty"` + APIProfile string `json:"apiProfile,omitempty"` + Parameters map[string]armTemplateParameterDef `json:"parameters,omitempty"` + Variables map[string]armTemplateVariable `json:"variables,omitempty"` + Functions []armTemplateFunction `json:"functions,omitempty"` + Resources armTemplateResources `json:"resources"` + Outputs map[string]armTemplateOutputDef `json:"outputs,omitempty"` +} + +// PreflightCheckSeverity indicates the severity level of a preflight check result. +type PreflightCheckSeverity int + +const ( + // PreflightCheckWarning indicates a non-blocking issue that should be reported to the user. + PreflightCheckWarning PreflightCheckSeverity = iota + // PreflightCheckError indicates a blocking issue that should prevent deployment. + PreflightCheckError +) + +// PreflightCheckResult holds the outcome of a single preflight check function. +type PreflightCheckResult struct { + // Severity indicates whether this result is a warning or a blocking error. + Severity PreflightCheckSeverity + // Message is a human-readable description of the finding. + Message string +} + +// validationContext provides the data and utilities available to preflight check functions. +// It acts as a bag of convenient values that checks may inspect to produce their results. +type validationContext struct { + // Console provides user interaction capabilities (prompts, messages). + Console input.Console + // Props contains derived properties from analyzing the ARM template resources. + Props resourcesProperties + // ResourcesSnapshot is the raw JSON output from `bicep snapshot`, containing the fully + // resolved deployment graph. It may be nil if the Bicep CLI was not available. + ResourcesSnapshot json.RawMessage + // SnapshotResources is the parsed list of predicted resources from the Bicep snapshot. + // Each entry represents a resource that would be deployed, with resolved values. + // It may be nil if the Bicep CLI was not available. + SnapshotResources []armTemplateResource +} + +// snapshotResult represents the top-level structure of the Bicep snapshot JSON output. +type snapshotResult struct { + PredictedResources []armTemplateResource `json:"predictedResources"` +} + +// PreflightCheckFn is a function that performs a single preflight validation check. +// It receives the execution context and a validationContext containing the console, +// analyzed resource properties, and the deployment snapshot. +// It returns a result describing the finding (or nil if there is nothing to report) +// and an error if the check itself failed to execute. +type PreflightCheckFn func( + ctx context.Context, + valCtx *validationContext, +) (*PreflightCheckResult, error) + +// localArmPreflight provides local (client-side) validation of an ARM template before deployment. +// It parses the template and parameters to build a comprehensive view of all resources that would +// be deployed, enabling early detection of issues without making Azure API calls. +// +// Callers can register additional check functions via AddCheck before calling validate. Each +// registered function is invoked with the analyzed resource properties, and the results are +// collected and returned alongside the resource properties. +type localArmPreflight struct { + // modulePath is the absolute path to the source Bicep module (e.g. /project/infra/main.bicep). + modulePath string + // bicepCli is the Bicep CLI wrapper used to run bicep commands such as snapshot. + bicepCli *bicep.Cli + // target is the deployment scope (subscription or resource group) used to derive snapshot options. + // It may be nil, in which case snapshot options are left empty. + target infra.Deployment + checks []PreflightCheckFn +} + +// newLocalArmPreflight creates a new instance of localArmPreflight. +// modulePath is the path to the source Bicep module file (e.g. "infra/main.bicep"). +// bicepCli is the Bicep CLI wrapper used to invoke bicep commands. +// target is the deployment scope used to populate snapshot options; it may be nil. +func newLocalArmPreflight(modulePath string, bicepCli *bicep.Cli, target infra.Deployment) *localArmPreflight { + return &localArmPreflight{modulePath: modulePath, bicepCli: bicepCli, target: target} +} + +// AddCheck registers a preflight check function to be executed during validate. +// Check functions are invoked in the order they are added. +func (l *localArmPreflight) AddCheck(fn PreflightCheckFn) { + l.checks = append(l.checks, fn) +} + +// validate performs local preflight validation on the given ARM template and parameters. +// It parses the template, resolves parameters, analyzes the resources, and then runs all +// registered check functions. It returns the collected results from all checks and an error +// if template parsing fails. +func (l *localArmPreflight) validate( + ctx context.Context, + console input.Console, + armTemplate azure.RawArmTemplate, + armParameters azure.ArmParameters, +) ([]PreflightCheckResult, error) { + _, err := l.parseTemplate(armTemplate) + if err != nil { + return nil, fmt.Errorf("parsing ARM template: %w", err) + } + + // Determine the .bicepparam file to use for the snapshot. + // If the module path already points to a .bicepparam file, use it directly. + // Otherwise, create a temporary .bicepparam file next to the .bicep module with the resolved parameters. + var bicepParamFile string + if filepath.Ext(l.modulePath) == ".bicepparam" { + bicepParamFile = l.modulePath + } else { + bicepFileName := filepath.Base(l.modulePath) + moduleDir := filepath.Dir(l.modulePath) + + bicepParamContent := generateBicepParam(bicepFileName, armParameters) + + tmpFile, err := os.CreateTemp(moduleDir, "preflight-*.bicepparam") + if err != nil { + return nil, fmt.Errorf("creating temp bicepparam file: %w", err) + } + bicepParamFile = tmpFile.Name() + defer func() { + tmpFile.Close() + os.Remove(bicepParamFile) + }() + + if _, err := tmpFile.WriteString(bicepParamContent); err != nil { + return nil, fmt.Errorf("writing temp bicepparam file: %w", err) + } + if err := tmpFile.Close(); err != nil { + return nil, fmt.Errorf("closing temp bicepparam file: %w", err) + } + } + + // Build snapshot options from the deployment target scope. + snapshotOpts := bicep.NewSnapshotOptions() + if l.target != nil { + snapshotOpts = snapshotOpts.WithSubscriptionID(l.target.SubscriptionId()) + + switch t := l.target.(type) { + case *infra.ResourceGroupDeployment: + snapshotOpts = snapshotOpts.WithResourceGroup(t.ResourceGroupName()) + case *infra.SubscriptionDeployment: + snapshotOpts = snapshotOpts.WithLocation(t.Location()) + } + } + + // Run the Bicep snapshot command to produce a deployment snapshot from the bicepparam file. + // The snapshot contains the fully resolved deployment graph with expressions evaluated, + // conditions applied, and copy loops expanded. + // If the snapshot fails (e.g., older Bicep binary without snapshot support), skip local + // preflight rather than blocking the deployment. + data, err := l.bicepCli.Snapshot(ctx, bicepParamFile, snapshotOpts) + if err != nil { + log.Printf("local preflight: skipping checks, bicep snapshot unavailable: %v", err) + return nil, nil + } + + var snapshot snapshotResult + if err := json.Unmarshal(data, &snapshot); err != nil { + return nil, fmt.Errorf("parsing bicep snapshot: %w", err) + } + + props := analyzeResources(snapshot.PredictedResources) + + valCtx := &validationContext{ + Console: console, + Props: props, + ResourcesSnapshot: json.RawMessage(data), + SnapshotResources: snapshot.PredictedResources, + } + + var results []PreflightCheckResult + for _, check := range l.checks { + result, err := check(ctx, valCtx) + if err != nil { + return results, fmt.Errorf("preflight check failed: %w", err) + } + if result != nil { + results = append(results, *result) + } + } + + return results, nil +} + +// parseTemplate unmarshals a raw ARM template into the parser's own armTemplate structure. +func (l *localArmPreflight) parseTemplate(raw azure.RawArmTemplate) (*armTemplate, error) { + var tmpl armTemplate + if err := json.Unmarshal(raw, &tmpl); err != nil { + return nil, fmt.Errorf("unmarshalling ARM template JSON: %w", err) + } + + if tmpl.Schema == "" { + return nil, fmt.Errorf("ARM template is missing required '$schema' property") + } + + if tmpl.ContentVersion == "" { + return nil, fmt.Errorf("ARM template is missing required 'contentVersion' property") + } + + if len(tmpl.Resources) == 0 { + return nil, fmt.Errorf("ARM template contains no resources") + } + + return &tmpl, nil +} + +// generateBicepParam produces a .bicepparam file content string from the given ARM parameters +// and the name of the Bicep file they target. The output follows the Bicep parameters file +// format: +// +// using '' +// +// param = +// +// Parameter names are emitted in sorted order for deterministic output. Values are serialized +// as Bicep literals: strings use single quotes, arrays and objects use JSON-like syntax with +// single-quoted string values, booleans and numbers are written as-is, and null produces the +// Bicep null keyword. Key Vault references are skipped because they cannot be represented +// directly as Bicep parameter values. +func generateBicepParam(bicepFile string, params azure.ArmParameters) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("using '%s'\n", bicepFile)) + + for _, name := range slices.Sorted(maps.Keys(params)) { + param := params[name] + + // Key Vault references cannot be expressed as bicepparam values; skip them. + if param.KeyVaultReference != nil { + continue + } + + sb.WriteString(fmt.Sprintf("\nparam %s = %s\n", name, toBicepValue(param.Value))) + } + + return sb.String() +} + +// toBicepValue converts a Go value into its Bicep literal representation. +// Supported types: string→'single-quoted', bool→true/false, nil→null, +// numeric types→number literal, arrays→[...], maps/objects→{key: value}. +func toBicepValue(v any) string { + if v == nil { + return "null" + } + + switch val := v.(type) { + case string: + // Bicep strings use single quotes; escape embedded single quotes by doubling them. + escaped := strings.ReplaceAll(val, "'", "''") + return fmt.Sprintf("'%s'", escaped) + case bool: + if val { + return "true" + } + return "false" + case json.Number: + return val.String() + case float64: + // JSON numbers decode as float64 by default. + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%g", val) + case float32: + if val == float32(int32(val)) { + return fmt.Sprintf("%d", int32(val)) + } + return fmt.Sprintf("%g", val) + case int: + return fmt.Sprintf("%d", val) + case int64: + return fmt.Sprintf("%d", val) + case []any: + items := make([]string, 0, len(val)) + for _, item := range val { + items = append(items, toBicepValue(item)) + } + return fmt.Sprintf("[\n %s\n]", strings.Join(items, "\n ")) + case map[string]any: + if len(val) == 0 { + return "{}" + } + entries := make([]string, 0, len(val)) + for _, k := range slices.Sorted(maps.Keys(val)) { + entries = append(entries, fmt.Sprintf(" %s: %s", k, toBicepValue(val[k]))) + } + return fmt.Sprintf("{\n%s\n}", strings.Join(entries, "\n")) + default: + // Fallback: marshal to JSON. + b, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("'%v'", val) + } + return string(b) + } +} + +// resourcesProperties contains derived properties from analyzing the collected preflight resources. +type resourcesProperties struct { + // HasRoleAssignments indicates whether the deployment includes one or more + // Microsoft.Authorization/roleAssignments resources. + HasRoleAssignments bool +} + +// analyzeResources inspects the list of snapshot resources and returns a resourcesProperties +// summarizing key characteristics of the deployment. +func analyzeResources(resources []armTemplateResource) resourcesProperties { + props := resourcesProperties{} + for _, r := range resources { + if strings.EqualFold(r.Type, "Microsoft.Authorization/roleAssignments") { + props.HasRoleAssignments = true + break + } + } + return props +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/local_preflight_test.go b/cli/azd/pkg/infra/provisioning/bicep/local_preflight_test.go new file mode 100644 index 00000000000..97a26921789 --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/bicep/local_preflight_test.go @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package bicep + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azure" + "github.com/stretchr/testify/require" +) + +func TestParseTemplate_ValidTemplate(t *testing.T) { + template := armTemplate{ + Schema: "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + ContentVersion: "1.0.0.0", + Resources: armTemplateResources{ + {Type: "Microsoft.Resources/resourceGroups", APIVersion: "2021-04-01", Name: "rg-test"}, + }, + } + raw, err := json.Marshal(template) + require.NoError(t, err) + + preflight := &localArmPreflight{} + parsed, err := preflight.parseTemplate(azure.RawArmTemplate(raw)) + + require.NoError(t, err) + require.NotNil(t, parsed) + require.Len(t, parsed.Resources, 1) + require.Equal(t, "Microsoft.Resources/resourceGroups", parsed.Resources[0].Type) +} + +func TestParseTemplate_MissingSchema(t *testing.T) { + raw := []byte(`{"contentVersion": "1.0.0.0", "resources": [{"type": "Microsoft.Resources/resourceGroups"}]}`) + + preflight := &localArmPreflight{} + _, err := preflight.parseTemplate(azure.RawArmTemplate(raw)) + + require.Error(t, err) + require.Contains(t, err.Error(), "missing required '$schema'") +} + +func TestParseTemplate_MissingContentVersion(t *testing.T) { + schema := "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#" + raw := []byte(fmt.Sprintf( + `{"$schema": "%s", "resources": [{"type": "Microsoft.Resources/resourceGroups"}]}`, + schema, + )) + + preflight := &localArmPreflight{} + _, err := preflight.parseTemplate(azure.RawArmTemplate(raw)) + + require.Error(t, err) + require.Contains(t, err.Error(), "missing required 'contentVersion'") +} + +func TestParseTemplate_NoResources(t *testing.T) { + schema := "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#" + raw := []byte(fmt.Sprintf( + `{"$schema": "%s", "contentVersion": "1.0.0.0", "resources": []}`, + schema, + )) + + preflight := &localArmPreflight{} + _, err := preflight.parseTemplate(azure.RawArmTemplate(raw)) + + require.Error(t, err) + require.Contains(t, err.Error(), "no resources") +} + +func TestParseTemplate_InvalidJSON(t *testing.T) { + preflight := &localArmPreflight{} + _, err := preflight.parseTemplate(azure.RawArmTemplate([]byte(`{}`))) + + require.Error(t, err) + require.Contains(t, err.Error(), "missing required") +} + +func TestRegisteredChecks_RunInOrder(t *testing.T) { + valCtx := &validationContext{ + Props: resourcesProperties{}, + } + + var checks []PreflightCheckFn + + // Add a warning check + checks = append(checks, func( + ctx context.Context, + valCtx *validationContext, + ) (*PreflightCheckResult, error) { + return &PreflightCheckResult{ + Severity: PreflightCheckWarning, + Message: "this is a warning", + }, nil + }) + + // Add a check that returns nil (no finding) + checks = append(checks, func( + ctx context.Context, + valCtx *validationContext, + ) (*PreflightCheckResult, error) { + return nil, nil + }) + + // Add an error check + checks = append(checks, func( + ctx context.Context, + valCtx *validationContext, + ) (*PreflightCheckResult, error) { + return &PreflightCheckResult{ + Severity: PreflightCheckError, + Message: "this is an error", + }, nil + }) + + var results []PreflightCheckResult + for _, check := range checks { + result, err := check(context.Background(), valCtx) + require.NoError(t, err) + if result != nil { + results = append(results, *result) + } + } + + require.Len(t, results, 2) + require.Equal(t, PreflightCheckWarning, results[0].Severity) + require.Equal(t, "this is a warning", results[0].Message) + require.Equal(t, PreflightCheckError, results[1].Severity) + require.Equal(t, "this is an error", results[1].Message) +} + +func TestAnalyzeResources(t *testing.T) { + tests := []struct { + name string + resources []armTemplateResource + hasRoleAssignments bool + }{ + { + name: "empty resources", + resources: nil, + hasRoleAssignments: false, + }, + { + name: "no role assignments", + resources: []armTemplateResource{ + {Type: "Microsoft.Storage/storageAccounts"}, + {Type: "Microsoft.Web/sites"}, + }, + hasRoleAssignments: false, + }, + { + name: "has role assignments", + resources: []armTemplateResource{ + {Type: "Microsoft.Storage/storageAccounts"}, + {Type: "Microsoft.Authorization/roleAssignments"}, + }, + hasRoleAssignments: true, + }, + { + name: "case insensitive match", + resources: []armTemplateResource{ + {Type: "microsoft.authorization/roleassignments"}, + }, + hasRoleAssignments: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + props := analyzeResources(tt.resources) + require.Equal(t, tt.hasRoleAssignments, props.HasRoleAssignments) + }) + } +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/role_assignment_check_test.go b/cli/azd/pkg/infra/provisioning/bicep/role_assignment_check_test.go new file mode 100644 index 00000000000..9abda9a6acb --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/bicep/role_assignment_check_test.go @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package bicep + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPreflightCheckFn_SkipsWhenNoRoleAssignments(t *testing.T) { + called := false + checkFn := PreflightCheckFn(func( + ctx context.Context, + valCtx *validationContext, + ) (*PreflightCheckResult, error) { + called = true + if !valCtx.Props.HasRoleAssignments { + return nil, nil + } + return &PreflightCheckResult{ + Severity: PreflightCheckError, + Message: "missing permissions", + }, nil + }) + + valCtx := &validationContext{ + Props: resourcesProperties{HasRoleAssignments: false}, + } + + result, err := checkFn(context.Background(), valCtx) + require.NoError(t, err) + require.True(t, called) + require.Nil(t, result) +} + +func TestPreflightCheckFn_ReportsErrorWhenRoleAssignments(t *testing.T) { + checkFn := PreflightCheckFn(func( + ctx context.Context, + valCtx *validationContext, + ) (*PreflightCheckResult, error) { + if !valCtx.Props.HasRoleAssignments { + return nil, nil + } + return &PreflightCheckResult{ + Severity: PreflightCheckError, + Message: "missing role assignment permissions", + }, nil + }) + + valCtx := &validationContext{ + Props: resourcesProperties{HasRoleAssignments: true}, + } + + result, err := checkFn(context.Background(), valCtx) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, PreflightCheckError, result.Severity) + require.Contains(t, result.Message, "missing role assignment permissions") +} diff --git a/cli/azd/pkg/infra/provisioning/manager.go b/cli/azd/pkg/infra/provisioning/manager.go index 306f9dd600e..9f1892b946d 100644 --- a/cli/azd/pkg/infra/provisioning/manager.go +++ b/cli/azd/pkg/infra/provisioning/manager.go @@ -97,6 +97,11 @@ func (m *Manager) Deploy(ctx context.Context) (*DeployResult, error) { skippedDueToDeploymentState := deployResult.SkippedReason == DeploymentStateSkipped + if deployResult.SkippedReason == PreflightAbortedSkipped { + // Preflight intentionally aborted the deployment. There is no Deployment to process. + return deployResult, nil + } + if skippedDueToDeploymentState { m.console.StopSpinner(ctx, "Didn't find new changes.", input.StepSkipped) } diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index 01826af16a6..4da7dfbe1a8 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -144,7 +144,10 @@ func (o *Options) Validate() error { type SkippedReasonType string -const DeploymentStateSkipped SkippedReasonType = "deployment State" +const ( + DeploymentStateSkipped SkippedReasonType = "deployment State" + PreflightAbortedSkipped SkippedReasonType = "preflight aborted" +) type DeployResult struct { Deployment *Deployment diff --git a/cli/azd/pkg/output/ux/preflight_report.go b/cli/azd/pkg/output/ux/preflight_report.go new file mode 100644 index 00000000000..8b70383809e --- /dev/null +++ b/cli/azd/pkg/output/ux/preflight_report.go @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ux + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/output" +) + +// PreflightReportItem represents a single finding from preflight validation. +type PreflightReportItem struct { + // IsError is true for blocking errors, false for warnings. + IsError bool + // Message describes the finding. + Message string +} + +// PreflightReport displays the results of local preflight validation. +// Warnings are shown first, followed by errors. Each entry is separated by a blank line. +type PreflightReport struct { + Items []PreflightReportItem +} + +func (r *PreflightReport) ToString(currentIndentation string) string { + warnings, errors := r.partition() + if len(warnings) == 0 && len(errors) == 0 { + return "" + } + + var sb strings.Builder + + for i, w := range warnings { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("%s%s %s", currentIndentation, warningPrefix, w.Message)) + } + + if len(warnings) > 0 && len(errors) > 0 { + sb.WriteString("\n") + } + + for i, e := range errors { + if i > 0 { + sb.WriteString("\n") + } + sb.WriteString(fmt.Sprintf("%s%s %s", currentIndentation, failedPrefix, e.Message)) + } + + return sb.String() +} + +func (r *PreflightReport) MarshalJSON() ([]byte, error) { + warnings, errors := r.partition() + + return json.Marshal(output.EventForMessage( + fmt.Sprintf("preflight: %d warning(s), %d error(s)", + len(warnings), len(errors)))) +} + +// HasErrors returns true if the report contains at least one error-level item. +func (r *PreflightReport) HasErrors() bool { + for _, item := range r.Items { + if item.IsError { + return true + } + } + return false +} + +// HasWarnings returns true if the report contains at least one warning-level item. +func (r *PreflightReport) HasWarnings() bool { + for _, item := range r.Items { + if !item.IsError { + return true + } + } + return false +} + +// partition splits items into warnings and errors, preserving order within each group. +func (r *PreflightReport) partition() (warnings, errors []PreflightReportItem) { + for _, item := range r.Items { + if item.IsError { + errors = append(errors, item) + } else { + warnings = append(warnings, item) + } + } + return warnings, errors +} diff --git a/cli/azd/pkg/output/ux/preflight_report_test.go b/cli/azd/pkg/output/ux/preflight_report_test.go new file mode 100644 index 00000000000..c74dbd7c535 --- /dev/null +++ b/cli/azd/pkg/output/ux/preflight_report_test.go @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package ux + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPreflightReport_EmptyItems(t *testing.T) { + report := &PreflightReport{} + require.Empty(t, report.ToString("")) + require.False(t, report.HasErrors()) + require.False(t, report.HasWarnings()) +} + +func TestPreflightReport_WarningsOnly(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + {IsError: false, Message: "first warning"}, + {IsError: false, Message: "second warning"}, + }, + } + + result := report.ToString("") + require.Contains(t, result, "first warning") + require.Contains(t, result, "second warning") + require.NotContains(t, result, "Failed") + + require.False(t, report.HasErrors()) + require.True(t, report.HasWarnings()) +} + +func TestPreflightReport_ErrorsOnly(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + {IsError: true, Message: "critical error"}, + }, + } + + result := report.ToString("") + require.Contains(t, result, "critical error") + require.NotContains(t, result, "Warning") + + require.True(t, report.HasErrors()) + require.False(t, report.HasWarnings()) +} + +func TestPreflightReport_WarningsBeforeErrors(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + {IsError: true, Message: "an error"}, + {IsError: false, Message: "a warning"}, + }, + } + + result := report.ToString("") + // Warning should appear before error in the output + warnIdx := indexOf(result, "a warning") + errIdx := indexOf(result, "an error") + require.Greater(t, errIdx, warnIdx, "warnings should appear before errors") + + require.True(t, report.HasErrors()) + require.True(t, report.HasWarnings()) +} + +func TestPreflightReport_MarshalJSON(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + {IsError: false, Message: "w1"}, + {IsError: true, Message: "e1"}, + }, + } + + data, err := json.Marshal(report) + require.NoError(t, err) + require.Contains(t, string(data), "1 warning(s)") + require.Contains(t, string(data), "1 error(s)") +} + +func TestPreflightReport_Indentation(t *testing.T) { + report := &PreflightReport{ + Items: []PreflightReportItem{ + {IsError: false, Message: "indented warning"}, + }, + } + + result := report.ToString(" ") + require.Contains(t, result, " ") + require.Contains(t, result, "indented warning") +} + +// 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 { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +} diff --git a/cli/azd/pkg/tools/bicep/bicep.go b/cli/azd/pkg/tools/bicep/bicep.go index 455a8703f23..354cfa41052 100644 --- a/cli/azd/pkg/tools/bicep/bicep.go +++ b/cli/azd/pkg/tools/bicep/bicep.go @@ -13,6 +13,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "sync" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" @@ -318,6 +319,117 @@ func (cli *Cli) BuildBicepParam(ctx context.Context, file string, env []string) }, nil } +// SnapshotOptions configures optional flags for the `bicep snapshot` command. +// Use the With* methods to set values using the builder pattern: +// +// opts := NewSnapshotOptions(). +// WithMode("validate"). +// WithSubscriptionID("sub-123"). +// WithLocation("eastus2") +type SnapshotOptions struct { + // Mode sets the snapshot mode: "overwrite" (generate new) or "validate" (compare against existing). + Mode string + // TenantID is the tenant ID to use for the deployment. + TenantID string + // SubscriptionID is the subscription ID to use for the deployment. + SubscriptionID string + // ResourceGroup is the resource group name to use for the deployment. + ResourceGroup string + // Location is the location to use for the deployment. + Location string + // DeploymentName is the deployment name to use. + DeploymentName string +} + +// NewSnapshotOptions returns a zero-valued SnapshotOptions ready for building. +func NewSnapshotOptions() SnapshotOptions { + return SnapshotOptions{} +} + +// WithMode sets the snapshot mode ("overwrite" or "validate"). +func (o SnapshotOptions) WithMode(mode string) SnapshotOptions { + o.Mode = mode + return o +} + +// WithTenantID sets the tenant ID for the deployment. +func (o SnapshotOptions) WithTenantID(tenantID string) SnapshotOptions { + o.TenantID = tenantID + return o +} + +// WithSubscriptionID sets the subscription ID for the deployment. +func (o SnapshotOptions) WithSubscriptionID(subscriptionID string) SnapshotOptions { + o.SubscriptionID = subscriptionID + return o +} + +// WithResourceGroup sets the resource group name for the deployment. +func (o SnapshotOptions) WithResourceGroup(resourceGroup string) SnapshotOptions { + o.ResourceGroup = resourceGroup + return o +} + +// WithLocation sets the location for the deployment. +func (o SnapshotOptions) WithLocation(location string) SnapshotOptions { + o.Location = location + return o +} + +// WithDeploymentName sets the deployment name. +func (o SnapshotOptions) WithDeploymentName(deploymentName string) SnapshotOptions { + o.DeploymentName = deploymentName + return o +} + +// Snapshot runs `bicep snapshot ` and reads the resulting snapshot file. +// The bicep CLI produces a `.snapshot.json` file next to the input .bicepparam file. +// This method reads the snapshot content into a byte slice and removes the generated file. +// If the snapshot file is not produced, it returns an error. +func (cli *Cli) Snapshot(ctx context.Context, file string, opts SnapshotOptions) ([]byte, error) { + if err := cli.ensureInstalledOnce(ctx); err != nil { + return nil, fmt.Errorf("ensuring bicep is installed: %w", err) + } + + args := []string{"snapshot", file} + if opts.Mode != "" { + args = append(args, "--mode", opts.Mode) + } + if opts.TenantID != "" { + args = append(args, "--tenant-id", opts.TenantID) + } + if opts.SubscriptionID != "" { + args = append(args, "--subscription-id", opts.SubscriptionID) + } + if opts.ResourceGroup != "" { + args = append(args, "--resource-group", opts.ResourceGroup) + } + if opts.Location != "" { + args = append(args, "--location", opts.Location) + } + if opts.DeploymentName != "" { + args = append(args, "--deployment-name", opts.DeploymentName) + } + + if _, err := cli.runCommand(ctx, nil, args...); err != nil { + return nil, fmt.Errorf("failed running bicep snapshot: %w", err) + } + + // The snapshot output file is .snapshot.json in the same directory. + snapshotFile := strings.TrimSuffix(file, filepath.Ext(file)) + ".snapshot.json" + + data, err := os.ReadFile(snapshotFile) + if err != nil { + return nil, fmt.Errorf("reading snapshot file %s: %w", snapshotFile, err) + } + + if err := os.Remove(snapshotFile); err != nil { + log.Printf("warning: failed to remove snapshot file %s: %v", snapshotFile, err) + } + + return data, nil +} + func (cli *Cli) runCommand(ctx context.Context, env []string, args ...string) (exec.RunResult, error) { runArgs := exec.NewRunArgs(cli.path, args...) if env != nil {