From 63d703e8bf3d7338e088f96a5da8d76faf6cb08e Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Wed, 10 Dec 2025 11:42:46 -0800 Subject: [PATCH 1/2] fix resource discovery --- cli/azd/pkg/azapi/standard_deployments.go | 60 ++++++--- .../pkg/azapi/standard_deployments_test.go | 118 ++++++++++++++++++ .../failed-subscription-deployment.json | 110 ++++++++++++++++ .../provisioning/bicep/bicep_provider.go | 38 ------ .../provisioning/bicep/bicep_provider_test.go | 77 ------------ 5 files changed, 268 insertions(+), 135 deletions(-) create mode 100644 cli/azd/pkg/azapi/testdata/failed-subscription-deployment.json diff --git a/cli/azd/pkg/azapi/standard_deployments.go b/cli/azd/pkg/azapi/standard_deployments.go index 5c8cf52a9fd..a27bd1b5921 100644 --- a/cli/azd/pkg/azapi/standard_deployments.go +++ b/cli/azd/pkg/azapi/standard_deployments.go @@ -8,7 +8,9 @@ import ( "encoding/json" "errors" "fmt" + "maps" "net/url" + "slices" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" @@ -343,32 +345,17 @@ func (ds *StandardDeployments) ListSubscriptionDeploymentResources( 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) - } - + resourceGroupNames := resourceGroupsFromDeployment(subscriptionDeployment) 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) + // Find all the resources from the deployment's resource groups + for _, resourceGroupName := range resourceGroupNames { + 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, resourceGroup.Name) + resourceGroupId := azure.ResourceGroupRID(subscriptionId, resourceGroupName) allResources = append(allResources, &armresources.ResourceReference{ ID: &resourceGroupId, }) @@ -383,6 +370,39 @@ func (ds *StandardDeployments) ListSubscriptionDeploymentResources( return allResources, nil } +// resourceGroupsFromDeployment extracts the unique resource groups associated to a deployment. +func resourceGroupsFromDeployment(deployment *ResourceDeployment) []string { + resourceGroups := map[string]struct{}{} + + if deployment.ProvisioningState == DeploymentProvisioningStateSucceeded { + // For a successful deployment, use the output resources property to find the resource groups. + 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, look at the dependencies to find resource groups that this deployment deployed into. + for _, dependency := range deployment.Dependencies { + if dependency.ResourceType != nil && *dependency.ResourceType == string(AzureResourceTypeDeployment) { + for _, dependent := range dependency.DependsOn { + if dependent.ResourceType != nil && *dependent.ResourceType == arm.ResourceGroupResourceType.String() { + if dependent.ResourceName != nil { + resourceGroups[*dependent.ResourceName] = struct{}{} + } + } + } + } + } + } + + return slices.Collect(maps.Keys(resourceGroups)) +} + func (ds *StandardDeployments) ListResourceGroupDeploymentResources( ctx context.Context, subscriptionId string, diff --git a/cli/azd/pkg/azapi/standard_deployments_test.go b/cli/azd/pkg/azapi/standard_deployments_test.go index 2547766a680..2265466ed90 100644 --- a/cli/azd/pkg/azapi/standard_deployments_test.go +++ b/cli/azd/pkg/azapi/standard_deployments_test.go @@ -5,12 +5,16 @@ package azapi import ( "context" + "sort" "testing" "time" + "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/cloud" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func Test_StandardDeployments_GenerateDeploymentName(t *testing.T) { @@ -45,3 +49,117 @@ func Test_StandardDeployments_GenerateDeploymentName(t *testing.T) { assert.LessOrEqual(t, len(deploymentName), 64) } } + +func TestResourceGroupsFromDeployment(t *testing.T) { + t.Parallel() + + t.Run("references used when no output resources", func(t *testing.T) { + mockDeployment := &ResourceDeployment{ + //nolint:lll + Id: "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/providers/Microsoft.Resources/deployments/matell-2508-1689982746", + //nolint:lll + DeploymentId: "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/providers/Microsoft.Resources/deployments/matell-2508-1689982746", + Name: "matell-2508", + Type: "Microsoft.Resources/deployments", + Tags: map[string]*string{ + "azd-env-name": to.Ptr("matell-2508"), + }, + ProvisioningState: DeploymentProvisioningStateFailed, + Timestamp: time.Now(), + Dependencies: []*armresources.Dependency{ + { + //nolint:lll + ID: to.Ptr( + "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg/providers/Microsoft.Resources/deployments/resources", + ), + ResourceName: to.Ptr("resources"), + ResourceType: to.Ptr("Microsoft.Resources/deployments"), + DependsOn: []*armresources.BasicDependency{ + { + //nolint:lll + ID: to.Ptr( + "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg", + ), + ResourceName: to.Ptr("matell-2508-rg"), + ResourceType: to.Ptr("Microsoft.Resources/resourceGroups"), + }, + }, + }, + }, + } + + require.Equal(t, []string{"matell-2508-rg"}, resourceGroupsFromDeployment(mockDeployment)) + }) + + t.Run("references used when no output resources", func(t *testing.T) { + mockDeployment := &ResourceDeployment{ + //nolint:lll + Id: "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/providers/Microsoft.Resources/deployments/matell-2508-1689982746", + //nolint:lll + DeploymentId: "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/providers/Microsoft.Resources/deployments/matell-2508-1689982746", + Name: "matell-2508", + Type: "Microsoft.Resources/deployments", + Tags: map[string]*string{ + "azd-env-name": to.Ptr("matell-2508"), + }, + ProvisioningState: DeploymentProvisioningStateFailed, + Timestamp: time.Now(), + Dependencies: []*armresources.Dependency{ + { + //nolint:lll + ID: to.Ptr( + "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg/providers/Microsoft.Resources/deployments/resources", + ), + ResourceName: to.Ptr("resources"), + ResourceType: to.Ptr("Microsoft.Resources/deployments"), + DependsOn: []*armresources.BasicDependency{ + { + //nolint:lll + ID: to.Ptr( + "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg", + ), + ResourceName: to.Ptr("matell-2508-rg"), + ResourceType: to.Ptr("Microsoft.Resources/resourceGroups"), + }, + }, + }, + }, + } + + require.Equal(t, []string{"matell-2508-rg"}, resourceGroupsFromDeployment(mockDeployment)) + }) + + t.Run("duplicate resource groups ignored", func(t *testing.T) { + + mockDeployment := ResourceDeployment{ + Id: "DEPLOYMENT_ID", + Name: "test-env", + Resources: []*armresources.ResourceReference{ + { + ID: to.Ptr("/subscriptions/sub-id/resourceGroups/groupA"), + }, + { + ID: to.Ptr( + "/subscriptions/sub-id/resourceGroups/groupA/Microsoft.Storage/storageAccounts/storageAccount", + ), + }, + { + ID: to.Ptr("/subscriptions/sub-id/resourceGroups/groupB"), + }, + { + ID: to.Ptr("/subscriptions/sub-id/resourceGroups/groupB/Microsoft.web/sites/test"), + }, + { + ID: to.Ptr("/subscriptions/sub-id/resourceGroups/groupC"), + }, + }, + ProvisioningState: DeploymentProvisioningStateSucceeded, + Timestamp: time.Now(), + } + + groups := resourceGroupsFromDeployment(&mockDeployment) + + sort.Strings(groups) + require.Equal(t, []string{"groupA", "groupB", "groupC"}, groups) + }) +} diff --git a/cli/azd/pkg/azapi/testdata/failed-subscription-deployment.json b/cli/azd/pkg/azapi/testdata/failed-subscription-deployment.json new file mode 100644 index 00000000000..06e44d85eaa --- /dev/null +++ b/cli/azd/pkg/azapi/testdata/failed-subscription-deployment.json @@ -0,0 +1,110 @@ + +{ + "id": "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/providers/Microsoft.Resources/deployments/matell-2508-1689982746", + "location": "eastus2", + "name": "matell-2508-1689982746", + "properties": { + "correlationId": "84c13af8bdb9709a44dda61f93cce798", + "debugSetting": null, + "dependencies": [ + { + "dependsOn": [ + { + "id": "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg", + "resourceName": "matell-2508-rg", + "resourceType": "Microsoft.Resources/resourceGroups" + } + ], + "id": "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg/providers/Microsoft.Resources/deployments/resources", + "resourceGroup": "matell-2508-rg", + "resourceName": "resources", + "resourceType": "Microsoft.Resources/deployments" + } + ], + "duration": "PT1M39.7689131S", + "error": { + "additionalInfo": null, + "code": "DeploymentFailed", + "details": [ + { + "additionalInfo": null, + "code": "DeploymentFailed", + "details": null, + "message": "At least one resource deployment operation failed. Please list deployment operations for details. Please see https://aka.ms/arm-deployment-operations for usage details.", + "target": null + } + ], + "message": "At least one resource deployment operation failed. Please list deployment operations for details. Please see https://aka.ms/arm-deployment-operations for usage details.", + "target": null + }, + "mode": "Incremental", + "onErrorDeployment": null, + "outputResources": null, + "outputs": null, + "parameters": { + "databasePassword": { + "type": "SecureString" + }, + "location": { + "type": "String", + "value": "eastus2" + }, + "name": { + "type": "String", + "value": "matell-2508" + }, + "secretKey": { + "type": "SecureString" + } + }, + "parametersLink": null, + "providers": [ + { + "id": null, + "namespace": "Microsoft.Resources", + "providerAuthorizationConsentState": null, + "registrationPolicy": null, + "registrationState": null, + "resourceTypes": [ + { + "aliases": null, + "apiProfiles": null, + "apiVersions": null, + "capabilities": null, + "defaultApiVersion": null, + "locationMappings": null, + "locations": [ + "eastus2" + ], + "properties": null, + "resourceType": "resourceGroups", + "zoneMappings": null + }, + { + "aliases": null, + "apiProfiles": null, + "apiVersions": null, + "capabilities": null, + "defaultApiVersion": null, + "locationMappings": null, + "locations": [ + null + ], + "properties": null, + "resourceType": "deployments", + "zoneMappings": null + } + ] + } + ], + "provisioningState": "Failed", + "templateHash": "11208209120649888667", + "templateLink": null, + "timestamp": "2023-07-21T23:40:49.234299+00:00", + "validatedResources": null + }, + "tags": { + "azd-env-name": "matell-2508" + }, + "type": "Microsoft.Resources/deployments" +} \ No newline at end of file diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 6f19d826506..e4a7955dd82 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -1072,44 +1072,6 @@ func getDeploymentOptions(deployments []*azapi.ResourceDeployment) []string { 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:"} diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go index 1caeb7763c9..a4a60781575 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go @@ -11,7 +11,6 @@ import ( "fmt" "io" "net/http" - "sort" "strings" "testing" "time" @@ -806,82 +805,6 @@ func httpRespondFn(request *http.Request) (*http.Response, error) { }, nil } -func TestResourceGroupsFromDeployment(t *testing.T) { - t.Parallel() - - t.Run("references used when no output resources", func(t *testing.T) { - mockDeployment := &azapi.ResourceDeployment{ - //nolint:lll - Id: "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/providers/Microsoft.Resources/deployments/matell-2508-1689982746", - //nolint:lll - DeploymentId: "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/providers/Microsoft.Resources/deployments/matell-2508-1689982746", - Name: "matell-2508", - Type: "Microsoft.Resources/deployments", - Tags: map[string]*string{ - "azd-env-name": to.Ptr("matell-2508"), - }, - ProvisioningState: azapi.DeploymentProvisioningStateFailed, - Timestamp: time.Now(), - Dependencies: []*armresources.Dependency{ - { - //nolint:lll - ID: to.Ptr( - "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg/providers/Microsoft.Resources/deployments/resources", - ), - ResourceName: to.Ptr("resources"), - ResourceType: to.Ptr("Microsoft.Resources/deployments"), - DependsOn: []*armresources.BasicDependency{ - { - //nolint:lll - ID: to.Ptr( - "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg", - ), - ResourceName: to.Ptr("matell-2508-rg"), - ResourceType: to.Ptr("Microsoft.Resources/resourceGroups"), - }, - }, - }, - }, - } - - require.Equal(t, []string{"matell-2508-rg"}, resourceGroupsToDelete(mockDeployment)) - }) - - t.Run("duplicate resource groups ignored", func(t *testing.T) { - - mockDeployment := azapi.ResourceDeployment{ - Id: "DEPLOYMENT_ID", - Name: "test-env", - Resources: []*armresources.ResourceReference{ - { - ID: to.Ptr("/subscriptions/sub-id/resourceGroups/groupA"), - }, - { - ID: to.Ptr( - "/subscriptions/sub-id/resourceGroups/groupA/Microsoft.Storage/storageAccounts/storageAccount", - ), - }, - { - ID: to.Ptr("/subscriptions/sub-id/resourceGroups/groupB"), - }, - { - ID: to.Ptr("/subscriptions/sub-id/resourceGroups/groupB/Microsoft.web/sites/test"), - }, - { - ID: to.Ptr("/subscriptions/sub-id/resourceGroups/groupC"), - }, - }, - ProvisioningState: azapi.DeploymentProvisioningStateSucceeded, - Timestamp: time.Now(), - } - - groups := resourceGroupsToDelete(&mockDeployment) - - sort.Strings(groups) - require.Equal(t, []string{"groupA", "groupB", "groupC"}, groups) - }) -} - // From a mocked list of deployments where there are multiple deployments with the matching tag, expect to pick the most // recent one. func TestFindCompletedDeployments(t *testing.T) { From 0ebf68c2dba0e8834cce20c3467985abb8c8cde5 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Tue, 16 Dec 2025 12:04:22 -0800 Subject: [PATCH 2/2] Apply suggestion from @weikanglim --- .../pkg/azapi/standard_deployments_test.go | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/cli/azd/pkg/azapi/standard_deployments_test.go b/cli/azd/pkg/azapi/standard_deployments_test.go index 2265466ed90..c2f1b735c57 100644 --- a/cli/azd/pkg/azapi/standard_deployments_test.go +++ b/cli/azd/pkg/azapi/standard_deployments_test.go @@ -91,44 +91,6 @@ func TestResourceGroupsFromDeployment(t *testing.T) { require.Equal(t, []string{"matell-2508-rg"}, resourceGroupsFromDeployment(mockDeployment)) }) - t.Run("references used when no output resources", func(t *testing.T) { - mockDeployment := &ResourceDeployment{ - //nolint:lll - Id: "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/providers/Microsoft.Resources/deployments/matell-2508-1689982746", - //nolint:lll - DeploymentId: "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/providers/Microsoft.Resources/deployments/matell-2508-1689982746", - Name: "matell-2508", - Type: "Microsoft.Resources/deployments", - Tags: map[string]*string{ - "azd-env-name": to.Ptr("matell-2508"), - }, - ProvisioningState: DeploymentProvisioningStateFailed, - Timestamp: time.Now(), - Dependencies: []*armresources.Dependency{ - { - //nolint:lll - ID: to.Ptr( - "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg/providers/Microsoft.Resources/deployments/resources", - ), - ResourceName: to.Ptr("resources"), - ResourceType: to.Ptr("Microsoft.Resources/deployments"), - DependsOn: []*armresources.BasicDependency{ - { - //nolint:lll - ID: to.Ptr( - "/subscriptions/faa080af-c1d8-40ad-9cce-e1a450ca5b57/resourceGroups/matell-2508-rg", - ), - ResourceName: to.Ptr("matell-2508-rg"), - ResourceType: to.Ptr("Microsoft.Resources/resourceGroups"), - }, - }, - }, - }, - } - - require.Equal(t, []string{"matell-2508-rg"}, resourceGroupsFromDeployment(mockDeployment)) - }) - t.Run("duplicate resource groups ignored", func(t *testing.T) { mockDeployment := ResourceDeployment{