From c8cdad1e3e1830adfc4a96d0a1a42a096681169c Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Wed, 27 Jul 2022 18:39:33 -0700 Subject: [PATCH] Log progress of resources created during `azd provision` (#152) Azure resources created are logged to standard output (in interactive mode). --- cli/azd/.vscode/cspell-azd-dictionary.txt | 1 + cli/azd/CHANGELOG.md | 1 + cli/azd/cmd/infra_create.go | 34 ++--- cli/azd/pkg/infra/azure_resource_manager.go | 39 +++++- .../pkg/infra/azure_resource_manager_test.go | 6 +- cli/azd/pkg/infra/azure_resource_types.go | 78 +++++++++-- .../pkg/infra/azure_resource_types_test.go | 33 +++++ .../provisioning_progress_display.go | 117 ++++++++++++++++ .../provisioning_progress_display_test.go | 132 ++++++++++++++++++ cli/azd/pkg/tools/azcli.go | 26 ++++ 10 files changed, 427 insertions(+), 40 deletions(-) create mode 100644 cli/azd/pkg/infra/azure_resource_types_test.go create mode 100644 cli/azd/pkg/infra/provisioning/provisioning_progress_display.go create mode 100644 cli/azd/pkg/infra/provisioning/provisioning_progress_display_test.go diff --git a/cli/azd/.vscode/cspell-azd-dictionary.txt b/cli/azd/.vscode/cspell-azd-dictionary.txt index 06ab68c441a..1ffa2c182a1 100644 --- a/cli/azd/.vscode/cspell-azd-dictionary.txt +++ b/cli/azd/.vscode/cspell-azd-dictionary.txt @@ -39,6 +39,7 @@ pyapp keychain restoreapp rzip +serverfarms sstore staticwebapp structs diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 131aaa7569d..2513d7af4f7 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features Added - [[#100]](https://github.com/Azure/azure-dev/pull/100) Add support for an optional `docker` section in service configuration to control advanced docker options. +- [[#152]](https://github.com/Azure/azure-dev/pull/152) While provisioning in interactive mode (default), Azure resources are now logged to console as they are created. ### Breaking Changes diff --git a/cli/azd/cmd/infra_create.go b/cli/azd/cmd/infra_create.go index db73bf67989..bf524744f59 100644 --- a/cli/azd/cmd/infra_create.go +++ b/cli/azd/cmd/infra_create.go @@ -14,6 +14,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/iac/bicep" "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/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/project" @@ -199,7 +200,7 @@ func (ica *infraCreateAction) Run(ctx context.Context, cmd *cobra.Command, args } var res deployFuncResult - deployAndReportProgress := func(showProgress func(string)) error { + deployAndReportProgress := func(spinner *spin.Spinner) error { deployResChan := make(chan deployFuncResult) go func() { res, err := bicep.Deploy(ctx, deploymentTarget, bicepPath, azdCtx.BicepParametersFilePath(ica.rootOptions.EnvironmentName, "main")) @@ -207,6 +208,8 @@ func (ica *infraCreateAction) Run(ctx context.Context, cmd *cobra.Command, args close(deployResChan) }() + progressDisplay := provisioning.NewProvisioningProgressDisplay(infra.NewAzureResourceManager(azCli), env.GetSubscriptionId(), env.GetEnvName()) + for { select { case deployRes := <-deployResChan: @@ -217,7 +220,7 @@ func (ica *infraCreateAction) Run(ctx context.Context, cmd *cobra.Command, args continue } if interactive { - reportDeploymentStatusInteractive(ctx, azCli, env, showProgress) + progressDisplay.ReportProgress(ctx, spinner.Title, spinner.Println) } else { reportDeploymentStatusJson(ctx, azCli, env, formatter, cmd) } @@ -236,7 +239,7 @@ func (ica *infraCreateAction) Run(ctx context.Context, cmd *cobra.Command, args spinner := spin.NewSpinner("Creating Azure resources") spinner.Start() - err = deployAndReportProgress(spinner.Title) + err = deployAndReportProgress(spinner) spinner.Stop() if err == nil { @@ -275,27 +278,6 @@ func (ica *infraCreateAction) Run(ctx context.Context, cmd *cobra.Command, args return nil } -func reportDeploymentStatusInteractive(ctx context.Context, azCli tools.AzCli, env environment.Environment, showProgress func(string)) { - resourceManager := infra.NewAzureResourceManager(azCli) - - operations, err := resourceManager.GetDeploymentResourceOperations(ctx, env.GetSubscriptionId(), env.GetEnvName()) - if err != nil { - // Status display is best-effort activity. - return - } - - succeededCount := 0 - - for _, resourceOperation := range *operations { - if resourceOperation.Properties.ProvisioningState == "Succeeded" { - succeededCount++ - } - } - - status := fmt.Sprintf("Creating Azure resources (%d of ~%d completed)", succeededCount, len(*operations)) - showProgress(status) -} - type progressReport struct { Timestamp time.Time `json:"timestamp"` Operations []tools.AzCliResourceOperation `json:"operations"` @@ -305,14 +287,14 @@ func reportDeploymentStatusJson(ctx context.Context, azCli tools.AzCli, env envi resourceManager := infra.NewAzureResourceManager(azCli) ops, err := resourceManager.GetDeploymentResourceOperations(ctx, env.GetSubscriptionId(), env.GetEnvName()) - if err != nil || len(*ops) == 0 { + if err != nil || len(ops) == 0 { // Status display is best-effort activity. return } report := progressReport{ Timestamp: time.Now(), - Operations: *ops, + Operations: ops, } _ = formatter.Format(report, cmd.OutOrStdout(), nil) diff --git a/cli/azd/pkg/infra/azure_resource_manager.go b/cli/azd/pkg/infra/azure_resource_manager.go index 3b599dce63f..25ed42344ef 100644 --- a/cli/azd/pkg/infra/azure_resource_manager.go +++ b/cli/azd/pkg/infra/azure_resource_manager.go @@ -18,7 +18,7 @@ func NewAzureResourceManager(azCli tools.AzCli) *AzureResourceManager { } } -func (rm *AzureResourceManager) GetDeploymentResourceOperations(ctx context.Context, subscriptionId string, deploymentName string) (*[]tools.AzCliResourceOperation, error) { +func (rm *AzureResourceManager) GetDeploymentResourceOperations(ctx context.Context, subscriptionId string, deploymentName string) ([]tools.AzCliResourceOperation, error) { // Gets all the subscription level deployments subOperations, err := rm.azCli.ListSubscriptionDeploymentOperations(ctx, subscriptionId, deploymentName) if err != nil { @@ -38,7 +38,7 @@ func (rm *AzureResourceManager) GetDeploymentResourceOperations(ctx context.Cont resourceOperations := []tools.AzCliResourceOperation{} if strings.TrimSpace(resourceGroupName) == "" { - return &resourceOperations, nil + return resourceOperations, nil } // Find all resource group deployments within the subscription operations @@ -52,7 +52,7 @@ func (rm *AzureResourceManager) GetDeploymentResourceOperations(ctx context.Cont } } - return &resourceOperations, nil + return resourceOperations, nil } func (rm *AzureResourceManager) appendDeploymentResourcesRecursive(ctx context.Context, subscriptionId string, resourceGroupName string, deploymentName string, resourceOperations *[]tools.AzCliResourceOperation) error { @@ -74,3 +74,36 @@ func (rm *AzureResourceManager) appendDeploymentResourcesRecursive(ctx context.C return nil } + +func (rm *AzureResourceManager) GetResourceTypeDisplayName(ctx context.Context, subscriptionId string, resourceId string, resourceType AzureResourceType) (string, error) { + if resourceType == 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 { + resourceTypeDisplayName := GetResourceTypeDisplayName(resourceType) + return resourceTypeDisplayName, nil + } +} + +func (rm *AzureResourceManager) GetWebAppResourceTypeDisplayName(ctx context.Context, subscriptionId string, resourceId string) (string, error) { + resource, err := rm.azCli.GetResource(ctx, subscriptionId, resourceId) + + 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 + } +} diff --git a/cli/azd/pkg/infra/azure_resource_manager_test.go b/cli/azd/pkg/infra/azure_resource_manager_test.go index 30e2fb02570..eb2aaf6f256 100644 --- a/cli/azd/pkg/infra/azure_resource_manager_test.go +++ b/cli/azd/pkg/infra/azure_resource_manager_test.go @@ -158,7 +158,7 @@ func TestGetDeploymentResourceOperationsSuccess(t *testing.T) { require.NotNil(t, operations) require.Nil(t, err) - require.Len(t, *operations, 2) + require.Len(t, operations, 2) require.Equal(t, 1, subCalls) require.Equal(t, 1, groupCalls) } @@ -220,7 +220,7 @@ func TestGetDeploymentResourceOperationsNoResourceGroup(t *testing.T) { require.NotNil(t, operations) require.Nil(t, err) - require.Len(t, *operations, 0) + require.Len(t, operations, 0) require.Equal(t, 1, subCalls) require.Equal(t, 0, groupCalls) } @@ -260,7 +260,7 @@ func TestGetDeploymentResourceOperationsWithNestedDeployments(t *testing.T) { require.NotNil(t, operations) require.Nil(t, err) - require.Len(t, *operations, 4) + require.Len(t, operations, 4) require.Equal(t, 1, subCalls) require.Equal(t, 2, groupCalls) } diff --git a/cli/azd/pkg/infra/azure_resource_types.go b/cli/azd/pkg/infra/azure_resource_types.go index 5d49bd73db9..1cb48fa5a51 100644 --- a/cli/azd/pkg/infra/azure_resource_types.go +++ b/cli/azd/pkg/infra/azure_resource_types.go @@ -1,14 +1,76 @@ package infra +import "strings" + type AzureResourceType string const ( - AzureResourceTypeResourceGroup AzureResourceType = "Microsoft.Resources/resourceGroups" - AzureResourceTypeDeployment AzureResourceType = "Microsoft.Resources/deployments" - AzureResourceTypeStorageAccount AzureResourceType = "Microsoft.Storage/storageAccounts" - AzureResourceTypeKeyVault AzureResourceType = "Microsoft.KeyVault/vaults" - AzureResourceTypePortalDashboard AzureResourceType = "Microsoft.Portal/dashboards" - AzureResourceTypeAppInsightComponent AzureResourceType = "Microsoft.Insights/components" - AzureResourceTypeWebSite AzureResourceType = "Microsoft.Web/sites" - AzureResourceTypeContainerApp AzureResourceType = "Microsoft.App/containerApps" + AzureResourceTypeResourceGroup AzureResourceType = "Microsoft.Resources/resourceGroups" + AzureResourceTypeDeployment AzureResourceType = "Microsoft.Resources/deployments" + AzureResourceTypeStorageAccount AzureResourceType = "Microsoft.Storage/storageAccounts" + AzureResourceTypeKeyVault AzureResourceType = "Microsoft.KeyVault/vaults" + AzureResourceTypePortalDashboard AzureResourceType = "Microsoft.Portal/dashboards" + AzureResourceTypeAppInsightComponent AzureResourceType = "Microsoft.Insights/components" + AzureResourceTypeLogAnalyticsWorkspace AzureResourceType = "Microsoft.OperationalInsights/workspaces" + AzureResourceTypeWebSite AzureResourceType = "Microsoft.Web/sites" + AzureResourceTypeStaticWebSite AzureResourceType = "Microsoft.Web/staticSites" + AzureResourceTypeServicePlan AzureResourceType = "Microsoft.Web/serverfarms" + AzureResourceTypeSqlDatabase AzureResourceType = "Microsoft.Sql/servers" + AzureResourceTypeCosmosDb AzureResourceType = "Microsoft.DocumentDB/databaseAccounts" + AzureResourceTypeContainerApp AzureResourceType = "Microsoft.App/containerApps" + AzureResourceTypeContainerAppEnvironment AzureResourceType = "Microsoft.App/managedEnvironments" ) + +const resourceLevelSeparator = "/" + +// GetResourceTypeDisplayName retrieves the display name for the given resource type. +// If the display name was not found for the given resource type, an empty string is returned instead. +func GetResourceTypeDisplayName(resourceType AzureResourceType) string { + // Azure Resource Manager does not offer an API for obtaining display name for resource types. + // Display names for Azure resource types in Azure Portal are encoded in UX definition files instead. + // As a result, we provide static translations for known resources below. These are obtained from the Azure Portal. + switch resourceType { + case AzureResourceTypeResourceGroup: + return "Resource group" + case AzureResourceTypeStorageAccount: + return "Storage account" + case AzureResourceTypeKeyVault: + return "Key vault" + case AzureResourceTypePortalDashboard: + return "Portal dashboard" + case AzureResourceTypeAppInsightComponent: + return "Application Insights" + case AzureResourceTypeLogAnalyticsWorkspace: + return "Log Analytics workspace" + case AzureResourceTypeWebSite: + return "Web App" + case AzureResourceTypeStaticWebSite: + return "Static Web App" + case AzureResourceTypeContainerApp: + return "Container App" + case AzureResourceTypeContainerAppEnvironment: + return "Container Apps Environment" + case AzureResourceTypeServicePlan: + return "App Service plan" + case AzureResourceTypeCosmosDb: + return "Azure Cosmos DB" + } + + return "" +} + +// IsTopLevelResourceType returns true if the resource type is a top-level resource type, otherwise false. +// A top-level resource type is of the format of: {ResourceProvider}/{TopLevelResourceType}, i.e. Microsoft.DocumentDB/databaseAccounts +func IsTopLevelResourceType(resourceType AzureResourceType) bool { + resType := string(resourceType) + firstIndex := strings.Index(resType, resourceLevelSeparator) + + if firstIndex == -1 || + firstIndex == 0 || + firstIndex == len(resType)-1 { + return false + } + + // Should not contain second separator + return !strings.Contains(resType[firstIndex+1:], resourceLevelSeparator) +} diff --git a/cli/azd/pkg/infra/azure_resource_types_test.go b/cli/azd/pkg/infra/azure_resource_types_test.go new file mode 100644 index 00000000000..799e8a9c239 --- /dev/null +++ b/cli/azd/pkg/infra/azure_resource_types_test.go @@ -0,0 +1,33 @@ +package infra + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsTopLevelResourceType(t *testing.T) { + var tests = []struct { + resourceType string + result bool + }{ + {"", false}, + {"/", false}, + {"/foo", false}, + {"foo", false}, + {"foo/", false}, + {"foo/b", true}, + {"foo/bar", true}, + {"foo/bar/baz", false}, + {"foo/bar/", false}, + {"Microsoft.Storage/storageAccounts", true}, + {"Microsoft.DocumentDB/databaseAccounts/collections", false}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("\"%s\")", test.resourceType), func(t *testing.T) { + assert.Equal(t, test.result, IsTopLevelResourceType(AzureResourceType(test.resourceType))) + }) + } +} diff --git a/cli/azd/pkg/infra/provisioning/provisioning_progress_display.go b/cli/azd/pkg/infra/provisioning/provisioning_progress_display.go new file mode 100644 index 00000000000..51dab247320 --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/provisioning_progress_display.go @@ -0,0 +1,117 @@ +package provisioning + +import ( + "context" + "fmt" + "log" + "sort" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/pkg/tools" +) + +const defaultProgressTitle string = "Creating Azure resources" +const succeededProvisioningState string = "Succeeded" + +type ResourceManager interface { + GetDeploymentResourceOperations(ctx context.Context, subscriptionId string, deploymentName string) ([]tools.AzCliResourceOperation, error) + GetResourceTypeDisplayName(ctx context.Context, subscriptionId string, resourceId string, resourceType infra.AzureResourceType) (string, error) + GetWebAppResourceTypeDisplayName(ctx context.Context, subscriptionId string, resourceId string) (string, error) +} + +// ProvisioningProgressDisplay displays interactive progress for an ongoing Azure provisioning operation. +type ProvisioningProgressDisplay struct { + // Keeps track of created resources + createdResources map[string]bool + subscriptionId string + deploymentName string + resourceManager ResourceManager +} + +func NewProvisioningProgressDisplay(rm ResourceManager, subscriptionId string, deploymentName string) ProvisioningProgressDisplay { + return ProvisioningProgressDisplay{ + createdResources: map[string]bool{}, + subscriptionId: subscriptionId, + deploymentName: deploymentName, + resourceManager: rm, + } +} + +// ReportProgress reports the current deployment progress, setting the currently executing operation title and logging progress. +func (display *ProvisioningProgressDisplay) ReportProgress(ctx context.Context, setOperationTitle func(string), logProgress func(string)) { + operations, err := display.resourceManager.GetDeploymentResourceOperations(ctx, display.subscriptionId, display.deploymentName) + if err != nil { + // Status display is best-effort activity. + return + } + + succeededCount := 0 + newlyDeployedResources := []*tools.AzCliResourceOperation{} + + for i := range operations { + if operations[i].Properties.ProvisioningState == succeededProvisioningState { + succeededCount++ + + if !display.createdResources[operations[i].Properties.TargetResource.Id] && + infra.IsTopLevelResourceType(infra.AzureResourceType(operations[i].Properties.TargetResource.ResourceType)) { + newlyDeployedResources = append(newlyDeployedResources, &operations[i]) + } + } + } + + sort.Slice(newlyDeployedResources, func(i int, j int) bool { + return time.Time.Before(newlyDeployedResources[i].Properties.Timestamp, newlyDeployedResources[j].Properties.Timestamp) + }) + + display.logNewlyCreatedResources(ctx, newlyDeployedResources, logProgress) + + status := "" + + if len(operations) > 0 { + status = formatProgressTitle(succeededCount, len(operations)) + } else { + status = defaultProgressTitle + } + + setOperationTitle(status) +} + +func (display *ProvisioningProgressDisplay) logNewlyCreatedResources(ctx context.Context, resources []*tools.AzCliResourceOperation, logProgress func(string)) { + for _, newResource := range resources { + resourceTypeName := newResource.Properties.TargetResource.ResourceType + resourceTypeDisplayName, err := display.resourceManager.GetResourceTypeDisplayName( + ctx, display.subscriptionId, newResource.Properties.TargetResource.Id, infra.AzureResourceType(resourceTypeName)) + + if err != nil { + // Dynamic resource type translation failed -- fallback to static translation + resourceTypeDisplayName = infra.GetResourceTypeDisplayName(infra.AzureResourceType(resourceTypeName)) + } + + // Don't log resource types for Azure resources that we do not have a translation of the resource type for. + // This will be improved on in a future iteration. + if resourceTypeDisplayName != "" { + logProgress(formatCreatedResourceLog(resourceTypeDisplayName, newResource.Properties.TargetResource.ResourceName)) + resourceTypeName = resourceTypeDisplayName + } + + log.Printf( + "%s - Created %s: %s", + newResource.Properties.Timestamp.Local().Format("2006-01-02 15:04:05"), + resourceTypeName, + newResource.Properties.TargetResource.ResourceName) + + display.createdResources[newResource.Properties.TargetResource.Id] = true + } +} + +func formatCreatedResourceLog(resourceTypeDisplayName string, resourceName string) string { + return fmt.Sprintf( + "Created %s: %s", + resourceTypeDisplayName, + resourceName) +} + +func formatProgressTitle(succeededCount int, totalCount int) string { + return fmt.Sprintf("Creating Azure resources (%d of ~%d completed)", succeededCount, totalCount) +} diff --git a/cli/azd/pkg/infra/provisioning/provisioning_progress_display_test.go b/cli/azd/pkg/infra/provisioning/provisioning_progress_display_test.go new file mode 100644 index 00000000000..a9c6b964f0f --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/provisioning_progress_display_test.go @@ -0,0 +1,132 @@ +package provisioning + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/pkg/tools" + "github.com/stretchr/testify/assert" +) + +type mockResourceManager struct { + operations []tools.AzCliResourceOperation +} + +func (mock *mockResourceManager) GetDeploymentResourceOperations(ctx context.Context, subscriptionId string, deploymentName string) ([]tools.AzCliResourceOperation, error) { + return mock.operations, nil +} + +func (mock *mockResourceManager) GetResourceTypeDisplayName(ctx context.Context, subscriptionId string, resourceId string, resourceType infra.AzureResourceType) (string, error) { + return string(resourceType), nil +} + +func (mock *mockResourceManager) GetWebAppResourceTypeDisplayName(ctx context.Context, subscriptionId string, resourceId string) (string, error) { + return "", nil +} + +func (mock *mockResourceManager) AddInProgressSubResourceOperation() { + mock.operations = append(mock.operations, tools.AzCliResourceOperation{Id: "website-deploy-id", + Properties: tools.AzCliResourceOperationProperties{ + ProvisioningOperation: "Create", + TargetResource: tools.AzCliResourceOperationTargetResource{ + ResourceType: string(infra.AzureResourceTypeWebSite) + "/config", + Id: fmt.Sprintf("website-resource-id-%d", len(mock.operations)), + ResourceName: fmt.Sprintf("website-resource-name-%d", len(mock.operations)), + ResourceGroup: "resource-group-name", + }, + }}) +} + +func (mock *mockResourceManager) AddInProgressOperation() { + mock.operations = append(mock.operations, tools.AzCliResourceOperation{Id: "website-deploy-id", + Properties: tools.AzCliResourceOperationProperties{ + ProvisioningOperation: "Create", + TargetResource: tools.AzCliResourceOperationTargetResource{ + ResourceType: string(infra.AzureResourceTypeWebSite), + Id: fmt.Sprintf("website-resource-id-%d", len(mock.operations)), + ResourceName: fmt.Sprintf("website-resource-name-%d", len(mock.operations)), + ResourceGroup: "resource-group-name", + }, + }}) +} + +func (mock *mockResourceManager) MarkComplete(i int) { + mock.operations[i].Properties.ProvisioningState = succeededProvisioningState + mock.operations[i].Properties.Timestamp = time.Now().UTC() +} + +func TestReportProgress(t *testing.T) { + t.Run("Displays progress correctly", func(t *testing.T) { + mockResourceManager := mockResourceManager{} + progressDisplay := NewProvisioningProgressDisplay(&mockResourceManager, "", "") + logOutput := []string{} + progressTitle := "" + progressDisplay.reportProgress(&progressTitle, &logOutput) + assert.Empty(t, logOutput) + + mockResourceManager.AddInProgressOperation() + progressDisplay.reportProgress(&progressTitle, &logOutput) + assert.Empty(t, logOutput) + assert.Equal(t, formatProgressTitle(0, 1), progressTitle) + + mockResourceManager.AddInProgressOperation() + progressDisplay.reportProgress(&progressTitle, &logOutput) + assert.Empty(t, logOutput) + assert.Equal(t, formatProgressTitle(0, 2), progressTitle) + + mockResourceManager.AddInProgressSubResourceOperation() + progressDisplay.reportProgress(&progressTitle, &logOutput) + assert.Empty(t, logOutput) + assert.Equal(t, formatProgressTitle(0, 3), progressTitle) + + mockResourceManager.MarkComplete(0) + progressDisplay.reportProgress(&progressTitle, &logOutput) + assert.Len(t, logOutput, 1) + assertOperationLogged(t, 0, mockResourceManager.operations, logOutput) + assert.Equal(t, formatProgressTitle(1, 3), progressTitle) + + mockResourceManager.MarkComplete(1) + progressDisplay.reportProgress(&progressTitle, &logOutput) + assert.Len(t, logOutput, 2) + assertOperationLogged(t, 1, mockResourceManager.operations, logOutput) + assert.Equal(t, formatProgressTitle(2, 3), progressTitle) + + // Verify display does not log sub resource types + oldLogOutput := make([]string, len(logOutput)) + copy(logOutput, oldLogOutput) + mockResourceManager.MarkComplete(2) + progressDisplay.reportProgress(&progressTitle, &logOutput) + assert.Equal(t, oldLogOutput, logOutput) + assert.Equal(t, formatProgressTitle(3, 3), progressTitle) + + // Verify display does not repeat logging for resources already logged. + copy(logOutput, oldLogOutput) + progressDisplay.reportProgress(&progressTitle, &logOutput) + assert.Equal(t, oldLogOutput, logOutput) + assert.Equal(t, formatProgressTitle(3, 3), progressTitle) + }) +} + +func (display *ProvisioningProgressDisplay) reportProgress(captureTitle *string, captureLogOutput *[]string) { + display.ReportProgress(context.Background(), titleCapturer(captureTitle), logOutputCapturer(captureLogOutput)) +} + +func assertOperationLogged(t *testing.T, i int, operations []tools.AzCliResourceOperation, logOutput []string) { + assert.True(t, len(logOutput) > i) + assert.Equal(t, formatCreatedResourceLog(operations[i].Properties.TargetResource.ResourceType, operations[i].Properties.TargetResource.ResourceName), logOutput[i]) +} + +func titleCapturer(title *string) func(string) { + return func(s string) { + *title = s + } +} + +func logOutputCapturer(logOutput *[]string) func(string) { + return func(s string) { + *logOutput = append(*logOutput, s) + } +} diff --git a/cli/azd/pkg/tools/azcli.go b/cli/azd/pkg/tools/azcli.go index 4b9b9b314d6..a0174b56962 100644 --- a/cli/azd/pkg/tools/azcli.go +++ b/cli/azd/pkg/tools/azcli.go @@ -58,6 +58,7 @@ type AzCli interface { GetSubscriptionTenant(ctx context.Context, subscriptionId string) (string, error) GetSubscriptionDeployment(ctx context.Context, subscriptionId string, deploymentName string) (AzCliDeployment, error) GetResourceGroupDeployment(ctx context.Context, subscriptionId string, resourceGroupName string, deploymentName string) (AzCliDeployment, error) + GetResource(ctx context.Context, subscriptionId string, resourceId string) (AzCliResourceExtended, error) GetKeyVault(ctx context.Context, subscriptionId string, vaultName string) (AzCliKeyVault, error) PurgeKeyVault(ctx context.Context, subscriptionId string, vaultName string) error DeployAppServiceZip(ctx context.Context, subscriptionId string, resourceGroup string, appName string, deployZipPath string) (string, error) @@ -153,6 +154,11 @@ type AzCliResource struct { Location string `json:"location"` } +type AzCliResourceExtended struct { + AzCliResource + Kind string `json:"kind"` +} + type AzCliDeploymentResourceReference struct { Id string `json:"id"` } @@ -169,6 +175,9 @@ type AzCliResourceOperationProperties struct { TargetResource AzCliResourceOperationTargetResource `json:"targetResource"` StatusCode string `json:"statusCode"` StatusMessage AzCliDeploymentStatusMessage `json:"statusMessage"` + // While the operation is in progress, this timestamp effectively represents "InProgressTimestamp". + // When the operation ends, this timestamp effectively represents "EndTimestamp". + Timestamp time.Time `json:"timestamp"` } type AzCliDeploymentStatusMessage struct { @@ -715,6 +724,23 @@ func (cli *azCli) ListResourceGroupResources(ctx context.Context, subscriptionId return resources, nil } +func (cli *azCli) GetResource(ctx context.Context, subscriptionId string, resourceId string) (AzCliResourceExtended, error) { + res, err := cli.runAzCommand(ctx, "resource", "show", "--ids", resourceId, "--output", "json") + if isNotLoggedInMessage(res.Stderr) { + return AzCliResourceExtended{}, ErrAzCliNotLoggedIn + } else if err != nil { + return AzCliResourceExtended{}, fmt.Errorf("failed running az resource show --ids: %s: %w", res.String(), err) + } + + var resource AzCliResourceExtended + + if err := json.Unmarshal([]byte(res.Stdout), &resource); err != nil { + return AzCliResourceExtended{}, fmt.Errorf("could not unmarshal output %s as a AzCliResourceExtended: %w", res.Stdout, err) + } + + return resource, nil +} + func (cli *azCli) ListSubscriptionDeploymentOperations(ctx context.Context, subscriptionId string, deploymentName string) ([]AzCliResourceOperation, error) { res, err := cli.runAzCommand(ctx, "deployment", "operation", "sub", "list", "--subscription", subscriptionId, "--name", deploymentName, "--output", "json") if isNotLoggedInMessage(res.Stderr) {