cli/azd/pkg/azapi/standard_deployments.go (676 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. package azapi import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "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/azure" "github.com/azure/azure-dev/cli/azd/pkg/cloud" "github.com/azure/azure-dev/cli/azd/pkg/convert" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/benbjohnson/clock" ) // cArmDeploymentNameLengthMax is the maximum length of the name of a deployment in ARM. const ( cArmDeploymentNameLengthMax = 64 cPortalUrlFragment = "#view/HubsExtension/DeploymentDetailsBlade/~/overview/id" cOutputsUrlFragment = "#view/HubsExtension/DeploymentDetailsBlade/~/outputs/id" ) type StandardDeployments struct { credentialProvider account.SubscriptionCredentialProvider armClientOptions *arm.ClientOptions resourceService *ResourceService cloud *cloud.Cloud clock clock.Clock } func NewStandardDeployments( credentialProvider account.SubscriptionCredentialProvider, armClientOptions *arm.ClientOptions, resourceService *ResourceService, cloud *cloud.Cloud, clock clock.Clock, ) *StandardDeployments { return &StandardDeployments{ credentialProvider: credentialProvider, armClientOptions: armClientOptions, resourceService: resourceService, cloud: cloud, clock: clock, } } // GenerateDeploymentName creates a name to use for the deployment object for a given environment. It appends the current // unix time to the environment name (separated by a hyphen) to provide a unique name for each deployment. If the resulting // name is longer than the ARM limit, the longest suffix of the name under the limit is returned. func (ds *StandardDeployments) GenerateDeploymentName(baseName string) string { name := fmt.Sprintf("%s-%d", baseName, ds.clock.Now().Unix()) if len(name) <= cArmDeploymentNameLengthMax { return name } return name[len(name)-cArmDeploymentNameLengthMax:] } func (ds *StandardDeployments) CalculateTemplateHash( ctx context.Context, subscriptionId string, template azure.RawArmTemplate, ) (string, error) { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return "", fmt.Errorf("creating deployments client: %w", err) } response, err := deploymentClient.CalculateTemplateHash(ctx, template, nil) if err != nil { return "", fmt.Errorf("calculating template hash: %w", err) } return *response.TemplateHashResult.TemplateHash, nil } func (ds *StandardDeployments) ListSubscriptionDeployments( ctx context.Context, subscriptionId string, ) ([]*ResourceDeployment, error) { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } results := []*ResourceDeployment{} pager := deploymentClient.NewListAtSubscriptionScopePager(nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, err } for _, deployment := range page.Value { results = append(results, ds.convertFromArmDeployment(deployment)) } } return results, nil } func (ds *StandardDeployments) GetSubscriptionDeployment( ctx context.Context, subscriptionId string, deploymentName string, ) (*ResourceDeployment, error) { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } deployment, err := deploymentClient.GetAtSubscriptionScope(ctx, deploymentName, nil) if err != nil { var errDetails *azcore.ResponseError if errors.As(err, &errDetails) && errDetails.StatusCode == 404 { return nil, ErrDeploymentNotFound } return nil, fmt.Errorf("getting deployment from subscription: %w", err) } return ds.convertFromArmDeployment(&deployment.DeploymentExtended), nil } func (ds *StandardDeployments) ListResourceGroupDeployments( ctx context.Context, subscriptionId string, resourceGroupName string, ) ([]*ResourceDeployment, error) { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } results := []*ResourceDeployment{} pager := deploymentClient.NewListByResourceGroupPager(resourceGroupName, nil) for pager.More() { page, err := pager.NextPage(ctx) if err != nil { return nil, err } for _, deployment := range page.Value { results = append(results, ds.convertFromArmDeployment(deployment)) } } return results, nil } func (ds *StandardDeployments) GetResourceGroupDeployment( ctx context.Context, subscriptionId string, resourceGroupName string, deploymentName string, ) (*ResourceDeployment, error) { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } deployment, err := deploymentClient.Get(ctx, resourceGroupName, deploymentName, nil) if err != nil { var errDetails *azcore.ResponseError if errors.As(err, &errDetails) && errDetails.StatusCode == 404 { return nil, ErrDeploymentNotFound } return nil, fmt.Errorf("getting deployment from resource group: %w", err) } return ds.convertFromArmDeployment(&deployment.DeploymentExtended), nil } func (ds *StandardDeployments) createDeploymentsClient( ctx context.Context, subscriptionId string, ) (*armresources.DeploymentsClient, error) { credential, err := ds.credentialProvider.CredentialForSubscription(ctx, subscriptionId) if err != nil { return nil, err } client, err := armresources.NewDeploymentsClient(subscriptionId, credential, ds.armClientOptions) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } return client, nil } func (ds *StandardDeployments) DeployToSubscription( ctx context.Context, subscriptionId string, location string, deploymentName string, armTemplate azure.RawArmTemplate, parameters azure.ArmParameters, tags map[string]*string, options map[string]any, ) (*ResourceDeployment, error) { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } createFromTemplateOperation, err := deploymentClient.BeginCreateOrUpdateAtSubscriptionScope( ctx, deploymentName, armresources.Deployment{ Properties: &armresources.DeploymentProperties{ Template: armTemplate, Parameters: parameters, Mode: to.Ptr(armresources.DeploymentModeIncremental), }, Location: to.Ptr(location), Tags: tags, }, nil) if err != nil { return nil, fmt.Errorf("starting deployment to subscription: %w", err) } // wait for deployment creation deployResult, err := createFromTemplateOperation.PollUntilDone(ctx, nil) if err != nil { deploymentError := createDeploymentError(err) return nil, fmt.Errorf( "deploying to subscription:\n\nDeployment Error Details:\n%w", deploymentError, ) } return ds.convertFromArmDeployment(&deployResult.DeploymentExtended), nil } func (ds *StandardDeployments) DeployToResourceGroup( ctx context.Context, subscriptionId, resourceGroup, deploymentName string, armTemplate azure.RawArmTemplate, parameters azure.ArmParameters, tags map[string]*string, options map[string]any, ) (*ResourceDeployment, error) { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } createFromTemplateOperation, err := deploymentClient.BeginCreateOrUpdate( ctx, resourceGroup, deploymentName, armresources.Deployment{ Properties: &armresources.DeploymentProperties{ Template: armTemplate, Parameters: parameters, Mode: to.Ptr(armresources.DeploymentModeIncremental), }, Tags: tags, }, nil) if err != nil { return nil, fmt.Errorf("starting deployment to resource group: %w", err) } // wait for deployment creation deployResult, err := createFromTemplateOperation.PollUntilDone(ctx, nil) if err != nil { deploymentError := createDeploymentError(err) return nil, fmt.Errorf( "deploying to resource group:\n\nDeployment Error Details:\n%w", deploymentError, ) } return ds.convertFromArmDeployment(&deployResult.DeploymentExtended), nil } func (ds *StandardDeployments) ListSubscriptionDeploymentOperations( ctx context.Context, subscriptionId string, deploymentName string, ) ([]*armresources.DeploymentOperation, error) { result := []*armresources.DeploymentOperation{} deploymentOperationsClient, err := ds.createDeploymentsOperationsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } // Get all without any filter getDeploymentsPager := deploymentOperationsClient.NewListAtSubscriptionScopePager(deploymentName, nil) for getDeploymentsPager.More() { page, err := getDeploymentsPager.NextPage(ctx) var errDetails *azcore.ResponseError if errors.As(err, &errDetails) && errDetails.StatusCode == 404 { return nil, ErrDeploymentNotFound } if err != nil { return nil, fmt.Errorf("failed getting list of deployment operations from subscription: %w", err) } result = append(result, page.Value...) } return result, nil } func (ds *StandardDeployments) ListResourceGroupDeploymentOperations( ctx context.Context, subscriptionId string, resourceGroupName string, deploymentName string, ) ([]*armresources.DeploymentOperation, error) { result := []*armresources.DeploymentOperation{} deploymentOperationsClient, err := ds.createDeploymentsOperationsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } // Get all without any filter getDeploymentsPager := deploymentOperationsClient.NewListPager(resourceGroupName, deploymentName, nil) for getDeploymentsPager.More() { page, err := getDeploymentsPager.NextPage(ctx) var errDetails *azcore.ResponseError if errors.As(err, &errDetails) && errDetails.StatusCode == 404 { return nil, ErrDeploymentNotFound } if err != nil { return nil, fmt.Errorf("failed getting list of deployment operations from resource group: %w", err) } result = append(result, page.Value...) } return result, nil } func (ds *StandardDeployments) ListSubscriptionDeploymentResources( ctx context.Context, subscriptionId string, deploymentName string, ) ([]*armresources.ResourceReference, error) { subscriptionDeployment, err := ds.GetSubscriptionDeployment(ctx, subscriptionId, deploymentName) if err != nil { return nil, fmt.Errorf("getting subscription deployment: %w", err) } // Get the environment name from the deployment tags envName, has := subscriptionDeployment.Tags[azure.TagKeyAzdEnvName] if !has || envName == nil { return nil, fmt.Errorf("environment name not found in deployment tags") } // Get all resource groups tagged with the azd-env-name tag resourceGroups, err := ds.resourceService.ListResourceGroup(ctx, subscriptionId, &ListResourceGroupOptions{ TagFilter: &Filter{Key: azure.TagKeyAzdEnvName, Value: *envName}, }) if err != nil { return nil, fmt.Errorf("listing resource groups: %w", err) } allResources := []*armresources.ResourceReference{} // Find all the resources from all the resource groups for _, resourceGroup := range resourceGroups { resources, err := ds.resourceService.ListResourceGroupResources(ctx, subscriptionId, resourceGroup.Name, nil) if err != nil { return nil, fmt.Errorf("listing resource group resources: %w", err) } resourceGroupId := azure.ResourceGroupRID(subscriptionId, resourceGroup.Name) allResources = append(allResources, &armresources.ResourceReference{ ID: &resourceGroupId, }) for _, resource := range resources { allResources = append(allResources, &armresources.ResourceReference{ ID: to.Ptr(resource.Id), }) } } return allResources, nil } func (ds *StandardDeployments) ListResourceGroupDeploymentResources( ctx context.Context, subscriptionId string, resourceGroupName string, deploymentName string, ) ([]*armresources.ResourceReference, error) { resources, err := ds.resourceService.ListResourceGroupResources(ctx, subscriptionId, resourceGroupName, nil) if err != nil { return nil, fmt.Errorf("listing resource group resources: %w", err) } resourceGroupId := azure.ResourceGroupRID(subscriptionId, resourceGroupName) allResources := []*armresources.ResourceReference{} allResources = append(allResources, &armresources.ResourceReference{ ID: &resourceGroupId, }) for _, resource := range resources { allResources = append(allResources, &armresources.ResourceReference{ ID: to.Ptr(resource.Id), }) } return allResources, nil } func (ds *StandardDeployments) DeleteSubscriptionDeployment( ctx context.Context, subscriptionId string, deploymentName string, options map[string]any, progress *async.Progress[DeleteDeploymentProgress], ) error { resources, err := ds.ListSubscriptionDeploymentResources(ctx, subscriptionId, deploymentName) if err != nil { return err } resourceGroups := map[string]struct{}{} for _, resource := range resources { resourceId, err := arm.ParseResourceID(*resource.ID) if err != nil { return fmt.Errorf("parsing resource ID: %w", err) } resourceGroups[resourceId.ResourceGroupName] = struct{}{} } for resourceGroup := range resourceGroups { progress.SetProgress(DeleteDeploymentProgress{ Name: resourceGroup, Message: fmt.Sprintf("Deleting resource group %s", output.WithHighLightFormat(resourceGroup)), State: DeleteResourceStateInProgress, }) if err := ds.resourceService.DeleteResourceGroup(ctx, subscriptionId, resourceGroup); err != nil { progress.SetProgress(DeleteDeploymentProgress{ Name: resourceGroup, Message: fmt.Sprintf("Failed deleting resource group %s", output.WithHighLightFormat(resourceGroup)), State: DeleteResourceStateFailed, }) return err } progress.SetProgress(DeleteDeploymentProgress{ Name: resourceGroup, Message: fmt.Sprintf("Deleted resource group %s", output.WithHighLightFormat(resourceGroup)), State: DeleteResourceStateSucceeded, }) } // Deploy empty template to void provision state and keep deployment history instead of deleting previous deployments // Get deployment metadata deployment, err := ds.GetSubscriptionDeployment(ctx, subscriptionId, deploymentName) if err != nil { return fmt.Errorf("subscription deployment '%s' not found: %w", deploymentName, err) } envName, has := deployment.Tags[azure.TagKeyAzdEnvName] if has { var emptyTemplate json.RawMessage = []byte(emptySubscriptionArmTemplate) emptyDeploymentName := ds.GenerateDeploymentName(*envName) tags := map[string]*string{ azure.TagKeyAzdEnvName: envName, "azd-deploy-reason": to.Ptr("down"), } _, err = ds.DeployToSubscription( ctx, subscriptionId, deployment.Location, emptyDeploymentName, emptyTemplate, azure.ArmParameters{}, tags, options, ) if err != nil { return fmt.Errorf("deploying empty template to subscription: %w", err) } } return nil } func (ds *StandardDeployments) DeleteResourceGroupDeployment( ctx context.Context, subscriptionId, resourceGroupName string, deploymentName string, options map[string]any, progress *async.Progress[DeleteDeploymentProgress], ) error { progress.SetProgress(DeleteDeploymentProgress{ Name: resourceGroupName, Message: fmt.Sprintf("Deleting resource group %s", output.WithHighLightFormat(resourceGroupName)), State: DeleteResourceStateInProgress, }) if err := ds.resourceService.DeleteResourceGroup(ctx, subscriptionId, resourceGroupName); err != nil { progress.SetProgress(DeleteDeploymentProgress{ Name: resourceGroupName, Message: fmt.Sprintf("Failed resource group %s", output.WithHighLightFormat(resourceGroupName)), State: DeleteResourceStateInProgress, }) return err } progress.SetProgress(DeleteDeploymentProgress{ Name: resourceGroupName, Message: fmt.Sprintf("Deleted resource group %s", output.WithHighLightFormat(resourceGroupName)), State: DeleteResourceStateInProgress, }) return nil } func (ds *StandardDeployments) WhatIfDeployToSubscription( ctx context.Context, subscriptionId string, location string, deploymentName string, armTemplate azure.RawArmTemplate, parameters azure.ArmParameters, ) (*armresources.WhatIfOperationResult, error) { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } createFromTemplateOperation, err := deploymentClient.BeginWhatIfAtSubscriptionScope( ctx, deploymentName, armresources.DeploymentWhatIf{ Properties: &armresources.DeploymentWhatIfProperties{ Template: armTemplate, Parameters: parameters, Mode: to.Ptr(armresources.DeploymentModeIncremental), WhatIfSettings: &armresources.DeploymentWhatIfSettings{}, }, Location: to.Ptr(location), }, nil) if err != nil { return nil, fmt.Errorf("starting deployment to subscription: %w", err) } // wait for deployment creation deployResult, err := createFromTemplateOperation.PollUntilDone(ctx, nil) if err != nil { deploymentError := createDeploymentError(err) return nil, fmt.Errorf( "deploying to subscription:\n\nDeployment Error Details:\n%w", deploymentError, ) } return &deployResult.WhatIfOperationResult, nil } func (ds *StandardDeployments) WhatIfDeployToResourceGroup( ctx context.Context, subscriptionId, resourceGroup, deploymentName string, armTemplate azure.RawArmTemplate, parameters azure.ArmParameters, ) (*armresources.WhatIfOperationResult, error) { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } createFromTemplateOperation, err := deploymentClient.BeginWhatIf( ctx, resourceGroup, deploymentName, armresources.DeploymentWhatIf{ Properties: &armresources.DeploymentWhatIfProperties{ Template: armTemplate, Parameters: parameters, Mode: to.Ptr(armresources.DeploymentModeIncremental), }, }, nil) if err != nil { return nil, fmt.Errorf("starting deployment to resource group: %w", err) } // wait for deployment creation deployResult, err := createFromTemplateOperation.PollUntilDone(ctx, nil) if err != nil { deploymentError := createDeploymentError(err) return nil, fmt.Errorf( "deploying to resource group:\n\nDeployment Error Details:\n%w", deploymentError, ) } return &deployResult.WhatIfOperationResult, nil } func (ds *StandardDeployments) createDeploymentsOperationsClient( ctx context.Context, subscriptionId string, ) (*armresources.DeploymentOperationsClient, error) { credential, err := ds.credentialProvider.CredentialForSubscription(ctx, subscriptionId) if err != nil { return nil, err } client, err := armresources.NewDeploymentOperationsClient(subscriptionId, credential, ds.armClientOptions) if err != nil { return nil, fmt.Errorf("creating deployments client: %w", err) } return client, nil } // Converts from an ARM Extended Deployment to Azd Generic deployment func (ds *StandardDeployments) convertFromArmDeployment(deployment *armresources.DeploymentExtended) *ResourceDeployment { return &ResourceDeployment{ Id: *deployment.ID, Location: convert.ToValueWithDefault(deployment.Location, ""), DeploymentId: *deployment.ID, Name: *deployment.Name, Type: *deployment.Type, Tags: deployment.Tags, ProvisioningState: convertFromStandardProvisioningState(*deployment.Properties.ProvisioningState), Timestamp: *deployment.Properties.Timestamp, TemplateHash: deployment.Properties.TemplateHash, Outputs: deployment.Properties.Outputs, Resources: deployment.Properties.OutputResources, Dependencies: deployment.Properties.Dependencies, PortalUrl: fmt.Sprintf("%s/%s/%s", ds.cloud.PortalUrlBase, cPortalUrlFragment, url.PathEscape(*deployment.ID), ), OutputsUrl: fmt.Sprintf("%s/%s/%s", ds.cloud.PortalUrlBase, cOutputsUrlFragment, url.PathEscape(*deployment.ID), ), DeploymentUrl: fmt.Sprintf("%s/%s/%s", ds.cloud.PortalUrlBase, cPortalUrlFragment, url.PathEscape(*deployment.ID), ), } } func convertFromStandardProvisioningState(state armresources.ProvisioningState) DeploymentProvisioningState { switch state { case armresources.ProvisioningStateAccepted: return DeploymentProvisioningStateAccepted case armresources.ProvisioningStateCanceled: return DeploymentProvisioningStateCanceled case armresources.ProvisioningStateCreating: return DeploymentProvisioningStateCreating case armresources.ProvisioningStateDeleted: return DeploymentProvisioningStateDeleted case armresources.ProvisioningStateDeleting: return DeploymentProvisioningStateDeleting case armresources.ProvisioningStateFailed: return DeploymentProvisioningStateFailed case armresources.ProvisioningStateNotSpecified: return DeploymentProvisioningStateNotSpecified case armresources.ProvisioningStateReady: return DeploymentProvisioningStateReady case armresources.ProvisioningStateRunning: return DeploymentProvisioningStateRunning case armresources.ProvisioningStateSucceeded: return DeploymentProvisioningStateSucceeded case armresources.ProvisioningStateUpdating: return DeploymentProvisioningStateUpdating } return DeploymentProvisioningState("") } // Preflight API validates whether the specified template is syntactically correct // and will be accepted by Azure Resource Manager. func (ds *StandardDeployments) ValidatePreflightToSubscription( ctx context.Context, subscriptionId string, location string, deploymentName string, armTemplate azure.RawArmTemplate, parameters azure.ArmParameters, tags map[string]*string, options map[string]any, ) error { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return fmt.Errorf("creating deployments client: %w", err) } var rawResponse *http.Response ctx = policy.WithCaptureResponse(ctx, &rawResponse) validateResult, err := deploymentClient.BeginValidateAtSubscriptionScope( ctx, deploymentName, armresources.Deployment{ Properties: &armresources.DeploymentProperties{ Template: armTemplate, Parameters: parameters, Mode: to.Ptr(armresources.DeploymentModeIncremental), }, Location: to.Ptr(location), Tags: tags, }, nil) if err != nil { return validatePreflightError(rawResponse, err, "subscription") } _, err = validateResult.PollUntilDone(ctx, nil) if err != nil { return validatePreflightError(rawResponse, err, "subscription") } return nil } func validatePreflightError( rawResponse *http.Response, err error, typeMessage string, ) error { var respErr *azcore.ResponseError if errors.As(err, &respErr) { err = createDeploymentError(err) } else if err != nil { // Error returned from azure sdk go bug: we receive a 400 Bad Request from the API, // but the client-handling in azure sdk fails internally with a different error // This special-cased handling and rawResponse capture can be removed once // https://github.com/Azure/azure-sdk-for-go/issues/23350 is fixed if rawResponse != nil && rawResponse.StatusCode == 400 { defer rawResponse.Body.Close() body, errOnRawResponse := io.ReadAll(rawResponse.Body) if errOnRawResponse != nil { return fmt.Errorf("failed to read response error body from validation api to %s: %w", typeMessage, errOnRawResponse) } err = NewAzureDeploymentError(string(body)) } } return fmt.Errorf( "validating deployment to %s:\n\nValidation Error Details:\n%w", typeMessage, err, ) } // Preflight API validates whether the specified template is syntactically correct // and will be accepted by Azure Resource Manager. func (ds *StandardDeployments) ValidatePreflightToResourceGroup( ctx context.Context, subscriptionId, resourceGroup, deploymentName string, armTemplate azure.RawArmTemplate, parameters azure.ArmParameters, tags map[string]*string, options map[string]any, ) error { deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId) if err != nil { return fmt.Errorf("creating deployments client: %w", err) } var rawResponse *http.Response ctx = policy.WithCaptureResponse(ctx, &rawResponse) validateResult, err := deploymentClient.BeginValidate(ctx, resourceGroup, deploymentName, armresources.Deployment{ Properties: &armresources.DeploymentProperties{ Template: armTemplate, Parameters: parameters, Mode: to.Ptr(armresources.DeploymentModeIncremental), }, Tags: tags, }, nil) if err != nil { return validatePreflightError(rawResponse, err, "resource group") } _, err = validateResult.PollUntilDone(ctx, nil) if err != nil { return validatePreflightError(rawResponse, err, "resource group") } return nil }