diff --git a/cli/azd/pkg/azapi/standard_deployments.go b/cli/azd/pkg/azapi/standard_deployments.go index efc55ed44cb..64feaf9b493 100644 --- a/cli/azd/pkg/azapi/standard_deployments.go +++ b/cli/azd/pkg/azapi/standard_deployments.go @@ -341,10 +341,10 @@ func (ds *StandardDeployments) ListSubscriptionDeploymentResources( return nil, fmt.Errorf("getting subscription deployment: %w", err) } - resourceGroupNames := resourceGroupsFromDeployment(subscriptionDeployment) + resourceGroupNames := createdResourceGroupsFromDeployment(subscriptionDeployment) allResources := []*armresources.ResourceReference{} - // Find all the resources from the deployment's resource groups + // Find all the resources from the resource groups created by this deployment. for _, resourceGroupName := range resourceGroupNames { resources, err := ds.resourceService.ListResourceGroupResources(ctx, subscriptionId, resourceGroupName, nil) if err != nil { @@ -366,23 +366,26 @@ func (ds *StandardDeployments) ListSubscriptionDeploymentResources( return allResources, nil } -// resourceGroupsFromDeployment extracts the unique resource groups associated to a deployment. -func resourceGroupsFromDeployment(deployment *ResourceDeployment) []string { +// createdResourceGroupsFromDeployment extracts the unique resource groups created by a deployment. +func createdResourceGroupsFromDeployment(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 != "" { + // For a successful deployment, only resource group output resources represent groups created by + // the deployment. Resources deployed into existing resource groups also appear in outputResources, + // but their resource group IDs do not. + for _, resourceRef := range deployment.Resources { + if resourceRef != nil && resourceRef.ID != nil { + resId, err := arm.ParseResourceID(*resourceRef.ID) + if err == nil && resId.ResourceType.Type == arm.ResourceGroupResourceType.Type { 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 a non-succeeded deployment, the outputResources field may not be populated. + // Instead, look at nested deployment dependencies to find resource groups that were created before + // child deployments ran. for _, dependency := range deployment.Dependencies { if dependency.ResourceType != nil && *dependency.ResourceType == string(AzureResourceTypeDeployment) { for _, dependent := range dependency.DependsOn { diff --git a/cli/azd/pkg/azapi/standard_deployments_coverage3_test.go b/cli/azd/pkg/azapi/standard_deployments_coverage3_test.go index 89f52e0d30a..8fd3b4035e0 100644 --- a/cli/azd/pkg/azapi/standard_deployments_coverage3_test.go +++ b/cli/azd/pkg/azapi/standard_deployments_coverage3_test.go @@ -125,6 +125,51 @@ func Test_StdDeployments_GetSubscriptionDeployment_Coverage3(t *testing.T) { }) } +func Test_StdDeployments_ListSubscriptionDeploymentResources_OnlyCreatedResourceGroups_Coverage3(t *testing.T) { + mockCtx := mocks.NewMockContext(t.Context()) + sd := newStdDeployments(mockCtx) + + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodGet && + strings.Contains(strings.ToLower(req.URL.Path), "/providers/microsoft.resources/deployments/deploy1") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + dep := makeDeploymentExtended("deploy1", armresources.ProvisioningStateSucceeded) + dep.Properties.OutputResources = []*armresources.ResourceReference{ + {ID: new("/subscriptions/SUB/resourceGroups/RG1")}, + {ID: new("/subscriptions/SUB/resourceGroups/RG1/providers/Microsoft.Web/sites/app1")}, + {ID: new("/subscriptions/SUB/resourceGroups/EXISTING/providers/Microsoft.Web/sites/app2")}, + } + + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, dep) + }) + + listedRGs := []string{} + mockCtx.HttpClient.When(func(req *http.Request) bool { + return req.Method == http.MethodGet && + strings.Contains(strings.ToLower(req.URL.Path), "/resourcegroups/rg1/resources") + }).RespondFn(func(req *http.Request) (*http.Response, error) { + listedRGs = append(listedRGs, "RG1") + + return mocks.CreateHttpResponseWithBody(req, http.StatusOK, armresources.ResourceListResult{ + Value: []*armresources.GenericResourceExpanded{ + { + ID: new("/subscriptions/SUB/resourceGroups/RG1/providers/Microsoft.Web/sites/app1"), + Name: new("app1"), + Type: new("Microsoft.Web/sites"), + Location: new("eastus"), + }, + }, + }) + }) + + resources, err := sd.ListSubscriptionDeploymentResources(*mockCtx.Context, "SUB", "deploy1") + require.NoError(t, err) + require.Len(t, resources, 2) + assert.Equal(t, []string{"RG1"}, listedRGs) + assert.Equal(t, "/subscriptions/SUB/resourceGroups/RG1", *resources[0].ID) + assert.Equal(t, "/subscriptions/SUB/resourceGroups/RG1/providers/Microsoft.Web/sites/app1", *resources[1].ID) +} + func Test_StdDeployments_ListResourceGroupDeployments_Coverage3(t *testing.T) { mockCtx := mocks.NewMockContext(t.Context()) sd := newStdDeployments(mockCtx) diff --git a/cli/azd/pkg/azapi/standard_deployments_test.go b/cli/azd/pkg/azapi/standard_deployments_test.go index 9bbdc895048..ddbc7abebf8 100644 --- a/cli/azd/pkg/azapi/standard_deployments_test.go +++ b/cli/azd/pkg/azapi/standard_deployments_test.go @@ -49,7 +49,7 @@ func Test_StandardDeployments_GenerateDeploymentName(t *testing.T) { } } -func TestResourceGroupsFromDeployment(t *testing.T) { +func TestCreatedResourceGroupsFromDeployment(t *testing.T) { t.Parallel() t.Run("references used when no output resources", func(t *testing.T) { @@ -87,10 +87,10 @@ func TestResourceGroupsFromDeployment(t *testing.T) { }, } - require.Equal(t, []string{"matell-2508-rg"}, resourceGroupsFromDeployment(mockDeployment)) + require.Equal(t, []string{"matell-2508-rg"}, createdResourceGroupsFromDeployment(mockDeployment)) }) - t.Run("duplicate resource groups ignored", func(t *testing.T) { + t.Run("succeeded deployment only returns created resource groups", func(t *testing.T) { mockDeployment := ResourceDeployment{ Id: "DEPLOYMENT_ID", @@ -101,26 +101,32 @@ func TestResourceGroupsFromDeployment(t *testing.T) { }, { ID: new( - "/subscriptions/sub-id/resourceGroups/groupA/Microsoft.Storage/storageAccounts/storageAccount", + "/subscriptions/sub-id/resourceGroups/groupA/providers/" + + "Microsoft.Storage/storageAccounts/storageAccount", ), }, { ID: new("/subscriptions/sub-id/resourceGroups/groupB"), }, { - ID: new("/subscriptions/sub-id/resourceGroups/groupB/Microsoft.web/sites/test"), + ID: new( + "/subscriptions/sub-id/resourceGroups/groupB/providers/Microsoft.Web/sites/test", + ), }, { - ID: new("/subscriptions/sub-id/resourceGroups/groupC"), + ID: new( + "/subscriptions/sub-id/resourceGroups/groupExisting/providers/" + + "Microsoft.Storage/storageAccounts/storageAccount", + ), }, }, ProvisioningState: DeploymentProvisioningStateSucceeded, Timestamp: time.Now(), } - groups := resourceGroupsFromDeployment(&mockDeployment) + groups := createdResourceGroupsFromDeployment(&mockDeployment) sort.Strings(groups) - require.Equal(t, []string{"groupA", "groupB", "groupC"}, groups) + require.Equal(t, []string{"groupA", "groupB"}, groups) }) }