diff --git a/infrastructure/templates/public-api/application/public-api/publicApiDataProcessor.bicep b/infrastructure/templates/public-api/application/public-api/publicApiDataProcessor.bicep index 29a9a450634..3abc099f17e 100644 --- a/infrastructure/templates/public-api/application/public-api/publicApiDataProcessor.bicep +++ b/infrastructure/templates/public-api/application/public-api/publicApiDataProcessor.bicep @@ -1,4 +1,4 @@ -import { ResourceNames, FirewallRule } from '../../types.bicep' +import { ResourceNames, FirewallRule, IpRange } from '../../types.bicep' @description('Specifies common resource naming variables.') param resourceNames ResourceNames @@ -15,8 +15,15 @@ param dataProcessorFunctionAppExists bool @description('Specifies the Application (Client) Id of a pre-existing App Registration used to represent the Data Processor Function App.') param dataProcessorAppRegistrationClientId string -@description('Public API Storage : Firewall rules.') -param storageFirewallRules FirewallRule[] = [] +@description('Specifies the principal id of the Azure DevOps SPN.') +@secure() +param devopsServicePrincipalId string + +@description('The IP address ranges that can access the Data Processor storage accounts.') +param storageFirewallRules IpRange[] + +@description('The IP address ranges that can access the Data Processor Function App endpoints.') +param functionAppFirewallRules FirewallRule[] @description('Whether to create or update Azure Monitor alerts during this deploy') param deployAlerts bool @@ -36,7 +43,6 @@ resource adminAppServiceIdentity 'Microsoft.ManagedIdentity/identities@2023-01-3 } var adminAppClientId = adminAppServiceIdentity.properties.clientId -var adminAppPrincipalId = adminAppServiceIdentity.properties.principalId resource publicApiStorageAccount 'Microsoft.Storage/storageAccounts@2023-05-01' existing = { name: resourceNames.publicApi.publicApiStorageAccount @@ -72,15 +78,15 @@ module dataProcessorFunctionAppModule '../../components/functionApp.bicep' = { applicationInsightsKey: applicationInsightsKey subnetId: outboundVnetSubnet.id privateEndpointSubnetId: inboundVnetSubnet.id - publicNetworkAccessEnabled: false + publicNetworkAccessEnabled: true + functionAppEndpointFirewallRules: functionAppFirewallRules entraIdAuthentication: { appRegistrationClientId: dataProcessorAppRegistrationClientId allowedClientIds: [ adminAppClientId + devopsServicePrincipalId ] - allowedPrincipalIds: [ - adminAppPrincipalId - ] + allowedPrincipalIds: [] requireAuthentication: true } userAssignedManagedIdentityParams: { @@ -98,9 +104,6 @@ module dataProcessorFunctionAppModule '../../components/functionApp.bicep' = { } preWarmedInstanceCount: 1 healthCheckPath: '/api/HealthCheck' - appSettings: { - App__MetaInsertBatchSize: 1000 - } azureFileShares: [{ storageName: resourceNames.publicApi.publicApiFileShare storageAccountKey: publicApiStorageAccount.listKeys().keys[0].value @@ -151,3 +154,5 @@ module fileServiceAvailabilityAlerts '../../components/alerts/fileServices/avail output managedIdentityName string = dataProcessorFunctionAppManagedIdentity.name output managedIdentityClientId string = dataProcessorFunctionAppManagedIdentity.properties.clientId output publicApiDataFileShareMountPath string = publicApiDataFileShareMountPath +output url string = dataProcessorFunctionAppModule.outputs.url +output stagingUrl string = dataProcessorFunctionAppModule.outputs.stagingUrl diff --git a/infrastructure/templates/public-api/application/public-api/publicApiStorage.bicep b/infrastructure/templates/public-api/application/public-api/publicApiStorage.bicep index f61c7f4efd3..1207e1a971a 100644 --- a/infrastructure/templates/public-api/application/public-api/publicApiStorage.bicep +++ b/infrastructure/templates/public-api/application/public-api/publicApiStorage.bicep @@ -1,4 +1,4 @@ -import { ResourceNames, FirewallRule } from '../../types.bicep' +import { ResourceNames, IpRange } from '../../types.bicep' param resourceNames ResourceNames @@ -9,7 +9,7 @@ param location string param publicApiDataFileShareQuota int @description('Public API Storage : Firewall rules.') -param storageFirewallRules FirewallRule[] +param storageFirewallRules IpRange[] @description('Specifies a set of tags with which to tag the resource in Azure.') param tagValues object diff --git a/infrastructure/templates/public-api/application/shared/postgreSqlFlexibleServer.bicep b/infrastructure/templates/public-api/application/shared/postgreSqlFlexibleServer.bicep index 2f9469be5d8..a5a96609131 100644 --- a/infrastructure/templates/public-api/application/shared/postgreSqlFlexibleServer.bicep +++ b/infrastructure/templates/public-api/application/shared/postgreSqlFlexibleServer.bicep @@ -1,4 +1,4 @@ -import { ResourceNames, FirewallRule, PrincipalNameAndId } from '../../types.bicep' +import { ResourceNames, IpRange, PrincipalNameAndId } from '../../types.bicep' @description('Specifies common resource naming variables.') param resourceNames ResourceNames @@ -23,7 +23,7 @@ param storageSizeGB int = 32 param autoGrowStatus string = 'Disabled' @description('Firewall rules.') -param firewallRules FirewallRule[] = [] +param firewallRules IpRange[] = [] @description('Specifies the subnet id that the PostgreSQL private endpoint will be attached to.') param privateEndpointSubnetId string diff --git a/infrastructure/templates/public-api/application/shared/virtualNetwork.bicep b/infrastructure/templates/public-api/application/shared/virtualNetwork.bicep index ad5f11225b7..04e86e6a381 100644 --- a/infrastructure/templates/public-api/application/shared/virtualNetwork.bicep +++ b/infrastructure/templates/public-api/application/shared/virtualNetwork.bicep @@ -83,6 +83,9 @@ output adminAppServiceSubnetStartIpAddress string = parseCidr(adminSubnet.proper @description('The last usable IP address for the Admin App Service Subnet.') output adminAppServiceSubnetEndIpAddress string = parseCidr(adminSubnet.properties.addressPrefix).lastUsable +@description('The IP address range for the Admin App Service Subnet.') +output adminAppServiceSubnetCidr string = adminSubnet.properties.addressPrefix + @description('The first usable IP address for the Publisher Function App Subnet.') output publisherFunctionAppSubnetStartIpAddress string = parseCidr(publisherSubnet.properties.addressPrefix).firstUsable diff --git a/infrastructure/templates/public-api/ci/azure-pipelines.yml b/infrastructure/templates/public-api/ci/azure-pipelines.yml index f3d3a984229..5412ea98168 100644 --- a/infrastructure/templates/public-api/ci/azure-pipelines.yml +++ b/infrastructure/templates/public-api/ci/azure-pipelines.yml @@ -1,15 +1,24 @@ trigger: none parameters: - - name: deployContainerApp - displayName: Can we deploy the Container App yet? This is dependent on the PostgreSQL Flexible Server being set up and having users manually added. - default: true - - name: updatePsqlFlexibleServer + - name: deploySharedPrivateDnsZones + displayName: Do the shared Private DNS Zones need creating or updating? + default: false + - name: deployPsqlFlexibleServer displayName: Does the PostgreSQL Flexible Server require any updates? False by default to avoid unnecessarily lengthy deploys. default: false + - name: deployContainerApp + displayName: Does the Public API Container App need creating or updating? This is dependent on the PostgreSQL Flexible Server being set up and having users manually added. + default: true + - name: deployDataProcessor + displayName: Does the Data Processor need creating or updating? + default: true - name: deployAlerts displayName: Whether to create or update Azure Monitor alerts during this deploy. default: false + - name: awaitActiveOrchestrations + displayName: Should this deploy wait for active orchestrations in Function Apps to complete prior to deploying? + default: true - name: forceDeployToEnvironment displayName: Set to either dev or test to force a deploy to that environment from the chosen branch. type: string @@ -41,12 +50,18 @@ variables: value: $[eq(variables['Build.SourceBranch'], 'refs/heads/master')] - name: vmImageName value: ubuntu-latest + - name: deploySharedPrivateDnsZones + value: ${{ parameters.deploySharedPrivateDnsZones }} + - name: deployPsqlFlexibleServer + value: ${{ parameters.deployPsqlFlexibleServer }} - name: deployContainerApp value: ${{ parameters.deployContainerApp }} - - name: updatePsqlFlexibleServer - value: ${{ parameters.updatePsqlFlexibleServer }} + - name: deployDataProcessor + value: ${{ parameters.deployDataProcessor }} - name: deployAlerts value: ${{ parameters.deployAlerts }} + - name: awaitActiveOrchestrations + value: ${{ parameters.awaitActiveOrchestrations }} pool: vmImage: $(vmImageName) diff --git a/infrastructure/templates/public-api/ci/jobs/deploy-data-processor.yml b/infrastructure/templates/public-api/ci/jobs/deploy-data-processor.yml index c55178694b8..1ec275354e8 100644 --- a/infrastructure/templates/public-api/ci/jobs/deploy-data-processor.yml +++ b/infrastructure/templates/public-api/ci/jobs/deploy-data-processor.yml @@ -10,6 +10,7 @@ parameters: jobs: - deployment: DeployPublicDataProcessor displayName: Deploy Public Data Processor + condition: and(succeeded(), eq(variables.deployDataProcessor, true)) dependsOn: ${{ parameters.dependsOn }} environment: ${{ parameters.environment }} strategy: @@ -42,9 +43,11 @@ jobs: --resource-group $(resourceGroupName) \ --slot staging \ --settings \ - "App__PrivateStorageConnectionString=@Microsoft.KeyVault(VaultName=$(keyVaultName); SecretName=$(coreStorageConnectionStringSecretKey))" \ + "App__MetaInsertBatchSize=1000" \ "App__EnableThemeDeletion=$(enableThemeDeletion)" \ + "App__PrivateStorageConnectionString=@Microsoft.KeyVault(VaultName=$(keyVaultName); SecretName=$(coreStorageConnectionStringSecretKey))" \ "AZURE_CLIENT_ID=$(dataProcessorFunctionAppManagedIdentityClientId)" \ + "AzureWebJobs.TriggerLongRunningOrchestration.Disabled=true" \ "DataFiles__BasePath=$(dataProcessorPublicApiDataFileShareMountPath)" az webapp config connection-string set \ @@ -63,28 +66,6 @@ jobs: --settings \ "PublicDataDb=@Microsoft.KeyVault(VaultName=$(keyVaultName); SecretName=$(dataProcessorPsqlConnectionStringSecretKey))" - # TODO EES-5128 - # Add Private Endpoint to Data Processor Function App into the VMSS VNet to allow - # DevOps to deploy the Data Processor Function App without having to temporarily - # make it publicly accessible. - - task: AzureCLI@2 - displayName: Temporarily enable public network access before deploy - retryCountOnTaskFailure: 1 - inputs: - azureSubscription: ${{ parameters.serviceConnection }} - scriptType: bash - scriptLocation: inlineScript - inlineScript: | - set -e - - az functionapp update \ - --name $(dataProcessorFunctionAppName) \ - --resource-group $(resourceGroupName) \ - --slot staging \ - --set \ - publicNetworkAccess=Enabled \ - siteConfig.publicNetworkAccess=Enabled - # TODO EES-5128 # Retry deploying the Function App in order to allow the staging slot the time to # fully restart after config and network settings have been updated prior to deploy. @@ -112,29 +93,21 @@ jobs: --resource-group $(resourceGroupName) \ --slot staging - # TODO EES-5128 - # Add Private Endpoint to Data Processor Function App into the VMSS VNet to allow - # DevOps to deploy the Data Processor Function App without having to temporarily - # make it publicly accessible. - - task: AzureCLI@2 - displayName: Disable public network access after deploy - retryCountOnTaskFailure: 1 - condition: always() - inputs: - azureSubscription: ${{ parameters.serviceConnection }} - scriptType: bash - scriptLocation: inlineScript - inlineScript: | - set -e - - az functionapp update \ - --name $(dataProcessorFunctionAppName) \ - --resource-group $(resourceGroupName) \ - --slot staging \ - --set \ - publicNetworkAccess=Disabled \ - siteConfig.publicNetworkAccess=Disabled + - template: ../tasks/wait-for-endpoint-success.yml + parameters: + serviceConnection: ${{ parameters.serviceConnection }} + displayName: Waiting for staging slot to start successfully + accessTokenScope: $(dataProcessorAppRegistrationClientId) + endpoint: $(dataProcessorFunctionAppStagingUrl)/api/HealthCheck + - template: ../tasks/wait-for-orchestrations-to-complete.yml + parameters: + serviceConnection: ${{ parameters.serviceConnection }} + displayName: Waiting for active orchestrations in the production slot to complete + accessTokenScope: $(dataProcessorAppRegistrationClientId) + endpoint: $(dataProcessorFunctionAppUrl)/api/StatusCheck + condition: eq(variables.awaitActiveOrchestrations, true) + - task: AzureCLI@2 displayName: Swap slots retryCountOnTaskFailure: 1 @@ -149,3 +122,10 @@ jobs: --resource-group $(resourceGroupName) \ --slot staging \ --target-slot production + + - template: ../tasks/wait-for-endpoint-success.yml + parameters: + serviceConnection: ${{ parameters.serviceConnection }} + displayName: Checking that production slot is healthy after slot swap + accessTokenScope: $(dataProcessorAppRegistrationClientId) + endpoint: $(dataProcessorFunctionAppUrl)/api/HealthCheck diff --git a/infrastructure/templates/public-api/ci/jobs/deploy-infrastructure.yml b/infrastructure/templates/public-api/ci/jobs/deploy-infrastructure.yml index 4deeb28a247..541651a2296 100644 --- a/infrastructure/templates/public-api/ci/jobs/deploy-infrastructure.yml +++ b/infrastructure/templates/public-api/ci/jobs/deploy-infrastructure.yml @@ -35,8 +35,9 @@ jobs: action: validate serviceConnection: ${{ parameters.serviceConnection }} parameterFile: $(paramFile) + deploySharedPrivateDnsZones: false + deployPsqlFlexibleServer: false deployContainerApp: true - updatePsqlFlexibleServer: false deployAlerts: false dataProcessorExists: true @@ -62,8 +63,9 @@ jobs: action: create serviceConnection: ${{ parameters.serviceConnection }} parameterFile: $(paramFile) + deploySharedPrivateDnsZones: $(deploySharedPrivateDnsZones) + deployPsqlFlexibleServer: $(deployPsqlFlexibleServer) deployContainerApp: $(deployContainerApp) - updatePsqlFlexibleServer: $(updatePsqlFlexibleServer) deployAlerts: $(deployAlerts) dataProcessorExists: $(dataProcessorExists) diff --git a/infrastructure/templates/public-api/ci/tasks/deploy-bicep.yml b/infrastructure/templates/public-api/ci/tasks/deploy-bicep.yml index fa5ceeaa331..2e9d5ca3518 100644 --- a/infrastructure/templates/public-api/ci/tasks/deploy-bicep.yml +++ b/infrastructure/templates/public-api/ci/tasks/deploy-bicep.yml @@ -11,14 +11,20 @@ parameters: type: string - name: parameterFile type: string + - name: deploySharedPrivateDnsZones + type: string + - name: deployPsqlFlexibleServer + type: string - name: deployContainerApp type: string - - name: updatePsqlFlexibleServer + default: true + - name: deployDataProcessor type: string - name: deployAlerts type: string - name: dataProcessorExists type: string + default: true steps: - task: AzureCLI@2 @@ -27,9 +33,10 @@ steps: azureSubscription: ${{ parameters.serviceConnection }} scriptType: bash scriptLocation: inlineScript + addSpnToEnvironment: true inlineScript: | set -e - + az deployment group ${{ parameters.action }} \ --name $(infraDeployName) \ --resource-group $(resourceGroupName) \ @@ -40,14 +47,16 @@ steps: resourceTags='$(resourceTags)' \ postgreSqlAdminName='$(postgreSqlAdminName)' \ postgreSqlAdminPassword='$(postgreSqlAdminPassword)' \ - postgreSqlFirewallRules='$(maintenanceFirewallRules)' \ postgreSqlEntraIdAdminPrincipals='$(postgreSqlEntraIdAdminPrincipals)' \ - storageFirewallRules='$(maintenanceFirewallRules)' \ + maintenanceIpRanges='$(maintenanceFirewallRules)' \ acrResourceGroupName='$(acrResourceGroupName)' \ dockerImagesTag='$(resources.pipeline.MainBuild.runName)' \ + deploySharedPrivateDnsZones=${{ parameters.deploySharedPrivateDnsZones }} \ + deployPsqlFlexibleServer=${{ parameters.deployPsqlFlexibleServer }} \ deployContainerApp=${{ parameters.deployContainerApp }} \ - updatePsqlFlexibleServer=${{ parameters.updatePsqlFlexibleServer }} \ + deployDataProcessor=${{ parameters.deployDataProcessor }} \ deployAlerts=${{ parameters.deployAlerts }} \ dataProcessorFunctionAppExists=${{ parameters.dataProcessorExists }} \ dataProcessorAppRegistrationClientId='$(dataProcessorAppRegistrationClientId)' \ - apiAppRegistrationClientId='$(apiAppRegistrationClientId)' + apiAppRegistrationClientId='$(apiAppRegistrationClientId)' \ + devopsServicePrincipalId="$servicePrincipalId" \ No newline at end of file diff --git a/infrastructure/templates/public-api/ci/tasks/wait-for-endpoint-success.yml b/infrastructure/templates/public-api/ci/tasks/wait-for-endpoint-success.yml new file mode 100644 index 00000000000..9b79efecbcc --- /dev/null +++ b/infrastructure/templates/public-api/ci/tasks/wait-for-endpoint-success.yml @@ -0,0 +1,57 @@ +parameters: + - name: serviceConnection + type: string + - name: displayName + type: string + default: Waiting for a successful response from endpoint + - name: accessTokenScope + type: string + default: null + - name: pollingDelaySeconds + type: number + default: 5 + - name: maxAttempts + type: number + default: 50 + - name: endpoint + type: string + +steps: + - task: AzureCLI@2 + displayName: ${{ parameters.displayName }} + inputs: + azureSubscription: ${{ parameters.serviceConnection }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + + if [ -n "${{ parameters.accessTokenScope }}" ]; then + accessToken=`az account get-access-token \ + --resource ${{ parameters.accessTokenScope }} \ + --query "accessToken" \ + -o tsv` + fi + + for attempt in $(seq 1 ${{ parameters.maxAttempts }}); + do + + echo "Attempt number $attempt of ${{ parameters.maxAttempts }} - calling ${{ parameters.endpoint }} to check for successful response." + + if [ -n "$accessToken" ]; then + httpStatusCode=`curl --write-out '%{http_code}' -H "Authorization: Bearer $accessToken" -s --output /dev/null ${{ parameters.endpoint }}` + else + httpStatusCode=`curl --write-out '%{http_code}' -s --output /dev/null ${{ parameters.endpoint }}` + fi + + if (( $httpStatusCode >= 200 && $httpStatusCode <= 204 )); then + echo "Received successful response with status code $httpStatusCode." + exit 0 + fi + + echo "Received response with status code $httpStatusCode. Retrying in ${{ parameters.pollingDelaySeconds }} seconds." + sleep ${{ parameters.pollingDelaySeconds }} + + done + + echo "Timed out waiting for successful response." + exit 1 diff --git a/infrastructure/templates/public-api/ci/tasks/wait-for-orchestrations-to-complete.yml b/infrastructure/templates/public-api/ci/tasks/wait-for-orchestrations-to-complete.yml new file mode 100644 index 00000000000..435dc897c9e --- /dev/null +++ b/infrastructure/templates/public-api/ci/tasks/wait-for-orchestrations-to-complete.yml @@ -0,0 +1,66 @@ +parameters: + - name: serviceConnection + type: string + - name: displayName + type: string + default: Waiting for active orchestrations to complete + - name: condition + type: string + - name: accessTokenScope + type: string + default: null + - name: pollingDelaySeconds + type: number + default: 5 + - name: maxAttempts + type: number + default: 50 + - name: endpoint + type: string + - name: dependsOn + type: object + default: [] + +steps: + - task: AzureCLI@2 + displayName: ${{ parameters.displayName }} + condition: ${{ parameters.condition}} + inputs: + azureSubscription: ${{ parameters.serviceConnection }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + + if [ -n "${{ parameters.accessTokenScope }}" ]; then + accessToken=`az account get-access-token \ + --resource ${{ parameters.accessTokenScope }} \ + --query "accessToken" \ + -o tsv` + fi + + for attempt in $(seq 1 ${{ parameters.maxAttempts }}); + do + + echo "Attempt number $attempt of ${{ parameters.maxAttempts }} - calling ${{ parameters.endpoint }} to check for active orchestrations." + + if [ -n "$accessToken" ]; then + activeOrchestrationResults=`curl -H "Authorization: Bearer $accessToken" -s ${{ parameters.endpoint }}` + else + activeOrchestrationResults=`curl -s ${{ parameters.endpoint }}` + fi + + activeOrchestrationCount=`echo $activeOrchestrationResults | jq -r '.activeOrchestrations'` + activeOrchestrations=`echo $activeOrchestrationResults | jq -r '.activeOrchestrations != 0'` + + if [[ "$activeOrchestrations" == "false" ]]; then + echo "No active orchestrations are running." + exit 0 + fi + + echo "$activeOrchestrationCount active orchestrations are still running. Retrying in ${{ parameters.pollingDelaySeconds }} seconds." + sleep ${{ parameters.pollingDelaySeconds }} + + done + + echo "Timed out waiting for active orchestrations to complete." + exit 1 diff --git a/infrastructure/templates/public-api/components/functionApp.bicep b/infrastructure/templates/public-api/components/functionApp.bicep index 4e274ad4cdf..12d55ea2dc9 100644 --- a/infrastructure/templates/public-api/components/functionApp.bicep +++ b/infrastructure/templates/public-api/components/functionApp.bicep @@ -1,4 +1,4 @@ -import { FirewallRule, AzureFileShareMount, EntraIdAuthentication } from '../types.bicep' +import { FirewallRule, IpRange, AzureFileShareMount, EntraIdAuthentication } from '../types.bicep' @description('Specifies the location for all resources.') param location string @@ -36,6 +36,9 @@ param privateEndpointSubnetId string? @description('Specifies whether this Function App is accessible from the public internet') param publicNetworkAccessEnabled bool = false +@description('IP address ranges that are allowed to access the Function App endpoints. Dependent on "publicNetworkAccessEnabled" being true.') +param functionAppFirewallRules FirewallRule[] = [] + @description('An existing Managed Identity\'s Resource Id with which to associate this Function App') param userAssignedManagedIdentityParams { id: string @@ -68,7 +71,7 @@ param healthCheckPath string? param azureFileShares AzureFileShareMount[] = [] @description('Specifies firewall rules for the various storage accounts in use by the Function App') -param storageFirewallRules FirewallRule[] = [] +param storageFirewallRules IpRange[] = [] var reserved = appServicePlanOS == 'Linux' @@ -172,6 +175,14 @@ resource slot2FileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2 ] } +var firewallRules = [for (firewallRule, index) in functionAppFirewallRules: { + name: firewallRule.name + ipAddress: firewallRule.cidr + action: 'Allow' + tag: firewallRule.tag != null ? firewallRule.tag : 'Default' + priority: firewallRule.priority != null ? firewallRule.priority : 100 + index +}] + var commonSiteProperties = { enabled: true httpsOnly: true @@ -189,9 +200,23 @@ var commonSiteProperties = { netFrameworkVersion: '8.0' linuxFxVersion: appServicePlanOS == 'Linux' ? 'DOTNET-ISOLATED|8.0' : null keyVaultReferenceIdentity: keyVaultReferenceIdentity + publicNetworkAccess: publicNetworkAccessEnabled ? 'Enabled' : 'Disabled' + ipSecurityRestrictions: publicNetworkAccessEnabled && length(firewallRules) > 0 ? firewallRules : null + ipSecurityRestrictionsDefaultAction: 'Deny' + // TODO EES-5446 - this setting controls access to the deploy site for the Function App. + // This is currently the default value, but ideally we would lock this down to only be accessible + // by our runners and certain other whitelisted IP address ranges (e.g. trusted VPNs). + scmIpSecurityRestrictions: [ + { + ipAddress: 'Any' + action: 'Allow' + priority: 2147483647 + name: 'Allow all' + description: 'Allow all access' + } + ] } keyVaultReferenceIdentity: keyVaultReferenceIdentity - publicNetworkAccess: publicNetworkAccessEnabled ? 'Enabled' : 'Disabled' } // Create the main production deploy slot. @@ -363,6 +388,8 @@ module privateEndpointModule 'privateEndpoint.bicep' = if (privateEndpointSubnet } output functionAppName string = functionApp.name +output url string = 'https://${functionApp.name}.azurewebsites.net' +output stagingUrl string = 'https://${functionApp.name}-staging.azurewebsites.net' output managementStorageAccountName string = sharedStorageAccountName output slot1StorageAccountName string = slot1StorageAccountName output slot2StorageAccountName string = slot2StorageAccountName diff --git a/infrastructure/templates/public-api/components/postgresqlDatabase.bicep b/infrastructure/templates/public-api/components/postgresqlDatabase.bicep index 273e148d322..006c67627b4 100644 --- a/infrastructure/templates/public-api/components/postgresqlDatabase.bicep +++ b/infrastructure/templates/public-api/components/postgresqlDatabase.bicep @@ -1,4 +1,4 @@ -import { FirewallRule, PrincipalNameAndId } from '../types.bicep' +import { IpRange, PrincipalNameAndId } from '../types.bicep' @description('Specifies the location for all resources.') param location string @@ -40,7 +40,7 @@ param geoRedundantBackup string = 'Disabled' param databaseNames string[] @description('An array of firewall rules containing IP address ranges') -param firewallRules FirewallRule[] = [] +param firewallRules IpRange[] = [] @description('An array of Entra ID admin principal names for this resource') param entraIdAdminPrincipals PrincipalNameAndId[] = [] diff --git a/infrastructure/templates/public-api/components/siteAzureAuthentication.bicep b/infrastructure/templates/public-api/components/siteAzureAuthentication.bicep index 0bbce6d3f9a..e0f525dbcde 100644 --- a/infrastructure/templates/public-api/components/siteAzureAuthentication.bicep +++ b/infrastructure/templates/public-api/components/siteAzureAuthentication.bicep @@ -11,7 +11,7 @@ param stagingSlotName string = 'none' param allowedClientIds string[] = [] @description('Specifies an optional set of Principal Ids of Managed Identities that are allowed to access this resource') -param allowedPrincipalIds string[] = [] +param allowedPrincipalIds string[] @description('Specifies whether all calls to this resource should be authenticated or not. Defaults to true') param requireAuthentication bool = true @@ -19,7 +19,7 @@ param requireAuthentication bool = true var properties = { globalValidation: { requireAuthentication: requireAuthentication - unauthenticatedClientAction: requireAuthentication ? 'Return401' : null + unauthenticatedClientAction: requireAuthentication ? 'Return401' : 'AllowAnonymous' } httpSettings: { requireHttps: true @@ -35,15 +35,14 @@ var properties = { allowedAudiences: [ 'api://${clientId}' ] - defaultAuthorizationPolicy: { + defaultAuthorizationPolicy: union({ allowedApplications: union( [clientId], allowedClientIds ) - allowedPrincipals: { - identities: allowedPrincipalIds - } - } + }, length(allowedPrincipalIds) > 0 ? { + identities: allowedPrincipalIds + } : {}) } } } diff --git a/infrastructure/templates/public-api/components/storageAccount.bicep b/infrastructure/templates/public-api/components/storageAccount.bicep index 9d5576d2312..a3e865d0b13 100644 --- a/infrastructure/templates/public-api/components/storageAccount.bicep +++ b/infrastructure/templates/public-api/components/storageAccount.bicep @@ -1,4 +1,4 @@ -import { FirewallRule } from '../types.bicep' +import { IpRange } from '../types.bicep' @description('Specifies the location for all resources.') param location string @@ -10,7 +10,7 @@ param storageAccountName string param allowedSubnetIds string[] = [] @description('Storage Account Network Firewall Rules') -param firewallRules FirewallRule[] = [] +param firewallRules IpRange[] = [] @description('Storage Account SKU') param skuStorageResource 'Standard_LRS' | 'Standard_GRS' | 'Standard_RAGRS' | 'Standard_ZRS' | 'Premium_LRS' | 'Premium_ZRS' | 'Standard_GZRS' | 'Standard_RAGZRS' = 'Standard_LRS' diff --git a/infrastructure/templates/public-api/main.bicep b/infrastructure/templates/public-api/main.bicep index 92c0cafd7e0..4426a92c6ac 100644 --- a/infrastructure/templates/public-api/main.bicep +++ b/infrastructure/templates/public-api/main.bicep @@ -1,5 +1,5 @@ import { abbreviations } from 'abbreviations.bicep' -import { FirewallRule, PrincipalNameAndId, StaticWebAppSku } from 'types.bicep' +import { IpRange, PrincipalNameAndId, StaticWebAppSku } from 'types.bicep' @description('Environment : Subscription name e.g. s101d01. Used as a prefix for created resources.') param subscription string = '' @@ -10,8 +10,8 @@ param location string = resourceGroup().location @description('Public API Storage : Size of the file share in GB.') param publicApiDataFileShareQuota int = 1 -@description('Public API Storage : Firewall rules.') -param storageFirewallRules FirewallRule[] = [] +@description('Provides access to resources for specific IP address ranges used for service maintenance.') +param maintenanceIpRanges IpRange[] = [] @description('Database : administrator login name.') @minLength(0) @@ -36,9 +36,6 @@ param postgreSqlStorageSizeGB int = 32 @description('Database : Azure Database for PostgreSQL Autogrow setting.') param postgreSqlAutoGrowStatus string = 'Disabled' -@description('Database : Firewall rules.') -param postgreSqlFirewallRules FirewallRule[] = [] - @description('Database : Entra ID admin principal names for this resource') param postgreSqlEntraIdAdminPrincipals PrincipalNameAndId[] = [] @@ -67,13 +64,19 @@ param dateProvisioned string = utcNow('u') @description('The tags of the Docker images to deploy.') param dockerImagesTag string = '' -@description('Can we deploy the Container App yet? This is dependent on the PostgreSQL Flexible Server being set up and having users manually added.') -param deployContainerApp bool = true +@description('Do the shared Private DNS Zones need creating or updating?') +param deploySharedPrivateDnsZones bool = false // TODO EES-5128 - Note that this has been added temporarily to avoid 10+ minute deploys where it appears that PSQL // will redeploy even if no changes exist in this deploy from the previous one. @description('Does the PostgreSQL Flexible Server require any updates? False by default to avoid unnecessarily lengthy deploys.') -param updatePsqlFlexibleServer bool = false +param deployPsqlFlexibleServer bool = false + +@description('Does the Public API Container App need creating or updating? This is dependent on the PostgreSQL Flexible Server being set up and having users manually added.') +param deployContainerApp bool = true + +@description('Does the Data Processor need creating or updating?') +param deployDataProcessor bool = true param deployAlerts bool = false @@ -93,6 +96,14 @@ param dataProcessorAppRegistrationClientId string = '' @description('Specifies the Application (Client) Id of a pre-existing App Registration used to represent the API Container App.') param apiAppRegistrationClientId string = '' +@description('Specifies the principal id of the Azure DevOps SPN.') +@secure() +param devopsServicePrincipalId string = '' + +// TODO EES-5446 - reinstate pipelineRunnerCidr when the DevOps runners have a static IP range available. +// @description('Specifies the IP address range of the pipeline runners.') +// param pipelineRunnerCidr string = '' + @description('Specifies whether or not test Themes can be deleted in the environment.') param enableThemeDeletion bool = false @@ -156,6 +167,13 @@ var resourceNames = { } } +var maintenanceFirewallRules = [for maintenanceIpRange in maintenanceIpRanges: { + name: maintenanceIpRange.name + cidr: maintenanceIpRange.cidr + tag: 'Default' + priority: 100 +}] + module vNetModule 'application/shared/virtualNetwork.bicep' = { name: 'virtualNetworkApplicationModuleDeploy' params: { @@ -170,7 +188,8 @@ module coreStorage 'application/shared/coreStorage.bicep' = { } } -module privateDnsZonesModule 'application/shared/privateDnsZones.bicep' = { +module privateDnsZonesModule 'application/shared/privateDnsZones.bicep' = + if (deploySharedPrivateDnsZones) { name: 'privateDnsZonesApplicationModuleDeploy' params: { resourceNames: resourceNames @@ -184,7 +203,7 @@ module publicApiStorageModule 'application/public-api/publicApiStorage.bicep' = location: location resourceNames: resourceNames publicApiDataFileShareQuota: publicApiDataFileShareQuota - storageFirewallRules: storageFirewallRules + storageFirewallRules: maintenanceIpRanges deployAlerts: deployAlerts tagValues: tagValues } @@ -208,7 +227,7 @@ module logAnalyticsWorkspaceModule 'application/shared/logAnalyticsWorkspace.bic } } -module postgreSqlServerModule 'application/shared/postgreSqlFlexibleServer.bicep' = if (updatePsqlFlexibleServer) { +module postgreSqlServerModule 'application/shared/postgreSqlFlexibleServer.bicep' = if (deployPsqlFlexibleServer) { name: 'postgreSqlFlexibleServerApplicationModuleDeploy' params: { location: location @@ -218,7 +237,7 @@ module postgreSqlServerModule 'application/shared/postgreSqlFlexibleServer.bicep entraIdAdminPrincipals: postgreSqlEntraIdAdminPrincipals privateEndpointSubnetId: vNetModule.outputs.psqlFlexibleServerSubnetRef autoGrowStatus: postgreSqlAutoGrowStatus - firewallRules: postgreSqlFirewallRules + firewallRules: maintenanceIpRanges sku: postgreSqlSkuName storageSizeGB: postgreSqlStorageSizeGB deployAlerts: deployAlerts @@ -366,14 +385,35 @@ module appGatewayModule 'application/shared/appGateway.bicep' = if (deployContai } } -module dataProcessorModule 'application/public-api/publicApiDataProcessor.bicep' = { +module dataProcessorModule 'application/public-api/publicApiDataProcessor.bicep' = if (deployDataProcessor) { name: 'publicApiDataProcessorApplicationModuleDeploy' params: { location: location resourceNames: resourceNames applicationInsightsKey: appInsightsModule.outputs.appInsightsKey dataProcessorAppRegistrationClientId: dataProcessorAppRegistrationClientId - storageFirewallRules: storageFirewallRules + devopsServicePrincipalId: devopsServicePrincipalId + storageFirewallRules: maintenanceIpRanges + functionAppFirewallRules: union([ + { + name: 'Admin App Service subnet range' + cidr: vNetModule.outputs.adminAppServiceSubnetCidr + tag: 'Default' + priority: 100 + } + // TODO EES-5446 - remove service tag whitelisting when runner scale set IP range reinstated + { + cidr: 'AzureCloud' + tag: 'ServiceTag' + priority: 101 + name: 'AzureCloud' + } + // TODO EES-5446 - reinstate when static IP range available for runner scale sets + // { + // name: 'Pipeline runner IP address range' + // cidr: pipelineRunnerCidr + // } + ], maintenanceFirewallRules) dataProcessorFunctionAppExists: dataProcessorFunctionAppExists deployAlerts: deployAlerts tagValues: tagValues @@ -386,11 +426,23 @@ module dataProcessorModule 'application/public-api/publicApiDataProcessor.bicep' output dataProcessorContentDbConnectionStringSecretKey string = 'ees-publicapi-data-processor-connectionstring-contentdb' output dataProcessorPsqlConnectionStringSecretKey string = 'ees-publicapi-data-processor-connectionstring-publicdatadb' -output dataProcessorFunctionAppManagedIdentityClientId string = dataProcessorModule.outputs.managedIdentityClientId + +output dataProcessorFunctionAppManagedIdentityClientId string = deployDataProcessor + ? dataProcessorModule.outputs.managedIdentityClientId + : '' + +output dataProcessorFunctionAppUrl string = deployDataProcessor + ? dataProcessorModule.outputs.url + : '' +output dataProcessorFunctionAppStagingUrl string = deployDataProcessor + ? dataProcessorModule.outputs.stagingUrl + : '' + +output dataProcessorPublicApiDataFileShareMountPath string = deployDataProcessor + ? dataProcessorModule.outputs.publicApiDataFileShareMountPath + : '' output coreStorageConnectionStringSecretKey string = coreStorage.outputs.coreStorageConnectionStringSecretKey output keyVaultName string = resourceNames.existingResources.keyVault -output dataProcessorPublicApiDataFileShareMountPath string = dataProcessorModule.outputs.publicApiDataFileShareMountPath - output enableThemeDeletion bool = enableThemeDeletion diff --git a/infrastructure/templates/public-api/types.bicep b/infrastructure/templates/public-api/types.bicep index fb5199aafa5..d21d724fad2 100644 --- a/infrastructure/templates/public-api/types.bicep +++ b/infrastructure/templates/public-api/types.bicep @@ -40,10 +40,18 @@ type ResourceNames = { } } +@export() +type IpRange = { + name: string + cidr: string +} + @export() type FirewallRule = { name: string cidr: string + priority: int + tag: string } @export() diff --git a/src/GovUk.Education.ExploreEducationStatistics.Common/Extensions/HttpRequestExtensions.cs b/src/GovUk.Education.ExploreEducationStatistics.Common/Extensions/HttpRequestExtensions.cs index 97709252ed5..9e60dc9f13a 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Common/Extensions/HttpRequestExtensions.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Common/Extensions/HttpRequestExtensions.cs @@ -111,7 +111,16 @@ public static bool GetRequestParamBool( string paramName, bool defaultValue) { - var paramValue = GetRequestParam(httpRequest, paramName, defaultValue + ""); + var paramValue = GetRequestParam(httpRequest, paramName, defaultValue.ToString()); return bool.Parse(paramValue); } + + public static int GetRequestParamInt( + this HttpRequest httpRequest, + string paramName, + int defaultValue) + { + var paramValue = GetRequestParam(httpRequest, paramName, defaultValue.ToString()); + return int.Parse(paramValue); + } } diff --git a/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor.Tests/ProcessorFunctionsIntegrationTest.cs b/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor.Tests/ProcessorFunctionsIntegrationTest.cs index b15821ee4c5..b79e4c82673 100644 --- a/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor.Tests/ProcessorFunctionsIntegrationTest.cs +++ b/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor.Tests/ProcessorFunctionsIntegrationTest.cs @@ -301,6 +301,7 @@ protected override IEnumerable GetFunctionTypes() typeof(HandleProcessingFailureFunction), typeof(HealthCheckFunctions), typeof(BulkDeleteDataSetVersionsFunction), + typeof(StatusCheckFunction), ]; } } diff --git a/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor/Functions/LongRunningFunctions.cs b/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor/Functions/LongRunningFunctions.cs new file mode 100644 index 00000000000..4835da3a1c5 --- /dev/null +++ b/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor/Functions/LongRunningFunctions.cs @@ -0,0 +1,90 @@ +using System.Diagnostics; +using GovUk.Education.ExploreEducationStatistics.Common.Extensions; +using GovUk.Education.ExploreEducationStatistics.Public.Data.Processor.Extensions; +using GovUk.Education.ExploreEducationStatistics.Public.Data.Processor.Model; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask; +using Microsoft.DurableTask.Client; +using Microsoft.Extensions.Logging; + +namespace GovUk.Education.ExploreEducationStatistics.Public.Data.Processor.Functions; + +public class LongRunningFunctions(ILogger logger) +{ + [Function(nameof(TriggerLongRunningOrchestration))] + public async Task TriggerLongRunningOrchestration( + [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = nameof(TriggerLongRunningOrchestration))] + HttpRequest httpRequest, + [DurableClient] DurableTaskClient client, + CancellationToken cancellationToken) + { + var instanceId = Guid.NewGuid(); + + var durationSeconds = + httpRequest.GetRequestParamInt("durationSeconds", defaultValue: 60); + + const string orchestratorName = + nameof(ProcessLongRunningOrchestration); + + var options = new StartOrchestrationOptions { InstanceId = instanceId.ToString() }; + + logger.LogInformation( + "Scheduling '{OrchestratorName}' (InstanceId={InstanceId}, DurationSeconds={DurationSeconds}))", + orchestratorName, + instanceId, + durationSeconds); + + await client.ScheduleNewOrchestrationInstanceAsync( + orchestratorName, + new LongRunningOrchestrationContext { DurationSeconds = durationSeconds }, + options, + cancellationToken); + + return new OkResult(); + } + + [Function(nameof(ProcessLongRunningOrchestration))] + public static async Task ProcessLongRunningOrchestration( + [OrchestrationTrigger] TaskOrchestrationContext context, + LongRunningOrchestrationContext input) + { + var logger = context.CreateReplaySafeLogger(nameof(ProcessLongRunningOrchestration)); + + logger.LogInformation( + "Processing long-running orchestration (InstanceId={InstanceId}, DurationSeconds={DurationSeconds})", + context.InstanceId, + input.DurationSeconds); + + try + { + await context.CallActivity(nameof(LongRunningActivity), logger, input); + } + catch (Exception e) + { + logger.LogError(e, + "Activity failed with an exception (InstanceId={InstanceId}, DurationSeconds={DurationSeconds})", + context.InstanceId, + input.DurationSeconds); + + await context.CallActivity(ActivityNames.HandleProcessingFailure, logger, context.InstanceId); + } + } + + [Function(nameof(LongRunningActivity))] + public async Task LongRunningActivity( + [ActivityTrigger] LongRunningOrchestrationContext input, + CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.Elapsed.Seconds < input.DurationSeconds) + { + await Task.Delay(10000, cancellationToken); + + logger.LogInformation($"Long-running orchestration running for {stopwatch.Elapsed.Seconds} " + + $"out of {input.DurationSeconds} seconds"); + } + } +} diff --git a/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor/Functions/StatusCheckFunction.cs b/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor/Functions/StatusCheckFunction.cs new file mode 100644 index 00000000000..7a59add4431 --- /dev/null +++ b/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor/Functions/StatusCheckFunction.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.DurableTask.Client; + +namespace GovUk.Education.ExploreEducationStatistics.Public.Data.Processor.Functions; + +public class StatusCheckFunction +{ + private static readonly OrchestrationQuery ActiveOrchestrationsQuery = new() + { + Statuses = + [ + OrchestrationRuntimeStatus.Pending, + OrchestrationRuntimeStatus.Running + ] + }; + + [Function(nameof(StatusCheck))] + [Produces("application/json")] + public static async Task StatusCheck( + [HttpTrigger(AuthorizationLevel.Anonymous, "get")] +#pragma warning disable IDE0060 + HttpRequest request, +#pragma warning restore IDE0060 + [DurableClient] DurableTaskClient client) + { + var activeOrchestrations = await client + .GetAllInstancesAsync(filter: ActiveOrchestrationsQuery) + .CountAsync(); + + return new OkObjectResult(new { ActiveOrchestrations = activeOrchestrations }); + } +} diff --git a/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor/Model/LongRunningOrchestrationContext.cs b/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor/Model/LongRunningOrchestrationContext.cs new file mode 100644 index 00000000000..40eb0ff82dc --- /dev/null +++ b/src/GovUk.Education.ExploreEducationStatistics.Public.Data.Processor/Model/LongRunningOrchestrationContext.cs @@ -0,0 +1,6 @@ +namespace GovUk.Education.ExploreEducationStatistics.Public.Data.Processor.Model; + +public record LongRunningOrchestrationContext +{ + public required int DurationSeconds { get; init; } +}