cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go (1,799 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package bicep import ( "context" "crypto/sha256" "encoding/json" "errors" "fmt" "log" "maps" "math" "os" "path/filepath" "reflect" "slices" "strconv" "strings" "time" "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/cognitiveservices/armcognitiveservices" "github.com/azure/azure-dev/cli/azd/pkg/account" "github.com/azure/azure-dev/cli/azd/pkg/async" "github.com/azure/azure-dev/cli/azd/pkg/azapi" "github.com/azure/azure-dev/cli/azd/pkg/azure" "github.com/azure/azure-dev/cli/azd/pkg/cloud" "github.com/azure/azure-dev/cli/azd/pkg/cmdsubst" "github.com/azure/azure-dev/cli/azd/pkg/config" "github.com/azure/azure-dev/cli/azd/pkg/convert" "github.com/azure/azure-dev/cli/azd/pkg/environment" "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/keyvault" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/password" "github.com/azure/azure-dev/cli/azd/pkg/prompt" "github.com/azure/azure-dev/cli/azd/pkg/tools" "github.com/azure/azure-dev/cli/azd/pkg/tools/bicep" "github.com/drone/envsubst" ) const ( defaultModule = "main" defaultPath = "infra" ) type deploymentDetails struct { CompiledBicep *compileBicepResult // Target is the unique resource in azure that represents the deployment that will happen. A target can be scoped to // either subscriptions, or resource groups. Target infra.Deployment } // BicepProvider exposes infrastructure provisioning using Azure Bicep templates type BicepProvider struct { env *environment.Environment envManager environment.Manager projectPath string options provisioning.Options console input.Console bicepCli *bicep.Cli azapi *azapi.AzureClient resourceService *azapi.ResourceService deploymentManager *infra.DeploymentManager prompters prompt.Prompter curPrincipal provisioning.CurrentPrincipalIdProvider ignoreDeploymentState bool // compileBicepResult is cached to avoid recompiling the same bicep file multiple times in the same azd run. compileBicepMemoryCache *compileBicepResult keyvaultService keyvault.KeyVaultService portalUrlBase string subscriptionManager *account.SubscriptionsManager azureClient *azapi.AzureClient } // Name gets the name of the infra provider func (p *BicepProvider) Name() string { return "Bicep" } func (p *BicepProvider) RequiredExternalTools() []tools.ExternalTool { return []tools.ExternalTool{} } // Initialize initializes provider state from the options. // It also calls EnsureEnv, which ensures the client-side state is ready for provisioning. func (p *BicepProvider) Initialize(ctx context.Context, projectPath string, options provisioning.Options) error { p.projectPath = projectPath p.options = options if p.options.Module == "" { p.options.Module = defaultModule } if p.options.Path == "" { p.options.Path = defaultPath } requiredTools := p.RequiredExternalTools() if err := tools.EnsureInstalled(ctx, requiredTools...); err != nil { return err } p.ignoreDeploymentState = options.IgnoreDeploymentState p.console.ShowSpinner(ctx, "Initialize bicep provider", input.Step) err := p.EnsureEnv(ctx) p.console.StopSpinner(ctx, "", input.Step) return err } var ErrEnsureEnvPreReqBicepCompileFailed = errors.New("") // EnsureEnv ensures that the environment is in a provision-ready state with required values set, prompting the user if // values are unset. This also requires that the Bicep module can be compiled. func (p *BicepProvider) EnsureEnv(ctx context.Context) error { modulePath := p.modulePath() // for .bicepparam, we first prompt for environment values before calling compiling bicepparam file // which can reference these values if isBicepParamFile(modulePath) { if err := provisioning.EnsureSubscriptionAndLocation( ctx, p.envManager, p.env, p.prompters, provisioning.EnsureSubscriptionAndLocationOptions{}); err != nil { return err } } compileResult, compileErr := p.compileBicep(ctx, modulePath) if compileErr != nil { return fmt.Errorf("%w%w", ErrEnsureEnvPreReqBicepCompileFailed, compileErr) } // for .bicep, azd must load a parameters.json file and create the ArmParameters so we know if the are filters // to apply for location (using the allowedValues or the location azd metadata) if isBicepFile(modulePath) { err := provisioning.EnsureSubscription( ctx, p.envManager, p.env, p.prompters) if err != nil { return err } _, err = p.ensureParameters(ctx, compileResult.Template) if err != nil { return err } } scope, err := compileResult.Template.TargetScope() if err != nil { return err } if scope == azure.DeploymentScopeResourceGroup { if p.env.Getenv(environment.ResourceGroupEnvVarName) == "" { // Prompt Resource Group supports creating a new resource group // And prompts for a location as part of creating a new resource group rgName, err := p.prompters.PromptResourceGroup(ctx, prompt.PromptResourceOptions{}) if err != nil { return err } p.env.DotenvSet(environment.ResourceGroupEnvVarName, rgName) if err := p.envManager.Save(ctx, p.env); err != nil { return fmt.Errorf("saving resource group name: %w", err) } } } return nil } func locationParameterFilterImpl(allowedLocations []string, location account.Location) bool { if allowedLocations == nil { return true } return slices.Contains(allowedLocations, location.Name) } // defaultPromptValue resolves if there is an intention from a location parameter to use a default location. // // If the parameter has AzdMetadataTypeLocation, with a default location set, the default location is returned. // If the parameter has AllowedValues, the first option value is returned. // Otherwise, nil is returned to indicate no user-provided default value. func defaultPromptValue(locationParam azure.ArmTemplateParameterDefinition) *string { azdMetadata, has := locationParam.AzdMetadata() if has && azdMetadata.Type != nil && *azdMetadata.Type == azure.AzdMetadataTypeLocation && azdMetadata.Default != nil { // Metadata using location type and a default location. This is the highest priority. return azdMetadata.Default } if locationParam.AllowedValues != nil { firstOption, castOk := (*locationParam.AllowedValues)[0].(string) // if cast doesn't work, we don't have a default location if castOk { return &firstOption } } return nil } func (p *BicepProvider) LastDeployment(ctx context.Context) (*azapi.ResourceDeployment, error) { modulePath := p.modulePath() compileResult, err := p.compileBicep(ctx, modulePath) if err != nil { return nil, fmt.Errorf("compiling bicep template: %w", err) } scope, err := p.scopeForTemplate(compileResult.Template) if err != nil { return nil, fmt.Errorf("computing deployment scope: %w", err) } return p.latestDeploymentResult(ctx, scope) } func (p *BicepProvider) State(ctx context.Context, options *provisioning.StateOptions) (*provisioning.StateResult, error) { if options == nil { options = &provisioning.StateOptions{} } var err error spinnerMessage := "Loading Bicep template" p.console.ShowSpinner(ctx, spinnerMessage, input.Step) defer func() { // Make sure we stop the spinner if an error occurs with the last message. if err != nil { p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed) } }() var scope infra.Scope var outputs azure.ArmTemplateOutputs var scopeErr error modulePath := p.modulePath() if _, err := os.Stat(modulePath); err == nil { compileResult, err := p.compileBicep(ctx, modulePath) if err != nil { return nil, fmt.Errorf("compiling bicep template: %w", err) } scope, err = p.scopeForTemplate(compileResult.Template) if err != nil { return nil, fmt.Errorf("computing deployment scope: %w", err) } outputs = compileResult.Template.Outputs } else if errors.Is(err, os.ErrNotExist) { // To support BYOI (bring your own infrastructure) // We need to support the case where there template does not contain an `infra` folder. scope, scopeErr = p.inferScopeFromEnv() if scopeErr != nil { return nil, fmt.Errorf("computing deployment scope: %w", err) } outputs = azure.ArmTemplateOutputs{} } spinnerMessage = "Retrieving Azure deployment" p.console.ShowSpinner(ctx, spinnerMessage, input.Step) var deployment *azapi.ResourceDeployment deployments, err := p.deploymentManager.CompletedDeployments(ctx, scope, p.env.Name(), options.Hint()) p.console.StopSpinner(ctx, "", input.StepDone) if err != nil { p.console.StopSpinner(ctx, spinnerMessage, input.StepFailed) return nil, fmt.Errorf("retrieving deployment: %w", err) } else { p.console.StopSpinner(ctx, "", input.StepDone) } if len(deployments) > 1 { deploymentOptions := getDeploymentOptions(deployments) p.console.Message(ctx, output.WithWarningFormat("WARNING: Multiple matching deployments were found\n")) promptConfig := input.ConsoleOptions{ Message: "Select a deployment to continue:", Options: deploymentOptions, } selectedDeployment, err := p.console.Select(ctx, promptConfig) if err != nil { return nil, err } deployment = deployments[selectedDeployment] p.console.Message(ctx, "") } else { deployment = deployments[0] } azdDeployment, err := p.createDeploymentFromArmDeployment(scope, deployment.Name) if err != nil { return nil, err } p.console.MessageUxItem(ctx, &ux.DoneMessage{ Message: fmt.Sprintf("Retrieving Azure deployment (%s)", output.WithHighLightFormat(deployment.Name)), }) state := provisioning.State{} state.Resources = make([]provisioning.Resource, len(deployment.Resources)) for idx, res := range deployment.Resources { state.Resources[idx] = provisioning.Resource{ Id: *res.ID, } } state.Outputs = p.createOutputParameters( outputs, azapi.CreateDeploymentOutput(deployment.Outputs), ) p.console.MessageUxItem(ctx, &ux.DoneMessage{ Message: fmt.Sprintf("Updated %d environment variables", len(state.Outputs)), }) outputsUrl, err := azdDeployment.OutputsUrl(ctx) if err != nil { return nil, err } p.console.Message(ctx, fmt.Sprintf( "\nPopulated environment from Azure infrastructure deployment: %s", output.WithHyperlink(outputsUrl, deployment.Name), )) return &provisioning.StateResult{ State: &state, }, nil } func (p *BicepProvider) createDeploymentFromArmDeployment( scope infra.Scope, deploymentName string, ) (infra.Deployment, error) { resourceGroupScope, ok := scope.(*infra.ResourceGroupScope) if ok { return p.deploymentManager.ResourceGroupDeployment(resourceGroupScope, deploymentName), nil } subscriptionScope, ok := scope.(*infra.SubscriptionScope) if ok { return p.deploymentManager.SubscriptionDeployment(subscriptionScope, deploymentName), nil } return nil, errors.New("unsupported deployment scope") } const bicepFileExtension = ".bicep" const bicepparamFileExtension = ".bicepparam" func isBicepFile(modulePath string) bool { return filepath.Ext(modulePath) == bicepFileExtension } func isBicepParamFile(modulePath string) bool { return filepath.Ext(modulePath) == bicepparamFileExtension } // Plans the infrastructure provisioning func (p *BicepProvider) plan(ctx context.Context) (*deploymentDetails, error) { p.console.ShowSpinner(ctx, "Creating a deployment plan", input.Step) modulePath := p.modulePath() compileResult, err := p.compileBicep(ctx, modulePath) if err != nil { return nil, fmt.Errorf("creating template: %w", err) } // for .bicep, azd must load a parameters.json file and create the ArmParameters if isBicepFile(modulePath) { configuredParameters, err := p.ensureParameters(ctx, compileResult.Template) if err != nil { return nil, err } compileResult.Parameters = configuredParameters } deploymentScope, err := compileResult.Template.TargetScope() if err != nil { return nil, err } target, err := p.deploymentFromScopeType(deploymentScope) if err != nil { return nil, err } return &deploymentDetails{ CompiledBicep: compileResult, Target: target, }, nil } func (p *BicepProvider) deploymentFromScopeType(deploymentScopeType azure.DeploymentScope) (infra.Deployment, error) { deploymentName := p.deploymentManager.GenerateDeploymentName(p.env.Name()) if deploymentScopeType == azure.DeploymentScopeSubscription { scope := p.deploymentManager.SubscriptionScope(p.env.GetSubscriptionId(), p.env.GetLocation()) return infra.NewSubscriptionDeployment( scope, deploymentName, ), nil } else if deploymentScopeType == azure.DeploymentScopeResourceGroup { scope := p.deploymentManager.ResourceGroupScope( p.env.GetSubscriptionId(), p.env.Getenv(environment.ResourceGroupEnvVarName), ) return infra.NewResourceGroupDeployment(scope, deploymentName), nil } return nil, fmt.Errorf("unsupported scope: %s", deploymentScopeType) } // deploymentState returns the latests deployment if it is the same as the deployment within deploymentData or an error // otherwise. func (p *BicepProvider) deploymentState( ctx context.Context, deploymentData *deploymentDetails, currentParamsHash string, ) (*azapi.ResourceDeployment, error) { p.console.ShowSpinner(ctx, "Comparing deployment state", input.Step) prevDeploymentResult, err := p.latestDeploymentResult(ctx, deploymentData.Target) if err != nil { return nil, fmt.Errorf("deployment state error: %w", err) } // State is invalid if the last deployment was not succeeded // This is currently safe because we rely on latestDeploymentResult which // relies on findCompletedDeployments which filters to only Failed and Succeeded if prevDeploymentResult.ProvisioningState != azapi.DeploymentProvisioningStateSucceeded { return nil, fmt.Errorf("last deployment failed.") } templateHash, err := p.deploymentManager.CalculateTemplateHash( ctx, p.env.GetSubscriptionId(), deploymentData.CompiledBicep.RawArmTemplate, ) if err != nil { return nil, fmt.Errorf("can't get hash from current template: %w", err) } if !prevDeploymentEqualToCurrent(prevDeploymentResult, templateHash, currentParamsHash) { return nil, fmt.Errorf("deployment state has changed") } return prevDeploymentResult, nil } // latestDeploymentResult looks and finds a previous deployment for the current azd project. func (p *BicepProvider) latestDeploymentResult( ctx context.Context, scope infra.Scope, ) (*azapi.ResourceDeployment, error) { deployments, err := p.deploymentManager.CompletedDeployments(ctx, scope, p.env.Name(), "") // findCompletedDeployments returns error if no deployments are found // No need to check for empty list if err != nil { return nil, err } if len(deployments) > 1 { // If more than one deployment found, ignore the prev-deployment return nil, fmt.Errorf("more than one previous deployment match.") } return deployments[0], nil } // parametersHash generates a hash from its name and final value. // The final value is either the parameter default value or the value from the params input. func parametersHash(templateParameters azure.ArmTemplateParameterDefinitions, params azure.ArmParameters) (string, error) { hash256 := sha256.New() // Get the parameter name and its final value. // Any other change on the parameter definition would break the template-hash nameAndValueParams := make(map[string]any, len(templateParameters)) for paramName, paramDefinition := range templateParameters { pValue := paramDefinition.DefaultValue if param, exists := params[paramName]; exists { pValue = param.Value } nameAndValueParams[paramName] = pValue } nameAndValueParamsBytes, err := json.Marshal(nameAndValueParams) if err != nil { return "", err } if _, err := hash256.Write(nameAndValueParamsBytes); err != nil { return "", err } return fmt.Sprintf("%x", hash256.Sum(nil)), nil } // prevDeploymentEqualToCurrent compares the template hash from a previous deployment against a current template. func prevDeploymentEqualToCurrent(prev *azapi.ResourceDeployment, templateHash, paramsHash string) bool { if prev == nil { logDS("No previous deployment.") return false } if prev.Tags == nil { logDS("No previous deployment params tags") return false } prevTemplateHash := convert.ToValueWithDefault(prev.TemplateHash, "") if prevTemplateHash != templateHash { logDS("template hash is different from previous deployment") return false } prevParamHash, hasTag := prev.Tags[azure.TagKeyAzdDeploymentStateParamHashName] if !hasTag { logDS("no param hash tag on last deployment.") return false } if *prevParamHash != paramsHash { logDS("template parameters are different from previous deployment") return false } logDS("Previous deployment state is equal to current deployment. Deployment can be skipped.") return true } func logDS(msg string, v ...any) { log.Printf("%s : %s", "deployment-state: ", fmt.Sprintf(msg, v...)) } // Provisioning the infrastructure within the specified template func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, error) { if p.ignoreDeploymentState { logDS("Azure Deployment State is disabled by --no-state arg.") } bicepDeploymentData, err := p.plan(ctx) if err != nil { return nil, err } deployment, err := p.convertToDeployment(bicepDeploymentData.CompiledBicep.Template) if err != nil { return nil, err } // parameters hash is required for doing deployment state validation check but also to set the hash // after a successful deployment. currentParamsHash, parametersHashErr := parametersHash( bicepDeploymentData.CompiledBicep.Template.Parameters, bicepDeploymentData.CompiledBicep.Parameters) if parametersHashErr != nil { // fail to hash parameters won't stop the operation. It only disables deployment state and recording parameters hash logDS("%s", parametersHashErr.Error()) } if !p.ignoreDeploymentState && parametersHashErr == nil { deploymentState, err := p.deploymentState(ctx, bicepDeploymentData, currentParamsHash) if err == nil { deployment.Outputs = p.createOutputParameters( bicepDeploymentData.CompiledBicep.Template.Outputs, azapi.CreateDeploymentOutput(deploymentState.Outputs), ) return &provisioning.DeployResult{ Deployment: deployment, SkippedReason: provisioning.DeploymentStateSkipped, }, nil } logDS("%s", err.Error()) } deploymentTags := map[string]*string{ azure.TagKeyAzdEnvName: to.Ptr(p.env.Name()), } if parametersHashErr == nil { deploymentTags[azure.TagKeyAzdDeploymentStateParamHashName] = to.Ptr(currentParamsHash) } optionsMap, err := convert.ToMap(p.options) if err != nil { return nil, err } err = p.validatePreflight( ctx, bicepDeploymentData.Target, bicepDeploymentData.CompiledBicep.RawArmTemplate, bicepDeploymentData.CompiledBicep.Parameters, deploymentTags, optionsMap, ) if err != nil { return nil, err } cancelProgress := make(chan bool) defer func() { cancelProgress <- true }() go func() { // Disable reporting progress if needed if use, err := strconv.ParseBool(os.Getenv("AZD_DEBUG_PROVISION_PROGRESS_DISABLE")); err == nil && use { log.Println("Disabling progress reporting since AZD_DEBUG_PROVISION_PROGRESS_DISABLE was set") <-cancelProgress return } // Report incremental progress progressDisplay := p.deploymentManager.ProgressDisplay(bicepDeploymentData.Target) // Make initial delay shorter to be more responsive in displaying initial progress initialDelay := 3 * time.Second regularDelay := 10 * time.Second timer := time.NewTimer(initialDelay) queryStartTime := time.Now() for { select { case <-cancelProgress: timer.Stop() return case <-timer.C: if err := progressDisplay.ReportProgress(ctx, &queryStartTime); err != nil { // We don't want to fail the whole deployment if a progress reporting error occurs log.Printf("error while reporting progress: %s", err.Error()) } timer.Reset(regularDelay) } } }() // Start the deployment p.console.ShowSpinner(ctx, "Creating/Updating resources", input.Step) deployResult, err := p.deployModule( ctx, bicepDeploymentData.Target, bicepDeploymentData.CompiledBicep.RawArmTemplate, bicepDeploymentData.CompiledBicep.Parameters, deploymentTags, optionsMap, ) if err != nil { return nil, err } deployment.Outputs = p.createOutputParameters( bicepDeploymentData.CompiledBicep.Template.Outputs, azapi.CreateDeploymentOutput(deployResult.Outputs), ) return &provisioning.DeployResult{ Deployment: deployment, }, nil } // Preview runs deploy using the what-if argument func (p *BicepProvider) Preview(ctx context.Context) (*provisioning.DeployPreviewResult, error) { bicepDeploymentData, err := p.plan(ctx) if err != nil { return nil, err } p.console.ShowSpinner(ctx, "Generating infrastructure preview", input.Step) targetScope := bicepDeploymentData.Target deployPreviewResult, err := targetScope.DeployPreview( ctx, bicepDeploymentData.CompiledBicep.RawArmTemplate, bicepDeploymentData.CompiledBicep.Parameters, ) if err != nil { return nil, err } if deployPreviewResult.Error != nil { deploymentErr := *deployPreviewResult.Error errDetailsList := make([]string, len(deploymentErr.Details)) for index, errDetail := range deploymentErr.Details { errDetailsList[index] = fmt.Sprintf( "code: %s, message: %s", convert.ToValueWithDefault(errDetail.Code, ""), convert.ToValueWithDefault(errDetail.Message, ""), ) } var errDetails string if len(errDetailsList) > 0 { errDetails = fmt.Sprintf(" Details: %s", strings.Join(errDetailsList, "\n")) } return nil, fmt.Errorf( "generating preview: error code: %s, message: %s.%s", convert.ToValueWithDefault(deploymentErr.Code, ""), convert.ToValueWithDefault(deploymentErr.Message, ""), errDetails, ) } var changes []*provisioning.DeploymentPreviewChange for _, change := range deployPreviewResult.Properties.Changes { resourceAfter := change.After.(map[string]interface{}) changes = append(changes, &provisioning.DeploymentPreviewChange{ ChangeType: provisioning.ChangeType(*change.ChangeType), ResourceId: provisioning.Resource{ Id: *change.ResourceID, }, ResourceType: resourceAfter["type"].(string), Name: resourceAfter["name"].(string), }) } return &provisioning.DeployPreviewResult{ Preview: &provisioning.DeploymentPreview{ Status: *deployPreviewResult.Status, Properties: &provisioning.DeploymentPreviewProperties{ Changes: changes, }, }, }, nil } type itemToPurge struct { resourceType string count int purge func(skipPurge bool, self *itemToPurge) error cognitiveAccounts []cognitiveAccount } func (p *BicepProvider) scopeForTemplate(t azure.ArmTemplate) (infra.Scope, error) { deploymentScope, err := t.TargetScope() if err != nil { return nil, err } if deploymentScope == azure.DeploymentScopeSubscription { return p.deploymentManager.SubscriptionScope(p.env.GetSubscriptionId(), p.env.GetLocation()), nil } else if deploymentScope == azure.DeploymentScopeResourceGroup { return p.deploymentManager.ResourceGroupScope( p.env.GetSubscriptionId(), p.env.Getenv(environment.ResourceGroupEnvVarName), ), nil } else { return nil, fmt.Errorf("unsupported deployment scope: %s", deploymentScope) } } func (p *BicepProvider) inferScopeFromEnv() (infra.Scope, error) { if resourceGroup, has := p.env.LookupEnv(environment.ResourceGroupEnvVarName); has { return p.deploymentManager.ResourceGroupScope(p.env.GetSubscriptionId(), resourceGroup), nil } else { return p.deploymentManager.SubscriptionScope(p.env.GetSubscriptionId(), p.env.GetLocation()), nil } } // Destroys the specified deployment by deleting all azure resources, resource groups & deployments that are referenced. func (p *BicepProvider) Destroy( ctx context.Context, options provisioning.DestroyOptions, ) (*provisioning.DestroyResult, error) { modulePath := p.modulePath() p.console.ShowSpinner(ctx, "Discovering resources to delete...", input.Step) defer p.console.StopSpinner(ctx, "", input.StepDone) compileResult, err := p.compileBicep(ctx, modulePath) if err != nil { return nil, fmt.Errorf("creating template: %w", err) } scope, err := p.scopeForTemplate(compileResult.Template) if err != nil { return nil, fmt.Errorf("computing deployment scope: %w", err) } completedDeployments, err := p.deploymentManager.CompletedDeployments(ctx, scope, p.env.Name(), "") if err != nil { return nil, fmt.Errorf("finding completed deployments: %w", err) } if len(completedDeployments) == 0 { return nil, fmt.Errorf("no deployments found for environment, '%s'", p.env.Name()) } mostRecentDeployment := completedDeployments[0] deploymentToDelete := scope.Deployment(mostRecentDeployment.Name) resourcesToDelete, err := deploymentToDelete.Resources(ctx) if err != nil { return nil, fmt.Errorf("getting resources to delete: %w", err) } groupedResources, err := azapi.GroupByResourceGroup(resourcesToDelete) if err != nil { return nil, fmt.Errorf("mapping resources to resource groups: %w", err) } if len(groupedResources) == 0 { return nil, fmt.Errorf("%w, '%s'", infra.ErrDeploymentResourcesNotFound, deploymentToDelete.Name()) } keyVaults, err := p.getKeyVaultsToPurge(ctx, groupedResources) if err != nil { return nil, fmt.Errorf("getting key vaults to purge: %w", err) } managedHSMs, err := p.getManagedHSMsToPurge(ctx, groupedResources) if err != nil { return nil, fmt.Errorf("getting managed hsms to purge: %w", err) } appConfigs, err := p.getAppConfigsToPurge(ctx, groupedResources) if err != nil { return nil, fmt.Errorf("getting app configurations to purge: %w", err) } apiManagements, err := p.getApiManagementsToPurge(ctx, groupedResources) if err != nil { return nil, fmt.Errorf("getting API managements to purge: %w", err) } cognitiveAccounts, err := p.getCognitiveAccountsToPurge(ctx, groupedResources) if err != nil { return nil, fmt.Errorf("getting cognitive accounts to purge: %w", err) } p.console.StopSpinner(ctx, "", input.StepDone) if err := p.destroyDeploymentWithConfirmation( ctx, options, deploymentToDelete, groupedResources, len(resourcesToDelete), ); err != nil { return nil, fmt.Errorf("deleting resource groups: %w", err) } keyVaultsPurge := itemToPurge{ resourceType: "Key Vault", count: len(keyVaults), purge: func(skipPurge bool, self *itemToPurge) error { return p.purgeKeyVaults(ctx, keyVaults, skipPurge) }, } managedHSMsPurge := itemToPurge{ resourceType: "Managed HSM", count: len(managedHSMs), purge: func(skipPurge bool, self *itemToPurge) error { return p.purgeManagedHSMs(ctx, managedHSMs, skipPurge) }, } appConfigsPurge := itemToPurge{ resourceType: "App Configuration", count: len(appConfigs), purge: func(skipPurge bool, self *itemToPurge) error { return p.purgeAppConfigs(ctx, appConfigs, skipPurge) }, } aPIManagement := itemToPurge{ resourceType: "API Management", count: len(apiManagements), purge: func(skipPurge bool, self *itemToPurge) error { return p.purgeAPIManagement(ctx, apiManagements, skipPurge) }, } var purgeItem []itemToPurge for _, item := range []itemToPurge{keyVaultsPurge, managedHSMsPurge, appConfigsPurge, aPIManagement} { if item.count > 0 { purgeItem = append(purgeItem, item) } } // cognitive services are grouped by resource group because the name of the resource group is required to purge groupByKind := cognitiveAccountsByKind(cognitiveAccounts) for name, cogAccounts := range groupByKind { addPurgeItem := itemToPurge{ resourceType: name, count: len(cogAccounts), purge: func(skipPurge bool, self *itemToPurge) error { return p.purgeCognitiveAccounts(ctx, self.cognitiveAccounts, skipPurge) }, cognitiveAccounts: groupByKind[name], } purgeItem = append(purgeItem, addPurgeItem) } if err := p.purgeItems(ctx, purgeItem, options); err != nil { return nil, fmt.Errorf("purging resources: %w", err) } destroyResult := &provisioning.DestroyResult{ InvalidatedEnvKeys: slices.Collect(maps.Keys(p.createOutputParameters( compileResult.Template.Outputs, azapi.CreateDeploymentOutput(mostRecentDeployment.Outputs), ))), } // Since we have deleted the resource group, add AZURE_RESOURCE_GROUP to the list of invalidated env vars // so it will be removed from the .env file. if _, ok := scope.(*infra.ResourceGroupScope); ok { destroyResult.InvalidatedEnvKeys = append( destroyResult.InvalidatedEnvKeys, environment.ResourceGroupEnvVarName, ) } return destroyResult, nil } // A local type for adding the resource group to a cognitive account as it is required for purging type cognitiveAccount struct { account armcognitiveservices.Account resourceGroup string } // transform a map of resourceGroup and accounts to group by kind in all resource groups but keeping the resource group // on each account func cognitiveAccountsByKind( accountsByResourceGroup map[string][]armcognitiveservices.Account) map[string][]cognitiveAccount { result := make(map[string][]cognitiveAccount) for resourceGroup, cogAccounts := range accountsByResourceGroup { for _, cogAccount := range cogAccounts { kindName := *cogAccount.Kind // Replace "FormRecognizer" with "DocumentIntelligence" if kindName == "FormRecognizer" { kindName = "Document Intelligence" } _, exists := result[kindName] if exists { result[kindName] = append(result[kindName], cognitiveAccount{ account: cogAccount, resourceGroup: resourceGroup, }) } else { result[kindName] = []cognitiveAccount{{ account: cogAccount, resourceGroup: resourceGroup, }} } } } return result } func getDeploymentOptions(deployments []*azapi.ResourceDeployment) []string { promptValues := []string{} for index, deployment := range deployments { optionTitle := fmt.Sprintf("%d. %s (%s)", index+1, deployment.Name, deployment.Timestamp.Local().Format("1/2/2006, 3:04 PM"), ) promptValues = append(promptValues, optionTitle) } return promptValues } // resourceGroupsToDelete collects the resource groups from an existing deployment which should be removed as part of a // destroy operation. func resourceGroupsToDelete(deployment *azapi.ResourceDeployment) []string { // NOTE: it's possible for a deployment to list a resource group more than once. We're only interested in the // unique set. resourceGroups := map[string]struct{}{} if deployment.ProvisioningState == azapi.DeploymentProvisioningStateSucceeded { // For a successful deployment, we can use the output resources property to see the resource groups that were // provisioned from this. for _, resourceId := range deployment.Resources { if resourceId != nil && resourceId.ID != nil { resId, err := arm.ParseResourceID(*resourceId.ID) if err == nil && resId.ResourceGroupName != "" { resourceGroups[resId.ResourceGroupName] = struct{}{} } } } } else { // For a failed deployment, the `outputResources` field is not populated. Instead, we assume that any resource // groups which this deployment itself deployed into should be deleted. This matches what a deployment likes // for the common pattern of having a subscription level deployment which allocates a set of resource groups // and then does nested deployments into them. for _, dependency := range deployment.Dependencies { if *dependency.ResourceType == string(azapi.AzureResourceTypeDeployment) { for _, dependent := range dependency.DependsOn { if *dependent.ResourceType == arm.ResourceGroupResourceType.String() { resourceGroups[*dependent.ResourceName] = struct{}{} } } } } } return slices.Collect(maps.Keys(resourceGroups)) } func (p *BicepProvider) generateResourcesToDelete(groupedResources map[string][]*azapi.Resource) []string { lines := []string{"Resource(s) to be deleted:"} for resourceGroupName, resources := range groupedResources { lines = append(lines, "") // Resource Group resourceGroupLink := fmt.Sprintf("%s/#@/resource/subscriptions/%s/resourceGroups/%s/overview", p.portalUrlBase, p.env.GetSubscriptionId(), resourceGroupName, ) lines = append(lines, fmt.Sprintf("%s %s", output.WithHighLightFormat("Resource Group:"), output.WithHyperlink(resourceGroupLink, resourceGroupName), ), ) // Resources in each group for _, resource := range resources { resourceTypeName := azapi.GetResourceTypeDisplayName(azapi.AzureResourceType(resource.Type)) if resourceTypeName == "" { continue } lines = append(lines, fmt.Sprintf(" • %s: %s", resourceTypeName, resource.Name)) } } return append(lines, "\n") } // Deletes the azure resources within the deployment func (p *BicepProvider) destroyDeploymentWithConfirmation( ctx context.Context, options provisioning.DestroyOptions, deployment infra.Deployment, groupedResources map[string][]*azapi.Resource, resourceCount int, ) error { if !options.Force() { p.console.MessageUxItem(ctx, &ux.MultilineMessage{ Lines: p.generateResourcesToDelete(groupedResources)}, ) confirmDestroy, err := p.console.Confirm(ctx, input.ConsoleOptions{ Message: fmt.Sprintf( "Total resources to %s: %d, are you sure you want to continue?", output.WithErrorFormat("delete"), resourceCount, ), DefaultValue: false, }) if err != nil { return fmt.Errorf("prompting for delete confirmation: %w", err) } if !confirmDestroy { return errors.New("user denied delete confirmation") } } p.console.Message(ctx, output.WithGrayFormat("Deleting your resources can take some time.\n")) err := async.RunWithProgressE(func(progressMessage azapi.DeleteDeploymentProgress) { switch progressMessage.State { case azapi.DeleteResourceStateInProgress: p.console.ShowSpinner(ctx, progressMessage.Message, input.Step) case azapi.DeleteResourceStateSucceeded: p.console.StopSpinner(ctx, progressMessage.Message, input.StepDone) case azapi.DeleteResourceStateFailed: p.console.StopSpinner(ctx, progressMessage.Message, input.StepFailed) } }, func(progress *async.Progress[azapi.DeleteDeploymentProgress]) error { optionsMap, err := convert.ToMap(p.options) if err != nil { return err } return deployment.Delete(ctx, optionsMap, progress) }) if err != nil { return err } p.console.Message(ctx, "") return nil } func itemsCountAsText(items []itemToPurge) string { count := len(items) if count < 1 { log.Panic("calling itemsCountAsText() with empty list.") } var tokens []string for _, item := range items { if item.count > 0 { tokens = append(tokens, fmt.Sprintf("%d %s", item.count, item.resourceType)) } } return ux.ListAsText(tokens) } func (p *BicepProvider) purgeItems( ctx context.Context, items []itemToPurge, options provisioning.DestroyOptions, ) error { if len(items) == 0 { // nothing to purge return nil } skipPurge := false if !options.Purge() { p.console.MessageUxItem(ctx, &ux.WarningMessage{ Description: fmt.Sprintf( "The following operation will delete %s.", itemsCountAsText(items), ), }) p.console.Message(ctx, fmt.Sprintf( "These resources have soft delete enabled allowing them to be recovered for a period or time "+ "after deletion. During this period, their names may not be reused. In the future, you can use "+ "the argument %s to skip this confirmation.\n", output.WithHighLightFormat("--purge"))) purgeItems, err := p.console.Confirm(ctx, input.ConsoleOptions{ Message: fmt.Sprintf( "Would you like to %s these resources instead, allowing their names to be reused?", output.WithErrorFormat("permanently delete"), ), DefaultValue: false, }) p.console.Message(ctx, "") if err != nil { return fmt.Errorf("prompting for confirmation: %w", err) } if !purgeItems { skipPurge = true } } for index, item := range items { if err := item.purge(skipPurge, &items[index]); err != nil { return fmt.Errorf("failed to purge %s: %w", item.resourceType, err) } } return nil } func (p *BicepProvider) getKeyVaults( ctx context.Context, groupedResources map[string][]*azapi.Resource, ) ([]*keyvault.KeyVault, error) { vaults := []*keyvault.KeyVault{} for resourceGroup, groupResources := range groupedResources { for _, resource := range groupResources { if resource.Type == string(azapi.AzureResourceTypeKeyVault) { vault, err := p.keyvaultService.GetKeyVault( ctx, azure.SubscriptionFromRID(resource.Id), resourceGroup, resource.Name) if err != nil { return nil, fmt.Errorf("listing key vault %s properties: %w", resource.Name, err) } vaults = append(vaults, vault) } } } return vaults, nil } func (p *BicepProvider) getKeyVaultsToPurge( ctx context.Context, groupedResources map[string][]*azapi.Resource, ) ([]*keyvault.KeyVault, error) { vaults, err := p.getKeyVaults(ctx, groupedResources) if err != nil { return nil, err } vaultsToPurge := []*keyvault.KeyVault{} for _, v := range vaults { if v.Properties.EnableSoftDelete && !v.Properties.EnablePurgeProtection { vaultsToPurge = append(vaultsToPurge, v) } } return vaultsToPurge, nil } func (p *BicepProvider) getManagedHSMs( ctx context.Context, groupedResources map[string][]*azapi.Resource, ) ([]*azapi.AzCliManagedHSM, error) { managedHSMs := []*azapi.AzCliManagedHSM{} for resourceGroup, groupResources := range groupedResources { for _, resource := range groupResources { if resource.Type == string(azapi.AzureResourceTypeManagedHSM) { managedHSM, err := p.azapi.GetManagedHSM( ctx, azure.SubscriptionFromRID(resource.Id), resourceGroup, resource.Name, ) if err != nil { return nil, fmt.Errorf("listing managed hsm %s properties: %w", resource.Name, err) } managedHSMs = append(managedHSMs, managedHSM) } } } return managedHSMs, nil } func (p *BicepProvider) getManagedHSMsToPurge( ctx context.Context, groupedResources map[string][]*azapi.Resource, ) ([]*azapi.AzCliManagedHSM, error) { managedHSMs, err := p.getManagedHSMs(ctx, groupedResources) if err != nil { return nil, err } managedHSMsToPurge := []*azapi.AzCliManagedHSM{} for _, v := range managedHSMs { if v.Properties.EnableSoftDelete && !v.Properties.EnablePurgeProtection { managedHSMsToPurge = append(managedHSMsToPurge, v) } } return managedHSMsToPurge, nil } func (p *BicepProvider) getCognitiveAccountsToPurge( ctx context.Context, groupedResources map[string][]*azapi.Resource, ) (map[string][]armcognitiveservices.Account, error) { result := make(map[string][]armcognitiveservices.Account) for resourceGroup, groupResources := range groupedResources { cognitiveAccounts := []armcognitiveservices.Account{} for _, resource := range groupResources { if resource.Type == string(azapi.AzureResourceTypeCognitiveServiceAccount) { account, err := p.azapi.GetCognitiveAccount( ctx, azure.SubscriptionFromRID(resource.Id), resourceGroup, resource.Name) if err != nil { return nil, fmt.Errorf("getting cognitive account %s: %w", resource.Name, err) } cognitiveAccounts = append(cognitiveAccounts, account) } if len(cognitiveAccounts) > 0 { result[resourceGroup] = cognitiveAccounts } } } return result, nil } // Azure KeyVaults have a "soft delete" functionality (now enabled by default) where a vault may be marked // such that when it is deleted it can be recovered for a period of time. During that time, the name may // not be reused. // // This means that running `azd provision`, then `azd down` and finally `azd provision` // again would lead to a deployment error since the vault name is in use. // // Since that's behavior we'd like to support, we run a purge operation for each KeyVault after // it has been deleted. // // See // https://docs.microsoft.com/azure/key-vault/general/key-vault-recovery?tabs=azure-portal#what-are-soft-delete-and-purge-protection // for more information on this feature. // //nolint:lll func (p *BicepProvider) purgeKeyVaults( ctx context.Context, keyVaults []*keyvault.KeyVault, skip bool, ) error { for _, keyVault := range keyVaults { err := p.runPurgeAsStep(ctx, "Key Vault", keyVault.Name, func() error { return p.keyvaultService.PurgeKeyVault( ctx, azure.SubscriptionFromRID(keyVault.Id), keyVault.Name, keyVault.Location) }, skip) if err != nil { return fmt.Errorf("purging key vault %s: %w", keyVault.Name, err) } } return nil } func (p *BicepProvider) purgeManagedHSMs( ctx context.Context, managedHSMs []*azapi.AzCliManagedHSM, skip bool, ) error { for _, managedHSM := range managedHSMs { err := p.runPurgeAsStep(ctx, "Managed HSM", managedHSM.Name, func() error { return p.azapi.PurgeManagedHSM( ctx, azure.SubscriptionFromRID(managedHSM.Id), managedHSM.Name, managedHSM.Location) }, skip) if err != nil { return fmt.Errorf("purging managed hsm %s: %w", managedHSM.Name, err) } } return nil } func (p *BicepProvider) purgeCognitiveAccounts( ctx context.Context, cognitiveAccounts []cognitiveAccount, skip bool, ) error { for _, cogAccount := range cognitiveAccounts { accountName := cogAccount.account.Name if accountName == nil { return fmt.Errorf("Cognitive account without a name") } accountId := cogAccount.account.ID if accountId == nil { return fmt.Errorf("Cognitive account without an id") } location := cogAccount.account.Location if location == nil { return fmt.Errorf("Cognitive account without a location") } err := p.runPurgeAsStep(ctx, "Cognitive Account", *accountName, func() error { return p.azapi.PurgeCognitiveAccount( ctx, azure.SubscriptionFromRID(*accountId), *location, cogAccount.resourceGroup, *accountName) }, skip) if err != nil { return fmt.Errorf("purging cognitive account %s: %w", *accountName, err) } } return nil } func (p *BicepProvider) runPurgeAsStep( ctx context.Context, purgeType, name string, step func() error, skipped bool) error { message := fmt.Sprintf("Purging %s: %s", purgeType, output.WithHighLightFormat(name)) p.console.ShowSpinner(ctx, message, input.Step) if skipped { p.console.StopSpinner(ctx, message, input.StepSkipped) return nil } err := step() p.console.StopSpinner(ctx, message, input.GetStepResultFormat(err)) return err } func (p *BicepProvider) getAppConfigsToPurge( ctx context.Context, groupedResources map[string][]*azapi.Resource, ) ([]*azapi.AzCliAppConfig, error) { configs := []*azapi.AzCliAppConfig{} for resourceGroup, groupResources := range groupedResources { for _, resource := range groupResources { if resource.Type == string(azapi.AzureResourceTypeAppConfig) { config, err := p.azapi.GetAppConfig( ctx, azure.SubscriptionFromRID(resource.Id), resourceGroup, resource.Name, ) if err != nil { return nil, fmt.Errorf("listing app configuration %s properties: %w", resource.Name, err) } if !config.Properties.EnablePurgeProtection { configs = append(configs, config) } } } } return configs, nil } func (p *BicepProvider) getApiManagementsToPurge( ctx context.Context, groupedResources map[string][]*azapi.Resource, ) ([]*azapi.AzCliApim, error) { apims := []*azapi.AzCliApim{} for resourceGroup, groupResources := range groupedResources { for _, resource := range groupResources { if resource.Type == string(azapi.AzureResourceTypeApim) { apim, err := p.azapi.GetApim(ctx, azure.SubscriptionFromRID(resource.Id), resourceGroup, resource.Name) if err != nil { return nil, fmt.Errorf("listing api management service %s properties: %w", resource.Name, err) } //No filtering needed like it does in key vaults or app configuration //as soft-delete happens for all Api Management resources apims = append(apims, apim) } } } return apims, nil } // Azure AppConfigurations have a "soft delete" functionality (now enabled by default) where a configuration store // may be marked such that when it is deleted it can be recovered for a period of time. During that time, // the name may not be reused. // // This means that running `azd provision`, then `azd down` and finally `azd provision` // again would lead to a deployment error since the configuration name is in use. // // Since that's behavior we'd like to support, we run a purge operation for each AppConfiguration after it has been deleted. // // See https://learn.microsoft.com/azure/azure-app-configuration/concept-soft-delete for more information // on this feature. func (p *BicepProvider) purgeAppConfigs( ctx context.Context, appConfigs []*azapi.AzCliAppConfig, skip bool, ) error { for _, appConfig := range appConfigs { err := p.runPurgeAsStep(ctx, "app config", appConfig.Name, func() error { return p.azapi.PurgeAppConfig( ctx, azure.SubscriptionFromRID(appConfig.Id), appConfig.Name, appConfig.Location) }, skip) if err != nil { return fmt.Errorf("purging app configuration %s: %w", appConfig.Name, err) } } return nil } func (p *BicepProvider) purgeAPIManagement( ctx context.Context, apims []*azapi.AzCliApim, skip bool, ) error { for _, apim := range apims { err := p.runPurgeAsStep(ctx, "apim", apim.Name, func() error { return p.azapi.PurgeApim(ctx, azure.SubscriptionFromRID(apim.Id), apim.Name, apim.Location) }, skip) if err != nil { return fmt.Errorf("purging api management service %s: %w", apim.Name, err) } } return nil } func (p *BicepProvider) mapBicepTypeToInterfaceType(s string) provisioning.ParameterType { switch s { case "String", "string", "secureString", "securestring": return provisioning.ParameterTypeString case "Bool", "bool": return provisioning.ParameterTypeBoolean case "Int", "int": return provisioning.ParameterTypeNumber case "Object", "object", "secureObject", "secureobject": return provisioning.ParameterTypeObject case "Array", "array": return provisioning.ParameterTypeArray default: panic(fmt.Sprintf("unexpected bicep type: '%s'", s)) } } // Creates a normalized view of the azure output parameters and resolves inconsistencies in the output parameter name // casings. func (p *BicepProvider) createOutputParameters( templateOutputs azure.ArmTemplateOutputs, azureOutputParams map[string]azapi.AzCliDeploymentOutput, ) map[string]provisioning.OutputParameter { canonicalOutputCasings := make(map[string]string, len(templateOutputs)) for key := range templateOutputs { canonicalOutputCasings[strings.ToLower(key)] = key } outputParams := make(map[string]provisioning.OutputParameter, len(azureOutputParams)) for key, azureParam := range azureOutputParams { var paramName string canonicalCasing, found := canonicalOutputCasings[strings.ToLower(key)] if found { paramName = canonicalCasing } else { // To support BYOI (bring your own infrastructure) scenarios we will default to UPPER when canonical casing // is not found in the parameters file to workaround strange azure behavior with OUTPUT values that look // like `azurE_RESOURCE_GROUP` paramName = strings.ToUpper(key) } outputParams[paramName] = provisioning.OutputParameter{ Type: p.mapBicepTypeToInterfaceType(azureParam.Type), Value: azureParam.Value, } } return outputParams } type loadParametersResult struct { parameters map[string]azure.ArmParameter locationParams []string } // loadParameters reads the parameters file template for environment/module specified by Options, // doing environment and command substitutions, and returns the values. func (p *BicepProvider) loadParameters(ctx context.Context) (loadParametersResult, error) { parametersFilename := fmt.Sprintf("%s.parameters.json", p.options.Module) parametersRoot := p.options.Path if !filepath.IsAbs(parametersRoot) { parametersRoot = filepath.Join(p.projectPath, parametersRoot) } paramFilePath := filepath.Join(parametersRoot, parametersFilename) parametersBytes, err := os.ReadFile(paramFilePath) // if the file does not exist, we return an empty parameters map // This makes AZD to support deploying bicep modules without parameters file, assuming AZD prompts for all required // parameters. if os.IsNotExist(err) { log.Printf("parameters file %s does not exist, using empty parameters", paramFilePath) return loadParametersResult{}, nil } if err != nil { return loadParametersResult{}, fmt.Errorf("reading parameters.json: %w", err) } principalId, err := p.curPrincipal.CurrentPrincipalId(ctx) if err != nil { return loadParametersResult{}, fmt.Errorf("fetching current principal id: %w", err) } var decodedParamsFile azure.ArmParameterFile if err := json.Unmarshal(parametersBytes, &decodedParamsFile); err != nil { return loadParametersResult{}, fmt.Errorf("error unmarshalling Bicep template parameters: %w", err) } parametersMappedToAzureLocation := []string{} resolvedParams := map[string]azure.ArmParameter{} // resolving each parameter to keep track of the name during the resolution. // We used to resolve all the file before, supporting env var substitution at any part of the file. // We want to support substitution only for the parameter value. // We also need to identify which parameters are mapped to AZURE_LOCATION (if any). // We also want to exclude parameters mapped to env vars which env var is not set (instead of using empty string). for paramName, param := range decodedParamsFile.Parameters { paramBytes, err := json.Marshal(param) if err != nil { return loadParametersResult{}, fmt.Errorf("error decoding deployment parameter %s: %w", paramName, err) } var hasUnsetEnvVar bool // envsubst.Eval handles env var substitution and default values like ${VAR=default} replaced, err := envsubst.Eval(string(paramBytes), func(name string) string { if name == environment.PrincipalIdEnvVarName { return principalId } if name == environment.LocationEnvVarName { parametersMappedToAzureLocation = append(parametersMappedToAzureLocation, paramName) } if _, isDefined := p.env.LookupEnv(name); !isDefined { hasUnsetEnvVar = true } return p.env.Getenv(name) }) if err != nil { return loadParametersResult{}, fmt.Errorf("substituting environment variables for %s: %w", paramName, err) } // resolve `secretOrRandomPassword` -> this is a way to ask AZD to generate a password for the user and // store it in a Key Vault. But if the Key Vault and secret exists, AZD just takes the secret from there. if cmdsubst.ContainsCommandInvocation(replaced, cmdsubst.SecretOrRandomPasswordCommandName) { cmdExecutor := cmdsubst.NewSecretOrRandomPasswordExecutor(p.keyvaultService, p.env.GetSubscriptionId()) replaced, err = cmdsubst.Eval(ctx, replaced, cmdExecutor) if err != nil { return loadParametersResult{}, fmt.Errorf("substituting command output inside parameter file: %w", err) } } var resolvedParam azure.ArmParameter if err := json.Unmarshal([]byte(replaced), &resolvedParam); err != nil { return loadParametersResult{}, fmt.Errorf("error unmarshalling Bicep template parameters: %w", err) } if resolvedParam.Value == nil && resolvedParam.KeyVaultReference == nil { // ignore parameters that are not set continue } if resolvedParam.Value != nil && resolvedParam.KeyVaultReference != nil { return loadParametersResult{}, fmt.Errorf( "parameter %s has both a value and a keyvault reference: %w", paramName, err) } if resolvedParam.KeyVaultReference != nil { // parameter defined using a key vault reference. AZD does not validate the key vault reference // if there is an issue with it, the deployment will fail. resolvedParams[paramName] = resolvedParam continue } stringValue, isString := resolvedParam.Value.(string) if !isString { continue } // After previous checks, we know resolvedParam.Value is not nil if stringValue == "" && hasUnsetEnvVar { // parameter is empty and has an unset env var continue } // all other cases here represent a valid resolved parameter resolvedParams[paramName] = resolvedParam } return loadParametersResult{ parameters: resolvedParams, locationParams: parametersMappedToAzureLocation, }, nil } type compiledBicepParamResult struct { TemplateJson string `json:"templateJson"` ParametersJson string `json:"parametersJson"` } type compileBicepResult struct { RawArmTemplate azure.RawArmTemplate Template azure.ArmTemplate // Parameters are populated either by compiling a .bicepparam (automatically) or by azd after compiling a .bicep file. Parameters azure.ArmParameters } // compileBicep compiles the bicep module at the given path and returns the compiled ARM template and parameters. // The results of the compilation are cached in memory. func (p *BicepProvider) compileBicep( ctx context.Context, modulePath string, ) (*compileBicepResult, error) { if p.compileBicepMemoryCache != nil { return p.compileBicepMemoryCache, nil } var compiled string var parameters azure.ArmParameters if isBicepParamFile(modulePath) { azdEnv := p.env.Environ() // append principalID (not stored to .env by default). For non-bicepparam, principalId is resolved // without looking at .env if _, exists := p.env.LookupEnv(environment.PrincipalIdEnvVarName); !exists { currentPrincipalId, err := p.curPrincipal.CurrentPrincipalId(ctx) if err != nil { return nil, fmt.Errorf("fetching current principal id for bicepparam compilation: %w", err) } azdEnv = append(azdEnv, fmt.Sprintf("%s=%s", environment.PrincipalIdEnvVarName, currentPrincipalId)) } compiledResult, err := p.bicepCli.BuildBicepParam(ctx, modulePath, azdEnv) if err != nil { return nil, fmt.Errorf("failed to compile bicepparam template: %w", err) } compiled = compiledResult.Compiled var bicepParamOutput compiledBicepParamResult if err := json.Unmarshal([]byte(compiled), &bicepParamOutput); err != nil { log.Printf("failed unmarshalling compiled bicepparam (err: %v), template contents:\n%s", err, compiled) return nil, fmt.Errorf("failed unmarshalling arm template from json: %w", err) } compiled = bicepParamOutput.TemplateJson var params azure.ArmParameterFile if err := json.Unmarshal([]byte(bicepParamOutput.ParametersJson), &params); err != nil { log.Printf("failed unmarshalling compiled bicepparam parameters(err: %v), template contents:\n%s", err, compiled) return nil, fmt.Errorf("failed unmarshalling arm parameters template from json: %w", err) } parameters = params.Parameters } else { res, err := p.bicepCli.Build(ctx, modulePath) if err != nil { return nil, fmt.Errorf("failed to compile bicep template: %w", err) } compiled = res.Compiled } rawTemplate := azure.RawArmTemplate(compiled) var template azure.ArmTemplate if err := json.Unmarshal(rawTemplate, &template); err != nil { log.Printf("failed unmarshalling compiled arm template to JSON (err: %v), template contents:\n%s", err, compiled) return nil, fmt.Errorf("failed unmarshalling arm template from json: %w", err) } // update user-defined parameters for paramKey, param := range template.Parameters { paramRef := param.Ref isUserDefinedType := paramRef != "" if isUserDefinedType { definitionKeyName, err := definitionName(paramRef) if err != nil { return nil, err } paramDefinition, findDefinition := template.Definitions[definitionKeyName] if !findDefinition { return nil, fmt.Errorf("did not find definition for parameter type: %s", definitionKeyName) } template.Parameters[paramKey] = azure.ArmTemplateParameterDefinition{ // Take this values from the parameter definition Type: paramDefinition.Type, AllowedValues: paramDefinition.AllowedValues, Properties: paramDefinition.Properties, AdditionalProperties: paramDefinition.AdditionalProperties, // Azd combines Metadata from type definition and original parameter // This allows to definitions to use azd-metadata on user-defined types and then add more properties // to metadata or override something just for one parameter Metadata: combineMetadata(paramDefinition.Metadata, param.Metadata), // Keep this values from the original parameter DefaultValue: param.DefaultValue, // Note: Min/MaxLength and Min/MaxValue can't be used on user-defined types. No need to handle it here. } } } // outputs resolves just the type. Value and Metadata should persist for outputKey, output := range template.Outputs { paramRef := output.Ref isUserDefinedType := paramRef != "" if isUserDefinedType { definitionKeyName, err := definitionName(paramRef) if err != nil { return nil, err } paramDefinition, findDefinition := template.Definitions[definitionKeyName] if !findDefinition { return nil, fmt.Errorf("did not find definition for parameter type: %s", definitionKeyName) } template.Outputs[outputKey] = azure.ArmTemplateOutput{ Type: paramDefinition.Type, Value: output.Value, Metadata: output.Metadata, } } } p.compileBicepMemoryCache = &compileBicepResult{ RawArmTemplate: rawTemplate, Template: template, Parameters: parameters, } return p.compileBicepMemoryCache, nil } func combineMetadata(base map[string]json.RawMessage, override map[string]json.RawMessage) map[string]json.RawMessage { if base == nil && override == nil { return nil } if override == nil { return base } // final map is expected to be at least the same size as the base finalMetadata := make(map[string]json.RawMessage, len(base)) for key, data := range base { finalMetadata[key] = data } for key, data := range override { finalMetadata[key] = data } return finalMetadata } func definitionName(typeDefinitionRef string) (string, error) { // We typically expect `#/definitions/<name>` or `/definitions/<name>`, but loosely, we simply take // `<name>` as the value of the last separated element. definitionKeyNameTokens := strings.Split(typeDefinitionRef, "/") definitionKeyNameTokensLen := len(definitionKeyNameTokens) if definitionKeyNameTokensLen < 1 { return "", fmt.Errorf("failed resolving user defined parameter type: %s", typeDefinitionRef) } return definitionKeyNameTokens[definitionKeyNameTokensLen-1], nil } // Converts a Bicep parameters file to a generic provisioning template func (p *BicepProvider) convertToDeployment(bicepTemplate azure.ArmTemplate) (*provisioning.Deployment, error) { template := provisioning.Deployment{} parameters := make(map[string]provisioning.InputParameter) outputs := make(map[string]provisioning.OutputParameter) for key, param := range bicepTemplate.Parameters { parameters[key] = provisioning.InputParameter{ Type: string(p.mapBicepTypeToInterfaceType(param.Type)), DefaultValue: param.DefaultValue, } } for key, param := range bicepTemplate.Outputs { outputs[key] = provisioning.OutputParameter{ Type: p.mapBicepTypeToInterfaceType(param.Type), Value: param.Value, } } template.Parameters = parameters template.Outputs = outputs return &template, nil } func (p *BicepProvider) validatePreflight( ctx context.Context, target infra.Deployment, armTemplate azure.RawArmTemplate, armParameters azure.ArmParameters, tags map[string]*string, options map[string]any, ) error { return target.ValidatePreflight(ctx, armTemplate, armParameters, tags, options) } // Deploys the specified Bicep module and parameters with the selected provisioning scope (subscription vs resource group) func (p *BicepProvider) deployModule( ctx context.Context, target infra.Deployment, armTemplate azure.RawArmTemplate, armParameters azure.ArmParameters, tags map[string]*string, options map[string]any, ) (*azapi.ResourceDeployment, error) { return target.Deploy(ctx, armTemplate, armParameters, tags, options) } // Returns either the bicep or bicepparam module file located in the infrastructure root. // The bicepparam file is preferred over bicep file. func (p *BicepProvider) modulePath() string { infraRoot := p.options.Path moduleName := p.options.Module if !filepath.IsAbs(infraRoot) { infraRoot = filepath.Join(p.projectPath, infraRoot) } // Check if there's a <moduleName>.bicepparam first. It will be preferred over a <moduleName>.bicep moduleFilename := moduleName + bicepparamFileExtension moduleFilePath := filepath.Join(infraRoot, moduleFilename) if _, err := os.Stat(moduleFilePath); err == nil { return moduleFilePath } // fallback to .bicep moduleFilename = moduleName + bicepFileExtension return filepath.Join(infraRoot, moduleFilename) } // inputsParameter generates and updates input parameters for the Azure Resource Manager (ARM) template. // It takes an existingInputs map that contains the current input values for each resource, and an autoGenParameters map // that contains information about the input parameters to be generated. // The method iterates over the autoGenParameters map and checks if each input parameter already exists in the existingInputs // map. // If an input parameter does not exist, a new value is generated and added to the existingInputs map. // The method returns an azure.ArmParameterValue struct that contains the updated existingInputs map, a boolean indicating // whether new inputs were written, and an error if any occurred during the generation of input values. func inputsParameter( existingInputs map[string]map[string]any, autoGenParameters map[string]map[string]azure.AutoGenInput) ( inputsParameter azure.ArmParameter, inputsUpdated bool, err error) { wroteNewInput := false for inputResource, inputResourceInfo := range autoGenParameters { existingRecordsForResource := make(map[string]any) if current, exists := existingInputs[inputResource]; exists { existingRecordsForResource = current } for inputName, inputInfo := range inputResourceInfo { if _, has := existingRecordsForResource[inputName]; !has { val, err := password.Generate(password.GenerateConfig{ Length: inputInfo.Length, NoLower: inputInfo.NoLower, NoUpper: inputInfo.NoUpper, NoNumeric: inputInfo.NoNumeric, NoSpecial: inputInfo.NoSpecial, MinLower: inputInfo.MinLower, MinUpper: inputInfo.MinUpper, MinNumeric: inputInfo.MinNumeric, MinSpecial: inputInfo.MinSpecial, }, ) if err != nil { return inputsParameter, inputsUpdated, fmt.Errorf("generating value for input %s: %w", inputName, err) } existingRecordsForResource[inputName] = val wroteNewInput = true } } existingInputs[inputResource] = existingRecordsForResource } return azure.ArmParameter{ Value: existingInputs, }, wroteNewInput, nil } // ensureParameters validates that all parameters from the template are defined. // Parameters values can be defined in the parameters file (main.parameters.json). This file supports mapping values to // environment variables. // If a parameter is not defined in the parameters file AND there is not a default value defined in the template for it, // AZD will prompt the user for a value. // Parameters mapped to env var ${AZURE_LOCATION} are identified as location parameters for AZD during prompting. AZD will // prompt just for one location and save the value in AZD's .env file as AZURE_LOCATION and the value is used for all // parameters mapped to that env var. // AZD supports resolving env vars with a default value defined in the parameters file using the syntax // ${AZURE_LOCATION=defaultValue}. If the env var is not set, the default value will be used. func (p *BicepProvider) ensureParameters( ctx context.Context, template azure.ArmTemplate, ) (azure.ArmParameters, error) { //snapshot the AZURE_LOCATIOn in azd env if it is set in System env locationSystemEnv, hasLocation := os.LookupEnv(environment.LocationEnvVarName) _, hasAzdLocation := p.env.Dotenv()[environment.LocationEnvVarName] if hasLocation && !hasAzdLocation && locationSystemEnv != "" { p.env.SetLocation(locationSystemEnv) if err := p.envManager.Save(ctx, p.env); err != nil { return nil, fmt.Errorf("saving location to .env: %w", err) } } // using loadParameters to resolve the parameters file (usually main.parameters.json) // parameters with a mapping to env vars are resolved. // Parameters mapped to env vars that are not set in the environment are removed from the parameters file parametersResult, err := p.loadParameters(ctx) if err != nil { return nil, fmt.Errorf("resolving bicep parameters file: %w", err) } parameters := parametersResult.parameters locationParameters := parametersResult.locationParams if len(template.Parameters) == 0 { return azure.ArmParameters{}, nil } configuredParameters := make(azure.ArmParameters, len(template.Parameters)) sortedKeys := slices.Sorted(maps.Keys(template.Parameters)) configModified := false var parameterPrompts []struct { key string param azure.ArmTemplateParameterDefinition } // make all parameters mapped to AZURE_LOCATION env var to be location parameters for _, key := range sortedKeys { param := template.Parameters[key] if slices.Contains(locationParameters, key) { azdMetadata, hasAzdMetadata := param.AzdMetadata() if !hasAzdMetadata { azdMetadata = azure.AzdMetadata{ Type: to.Ptr(azure.AzdMetadataTypeLocation), } } if azdMetadata.Type == nil { azdMetadata.Type = to.Ptr(azure.AzdMetadataTypeLocation) } if azdMetadata.Type != nil && *azdMetadata.Type != azure.AzdMetadataTypeLocation { return nil, fmt.Errorf( "parameter %s is mapped to AZURE_LOCATION but has a different azd metadata type: %s."+ "Parameters mapped to AZURE_LOCATION can only be typed as location", key, *azdMetadata.Type) } mdBytes, err := json.Marshal(azdMetadata) if err != nil { return nil, fmt.Errorf("marshalling azd metadata: %w", err) } if param.Metadata == nil { param.Metadata = map[string]json.RawMessage{"azd": mdBytes} } else { param.Metadata["azd"] = mdBytes } template.Parameters[key] = param } } for _, key := range sortedKeys { param := template.Parameters[key] parameterType := p.mapBicepTypeToInterfaceType(param.Type) azdMetadata, hasMetadata := param.AzdMetadata() // If a value is explicitly configured via a parameters file, use it. // unless the parameter value inference is nil/empty if v, has := parameters[key]; has { // Directly pass through Key Vault references without prompting. if v.KeyVaultReference != nil { configuredParameters[key] = azure.ArmParameter{ KeyVaultReference: v.KeyVaultReference, } continue } paramValue := armParameterFileValue(parameterType, v.Value, param.DefaultValue) if paramValue != nil { if stringValue, isString := paramValue.(string); isString && param.Secure() { // For secure parameters using a string value, azd checks if the string is an Azure Key Vault Secret // and if yes, it fetches the secret value from the Key Vault. if keyvault.IsAzureKeyVaultSecret(stringValue) { paramValue, err = p.keyvaultService.SecretFromAkvs(ctx, stringValue) if err != nil { return nil, err } } } needForDeployParameter := hasMetadata && azdMetadata.Type != nil && *azdMetadata.Type == azure.AzdMetadataTypeNeedForDeploy if needForDeployParameter && paramValue == "" && param.DefaultValue != nil { // Parameters with needForDeploy metadata don't support overriding with empty values when a default // value is present. If the value is empty, we'll use the default value instead. defValue, castOk := param.DefaultValue.(string) if castOk { paramValue = defValue } } configuredParameters[key] = azure.ArmParameter{ Value: paramValue, } if needForDeployParameter { mustSetParamAsConfig(key, paramValue, p.env.Config, param.Secure()) configModified = true } continue } } // If this parameter has a default, then there is no need for us to configure it. if param.DefaultValue != nil { continue } if param.Nullable != nil && *param.Nullable { // If the parameter is nullable, we can skip prompting for it. continue } // This required parameter was not in parameters file - see if we stored a value in config from an earlier // prompt and if so use it. configKey := fmt.Sprintf("infra.parameters.%s", key) if v, has := p.env.Config.Get(configKey); has { if isValueAssignableToParameterType(parameterType, v) { configuredParameters[key] = azure.ArmParameter{ Value: v, } continue } else { // The saved value is no longer valid (perhaps the user edited their template to change the type of a) // parameter and then re-ran `azd provision`. Forget the saved value (if we can) and prompt for a new one. _ = p.env.Config.Unset("infra.parameters.%s") } } // If the parameter is tagged with {type: "generate"}, skip prompting. // We generate it once, then save to config for next attempts.`. if hasMetadata && parameterType == provisioning.ParameterTypeString && azdMetadata.Type != nil && *azdMetadata.Type == azure.AzdMetadataTypeGenerate { // - generate once genValue, err := autoGenerate(key, azdMetadata) if err != nil { return nil, err } configuredParameters[key] = azure.ArmParameter{ Value: genValue, } mustSetParamAsConfig(key, genValue, p.env.Config, param.Secure()) configModified = true continue } // No saved value for this required parameter, we'll need to prompt for it. parameterPrompts = append(parameterPrompts, struct { key string param azure.ArmTemplateParameterDefinition }{key: key, param: param}) } if len(parameterPrompts) > 0 { if p.console.SupportsPromptDialog() { dialog := input.PromptDialog{ Title: "Configure required deployment parameters", Description: "The following parameters are required for deployment. " + "Provide values for each parameter. They will be saved for future deployments.", } for _, prompt := range parameterPrompts { dialog.Prompts = append(dialog.Prompts, p.promptDialogItemForParameter(prompt.key, prompt.param)) } values, err := p.console.PromptDialog(ctx, dialog) if err != nil { return nil, fmt.Errorf("prompting for values: %w", err) } for _, prompt := range parameterPrompts { key := prompt.key value := values[prompt.key] mustSetParamAsConfig(key, value, p.env.Config, prompt.param.Secure()) configModified = true configuredParameters[key] = azure.ArmParameter{ Value: value, } } } else { for _, prompt := range parameterPrompts { key := prompt.key // Otherwise, prompt for the value. value, err := p.promptForParameter(ctx, key, prompt.param, locationParameters) if err != nil { return nil, fmt.Errorf("prompting for value: %w", err) } if key != "location" { // location param is special. // It is not persisted in config, it is set in the .env directly mustSetParamAsConfig(key, value, p.env.Config, prompt.param.Secure()) } configModified = true configuredParameters[key] = azure.ArmParameter{ Value: value, } } } } if configModified { if err := p.envManager.Save(ctx, p.env); err != nil { return nil, fmt.Errorf("saving prompt values: %w", err) } } return configuredParameters, nil } var configInfraParametersKey = "infra.parameters." // mustSetParamAsConfig sets the specified key-value pair in the given config.Config object. // If the isSecured flag is set to true, the value is set as a secret using config.SetSecret, // otherwise it is set using config.Set. // If an error occurs while setting the value, the function panics with a warning message. func mustSetParamAsConfig(key string, value any, config config.Config, isSecured bool) { configKey := configInfraParametersKey + key if !isSecured { if err := config.Set(configKey, value); err != nil { log.Panicf("failed setting config value: %v", err) } return } secretString, castOk := value.(string) if !castOk { log.Panic("tried to set a non-string as secret. This is not supported.") } if err := config.SetSecret(configKey, secretString); err != nil { log.Panicf("failed setting a secret in config: %v", err) } } // Convert the ARM parameters file value into a value suitable for deployment func armParameterFileValue(paramType provisioning.ParameterType, value any, defaultValue any) any { // Quick return if the value being converted is not a string if value == nil || reflect.TypeOf(value).Kind() != reflect.String { return value } // Relax the handling of bool and number types to accept convertible strings switch paramType { case provisioning.ParameterTypeBoolean: if val, ok := value.(string); ok { if boolVal, err := strconv.ParseBool(val); err == nil { return boolVal } } case provisioning.ParameterTypeNumber: if val, ok := value.(string); ok { if intVal, err := strconv.ParseInt(val, 10, 64); err == nil { return intVal } } case provisioning.ParameterTypeString: // Use Cases // 1. Non-empty input value, return input value (no prompt) // 2. Empty input value and no default - return nil (prompt user) // 3. Empty input value and non-empty default - return empty input string (no prompt) paramVal, paramValid := value.(string) if paramValid && paramVal != "" { return paramVal } defaultVal, hasDefault := defaultValue.(string) if hasDefault && paramValid && paramVal != defaultVal { return paramVal } default: return value } return nil } func isValueAssignableToParameterType(paramType provisioning.ParameterType, value any) bool { switch paramType { case provisioning.ParameterTypeArray: _, ok := value.([]any) return ok case provisioning.ParameterTypeBoolean: _, ok := value.(bool) return ok case provisioning.ParameterTypeNumber: switch t := value.(type) { case int, int8, int16, int32, int64: return true case uint, uint8, uint16, uint32, uint64: return true case float32: return float64(t) == math.Trunc(float64(t)) case float64: return t == math.Trunc(t) case json.Number: _, err := t.Int64() return err == nil default: return false } case provisioning.ParameterTypeObject: _, ok := value.(map[string]any) return ok case provisioning.ParameterTypeString: _, ok := value.(string) return ok default: panic(fmt.Sprintf("unexpected type: %v", paramType)) } } // NewBicepProvider creates a new instance of a Bicep Infra provider func NewBicepProvider( azapi *azapi.AzureClient, bicepCli *bicep.Cli, resourceService *azapi.ResourceService, deploymentManager *infra.DeploymentManager, envManager environment.Manager, env *environment.Environment, console input.Console, prompters prompt.Prompter, curPrincipal provisioning.CurrentPrincipalIdProvider, keyvaultService keyvault.KeyVaultService, cloud *cloud.Cloud, subscriptionManager *account.SubscriptionsManager, azureClient *azapi.AzureClient, ) provisioning.Provider { return &BicepProvider{ envManager: envManager, env: env, console: console, azapi: azapi, bicepCli: bicepCli, resourceService: resourceService, deploymentManager: deploymentManager, prompters: prompters, curPrincipal: curPrincipal, keyvaultService: keyvaultService, portalUrlBase: cloud.PortalUrlBase, subscriptionManager: subscriptionManager, azureClient: azureClient, } }