diff --git a/internal/services/recoveryservices/custompollers/site_recovery_replicated_vm_disk_type_poller.go b/internal/services/recoveryservices/custompollers/site_recovery_replicated_vm_disk_type_poller.go new file mode 100644 index 000000000000..52b8d12ef25f --- /dev/null +++ b/internal/services/recoveryservices/custompollers/site_recovery_replicated_vm_disk_type_poller.go @@ -0,0 +1,184 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package custompollers + +import ( + "context" + "fmt" + "log" + "sort" + "strings" + "time" + + "github.com/hashicorp/go-azure-helpers/lang/pointer" + "github.com/hashicorp/go-azure-helpers/resourcemanager/commonids" + "github.com/hashicorp/go-azure-sdk/resource-manager/recoveryservicessiterecovery/2024-04-01/replicationprotecteditems" + "github.com/hashicorp/go-azure-sdk/sdk/client" + "github.com/hashicorp/go-azure-sdk/sdk/client/pollers" +) + +var _ pollers.PollerType = &SiteRecoveryReplicatedVMDiskTypePoller{} + +// siteRecoveryReplicatedVMDiskTypeRequiredSuccessfulReads is intentionally high. The Site +// Recovery service is eventually consistent and can briefly report the requested disk type +// before the value flips back while replication catches up, so we require this many +// consecutive matching reads before treating the update as complete. Testing showed that a +// lower threshold (e.g. 5) still produced false positives. +const siteRecoveryReplicatedVMDiskTypeRequiredSuccessfulReads = 10 + +type SiteRecoveryReplicatedVMDiskTypeUpdate struct { + DiskId string + RecoveryTargetDiskType string + RecoveryReplicaDiskType string +} + +type SiteRecoveryReplicatedVMDiskTypePoller struct { + client *replicationprotecteditems.ReplicationProtectedItemsClient + id replicationprotecteditems.ReplicationProtectedItemId + updates map[string]SiteRecoveryReplicatedVMDiskTypeUpdate + consecutiveSuccessfulReads int +} + +func NewSiteRecoveryReplicatedVMDiskTypePoller(client *replicationprotecteditems.ReplicationProtectedItemsClient, id replicationprotecteditems.ReplicationProtectedItemId, updates []SiteRecoveryReplicatedVMDiskTypeUpdate) *SiteRecoveryReplicatedVMDiskTypePoller { + updatesByDiskId := make(map[string]SiteRecoveryReplicatedVMDiskTypeUpdate, len(updates)) + for _, update := range updates { + updatesByDiskId[normalizeSiteRecoveryReplicatedVMManagedDiskID(update.DiskId)] = update + } + + return &SiteRecoveryReplicatedVMDiskTypePoller{ + client: client, + id: id, + updates: updatesByDiskId, + } +} + +func (p *SiteRecoveryReplicatedVMDiskTypePoller) Poll(ctx context.Context) (*pollers.PollResult, error) { + resp, err := p.client.Get(ctx, p.id) + if err != nil { + return nil, fmt.Errorf("retrieving %s: %+v", p.id, err) + } + + if resp.Model == nil { + return nil, fmt.Errorf("retrieving %s: `model` was nil", p.id) + } + + if resp.Model.Properties == nil { + return nil, fmt.Errorf("retrieving %s: `properties` was nil", p.id) + } + + result := &pollers.PollResult{ + HttpResponse: &client.Response{ + OData: resp.OData, + Response: resp.HttpResponse, + }, + PollInterval: 30 * time.Second, + } + + if siteRecoveryReplicatedVMStateIndicatesFailure(pointer.From(resp.Model.Properties.ProtectionState)) { + return nil, pollers.PollingFailedError{ + HttpResponse: result.HttpResponse, + Message: fmt.Sprintf("%s entered protection state %q", p.id, pointer.From(resp.Model.Properties.ProtectionState)), + } + } + + a2aDetails, ok := resp.Model.Properties.ProviderSpecificDetails.(replicationprotecteditems.A2AReplicationDetails) + if !ok { + return nil, pollers.PollingFailedError{ + HttpResponse: result.HttpResponse, + Message: fmt.Sprintf("%s returned provider specific details that were not A2A", p.id), + } + } + + if siteRecoveryReplicatedVMStateIndicatesFailure(pointer.From(a2aDetails.VMProtectionState)) { + return nil, pollers.PollingFailedError{ + HttpResponse: result.HttpResponse, + Message: fmt.Sprintf("%s entered VM protection state %q", p.id, pointer.From(a2aDetails.VMProtectionState)), + } + } + + pending, err := p.pendingDiskTypeUpdates(a2aDetails, result.HttpResponse) + if err != nil { + return nil, err + } + + if len(pending) == 0 { + p.consecutiveSuccessfulReads++ + if p.consecutiveSuccessfulReads < siteRecoveryReplicatedVMDiskTypeRequiredSuccessfulReads { + log.Printf("[DEBUG] waiting for managed disk type updates for %s: matched %d/%d consecutive reads", p.id, p.consecutiveSuccessfulReads, siteRecoveryReplicatedVMDiskTypeRequiredSuccessfulReads) + result.Status = pollers.PollingStatusInProgress + return result, nil + } + + result.Status = pollers.PollingStatusSucceeded + return result, nil + } + + p.consecutiveSuccessfulReads = 0 + sort.Strings(pending) + log.Printf("[DEBUG] waiting for managed disk type updates for %s: %s", p.id, strings.Join(pending, "; ")) + result.Status = pollers.PollingStatusInProgress + return result, nil +} + +func (p *SiteRecoveryReplicatedVMDiskTypePoller) pendingDiskTypeUpdates(details replicationprotecteditems.A2AReplicationDetails, resp *client.Response) ([]string, error) { + pending := make([]string, 0) + + protectedDisks := make(map[string]replicationprotecteditems.A2AProtectedManagedDiskDetails) + if details.ProtectedManagedDisks != nil { + for _, disk := range *details.ProtectedManagedDisks { + diskId := normalizeSiteRecoveryReplicatedVMManagedDiskID(pointer.From(disk.DiskId)) + if _, ok := p.updates[diskId]; ok { + protectedDisks[diskId] = disk + } + } + } + + for diskId, update := range p.updates { + disk, ok := protectedDisks[diskId] + if !ok { + pending = append(pending, fmt.Sprintf("%s is not present in protected managed disks", update.DiskId)) + continue + } + + if siteRecoveryReplicatedVMStateIndicatesFailure(pointer.From(disk.DiskState)) { + return nil, pollers.PollingFailedError{ + HttpResponse: resp, + Message: fmt.Sprintf("%s entered disk state %q", update.DiskId, pointer.From(disk.DiskState)), + } + } + + actualTargetDiskType := pointer.From(disk.RecoveryTargetDiskAccountType) + actualTargetReplicaDiskType := pointer.From(disk.RecoveryReplicaDiskAccountType) + + mismatches := make([]string, 0) + if actualTargetDiskType != update.RecoveryTargetDiskType { + mismatches = append(mismatches, fmt.Sprintf("target_disk_type is %q, expected %q", actualTargetDiskType, update.RecoveryTargetDiskType)) + } + if actualTargetReplicaDiskType != update.RecoveryReplicaDiskType { + mismatches = append(mismatches, fmt.Sprintf("target_replica_disk_type is %q, expected %q", actualTargetReplicaDiskType, update.RecoveryReplicaDiskType)) + } + if len(mismatches) > 0 { + pending = append(pending, fmt.Sprintf("%s: %s", update.DiskId, strings.Join(mismatches, ", "))) + } + } + + return pending, nil +} + +func normalizeSiteRecoveryReplicatedVMManagedDiskID(input string) string { + if parsed, err := commonids.ParseManagedDiskIDInsensitively(input); err == nil { + return strings.ToLower(parsed.ID()) + } + + return strings.ToLower(input) +} + +func siteRecoveryReplicatedVMStateIndicatesFailure(input string) bool { + input = strings.ToLower(input) + if input == "" || input == "noerror" { + return false + } + + return strings.Contains(input, "failed") || strings.Contains(input, "error") +} diff --git a/internal/services/recoveryservices/migration/site_recovery_replicated_vm.go b/internal/services/recoveryservices/migration/site_recovery_replicated_vm.go new file mode 100644 index 000000000000..8619a89cf8b6 --- /dev/null +++ b/internal/services/recoveryservices/migration/site_recovery_replicated_vm.go @@ -0,0 +1,294 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package migration + +import ( + "context" + + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" +) + +var _ pluginsdk.StateUpgrade = SiteRecoveryReplicatedVMV0ToV1{} + +type SiteRecoveryReplicatedVMV0ToV1 struct{} + +func (SiteRecoveryReplicatedVMV0ToV1) UpgradeFunc() pluginsdk.StateUpgraderFunc { + return func(ctx context.Context, rawState map[string]interface{}, meta interface{}) (map[string]interface{}, error) { + return rawState, nil + } +} + +func (SiteRecoveryReplicatedVMV0ToV1) Schema() map[string]*pluginsdk.Schema { + return map[string]*pluginsdk.Schema{ + "name": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "resource_group_name": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "recovery_vault_name": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "source_recovery_fabric_name": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "source_vm_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "target_recovery_fabric_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "recovery_replication_policy_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "source_recovery_protection_container_name": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "target_recovery_protection_container_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "target_resource_group_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + + "target_availability_set_id": { + Type: pluginsdk.TypeString, + Optional: true, + }, + + "target_zone": { + Type: pluginsdk.TypeString, + Optional: true, + }, + + "target_network_id": { + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + }, + + "test_network_id": { + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + }, + + "target_edge_zone": { + Type: pluginsdk.TypeString, + Optional: true, + }, + + "unmanaged_disk": { + Type: pluginsdk.TypeSet, + Optional: true, + Computed: true, + ConfigMode: pluginsdk.SchemaConfigModeAttr, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "disk_uri": { + Type: pluginsdk.TypeString, + Required: true, + }, + "staging_storage_account_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + "target_storage_account_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + }, + }, + }, + + "multi_vm_group_name": { + Type: pluginsdk.TypeString, + Optional: true, + }, + + "managed_disk": { + Type: pluginsdk.TypeSet, + Optional: true, + Computed: true, + ConfigMode: pluginsdk.SchemaConfigModeAttr, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "disk_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + "staging_storage_account_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + "target_resource_group_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + "target_disk_type": { + Type: pluginsdk.TypeString, + Required: true, + }, + "target_replica_disk_type": { + Type: pluginsdk.TypeString, + Required: true, + }, + "target_disk_encryption_set_id": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "target_disk_encryption": { + Type: pluginsdk.TypeList, + Optional: true, + ConfigMode: pluginsdk.SchemaConfigModeAttr, + MaxItems: 1, + Elem: diskEncryptionResourceV0(), + }, + }, + }, + }, + + "target_proximity_placement_group_id": { + Type: pluginsdk.TypeString, + Optional: true, + }, + + "target_boot_diagnostic_storage_account_id": { + Type: pluginsdk.TypeString, + Optional: true, + }, + + "target_capacity_reservation_group_id": { + Type: pluginsdk.TypeString, + Optional: true, + }, + + "target_virtual_machine_scale_set_id": { + Type: pluginsdk.TypeString, + Optional: true, + }, + + "target_virtual_machine_size": { + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + }, + + "network_interface": { + Type: pluginsdk.TypeSet, + Optional: true, + Computed: true, + ConfigMode: pluginsdk.SchemaConfigModeAttr, + Elem: networkInterfaceResourceV0(), + }, + } +} + +func networkInterfaceResourceV0() *pluginsdk.Resource { + return &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "source_network_interface_id": { + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + }, + "failover_test_static_ip": { + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + }, + "target_static_ip": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "failover_test_subnet_name": { + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + }, + "target_subnet_name": { + Type: pluginsdk.TypeString, + Optional: true, + }, + "failover_test_public_ip_address_id": { + Type: pluginsdk.TypeString, + Optional: true, + Computed: true, + }, + "recovery_load_balancer_backend_address_pool_ids": { + Type: pluginsdk.TypeSet, + Optional: true, + Elem: &pluginsdk.Schema{ + Type: pluginsdk.TypeString, + }, + }, + "recovery_public_ip_address_id": { + Type: pluginsdk.TypeString, + Optional: true, + }, + }, + } +} + +func diskEncryptionResourceV0() *pluginsdk.Resource { + return &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "disk_encryption_key": { + Type: pluginsdk.TypeList, + Required: true, + MaxItems: 1, + ConfigMode: pluginsdk.SchemaConfigModeAttr, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "secret_url": { + Type: pluginsdk.TypeString, + Required: true, + }, + "vault_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + }, + }, + }, + "key_encryption_key": { + Type: pluginsdk.TypeList, + Optional: true, + MaxItems: 1, + ConfigMode: pluginsdk.SchemaConfigModeAttr, + Elem: &pluginsdk.Resource{ + Schema: map[string]*pluginsdk.Schema{ + "key_url": { + Type: pluginsdk.TypeString, + Required: true, + }, + "vault_id": { + Type: pluginsdk.TypeString, + Required: true, + }, + }, + }, + }, + }, + } +} diff --git a/internal/services/recoveryservices/migration/site_recovery_replicated_vm_test.go b/internal/services/recoveryservices/migration/site_recovery_replicated_vm_test.go new file mode 100644 index 000000000000..36e96de1e6d8 --- /dev/null +++ b/internal/services/recoveryservices/migration/site_recovery_replicated_vm_test.go @@ -0,0 +1,61 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package migration + +import ( + "context" + "reflect" + "testing" + + "github.com/hashicorp/terraform-provider-azurerm/internal/tf/pluginsdk" +) + +func TestSiteRecoveryReplicatedVMV0ToV1(t *testing.T) { + // `managed_disk` switched from a TypeSet to a TypeList. Both are persisted as a JSON array, so the + // upgrade is a no-op on the value itself - the version bump is what re-interprets it as a list. The + // upgrade must preserve the disks (order and contents) so Read can re-align them to config order. + input := map[string]interface{}{ + "name": "repl-1", + "managed_disk": []interface{}{ + map[string]interface{}{ + "disk_id": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Compute/disks/disk1", + "staging_storage_account_id": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa1", + "target_resource_group_id": "/subscriptions/s/resourceGroups/rg", + "target_disk_type": "Premium_LRS", + "target_replica_disk_type": "Premium_LRS", + }, + map[string]interface{}{ + "disk_id": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Compute/disks/disk2", + "staging_storage_account_id": "/subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/storageAccounts/sa1", + "target_resource_group_id": "/subscriptions/s/resourceGroups/rg", + "target_disk_type": "StandardSSD_LRS", + "target_replica_disk_type": "StandardSSD_LRS", + }, + }, + } + + expected := map[string]interface{}{ + "name": "repl-1", + "managed_disk": input["managed_disk"], + } + + got, err := SiteRecoveryReplicatedVMV0ToV1{}.UpgradeFunc()(context.Background(), input, nil) + if err != nil { + t.Fatalf("upgrading state: %v", err) + } + + if !reflect.DeepEqual(got, expected) { + t.Fatalf("unexpected upgraded state:\n got: %#v\nwant: %#v", got, expected) + } +} + +func TestSiteRecoveryReplicatedVMV0SchemaManagedDiskIsSet(t *testing.T) { + managedDisk, ok := SiteRecoveryReplicatedVMV0ToV1{}.Schema()["managed_disk"] + if !ok { + t.Fatal("expected managed_disk in the v0 schema") + } + if managedDisk.Type != pluginsdk.TypeSet { + t.Fatalf("v0 managed_disk must be a TypeSet, got %v", managedDisk.Type) + } +} diff --git a/internal/services/recoveryservices/site_recovery_replicated_vm_resource.go b/internal/services/recoveryservices/site_recovery_replicated_vm_resource.go index 543d9bd15f74..277c9c845030 100644 --- a/internal/services/recoveryservices/site_recovery_replicated_vm_resource.go +++ b/internal/services/recoveryservices/site_recovery_replicated_vm_resource.go @@ -4,10 +4,10 @@ package recoveryservices import ( - "bytes" "context" "fmt" "log" + "sort" "strings" "time" @@ -26,12 +26,15 @@ import ( "github.com/hashicorp/go-azure-sdk/resource-manager/recoveryservicessiterecovery/2024-04-01/replicationpolicies" "github.com/hashicorp/go-azure-sdk/resource-manager/recoveryservicessiterecovery/2024-04-01/replicationprotecteditems" "github.com/hashicorp/go-azure-sdk/resource-manager/recoveryservicessiterecovery/2024-04-01/replicationprotectioncontainers" + "github.com/hashicorp/go-azure-sdk/sdk/client/pollers" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-provider-azurerm/helpers/azure" "github.com/hashicorp/terraform-provider-azurerm/helpers/tf" "github.com/hashicorp/terraform-provider-azurerm/internal/clients" "github.com/hashicorp/terraform-provider-azurerm/internal/features" "github.com/hashicorp/terraform-provider-azurerm/internal/sdk" + "github.com/hashicorp/terraform-provider-azurerm/internal/services/recoveryservices/custompollers" + "github.com/hashicorp/terraform-provider-azurerm/internal/services/recoveryservices/migration" "github.com/hashicorp/terraform-provider-azurerm/internal/services/recoveryservices/parse" "github.com/hashicorp/terraform-provider-azurerm/internal/services/recoveryservices/validate" resourceParse "github.com/hashicorp/terraform-provider-azurerm/internal/services/resource/parse" @@ -44,15 +47,21 @@ import ( func resourceSiteRecoveryReplicatedVM() *pluginsdk.Resource { return &pluginsdk.Resource{ - Create: resourceSiteRecoveryReplicatedItemCreate, - Read: resourceSiteRecoveryReplicatedItemRead, - Update: resourceSiteRecoveryReplicatedItemUpdate, - Delete: resourceSiteRecoveryReplicatedItemDelete, + Create: resourceSiteRecoveryReplicatedItemCreate, + Read: resourceSiteRecoveryReplicatedItemRead, + Update: resourceSiteRecoveryReplicatedItemUpdate, + Delete: resourceSiteRecoveryReplicatedItemDelete, + CustomizeDiff: pluginsdk.CustomizeDiffShim(resourceSiteRecoveryReplicatedVMCustomizeDiff), Importer: pluginsdk.ImporterValidatingResourceId(func(id string) error { _, err := parse.ReplicationProtectedItemID(id) return err }), + SchemaVersion: 1, + StateUpgraders: pluginsdk.StateUpgrades(map[int]pluginsdk.StateUpgrade{ + 0: migration.SiteRecoveryReplicatedVMV0ToV1{}, + }), + Timeouts: &pluginsdk.ResourceTimeout{ Create: pluginsdk.DefaultTimeout(180 * time.Minute), Read: pluginsdk.DefaultTimeout(5 * time.Minute), @@ -195,54 +204,54 @@ func resourceSiteRecoveryReplicatedVM() *pluginsdk.Resource { }, "managed_disk": { - Type: pluginsdk.TypeSet, + Type: pluginsdk.TypeList, Optional: true, Computed: true, ConfigMode: pluginsdk.SchemaConfigModeAttr, - ForceNew: true, - Set: resourceSiteRecoveryReplicatedVMDiskHash, Elem: &pluginsdk.Resource{ Schema: map[string]*pluginsdk.Schema{ "disk_id": { Type: pluginsdk.TypeString, Required: true, - ForceNew: true, ValidateFunc: validation.StringIsNotEmpty, + // The service returns IDs with inconsistent casing. + DiffSuppressFunc: suppress.CaseDifference, }, "staging_storage_account_id": { Type: pluginsdk.TypeString, Required: true, - ForceNew: true, ValidateFunc: commonids.ValidateStorageAccountID, + // The service returns IDs with inconsistent casing. + DiffSuppressFunc: suppress.CaseDifference, }, "target_resource_group_id": { Type: pluginsdk.TypeString, Required: true, - ForceNew: true, ValidateFunc: commonids.ValidateResourceGroupID, + // The service returns IDs with inconsistent casing. + DiffSuppressFunc: suppress.CaseDifference, }, "target_disk_type": { Type: pluginsdk.TypeString, Required: true, - ForceNew: true, ValidateFunc: validation.StringInSlice(replicationprotecteditems.PossibleValuesForDiskAccountType(), false), }, "target_replica_disk_type": { Type: pluginsdk.TypeString, Required: true, - ForceNew: true, ValidateFunc: validation.StringInSlice(replicationprotecteditems.PossibleValuesForDiskAccountType(), false), }, "target_disk_encryption_set_id": { Type: pluginsdk.TypeString, Optional: true, - ForceNew: true, ValidateFunc: commonids.ValidateDiskEncryptionSetID, + // The service returns IDs with inconsistent casing. + DiffSuppressFunc: suppress.CaseDifference, }, "target_disk_encryption": { @@ -417,6 +426,71 @@ func diskEncryptionResource() *pluginsdk.Resource { return args } +func resourceSiteRecoveryReplicatedVMCustomizeDiff(_ context.Context, d *pluginsdk.ResourceDiff, _ interface{}) error { + if !d.HasChange("managed_disk") { + return nil + } + + oldRaw, newRaw := d.GetChange("managed_disk") + oldDisks, ok := oldRaw.([]interface{}) + + if !ok || len(oldDisks) == 0 { + return nil + } + + newDisks, ok := newRaw.([]interface{}) + if !ok { + return nil + } + + // Changing a disk's identity (the disk itself, its staging account, target resource group or disk + // encryption set) requires the protected item to be recreated, whereas `target_disk_type` and + // `target_replica_disk_type` are migrated in place. Disks are matched by `disk_id` so that merely + // re-ordering the configured disks does not force a new resource. + if siteRecoveryReplicatedVMManagedDiskChangeRequiresNew(oldDisks, newDisks) { + return d.ForceNew("managed_disk") + } + + return nil +} + +func siteRecoveryReplicatedVMManagedDiskChangeRequiresNew(oldDisks, newDisks []interface{}) bool { + if len(oldDisks) != len(newDisks) { + return true + } + + newDisksById := make(map[string]map[string]interface{}, len(newDisks)) + for _, raw := range newDisks { + disk := raw.(map[string]interface{}) + newDisksById[strings.ToLower(siteRecoveryReplicatedVMManagedDiskValue(disk, "disk_id"))] = disk + } + + for _, raw := range oldDisks { + oldDisk := raw.(map[string]interface{}) + diskId := strings.ToLower(siteRecoveryReplicatedVMManagedDiskValue(oldDisk, "disk_id")) + newDisk, ok := newDisksById[diskId] + if !ok { + return true + } + + for _, field := range []string{"staging_storage_account_id", "target_resource_group_id", "target_disk_encryption_set_id"} { + if !strings.EqualFold(siteRecoveryReplicatedVMManagedDiskValue(oldDisk, field), siteRecoveryReplicatedVMManagedDiskValue(newDisk, field)) { + return true + } + } + } + + return false +} + +func siteRecoveryReplicatedVMManagedDiskValue(disk map[string]interface{}, field string) string { + if value, ok := disk[field].(string); ok { + return value + } + + return "" +} + func resourceSiteRecoveryReplicatedItemCreate(d *pluginsdk.ResourceData, meta interface{}) error { subscriptionId := meta.(*clients.Client).Account.SubscriptionId resGroup := d.Get("resource_group_name").(string) @@ -463,7 +537,7 @@ func resourceSiteRecoveryReplicatedItemCreate(d *pluginsdk.ResourceData, meta in } } - managedDisksGet := d.Get("managed_disk").(*pluginsdk.Set).List() + managedDisksGet := d.Get("managed_disk").([]interface{}) managedDisks := make([]replicationprotecteditems.A2AVMManagedDiskInputDetails, 0, len(managedDisksGet)) for _, raw := range managedDisksGet { diskInput := raw.(map[string]interface{}) @@ -593,7 +667,7 @@ func resourceSiteRecoveryReplicatedItemUpdateInternal(ctx context.Context, d *pl }) } - managedDisksGet := d.Get("managed_disk").(*pluginsdk.Set).List() + managedDisksGet := d.Get("managed_disk").([]interface{}) managedDisks := make([]replicationprotecteditems.A2AVMManagedDiskUpdateDetails, 0, len(managedDisksGet)) for _, raw := range managedDisksGet { diskInput := raw.(map[string]interface{}) @@ -649,14 +723,53 @@ func resourceSiteRecoveryReplicatedItemUpdateInternal(ctx context.Context, d *pl }, } + var diskTypeTargets []custompollers.SiteRecoveryReplicatedVMDiskTypeUpdate + if d.HasChange("managed_disk") { + diskTypeTargets = siteRecoveryReplicatedVMManagedDiskTypeUpdateTargets(managedDisks) + } + err = client.UpdateThenPoll(ctx, id, parameters) if err != nil { return fmt.Errorf("updating replicated vm %s (vault %s): %+v", name, vaultName, err) } + if len(diskTypeTargets) > 0 { + diskTypePoller := custompollers.NewSiteRecoveryReplicatedVMDiskTypePoller(client, id, diskTypeTargets) + poller := pollers.NewPoller(diskTypePoller, 0, pollers.DefaultNumberOfDroppedConnectionsToAllow) + if err = poller.PollUntilDone(ctx); err != nil { + return fmt.Errorf("waiting for managed disk type updates for replicated vm %s (vault %s): %+v", name, vaultName, err) + } + } + return resourceSiteRecoveryReplicatedItemRead(d, meta) } +func siteRecoveryReplicatedVMManagedDiskTypeUpdateTargets(managedDisks []replicationprotecteditems.A2AVMManagedDiskUpdateDetails) []custompollers.SiteRecoveryReplicatedVMDiskTypeUpdate { + if len(managedDisks) == 0 { + return nil + } + + targets := make([]custompollers.SiteRecoveryReplicatedVMDiskTypeUpdate, 0, len(managedDisks)) + for _, disk := range managedDisks { + diskId := pointer.From(disk.DiskId) + if diskId == "" { + continue + } + + targets = append(targets, custompollers.SiteRecoveryReplicatedVMDiskTypeUpdate{ + DiskId: diskId, + RecoveryTargetDiskType: pointer.From(disk.RecoveryTargetDiskAccountType), + RecoveryReplicaDiskType: pointer.From(disk.RecoveryReplicaDiskAccountType), + }) + } + + if len(targets) == 0 { + return nil + } + + return targets +} + func findNicId(state *replicationprotecteditems.ReplicationProtectedItem, sourceNicId string) *string { if a2aDetails, isA2a := state.Properties.ProviderSpecificDetails.(replicationprotecteditems.A2AReplicationDetails); isA2a { if a2aDetails.VMNics != nil { @@ -900,7 +1013,9 @@ func resourceSiteRecoveryReplicatedItemRead(d *pluginsdk.ResourceData, meta inte disksOutput = append(disksOutput, diskOutput) } - d.Set("managed_disk", pluginsdk.NewSet(resourceSiteRecoveryReplicatedVMDiskHash, disksOutput)) + // The service does not guarantee the order of the returned disks, so re-order them to match + // the configured / prior-state order to avoid a spurious diff for this `TypeList`. + d.Set("managed_disk", siteRecoveryReplicatedVMOrderManagedDisks(d.Get("managed_disk").([]interface{}), disksOutput)) } if a2aDetails.VMNics != nil { @@ -974,16 +1089,43 @@ func resourceSiteRecoveryReplicatedItemDelete(d *pluginsdk.ResourceData, meta in return nil } -func resourceSiteRecoveryReplicatedVMDiskHash(v interface{}) int { - var buf bytes.Buffer +func siteRecoveryReplicatedVMOrderManagedDisks(configured []interface{}, apiDisks []interface{}) []interface{} { + remaining := make(map[string]interface{}, len(apiDisks)) + for _, raw := range apiDisks { + disk, ok := raw.(map[string]interface{}) + if !ok { + continue + } + remaining[strings.ToLower(siteRecoveryReplicatedVMManagedDiskValue(disk, "disk_id"))] = raw + } + + ordered := make([]interface{}, 0, len(apiDisks)) - if m, ok := v.(map[string]interface{}); ok { - if v, ok := m["disk_id"]; ok { - buf.WriteString(strings.ToLower(v.(string))) + // Emit the disks in the order they appear in the configuration / prior state. + for _, raw := range configured { + disk, ok := raw.(map[string]interface{}) + if !ok { + continue } + diskId := strings.ToLower(siteRecoveryReplicatedVMManagedDiskValue(disk, "disk_id")) + if apiDisk, ok := remaining[diskId]; ok { + ordered = append(ordered, apiDisk) + delete(remaining, diskId) + } + } + + // Append any disks that are not present in the configuration (e.g. on import) in a deterministic + // order so the result is stable. + leftoverIds := make([]string, 0, len(remaining)) + for diskId := range remaining { + leftoverIds = append(leftoverIds, diskId) + } + sort.Strings(leftoverIds) + for _, diskId := range leftoverIds { + ordered = append(ordered, remaining[diskId]) } - return pluginsdk.HashString(buf.String()) + return ordered } func waitForReplicationToBeHealthy(ctx context.Context, d *pluginsdk.ResourceData, meta interface{}) (*replicationprotecteditems.ReplicationProtectedItem, error) { diff --git a/internal/services/recoveryservices/site_recovery_replicated_vm_resource_test.go b/internal/services/recoveryservices/site_recovery_replicated_vm_resource_test.go index 1fdfa204e0fe..c806161b588f 100644 --- a/internal/services/recoveryservices/site_recovery_replicated_vm_resource_test.go +++ b/internal/services/recoveryservices/site_recovery_replicated_vm_resource_test.go @@ -253,6 +253,32 @@ func TestAccSiteRecoveryReplicatedVm_targetVirtualMachineSize(t *testing.T) { }) } +func TestAccSiteRecoveryReplicatedVm_updateDiskType(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_site_recovery_replicated_vm", "test") + r := SiteRecoveryReplicatedVmResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.updateDiskType(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("managed_disk.0.target_disk_type").HasValue("Premium_LRS"), + check.That(data.ResourceName).Key("managed_disk.0.target_replica_disk_type").HasValue("Premium_LRS"), + ), + }, + data.ImportStep(), + { + Config: r.updateDiskTypeUpdated(data), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("managed_disk.0.target_disk_type").HasValue("StandardSSD_LRS"), + check.That(data.ResourceName).Key("managed_disk.0.target_replica_disk_type").HasValue("StandardSSD_LRS"), + ), + }, + data.ImportStep(), + }) +} + func (SiteRecoveryReplicatedVmResource) template(data acceptance.TestData) string { tags := "" if strings.HasPrefix(strings.ToLower(data.Client().SubscriptionID), "85b3dbca") { @@ -2751,6 +2777,72 @@ resource "azurerm_site_recovery_replicated_vm" "test" { `, r.vmSizeTemplate(data, "Standard_B2s"), data.RandomInteger) } +func (r SiteRecoveryReplicatedVmResource) updateDiskType(data acceptance.TestData) string { + return fmt.Sprintf(` +%s + +resource "azurerm_site_recovery_replicated_vm" "test" { + name = "acctest-repl-%[2]d" + resource_group_name = azurerm_resource_group.test2.name + recovery_vault_name = azurerm_recovery_services_vault.test.name + source_vm_id = azurerm_virtual_machine.test.id + source_recovery_fabric_name = azurerm_site_recovery_fabric.test1.name + recovery_replication_policy_id = azurerm_site_recovery_replication_policy.test.id + source_recovery_protection_container_name = azurerm_site_recovery_protection_container.test1.name + + target_resource_group_id = azurerm_resource_group.test2.id + target_recovery_fabric_id = azurerm_site_recovery_fabric.test2.id + target_recovery_protection_container_id = azurerm_site_recovery_protection_container.test2.id + + managed_disk { + disk_id = azurerm_virtual_machine.test.storage_os_disk[0].managed_disk_id + staging_storage_account_id = azurerm_storage_account.test.id + target_resource_group_id = azurerm_resource_group.test2.id + target_disk_type = "Premium_LRS" + target_replica_disk_type = "Premium_LRS" + } + + depends_on = [ + azurerm_site_recovery_protection_container_mapping.test, + azurerm_site_recovery_network_mapping.test, + ] +} +`, r.template(data), data.RandomInteger) +} + +func (r SiteRecoveryReplicatedVmResource) updateDiskTypeUpdated(data acceptance.TestData) string { + return fmt.Sprintf(` +%s + +resource "azurerm_site_recovery_replicated_vm" "test" { + name = "acctest-repl-%[2]d" + resource_group_name = azurerm_resource_group.test2.name + recovery_vault_name = azurerm_recovery_services_vault.test.name + source_vm_id = azurerm_virtual_machine.test.id + source_recovery_fabric_name = azurerm_site_recovery_fabric.test1.name + recovery_replication_policy_id = azurerm_site_recovery_replication_policy.test.id + source_recovery_protection_container_name = azurerm_site_recovery_protection_container.test1.name + + target_resource_group_id = azurerm_resource_group.test2.id + target_recovery_fabric_id = azurerm_site_recovery_fabric.test2.id + target_recovery_protection_container_id = azurerm_site_recovery_protection_container.test2.id + + managed_disk { + disk_id = azurerm_virtual_machine.test.storage_os_disk[0].managed_disk_id + staging_storage_account_id = azurerm_storage_account.test.id + target_resource_group_id = azurerm_resource_group.test2.id + target_disk_type = "StandardSSD_LRS" + target_replica_disk_type = "StandardSSD_LRS" + } + + depends_on = [ + azurerm_site_recovery_protection_container_mapping.test, + azurerm_site_recovery_network_mapping.test, + ] +} +`, r.template(data), data.RandomInteger) +} + func (r SiteRecoveryReplicatedVmResource) Exists(ctx context.Context, clients *clients.Client, state *pluginsdk.InstanceState) (*bool, error) { id, err := replicationprotecteditems.ParseReplicationProtectedItemID(state.ID) if err != nil { diff --git a/internal/tools/document-lint/check/skip_config.go b/internal/tools/document-lint/check/skip_config.go index 205cdcfaaad0..e2797c98de09 100644 --- a/internal/tools/document-lint/check/skip_config.go +++ b/internal/tools/document-lint/check/skip_config.go @@ -30,6 +30,9 @@ var skipProps = []string{ "/azurerm_orchestrated_virtual_machine_scale_set.os_profile.(windows|linux)_configuration.secret.certificate", "/azurerm_eventgrid_event_subscription.advanced_filter.*", "/azurerm_eventgrid_system_topic_event_subscription.advanced_filter.*", + + // managed_disk ForceNew is enforced in CustomizeDiff, not the schema flag + "/azurerm_site_recovery_replicated_vm.managed_disk.(disk_id|staging_storage_account_id|target_resource_group_id|target_disk_encryption_set_id)", } var skipConfig = &struct { diff --git a/website/docs/r/site_recovery_replicated_vm.html.markdown b/website/docs/r/site_recovery_replicated_vm.html.markdown index eefbf8e5f209..665f425bb05b 100644 --- a/website/docs/r/site_recovery_replicated_vm.html.markdown +++ b/website/docs/r/site_recovery_replicated_vm.html.markdown @@ -246,7 +246,7 @@ The following arguments are supported: * `target_zone` - (Optional) Specifies the Availability Zone where the Failover VM should exist. Changing this forces a new resource to be created. -* `managed_disk` - (Optional) One or more `managed_disk` block as defined below. Changing this forces a new resource to be created. +* `managed_disk` - (Optional) One or more `managed_disk` block as defined below. * `unmanaged_disk` - (Optional) One or more `unmanaged_disk` block as defined below. Changing this forces a new resource to be created. @@ -280,9 +280,9 @@ A `managed_disk` block supports the following: * `target_resource_group_id` - (Required) Resource group disk should belong to when a failover is done. Changing this forces a new resource to be created. -* `target_disk_type` - (Required) What type should the disk be when a failover is done. Possible values are `Standard_LRS`, `Premium_LRS`, `PremiumV2_LRS`, `StandardSSD_LRS`, `UltraSSD_LRS`, `StandardSSD_ZRS` and `Premium_ZRS`. Changing this forces a new resource to be created. +* `target_disk_type` - (Required) What type should the disk be when a failover is done. Possible values are `Standard_LRS`, `Premium_LRS`, `PremiumV2_LRS`, `StandardSSD_LRS`, `UltraSSD_LRS`, `StandardSSD_ZRS` and `Premium_ZRS`. -* `target_replica_disk_type` - (Required) What type should the disk be that holds the replication data. Possible values are `Standard_LRS`, `Premium_LRS`, `PremiumV2_LRS`, `StandardSSD_LRS`, `UltraSSD_LRS`, `StandardSSD_ZRS` and `Premium_ZRS`. Changing this forces a new resource to be created. +* `target_replica_disk_type` - (Required) What type should the disk be that holds the replication data. Possible values are `Standard_LRS`, `Premium_LRS`, `PremiumV2_LRS`, `StandardSSD_LRS`, `UltraSSD_LRS`, `StandardSSD_ZRS` and `Premium_ZRS`. * `target_disk_encryption_set_id` - (Optional) The Disk Encryption Set that the Managed Disk will be associated with. Changing this forces a new resource to be created.