diff --git a/templates/workspaces/base/cleanup_vault.sh b/templates/workspaces/base/cleanup_vault.sh new file mode 100644 index 0000000000..30d7910247 --- /dev/null +++ b/templates/workspaces/base/cleanup_vault.sh @@ -0,0 +1,110 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + +# Uncomment for debugging (will show secrets) +# set -o xtrace + +function usage() { + cat < /dev/null; then + echo "Error: $cmd is not installed." + exit 1 + fi +done + +# Ensure environment variable is set +if [ -z "${AZURE_ENVIRONMENT:-}" ]; then + echo "Error: AZURE_ENVIRONMENT environment variable is not set." + usage +fi + +# Ensure arguments are provided +if [ $# -eq 0 ]; then + usage +fi + +# Parse arguments +while [ "$1" != "" ]; do + case $1 in + --resource-group) + shift + RESOURCE_GROUP=$1 + ;; + --vault-name) + shift + VAULT_NAME=$1 + ;; + *) + echo "Unexpected argument: '$1'" + usage + ;; + esac + + if [[ -z "$2" ]]; then + break + fi + + shift +done + +# Ensure required arguments are set +if [ -z "${RESOURCE_GROUP:-}" ] || [ -z "${VAULT_NAME:-}" ]; then + echo "Error: --resource-group and --vault-name are required." + usage +fi + +# Set Azure Cloud environment +az cloud set --name "$AZURE_ENVIRONMENT" + +# Check if Vault Exists +echo "Checking for Recovery Services Vault: $VAULT_NAME in $RESOURCE_GROUP" +vault_exists=$(az resource show --resource-group "$RESOURCE_GROUP" --name "$VAULT_NAME" --resource-type "Microsoft.RecoveryServices/vaults" --query "id" -o tsv || echo "") + +if [ -z "$vault_exists" ]; then + echo "Vault does not exist or is already deleted. Skipping cleanup." + exit 0 +fi + +# **Disable Soft Delete for the Vault** +echo "Disabling soft delete for Recovery Services Vault..." +az backup vault backup-properties set --resource-group "$RESOURCE_GROUP" --vault-name "$VAULT_NAME" --soft-delete-feature-state Disable || echo "Warning: Unable to disable soft delete." + +# **Verify Soft Delete is Disabled** +soft_delete_status=$(az backup vault backup-properties show --resource-group "$RESOURCE_GROUP" --vault-name "$VAULT_NAME" --query "softDeleteFeatureState" -o tsv) +if [[ "$soft_delete_status" != "Disabled" ]]; then + echo "Error: Soft delete is still enabled. Vault cannot be deleted." + exit 1 +fi + +# **Get all protected backup items (VMs, File Shares, SQL, etc.)** +echo "Fetching all protected backup items..." +protected_items=$(az backup item list --resource-group "$RESOURCE_GROUP" --vault-name "$VAULT_NAME" --query "[].{name:name, containerName:properties.containerName, type:properties.protectedItemType}" -o json) + +# **Disable protection for each registered backup item** +for row in $(echo "$protected_items" | jq -c '.[]'); do + item_name=$(echo "$row" | jq -r '.name') + container_name=$(echo "$row" | jq -r '.containerName') + item_type=$(echo "$row" | jq -r '.type') + + echo "Disabling protection for: $item_name ($item_type)" + az backup protection disable --resource-group "$RESOURCE_GROUP" --vault-name "$VAULT_NAME" --container-name "$container_name" --item-name "$item_name" --delete-backup-data true --yes || echo "Warning: Failed to disable protection for $item_name" +done + + + + diff --git a/templates/workspaces/base/parameters.json b/templates/workspaces/base/parameters.json index f95d146600..3d9896abb0 100755 --- a/templates/workspaces/base/parameters.json +++ b/templates/workspaces/base/parameters.json @@ -159,6 +159,18 @@ "source": { "env": "KEY_STORE_ID" } + }, + { + "name": "enable_backup", + "source": { + "env": "ENABLE_BACKUP" + } + }, + { + "name": "shared_storage_name", + "source": { + "env": "SHARED_STORAGE_NAME" + } } ] } diff --git a/templates/workspaces/base/porter.yaml b/templates/workspaces/base/porter.yaml index ed48af1a9b..572317ecea 100644 --- a/templates/workspaces/base/porter.yaml +++ b/templates/workspaces/base/porter.yaml @@ -1,7 +1,7 @@ --- schemaVersion: 1.0.0 name: tre-workspace-base -version: 1.9.3 +version: 2.0.10 description: "A base Azure TRE workspace" dockerfile: Dockerfile.tmpl registry: azuretre @@ -126,6 +126,31 @@ parameters: type: string default: "GRS" description: "The redundancy option for the storage account in the workspace: GRS (Geo-Redundant Storage) or ZRS (Zone-Redundant Storage)." + - name: enable_backup + type: boolean + default: true + description: "Enable backups for the workspace, including the vm's & shared storage." + - name: shared_storage_name + type: string + default: "vm-shared-storage" + description: "The name of the shared storage used for the workspace VMs" + - name: backup_vault_name + type: string + default: "" + description: "The name of the backup vault to use for backups" + - name: backup_vault_vm_backup_policy_name + type: string + default: "" + description: "The name of the backup policy to use for VM backups" + - name: backup_vault_fileshare_backup_policy_name + type: string + default: "" + description: "The name of the backup policy to use for fileshare backups" + - name: workspace_resource_name_suffix + type: string + default: "" + description: "A suffix to append to the workspace resource names" + outputs: - name: app_role_id_workspace_owner @@ -158,6 +183,22 @@ outputs: applyTo: - install - upgrade + - name: backup_vault_name + type: string + applyTo: + - install + - upgrade + - name: backup_vault_vm_backup_policy_name + type: string + applyTo: + - install + - upgrade + - name: backup_vault_fileshare_backup_policy_name + type: string + applyTo: + - install + - upgrade + mixins: - exec @@ -196,6 +237,10 @@ install: enable_cmk_encryption: ${ bundle.parameters.enable_cmk_encryption } key_store_id: ${ bundle.parameters.key_store_id } storage_account_redundancy: ${ bundle.parameters.storage_account_redundancy } + enable_backup: ${ bundle.parameters.enable_backup } + backup_vault_fileshare_backup_policy_name: ${ bundle.parameters.backup_vault_fileshare_backup_policy_name } + backup_vault_vm_backup_policy_name: ${ bundle.parameters.backup_vault_vm_backup_policy_name } + backup_vault_name: ${ bundle.parameters.backup_vault_name } backendConfig: use_azuread_auth: "true" use_oidc: "true" @@ -210,6 +255,9 @@ install: - name: client_id - name: scope_id - name: sp_id + - name: backup_vault_name + - name: backup_vault_vm_backup_policy_name + - name: backup_vault_fileshare_backup_policy_name upgrade: - terraform: @@ -241,6 +289,10 @@ upgrade: enable_cmk_encryption: ${ bundle.parameters.enable_cmk_encryption } key_store_id: ${ bundle.parameters.key_store_id } storage_account_redundancy: ${ bundle.parameters.storage_account_redundancy } + enable_backup: ${ bundle.parameters.enable_backup } + backup_vault_fileshare_backup_policy_name: ${ bundle.parameters.backup_vault_fileshare_backup_policy_name } + backup_vault_vm_backup_policy_name: ${ bundle.parameters.backup_vault_vm_backup_policy_name } + backup_vault_name: ${ bundle.parameters.backup_vault_name } backendConfig: use_azuread_auth: "true" use_oidc: "true" @@ -255,6 +307,9 @@ upgrade: - name: client_id - name: scope_id - name: sp_id + - name: backup_vault_name + - name: backup_vault_vm_backup_policy_name + - name: backup_vault_fileshare_backup_policy_name - az: description: "Set Azure Cloud Environment" arguments: @@ -281,6 +336,30 @@ upgrade: register-aad-application: '${ bundle.parameters.register_aad_application }' uninstall: + - az: + description: "Set Azure Cloud Environment" + arguments: + - cloud + - set + flags: + name: ${ bundle.parameters.azure_environment } + - az: + description: "Azure Login" + arguments: + - login + flags: + service-principal: "" + username: '${ bundle.credentials.azure_client_id }' + password: '${ bundle.credentials.azure_client_secret }' + tenant: '${ bundle.credentials.azure_tenant_id }' + allow-no-subscriptions: "" + - exec: + description: "Running Recovery Services Vault Cleanup" + command: ./cleanup_vault.sh + flags: + resource-group: '${ bundle.parameters.workspace_resource_name_suffix }' + vault-name: '${ bundle.parameters.backup_vault_name }' + - terraform: description: "Tear down workspace" vars: @@ -309,6 +388,10 @@ uninstall: enable_cmk_encryption: ${ bundle.parameters.enable_cmk_encryption } key_store_id: ${ bundle.parameters.key_store_id } storage_account_redundancy: ${ bundle.parameters.storage_account_redundancy } + enable_backup: ${ bundle.parameters.enable_backup } + backup_vault_fileshare_backup_policy_name: ${ bundle.parameters.backup_vault_fileshare_backup_policy_name } + backup_vault_vm_backup_policy_name: ${ bundle.parameters.backup_vault_vm_backup_policy_name } + backup_vault_name: ${ bundle.parameters.backup_vault_name } backendConfig: use_azuread_auth: "true" use_oidc: "true" @@ -316,3 +399,13 @@ uninstall: storage_account_name: ${ bundle.parameters.tfstate_storage_account_name } container_name: ${ bundle.parameters.tfstate_container_name } key: ${ bundle.parameters.tre_id }-ws-${ bundle.parameters.id } + outputs: + - name: app_role_id_workspace_owner + - name: app_role_id_workspace_researcher + - name: app_role_id_workspace_airlock_manager + - name: client_id + - name: scope_id + - name: sp_id + - name: backup_vault_name + - name: backup_vault_vm_backup_policy_name + - name: backup_vault_fileshare_backup_policy_name diff --git a/templates/workspaces/base/template_schema.json b/templates/workspaces/base/template_schema.json index 24cec47f34..bee7c282c7 100644 --- a/templates/workspaces/base/template_schema.json +++ b/templates/workspaces/base/template_schema.json @@ -73,6 +73,13 @@ "Manual" ], "updateable": true + }, + "enable_backup": { + "type": "boolean", + "title": "Enable Backup", + "description": "Enable backups for the workspace, including the vm's & shared storage.", + "default": true, + "updateable": true } }, "allOf": [ @@ -304,6 +311,7 @@ "create_aad_groups", "client_id", "client_secret", + "enable_backup", "enable_airlock", "configure_review_vms", "airlock_review_config", diff --git a/templates/workspaces/base/terraform/aad/aad.tf b/templates/workspaces/base/terraform/aad/aad.tf index 031f32b5a0..52005f432c 100644 --- a/templates/workspaces/base/terraform/aad/aad.tf +++ b/templates/workspaces/base/terraform/aad/aad.tf @@ -103,6 +103,7 @@ resource "azuread_service_principal" "workspace" { resource "azuread_service_principal_password" "workspace" { service_principal_id = azuread_service_principal.workspace.object_id + } resource "azurerm_key_vault_secret" "client_id" { diff --git a/templates/workspaces/base/terraform/api-permissions.tf b/templates/workspaces/base/terraform/api-permissions.tf index ee4f5ee6b8..1d5c427759 100644 --- a/templates/workspaces/base/terraform/api-permissions.tf +++ b/templates/workspaces/base/terraform/api-permissions.tf @@ -20,3 +20,16 @@ resource "azurerm_role_assignment" "api_reader" { role_definition_name = "Reader" principal_id = data.azurerm_user_assigned_identity.api_id.principal_id } + +# adds the needed permissions to the API to manage the backup and site recovery +resource "azurerm_role_assignment" "backup_contributor" { + scope = azurerm_resource_group.ws.id + role_definition_name = "Backup Contributor" + principal_id = data.azurerm_user_assigned_identity.api_id.principal_id +} + +resource "azurerm_role_assignment" "site_recover_contributor" { + scope = azurerm_resource_group.ws.id + role_definition_name = "Site Recovery Contributor" + principal_id = data.azurerm_user_assigned_identity.api_id.principal_id +} diff --git a/templates/workspaces/base/terraform/backup/backup.tf b/templates/workspaces/base/terraform/backup/backup.tf new file mode 100644 index 0000000000..04e3872f7e --- /dev/null +++ b/templates/workspaces/base/terraform/backup/backup.tf @@ -0,0 +1,136 @@ + +resource "azurerm_recovery_services_vault" "vault" { + name = local.vault_name + location = var.location + resource_group_name = var.resource_group_name + sku = "Standard" + soft_delete_enabled = true + storage_mode_type = "ZoneRedundant" # Possible values are "GeoRedundant", "LocallyRedundant" and "ZoneRedundant". Defaults to "GeoRedundant". + tags = var.tre_workspace_tags + + dynamic "identity" { + for_each = var.enable_cmk_encryption ? [1] : [] + content { + type = "UserAssigned" + identity_ids = [azurerm_user_assigned_identity.encryption_identity[0].id] + } + } + + dynamic "encryption" { + for_each = var.enable_cmk_encryption ? [1] : [] + content{ + key_id = azurerm_key_vault_key.encryption_key[0].versionless_id + infrastructure_encryption_enabled = true + user_assigned_identity_id = azurerm_user_assigned_identity.encryption_identity[0].id + use_system_assigned_identity = false + } + } + + lifecycle { ignore_changes = [encryption, tags] } + +} + +resource "azurerm_backup_policy_vm" "vm_policy" { + name = local.vm_backup_policy_name + resource_group_name = var.resource_group_name + recovery_vault_name = azurerm_recovery_services_vault.vault.name + + + timezone = "UTC" + + backup { + frequency = "Daily" + time = "22:00" + } + + retention_daily { + count = 14 + } + + retention_weekly { + count = 4 + weekdays = ["Sunday"] + } + + retention_monthly { + count = 12 + weekdays = ["Monday"] + weeks = ["First"] + } + + retention_yearly { + count = 2 + months = ["December"] + weekdays = ["Sunday"] + weeks = ["Last"] + } + + depends_on = [ + azurerm_recovery_services_vault.vault + ] + + +} + +resource "azurerm_backup_policy_file_share" "file_share_policy" { + name = local.fs_backup_policy_name + resource_group_name = var.resource_group_name + recovery_vault_name = azurerm_recovery_services_vault.vault.name + + timezone = "UTC" + + backup { + frequency = "Daily" + time = "23:00" + } + + retention_daily { + count = 14 + } + + retention_weekly { + count = 4 + weekdays = ["Sunday"] + } + + retention_monthly { + count = 12 + weekdays = ["Monday"] + weeks = ["First"] + } + + retention_yearly { + count = 2 + months = ["December"] + weekdays = ["Sunday"] + weeks = ["Last"] + } + + depends_on = [ + azurerm_recovery_services_vault.vault + ] + +} + +resource "azurerm_backup_container_storage_account" "storage_account" { + resource_group_name = var.resource_group_name + recovery_vault_name = azurerm_recovery_services_vault.vault.name + storage_account_id = var.azurerm_storage_account_id + + depends_on = [ + azurerm_recovery_services_vault.vault + ] +} + +resource "azurerm_backup_protected_file_share" "file_share" { + resource_group_name = var.resource_group_name + recovery_vault_name = azurerm_recovery_services_vault.vault.name + source_storage_account_id = var.azurerm_storage_account_id + source_file_share_name = var.shared_storage_name + backup_policy_id = azurerm_backup_policy_file_share.file_share_policy.id + + depends_on = [ + azurerm_backup_policy_file_share.file_share_policy, + azurerm_backup_container_storage_account.storage_account + ] +} diff --git a/templates/workspaces/base/terraform/backup/locals.tf b/templates/workspaces/base/terraform/backup/locals.tf new file mode 100644 index 0000000000..54be196867 --- /dev/null +++ b/templates/workspaces/base/terraform/backup/locals.tf @@ -0,0 +1,6 @@ +locals { + short_workspace_id = substr(var.tre_resource_id, -4, -1) + vault_name = "arsv-${var.tre_id}-ws-${local.short_workspace_id}" + vm_backup_policy_name = "abp-vm-${var.tre_id}-ws-${local.short_workspace_id}" + fs_backup_policy_name = "abp-fs-${var.tre_id}-ws-${local.short_workspace_id}" +} \ No newline at end of file diff --git a/templates/workspaces/base/terraform/backup/outputs.tf b/templates/workspaces/base/terraform/backup/outputs.tf new file mode 100644 index 0000000000..d9f78ca882 --- /dev/null +++ b/templates/workspaces/base/terraform/backup/outputs.tf @@ -0,0 +1,11 @@ +output "backup_vault_name" { + value = azurerm_recovery_services_vault.vault.name +} + +output "backup_vault_vm_backup_policy_name" { + value = azurerm_backup_policy_vm.vm_policy.name +} + +output "backup_vault_fileshare_backup_policy_name" { + value = azurerm_backup_policy_file_share.file_share_policy.name +} diff --git a/templates/workspaces/base/terraform/backup/providers.tf b/templates/workspaces/base/terraform/backup/providers.tf new file mode 100644 index 0000000000..e8faff4500 --- /dev/null +++ b/templates/workspaces/base/terraform/backup/providers.tf @@ -0,0 +1,13 @@ +terraform { + # In modules we should only specify the min version + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 3.117.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.1.0" + } + } +} diff --git a/templates/workspaces/base/terraform/backup/variables.tf b/templates/workspaces/base/terraform/backup/variables.tf new file mode 100644 index 0000000000..bd29f28b64 --- /dev/null +++ b/templates/workspaces/base/terraform/backup/variables.tf @@ -0,0 +1,33 @@ +variable "location" { + type = string +} +variable "tre_id" { + type = string +} +variable "resource_group_name" { + type = string +} +variable "resource_group_id" { + type = string +} +variable "enable_local_debugging" { + type = bool +} +variable "tre_workspace_tags" { + type = map(string) +} +variable "arm_environment" { + type = string +} +variable "azurerm_storage_account_id" { + type = string +} +variable "tre_resource_id" { + type = string +} +variable "enable_cmk_encryption" { + type = bool +} +variable "shared_storage_name" { + type = string +} diff --git a/templates/workspaces/base/terraform/outputs.tf b/templates/workspaces/base/terraform/outputs.tf index 40fa8dcd69..691751cbfa 100644 --- a/templates/workspaces/base/terraform/outputs.tf +++ b/templates/workspaces/base/terraform/outputs.tf @@ -29,3 +29,15 @@ output "scope_id" { value = var.register_aad_application ? module.aad[0].scope_id : var.scope_id } +output "backup_vault_name" { + value = var.enable_backup ? module.backup[0].backup_vault_name : var.backup_vault_name +} + +output "backup_vault_vm_backup_policy_name" { + value = var.enable_backup ? module.backup[0].backup_vault_vm_backup_policy_name : var.backup_vault_vm_backup_policy_name +} + +output "backup_vault_fileshare_backup_policy_name" { + value = var.enable_backup ? module.backup[0].backup_vault_fileshare_backup_policy_name : var.backup_vault_fileshare_backup_policy_name +} + diff --git a/templates/workspaces/base/terraform/storage.tf b/templates/workspaces/base/terraform/storage.tf index 7fc6f00a2c..75f34786de 100644 --- a/templates/workspaces/base/terraform/storage.tf +++ b/templates/workspaces/base/terraform/storage.tf @@ -36,7 +36,7 @@ resource "azurerm_storage_account" "stg" { # Using AzAPI as AzureRM uses shared account key for Azure files operations resource "azapi_resource" "shared_storage" { type = "Microsoft.Storage/storageAccounts/fileServices/shares@2023-05-01" - name = "vm-shared-storage" + name = var.shared_storage_name parent_id = "${azurerm_storage_account.stg.id}/fileServices/default" body = jsonencode({ properties = { diff --git a/templates/workspaces/base/terraform/variables.tf b/templates/workspaces/base/terraform/variables.tf index b3e2812785..ed928f5c49 100644 --- a/templates/workspaces/base/terraform/variables.tf +++ b/templates/workspaces/base/terraform/variables.tf @@ -75,6 +75,17 @@ variable "auth_client_secret" { type = string description = "Used to authenticate into the AAD Tenant to create the AAD App" } +variable "enable_backup"{ + type = bool + default = true + description = "Enable backups for the workspace" +} + +variable "shared_storage_name" { + type = string + default = "vm-shared-storage" + description = "Name of the VM Shared Storage" +} # These variables are only passed in if you are not registering an AAD # application as they need passing back out @@ -139,3 +150,21 @@ variable "storage_account_redundancy" { default = "GRS" description = "The redundancy option for the storage account in the workspace: GRS (Geo-Redundant Storage) or ZRS (Zone-Redundant Storage)." } + +variable "backup_vault_name" { + type = string + default = "" + description = "Name of the backup vault" +} + +variable "backup_vault_vm_backup_policy_name" { + type = string + default = "" + description = "Name of the backup policy for VMs" +} + +variable "backup_vault_fileshare_backup_policy_name" { + type = string + default = "" + description = "Name of the backup policy for File Shares" +} diff --git a/templates/workspaces/base/terraform/workspace.tf b/templates/workspaces/base/terraform/workspace.tf index 10fb74c6a7..fe1fec2f33 100644 --- a/templates/workspaces/base/terraform/workspace.tf +++ b/templates/workspaces/base/terraform/workspace.tf @@ -88,3 +88,29 @@ module "azure_monitor" { module.airlock ] } + + +module "backup" { + count = var.enable_backup ? 1 : 0 + source = "./backup" + tre_id = var.tre_id + tre_resource_id = var.tre_resource_id + location = var.location + resource_group_name = azurerm_resource_group.ws.name + resource_group_id = azurerm_resource_group.ws.id + tre_workspace_tags = local.tre_workspace_tags + arm_environment = var.arm_environment + azurerm_storage_account_id = azurerm_storage_account.stg.id + enable_local_debugging = var.enable_local_debugging + enable_cmk_encryption = var.enable_cmk_encryption + shared_storage_name = var.shared_storage_name + + + depends_on = [ + azurerm_storage_account.stg, + azapi_resource.shared_storage, + module.network, + module.airlock, + module.aad + ] +}