From d6cf7699792f28318f72ba00e698bd602480003f Mon Sep 17 00:00:00 2001 From: ziyeqf <51212351+ziyeqf@users.noreply.github.com> Date: Fri, 10 May 2024 01:42:32 +0800 Subject: [PATCH] provider - support for the `recover_soft_deleted_backup_protected_vm` feature (#24157) * `azurerm_backup_protected_vm` - support recover soft deleted vm * update test case * update per comment * update per comments * update per comment * format code * update test case * revert site recovery test file * `recover_soft_deleted_backup_protected_vm` defaults to `false` * fix test --- internal/features/defaults.go | 3 + internal/features/user_flags.go | 5 + internal/provider/features.go | 29 ++- internal/provider/features_test.go | 84 +++++++ .../backup_protected_vm_resource.go | 75 +++++- .../backup_protected_vm_resource_test.go | 227 +++++++++++++++++- .../docs/guides/features-block.html.markdown | 12 + 7 files changed, 421 insertions(+), 14 deletions(-) diff --git a/internal/features/defaults.go b/internal/features/defaults.go index 4bb9b38a5133..8e8e46862181 100644 --- a/internal/features/defaults.go +++ b/internal/features/defaults.go @@ -40,6 +40,9 @@ func Default() UserFeatures { ResourceGroup: ResourceGroupFeatures{ PreventDeletionIfContainsResources: true, }, + RecoveryServicesVault: RecoveryServicesVault{ + RecoverSoftDeletedBackupProtectedVM: true, + }, TemplateDeployment: TemplateDeploymentFeatures{ DeleteNestedItemsDuringDeletion: true, }, diff --git a/internal/features/user_flags.go b/internal/features/user_flags.go index f696f5cfeb6c..52526e07fb04 100644 --- a/internal/features/user_flags.go +++ b/internal/features/user_flags.go @@ -14,6 +14,7 @@ type UserFeatures struct { TemplateDeployment TemplateDeploymentFeatures LogAnalyticsWorkspace LogAnalyticsWorkspaceFeatures ResourceGroup ResourceGroupFeatures + RecoveryServicesVault RecoveryServicesVault ManagedDisk ManagedDiskFeatures Subscription SubscriptionFeatures PostgresqlFlexibleServer PostgresqlFlexibleServerFeatures @@ -84,6 +85,10 @@ type SubscriptionFeatures struct { PreventCancellationOnDestroy bool } +type RecoveryServicesVault struct { + RecoverSoftDeletedBackupProtectedVM bool +} + type PostgresqlFlexibleServerFeatures struct { RestartServerOnConfigurationValueChange bool } diff --git a/internal/provider/features.go b/internal/provider/features.go index 57238f1db26d..ac7f6da7d6ca 100644 --- a/internal/provider/features.go +++ b/internal/provider/features.go @@ -15,7 +15,7 @@ func schemaFeatures(supportLegacyTestSuite bool) *pluginsdk.Schema { // NOTE: if there's only one nested field these want to be Required (since there's no point // specifying the block otherwise) - however for 2+ they should be optional featuresMap := map[string]*pluginsdk.Schema{ - //lintignore:XS003 + // lintignore:XS003 "api_management": { Type: pluginsdk.TypeList, Optional: true, @@ -189,7 +189,7 @@ func schemaFeatures(supportLegacyTestSuite bool) *pluginsdk.Schema { }, }, - //lintignore:XS003 + // lintignore:XS003 "virtual_machine": { Type: pluginsdk.TypeList, Optional: true, @@ -260,6 +260,21 @@ func schemaFeatures(supportLegacyTestSuite bool) *pluginsdk.Schema { }, }, + "recovery_services_vaults": { + Type: pluginsdk.TypeList, + Optional: true, + MaxItems: 1, + Elem: &pluginsdk.Resource{ + Schema: map[string]*schema.Schema{ + "recover_soft_deleted_backup_protected_vm": { + Type: pluginsdk.TypeBool, + Optional: true, + Default: false, + }, + }, + }, + }, + "managed_disk": { Type: pluginsdk.TypeList, Optional: true, @@ -518,6 +533,16 @@ func expandFeatures(input []interface{}) features.UserFeatures { } } + if raw, ok := val["recovery_services_vaults"]; ok { + items := raw.([]interface{}) + if len(items) > 0 && items[0] != nil { + appConfRaw := items[0].(map[string]interface{}) + if v, ok := appConfRaw["recover_soft_deleted_backup_protected_vm"]; ok { + featuresMap.RecoveryServicesVault.RecoverSoftDeletedBackupProtectedVM = v.(bool) + } + } + } + if raw, ok := val["managed_disk"]; ok { items := raw.([]interface{}) if len(items) > 0 { diff --git a/internal/provider/features_test.go b/internal/provider/features_test.go index 3714c590c6c7..416595acf669 100644 --- a/internal/provider/features_test.go +++ b/internal/provider/features_test.go @@ -69,6 +69,9 @@ func TestExpandFeatures(t *testing.T) { ResourceGroup: features.ResourceGroupFeatures{ PreventDeletionIfContainsResources: true, }, + RecoveryServicesVault: features.RecoveryServicesVault{ + RecoverSoftDeletedBackupProtectedVM: true, + }, Subscription: features.SubscriptionFeatures{ PreventCancellationOnDestroy: false, }, @@ -143,6 +146,11 @@ func TestExpandFeatures(t *testing.T) { "prevent_deletion_if_contains_resources": true, }, }, + "recovery_services_vaults": []interface{}{ + map[string]interface{}{ + "recover_soft_deleted_backup_protected_vm": true, + }, + }, "subscription": []interface{}{ map[string]interface{}{ "prevent_cancellation_on_destroy": true, @@ -216,6 +224,9 @@ func TestExpandFeatures(t *testing.T) { ResourceGroup: features.ResourceGroupFeatures{ PreventDeletionIfContainsResources: true, }, + RecoveryServicesVault: features.RecoveryServicesVault{ + RecoverSoftDeletedBackupProtectedVM: true, + }, Subscription: features.SubscriptionFeatures{ PreventCancellationOnDestroy: true, }, @@ -304,6 +315,11 @@ func TestExpandFeatures(t *testing.T) { "prevent_deletion_if_contains_resources": false, }, }, + "recovery_services_vaults": []interface{}{ + map[string]interface{}{ + "recover_soft_deleted_backup_protected_vm": false, + }, + }, "subscription": []interface{}{ map[string]interface{}{ "prevent_cancellation_on_destroy": false, @@ -377,6 +393,9 @@ func TestExpandFeatures(t *testing.T) { ResourceGroup: features.ResourceGroupFeatures{ PreventDeletionIfContainsResources: false, }, + RecoveryServicesVault: features.RecoveryServicesVault{ + RecoverSoftDeletedBackupProtectedVM: false, + }, Subscription: features.SubscriptionFeatures{ PreventCancellationOnDestroy: false, }, @@ -1224,6 +1243,71 @@ func TestExpandFeaturesResourceGroup(t *testing.T) { } } +func TestExpandFeaturesRecoveryServicesVault(t *testing.T) { + testData := []struct { + Name string + Input []interface{} + EnvVars map[string]interface{} + Expected features.UserFeatures + }{ + { + Name: "Empty Block", + Input: []interface{}{ + map[string]interface{}{ + "recovery_services_vaults": []interface{}{}, + }, + }, + Expected: features.UserFeatures{ + RecoveryServicesVault: features.RecoveryServicesVault{ + RecoverSoftDeletedBackupProtectedVM: true, + }, + }, + }, + { + Name: "Recover Soft Deleted Protected VM Enabled", + Input: []interface{}{ + map[string]interface{}{ + "recovery_services_vaults": []interface{}{ + map[string]interface{}{ + "recover_soft_deleted_backup_protected_vm": true, + }, + }, + }, + }, + Expected: features.UserFeatures{ + RecoveryServicesVault: features.RecoveryServicesVault{ + RecoverSoftDeletedBackupProtectedVM: true, + }, + }, + }, + { + Name: "Recover Soft Deleted Protected VM Disabled", + Input: []interface{}{ + map[string]interface{}{ + "recovery_services_vaults": []interface{}{ + map[string]interface{}{ + "recover_soft_deleted_backup_protected_vm": false, + }, + }, + }, + }, + Expected: features.UserFeatures{ + RecoveryServicesVault: features.RecoveryServicesVault{ + RecoverSoftDeletedBackupProtectedVM: false, + }, + }, + }, + } + + for _, testCase := range testData { + t.Logf("[DEBUG] Test Case: %q", testCase.Name) + result := expandFeatures(testCase.Input) + if !reflect.DeepEqual(result.RecoveryServicesVault, testCase.Expected.RecoveryServicesVault) { + t.Fatalf("Expected %+v but got %+v", testCase.Expected.RecoveryServicesVault, result.RecoveryServicesVault) + } + } +} + func TestExpandFeaturesManagedDisk(t *testing.T) { testData := []struct { Name string diff --git a/internal/services/recoveryservices/backup_protected_vm_resource.go b/internal/services/recoveryservices/backup_protected_vm_resource.go index 244ee3b85094..932f6a86f8cb 100644 --- a/internal/services/recoveryservices/backup_protected_vm_resource.go +++ b/internal/services/recoveryservices/backup_protected_vm_resource.go @@ -96,6 +96,7 @@ func resourceRecoveryServicesBackupProtectedVMCreateUpdate(d *pluginsdk.Resource log.Printf("[DEBUG] Creating/updating Azure Backup Protected VM %s (resource group %q)", protectedItemName, resourceGroup) id := protecteditems.NewProtectedItemID(subscriptionId, resourceGroup, vaultName, "Azure", containerName, protectedItemName) + if d.IsNewResource() { existing, err := client.Get(ctx, id, protecteditems.GetOperationOptions{}) if err != nil { @@ -105,8 +106,29 @@ func resourceRecoveryServicesBackupProtectedVMCreateUpdate(d *pluginsdk.Resource } if !response.WasNotFound(existing.HttpResponse) { - return tf.ImportAsExistsError("azurerm_backup_protected_vm", id.ID()) + isSoftDeleted := false + if existing.Model != nil && existing.Model.Properties != nil { + if prop, ok := existing.Model.Properties.(protecteditems.AzureIaaSComputeVMProtectedItem); ok { + isSoftDeleted = pointer.From(prop.IsScheduledForDeferredDelete) + } + } + + if isSoftDeleted { + if meta.(*clients.Client).Features.RecoveryServicesVault.RecoverSoftDeletedBackupProtectedVM { + err = resourceRecoveryServicesVaultBackupProtectedVMRecoverSoftDeleted(ctx, client, opClient, id) + if err != nil { + return fmt.Errorf("recovering soft deleted %s: %+v", id, err) + } + } else { + return fmt.Errorf(optedOutOfRecoveringSoftDeletedBackupProtectedVMFmt(parsedVmId.ID(), vaultName)) + } + } + + if !isSoftDeleted { + return tf.ImportAsExistsError("azurerm_backup_protected_vm", id.ID()) + } } + } item := protecteditems.ProtectedItemResource{ @@ -194,12 +216,14 @@ func resourceRecoveryServicesBackupProtectedVMRead(d *pluginsdk.ResourceData, me return fmt.Errorf("making Read request on %s: %+v", id, err) } - d.Set("resource_group_name", id.ResourceGroupName) - d.Set("recovery_vault_name", id.VaultName) - if model := resp.Model; model != nil { if properties := model.Properties; properties != nil { if vm, ok := properties.(protecteditems.AzureIaaSComputeVMProtectedItem); ok { + if vm.IsScheduledForDeferredDelete != nil && *vm.IsScheduledForDeferredDelete { + d.SetId("") + return nil + } + d.Set("source_vm_id", vm.SourceResourceId) d.Set("protection_state", pointer.From(vm.ProtectionState)) @@ -228,6 +252,9 @@ func resourceRecoveryServicesBackupProtectedVMRead(d *pluginsdk.ResourceData, me } } + d.Set("resource_group_name", id.ResourceGroupName) + d.Set("recovery_vault_name", id.VaultName) + return nil } @@ -461,6 +488,46 @@ func expandDiskLunList(input []interface{}) []interface{} { return result } +func resourceRecoveryServicesVaultBackupProtectedVMRecoverSoftDeleted(ctx context.Context, client *protecteditems.ProtectedItemsClient, opClient *backup.ProtectedItemOperationResultsClient, id protecteditems.ProtectedItemId) (err error) { + resp, err := client.CreateOrUpdate(ctx, id, protecteditems.ProtectedItemResource{ + Properties: &protecteditems.AzureIaaSComputeVMProtectedItem{ + IsRehydrate: pointer.To(true), + }, + }, + ) + if err != nil { + return fmt.Errorf("issuing request for %s: %+v", id, err) + } + + operationId, err := parseBackupOperationId(resp.HttpResponse) + if err != nil { + return err + } + + if err = resourceRecoveryServicesBackupProtectedVMWaitForStateCreateUpdate(ctx, opClient, id, operationId); err != nil { + return err + } + + return nil +} + +func optedOutOfRecoveringSoftDeletedBackupProtectedVMFmt(vmId string, vaultName string) string { + return fmt.Sprintf(` +An existing soft-deleted Backup Protected VM exists with the source VM %q in the recovery services +vault %q, however automatically recovering this Backup Protected VM has been disabled via the +"features" block. + +Terraform can automatically recover the soft-deleted Backup Protected VM when this behaviour is +enabled within the "features" block (located within the "provider" block) - more +information can be found here: + +https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/guides/features-block + +Alternatively you can manually recover this (e.g. using the Azure CLI) and then import +this into Terraform via "terraform import". +`, vmId, vaultName) +} + func resourceRecoveryServicesBackupProtectedVMSchema() map[string]*pluginsdk.Schema { return map[string]*pluginsdk.Schema{ "resource_group_name": commonschema.ResourceGroupName(), diff --git a/internal/services/recoveryservices/backup_protected_vm_resource_test.go b/internal/services/recoveryservices/backup_protected_vm_resource_test.go index 658b66fbffd8..bb284eb71668 100644 --- a/internal/services/recoveryservices/backup_protected_vm_resource_test.go +++ b/internal/services/recoveryservices/backup_protected_vm_resource_test.go @@ -251,6 +251,41 @@ func TestAccBackupProtectedVm_protectionStoppedOnDestroy(t *testing.T) { }) } +func TestAccBackupProtectedVm_recoverSoftDeletedVM(t *testing.T) { + data := acceptance.BuildTestData(t, "azurerm_backup_protected_vm", "test") + r := BackupProtectedVmResource{} + + data.ResourceTest(t, r, []acceptance.TestStep{ + { + Config: r.basicWithSoftDelete(data, false), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("resource_group_name").Exists(), + ), + }, + data.ImportStep(), + { + Config: r.basicWithSoftDelete(data, true), + }, + { + Config: r.basicWithSoftDelete(data, false), + Check: acceptance.ComposeTestCheckFunc( + check.That(data.ResourceName).ExistsInAzure(r), + check.That(data.ResourceName).Key("resource_group_name").Exists(), + ), + }, + data.ImportStep(), + { + // to disable soft delete feature + Config: r.basic(data), + }, + { + // vault cannot be deleted unless we unregister all backups + Config: r.base(data), + }, + }) +} + func (t BackupProtectedVmResource) Exists(ctx context.Context, clients *clients.Client, state *pluginsdk.InstanceState) (*bool, error) { id, err := protecteditems.ParseProtectedItemID(state.ID) if err != nil { @@ -484,6 +519,152 @@ resource "azurerm_backup_policy_vm" "test" { `, data.RandomInteger, data.Locations.Primary, data.RandomInteger, data.RandomString, data.RandomInteger, data.RandomInteger) } +func (BackupProtectedVmResource) baseWithSoftDelete(data acceptance.TestData) string { + return fmt.Sprintf(` +resource "azurerm_resource_group" "test" { + name = "acctestRG-backup-%d" + location = "%s" +} + +resource "azurerm_virtual_network" "test" { + name = "vnet" + location = azurerm_resource_group.test.location + address_space = ["10.0.0.0/16"] + resource_group_name = azurerm_resource_group.test.name +} + +resource "azurerm_subnet" "test" { + name = "acctest_subnet" + virtual_network_name = azurerm_virtual_network.test.name + resource_group_name = azurerm_resource_group.test.name + address_prefixes = ["10.0.10.0/24"] +} + +resource "azurerm_network_interface" "test" { + name = "acctest_nic" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + + ip_configuration { + name = "acctestipconfig" + subnet_id = azurerm_subnet.test.id + private_ip_address_allocation = "Dynamic" + public_ip_address_id = azurerm_public_ip.test.id + } +} + +resource "azurerm_public_ip" "test" { + name = "acctest-ip" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + allocation_method = "Dynamic" + domain_name_label = "acctestip%d" +} + +resource "azurerm_storage_account" "test" { + name = "acctest%s" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + account_tier = "Standard" + account_replication_type = "LRS" +} + +resource "azurerm_managed_disk" "test" { + name = "acctest-datadisk" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + storage_account_type = "Standard_LRS" + create_option = "Empty" + disk_size_gb = "1023" +} + +resource "azurerm_virtual_machine" "test" { + name = "acctestvm" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + vm_size = "Standard_D1_v2" + network_interface_ids = [azurerm_network_interface.test.id] + + delete_os_disk_on_termination = true + delete_data_disks_on_termination = true + + storage_image_reference { + publisher = "Canonical" + offer = "0001-com-ubuntu-server-jammy" + sku = "22_04-lts" + version = "latest" + } + + storage_os_disk { + name = "acctest-osdisk" + managed_disk_type = "Standard_LRS" + caching = "ReadWrite" + create_option = "FromImage" + } + + storage_data_disk { + name = "acctest-datadisk" + managed_disk_id = azurerm_managed_disk.test.id + managed_disk_type = "Standard_LRS" + disk_size_gb = azurerm_managed_disk.test.disk_size_gb + create_option = "Attach" + lun = 0 + } + + storage_data_disk { + name = "acctest-another-datadisk" + create_option = "Empty" + disk_size_gb = "1" + lun = 1 + managed_disk_type = "Standard_LRS" + } + + os_profile { + computer_name = "acctest" + admin_username = "vmadmin" + admin_password = "Password123!@#" + } + + os_profile_linux_config { + disable_password_authentication = false + } + + boot_diagnostics { + enabled = true + storage_uri = azurerm_storage_account.test.primary_blob_endpoint + } + + lifecycle { + ignore_changes = [tags] + } +} + +resource "azurerm_recovery_services_vault" "test" { + name = "acctest-%d" + location = azurerm_resource_group.test.location + resource_group_name = azurerm_resource_group.test.name + sku = "Standard" + + soft_delete_enabled = true +} + +resource "azurerm_backup_policy_vm" "test" { + name = "acctest-%d" + resource_group_name = azurerm_resource_group.test.name + recovery_vault_name = azurerm_recovery_services_vault.test.name + + backup { + frequency = "Daily" + time = "23:00" + } + + retention_daily { + count = 10 + } +} +`, data.RandomInteger, data.Locations.Primary, data.RandomInteger, data.RandomString, data.RandomInteger, data.RandomInteger) +} + func (r BackupProtectedVmResource) basic(data acceptance.TestData) string { return fmt.Sprintf(` provider "azurerm" { @@ -587,6 +768,10 @@ resource "azurerm_managed_disk" "test" { // For update backup policy id test func (r BackupProtectedVmResource) withBasePolicy(data acceptance.TestData) string { return fmt.Sprintf(` +provider "azurerm" { + features {} +} + %s resource "azurerm_backup_policy_vm" "test_change_backup" { @@ -609,10 +794,6 @@ resource "azurerm_backup_policy_vm" "test_change_backup" { // For update backup policy id test func (r BackupProtectedVmResource) linkFirstBackupPolicy(data acceptance.TestData) string { return fmt.Sprintf(` -provider "azurerm" { - features {} -} - %s resource "azurerm_backup_protected_vm" "test" { @@ -627,10 +808,6 @@ resource "azurerm_backup_protected_vm" "test" { // For update backup policy id test func (r BackupProtectedVmResource) linkSecondBackupPolicy(data acceptance.TestData) string { return fmt.Sprintf(` -provider "azurerm" { - features {} -} - %s resource "azurerm_backup_protected_vm" "test" { @@ -749,6 +926,10 @@ resource "azurerm_backup_protected_vm" "test" { func (r BackupProtectedVmResource) protectionStopped(data acceptance.TestData) string { return fmt.Sprintf(` +provider "azurerm" { + features {} +} + %s resource "azurerm_backup_protected_vm" "test" { @@ -776,3 +957,33 @@ provider "azurerm" { %s `, r.base(data)) } + +func (r BackupProtectedVmResource) basicWithSoftDelete(data acceptance.TestData, deleted bool) string { + protectedVMBlock := ` +resource "azurerm_backup_protected_vm" "test" { + resource_group_name = azurerm_resource_group.test.name + recovery_vault_name = azurerm_recovery_services_vault.test.name + source_vm_id = azurerm_virtual_machine.test.id + backup_policy_id = azurerm_backup_policy_vm.test.id + + include_disk_luns = [0] +} +` + if deleted { + protectedVMBlock = "" + } + + return fmt.Sprintf(` +provider "azurerm" { + features { + recovery_services_vaults { + recover_soft_deleted_backup_protected_vm = true + } + } +} + +%s + +%s +`, r.baseWithSoftDelete(data), protectedVMBlock) +} diff --git a/website/docs/guides/features-block.html.markdown b/website/docs/guides/features-block.html.markdown index 2e2ddae26ad9..5abf5a0f80a3 100644 --- a/website/docs/guides/features-block.html.markdown +++ b/website/docs/guides/features-block.html.markdown @@ -75,6 +75,10 @@ provider "azurerm" { prevent_deletion_if_contains_resources = true } + recovery_services_vault { + recover_soft_deleted_backup_protected_vm = true + } + subscription { prevent_cancellation_on_destroy = false } @@ -122,6 +126,8 @@ The `features` block supports the following: * `resource_group` - (Optional) A `resource_group` block as defined below. +* `recovery_services_vault` - (Optional) A `recovery_services_vault` block as defined below. + * `template_deployment` - (Optional) A `template_deployment` block as defined below. * `virtual_machine` - (Optional) A `virtual_machine` block as defined below. @@ -226,6 +232,12 @@ The `resource_group` block supports the following: --- +The `recovery_services_vault` block supports the following: + +* `recover_soft_deleted_backup_protected_vm` - (Optional) Should the `azurerm_backup_protected_vm` resource recover a Soft-Deleted protected VM? Defaults to `false`. + +--- + The `subscription` block supports the following: * `prevent_cancellation_on_destroy` - (Optional) Should the `azurerm_subscription` resource prevent a subscription to be cancelled on destroy? Defaults to `false`.