cli/azd/pkg/infra/azure_resource_manager.go (248 lines of code) (raw):
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
package infra
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"maps"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources"
"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/azureutil"
"github.com/azure/azure-dev/cli/azd/pkg/compare"
)
type AzureResourceManager struct {
resourceService *azapi.ResourceService
deploymentService *azapi.StandardDeployments
}
type ResourceManager interface {
GetDeploymentResourceOperations(
ctx context.Context, deployment Deployment, queryStart *time.Time) ([]*armresources.DeploymentOperation, error)
GetResourceTypeDisplayName(
ctx context.Context,
subscriptionId string,
resourceId string,
resourceType azapi.AzureResourceType,
) (string, error)
GetResourceGroupsForEnvironment(
ctx context.Context,
subscriptionId string,
envName string,
) ([]*azapi.Resource, error)
FindResourceGroupForEnvironment(
ctx context.Context,
subscriptionId string,
envName string,
) (string, error)
}
func NewAzureResourceManager(
resourceService *azapi.ResourceService,
deploymentService *azapi.StandardDeployments,
) ResourceManager {
return &AzureResourceManager{
resourceService: resourceService,
deploymentService: deploymentService,
}
}
// GetDeploymentResourceOperations gets the list of all the resources created as part of the provided deployment.
// Each DeploymentOperation on the list holds a resource and the result of its deployment.
// One deployment operation can trigger new deployment operations, GetDeploymentResourceOperations traverses all
// operations recursively to find the leaf operations.
func (rm *AzureResourceManager) GetDeploymentResourceOperations(
ctx context.Context,
deployment Deployment,
queryStart *time.Time,
) ([]*armresources.DeploymentOperation, error) {
allOperations := []*armresources.DeploymentOperation{}
rootDeploymentOperations, err := deployment.Operations(ctx)
if err != nil {
return nil, fmt.Errorf("getting root deployment operations: %w", err)
}
operationMap := map[string]*armresources.DeploymentOperation{}
if err := rm.appendDeploymentOperationsRecursive(ctx, queryStart, rootDeploymentOperations, operationMap); err != nil {
return nil, err
}
recursiveOperations := slices.Collect(maps.Values(operationMap))
allOperations = append(allOperations, recursiveOperations...)
return allOperations, nil
}
// GetResourceGroupsForEnvironment gets all resources groups for a given environment
func (rm *AzureResourceManager) GetResourceGroupsForEnvironment(
ctx context.Context,
subscriptionId string,
envName string,
) ([]*azapi.Resource, error) {
res, err := rm.resourceService.ListResourceGroup(ctx, subscriptionId, &azapi.ListResourceGroupOptions{
TagFilter: &azapi.Filter{Key: azure.TagKeyAzdEnvName, Value: envName},
})
if err != nil {
return nil, err
}
if len(res) == 0 {
return nil, azureutil.ResourceNotFound(
fmt.Errorf("0 resource groups with tag '%s' with value: '%s'", azure.TagKeyAzdEnvName, envName),
)
}
return res, nil
}
// GetDefaultResourceGroups gets the default resource groups regardless of azd-env-name setting
// azd initially released with {envname}-rg for a default resource group name. We now don't hardcode the default
// We search for them instead using the rg- prefix or -rg suffix
func (rm *AzureResourceManager) GetDefaultResourceGroups(
ctx context.Context,
subscriptionId string,
environmentName string,
) ([]*azapi.Resource, error) {
allGroups, err := rm.resourceService.ListResourceGroup(ctx, subscriptionId, nil)
matchingGroups := []*azapi.Resource{}
for _, group := range allGroups {
if group.Name == fmt.Sprintf("rg-%[1]s", environmentName) ||
group.Name == fmt.Sprintf("%[1]s-rg", environmentName) {
matchingGroups = append(matchingGroups, group)
}
}
if err != nil {
return nil, err
}
if len(matchingGroups) == 0 {
return nil, azureutil.ResourceNotFound(
fmt.Errorf("0 resource groups with prefix or suffix with value: '%s'", environmentName),
)
}
return matchingGroups, nil
}
// FindResourceGroupForEnvironment will search for the resource group associated with an environment
// It will first try to find a resource group tagged with azd-env-name
// Then it will try to find a resource group that defaults to either {envname}-rg or rg-{envname}
// If it finds exactly one resource group, then it will use it
// If it finds more than one or zero resource groups, then it will prompt the user to update azure.yaml or
// AZURE_RESOURCE_GROUP
// with the resource group to use.
func (rm *AzureResourceManager) FindResourceGroupForEnvironment(
ctx context.Context,
subscriptionId string,
envName string,
) (string, error) {
// Let's first try to find the resource group by environment name tag (azd-env-name)
rgs, err := rm.GetResourceGroupsForEnvironment(ctx, subscriptionId, envName)
var notFoundError *azureutil.ResourceNotFoundError
if err != nil && !errors.As(err, ¬FoundError) {
return "", fmt.Errorf("getting resource group for environment: %s: %w", envName, err)
}
// Several Azure resources can create managed resource groups automatically. Here are a few examples:
// - Azure Kubernetes Service (AKS)
// - Azure Data Factory
// - Azure Machine Learning
// - Azure Synapse Analytics
// Managed resource groups are created with the same tag as the environment name, leading azd to think there are
// multiple resource groups for the environment. We need to filter them out.
// We do this by checking if the resource group is managed by a resource.
rgs = slices.DeleteFunc(rgs, func(r *azapi.Resource) bool {
return r.ManagedBy != nil
})
if len(rgs) == 0 {
// We didn't find any Resource Groups for the environment, now let's try to find Resource Groups with the
// rg-{envname} prefix or {envname}-rg suffix
rgs, err = rm.GetDefaultResourceGroups(ctx, subscriptionId, envName)
if err != nil {
return "", fmt.Errorf("getting default resource groups for environment: %s: %w", envName, err)
}
}
if len(rgs) == 1 && len(rgs[0].Name) > 0 {
// We found one and only one RG, so we'll use it.
return rgs[0].Name, nil
}
var msg string
if len(rgs) > 1 {
// We found more than one RG
msg = "more than one possible resource group was found."
} else {
// We didn't find any RGs
msg = "unable to find the environment resource group."
}
return "", fmt.Errorf(
"%s explicitly specify your resource group in azure.yaml or the AZURE_RESOURCE_GROUP environment variable",
msg,
)
}
func (rm *AzureResourceManager) GetResourceTypeDisplayName(
ctx context.Context,
subscriptionId string,
resourceId string,
resourceType azapi.AzureResourceType,
) (string, error) {
if resourceType == azapi.AzureResourceTypeWebSite {
// Web apps have different kinds of resources sharing the same resource type 'Microsoft.Web/sites', i.e. Function app
// vs. App service It is extremely important that we display the right one, thus we resolve it by querying the
// properties of the ARM resource.
resourceTypeDisplayName, err := rm.getWebAppResourceTypeDisplayName(ctx, subscriptionId, resourceId)
if err != nil {
return "", err
} else {
return resourceTypeDisplayName, nil
}
} else if resourceType == azapi.AzureResourceTypeCognitiveServiceAccount {
resourceTypeDisplayName, err := rm.getCognitiveServiceResourceTypeDisplayName(ctx, subscriptionId, resourceId)
if err != nil {
return "", err
} else {
return resourceTypeDisplayName, nil
}
} else {
resourceTypeDisplayName := azapi.GetResourceTypeDisplayName(resourceType)
return resourceTypeDisplayName, nil
}
}
// webAppApiVersion is the API Version we use when querying information about Web App resources
const webAppApiVersion = "2021-03-01"
func (rm *AzureResourceManager) getWebAppResourceTypeDisplayName(
ctx context.Context,
subscriptionId string,
resourceId string,
) (string, error) {
resource, err := rm.resourceService.GetResource(ctx, subscriptionId, resourceId, webAppApiVersion)
if err != nil {
return "", fmt.Errorf("getting web app resource type display names: %w", err)
}
if strings.Contains(resource.Kind, "functionapp") {
return "Function App", nil
} else if strings.Contains(resource.Kind, "app") {
return "App Service", nil
} else {
return "Web App", nil
}
}
// cognitiveServiceApiVersion is the API Version we use when querying information about Cognitive Service resources
const cognitiveServiceApiVersion = "2021-04-30"
func (rm *AzureResourceManager) getCognitiveServiceResourceTypeDisplayName(
ctx context.Context,
subscriptionId string,
resourceId string,
) (string, error) {
resource, err := rm.resourceService.GetResource(ctx, subscriptionId, resourceId, cognitiveServiceApiVersion)
if err != nil {
return "", fmt.Errorf("getting cognitive service resource type display names: %w", err)
}
if strings.Contains(resource.Kind, "OpenAI") {
return "Azure OpenAI", nil
} else if strings.Contains(resource.Kind, "FormRecognizer") {
return "Document Intelligence", nil
} else {
return "Azure AI Services", nil
}
}
// appendDeploymentResourcesRecursive gets the leaf deployment operations and adds them to resourceOperations
// if they are not already in the list.
func (rm *AzureResourceManager) appendDeploymentOperationsRecursive(
ctx context.Context,
queryStart *time.Time,
operations []*armresources.DeploymentOperation,
operationMap map[string]*armresources.DeploymentOperation,
) error {
for _, operation := range operations {
// Operations w/o target data can't be resolved. Ignoring them
if operation.Properties.TargetResource == nil ||
// The time stamp is used to filter only records after the queryStart.
// We ignore the resource if we can't know when it was created
operation.Properties.Timestamp == nil ||
// The resource type is required to resolve the name of the resource.
// If the dep-op is missing this, we can't resolve it.
compare.IsStringNilOrEmpty(operation.Properties.TargetResource.ResourceType) {
continue
}
// Process any nested deployments
if *operation.Properties.TargetResource.ResourceType == string(azapi.AzureResourceTypeDeployment) &&
*operation.Properties.ProvisioningOperation == armresources.ProvisioningOperationCreate {
deploymentResourceId, err := arm.ParseResourceID(*operation.Properties.TargetResource.ID)
if err != nil {
return fmt.Errorf("parsing deployment resource ID: %w", err)
}
var nestedOperations []*armresources.DeploymentOperation
var nestedError error
if deploymentResourceId.ResourceGroupName == "" {
nestedOperations, nestedError = rm.deploymentService.ListSubscriptionDeploymentOperations(
ctx,
deploymentResourceId.SubscriptionID,
deploymentResourceId.Name)
} else {
nestedOperations, nestedError = rm.deploymentService.ListResourceGroupDeploymentOperations(
ctx,
deploymentResourceId.SubscriptionID,
deploymentResourceId.ResourceGroupName,
deploymentResourceId.Name,
)
}
if nestedError != nil {
return fmt.Errorf("getting deployment operations recursively: %w", nestedError)
}
if err = rm.appendDeploymentOperationsRecursive(ctx, queryStart, nestedOperations, operationMap); err != nil {
return err
}
} else if *operation.Properties.ProvisioningOperation == armresources.ProvisioningOperationCreate &&
// Only append CREATE operations that started after our queryStart time
operation.Properties.Timestamp.After(*queryStart) {
operationMap[*operation.ID] = operation
}
}
return nil
}