diff --git a/.azuredevops/pipelines/task_azure_sql_backup_prod.yaml b/.azuredevops/pipelines/task_azure_sql_backup_prod.yaml new file mode 100644 index 0000000000..e771837b38 --- /dev/null +++ b/.azuredevops/pipelines/task_azure_sql_backup_prod.yaml @@ -0,0 +1,53 @@ +--- + +name: $(Build.SourceBranchName)-$(Date:yyyyMMdd)_$(Rev:r) +trigger: none +pr: none + +schedules: + - cron: "0 2 * * *" # Run daily at 2:00 AM UTC + displayName: 'Run Backup' + branches: + include: + - main + always: true + +resources: + repositories: + - repository: dtos-devops-templates + type: github + name: NHSDigital/dtos-devops-templates + ref: 34a7e5c3072bddaa350804905c60b9c8e7ae7191 + endpoint: NHSDigital + +variables: + - name: hostPoolName + value: private-pool-prod-uks + - group: PROD_core_backend + - group: PROD_image_pipelines + - name: TF_VERSION + value: 1.11.4 + - name: TF_PLAN_ARTIFACT + value: tf_plan_core_PROD + - name: TF_DIRECTORY + value: $(System.DefaultWorkingDirectory)/$(System.TeamProject)/infrastructure/tf-core + - name: ENVIRONMENT + value: production + +stages: +- stage: db_backup_stage + displayName: Database backup stage + pool: + name: $(hostPoolName) + jobs: + - job: db_changes + displayName: Create Database Backup and Upload to Storage Account + steps: + - checkout: self + - checkout: dtos-devops-templates + - template: .azuredevops/templates/steps/app-container-job-start.yaml@dtos-devops-templates + parameters: + serviceConnection: $(SERVICE_CONNECTION) + targetSubscriptionId: $(TF_VAR_TARGET_SUBSCRIPTION_ID) + resourceGroupName: $(RESOURCE_GROUP_NAME_SQL) + jobName: ca-db-backup-uksouth diff --git a/infrastructure/tf-audit/environments/production.tfvars b/infrastructure/tf-audit/environments/production.tfvars index 29ab9f4645..b2f5eda837 100644 --- a/infrastructure/tf-audit/environments/production.tfvars +++ b/infrastructure/tf-audit/environments/production.tfvars @@ -72,4 +72,24 @@ storage_accounts = { } } } + sqlbackups = { + name_suffix = "sqlbackups" + account_tier = "Standard" + replication_type = "GRS" + public_network_access_enabled = false + blob_properties_delete_retention_policy = 28 + blob_properties_versioning_enabled = true + containers = { + sql-backups-immutable = { + container_name = "sql-backups-immutable" + container_access_type = "private" + immutability_policy = { + is_locked = false + immutability_period_in_days = 1 + protected_append_writes_all_enabled = false + protected_append_writes_enabled = false + } + } + } + } } diff --git a/infrastructure/tf-audit/environments/sandbox.tfvars b/infrastructure/tf-audit/environments/sandbox.tfvars index d3c96b4ec4..52ec879197 100644 --- a/infrastructure/tf-audit/environments/sandbox.tfvars +++ b/infrastructure/tf-audit/environments/sandbox.tfvars @@ -1,6 +1,6 @@ application = "cohman" application_full_name = "cohort-manager" -environment = "SBX" +environment = "SBRK" tags = { Environment = "sandbox" @@ -17,7 +17,7 @@ features = { regions = { uksouth = { is_primary_region = true - address_space = "10.127.0.0/16" + address_space = "10.129.0.0/16" connect_peering = true subnets = { pep = { @@ -52,7 +52,16 @@ storage_accounts = { container_name = "vulnerability-assessment" container_access_type = "private" } + sql-backups-immutable = { + container_name = "sql-backups-immutable" + container_access_type = "private" + immutability_policy = { + is_locked = false + immutability_period_in_days = 1 + protected_append_writes_all_enabled = false + protected_append_writes_enabled = false + } + } } - } } diff --git a/infrastructure/tf-audit/variables.tf b/infrastructure/tf-audit/variables.tf index 9f4a3e42ee..337fd31278 100644 --- a/infrastructure/tf-audit/variables.tf +++ b/infrastructure/tf-audit/variables.tf @@ -113,6 +113,12 @@ variable "storage_accounts" { containers = optional(map(object({ container_name = string container_access_type = optional(string, "private") + immutability_policy = optional(object({ + is_locked = optional(bool, false) + immutability_period_in_days = optional(number, 0) + protected_append_writes_all_enabled = optional(bool, false) + protected_append_writes_enabled = optional(bool, false) + }), null) })), {}) })) } diff --git a/infrastructure/tf-core/container_app_job.tf b/infrastructure/tf-core/container_app_job.tf index 1ca814f172..fd71ca7f47 100644 --- a/infrastructure/tf-core/container_app_job.tf +++ b/infrastructure/tf-core/container_app_job.tf @@ -8,7 +8,24 @@ locals { region = region # 1st iterator container_app_job = container_app_job # 2nd iterator }, - config # the rest of the key/value pairs for a specific container_app_job + config, # the rest of the key/value pairs for a specific container_app_job + { + env_vars = merge( + # Add environment variables defined specifically for this container app job: + config.env_vars_static, + + # Add in the database connection string if the name of the variable is provided: + config.add_user_assigned_identity != null && length(config.db_connection_string_name) > 0 ? { + (config.db_connection_string_name) = "Server=tcp:${module.regions_config[region].names.sql-server}.database.windows.net,1433;Initial Catalog=${var.sqlserver.dbs.cohman.db_name_suffix};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;Authentication='Active Directory Managed Identity';User ID=${module.user_assigned_managed_identity_sql["${container_app_job}-${region}"].client_id};" + } : {}, + + # Add in the MANAGED_IDENTITY_CLIENT_ID environment variable if using a user assigned managed identity: + config.add_user_assigned_identity != null ? { + "MANAGED_IDENTITY_CLIENT_ID" = "${module.user_assigned_managed_identity_sql["${container_app_job}-${region}"].client_id}", + "TARGET_SUBSCRIPTION_ID" = var.TARGET_SUBSCRIPTION_ID + } : {} + ) + } ) ] ]) @@ -29,17 +46,16 @@ module "container-app-job" { location = each.value.region container_app_environment_id = module.container-app-environment["${each.value.container_app_environment_key}-${each.value.region}"].id - user_assigned_identity_ids = [module.managed_identity_sql_db_management[each.value.region].id] + user_assigned_identity_ids = each.value.add_user_assigned_identity ? [module.user_assigned_managed_identity_sql["${each.key}"].id] : [] acr_login_server = data.azurerm_container_registry.acr.login_server acr_managed_identity_id = each.value.container_registry_use_mi ? data.azurerm_user_assigned_identity.acr_mi.id : null docker_image = "${data.azurerm_container_registry.acr.login_server}/${each.value.docker_image}:${each.value.docker_env_tag != "" ? each.value.docker_env_tag : var.docker_image_tag}" + replica_retry_limit = each.value.replica_retry_limit != null ? each.value.replica_retry_limit : 1 - environment_variables = { - "DtOsDatabaseConnectionString" = "Server=tcp:${module.regions_config[each.value.region].names.sql-server}.database.windows.net,1433;Initial Catalog=${var.sqlserver.dbs.cohman.db_name_suffix};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;Authentication='Active Directory Managed Identity';User ID=${module.managed_identity_sql_db_management[each.value.region].client_id};" - } + environment_variables = each.value.env_vars != null ? each.value.env_vars : {} depends_on = [ - module.managed_identity_sql_db_management + module.azure_sql_server ] } diff --git a/infrastructure/tf-core/environments/production.tfvars b/infrastructure/tf-core/environments/production.tfvars index d4fb3dcd37..b5e8d0c5c1 100644 --- a/infrastructure/tf-core/environments/production.tfvars +++ b/infrastructure/tf-core/environments/production.tfvars @@ -109,7 +109,6 @@ routes = { app_service_plan = { os_type = "Linux" vnet_integration_enabled = true - zone_balancing_enabled = true autoscale = { scaling_rule = { @@ -247,6 +246,38 @@ container_app_jobs = { container_app_environment_key = "db-management" docker_image = "cohort-manager-db-migration" container_registry_use_mi = true + db_connection_string_name = "DtOsDatabaseConnectionString" + add_user_assigned_identity = true + replica_retry_limit = 1 + } + db-backup = { + container_app_environment_key = "db-management" + docker_image = "db-immutable-backup" + docker_env_tag = "latest" + container_registry_use_mi = true + add_user_assigned_identity = true + replica_retry_limit = 1 + env_vars_static = { + SQL_SERVER_NAME = "sqlsvr-cohman-prod-uks" + SQL_DATABASE_NAME = "DToSDB" + STORAGE_ACCOUNT_NAME = "stcohmanprodukssqlbackup" + STORAGE_CONTAINER_NAME = "sql-backups-immutable" + } + } + db-restore = { + container_app_environment_key = "db-management" + docker_image = "db-immutable-backup-restore" + docker_env_tag = "latest" + container_registry_use_mi = true + add_user_assigned_identity = true + replica_retry_limit = 1 + env_vars_static = { + SQL_SERVER_NAME = "sqlsvr-cohman-prod-uks" + SQL_DATABASE_NAME = "DToSDB_RESTORE" + STORAGE_ACCOUNT_NAME = "stcohmanprodukssqlbackup" + STORAGE_CONTAINER_NAME = "sql-backups-immutable" + BACKUP_FILE_NAME = "filename.bacpac" + } } } } @@ -1002,10 +1033,11 @@ function_apps = { } RetrievePDSDemographic = { - name_suffix = "retrieve-pds-demographic" - function_endpoint_name = "RetrievePDSDemographic" - app_service_plan_key = "NonScaling" - key_vault_url = "KeyVaultConnectionString" + name_suffix = "retrieve-pds-demographic" + function_endpoint_name = "RetrievePDSDemographic" + app_service_plan_key = "NonScaling" + service_bus_connections = ["internal"] + key_vault_url = "KeyVaultConnectionString" app_urls = [ { env_var_name = "ExceptionFunctionURL" @@ -1245,7 +1277,11 @@ sqlserver = { ad_auth_only = true auditing_policy_retention_in_days = 30 security_alert_policy_retention_days = 30 - db_management_mi_name_prefix = "mi-cohort-manager-db-management" + user_assigned_identities = [ + "db-management", + "db-backup", + "db-restore" + ] server = { sqlversion = "12.0" @@ -1261,7 +1297,7 @@ sqlserver = { licence_type = "LicenseIncluded" max_gb = 100 read_scale = false - sku = "S12" + sku = "S2" storage_account_type = "GeoZone" zone_redundant = false diff --git a/infrastructure/tf-core/environments/sandbox.tfvars b/infrastructure/tf-core/environments/sandbox.tfvars index 093a8358b7..6e2e43a1b5 100644 --- a/infrastructure/tf-core/environments/sandbox.tfvars +++ b/infrastructure/tf-core/environments/sandbox.tfvars @@ -299,6 +299,8 @@ container_app_jobs = { container_app_environment_key = "db-management" docker_image = "cohort-manager-db-migration" container_registry_use_mi = true + db_connection_string_name = "DtOsDatabaseConnectionString" + add_user_assigned_identity = true } } } @@ -1299,7 +1301,9 @@ sqlserver = { ad_auth_only = true auditing_policy_retention_in_days = 30 security_alert_policy_retention_days = 30 - db_management_mi_name_prefix = "mi-cohort-manager-db-management" + user_assigned_identities = [ + "db-management" + ] server = { sqlversion = "12.0" diff --git a/infrastructure/tf-core/network_routing.tf b/infrastructure/tf-core/network_routing.tf index 2b5351df79..c907d06545 100644 --- a/infrastructure/tf-core/network_routing.tf +++ b/infrastructure/tf-core/network_routing.tf @@ -44,7 +44,8 @@ module "route_table_core" { subnet_ids = [ module.subnets["${module.regions_config[each.key].names.subnet}-apps"].id, - module.subnets["${module.regions_config[each.key].names.subnet}-pep"].id + module.subnets["${module.regions_config[each.key].names.subnet}-pep"].id, + module.subnets["${module.regions_config[each.key].names.subnet}-container-app-db-management"].id ] tags = var.tags diff --git a/infrastructure/tf-core/sql_server.tf b/infrastructure/tf-core/sql_server.tf index 038599d8d1..10172a1290 100644 --- a/infrastructure/tf-core/sql_server.tf +++ b/infrastructure/tf-core/sql_server.tf @@ -68,25 +68,87 @@ module "azure_sql_server" { tags = var.tags } -module "managed_identity_sql_db_management" { - for_each = var.sqlserver != {} ? var.regions : {} +# Create User Assigned Managed Identities for Azure SQL access by other resources + +locals { + managed_identities = flatten([ + for region, _ in var.regions : [ + for mi_name in var.sqlserver.user_assigned_identities : { + region = region + mi_name = mi_name + } + ] + ]) + + managed_identities_map = { + for object in local.managed_identities : "${object.mi_name}-${object.region}" => object + } +} + +module "user_assigned_managed_identity_sql" { + for_each = local.managed_identities_map source = "../../../dtos-devops-templates/infrastructure/modules/managed-identity" - uai_name = "${var.sqlserver.db_management_mi_name_prefix}-${lower(var.environment)}-${lower(each.key)}" - resource_group_name = azurerm_resource_group.core[each.key].name - location = each.key + uai_name = "${module.regions_config[each.value.region].names.managed-identity}-${lower(each.value.mi_name)}" + resource_group_name = azurerm_resource_group.core[each.value.region].name + location = each.value.region tags = var.tags } +# Assign RBAC roles to the User Assigned Managed Identities for Azure SQL access by other resources +# DB-MANAGEMENT needs Contributor on the SQL Server to be able to run migrations module "sql_db_management_rbac_assignment" { - for_each = var.sqlserver != {} ? var.regions : {} + for_each = contains(var.sqlserver.user_assigned_identities, "db-management") && var.sqlserver != {} ? var.regions : {} source = "../../../dtos-devops-templates/infrastructure/modules/rbac-assignment" - principal_id = module.managed_identity_sql_db_management[each.key].principal_id + principal_id = module.user_assigned_managed_identity_sql["db-management-${each.key}"].principal_id role_definition_name = "Contributor" scope = module.azure_sql_server[each.key].sql_server_id } + +# DB-BACKUP needs SQL DB Contributor on the SQL Server to be able to read the database, and Storage Blob Data Contributor on the Storage Account to write the backups +module "sql_db_backup_rbac_assignment_sql_contributor" { + for_each = contains(var.sqlserver.user_assigned_identities, "db-backup") && var.sqlserver != {} ? var.regions : {} + + source = "../../../dtos-devops-templates/infrastructure/modules/rbac-assignment" + + principal_id = module.user_assigned_managed_identity_sql["db-backup-${each.key}"].principal_id + role_definition_name = "SQL DB Contributor" + scope = module.azure_sql_server[each.key].sql_server_id +} + +module "sql_db_backup_rbac_assignment_storage_contributor" { + for_each = contains(var.sqlserver.user_assigned_identities, "db-backup") && var.sqlserver != {} ? var.regions : {} + + source = "../../../dtos-devops-templates/infrastructure/modules/rbac-assignment" + + principal_id = module.user_assigned_managed_identity_sql["db-backup-${each.key}"].principal_id + role_definition_name = "Storage Blob Data Contributor" + scope = data.terraform_remote_state.audit.outputs.storage_account_audit["sqlbackups-${local.primary_region}"].id +} + + +# DB-RESTORE needs SQL DB Contributor on the SQL Server to be able to write to the database, and Storage Blob Data Reader on the Storage Account to read the backups +module "sql_db_restore_rbac_assignment_sql_contributor" { + for_each = contains(var.sqlserver.user_assigned_identities, "db-restore") && var.sqlserver != {} ? var.regions : {} + + source = "../../../dtos-devops-templates/infrastructure/modules/rbac-assignment" + + principal_id = module.user_assigned_managed_identity_sql["db-restore-${each.key}"].principal_id + role_definition_name = "SQL DB Contributor" + scope = module.azure_sql_server[each.key].sql_server_id +} + +module "sql_db_restore_rbac_assignment_storage_reader" { + for_each = contains(var.sqlserver.user_assigned_identities, "db-restore") && var.sqlserver != {} ? var.regions : {} + + source = "../../../dtos-devops-templates/infrastructure/modules/rbac-assignment" + + principal_id = module.user_assigned_managed_identity_sql["db-restore-${each.key}"].principal_id + role_definition_name = "Storage Blob Data Reader" + scope = data.terraform_remote_state.audit.outputs.storage_account_audit["sqlbackups-${local.primary_region}"].id +} diff --git a/infrastructure/tf-core/variables.tf b/infrastructure/tf-core/variables.tf index f7009ea821..51f7ca6d07 100644 --- a/infrastructure/tf-core/variables.tf +++ b/infrastructure/tf-core/variables.tf @@ -172,7 +172,7 @@ variable "container_app_environments" { name = optional(string) workload_profile_type = optional(string) minimum_count = optional(number, 0) - maximum_count = optional(string, 1) + maximum_count = optional(string, 0) # Value not used for Consumption type and causes unnecessary plan changes }), {}) zone_redundancy_enabled = optional(bool, false) })), {}) @@ -204,6 +204,10 @@ variable "container_app_jobs" { docker_env_tag = optional(string, "") docker_image = optional(string) container_registry_use_mi = optional(bool, false) + db_connection_string_name = optional(string, "") + env_vars_static = optional(map(string), {}) + add_user_assigned_identity = optional(bool, false) + replica_retry_limit = optional(number, 3) })), {}) }) } @@ -497,7 +501,7 @@ variable "sqlserver" { ad_auth_only = optional(bool) auditing_policy_retention_in_days = optional(number) security_alert_policy_retention_days = optional(number) - db_management_mi_name_prefix = optional(string) + user_assigned_identities = optional(list(string), []) # Server Instance server = optional(object({ diff --git a/scripts/backups/db-backup-container/Dockerfile b/scripts/backups/db-backup-container/Dockerfile new file mode 100644 index 0000000000..90a48e8d60 --- /dev/null +++ b/scripts/backups/db-backup-container/Dockerfile @@ -0,0 +1,30 @@ +# Use a base image with PowerShell already installed. +FROM mcr.microsoft.com/powershell:latest + +# Create a non-root user and group +RUN groupadd -r appgroup && useradd -r -g appgroup appuser + +# Update package lists and install wget and unzip. +RUN apt-get --no-install-recommends update && apt-get --no-install-recommends install -y wget unzip + +# Install the Az PowerShell module for Azure authentication +RUN pwsh -Command "Install-Module -Name Az.Accounts -RequiredVersion 2.12.0 -Force -Scope AllUsers" + +# Install SqlPackage. +RUN wget -O sqlpackage.zip https://aka.ms/sqlpackage-linux \ + && mkdir /opt/sqlpackage \ + && unzip sqlpackage.zip -d /opt/sqlpackage \ + && rm sqlpackage.zip \ + && chmod +x /opt/sqlpackage/sqlpackage + +# Switch to the non-root user +USER appuser + +# Set the working directory for the non-root user +WORKDIR /app + +# Copy the PowerShell script into the container. +COPY export-using-identity.ps1 . + +# Define the command to run when the container starts. +CMD ["pwsh", "./export-using-identity.ps1"] diff --git a/scripts/backups/db-backup-container/build.sh b/scripts/backups/db-backup-container/build.sh new file mode 100755 index 0000000000..4387f4863b --- /dev/null +++ b/scripts/backups/db-backup-container/build.sh @@ -0,0 +1,17 @@ +# !/bin/bash + +# Log into Azure CLI as self +az login +az account set -s 'Digital Screening DToS - Core Services Prod Hub' + +# Log in to Azure Container Registry +az acr login --name acrukshubprodcohman + +# Build the Docker image +docker build -t db-immutable-backup:latest . + +# Tag the image for the registry +docker tag db-immutable-backup:latest acrukshubprodcohman.azurecr.io/db-immutable-backup:latest + +# Push the image to the registry +docker push acrukshubprodcohman.azurecr.io/db-immutable-backup:latest diff --git a/scripts/backups/db-backup-container/export-using-identity.ps1 b/scripts/backups/db-backup-container/export-using-identity.ps1 new file mode 100644 index 0000000000..60385c1dff --- /dev/null +++ b/scripts/backups/db-backup-container/export-using-identity.ps1 @@ -0,0 +1,168 @@ +# PowerShell script to export an Azure SQL Database to a BACPAC file using a Managed Identity and upload it to Azure Blob Storage. +# This script is intended to run inside a Docker container with the necessary tools installed (sqlpackage, Az PowerShell module). +# It requires the following environment variables to be set: +# - SQL_SERVER_NAME: The name of the Azure SQL Server (without .database.windows.net) +# - SQL_DATABASE_NAME: The name of the database to export +# - STORAGE_ACCOUNT_NAME: The name of the Azure Storage Account +# - STORAGE_CONTAINER_NAME: The name of the Blob container to upload the BACPAC file to +# - MANAGED_IDENTITY_CLIENT_ID: The Client ID of the User Assigned Managed Identity with access to the SQL Database and Storage Account +# - TARGET_SUBSCRIPTION_ID: The Subscription ID where the resources are located + +# Check for Az modules +try { + Import-Module Az.Accounts -ErrorAction Stop +} +catch { + Write-Error "Failed to import Az modules. Ensure the Az PowerShell module is installed in the container. Error: $($_.Exception.Message)" + exit 1 +} +Write-Output "Imported Az modules successfully." + +$ServerName = $env:SQL_SERVER_NAME +$DatabaseName = $env:SQL_DATABASE_NAME +$StorageAccountName = $env:STORAGE_ACCOUNT_NAME +$ContainerName = $env:STORAGE_CONTAINER_NAME +$ManagedIdentityClientId = $env:MANAGED_IDENTITY_CLIENT_ID +$TargetSubscriptionId = $env:TARGET_SUBSCRIPTION_ID + +# Check if environment variables are set +if (-not $ServerName -or -not $DatabaseName) { + Write-Error "Error: SQL_SERVER_NAME or SQL_DATABASE_NAME environment variable is not set." + exit 1 +} +elseif (-not $StorageAccountName -or -not $ContainerName) { + Write-Error "Error: STORAGE_ACCOUNT_NAME or STORAGE_CONTAINER_NAME environment variable is not set." + exit 1 +} +elseif (-not $ManagedIdentityClientId) { + Write-Error "Error: MANAGED_IDENTITY_CLIENT_ID environment variable is not set." + exit 1 +} +elseif (-not $TargetSubscriptionId) { + Write-Error "Error: TARGET_SUBSCRIPTION_ID environment variable is not set." + exit 1 +} +else { + Write-Output "Environment variables are set correctly." +} + +# Connect using Managed Identity +try { + # For System Assigned Managed Identity + Connect-AzAccount -Identity -AccountId $ManagedIdentityClientId -ErrorAction Stop + Select-AzSubscription -SubscriptionId $TargetSubscriptionId -ErrorAction Stop +} catch { + Write-Error "Failed to connect to Azure using Managed Identity. Error: $($_.Exception.Message)" + exit 1 +} + +# Define SQL Package path here: +$SqlPackagePath = "/opt/sqlpackage/sqlpackage" + +# Define the backup file name with timestamp +$BackupFileName = "${ServerName}-${DatabaseName}_$(Get-Date -Format 'yyyy-MM-dd_HHmmss').bacpac" +$localFilePath = "/tmp/$BackupFileName" +Write-Output "Backup file will be named: $BackupFileName" + +# Use a connection string as this appears(after much trial and error) to be the only way to get sqlpackage to work with the correct User Assigned Managed Identity. +# Unfortunately the string parameters vary slightly compared to those used by the db-management container so it's easiest to compose it here rather than in the Terraform code. +$ConnectionString = "Server=$ServerName.database.windows.net;Authentication=Active Directory Managed Identity; Encrypt=True; User Id=$ManagedIdentityClientId; Database=$DatabaseName" + +$Arguments = @( + "/Action:Export" + "/TargetFile:$localFilePath" + "/SourceConnectionString:$ConnectionString" +) + +# Execute SQLPackage +try { + $sqlpackageOutput = & $SqlPackagePath $Arguments *>&1 + # Uncomment for debugging + # Write-Output "sqlpackage output: $sqlpackageOutput" + + # Check file was written by getting its size + $fileInfo = Get-Item -Path "$localFilePath" -ErrorAction Stop + if ($fileInfo.Length -eq 0) { + throw "The backup file was created but is empty." + } + Write-Output "Created backup file: $localFilePath with size $($fileInfo.Length) bytes." +} +catch { + Write-Error "Error: Failed to create SQL Backup File. Error: $($_.Exception.Message)" + exit 1 +} + +# Upload file to Blob storage +Write-Output "Uploading $localFilePath to Blob Storage container '$ContainerName' in storage account '$StorageAccountName'." + +# Upload the blob: +# Cannot use Set-AzStorageBlobContent with System Assigned Managed Identity as it is not possible to pass in the correct identity to use. +# Therefore we need to obtain a token manually and then push the file using a web request.. + +try { + # Get the access token using the managed identity endpoint... + # The IDENTITY_ENDPOINT and IDENTITY_HEADER environment variables are injected by Azure. + $resource = "https://storage.azure.com/" + $headers = @{ + "X-IDENTITY-HEADER" = $env:IDENTITY_HEADER + } + + $apiVersion = "2019-08-01" + $IdentityEndpointUri = "$($env:IDENTITY_ENDPOINT)?resource=$resource&client_id=$ManagedIdentityClientId&api-version=$apiVersion" + + # Use -SkipHttpErrorCheck to ensure the response object is not disposed, even on failure - we will check the status code ourselves. + $tokenResponse = Invoke-WebRequest -Method GET -Uri $IdentityEndpointUri -Headers $headers -SkipHttpErrorCheck + + if ($tokenResponse.StatusCode -ne 200) { + # An HTTP error occurred. Read the response content for details. + $responseBody = $tokenResponse.Content | ConvertFrom-Json + Write-Error "Failed to get access token from managed identity endpoint." + Write-Error "HTTP Status Code: $($tokenResponse.StatusCode)" + Write-Error "Response Body: $($responseBody | ConvertTo-Json -Compress)" + exit 1 + } + + # If the status code is 200, parse the content to get the token. + $responseContent = $tokenResponse.Content | ConvertFrom-Json + $accessToken = $responseContent.access_token + Write-Output "Successfully retrieved storage account access token." + + # Prepare the blob upload + $blobUrl = "https://$StorageAccountName.blob.core.windows.net/$ContainerName/$BackupFileName" + Write-Output "Uploading to: $blobUrl" + + # Define the headers for the PUT request to Azure Storage + $uploadHeaders = @{ + "Authorization" = "Bearer $accessToken" + "x-ms-blob-type" = "BlockBlob" + "x-ms-version" = "2021-08-06" + } + + try { + # Upload the blob via REST API + $uploadResult = Invoke-WebRequest -Method PUT -Uri $blobUrl -Headers $uploadHeaders -InFile $localFilePath -ContentType "application/octet-stream" -TimeoutSec 300 -SkipHttpErrorCheck + + if ($uploadResult.StatusCode -ne 201) { + throw "Failed to upload blob to Azure Storage. HTTP Status Code: $($uploadResult.StatusCode). Response Body: $($uploadResult.Content)" + } + } + catch { + throw "Failed to upload blob to Azure Storage. Error: $($_.Exception.Message)" + } + + Write-Output "Upload complete: $blobUrl" +} catch { + # This catch block will only be triggered for network-level errors such as DNS lookup failure or a connection timeout, not for HTTP status errors. + Write-Error "Error: An unexpected network or system error occurred: $($_.Exception.Message)" + exit 1 +} + +# Clean up the local file after successful upload +Remove-Item "$localFilePath" -Force +Write-Output "Cleaned up local temporary file." + +# SQL permissions required: +# CREATE USER [mi-prod-uks-cohman-db-backup] FROM EXTERNAL PROVIDER; +# ALTER ROLE db_datareader ADD MEMBER [mi-prod-uks-cohman-db-backup]; +# GRANT VIEW DEFINITION TO [mi-prod-uks-cohman-db-backup]; +# GRANT VIEW DATABASE STATE TO [mi-prod-uks-cohman-db-backup]; diff --git a/scripts/backups/db-restore-container/Dockerfile b/scripts/backups/db-restore-container/Dockerfile new file mode 100644 index 0000000000..2575e6eb07 --- /dev/null +++ b/scripts/backups/db-restore-container/Dockerfile @@ -0,0 +1,30 @@ +# Use a base image with PowerShell already installed. +FROM mcr.microsoft.com/powershell:latest + +# Create a non-root user and group +RUN groupadd -r appgroup && useradd -r -g appgroup appuser + +# Update package lists and install wget and unzip. +RUN apt-get --no-install-recommends update && apt-get --no-install-recommends install -y wget unzip + +# Install the Az PowerShell module for Azure authentication +RUN pwsh -Command "Install-Module -Name Az.Accounts -RequiredVersion 2.12.0 -Force -Scope AllUsers" + +# Install SqlPackage. +RUN wget -O sqlpackage.zip https://aka.ms/sqlpackage-linux \ + && mkdir /opt/sqlpackage \ + && unzip sqlpackage.zip -d /opt/sqlpackage \ + && rm sqlpackage.zip \ + && chmod +x /opt/sqlpackage/sqlpackage + +# Switch to the non-root user +USER appuser + +# Set the working directory for the non-root user +WORKDIR /app + +# Copy the PowerShell script into the container. +COPY import-using-identity.ps1 . + +# Define the command to run when the container starts. +CMD ["pwsh", "./import-using-identity.ps1"] diff --git a/scripts/backups/db-restore-container/build.sh b/scripts/backups/db-restore-container/build.sh new file mode 100755 index 0000000000..411184ae89 --- /dev/null +++ b/scripts/backups/db-restore-container/build.sh @@ -0,0 +1,17 @@ +# !/bin/bash + +# Log into Azure CLI as self +az login +az account set -s 'Digital Screening DToS - Core Services Prod Hub' + +# Log in to Azure Container Registry +az acr login --name acrukshubprodcohman + +# Build the Docker image +docker build -t db-immutable-backup-restore:latest . + +# Tag the image for the registry +docker tag db-immutable-backup-restore:latest acrukshubprodcohman.azurecr.io/db-immutable-backup-restore:latest + +# Push the image to the registry +docker push acrukshubprodcohman.azurecr.io/db-immutable-backup-restore:latest diff --git a/scripts/backups/db-restore-container/import-using-identity.ps1 b/scripts/backups/db-restore-container/import-using-identity.ps1 new file mode 100644 index 0000000000..f33a094622 --- /dev/null +++ b/scripts/backups/db-restore-container/import-using-identity.ps1 @@ -0,0 +1,173 @@ +# PowerShell script to import an Azure SQL Database from a BACPAC file in Azure Blob Storage using a Managed Identity, and restore it. +# This script is intended to run inside a Docker container with the necessary tools installed (sqlpackage, Az PowerShell module). +# It requires the following environment variables to be set: +# - SQL_SERVER_NAME: The name of the Azure SQL Server (without .database.windows.net) +# - SQL_DATABASE_NAME: The name of the database to restore the import as +# - STORAGE_ACCOUNT_NAME: The name of the Azure Storage Account +# - STORAGE_CONTAINER_NAME: The name of the Blob container to download the BACPAC file from +# - BACKUP_FILE_NAME: The name of the BACPAC file to import (including .bacpac extension) +# - MANAGED_IDENTITY_CLIENT_ID: The Client ID of the User Assigned Managed Identity with access to the SQL Database and Storage Account +# - TARGET_SUBSCRIPTION_ID: The Subscription ID where the resources are located + +# Check for Az modules +try { + Import-Module Az.Accounts -ErrorAction Stop +} +catch { + Write-Error "Failed to import Az modules. Ensure the Az PowerShell module is installed in the container. Error: $($_.Exception.Message)" + exit 1 +} +Write-Output "Imported Az modules successfully." + +$ServerName = $env:SQL_SERVER_NAME +$DatabaseName = $env:SQL_DATABASE_NAME +$StorageAccountName = $env:STORAGE_ACCOUNT_NAME +$ContainerName = $env:STORAGE_CONTAINER_NAME +$BackupFileName = $env:BACKUP_FILE_NAME +$ManagedIdentityClientId = $env:MANAGED_IDENTITY_CLIENT_ID +$TargetSubscriptionId = $env:TARGET_SUBSCRIPTION_ID + +# Check if environment variables are set +if (-not $ServerName -or -not $DatabaseName) { + Write-Error "Error: SQL_SERVER_NAME or SQL_DATABASE_NAME environment variable is not set." + exit 1 +} +elseif (-not $StorageAccountName -or -not $ContainerName -or -not $BackupFileName) { + Write-Error "Error: STORAGE_ACCOUNT_NAME or STORAGE_CONTAINER_NAME or BACKUP_FILE_NAME environment variable is not set." + exit 1 +} +elseif (-not $ManagedIdentityClientId) { + Write-Error "Error: MANAGED_IDENTITY_CLIENT_ID environment variable is not set." + exit 1 +} +elseif (-not $TargetSubscriptionId) { + Write-Error "Error: TARGET_SUBSCRIPTION_ID environment variable is not set." + exit 1 +} +else { + Write-Output "Environment variables are set correctly." +} + +# Connect using Managed Identity +try { + # For System Assigned Managed Identity + Connect-AzAccount -Identity -AccountId $ManagedIdentityClientId -ErrorAction Stop + Select-AzSubscription -SubscriptionId $TargetSubscriptionId -ErrorAction Stop +} catch { + Write-Error "Failed to connect to Azure using Managed Identity. Error: $($_.Exception.Message)" + exit 1 +} + +# Download file from Blob storage + +# Download the blob: +# Cannot use Get-AzStorageBlobContent with System Assigned Managed Identity as it is not possible to pass in the correct identity to use. +# Therefore we need to obtain a token manually and then push the file using a web request.. + +# Define the temporary local file path +$localFilePath = "/tmp/$BackupFileName" + +try { + # Get the access token using the managed identity endpoint... + # The IDENTITY_ENDPOINT and IDENTITY_HEADER environment variables are injected by Azure. + $resource = "https://storage.azure.com/" + $headers = @{ + "X-IDENTITY-HEADER" = $env:IDENTITY_HEADER + } + + $apiVersion = "2019-08-01" + $IdentityEndpointUri = "$($env:IDENTITY_ENDPOINT)?resource=$resource&client_id=$ManagedIdentityClientId&api-version=$apiVersion" + + # Use -SkipHttpErrorCheck to ensure the response object is not disposed, even on failure - we will check the status code ourselves. + $tokenResponse = Invoke-WebRequest -Method GET -Uri $IdentityEndpointUri -Headers $headers -SkipHttpErrorCheck + + if ($tokenResponse.StatusCode -ne 200) { + # An HTTP error occurred. Read the response content for details. + $responseBody = $tokenResponse.Content | ConvertFrom-Json + Write-Error "Failed to get access token from managed identity endpoint." + Write-Error "HTTP Status Code: $($tokenResponse.StatusCode)" + Write-Error "Response Body: $($responseBody | ConvertTo-Json -Compress)" + exit 1 + } + + # If the status code is 200, parse the content to get the token. + $responseContent = $tokenResponse.Content | ConvertFrom-Json + $accessToken = $responseContent.access_token + Write-Error "Successfully retrieved storage account access token." + + # Prepare the blob download + $blobUrl = "https://$StorageAccountName.blob.core.windows.net/$ContainerName/$BackupFileName" + Write-Output "Downloading $blobUrl to $localFilePath" + + # Define the headers for the PUT request to Azure Storage + $downloadHeaders = @{ + "Authorization" = "Bearer $accessToken" + "x-ms-version" = "2021-08-06" + } + + # Download the blob via REST API + try { + Invoke-WebRequest -Method GET -Uri $blobUrl -Headers $downloadHeaders -OutFile $localFilePath -TimeoutSec 300 + + if (-not (Test-Path $localFilePath) -or ((Get-Item $localFilePath).Length -eq 0)) { + throw "Downloaded file does not exist or is empty: $localFilePath" + } + Write-Output "Download complete: $localFilePath" + } + catch { + Write-Error "Failed to download blob from Azure Storage. Error: $($_.Exception.Message)" + exit 1 + } +} catch { + # This catch block will only be triggered for network-level errors such as DNS lookup failure or a connection timeout, not for HTTP status errors. + Write-Error "Error: An unexpected network or system error occurred downloading backup file: $($_.Exception.Message)" + exit 1 +} + +# Do the restore using sqlpackage: +# Define SQL Package path here: +$SqlPackagePath = "/opt/sqlpackage/sqlpackage" + +# Use a connection string as this appears(after much trial and error) to be the only way to get sqlpackage to work with the correct User Assigned Managed Identity. +# Unfortunately the string parameters vary slightly compared to those used by the db-management container so it's easiest to compose it here rather than in the Terraform code. +$ConnectionString = "Server=$ServerName.database.windows.net;Authentication=Active Directory Managed Identity;Encrypt=True;User Id=$ManagedIdentityClientId;Initial Catalog=$DatabaseName" + +# Configuration Parameters for new database: +$ServiceObjective = "S12" +$MaxSizeGB = "30" + +$Arguments = @( + "/Action:Import" + "/SourceFile:$localFilePath" + "/TargetConnectionString:$ConnectionString" + "/p:DatabaseServiceObjective=$ServiceObjective" + "/p:DatabaseMaximumSize=$MaxSizeGB" +) + +# Execute SQLPackage +try { + Write-Output "Starting database restore to $DatabaseName [sku: $ServiceObjective, size: $MaxSizeGB] using file: $localFilePath..." + + $sqlpackageOutput = & $SqlPackagePath $Arguments *>&1 + # Write-Output "sqlpackage output: $sqlpackageOutput" + + # If output contains error string, throw an error + if ($sqlpackageOutput -match "Error:") { + throw "sqlpackage reported an error during import: $sqlpackageOutput" + } + Write-Output "Completed database restore successfully." + + # Clean up the local file after successful download + Remove-Item "$localFilePath" -Force + Write-Output "Cleaned up local temporary file." +} +catch { + Write-Error "Error: Failed to create SQL Backup File. Error: $($_.Exception.Message)" + exit 1 +} + +# SQL permissions required: +# CREATE USER [mi-prod-uks-cohman-db-backup] FROM EXTERNAL PROVIDER; +# ALTER ROLE db_datareader ADD MEMBER [mi-prod-uks-cohman-db-backup]; +# GRANT VIEW DEFINITION TO [mi-prod-uks-cohman-db-backup]; +# GRANT VIEW DATABASE STATE TO [mi-prod-uks-cohman-db-backup];