diff --git a/README.md b/README.md index 3a14f13..eb1d59d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,116 @@ -# Azure Recovery Services Vault Terraform Module +# Azure Recovery Services Vault Module + +Creates a Recovery Services Vault and can optionally configure VM backup policies and VM protection. + +## Usage scenarios + +The recovery services vault may be used to protect the following Azure workloads: + +- **Azure Virtual Machines**: Policy-based backup and restore for IaaS VMs. +- **SQL Server in Azure VMs**: Workload-aware database backup for SQL running inside Azure VMs. +- **SAP HANA in Azure VMs**: Workload-aware backup for SAP HANA databases running in Azure VMs. +- **Azure Files**: Share-level backup and restore for Azure file shares. +- **MARS agent workloads**: File/folder and system-state backup from supported Windows servers/clients. +- **MABS / DPM-protected workloads**: Backup streams managed through Azure Backup Server or System Center DPM. + +## Storage modes + +`LocallyRedundant` stores backup data redundantly within a single region. + +`ZoneRedundant` stores backup data across availability zones in the same region. + +`GeoRedundant` replicates backup data to a paired region and enables cross-region restore when `cross_region_restore_enabled` is set to `true`. + +## Protecting Resources + +This module can protect Recovery Services Vault workloads. Supported resource types in module status are listed below. + +Implemented: + +- Azure Virtual Machines (`azurerm_backup_policy_vm`, `azurerm_backup_protected_vm`) + +Not implemented yet: + +- SQL Server in Azure VMs (`azurerm_backup_policy_vm_workload` + protected workload resources) +- SAP HANA in Azure VMs (`azurerm_backup_policy_vm_workload` + protected workload resources) + +### Azure Virtual Machines + +Use `vm_backup_policies` to define one or more VM backup policy profiles, and `protected_vms` to map each VM to a selected policy via `backup_policy_key`. + +For each protected VM, you can optionally set: + +- `include_disk_luns` to include only selected data disks +- `exclude_disk_luns` to exclude selected data disks +- `protection_state` to control protection state (`Protected`, `BackupsSuspended`, `ProtectionStopped`) + +## Module Inputs, Outputs, and Examples + +### Variables + +- `rg_name`: The name of the resource group where the Recovery Services Vault will be created. +- `location`: The Azure region where the Recovery Services Vault will be created. +- `base_name`: Optional base name used to generate a unique vault name when `name` is not set. +- `name`: Optional explicit vault name. If omitted, the module generates a deterministic name from `base_name`. +- `sku`: Vault SKU. Allowed values: `Standard`, `RS0`. +- `storage_mode_type`: Backup storage redundancy type. Allowed values: `GeoRedundant`, `LocallyRedundant`, `ZoneRedundant`. +- `cross_region_restore_enabled`: Enables cross-region restore. Can only be set to `true` when `storage_mode_type = "GeoRedundant"`. +- `soft_delete_enabled`: Enables soft delete in the Recovery Services Vault. +- `public_network_access_enabled`: Enables public network access to the vault. +- `immutability`: Immutability state. Allowed values: `Disabled`, `Locked`, `Unlocked`. +- `identity`: Optional managed identity configuration object: + - `type`: Identity type. Allowed values: `SystemAssigned`, `UserAssigned`, `SystemAssigned, UserAssigned`. + - `identity_ids`: Optional list of user-assigned identity IDs (required when `type` includes `UserAssigned`). +- `tags`: A map of tags to apply to the vault. +- `vm_backup_policies`: Map of VM backup policy definitions. +- `protected_vms`: Map of VMs to protect, including policy mapping via `backup_policy_key`. + +### Outputs + +- `recovery_services_vault_id`: The ID of the created Recovery Services Vault. +- `recovery_services_vault_name`: The name of the created Recovery Services Vault. +- `recovery_services_vault_identity_principal_id`: Principal ID of the assigned managed identity, if configured. +- `vm_backup_policy_ids`: Map of VM backup policy IDs keyed by policy key. +- `protected_vm_backup_ids`: Map of protected VM backup item IDs keyed by protected VM key. + +### Example Usage + +```hcl +module "recovery_services_vault" { + source = "./modules/recovery-services-vault" + + rg_name = azurerm_resource_group.rg.name + location = azurerm_resource_group.rg.location + + base_name = "rsv" + + storage_mode_type = "LocallyRedundant" + + vm_backup_policies = { + default = { + backup = { + frequency = "Daily" + time = "23:00" + } + retention_daily = { + count = 30 + } + } + } + + protected_vms = { + app = { + source_vm_id = azurerm_linux_virtual_machine.app.id + backup_policy_key = "default" + } + } +} +``` + +## References + +- [Recovery Services vaults overview](https://learn.microsoft.com/azure/backup/backup-azure-recovery-services-vault-overview) +- [Back up Azure VMs in a Recovery Services vault](https://learn.microsoft.com/azure/backup/backup-azure-arm-vms-prepare) +- [Azure Backup FAQ: vault support matrix](https://learn.microsoft.com/azure/backup/backup-azure-backup-faq#what-are-the-various-vaults-supported-for-backup-and-restore) diff --git a/main.tf b/main.tf new file mode 100644 index 0000000..a4e24e7 --- /dev/null +++ b/main.tf @@ -0,0 +1,146 @@ +data "azurerm_client_config" "current" {} + +locals { + recovery_services_vault_name = ( + var.name != null && + trimspace(var.name) != "" ? + var.name : + "${coalesce(var.base_name, "")}${substr(md5("${data.azurerm_client_config.current.subscription_id}/${var.rg_name}/${coalesce(var.base_name, "")}"), 0, 6)}" + ) + + default_vm_backup_policies = { + default = { + name = "${local.recovery_services_vault_name}-vm-policy" + policy_type = "V2" + timezone = "UTC" + instant_restore_retention_days = 5 + backup = { + frequency = "Daily" + time = "23:00" + } + retention_daily = { + count = 30 + } + retention_weekly = null + retention_monthly = null + retention_yearly = null + } + } + + effective_vm_backup_policies = length(var.vm_backup_policies) > 0 ? { + for key, policy in var.vm_backup_policies : key => { + name = coalesce(try(policy.name, null), "${local.recovery_services_vault_name}-${key}-vm-policy") + policy_type = coalesce(try(policy.policy_type, null), "V2") + timezone = coalesce(try(policy.timezone, null), "UTC") + instant_restore_retention_days = try(policy.instant_restore_retention_days, null) + backup = policy.backup + retention_daily = try(policy.retention_daily, null) + retention_weekly = try(policy.retention_weekly, null) + retention_monthly = try(policy.retention_monthly, null) + retention_yearly = try(policy.retention_yearly, null) + } + } : (length(var.protected_vms) > 0 ? local.default_vm_backup_policies : {}) + + default_vm_backup_policy_key = contains(keys(local.effective_vm_backup_policies), "default") ? "default" : ( + length(keys(local.effective_vm_backup_policies)) > 0 ? sort(keys(local.effective_vm_backup_policies))[0] : null + ) +} + +resource "azurerm_recovery_services_vault" "this" { + name = local.recovery_services_vault_name + location = var.location + resource_group_name = var.rg_name + + sku = var.sku + storage_mode_type = var.storage_mode_type + cross_region_restore_enabled = var.cross_region_restore_enabled + soft_delete_enabled = var.soft_delete_enabled + public_network_access_enabled = var.public_network_access_enabled + immutability = var.immutability + + dynamic "identity" { + for_each = var.identity == null ? [] : [var.identity] + + content { + type = identity.value.type + identity_ids = try(identity.value.identity_ids, null) + } + } + + tags = var.tags +} + +resource "azurerm_backup_policy_vm" "this" { + for_each = local.effective_vm_backup_policies + + name = each.value.name + resource_group_name = var.rg_name + recovery_vault_name = azurerm_recovery_services_vault.this.name + + policy_type = each.value.policy_type + timezone = each.value.timezone + instant_restore_retention_days = each.value.instant_restore_retention_days + + backup { + frequency = each.value.backup.frequency + time = each.value.backup.time + hour_interval = try(each.value.backup.hour_interval, null) + hour_duration = try(each.value.backup.hour_duration, null) + weekdays = try(each.value.backup.weekdays, null) + } + + dynamic "retention_daily" { + for_each = try(each.value.retention_daily, null) == null ? [] : [each.value.retention_daily] + + content { + count = retention_daily.value.count + } + } + + dynamic "retention_weekly" { + for_each = try(each.value.retention_weekly, null) == null ? [] : [each.value.retention_weekly] + + content { + count = retention_weekly.value.count + weekdays = retention_weekly.value.weekdays + } + } + + dynamic "retention_monthly" { + for_each = try(each.value.retention_monthly, null) == null ? [] : [each.value.retention_monthly] + + content { + count = retention_monthly.value.count + weekdays = try(retention_monthly.value.weekdays, null) + weeks = try(retention_monthly.value.weeks, null) + days = try(retention_monthly.value.days, null) + include_last_days = try(retention_monthly.value.include_last_days, null) + } + } + + dynamic "retention_yearly" { + for_each = try(each.value.retention_yearly, null) == null ? [] : [each.value.retention_yearly] + + content { + count = retention_yearly.value.count + months = retention_yearly.value.months + weekdays = try(retention_yearly.value.weekdays, null) + weeks = try(retention_yearly.value.weeks, null) + days = try(retention_yearly.value.days, null) + include_last_days = try(retention_yearly.value.include_last_days, null) + } + } +} + +resource "azurerm_backup_protected_vm" "this" { + for_each = var.protected_vms + + resource_group_name = var.rg_name + recovery_vault_name = azurerm_recovery_services_vault.this.name + source_vm_id = each.value.source_vm_id + backup_policy_id = azurerm_backup_policy_vm.this[coalesce(try(each.value.backup_policy_key, null), local.default_vm_backup_policy_key)].id + + include_disk_luns = try(each.value.include_disk_luns, null) + exclude_disk_luns = try(each.value.exclude_disk_luns, null) + protection_state = try(each.value.protection_state, null) +} diff --git a/outputs.tf b/outputs.tf new file mode 100644 index 0000000..58e9bdf --- /dev/null +++ b/outputs.tf @@ -0,0 +1,25 @@ +output "recovery_services_vault_id" { + value = azurerm_recovery_services_vault.this.id +} + +output "recovery_services_vault_name" { + value = azurerm_recovery_services_vault.this.name +} + +output "recovery_services_vault_identity_principal_id" { + value = try(azurerm_recovery_services_vault.this.identity[0].principal_id, null) +} + +output "vm_backup_policy_ids" { + value = { + for key, policy in azurerm_backup_policy_vm.this : + key => policy.id + } +} + +output "protected_vm_backup_ids" { + value = { + for key, item in azurerm_backup_protected_vm.this : + key => item.id + } +} diff --git a/variables.tf b/variables.tf new file mode 100644 index 0000000..3e297f3 --- /dev/null +++ b/variables.tf @@ -0,0 +1,194 @@ +variable "rg_name" { + type = string +} + +variable "location" { + type = string +} + +variable "base_name" { + type = string + default = null +} + +variable "name" { + type = string + default = null + + validation { + condition = ( + (var.name != null && trimspace(var.name) != "") || + (var.base_name != null && trimspace(var.base_name) != "") + ) + error_message = "Provide name or base_name with a non-empty value." + } +} + +variable "sku" { + type = string + default = "Standard" + + validation { + condition = contains(["Standard", "RS0"], var.sku) + error_message = "sku must be one of 'Standard' or 'RS0'." + } +} + +variable "storage_mode_type" { + type = string + default = "LocallyRedundant" + + validation { + condition = contains(["GeoRedundant", "LocallyRedundant", "ZoneRedundant"], var.storage_mode_type) + error_message = "storage_mode_type must be one of 'GeoRedundant', 'LocallyRedundant', or 'ZoneRedundant'." + } +} + +variable "cross_region_restore_enabled" { + type = bool + default = false + + validation { + condition = var.cross_region_restore_enabled == false || var.storage_mode_type == "GeoRedundant" + error_message = "cross_region_restore_enabled can only be true when storage_mode_type is 'GeoRedundant'." + } +} + +variable "soft_delete_enabled" { + type = bool + default = true +} + +variable "public_network_access_enabled" { + type = bool + default = true +} + +variable "immutability" { + type = string + default = "Disabled" + + validation { + condition = contains(["Disabled", "Locked", "Unlocked"], var.immutability) + error_message = "immutability must be one of 'Disabled', 'Locked', or 'Unlocked'." + } +} + +variable "identity" { + type = object({ + type = string + identity_ids = optional(list(string)) + }) + default = null + + validation { + condition = ( + var.identity == null || + contains([ + "SystemAssigned", + "UserAssigned", + "SystemAssigned, UserAssigned", + ], var.identity.type) + ) + error_message = "identity.type must be one of 'SystemAssigned', 'UserAssigned', or 'SystemAssigned, UserAssigned'." + } + + validation { + condition = ( + var.identity == null || + var.identity.type == "SystemAssigned" || + length(try(var.identity.identity_ids, [])) > 0 + ) + error_message = "identity.identity_ids must be provided when identity.type includes 'UserAssigned'." + } +} + +variable "tags" { + type = map(string) + default = {} +} + +variable "vm_backup_policies" { + type = map(object({ + name = optional(string) + policy_type = optional(string) + timezone = optional(string) + instant_restore_retention_days = optional(number) + + backup = object({ + frequency = string + time = string + hour_interval = optional(number) + hour_duration = optional(number) + weekdays = optional(list(string)) + }) + + retention_daily = optional(object({ + count = number + })) + + retention_weekly = optional(object({ + count = number + weekdays = list(string) + })) + + retention_monthly = optional(object({ + count = number + weekdays = optional(list(string)) + weeks = optional(list(string)) + days = optional(list(number)) + include_last_days = optional(bool) + })) + + retention_yearly = optional(object({ + count = number + months = list(string) + weekdays = optional(list(string)) + weeks = optional(list(string)) + days = optional(list(number)) + include_last_days = optional(bool) + })) + })) + default = {} + + validation { + condition = alltrue([ + for policy in values(var.vm_backup_policies) : contains(["V1", "V2"], coalesce(try(policy.policy_type, null), "V2")) + ]) + error_message = "Each vm_backup_policies[*].policy_type must be 'V1' or 'V2' when set." + } +} + +variable "protected_vms" { + type = map(object({ + source_vm_id = string + backup_policy_key = optional(string) + include_disk_luns = optional(list(number)) + exclude_disk_luns = optional(list(number)) + protection_state = optional(string) + })) + default = {} + + validation { + condition = alltrue([ + for vm in values(var.protected_vms) : ( + try(vm.backup_policy_key, null) == null || + contains( + keys(length(var.vm_backup_policies) > 0 ? var.vm_backup_policies : { default = {} }), + vm.backup_policy_key + ) + ) + ]) + error_message = "Each protected_vms[*].backup_policy_key must exist in vm_backup_policies." + } + + validation { + condition = alltrue([ + for vm in values(var.protected_vms) : ( + try(vm.protection_state, null) == null || + contains(["Protected", "BackupsSuspended", "ProtectionStopped"], vm.protection_state) + ) + ]) + error_message = "Each protected_vms[*].protection_state must be one of 'Protected', 'BackupsSuspended', or 'ProtectionStopped' when set." + } +} diff --git a/versions.tf b/versions.tf new file mode 100644 index 0000000..98391a2 --- /dev/null +++ b/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.0.0, < 5.0.0" + } + } + + required_version = ">= 1.0.0" +}