diff --git a/cli/azd/internal/cmd/provision.go b/cli/azd/internal/cmd/provision.go index a4e75131c12..a2206e180d2 100644 --- a/cli/azd/internal/cmd/provision.go +++ b/cli/azd/internal/cmd/provision.go @@ -445,10 +445,22 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error func deployResultToUx(previewResult *provisioning.DeployPreviewResult) ux.UxItem { var operations []*ux.Resource for _, change := range previewResult.Preview.Properties.Changes { + // Convert property deltas to UX format + var propertyDeltas []ux.PropertyDelta + for _, delta := range change.Delta { + propertyDeltas = append(propertyDeltas, ux.PropertyDelta{ + Path: delta.Path, + ChangeType: string(delta.ChangeType), + Before: delta.Before, + After: delta.After, + }) + } + operations = append(operations, &ux.Resource{ - Operation: ux.OperationType(change.ChangeType), - Type: change.ResourceType, - Name: change.Name, + Operation: ux.OperationType(change.ChangeType), + Type: change.ResourceType, + Name: change.Name, + PropertyDeltas: propertyDeltas, }) } return &ux.PreviewProvision{ diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index e4a7955dd82..f023698a770 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -23,6 +23,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm" "github.com/Azure/azure-sdk-for-go/sdk/azcore/to" "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices" + "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/azapi" @@ -811,6 +812,12 @@ func (p *BicepProvider) Preview(ctx context.Context) (*provisioning.DeployPrevie resourceType, _ := resourceState["type"].(string) resourceName, _ := resourceState["name"].(string) + // Convert Delta (property-level changes) from Azure SDK format to our format + var delta []provisioning.DeploymentPreviewPropertyChange + if change.Delta != nil { + delta = convertPropertyChanges(change.Delta) + } + changes = append(changes, &provisioning.DeploymentPreviewChange{ ChangeType: provisioning.ChangeType(*change.ChangeType), ResourceId: provisioning.Resource{ @@ -818,6 +825,9 @@ func (p *BicepProvider) Preview(ctx context.Context) (*provisioning.DeployPrevie }, ResourceType: resourceType, Name: resourceName, + Before: change.Before, + After: change.After, + Delta: delta, }) } @@ -831,6 +841,40 @@ func (p *BicepProvider) Preview(ctx context.Context) (*provisioning.DeployPrevie }, nil } +// convertPropertyChanges converts Azure SDK's WhatIfPropertyChange to our DeploymentPreviewPropertyChange +func convertPropertyChanges(changes []*armresources.WhatIfPropertyChange) []provisioning.DeploymentPreviewPropertyChange { + if changes == nil { + return nil + } + + result := make([]provisioning.DeploymentPreviewPropertyChange, 0, len(changes)) + for _, change := range changes { + if change == nil { + continue + } + + propertyChange := provisioning.DeploymentPreviewPropertyChange{ + Path: convert.ToValueWithDefault(change.Path, ""), + Before: change.Before, + After: change.After, + } + + // Convert PropertyChangeType + if change.PropertyChangeType != nil { + propertyChange.ChangeType = provisioning.PropertyChangeType(*change.PropertyChangeType) + } + + // Recursively convert children if present + if change.Children != nil { + propertyChange.Children = convertPropertyChanges(change.Children) + } + + result = append(result, propertyChange) + } + + return result +} + type itemToPurge struct { resourceType string count int diff --git a/cli/azd/pkg/output/ux/preview_provision.go b/cli/azd/pkg/output/ux/preview_provision.go index 49559a2555f..3fe1a2157e0 100644 --- a/cli/azd/pkg/output/ux/preview_provision.go +++ b/cli/azd/pkg/output/ux/preview_provision.go @@ -46,16 +46,26 @@ func (op OperationType) String() (displayName string) { // Resource provides a basic structure for an Azure resource. type Resource struct { - Operation OperationType - Name string - Type string + Operation OperationType + Name string + Type string + PropertyDeltas []PropertyDelta +} + +// PropertyDelta represents a property-level change in a resource +type PropertyDelta struct { + Path string + ChangeType string + Before interface{} + After interface{} } func colorType(opType OperationType) func(string, ...interface{}) string { var final func(format string, a ...interface{}) string switch opType { - case OperationTypeCreate, - OperationTypeNoChange, + case OperationTypeCreate: + final = color.GreenString + case OperationTypeNoChange, OperationTypeIgnore: final = output.WithGrayFormat case OperationTypeDelete: @@ -76,7 +86,7 @@ func (pp *PreviewProvision) ToString(currentIndentation string) string { title := currentIndentation + "Resources:" - changes := make([]string, len(pp.Operations)) + var output []string actions := make([]string, len(pp.Operations)) resources := make([]string, len(pp.Operations)) @@ -102,15 +112,88 @@ func (pp *PreviewProvision) ToString(currentIndentation string) string { } for index, op := range pp.Operations { - changes[index] = fmt.Sprintf("%s%s %s %s", + resourceLine := fmt.Sprintf("%s%s %s %s", currentIndentation, colorType(op.Operation)(actions[index]), resources[index], op.Name, ) + output = append(output, resourceLine) + + // Only show property-level changes for resources that are being created, modified, or deleted + // Skip showing properties for NoChange/Ignore operations + if len(op.PropertyDeltas) > 0 && + op.Operation != OperationTypeNoChange && + op.Operation != OperationTypeIgnore { + // Calculate indentation to align with resource name (after the second colon) + // Find the position of the second colon in the resource line + propertyIndent := currentIndentation + " " + + for _, delta := range op.PropertyDeltas { + propertyLine := formatPropertyChange(propertyIndent, delta) + output = append(output, propertyLine) + } + } + } + + return fmt.Sprintf("%s\n\n%s", title, strings.Join(output, "\n")) +} + +// formatPropertyChange formats a single property change for display +func formatPropertyChange(indent string, delta PropertyDelta) string { + changeSymbol := "" + changeColor := func(format string, a ...interface{}) string { return fmt.Sprintf(format, a...) } + + switch delta.ChangeType { + case "Create": + changeSymbol = "+" + changeColor = color.GreenString + case "Delete": + changeSymbol = "-" + changeColor = color.RedString + case "Modify": + changeSymbol = "~" + changeColor = color.YellowString + case "Array": + changeSymbol = "*" + changeColor = color.CyanString } - return fmt.Sprintf("%s\n\n%s", title, strings.Join(changes, "\n")) + // Format values for display + beforeStr := formatValue(delta.Before) + afterStr := formatValue(delta.After) + + if delta.ChangeType == "Modify" { + return changeColor("%s%s %s: %s => %s", indent, changeSymbol, delta.Path, beforeStr, afterStr) + } else if delta.ChangeType == "Create" { + return changeColor("%s%s %s: %s", indent, changeSymbol, delta.Path, afterStr) + } else if delta.ChangeType == "Delete" { + return changeColor("%s%s %s", indent, changeSymbol, delta.Path) + } else { + // Array or other types + return changeColor("%s%s %s", indent, changeSymbol, delta.Path) + } +} + +// formatValue formats a value for display (handling various types) +func formatValue(value interface{}) string { + if value == nil { + return "(null)" + } + + switch v := value.(type) { + case string: + return fmt.Sprintf("\"%s\"", v) + case map[string]interface{}, []interface{}: + // For complex types, use a JSON-like representation + data, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(data) + default: + return fmt.Sprintf("%v", v) + } } func (pp *PreviewProvision) MarshalJSON() ([]byte, error) { diff --git a/cli/azd/pkg/output/ux/preview_provision_test.go b/cli/azd/pkg/output/ux/preview_provision_test.go index aa1cd122bc9..2ea81c1a9a6 100644 --- a/cli/azd/pkg/output/ux/preview_provision_test.go +++ b/cli/azd/pkg/output/ux/preview_provision_test.go @@ -48,3 +48,92 @@ func TestPreviewProvisionNoChanges(t *testing.T) { output := pp.ToString(" ") require.Equal(t, "", output) } + +func TestPreviewProvisionWithPropertyChanges(t *testing.T) { + pp := &PreviewProvision{ + Operations: []*Resource{ + { + Type: "Microsoft.Storage/storageAccounts", + Name: "mystorageaccount", + Operation: OperationTypeModify, + PropertyDeltas: []PropertyDelta{ + { + Path: "properties.sku.name", + ChangeType: "Modify", + Before: "Standard_LRS", + After: "Premium_LRS", + }, + { + Path: "properties.minimumTlsVersion", + ChangeType: "Create", + After: "TLS1_2", + }, + }, + }, + { + Type: "Microsoft.KeyVault/vaults", + Name: "mykeyvault", + Operation: OperationTypeCreate, + PropertyDeltas: []PropertyDelta{ + { + Path: "properties.sku.name", + ChangeType: "Create", + After: "standard", + }, + }, + }, + }, + } + + output := pp.ToString(" ") + snapshot.SnapshotT(t, output) +} + +func TestPreviewProvisionSkipHidesProperties(t *testing.T) { + pp := &PreviewProvision{ + Operations: []*Resource{ + { + Type: "Microsoft.Storage/storageAccounts", + Name: "mystorageaccount", + Operation: OperationTypeModify, + PropertyDeltas: []PropertyDelta{ + { + Path: "properties.sku.name", + ChangeType: "Modify", + Before: "Standard_LRS", + After: "Premium_LRS", + }, + }, + }, + { + Type: "Microsoft.KeyVault/vaults", + Name: "skippedvault", + Operation: OperationTypeIgnore, + PropertyDeltas: []PropertyDelta{ + { + Path: "properties.sku.name", + ChangeType: "NoEffect", + Before: "standard", + After: "standard", + }, + }, + }, + { + Type: "Microsoft.Network/virtualNetworks", + Name: "unchangedvnet", + Operation: OperationTypeNoChange, + PropertyDeltas: []PropertyDelta{ + { + Path: "properties.addressSpace", + ChangeType: "NoEffect", + Before: "10.0.0.0/16", + After: "10.0.0.0/16", + }, + }, + }, + }, + } + + output := pp.ToString(" ") + snapshot.SnapshotT(t, output) +} diff --git a/cli/azd/pkg/output/ux/testdata/TestPreviewProvisionSkipHidesProperties.snap b/cli/azd/pkg/output/ux/testdata/TestPreviewProvisionSkipHidesProperties.snap new file mode 100644 index 00000000000..dc0f74f4a10 --- /dev/null +++ b/cli/azd/pkg/output/ux/testdata/TestPreviewProvisionSkipHidesProperties.snap @@ -0,0 +1,6 @@ + Resources: + + Modify : Microsoft.Storage/storageAccounts : mystorageaccount + ~ properties.sku.name: "Standard_LRS" => "Premium_LRS" + Skip : Microsoft.KeyVault/vaults : skippedvault + Skip : Microsoft.Network/virtualNetworks : unchangedvnet diff --git a/cli/azd/pkg/output/ux/testdata/TestPreviewProvisionWithPropertyChanges.snap b/cli/azd/pkg/output/ux/testdata/TestPreviewProvisionWithPropertyChanges.snap new file mode 100644 index 00000000000..77e1899fc6a --- /dev/null +++ b/cli/azd/pkg/output/ux/testdata/TestPreviewProvisionWithPropertyChanges.snap @@ -0,0 +1,7 @@ + Resources: + + Modify : Microsoft.Storage/storageAccounts : mystorageaccount + ~ properties.sku.name: "Standard_LRS" => "Premium_LRS" + + properties.minimumTlsVersion: "TLS1_2" + Create : Microsoft.KeyVault/vaults : mykeyvault + + properties.sku.name: "standard"