diff --git a/build/build-variables.yml b/build/build-variables.yml index 8e4c41360b..3815007b34 100644 --- a/build/build-variables.yml +++ b/build/build-variables.yml @@ -16,6 +16,7 @@ variables: DeploymentEnvironmentNameR4BSql: '$(DeploymentEnvironmentNameR4B)-sql' DeploymentEnvironmentNameR5: '$(DeploymentEnvironmentName)-r5' DeploymentEnvironmentNameR5Sql: '$(DeploymentEnvironmentNameR5)-sql' + AcaEnvironmentName: '$(DeploymentEnvironmentName)-acae' # Key Vault names (shorter due to 24 character limit) KeyVaultNameSql: '$(KeyVaultBaseName)-sql' KeyVaultNameR4: '$(KeyVaultBaseName)-r4' diff --git a/build/ci-deploy.yml b/build/ci-deploy.yml index 826e8078ba..f397adb6bb 100644 --- a/build/ci-deploy.yml +++ b/build/ci-deploy.yml @@ -56,6 +56,49 @@ stages: azureSubscription: $(ConnectedServiceName) resourceGroupName: $(ResourceGroupName) +- stage: createAcaEnvironment + displayName: 'Create ACA Managed Environment' + dependsOn: + - provisionEnvironment + jobs: + - job: createAcaEnvironment + displayName: 'Create ACA Managed Environment' + pool: + name: '$(DefaultLinuxPool)' + vmImage: '$(LinuxVmImage)' + steps: + - task: AzurePowerShell@5 + displayName: 'Create ACA Managed Environment' + inputs: + azureSubscription: $(ConnectedServiceName) + azurePowerShellVersion: latestVersion + pwsh: true + ScriptType: inlineScript + Inline: | + $envName = '$(AcaEnvironmentName)'.ToLowerInvariant() + $rgName = '$(ResourceGroupName)' + $location = '$(ResourceGroupRegion)' + + if ($envName.Length -gt 32) { + $envName = $envName.Substring(0, 32).TrimEnd('-') + } + + $existing = Get-AzResource -ResourceGroupName $rgName -ResourceType 'Microsoft.App/managedEnvironments' -Name $envName -ErrorAction SilentlyContinue + if ($null -ne $existing) { + Write-Host "ACA managed environment '$envName' already exists." + return + } + + Write-Host "Creating ACA managed environment '$envName' in '$rgName'..." + New-AzResourceGroupDeployment ` + -Name "aca-env-$envName" ` + -ResourceGroupName $rgName ` + -TemplateFile $(System.DefaultWorkingDirectory)/samples/templates/aca/aca-environment.bicep ` + -environmentName $envName ` + -location $location ` + -Verbose -ErrorAction Stop + Write-Host "ACA managed environment '$envName' created successfully." + - stage: createNsp displayName: 'Create Network Security Perimeter' dependsOn: @@ -113,15 +156,16 @@ stages: - aadTestEnvironment - DockerBuild - createNsp + - createAcaEnvironment jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-cosmos-aca.yml parameters: version: Stu3 webAppName: $(DeploymentEnvironmentName) - appServicePlanName: $(appServicePlanName) - appServicePlanResourceGroup: $(appServicePlanResourceGroup) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultBaseName) subscription: $(ConnectedServiceName) - resourceGroup: $(DeploymentEnvironmentName) + resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) reindexEnabled: true @@ -132,16 +176,17 @@ stages: - aadTestEnvironment - DockerBuild - deploySqlServer + - createAcaEnvironment jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-aca.yml parameters: version: Stu3 sql: true webAppName: $(DeploymentEnvironmentNameSql) - appServicePlanName: $(appServicePlanName) - appServicePlanResourceGroup: $(appServicePlanResourceGroup) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameSql) subscription: $(ConnectedServiceName) - resourceGroup: $(DeploymentEnvironmentName) + resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) schemaAutomaticUpdatesEnabled: 'auto' @@ -155,15 +200,16 @@ stages: - aadTestEnvironment - DockerBuild - createNsp + - createAcaEnvironment jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-cosmos-aca.yml parameters: version: R4 webAppName: $(DeploymentEnvironmentNameR4) - appServicePlanName: $(appServicePlanName) - appServicePlanResourceGroup: $(appServicePlanResourceGroup) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameR4) subscription: $(ConnectedServiceName) - resourceGroup: $(DeploymentEnvironmentName) + resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) reindexEnabled: true @@ -174,16 +220,17 @@ stages: - aadTestEnvironment - DockerBuild - deploySqlServer + - createAcaEnvironment jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-aca.yml parameters: version: R4 sql: true webAppName: $(DeploymentEnvironmentNameR4Sql) - appServicePlanName: $(appServicePlanName) - appServicePlanResourceGroup: $(appServicePlanResourceGroup) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameR4Sql) subscription: $(ConnectedServiceName) - resourceGroup: $(DeploymentEnvironmentName) + resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) schemaAutomaticUpdatesEnabled: 'auto' @@ -197,15 +244,16 @@ stages: - aadTestEnvironment - DockerBuild - createNsp + - createAcaEnvironment jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-cosmos-aca.yml parameters: version: R4B webAppName: $(DeploymentEnvironmentNameR4B) - appServicePlanName: $(appServicePlanName) - appServicePlanResourceGroup: $(appServicePlanResourceGroup) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameR4B) subscription: $(ConnectedServiceName) - resourceGroup: $(DeploymentEnvironmentName) + resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) reindexEnabled: true @@ -216,21 +264,23 @@ stages: - aadTestEnvironment - DockerBuild - deploySqlServer + - createAcaEnvironment jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-aca.yml parameters: version: R4B sql: true webAppName: $(DeploymentEnvironmentNameR4BSql) - appServicePlanName: $(appServicePlanName) - appServicePlanResourceGroup: $(appServicePlanResourceGroup) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameR4BSql) subscription: $(ConnectedServiceName) - resourceGroup: $(DeploymentEnvironmentName) + resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) - sqlServerName: $(DeploymentEnvironmentName) - sqlComputeTier: 'Hyperscale' imageTag: $(ImageTag) schemaAutomaticUpdatesEnabled: 'auto' + sqlServerName: $(DeploymentEnvironmentName) + sqlComputeTier: 'Hyperscale' + reindexEnabled: true - stage: deployR5 displayName: 'Deploy R5 CosmosDB Site' @@ -238,15 +288,16 @@ stages: - aadTestEnvironment - DockerBuild - createNsp + - createAcaEnvironment jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-cosmos-aca.yml parameters: version: R5 webAppName: $(DeploymentEnvironmentNameR5) - appServicePlanName: $(appServicePlanName) - appServicePlanResourceGroup: $(appServicePlanResourceGroup) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameR5) subscription: $(ConnectedServiceName) - resourceGroup: $(DeploymentEnvironmentName) + resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) reindexEnabled: true @@ -257,16 +308,17 @@ stages: - aadTestEnvironment - DockerBuild - deploySqlServer + - createAcaEnvironment jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-aca.yml parameters: version: R5 sql: true webAppName: $(DeploymentEnvironmentNameR5Sql) - appServicePlanName: $(appServicePlanName) - appServicePlanResourceGroup: $(appServicePlanResourceGroup) + acaEnvironmentName: $(AcaEnvironmentName) + keyVaultName: $(KeyVaultNameR5Sql) subscription: $(ConnectedServiceName) - resourceGroup: $(DeploymentEnvironmentName) + resourceGroup: $(ResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) schemaAutomaticUpdatesEnabled: 'auto' diff --git a/build/ci-pipeline.yml b/build/ci-pipeline.yml index d3ed4dffad..d2fb1c8e66 100644 --- a/build/ci-pipeline.yml +++ b/build/ci-pipeline.yml @@ -149,6 +149,7 @@ stages: webAppName: $(DeploymentEnvironmentName) subscription: $(ConnectedServiceName) imageTag: $(ImageTag) + resourceGroup: $(ResourceGroupName) - stage: redeployStu3Sql displayName: 'Redeploy STU3 SQL Site' @@ -161,6 +162,7 @@ stages: webAppName: $(DeploymentEnvironmentNameSql) subscription: $(ConnectedServiceName) imageTag: $(ImageTag) + resourceGroup: $(ResourceGroupName) - stage: testStu3Cosmos displayName: 'Run Stu3 Cosmos Tests' @@ -168,11 +170,11 @@ stages: - BuildArtifacts - redeployStu3 jobs: - - template: ./jobs/run-cosmos-tests.yml + - template: ./jobs/run-cosmos-tests-aca.yml parameters: version: Stu3 - keyVaultName: $(DeploymentEnvironmentName) - appServiceName: $(DeploymentEnvironmentName) + keyVaultName: $(KeyVaultBaseName) + containerAppName: $(DeploymentEnvironmentName) - stage: testStu3Sql displayName: 'Run Stu3 SQL Tests' @@ -180,11 +182,11 @@ stages: - BuildArtifacts - redeployStu3Sql jobs: - - template: ./jobs/run-sql-tests.yml + - template: ./jobs/run-sql-tests-aca.yml parameters: version: Stu3 - keyVaultName: $(DeploymentEnvironmentName) - appServiceName: $(DeploymentEnvironmentName) + keyVaultName: $(KeyVaultNameSql) + containerAppName: $(DeploymentEnvironmentNameSql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest # *********************** R4 *********************** @@ -199,6 +201,7 @@ stages: webAppName: $(DeploymentEnvironmentNameR4) subscription: $(ConnectedServiceName) imageTag: $(ImageTag) + resourceGroup: $(ResourceGroupName) - stage: redeployR4Sql displayName: 'Redeploy R4 SQL Site' @@ -211,6 +214,7 @@ stages: webAppName: $(DeploymentEnvironmentNameR4Sql) subscription: $(ConnectedServiceName) imageTag: $(ImageTag) + resourceGroup: $(ResourceGroupName) - stage: testR4Cosmos displayName: 'Run R4 Cosmos Tests' @@ -218,11 +222,11 @@ stages: - BuildArtifacts - redeployR4 jobs: - - template: ./jobs/run-cosmos-tests.yml + - template: ./jobs/run-cosmos-tests-aca.yml parameters: version: R4 - keyVaultName: $(DeploymentEnvironmentNameR4) - appServiceName: $(DeploymentEnvironmentNameR4) + keyVaultName: $(KeyVaultNameR4) + containerAppName: $(DeploymentEnvironmentNameR4) - stage: testR4Sql displayName: 'Run R4 SQL Tests' @@ -230,11 +234,11 @@ stages: - BuildArtifacts - redeployR4Sql jobs: - - template: ./jobs/run-sql-tests.yml + - template: ./jobs/run-sql-tests-aca.yml parameters: version: R4 - keyVaultName: $(DeploymentEnvironmentNameR4) - appServiceName: $(DeploymentEnvironmentNameR4) + keyVaultName: $(KeyVaultNameR4Sql) + containerAppName: $(DeploymentEnvironmentNameR4Sql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest # *********************** R4B *********************** @@ -249,6 +253,7 @@ stages: webAppName: $(DeploymentEnvironmentNameR4B) subscription: $(ConnectedServiceName) imageTag: $(ImageTag) + resourceGroup: $(ResourceGroupName) - stage: redeployR4BSql displayName: 'Redeploy R4B SQL Site' @@ -261,6 +266,7 @@ stages: webAppName: $(DeploymentEnvironmentNameR4BSql) subscription: $(ConnectedServiceName) imageTag: $(ImageTag) + resourceGroup: $(ResourceGroupName) - stage: testR4BCosmos displayName: 'Run R4B Cosmos Tests' @@ -268,11 +274,11 @@ stages: - BuildArtifacts - redeployR4B jobs: - - template: ./jobs/run-cosmos-tests.yml + - template: ./jobs/run-cosmos-tests-aca.yml parameters: version: R4B - keyVaultName: $(DeploymentEnvironmentNameR4B) - appServiceName: $(DeploymentEnvironmentNameR4B) + keyVaultName: $(KeyVaultNameR4B) + containerAppName: $(DeploymentEnvironmentNameR4B) - stage: testR4BSql displayName: 'Run R4B SQL Tests' @@ -280,11 +286,11 @@ stages: - BuildArtifacts - redeployR4BSql jobs: - - template: ./jobs/run-sql-tests.yml + - template: ./jobs/run-sql-tests-aca.yml parameters: version: R4B - keyVaultName: $(DeploymentEnvironmentNameR4B) - appServiceName: $(DeploymentEnvironmentNameR4B) + keyVaultName: $(KeyVaultNameR4BSql) + containerAppName: $(DeploymentEnvironmentNameR4BSql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest # *********************** R5 *********************** @@ -299,6 +305,7 @@ stages: webAppName: $(DeploymentEnvironmentNameR5) subscription: $(ConnectedServiceName) imageTag: $(ImageTag) + resourceGroup: $(ResourceGroupName) - stage: redeployR5Sql displayName: 'Redeploy R5 SQL Site' @@ -311,6 +318,7 @@ stages: webAppName: $(DeploymentEnvironmentNameR5Sql) subscription: $(ConnectedServiceName) imageTag: $(ImageTag) + resourceGroup: $(ResourceGroupName) - stage: testR5Cosmos displayName: 'Run R5 Cosmos Tests' @@ -318,11 +326,11 @@ stages: - BuildArtifacts - redeployR5 jobs: - - template: ./jobs/run-cosmos-tests.yml + - template: ./jobs/run-cosmos-tests-aca.yml parameters: version: R5 - keyVaultName: $(DeploymentEnvironmentNameR5) - appServiceName: $(DeploymentEnvironmentNameR5) + keyVaultName: $(KeyVaultNameR5) + containerAppName: $(DeploymentEnvironmentNameR5) - stage: testR5Sql displayName: 'Run R5 SQL Tests' @@ -330,11 +338,11 @@ stages: - BuildArtifacts - redeployR5Sql jobs: - - template: ./jobs/run-sql-tests.yml + - template: ./jobs/run-sql-tests-aca.yml parameters: version: R5 - keyVaultName: $(DeploymentEnvironmentNameR5) - appServiceName: $(DeploymentEnvironmentNameR5) + keyVaultName: $(KeyVaultNameR5Sql) + containerAppName: $(DeploymentEnvironmentNameR5Sql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest - stage: aggregateCoverage diff --git a/build/ci-variables.yml b/build/ci-variables.yml index d7e2416f6c..c2ea660cf2 100644 --- a/build/ci-variables.yml +++ b/build/ci-variables.yml @@ -1,8 +1,8 @@ variables: ResourceGroupRegion: 'southcentralus' resourceGroupRoot: 'msh-fhir-ci' - appServicePlanName: '$(resourceGroupRoot)-linux' DeploymentEnvironmentName: '$(resourceGroupRoot)' + AcaEnvironmentName: '$(DeploymentEnvironmentName)-acae' # CI uses the same name for Key Vaults as the deployment environment (no BuildId suffix needed) KeyVaultBaseName: '$(DeploymentEnvironmentName)' ResourceGroupName: '$(resourceGroupRoot)' diff --git a/build/export-pipeline.yml b/build/export-pipeline.yml index d0e5b882ed..5852f6881c 100644 --- a/build/export-pipeline.yml +++ b/build/export-pipeline.yml @@ -45,6 +45,7 @@ stages: parameters: webAppName: $(DeploymentEnvironmentName) subscription: $(ConnectedServiceName) + resourceGroup: $(ResourceGroupName) - stage: redeployStu3Sql displayName: 'Redeploy STU3 SQL Site' @@ -54,6 +55,7 @@ stages: parameters: webAppName: $(DeploymentEnvironmentNameSql) subscription: $(ConnectedServiceName) + resourceGroup: $(ResourceGroupName) - stage: redeployR4 displayName: 'Redeploy R4 Site' @@ -63,6 +65,7 @@ stages: parameters: webAppName: $(DeploymentEnvironmentNameR4) subscription: $(ConnectedServiceName) + resourceGroup: $(ResourceGroupName) - stage: redeployR4Sql displayName: 'Redeploy R4 SQL Site' @@ -72,6 +75,7 @@ stages: parameters: webAppName: $(DeploymentEnvironmentNameR4Sql) subscription: $(ConnectedServiceName) + resourceGroup: $(ResourceGroupName) - stage: testStu3 displayName: 'Run Stu3 Tests' @@ -84,6 +88,8 @@ stages: parameters: version: Stu3 keyVaultName: $(DeploymentEnvironmentName) + cosmosContainerAppName: $(DeploymentEnvironmentName) + sqlContainerAppName: $(DeploymentEnvironmentNameSql) - stage: testR4 displayName: 'Run R4 Tests' @@ -96,6 +102,8 @@ stages: parameters: version: R4 keyVaultName: $(DeploymentEnvironmentNameR4) + cosmosContainerAppName: $(DeploymentEnvironmentNameR4) + sqlContainerAppName: $(DeploymentEnvironmentNameR4Sql) - stage: cleanStorageAccounts displayName: 'Clean Storage Accounts' diff --git a/build/export-variables.yml b/build/export-variables.yml index f4e7eebc4b..03b300353f 100644 --- a/build/export-variables.yml +++ b/build/export-variables.yml @@ -1,8 +1,8 @@ variables: ResourceGroupRegion: 'southcentralus' resourceGroupRoot: 'msh-fhir-export' - appServicePlanName: '$(resourceGroupRoot)-linux' DeploymentEnvironmentName: '$(resourceGroupRoot)' + AcaEnvironmentName: '$(DeploymentEnvironmentName)-acae' ResourceGroupName: '$(resourceGroupRoot)' TestEnvironmentName: 'OSS Export' ImageTag: 'master' diff --git a/build/jobs/e2e-tests.yml b/build/jobs/e2e-tests-aca.yml similarity index 95% rename from build/jobs/e2e-tests.yml rename to build/jobs/e2e-tests-aca.yml index 12b1babfc9..6f28ad4ea9 100644 --- a/build/jobs/e2e-tests.yml +++ b/build/jobs/e2e-tests-aca.yml @@ -1,7 +1,7 @@ parameters: - name: version type: string -- name: appServiceName +- name: containerAppName type: string - name: appServiceType type: string @@ -17,9 +17,11 @@ steps: parameters: version: ${{parameters.version}} - - template: ../tasks/e2e-set-variables.yml + - template: ../tasks/e2e-set-variables-aca.yml parameters: - appServiceName: ${{ parameters.appServiceName }} + containerAppName: ${{ parameters.containerAppName }} + version: ${{ parameters.version }} + appServiceType: ${{ parameters.appServiceType }} - task: PowerShell@2 displayName: 'E2E ${{ parameters.version }} ${{parameters.appServiceType}}${{ parameters.testRunTitleSuffix }}' diff --git a/build/jobs/provision-deploy-aca.yml b/build/jobs/provision-deploy-aca.yml new file mode 100644 index 0000000000..22a9c7d88f --- /dev/null +++ b/build/jobs/provision-deploy-aca.yml @@ -0,0 +1,297 @@ +parameters: +- name: version + type: string +- name: webAppName + type: string +- name: acaEnvironmentName + type: string +- name: subscription + type: string +- name: resourceGroup + type: string +- name: testEnvironmentUrl + type: string +- name: imageTag + type: string +- name: schemaAutomaticUpdatesEnabled + type: string + default: 'auto' +- name: sqlServerName + type: string +- name: reindexEnabled + type: boolean + default: true +- name: minReplicas + type: number + default: 0 +- name: keyVaultName + type: string + +jobs: +- job: provisionEnvironment + pool: + name: '$(SharedLinuxPool)' + vmImage: '$(LinuxVmImage)' + steps: + - task: AzureKeyVault@1 + displayName: 'Azure Key Vault: resolute-oss-tenant-info' + inputs: + azureSubscription: ${{ parameters.subscription }} + KeyVaultName: 'resolute-oss-tenant-info' + + - task: AzurePowerShell@5 + name: SetAcaOutputs + displayName: 'Deploy ACA for SQL and emit test URL' + retryCountOnTaskFailure: 1 + inputs: + azureSubscription: ${{ parameters.subscription }} + azurePowerShellVersion: latestVersion + pwsh: true + ScriptType: inlineScript + Inline: | + Add-Type -AssemblyName System.Web + + # Import retry helper for transient error handling + . $(System.DefaultWorkingDirectory)/release/scripts/PowerShell/Invoke-WithRetry.ps1 + + $deployPath = "$(System.DefaultWorkingDirectory)/test/Configuration" + + $testConfig = ConvertFrom-Json (Get-Content -Raw "$deployPath/testconfiguration.json") + $flattenedTestConfig = $(System.DefaultWorkingDirectory)/release/scripts/PowerShell/ConvertTo-FlattenedConfigurationHashtable.ps1 -InputObject $testConfig + + $additionalProperties = $flattenedTestConfig + $additionalProperties["SqlServer__DeleteAllDataOnStartup"] = "false" + $additionalProperties["SqlServer__AllowDatabaseCreation"] = "true" + $additionalProperties["TaskHosting__PollingFrequencyInSeconds"] = 1 + $additionalProperties["FhirServer__CoreFeatures__SearchParameterCacheRefreshIntervalSeconds"] = 2 + $additionalProperties["ASPNETCORE_FORWARDEDHEADERS_ENABLED"] = "true" + + $staticEnvNames = @( + "ASPNETCORE_FORWARDEDHEADERS_ENABLED", + "KeyVault__Endpoint", + "FhirServer__Security__Enabled", + "FhirServer__Security__EnableAadSmartOnFhirProxy", + "FhirServer__Security__Authentication__Authority", + "FhirServer__Security__Authentication__Audience", + "DataStore", + "SqlServer__Initialize", + "SqlServer__SchemaOptions__AutomaticUpdatesEnabled", + "SqlServer__DeleteAllDataOnStartup", + "SqlServer__AllowDatabaseCreation", + "TaskHosting__Enabled", + "TaskHosting__PollingFrequencyInSeconds", + "TaskHosting__MaxRunningTaskCount", + "FhirServer__CoreFeatures__SearchParameterCacheRefreshIntervalSeconds", + "FhirServer__Operations__Export__Enabled", + "FhirServer__Operations__Export__StorageAccountUri", + "FhirServer__Operations__Import__Enabled", + "FhirServer__Operations__IntegrationDataStore__StorageAccountUri", + "FhirServer__Operations__ConvertData__Enabled", + "FhirServer__Operations__ConvertData__ContainerRegistryServers__0", + "FhirServer__Operations__Reindex__Enabled" + ) + + $additionalEnvVars = @() + foreach ($entry in $additionalProperties.GetEnumerator()) { + if ($staticEnvNames -contains $entry.Key) { + continue + } + + if ($null -eq $entry.Value) { + continue + } + + $value = if ($entry.Value -is [bool]) { $entry.Value.ToString().ToLowerInvariant() } else { [string]$entry.Value } + if ([string]::IsNullOrWhiteSpace($value)) { + continue + } + + $additionalEnvVars += @{ + name = [string]$entry.Key + value = $value + } + } + + $webAppName = "${{ parameters.webAppName }}".ToLowerInvariant() + $acaEnvironmentName = "${{ parameters.acaEnvironmentName }}".ToLowerInvariant() + + if ($acaEnvironmentName.Length -gt 32) { + $acaEnvironmentName = $acaEnvironmentName.Substring(0, 32).TrimEnd('-') + } + + if ($acaEnvironmentName.Length -lt 2) { + $acaEnvironmentName = "ca-$acaEnvironmentName" + } + + $templateParameters = @{ + containerAppName = $webAppName + containerAppsEnvironmentName = $acaEnvironmentName + keyVaultName = "${{ parameters.keyVaultName }}".ToLowerInvariant() + fhirVersion = "${{ parameters.version }}" + registryName = '$(azureContainerRegistry)' + imageTag = "${{ parameters.imageTag }}" + sqlServerName = "${{ parameters.sqlServerName }}".ToLowerInvariant() + sqlSchemaAutomaticUpdatesEnabled = "${{ parameters.schemaAutomaticUpdatesEnabled }}" + securityAuthenticationAuthority = "https://sts.windows.net/$(tenant-id-guid)" + securityAuthenticationAudience = "${{ parameters.testEnvironmentUrl }}" + enableExport = $true + enableImport = $true + enableConvertData = $true + enableReindex = if ("${{ parameters.reindexEnabled }}" -eq "true") { $true } else { $false } + minReplicas = ${{ parameters.minReplicas }} + maxReplicas = 8 + acrPullUserAssignedManagedIdentityResourceId = '/subscriptions/a1766500-6fd5-4f5c-8515-607798271014/resourceGroups/HealthPlatformRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/acr-pull-uami' + additionalEnvVars = $additionalEnvVars + } + + $deploymentName = "$webAppName" + $resourceGroupName = "${{ parameters.resourceGroup }}" + $sqlServerName = "${{ parameters.sqlServerName }}".ToLowerInvariant() + + Write-Host "Provisioning ACA resources" + Write-Host "ResourceGroupName: $resourceGroupName" + Write-Host "ContainerAppName: $webAppName" + Write-Host "ContainerAppsEnvironmentName: $acaEnvironmentName" + + # Ensure the SQL database exists before deploying the container. + # The deploySqlServer stage creates the SQL server but NOT the database. + # Azure SQL returns error 18456 (not 4060) when a AAD token targets a + # non-existent database, preventing the FHIR app from self-creating it. + $sqlDatabaseName = "FHIR${{ parameters.version }}" + $existingDb = Get-AzSqlDatabase -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -DatabaseName $sqlDatabaseName -ErrorAction SilentlyContinue + if ($null -eq $existingDb) { + Write-Host "Database '$sqlDatabaseName' does not exist on server '$sqlServerName'. Creating..." + Invoke-WithRetry -OperationName "Create SQL Database" -ScriptBlock { + New-AzSqlDatabase -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -DatabaseName $sqlDatabaseName -Edition "GeneralPurpose" -RequestedServiceObjectiveName "GP_Gen5_4" -CollationName "SQL_Latin1_General_CP1_CI_AS" -ErrorAction Stop + } + Write-Host "Database '$sqlDatabaseName' created successfully." + } else { + Write-Host "Database '$sqlDatabaseName' already exists on server '$sqlServerName'." + } + + $deploymentResult = Invoke-WithRetry -OperationName "ACA Deployment" -ScriptBlock { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $resourceGroupName -TemplateFile $(System.DefaultWorkingDirectory)/samples/templates/aca/fhir-sql-aca.bicep -TemplateParameterObject $templateParameters -Verbose -ErrorAction Stop + } + + $managedEnvironment = Get-AzResource -ResourceGroupName $resourceGroupName -ResourceType "Microsoft.App/managedEnvironments" -Name $acaEnvironmentName -ApiVersion "2023-05-01" -ExpandProperties + if ($null -eq $managedEnvironment) { + throw "Container Apps environment '$acaEnvironmentName' was not found in resource group '$resourceGroupName'." + } + + $managedEnvironmentData = $managedEnvironment | ConvertTo-Json -Depth 100 | ConvertFrom-Json + $outboundIps = @() + + if ($null -ne $managedEnvironmentData.Properties.outboundIpAddresses) { + if ($managedEnvironmentData.Properties.outboundIpAddresses -is [string]) { + $outboundIps += ($managedEnvironmentData.Properties.outboundIpAddresses -split ',') + } + else { + foreach ($ip in $managedEnvironmentData.Properties.outboundIpAddresses) { + $outboundIps += [string]$ip + } + } + } + + if (-not [string]::IsNullOrWhiteSpace([string]$managedEnvironmentData.Properties.staticIp)) { + $outboundIps += [string]$managedEnvironmentData.Properties.staticIp + } + + $outboundIps = $outboundIps | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique + + # Ensure Azure services firewall access is enabled for SQL. ACA egress IPs can change, + # and this avoids startup failures when outbound IP discovery is incomplete. + $allowAzureServicesRule = Get-AzSqlServerFirewallRule -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -FirewallRuleName "AllowAzureServices" -ErrorAction SilentlyContinue + if ($null -ne $allowAzureServicesRule) { + if ($allowAzureServicesRule.StartIpAddress -eq "0.0.0.0" -and $allowAzureServicesRule.EndIpAddress -eq "0.0.0.0") { + Write-Host "SQL firewall rule 'AllowAzureServices' already exists with expected address range." + } + else { + Invoke-WithRetry -OperationName "SQL Firewall Rule Update AllowAzureServices" -ScriptBlock { + Set-AzSqlServerFirewallRule -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -FirewallRuleName "AllowAzureServices" -StartIpAddress "0.0.0.0" -EndIpAddress "0.0.0.0" -ErrorAction Stop + } + } + } + else { + Invoke-WithRetry -OperationName "SQL Firewall Rule Create AllowAzureServices" -ScriptBlock { + New-AzSqlServerFirewallRule -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -FirewallRuleName "AllowAzureServices" -StartIPAddress "0.0.0.0" -EndIPAddress "0.0.0.0" -ErrorAction Stop + } + } + + if ($outboundIps.Count -eq 0) { + Write-Warning "ACA outbound IP addresses were not found. Continuing with AllowAzureServices firewall rule only." + } + else { + $ruleIndex = 0 + foreach ($ip in $outboundIps) { + $ruleIndex++ + $ruleName = "AcaOutbound-$ruleIndex" + $existingRule = Get-AzSqlServerFirewallRule -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -FirewallRuleName $ruleName -ErrorAction SilentlyContinue + if ($null -ne $existingRule -and $existingRule.StartIpAddress -eq $ip -and $existingRule.EndIpAddress -eq $ip) { + Write-Host "SQL firewall rule '$ruleName' already exists for $ip." + continue + } + + if ($null -ne $existingRule) { + Invoke-WithRetry -OperationName "SQL Firewall Rule Update $ruleName" -ScriptBlock { + Set-AzSqlServerFirewallRule -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -FirewallRuleName $ruleName -StartIpAddress $ip -EndIpAddress $ip -ErrorAction Stop + } + } + else { + Invoke-WithRetry -OperationName "SQL Firewall Rule Create $ruleName" -ScriptBlock { + New-AzSqlServerFirewallRule -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -FirewallRuleName $ruleName -StartIPAddress $ip -EndIPAddress $ip -ErrorAction Stop + } + } + } + } + + $containerAppUrl = $deploymentResult.Outputs.containerAppUrl.Value + if ([string]::IsNullOrWhiteSpace($containerAppUrl)) { + throw "ACA deployment output did not contain containerAppUrl" + } + + $containerAppUrl = $containerAppUrl.TrimEnd('/') + $healthCheckUrl = "$containerAppUrl/health/check" + + $timeoutMinutes = 7 + $consecutiveSuccessRequired = 5 + $consecutiveSuccessCount = 0 + $startTime = Get-Date + $timeoutTime = $startTime.AddMinutes($timeoutMinutes) + + Write-Host "Starting health check for $healthCheckUrl" + Write-Host "Timeout: $timeoutMinutes minutes, consecutive successes required: $consecutiveSuccessRequired" + + Do { + Start-Sleep -s 5 + Write-Host "Checking: $healthCheckUrl (consecutive successes: $consecutiveSuccessCount/$consecutiveSuccessRequired)" + try { + $response = Invoke-WebRequest -Uri $healthCheckUrl -UseBasicParsing + if ($response.StatusCode -eq 200) { + $consecutiveSuccessCount++ + Write-Host "Success ($consecutiveSuccessCount/$consecutiveSuccessRequired)" + } + else { + Write-Host "Non-200 response ($($response.StatusCode)), resetting count" + $consecutiveSuccessCount = 0 + } + } + catch { + Write-Host $_.Exception.Message + $consecutiveSuccessCount = 0 + } + finally { + $Error.Clear() + } + + if ((Get-Date) -gt $timeoutTime) { + $elapsed = [math]::Round(((Get-Date) - $startTime).TotalMinutes, 2) + Write-Error "Health check timed out after $elapsed minutes ($consecutiveSuccessCount/$consecutiveSuccessRequired consecutive successes)" + throw "ACA health check timed out" + } + } While ($consecutiveSuccessCount -lt $consecutiveSuccessRequired) + + $elapsed = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 2) + Write-Host "ACA health check passed with $consecutiveSuccessRequired consecutive successes after $elapsed seconds" + + Write-Host "##vso[task.setvariable variable=TestEnvironmentUrl_${{ parameters.version }}_Sql;isOutput=true]$containerAppUrl" + Write-Host "##vso[task.setvariable variable=TestEnvironmentUrl_Sql;isOutput=true]$containerAppUrl" diff --git a/build/jobs/provision-deploy-cosmos-aca.yml b/build/jobs/provision-deploy-cosmos-aca.yml new file mode 100644 index 0000000000..c1d5aebc90 --- /dev/null +++ b/build/jobs/provision-deploy-cosmos-aca.yml @@ -0,0 +1,319 @@ +parameters: +- name: version + type: string +- name: webAppName + type: string +- name: acaEnvironmentName + type: string +- name: subscription + type: string +- name: resourceGroup + type: string +- name: testEnvironmentUrl + type: string +- name: imageTag + type: string +- name: reindexEnabled + type: boolean + default: true +- name: minReplicas + type: number + default: 0 +- name: keyVaultName + type: string + +jobs: +- job: provisionEnvironment + pool: + name: '$(SharedLinuxPool)' + vmImage: '$(LinuxVmImage)' + steps: + - task: AzureKeyVault@1 + displayName: 'Azure Key Vault: resolute-oss-tenant-info' + inputs: + azureSubscription: ${{ parameters.subscription }} + KeyVaultName: 'resolute-oss-tenant-info' + + - task: AzurePowerShell@5 + name: SetAcaOutputs + displayName: 'Deploy ACA for Cosmos and emit test URL' + retryCountOnTaskFailure: 1 + inputs: + azureSubscription: ${{ parameters.subscription }} + azurePowerShellVersion: latestVersion + pwsh: true + ScriptType: inlineScript + Inline: | + Add-Type -AssemblyName System.Web + + # Import retry helper for transient error handling + . $(System.DefaultWorkingDirectory)/release/scripts/PowerShell/Invoke-WithRetry.ps1 + + $deployPath = "$(System.DefaultWorkingDirectory)/test/Configuration" + + $testConfig = ConvertFrom-Json (Get-Content -Raw "$deployPath/testconfiguration.json") + $flattenedTestConfig = $(System.DefaultWorkingDirectory)/release/scripts/PowerShell/ConvertTo-FlattenedConfigurationHashtable.ps1 -InputObject $testConfig + + $additionalProperties = $flattenedTestConfig + $additionalProperties["TaskHosting__PollingFrequencyInSeconds"] = 1 + $additionalProperties["FhirServer__CoreFeatures__SearchParameterCacheRefreshIntervalSeconds"] = 2 + $additionalProperties["ASPNETCORE_FORWARDEDHEADERS_ENABLED"] = "true" + + $staticEnvNames = @( + "ASPNETCORE_FORWARDEDHEADERS_ENABLED", + "KeyVault__Endpoint", + "FhirServer__Security__Enabled", + "FhirServer__Security__EnableAadSmartOnFhirProxy", + "FhirServer__Security__Authentication__Authority", + "FhirServer__Security__Authentication__Audience", + "DataStore", + "CosmosDb__UseManagedIdentity", + "CosmosDb__ContinuationTokenSizeLimitInKb", + "CosmosDb__UseQueueClientJobs", + "TaskHosting__Enabled", + "TaskHosting__PollingFrequencyInSeconds", + "TaskHosting__MaxRunningTaskCount", + "FhirServer__CoreFeatures__SearchParameterCacheRefreshIntervalSeconds", + "FhirServer__Operations__Export__Enabled", + "FhirServer__Operations__Export__StorageAccountUri", + "FhirServer__Operations__Import__Enabled", + "FhirServer__Operations__IntegrationDataStore__StorageAccountUri", + "FhirServer__Operations__ConvertData__Enabled", + "FhirServer__Operations__ConvertData__ContainerRegistryServers__0", + "FhirServer__Operations__Reindex__Enabled" + ) + + $additionalEnvVars = @() + foreach ($entry in $additionalProperties.GetEnumerator()) { + if ($staticEnvNames -contains $entry.Key) { + continue + } + + if ($null -eq $entry.Value) { + continue + } + + $value = if ($entry.Value -is [bool]) { $entry.Value.ToString().ToLowerInvariant() } else { [string]$entry.Value } + if ([string]::IsNullOrWhiteSpace($value)) { + continue + } + + $additionalEnvVars += @{ + name = [string]$entry.Key + value = $value + } + } + + $webAppName = "${{ parameters.webAppName }}".ToLowerInvariant() + $acaEnvironmentName = "${{ parameters.acaEnvironmentName }}".ToLowerInvariant() + + if ($acaEnvironmentName.Length -gt 32) { + $acaEnvironmentName = $acaEnvironmentName.Substring(0, 32).TrimEnd('-') + } + + if ($acaEnvironmentName.Length -lt 2) { + $acaEnvironmentName = "ca-$acaEnvironmentName" + } + + # Cosmos DB account uses the same name as the container app (matching App Service convention) + $cosmosDbAccountName = $webAppName + $resourceGroupName = "${{ parameters.resourceGroup }}" + + # Ensure Cosmos DB account exists + $existingAccount = Get-AzCosmosDBAccount -ResourceGroupName $resourceGroupName -Name $cosmosDbAccountName -ErrorAction SilentlyContinue + if ($null -eq $existingAccount) { + Write-Host "Cosmos DB account '$cosmosDbAccountName' does not exist. Creating..." + Invoke-WithRetry -OperationName "Create Cosmos DB Account" -ScriptBlock { + New-AzCosmosDBAccount ` + -ResourceGroupName $resourceGroupName ` + -Name $cosmosDbAccountName ` + -Location (Get-AzResourceGroup -Name $resourceGroupName).Location ` + -ApiKind "Sql" ` + -DefaultConsistencyLevel "Strong" ` + -ErrorAction Stop + } + Write-Host "Cosmos DB account '$cosmosDbAccountName' created successfully." + } else { + Write-Host "Cosmos DB account '$cosmosDbAccountName' already exists." + } + + # Ensure Cosmos DB database exists + $cosmosDbDatabaseName = "health" + $existingDb = Get-AzCosmosDBSqlDatabase -ResourceGroupName $resourceGroupName -AccountName $cosmosDbAccountName -Name $cosmosDbDatabaseName -ErrorAction SilentlyContinue + if ($null -eq $existingDb) { + Write-Host "Cosmos DB database '$cosmosDbDatabaseName' does not exist. Creating with autoscale 10000 RU..." + Invoke-WithRetry -OperationName "Create Cosmos DB Database" -ScriptBlock { + New-AzCosmosDBSqlDatabase ` + -ResourceGroupName $resourceGroupName ` + -AccountName $cosmosDbAccountName ` + -Name $cosmosDbDatabaseName ` + -AutoscaleMaxThroughput 10000 ` + -ErrorAction Stop + } + Write-Host "Cosmos DB database '$cosmosDbDatabaseName' created successfully." + } else { + Write-Host "Cosmos DB database '$cosmosDbDatabaseName' already exists." + } + + # Add DevOps MI permission to Cosmos database (Data Contributor role) + Write-Host "Adding DevOps MI permission to Cosmos database..." + $account = Get-AzContext + $principalId = Invoke-WithRetry -OperationName "Get Service Principal" -ScriptBlock { + (Get-AzADServicePrincipal -ApplicationId $account.Account.Id -ErrorAction Stop).Id + } + + Invoke-WithRetry -OperationName "CosmosDB Role Assignment" -ScriptBlock { + New-AzCosmosDBSqlRoleAssignment ` + -AccountName $cosmosDbAccountName ` + -ResourceGroupName $resourceGroupName ` + -Scope "/" ` + -PrincipalId $principalId ` + -RoleDefinitionId "00000000-0000-0000-0000-000000000002" ` + -ErrorAction Stop + } + Write-Host "DevOps MI Cosmos DB role assignment completed." + + $templateParameters = @{ + containerAppName = $webAppName + containerAppsEnvironmentName = $acaEnvironmentName + keyVaultName = "${{ parameters.keyVaultName }}".ToLowerInvariant() + fhirVersion = "${{ parameters.version }}" + registryName = '$(azureContainerRegistry)' + imageTag = "${{ parameters.imageTag }}" + cosmosDbAccountName = $cosmosDbAccountName + securityAuthenticationAuthority = "https://sts.windows.net/$(tenant-id-guid)" + securityAuthenticationAudience = "${{ parameters.testEnvironmentUrl }}" + enableExport = $true + enableImport = $true + enableConvertData = $true + enableReindex = if ("${{ parameters.reindexEnabled }}" -eq "true") { $true } else { $false } + minReplicas = ${{ parameters.minReplicas }} + maxReplicas = 8 + acrPullUserAssignedManagedIdentityResourceId = '/subscriptions/a1766500-6fd5-4f5c-8515-607798271014/resourceGroups/HealthPlatformRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/acr-pull-uami' + additionalEnvVars = $additionalEnvVars + } + + $deploymentName = "$webAppName" + + Write-Host "Provisioning ACA resources" + Write-Host "ResourceGroupName: $resourceGroupName" + Write-Host "ContainerAppName: $webAppName" + Write-Host "ContainerAppsEnvironmentName: $acaEnvironmentName" + Write-Host "CosmosDbAccountName: $cosmosDbAccountName" + + $deploymentResult = Invoke-WithRetry -OperationName "ACA Deployment" -ScriptBlock { + New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $resourceGroupName -TemplateFile $(System.DefaultWorkingDirectory)/samples/templates/aca/fhir-cosmos-aca.bicep -TemplateParameterObject $templateParameters -Verbose -ErrorAction Stop + } + + # Assign Cosmos DB Data Contributor role to the container app's system-assigned identity + $containerAppPrincipalId = (Get-AzResource -ResourceGroupName $resourceGroupName -ResourceType "Microsoft.App/containerApps" -Name $webAppName -ApiVersion "2023-05-01" -ExpandProperties).Identity.PrincipalId + if (-not [string]::IsNullOrWhiteSpace($containerAppPrincipalId)) { + Write-Host "Assigning Cosmos DB Data Contributor role to container app identity ($containerAppPrincipalId)..." + Invoke-WithRetry -OperationName "CosmosDB Role Assignment for Container App" -ScriptBlock { + New-AzCosmosDBSqlRoleAssignment ` + -AccountName $cosmosDbAccountName ` + -ResourceGroupName $resourceGroupName ` + -Scope "/" ` + -PrincipalId $containerAppPrincipalId ` + -RoleDefinitionId "00000000-0000-0000-0000-000000000002" ` + -ErrorAction Stop + } + Write-Host "Container app Cosmos DB role assignment completed." + + # Assign DocumentDB Account Contributor ARM role so ResourceManagerCollectionSetup + # can perform control plane operations (read/create databases and collections). + $cosmosDbResourceId = (Get-AzCosmosDBAccount -ResourceGroupName $resourceGroupName -Name $cosmosDbAccountName).Id + Write-Host "Assigning DocumentDB Account Contributor ARM role on Cosmos DB account..." + $existingArmRole = Get-AzRoleAssignment -ObjectId $containerAppPrincipalId -RoleDefinitionName "DocumentDB Account Contributor" -Scope $cosmosDbResourceId -ErrorAction SilentlyContinue + if ($null -eq $existingArmRole) { + Invoke-WithRetry -OperationName "ARM Role Assignment for Container App" -ScriptBlock { + New-AzRoleAssignment ` + -ObjectId $containerAppPrincipalId ` + -RoleDefinitionName "DocumentDB Account Contributor" ` + -Scope $cosmosDbResourceId ` + -ErrorAction Stop + } + Write-Host "DocumentDB Account Contributor ARM role assigned." + } else { + Write-Host "DocumentDB Account Contributor ARM role already assigned." + } + } else { + Write-Warning "Could not retrieve container app system-assigned identity principal ID." + } + + # Associate Cosmos DB with Network Security Perimeter + $nspName = "nsp-$resourceGroupName" + $associationName = "cosmosdb-$webAppName-association" + + Write-Host "Associating CosmosDB with Network Security Perimeter: $nspName" + $nspTemplateParameters = @{ + 'nspName' = $nspName + 'resourceName' = $cosmosDbAccountName + 'resourceType' = 'Microsoft.DocumentDB/databaseAccounts' + 'associationName' = $associationName + 'accessMode' = 'Learning' + 'nspProfile' = 'default' + } + + Invoke-WithRetry -OperationName "NSP Association Deployment" -ScriptBlock { + New-AzResourceGroupDeployment ` + -ResourceGroupName $resourceGroupName ` + -Name "$webAppName-cosmosdb-nsp-association" ` + -TemplateFile $(System.DefaultWorkingDirectory)/samples/templates/nsp-resource-association.json ` + -TemplateParameterObject $nspTemplateParameters ` + -Verbose ` + -ErrorAction Stop + } + Write-Host "Successfully associated CosmosDB with NSP." + + $containerAppUrl = $deploymentResult.Outputs.containerAppUrl.Value + if ([string]::IsNullOrWhiteSpace($containerAppUrl)) { + throw "ACA deployment output did not contain containerAppUrl" + } + + $containerAppUrl = $containerAppUrl.TrimEnd('/') + $healthCheckUrl = "$containerAppUrl/health/check" + + $timeoutMinutes = 7 + $consecutiveSuccessRequired = 5 + $consecutiveSuccessCount = 0 + $startTime = Get-Date + $timeoutTime = $startTime.AddMinutes($timeoutMinutes) + + Write-Host "Starting health check for $healthCheckUrl" + Write-Host "Timeout: $timeoutMinutes minutes, consecutive successes required: $consecutiveSuccessRequired" + + Do { + Start-Sleep -s 5 + Write-Host "Checking: $healthCheckUrl (consecutive successes: $consecutiveSuccessCount/$consecutiveSuccessRequired)" + try { + $response = Invoke-WebRequest -Uri $healthCheckUrl -UseBasicParsing + if ($response.StatusCode -eq 200) { + $consecutiveSuccessCount++ + Write-Host "Success ($consecutiveSuccessCount/$consecutiveSuccessRequired)" + } + else { + Write-Host "Non-200 response ($($response.StatusCode)), resetting count" + $consecutiveSuccessCount = 0 + } + } + catch { + Write-Host $_.Exception.Message + $consecutiveSuccessCount = 0 + } + finally { + $Error.Clear() + } + + if ((Get-Date) -gt $timeoutTime) { + $elapsed = [math]::Round(((Get-Date) - $startTime).TotalMinutes, 2) + Write-Error "Health check timed out after $elapsed minutes ($consecutiveSuccessCount/$consecutiveSuccessRequired consecutive successes)" + throw "ACA health check timed out" + } + } While ($consecutiveSuccessCount -lt $consecutiveSuccessRequired) + + $elapsed = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 2) + Write-Host "ACA health check passed with $consecutiveSuccessRequired consecutive successes after $elapsed seconds" + + Write-Host "##vso[task.setvariable variable=TestEnvironmentUrl_${{ parameters.version }};isOutput=true]$containerAppUrl" + Write-Host "##vso[task.setvariable variable=TestEnvironmentUrl;isOutput=true]$containerAppUrl" diff --git a/build/jobs/provision-deploy.yml b/build/jobs/provision-deploy.yml deleted file mode 100644 index 2186eb5abd..0000000000 --- a/build/jobs/provision-deploy.yml +++ /dev/null @@ -1,239 +0,0 @@ -parameters: -- name: version - type: string -- name: sql - type: boolean - default: false -- name: webAppName - type: string -- name: appServicePlanName - type: string -- name: appServicePlanResourceGroup - type: string -- name: subscription - type: string -- name: resourceGroup - type: string -- name: testEnvironmentUrl - type: string -- name: imageTag - type: string -- name: schemaAutomaticUpdatesEnabled - type: string - default: 'tool' -- name: sqlServerName - type: string - default: '' -- name: sqlComputeTier - type: string - default: 'Standard' -- name: reindexEnabled - type: boolean - default: true -- name: keyVaultName - type: string - default: '' - -jobs: -- job: provisionEnvironment - pool: - name: '$(SharedLinuxPool)' - vmImage: '$(LinuxVmImage)' - steps: - - task: AzureKeyVault@1 - displayName: 'Azure Key Vault: resolute-oss-tenant-info' - inputs: - azureSubscription: $(ConnectedServiceName) - KeyVaultName: 'resolute-oss-tenant-info' - - - task: AzurePowerShell@5 - displayName: 'Azure PowerShell script: InlineScript' - retryCountOnTaskFailure: 1 - inputs: - azureSubscription: $(ConnectedServiceName) - azurePowerShellVersion: latestVersion - pwsh: true - ScriptType: inlineScript - Inline: | - Add-Type -AssemblyName System.Web - - # Import retry helper for transient error handling - . $(System.DefaultWorkingDirectory)/release/scripts/PowerShell/Invoke-WithRetry.ps1 - - $deployPath = "$(System.DefaultWorkingDirectory)/test/Configuration" - - $testConfig = (ConvertFrom-Json (Get-Content -Raw "$deployPath/testconfiguration.json")) - $flattenedTestConfig = $(System.DefaultWorkingDirectory)/release/scripts/PowerShell/ConvertTo-FlattenedConfigurationHashtable.ps1 -InputObject $testConfig - - $additionalProperties = $flattenedTestConfig - - $additionalProperties["SqlServer__DeleteAllDataOnStartup"] = "false" - $additionalProperties["SqlServer__AllowDatabaseCreation"] = "true" - # Cosmos DB autoscale is configured in the ARM template (10,000 RU max) - $additionalProperties["TaskHosting__PollingFrequencyInSeconds"] = 1 - $additionalProperties["FhirServer__CoreFeatures__SearchParameterCacheRefreshIntervalSeconds"] = 2 - $additionalProperties["ASPNETCORE_FORWARDEDHEADERS_ENABLED"] = "true" - - $webAppName = "${{ parameters.webAppName }}".ToLower() - Write-Host "Web App Name: $webAppName" - $templateParameters = @{ - fhirVersion = "${{ parameters.version }}" - appServicePlanName = "${{ parameters.appServicePlanName }}" - appServicePlanSku = "P0V3" - numberOfInstances = 3 - serviceName = $webAppName - keyVaultName = "${{ parameters.keyVaultName }}".ToLower() - securityAuthenticationAuthority = "https://sts.windows.net/$(tenant-id-guid)" - securityAuthenticationAudience = "${{ parameters.testEnvironmentUrl }}" - additionalFhirServerConfigProperties = $additionalProperties - enableAadSmartOnFhirProxy = $true - enableExport = $true - enableConvertData = $true - enableImport = $true - backgroundTaskCount = 2 - enableReindex = if ("${{ parameters.reindexEnabled }}" -eq "true") { $true } else { $false } - registryName = '$(azureContainerRegistry)' - imageTag = '${{ parameters.imageTag }}' - acrPullUserAssignedManagedIdentityResourceId = '/subscriptions/a1766500-6fd5-4f5c-8515-607798271014/resourceGroups/HealthPlatformRG/providers/Microsoft.ManagedIdentity/userAssignedIdentities/acr-pull-uami' - acrPullUserAssignedManagedIdentityClientId = 'e4acf07d-0124-4373-9ea4-bf5c53af647d' - isNspExisting = $true - } - - if("${{ parameters.sql }}" -eq "true"){ - # Set SQL Variables - $templateParameters["solutionType"] = "FhirServerSqlServer" - $templateParameters["sqlServerName"] = "${{parameters.sqlServerName}}".ToLower() - $templateParameters["sqlServerNewOrExisting"] = "existing" - $templateParameters["sqlSchemaAutomaticUpdatesEnabled"] = "${{parameters.schemaAutomaticUpdatesEnabled}}" - $templateParameters["sqlDatabaseComputeTier"] = "${{parameters.sqlComputeTier}}" - } - - $deploymentName = $webAppName - $resourceGroupName = "${{ parameters.resourceGroup }}" - - Write-Host "Provisioning Resource Group" - Write-Host "ResourceGroupName: ${{ parameters.resourceGroup }}" - - # Set NSP name to match ARM template convention - $nspName = "nsp-$resourceGroupName" - $templateParameters["networkSecurityPerimeterName"] = $nspName - - # Check if a deployment with the specified name already exists - $existingDeployment = Get-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $resourceGroupName -ErrorAction SilentlyContinue - - # If the deployment exists and is ongoing, wait for it to complete - if ($existingDeployment -and ($existingDeployment.ProvisioningState -eq "Running" -or $existingDeployment.ProvisioningState -eq "Accepted")) { - Write-Host "Waiting for existing deployment '$deploymentName' to complete..." - do { - Start-Sleep -Seconds 10 - $existingDeployment = Get-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $resourceGroupName - } while ($existingDeployment.ProvisioningState -eq "Running" -or $existingDeployment.ProvisioningState -eq "Accepted") - Write-Host "Existing deployment completed." - } - else - { - try { - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $resourceGroupName -TemplateFile $(System.DefaultWorkingDirectory)/samples/templates/default-azuredeploy-docker.json -TemplateParameterObject $templateParameters -Verbose - } catch { - if ("${{ parameters.sql }}" -eq "true") { - $templateParameters["sqlServerNewOrExisting"] = "new" - New-AzResourceGroupDeployment -Name $deploymentName -ResourceGroupName $resourceGroupName -TemplateFile $(System.DefaultWorkingDirectory)/samples/templates/default-azuredeploy-docker.json -TemplateParameterObject $templateParameters -Verbose - } - } - } - - if("${{ parameters.sql }}" -eq "false"){ - Write-Host "Add DevOps MI permission to Cosmos database" - - $account = Get-AzContext - - # Retry Get-AzADServicePrincipal for AAD replication delays - $principalId = Invoke-WithRetry -OperationName "Get Service Principal" -ScriptBlock { - (Get-AzADServicePrincipal -ApplicationId $account.Account.Id -ErrorAction Stop).Id - } - - # Retry CosmosDB role assignment for resource propagation delays - Invoke-WithRetry -OperationName "CosmosDB Role Assignment" -ScriptBlock { - New-AzCosmosDBSqlRoleAssignment ` - -AccountName $webAppName ` - -ResourceGroupName $resourceGroupName ` - -Scope "/" ` - -PrincipalId $principalId ` - -RoleDefinitionId "00000000-0000-0000-0000-000000000002" ` - -ErrorAction Stop - } - - # Associate CosmosDB with Network Security Perimeter using external template - Write-Host "Associating CosmosDB with Network Security Perimeter: $nspName" - - $associationName = "cosmosdb-$webAppName-association" - - Write-Host "Creating NSP association using external ARM template..." - Write-Host "NSP Name: $nspName" - Write-Host "CosmosDB Account: $webAppName" - Write-Host "Association Name: $associationName" - - $nspTemplateParameters = @{ - 'nspName' = $nspName - 'resourceName' = $webAppName - 'resourceType' = 'Microsoft.DocumentDB/databaseAccounts' - 'associationName' = $associationName - 'accessMode' = 'Learning' - 'nspProfile' = 'default' - } - - # Retry NSP association deployment for resource propagation delays - $nspDeploymentResult = Invoke-WithRetry -OperationName "NSP Association Deployment" -ScriptBlock { - New-AzResourceGroupDeployment ` - -ResourceGroupName $resourceGroupName ` - -Name "$webAppName-cosmosdb-nsp-association" ` - -TemplateFile $(System.DefaultWorkingDirectory)/samples/templates/nsp-resource-association.json ` - -TemplateParameterObject $nspTemplateParameters ` - -Verbose ` - -ErrorAction Stop - } - - Write-Host "Successfully associated CosmosDB with NSP using external ARM template" - Write-Host "Association Resource ID: $($nspDeploymentResult.Outputs.associationResourceId.Value)" - } - - # Health check — inlined so a retryCountOnTaskFailure on this task retries the full - # provisioning + health check together, covering race conditions where Cosmos comes up - # in a bad state and the app never becomes healthy. - $healthCheckUrl = "https://$webAppName.azurewebsites.net/health/check" - $timeoutMinutes = 5 - $consecutiveSuccessRequired = 5 - $consecutiveSuccessCount = 0 - $startTime = Get-Date - $timeoutTime = $startTime.AddMinutes($timeoutMinutes) - - Write-Host "Starting health check for $healthCheckUrl" - Write-Host "Timeout: $timeoutMinutes minutes, consecutive successes required: $consecutiveSuccessRequired" - - Do { - Start-Sleep -s 5 - Write-Host "Checking: $healthCheckUrl (consecutive successes: $consecutiveSuccessCount/$consecutiveSuccessRequired)" - try { - $response = Invoke-WebRequest -URI $healthCheckUrl - if ($response.StatusCode -eq 200) { - $consecutiveSuccessCount++ - Write-Host "Success ($consecutiveSuccessCount/$consecutiveSuccessRequired)" - } else { - Write-Host "Non-200 response ($($response.StatusCode)), resetting count" - $consecutiveSuccessCount = 0 - } - } catch { - Write-Host $PSItem.Exception.Message - $consecutiveSuccessCount = 0 - } finally { - $Error.Clear() - } - if ((Get-Date) -gt $timeoutTime) { - $elapsed = [math]::Round(((Get-Date) - $startTime).TotalMinutes, 2) - Write-Error "Health check timed out after $elapsed minutes ($consecutiveSuccessCount/$consecutiveSuccessRequired consecutive successes)" - throw "Health check timed out" - } - } While ($consecutiveSuccessCount -lt $consecutiveSuccessRequired) - - $elapsed = [math]::Round(((Get-Date) - $startTime).TotalSeconds, 2) - Write-Host "Health check passed with $consecutiveSuccessRequired consecutive successes after $elapsed seconds" diff --git a/build/jobs/redeploy-webapp.yml b/build/jobs/redeploy-webapp.yml index 6845260147..95a75bc228 100644 --- a/build/jobs/redeploy-webapp.yml +++ b/build/jobs/redeploy-webapp.yml @@ -7,23 +7,43 @@ parameters: type: string - name: imageTag type: string +- name: resourceGroup + type: string jobs: - job: provisionEnvironment pool: name: '$(DefaultLinuxPool)' vmImage: '$(LinuxVmImage)' - steps: - - task: AzureRmWebAppDeployment@4 - displayName: 'Azure App Service Deploy' + steps: + - task: AzureCLI@2 + displayName: 'Update Container App Image' inputs: azureSubscription: '${{ parameters.subscription }}' - appType: 'webAppContainer' - WebAppName: '${{ parameters.webAppName }}' - DockerNamespace: $(azureContainerRegistry) - DockerRepository: '${{ parameters.version }}_fhir-server' - DockerImageTag: ${{ parameters.imageTag }} + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + $containerAppName = "${{ parameters.webAppName }}".ToLower() + $rgName = "${{ parameters.resourceGroup }}" + $newImage = "$(azureContainerRegistry)/$("${{ parameters.version }}".ToLower())_fhir-server:${{ parameters.imageTag }}" + + $currentImage = az containerapp show ` + --name $containerAppName ` + --resource-group $rgName ` + --query "properties.template.containers[0].image" -o tsv + Write-Host "Current image: $currentImage" + Write-Host "New image: $newImage" + + az containerapp update ` + --name $containerAppName ` + --resource-group $rgName ` + --image $newImage + + if ($LASTEXITCODE -ne 0) { throw "Failed to update container app '$containerAppName'" } + Write-Host "Container app image updated successfully." - - template: ./provision-healthcheck.yml - parameters: - webAppName: ${{ parameters.webAppName }} \ No newline at end of file + - template: ../tasks/container-app-health-check.yml + parameters: + containerAppName: ${{ parameters.webAppName }} + resourceGroup: ${{ parameters.resourceGroup }} + subscription: ${{ parameters.subscription }} \ No newline at end of file diff --git a/build/jobs/restart-webapp.yml b/build/jobs/restart-webapp.yml index f16d54448b..7a9d5c1612 100644 --- a/build/jobs/restart-webapp.yml +++ b/build/jobs/restart-webapp.yml @@ -3,20 +3,38 @@ parameters: type: string - name: subscription type: string +- name: resourceGroup + type: string jobs: - job: provisionEnvironment pool: name: '$(DefaultLinuxPool)' vmImage: '$(LinuxVmImage)' - steps: - - task: AzureAppServiceManage@0 - displayName: 'Azure App Service Restart' + steps: + - task: AzureCLI@2 + displayName: 'Restart Container App' inputs: azureSubscription: '${{ parameters.subscription }}' - action: 'Restart Azure App Service' - WebAppName: '${{ parameters.webAppName }}' + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + $containerAppName = "${{ parameters.webAppName }}".ToLower() + $rgName = "${{ parameters.resourceGroup }}" + + # Create a new revision to force restart + $suffix = "r$(Get-Date -Format 'yyMMddHHmmss')" + Write-Host "Restarting container app '$containerAppName' with revision suffix '$suffix'..." + az containerapp update ` + --name $containerAppName ` + --resource-group $rgName ` + --revision-suffix $suffix + + if ($LASTEXITCODE -ne 0) { throw "Failed to restart container app '$containerAppName'" } + Write-Host "Container app revision updated successfully." - - template: ./provision-healthcheck.yml - parameters: - webAppName: ${{ parameters.webAppName }} \ No newline at end of file + - template: ../tasks/container-app-health-check.yml + parameters: + containerAppName: ${{ parameters.webAppName }} + resourceGroup: ${{ parameters.resourceGroup }} + subscription: ${{ parameters.subscription }} \ No newline at end of file diff --git a/build/jobs/run-cosmos-tests.yml b/build/jobs/run-cosmos-tests-aca.yml similarity index 82% rename from build/jobs/run-cosmos-tests.yml rename to build/jobs/run-cosmos-tests-aca.yml index 7a6aca889c..004cf20a71 100644 --- a/build/jobs/run-cosmos-tests.yml +++ b/build/jobs/run-cosmos-tests-aca.yml @@ -3,7 +3,7 @@ parameters: type: string - name: keyVaultName type: string -- name: appServiceName +- name: containerAppName type: string jobs: @@ -42,10 +42,16 @@ jobs: Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_TENANT_ID]$env:AZURESUBSCRIPTION_TENANT_ID" Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_SERVICE_CONNECTION_ID]$env:AZURESUBSCRIPTION_SERVICE_CONNECTION_ID" - $appServiceName = '${{ parameters.appServiceName }}' - $appSettings = (Get-AzWebApp -ResourceGroupName $(UniqueResourceGroupName) -Name $appServiceName).SiteConfig.AppSettings - $dataStoreResourceId = $appSettings | where {$_.Name -eq "FhirServer__ResourceManager__DataStoreResourceId"} - $dataStoreResourceId = $dataStoreResourceId[0].Value + $containerAppName = '${{ parameters.containerAppName }}' + $containerApp = Get-AzResource -ResourceGroupName $(UniqueResourceGroupName) -ResourceType "Microsoft.App/containerApps" -Name $containerAppName -ApiVersion "2023-05-01" -ExpandProperties + if ($null -eq $containerApp) { + throw "Container App '$containerAppName' was not found in resource group '$(UniqueResourceGroupName)'" + } + + $containerAppData = $containerApp | ConvertTo-Json -Depth 100 | ConvertFrom-Json + $envSettings = $containerAppData.Properties.template.containers[0].env + + $dataStoreResourceId = ($envSettings | Where-Object { $_.name -eq "FhirServer__ResourceManager__DataStoreResourceId" } | Select-Object -First 1).value Write-Host "$dataStoreResourceId" Write-Host "##vso[task.setvariable variable=DataStoreResourceId]$($dataStoreResourceId)" @@ -107,10 +113,10 @@ jobs: vmImage: '$(LinuxVmImage)' steps: - template: e2e-setup.yml - - template: e2e-tests.yml + - template: e2e-tests-aca.yml parameters: version: ${{ parameters.version }} - appServiceName: ${{ parameters.appServiceName }} + containerAppName: '${{ parameters.containerAppName }}' appServiceType: 'CosmosDb' categoryFilter: 'Category!=ExportLongRunning&Category!=IndexAndReindex' @@ -122,10 +128,10 @@ jobs: vmImage: '$(LinuxVmImage)' steps: - template: e2e-setup.yml - - template: e2e-tests.yml + - template: e2e-tests-aca.yml parameters: version: ${{ parameters.version }} - appServiceName: ${{ parameters.appServiceName }} + containerAppName: '${{ parameters.containerAppName }}' appServiceType: 'CosmosDb' categoryFilter: 'Category=IndexAndReindex' testRunTitleSuffix: ' Reindex' diff --git a/build/jobs/run-export-tests.yml b/build/jobs/run-export-tests.yml index 125ee7a6af..b6bb2f468a 100644 --- a/build/jobs/run-export-tests.yml +++ b/build/jobs/run-export-tests.yml @@ -3,6 +3,10 @@ parameters: type: string - name: keyVaultName type: string +- name: cosmosContainerAppName + type: string +- name: sqlContainerAppName + type: string jobs: - job: 'cosmosE2eTests' @@ -16,6 +20,25 @@ jobs: parameters: version: ${{parameters.version}} + - task: AzurePowerShell@5 + displayName: 'Resolve ACA FQDN (Cosmos)' + inputs: + azureSubscription: $(ConnectedServiceName) + azurePowerShellVersion: latestVersion + pwsh: true + ScriptType: inlineScript + Inline: | + $containerAppName = "${{ parameters.cosmosContainerAppName }}".ToLowerInvariant() + $containerApp = Get-AzResource -ResourceGroupName $(ResourceGroupName) -ResourceType "Microsoft.App/containerApps" -Name $containerAppName -ApiVersion "2023-05-01" -ExpandProperties + if ($null -eq $containerApp) { + throw "Container App '$containerAppName' not found in resource group '$(ResourceGroupName)'" + } + $fqdn = $containerApp.Properties.configuration.ingress.fqdn + $url = "https://$fqdn" + Write-Host "Resolved Cosmos ACA URL: $url" + Write-Host "##vso[task.setvariable variable=TestEnvironmentUrl]$url" + Write-Host "##vso[task.setvariable variable=TestEnvironmentUrl_${{ parameters.version }}]$url" + - task: AzurePowerShell@5 displayName: 'Set Variables' inputs: @@ -136,6 +159,25 @@ jobs: - template: e2e-tests-extract.yml parameters: version: ${{parameters.version}} + + - task: AzurePowerShell@5 + displayName: 'Resolve ACA FQDN (SQL)' + inputs: + azureSubscription: $(ConnectedServiceName) + azurePowerShellVersion: latestVersion + pwsh: true + ScriptType: inlineScript + Inline: | + $containerAppName = "${{ parameters.sqlContainerAppName }}".ToLowerInvariant() + $containerApp = Get-AzResource -ResourceGroupName $(ResourceGroupName) -ResourceType "Microsoft.App/containerApps" -Name $containerAppName -ApiVersion "2023-05-01" -ExpandProperties + if ($null -eq $containerApp) { + throw "Container App '$containerAppName' not found in resource group '$(ResourceGroupName)'" + } + $fqdn = $containerApp.Properties.configuration.ingress.fqdn + $url = "https://$fqdn" + Write-Host "Resolved SQL ACA URL: $url" + Write-Host "##vso[task.setvariable variable=TestEnvironmentUrl_Sql]$url" + Write-Host "##vso[task.setvariable variable=TestEnvironmentUrl_${{ parameters.version }}_Sql]$url" - task: AzurePowerShell@5 displayName: 'Set Variables' diff --git a/build/jobs/run-sql-tests.yml b/build/jobs/run-sql-tests-aca.yml similarity index 93% rename from build/jobs/run-sql-tests.yml rename to build/jobs/run-sql-tests-aca.yml index 57688219e3..935c57a744 100644 --- a/build/jobs/run-sql-tests.yml +++ b/build/jobs/run-sql-tests-aca.yml @@ -3,7 +3,7 @@ parameters: type: string - name: keyVaultName type: string -- name: appServiceName +- name: containerAppName type: string - name: integrationSqlServerName type: string @@ -27,10 +27,10 @@ jobs: version: ${{ parameters.version }} - task: AzureKeyVault@1 - displayName: 'Azure Key Vault: ${{ parameters.keyVaultName }}-sql' + displayName: 'Azure Key Vault: ${{ parameters.keyVaultName }}' inputs: azureSubscription: $(ConnectedServiceName) - KeyVaultName: '${{ parameters.keyVaultName }}-sql' + KeyVaultName: '${{ parameters.keyVaultName }}' - task: AzurePowerShell@5 displayName: 'Set Workload Identity Variables' @@ -100,10 +100,10 @@ jobs: vmImage: '$(LinuxVmImage)' steps: - template: e2e-setup.yml - - template: e2e-tests.yml + - template: e2e-tests-aca.yml parameters: version: ${{ parameters.version }} - appServiceName: '${{ parameters.appServiceName }}-sql' + containerAppName: '${{ parameters.containerAppName }}' appServiceType: 'SqlServer' categoryFilter: 'Category!=ExportLongRunning&Category!=IndexAndReindex&Category!=BulkUpdate' @@ -115,10 +115,10 @@ jobs: vmImage: '$(LinuxVmImage)' steps: - template: e2e-setup.yml - - template: e2e-tests.yml + - template: e2e-tests-aca.yml parameters: version: ${{ parameters.version }} - appServiceName: '${{ parameters.appServiceName }}-sql' + containerAppName: '${{ parameters.containerAppName }}' appServiceType: 'SqlServer' categoryFilter: 'Category=IndexAndReindex' testRunTitleSuffix: ' Reindex' @@ -131,10 +131,10 @@ jobs: vmImage: '$(LinuxVmImage)' steps: - template: e2e-setup.yml - - template: e2e-tests.yml + - template: e2e-tests-aca.yml parameters: version: ${{ parameters.version }} - appServiceName: '${{ parameters.appServiceName }}-sql' + containerAppName: '${{ parameters.containerAppName }}' appServiceType: 'SqlServer' categoryFilter: 'Category=BulkUpdate' testRunTitleSuffix: ' BulkUpdate' diff --git a/build/pr-pipeline.yml b/build/pr-pipeline.yml index 05a5773232..194da3abe3 100644 --- a/build/pr-pipeline.yml +++ b/build/pr-pipeline.yml @@ -181,6 +181,43 @@ stages: vmImage: '$(WindowsVmImage)' steps: - template: ./jobs/add-aad-test-environment.yml + - job: createAcaEnvironment + displayName: 'Create ACA Managed Environment' + pool: + name: '$(DefaultLinuxPool)' + vmImage: '$(LinuxVmImage)' + steps: + - task: AzurePowerShell@5 + displayName: 'Create ACA Managed Environment' + inputs: + azureSubscription: $(ConnectedServiceName) + azurePowerShellVersion: latestVersion + pwsh: true + ScriptType: inlineScript + Inline: | + $envName = '$(AcaEnvironmentName)'.ToLowerInvariant() + $rgName = '$(UniqueResourceGroupName)' + $location = '$(ResourceGroupRegion)' + + if ($envName.Length -gt 32) { + $envName = $envName.Substring(0, 32).TrimEnd('-') + } + + $existing = Get-AzResource -ResourceGroupName $rgName -ResourceType 'Microsoft.App/managedEnvironments' -Name $envName -ErrorAction SilentlyContinue + if ($null -ne $existing) { + Write-Host "ACA managed environment '$envName' already exists." + return + } + + Write-Host "Creating ACA managed environment '$envName' in '$rgName'..." + New-AzResourceGroupDeployment ` + -Name "aca-env-$envName" ` + -ResourceGroupName $rgName ` + -TemplateFile $(System.DefaultWorkingDirectory)/samples/templates/aca/aca-environment.bicep ` + -environmentName $envName ` + -location $location ` + -Verbose -ErrorAction Stop + Write-Host "ACA managed environment '$envName' created successfully." - stage: createNsp displayName: 'Create Network Security Perimeter' @@ -235,18 +272,18 @@ stages: - setupEnvironment - createNsp jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-cosmos-aca.yml parameters: version: Stu3 webAppName: $(DeploymentEnvironmentName) + acaEnvironmentName: $(AcaEnvironmentName) keyVaultName: $(KeyVaultBaseName) - appServicePlanName: '$(appServicePlanName)-cosmos' - appServicePlanResourceGroup: $(appServicePlanResourceGroup) subscription: $(ConnectedServiceName) resourceGroup: $(UniqueResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) reindexEnabled: true + minReplicas: 3 - stage: deployStu3Sql displayName: 'Deploy STU3 SQL Site' @@ -255,22 +292,20 @@ stages: - setupEnvironment - deploySqlServer jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-aca.yml parameters: version: Stu3 - sql: true webAppName: $(DeploymentEnvironmentNameSql) + acaEnvironmentName: $(AcaEnvironmentName) keyVaultName: $(KeyVaultNameSql) - appServicePlanName: '$(appServicePlanName)-sql' - appServicePlanResourceGroup: $(appServicePlanResourceGroup) subscription: $(ConnectedServiceName) resourceGroup: $(UniqueResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) schemaAutomaticUpdatesEnabled: 'auto' sqlServerName: $(DeploymentEnvironmentName) - sqlComputeTier: 'Standard' reindexEnabled: true + minReplicas: 3 - stage: deployR4 displayName: 'Deploy R4 CosmosDB Site' @@ -279,18 +314,18 @@ stages: - setupEnvironment - createNsp jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-cosmos-aca.yml parameters: version: R4 webAppName: $(DeploymentEnvironmentNameR4) + acaEnvironmentName: $(AcaEnvironmentName) keyVaultName: $(KeyVaultNameR4) - appServicePlanName: '$(appServicePlanName)-cosmos' - appServicePlanResourceGroup: $(appServicePlanResourceGroup) subscription: $(ConnectedServiceName) resourceGroup: $(UniqueResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) reindexEnabled: true + minReplicas: 3 - stage: deployR4Sql displayName: 'Deploy R4 SQL Site' @@ -299,22 +334,20 @@ stages: - setupEnvironment - deploySqlServer jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-aca.yml parameters: version: R4 - sql: true webAppName: $(DeploymentEnvironmentNameR4Sql) + acaEnvironmentName: $(AcaEnvironmentName) keyVaultName: $(KeyVaultNameR4Sql) - appServicePlanName: '$(appServicePlanName)-sql' - appServicePlanResourceGroup: $(appServicePlanResourceGroup) subscription: $(ConnectedServiceName) resourceGroup: $(UniqueResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) schemaAutomaticUpdatesEnabled: 'auto' sqlServerName: $(DeploymentEnvironmentName) - sqlComputeTier: 'Standard' reindexEnabled: true + minReplicas: 3 - stage: deployR4B displayName: 'Deploy R4B CosmosDB Site' @@ -323,18 +356,18 @@ stages: - setupEnvironment - createNsp jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-cosmos-aca.yml parameters: version: R4B webAppName: $(DeploymentEnvironmentNameR4B) + acaEnvironmentName: $(AcaEnvironmentName) keyVaultName: $(KeyVaultNameR4B) - appServicePlanName: '$(appServicePlanName)-cosmos' - appServicePlanResourceGroup: $(appServicePlanResourceGroup) subscription: $(ConnectedServiceName) resourceGroup: $(UniqueResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) reindexEnabled: true + minReplicas: 3 - stage: deployR4BSql displayName: 'Deploy R4B SQL Site' @@ -343,22 +376,20 @@ stages: - setupEnvironment - deploySqlServer jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-aca.yml parameters: version: R4B - sql: true webAppName: $(DeploymentEnvironmentNameR4BSql) + acaEnvironmentName: $(AcaEnvironmentName) keyVaultName: $(KeyVaultNameR4BSql) - appServicePlanName: '$(appServicePlanName)-sql' - appServicePlanResourceGroup: $(appServicePlanResourceGroup) subscription: $(ConnectedServiceName) resourceGroup: $(UniqueResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) schemaAutomaticUpdatesEnabled: 'auto' sqlServerName: $(DeploymentEnvironmentName) - sqlComputeTier: 'Standard' reindexEnabled: true + minReplicas: 3 - stage: deployR5 displayName: 'Deploy R5 CosmosDB Site' @@ -367,18 +398,18 @@ stages: - setupEnvironment - createNsp jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-cosmos-aca.yml parameters: version: R5 webAppName: $(DeploymentEnvironmentNameR5) + acaEnvironmentName: $(AcaEnvironmentName) keyVaultName: $(KeyVaultNameR5) - appServicePlanName: '$(appServicePlanName)-cosmos' - appServicePlanResourceGroup: $(appServicePlanResourceGroup) subscription: $(ConnectedServiceName) resourceGroup: $(UniqueResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) reindexEnabled: true + minReplicas: 3 - stage: deployR5Sql displayName: 'Deploy R5 SQL Site' @@ -387,22 +418,20 @@ stages: - setupEnvironment - deploySqlServer jobs: - - template: ./jobs/provision-deploy.yml + - template: ./jobs/provision-deploy-aca.yml parameters: version: R5 - sql: true webAppName: $(DeploymentEnvironmentNameR5Sql) + acaEnvironmentName: $(AcaEnvironmentName) keyVaultName: $(KeyVaultNameR5Sql) - appServicePlanName: '$(appServicePlanName)-sql' - appServicePlanResourceGroup: $(appServicePlanResourceGroup) subscription: $(ConnectedServiceName) resourceGroup: $(UniqueResourceGroupName) testEnvironmentUrl: $(TestApplicationResource) imageTag: $(ImageTag) schemaAutomaticUpdatesEnabled: 'auto' sqlServerName: $(DeploymentEnvironmentName) - sqlComputeTier: 'Standard' reindexEnabled: true + minReplicas: 3 - stage: testStu3Cosmos displayName: 'Run Stu3 Cosmos Tests' @@ -410,12 +439,15 @@ stages: - BuildArtifacts - setupEnvironment - deployStu3 + variables: + TestEnvironmentUrl_Stu3: $[stageDependencies.deployStu3.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Stu3']] + TestEnvironmentUrl: $[stageDependencies.deployStu3.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl']] jobs: - - template: ./jobs/run-cosmos-tests.yml + - template: ./jobs/run-cosmos-tests-aca.yml parameters: version: Stu3 keyVaultName: $(KeyVaultBaseName) - appServiceName: $(DeploymentEnvironmentName) + containerAppName: $(DeploymentEnvironmentName) - stage: testStu3Sql displayName: 'Run Stu3 SQL Tests' @@ -423,12 +455,15 @@ stages: - BuildArtifacts - setupEnvironment - deployStu3Sql + variables: + TestEnvironmentUrl_Stu3_Sql: $[stageDependencies.deployStu3Sql.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Stu3_Sql']] + TestEnvironmentUrl_Sql: $[stageDependencies.deployStu3Sql.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Sql']] jobs: - - template: ./jobs/run-sql-tests.yml + - template: ./jobs/run-sql-tests-aca.yml parameters: version: Stu3 - keyVaultName: $(KeyVaultBaseName) - appServiceName: $(DeploymentEnvironmentName) + keyVaultName: $(KeyVaultNameSql) + containerAppName: $(DeploymentEnvironmentNameSql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest - stage: testR4Cosmos @@ -437,12 +472,15 @@ stages: - BuildArtifacts - setupEnvironment - deployR4 + variables: + TestEnvironmentUrl_R4: $[stageDependencies.deployR4.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_R4']] + TestEnvironmentUrl: $[stageDependencies.deployR4.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl']] jobs: - - template: ./jobs/run-cosmos-tests.yml + - template: ./jobs/run-cosmos-tests-aca.yml parameters: version: R4 keyVaultName: $(KeyVaultNameR4) - appServiceName: $(DeploymentEnvironmentNameR4) + containerAppName: $(DeploymentEnvironmentNameR4) - stage: testR4Sql displayName: 'Run R4 SQL Tests' @@ -450,12 +488,15 @@ stages: - BuildArtifacts - setupEnvironment - deployR4Sql + variables: + TestEnvironmentUrl_R4_Sql: $[stageDependencies.deployR4Sql.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_R4_Sql']] + TestEnvironmentUrl_Sql: $[stageDependencies.deployR4Sql.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Sql']] jobs: - - template: ./jobs/run-sql-tests.yml + - template: ./jobs/run-sql-tests-aca.yml parameters: version: R4 - keyVaultName: $(KeyVaultNameR4) - appServiceName: $(DeploymentEnvironmentNameR4) + keyVaultName: $(KeyVaultNameR4Sql) + containerAppName: $(DeploymentEnvironmentNameR4Sql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest - stage: testR4BCosmos @@ -464,12 +505,15 @@ stages: - BuildArtifacts - setupEnvironment - deployR4B + variables: + TestEnvironmentUrl_R4B: $[stageDependencies.deployR4B.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_R4B']] + TestEnvironmentUrl: $[stageDependencies.deployR4B.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl']] jobs: - - template: ./jobs/run-cosmos-tests.yml + - template: ./jobs/run-cosmos-tests-aca.yml parameters: version: R4B keyVaultName: $(KeyVaultNameR4B) - appServiceName: $(DeploymentEnvironmentNameR4B) + containerAppName: $(DeploymentEnvironmentNameR4B) - stage: testR4BSql displayName: 'Run R4B SQL Tests' @@ -477,12 +521,15 @@ stages: - BuildArtifacts - setupEnvironment - deployR4BSql + variables: + TestEnvironmentUrl_R4B_Sql: $[stageDependencies.deployR4BSql.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_R4B_Sql']] + TestEnvironmentUrl_Sql: $[stageDependencies.deployR4BSql.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Sql']] jobs: - - template: ./jobs/run-sql-tests.yml + - template: ./jobs/run-sql-tests-aca.yml parameters: version: R4B - keyVaultName: $(KeyVaultNameR4B) - appServiceName: $(DeploymentEnvironmentNameR4B) + keyVaultName: $(KeyVaultNameR4BSql) + containerAppName: $(DeploymentEnvironmentNameR4BSql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest - stage: testR5Cosmos @@ -491,12 +538,15 @@ stages: - BuildArtifacts - setupEnvironment - deployR5 + variables: + TestEnvironmentUrl_R5: $[stageDependencies.deployR5.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_R5']] + TestEnvironmentUrl: $[stageDependencies.deployR5.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl']] jobs: - - template: ./jobs/run-cosmos-tests.yml + - template: ./jobs/run-cosmos-tests-aca.yml parameters: version: R5 keyVaultName: $(KeyVaultNameR5) - appServiceName: $(DeploymentEnvironmentNameR5) + containerAppName: $(DeploymentEnvironmentNameR5) - stage: testR5Sql displayName: 'Run R5 SQL Tests' @@ -504,12 +554,15 @@ stages: - BuildArtifacts - setupEnvironment - deployR5Sql + variables: + TestEnvironmentUrl_R5_Sql: $[stageDependencies.deployR5Sql.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_R5_Sql']] + TestEnvironmentUrl_Sql: $[stageDependencies.deployR5Sql.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Sql']] jobs: - - template: ./jobs/run-sql-tests.yml + - template: ./jobs/run-sql-tests-aca.yml parameters: version: R5 - keyVaultName: $(KeyVaultNameR5) - appServiceName: $(DeploymentEnvironmentNameR5) + keyVaultName: $(KeyVaultNameR5Sql) + containerAppName: $(DeploymentEnvironmentNameR5Sql) integrationSqlServerName: $(DeploymentEnvironmentName)inttest - stage: aggregateCoverage @@ -524,7 +577,6 @@ stages: - testR4BSql - testR5Cosmos - testR5Sql - condition: succeeded() jobs: - job: AggregateCoverage displayName: 'Aggregate coverage from all test types and versions' diff --git a/build/pr-variables.yml b/build/pr-variables.yml index f2c2ac69b1..765b1e7cff 100644 --- a/build/pr-variables.yml +++ b/build/pr-variables.yml @@ -1,7 +1,6 @@ variables: ResourceGroupRegion: 'westus2' resourceGroupRoot: 'msh-fhir-pr' - appServicePlanName: '$(resourceGroupRoot)-$(prName)-asp' ResourceGroupName: '$(resourceGroupRoot)-$(prName)' # Unique resource group name with Build.BuildId suffix to avoid waiting for deletion of existing RG UniqueResourceGroupName: '$(resourceGroupRoot)-$(prName)-$(Build.BuildId)' diff --git a/build/tasks/container-app-health-check.yml b/build/tasks/container-app-health-check.yml new file mode 100644 index 0000000000..d30cc7542a --- /dev/null +++ b/build/tasks/container-app-health-check.yml @@ -0,0 +1,58 @@ +parameters: +- name: containerAppName + type: string +- name: resourceGroup + type: string +- name: subscription + type: string + +steps: +- task: AzureCLI@2 + displayName: 'Health Check - ${{ parameters.containerAppName }}' + inputs: + azureSubscription: '${{ parameters.subscription }}' + scriptType: pscore + scriptLocation: inlineScript + inlineScript: | + $fqdn = az containerapp show ` + --name "${{ parameters.containerAppName }}".ToLower() ` + --resource-group "${{ parameters.resourceGroup }}" ` + --query "properties.configuration.ingress.fqdn" -o tsv + + if (-not $fqdn) { + throw "Could not resolve FQDN for container app '${{ parameters.containerAppName }}'" + } + + $healthCheckUrl = "https://$fqdn/health/check" + $timeoutMinutes = 7 + $consecutiveSuccessRequired = 5 + $consecutiveSuccessCount = 0 + $startTime = Get-Date + $timeoutTime = $startTime.AddMinutes($timeoutMinutes) + + Write-Host "Starting health check for $healthCheckUrl" + + Do { + Start-Sleep -Seconds 5 + Write-Host "Checking: $healthCheckUrl ($consecutiveSuccessCount/$consecutiveSuccessRequired)" + try { + $response = Invoke-WebRequest -Uri $healthCheckUrl -UseBasicParsing + if ($response.StatusCode -eq 200) { + $consecutiveSuccessCount++ + Write-Host "Success ($consecutiveSuccessCount/$consecutiveSuccessRequired)" + } else { + $consecutiveSuccessCount = 0 + } + } catch { + Write-Host $_.Exception.Message + $consecutiveSuccessCount = 0 + } finally { + $Error.Clear() + } + + if ((Get-Date) -gt $timeoutTime) { + throw "Health check timed out after $([math]::Round(((Get-Date) - $startTime).TotalMinutes, 2)) minutes" + } + } While ($consecutiveSuccessCount -lt $consecutiveSuccessRequired) + + Write-Host "Health check passed after $([math]::Round(((Get-Date) - $startTime).TotalSeconds, 2)) seconds" diff --git a/build/tasks/delete-resource-groups.yml b/build/tasks/delete-resource-groups.yml index aaca698481..87aea2ba61 100644 --- a/build/tasks/delete-resource-groups.yml +++ b/build/tasks/delete-resource-groups.yml @@ -13,6 +13,12 @@ steps: $prNumber = "$(prName)" $resourceGroupRoot = "$(resourceGroupRoot)" $currentRgName = "$(UniqueResourceGroupName)" + + if ([string]::IsNullOrWhiteSpace($prNumber)) { + Write-Warning "Skipping cleanup because prName is empty." + return + } + $pattern = "$resourceGroupRoot-$prNumber*" Write-Host "Looking for old resource groups matching pattern: $pattern" diff --git a/build/tasks/e2e-set-variables-aca.yml b/build/tasks/e2e-set-variables-aca.yml new file mode 100644 index 0000000000..94a0ef7ba5 --- /dev/null +++ b/build/tasks/e2e-set-variables-aca.yml @@ -0,0 +1,135 @@ +parameters: +- name: containerAppName + type: string +- name: version + type: string +- name: appServiceType + type: string + +steps: +- task: AzurePowerShell@5 + displayName: 'Set Variables (ACA)' + inputs: + azureSubscription: $(ConnectedServiceName) + azurePowerShellVersion: latestVersion + pwsh: true + ScriptType: InlineScript + Inline: | + function Set-SecretsAsPipelineVariables { + param( + [Parameter(Mandatory = $true)] + [string]$VaultName + ) + + $secrets = Get-AzKeyVaultSecret -VaultName $VaultName + foreach ($secret in $secrets) { + $environmentVariableName = $secret.Name.Replace("--", "_") + + $secretValue = Get-AzKeyVaultSecret -VaultName $VaultName -Name $secret.Name + # Replace with -AsPlainText flag when v5.3 of the Az Module is supported + $plainValue = ([System.Net.NetworkCredential]::new("", $secretValue.SecretValue).Password).ToString() + if ([string]::IsNullOrEmpty($plainValue)) { + throw "$($secret.Name) is empty" + } + + Write-Host "##vso[task.setvariable variable=$($environmentVariableName)]$($plainValue)" + } + } + + function Get-ContainerEnvValue { + param( + [Parameter(Mandatory = $true)] + [array]$EnvSettings, + [Parameter(Mandatory = $true)] + [string]$Name + ) + + $setting = $EnvSettings | Where-Object { $_.name -eq $Name } | Select-Object -First 1 + if ($null -eq $setting) { + return $null + } + + if ($null -ne $setting.value) { + return [string]$setting.value + } + + return $null + } + + function Set-TestEnvironmentUrlVariables { + param( + [Parameter(Mandatory = $true)] + [psobject]$ContainerAppData, + [Parameter(Mandatory = $true)] + [string]$Version, + [Parameter(Mandatory = $true)] + [string]$AppServiceType + ) + + $fqdn = $ContainerAppData.Properties.configuration.ingress.fqdn + if ([string]::IsNullOrEmpty($fqdn) -or $fqdn -eq 'null') { + throw "Container App '$containerAppName' does not have an ingress FQDN configured." + } + + $url = "https://$fqdn" + if ($AppServiceType -eq 'SqlServer') { + $defaultVariableName = 'TestEnvironmentUrl_Sql' + $versionVariableName = "TestEnvironmentUrl_${Version}_Sql" + } + else { + $defaultVariableName = 'TestEnvironmentUrl' + $versionVariableName = "TestEnvironmentUrl_$Version" + } + + Write-Host "Resolved ACA URL for $AppServiceType/$($Version): $url" + Write-Host "Setting pipeline variables '$defaultVariableName' and '$versionVariableName'" + Write-Host "##vso[task.setvariable variable=$defaultVariableName]$url" + Write-Host "##vso[task.setvariable variable=$versionVariableName]$url" + } + + $keyVault = "$(KeyVaultBaseName)-ts" + Set-SecretsAsPipelineVariables -VaultName $keyVault + + $containerAppName = "${{ parameters.containerAppName }}" + $containerApp = Get-AzResource -ResourceGroupName $(UniqueResourceGroupName) -ResourceType "Microsoft.App/containerApps" -Name $containerAppName -ApiVersion "2023-05-01" -ExpandProperties + if ($null -eq $containerApp) { + throw "Container App '$containerAppName' was not found in resource group '$(UniqueResourceGroupName)'" + } + + $containerAppData = $containerApp | ConvertTo-Json -Depth 100 | ConvertFrom-Json + $envSettings = $containerAppData.Properties.template.containers[0].env + if ($null -eq $envSettings) { + throw "Container App '$containerAppName' has no environment variables configured." + } + + Set-TestEnvironmentUrlVariables -ContainerAppData $containerAppData -Version '${{ parameters.version }}' -AppServiceType '${{ parameters.appServiceType }}' + + $acrLoginServer = Get-ContainerEnvValue -EnvSettings $envSettings -Name "FhirServer__Operations__ConvertData__ContainerRegistryServers__0" + + $exportStoreUri = Get-ContainerEnvValue -EnvSettings $envSettings -Name "FhirServer__Operations__Export__StorageAccountUri" + if ([string]::IsNullOrEmpty($exportStoreUri) -or $exportStoreUri -eq "null") { + throw "FhirServer__Operations__Export__StorageAccountUri is missing on container app '$containerAppName'." + } + Write-Host "$exportStoreUri" + Write-Host "##vso[task.setvariable variable=TestExportStoreUri]$($exportStoreUri)" + + $integrationStoreUri = Get-ContainerEnvValue -EnvSettings $envSettings -Name "FhirServer__Operations__IntegrationDataStore__StorageAccountUri" + if ([string]::IsNullOrEmpty($integrationStoreUri) -or $integrationStoreUri -eq "null") { + throw "FhirServer__Operations__IntegrationDataStore__StorageAccountUri is missing on container app '$containerAppName'." + } + Write-Host "$integrationStoreUri" + Write-Host "##vso[task.setvariable variable=TestIntegrationStoreUri]$($integrationStoreUri)" + + if (-not [string]::IsNullOrEmpty($acrLoginServer) -and $acrLoginServer -ne "null") { + Write-Host "ACA convert-data registry server: $acrLoginServer" + } + + Write-Host "##vso[task.setvariable variable=Resource]$(TestApplicationResource)" + + Set-SecretsAsPipelineVariables -VaultName resolute-oss-tenant-info + + dotnet dev-certs https + + Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_CLIENT_ID]$env:AZURESUBSCRIPTION_CLIENT_ID" + Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_TENANT_ID]$env:AZURESUBSCRIPTION_TENANT_ID" + Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_SERVICE_CONNECTION_ID]$env:AZURESUBSCRIPTION_SERVICE_CONNECTION_ID" diff --git a/build/tasks/e2e-set-variables.yml b/build/tasks/e2e-set-variables.yml deleted file mode 100644 index 480902fc65..0000000000 --- a/build/tasks/e2e-set-variables.yml +++ /dev/null @@ -1,78 +0,0 @@ -parameters: -- name: appServiceName - type: string - -steps: -- task: AzurePowerShell@5 - displayName: 'Set Variables' - inputs: - azureSubscription: $(ConnectedServiceName) - azurePowerShellVersion: latestVersion - pwsh: true - ScriptType: InlineScript - Inline: | - $keyVault = "$(KeyVaultBaseName)-ts" - $secrets = Get-AzKeyVaultSecret -VaultName $keyVault - - foreach($secret in $secrets) - { - $environmentVariableName = $secret.Name.Replace("--","_") - - $secretValue = Get-AzKeyVaultSecret -VaultName $keyVault -Name $secret.Name - # Replace with -AsPlainText flag when v5.3 of the Az Module is supported - $plainValue = ([System.Net.NetworkCredential]::new("", $secretValue.SecretValue).Password).ToString() - if([string]::IsNullOrEmpty($plainValue)) - { - throw "$($secret.Name) is empty" - } - Write-Host "##vso[task.setvariable variable=$($environmentVariableName)]$($plainValue)" - } - - $appServiceName = "${{ parameters.appServiceName }}" - $appSettings = (Get-AzWebApp -ResourceGroupName $(UniqueResourceGroupName) -Name $appServiceName).SiteConfig.AppSettings - $acrSettings = $appSettings | where {$_.Name -eq "FhirServer__Operations__ConvertData__ContainerRegistryServers__0"} - $acrLoginServer = $acrSettings[0].Value - $acrAccountName = ($acrLoginServer -split '\.')[0] - - ## This needs to be moved to MI, WI #125246 - # $acrPassword = (Get-AzContainerRegistryCredential -ResourceGroupName $(ResourceGroupName) -Name $acrAccountName).Password - # Write-Host "##vso[task.setvariable variable=TestContainerRegistryServer]$($acrLoginServer)" - # Write-Host "##vso[task.setvariable variable=TestContainerRegistryPassword]$($acrPassword)" - - $exportStoreSettings = $appSettings | where {$_.Name -eq "FhirServer__Operations__Export__StorageAccountUri"} - $exportStoreUri = $exportStoreSettings[0].Value - Write-Host "$exportStoreUri" - $exportStoreAccountName = [System.Uri]::new("$exportStoreUri").Host.Split('.')[0] - $exportStoreKey = Get-AzStorageAccountKey -ResourceGroupName $(UniqueResourceGroupName) -Name "$exportStoreAccountName" | Where-Object {$_.KeyName -eq "key1"} - - Write-Host "##vso[task.setvariable variable=TestExportStoreUri]$($exportStoreUri)" - - $integrationStoreSettings = $appSettings | where {$_.Name -eq "FhirServer__Operations__IntegrationDataStore__StorageAccountUri"} - $integrationStoreUri = $integrationStoreSettings[0].Value - Write-Host "$integrationStoreUri" - $integrationStoreAccountName = [System.Uri]::new("$integrationStoreUri").Host.Split('.')[0] - Write-Host "##vso[task.setvariable variable=TestIntegrationStoreUri]$($integrationStoreUri)" - - Write-Host "##vso[task.setvariable variable=Resource]$(TestApplicationResource)" - - $secrets = Get-AzKeyVaultSecret -VaultName resolute-oss-tenant-info - foreach($secret in $secrets) - { - $environmentVariableName = $secret.Name.Replace("--","_") - - $secretValue = Get-AzKeyVaultSecret -VaultName resolute-oss-tenant-info -Name $secret.Name - # Replace with -AsPlainText flag when v5.3 of the Az Module is supported - $plainValue = ([System.Net.NetworkCredential]::new("", $secretValue.SecretValue).Password).ToString() - if([string]::IsNullOrEmpty($plainValue)) - { - throw "$($secret.Name) is empty" - } - Write-Host "##vso[task.setvariable variable=$($environmentVariableName)]$($plainValue)" - } - # ---------------------------------------- - - dotnet dev-certs https - - Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_CLIENT_ID]$env:AZURESUBSCRIPTION_CLIENT_ID" - Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_TENANT_ID]$env:AZURESUBSCRIPTION_TENANT_ID" - Write-Host "##vso[task.setvariable variable=AZURESUBSCRIPTION_SERVICE_CONNECTION_ID]$env:AZURESUBSCRIPTION_SERVICE_CONNECTION_ID" diff --git a/samples/templates/aca/aca-environment.bicep b/samples/templates/aca/aca-environment.bicep new file mode 100644 index 0000000000..8a95376068 --- /dev/null +++ b/samples/templates/aca/aca-environment.bicep @@ -0,0 +1,8 @@ +param environmentName string +param location string = resourceGroup().location + +resource managedEnvironment 'Microsoft.App/managedEnvironments@2023-05-01' = { + name: environmentName + location: location + properties: {} +} diff --git a/samples/templates/aca/fhir-cosmos-aca.bicep b/samples/templates/aca/fhir-cosmos-aca.bicep new file mode 100644 index 0000000000..6205a20ab5 --- /dev/null +++ b/samples/templates/aca/fhir-cosmos-aca.bicep @@ -0,0 +1,141 @@ +// FHIR Server on ACA with Cosmos DB datastore. + +// ────────────────────────────────────────────── +// Parameters +// ────────────────────────────────────────────── + +@description('Name of the Azure Container App hosting FHIR.') +param containerAppName string + +@description('Name of the Container Apps Environment.') +param containerAppsEnvironmentName string + +@description('Name of the Key Vault used by FHIR server.') +@maxLength(24) +param keyVaultName string + +@description('FHIR version to deploy.') +@allowed(['Stu3', 'R4', 'R4B', 'R5']) +param fhirVersion string = 'R4' + +@description('Container registry server host name.') +param registryName string + +@description('Docker image tag.') +param imageTag string = 'latest' + +@description('Name of the existing Cosmos DB account.') +param cosmosDbAccountName string + +@description('Authority URL for AAD authentication.') +param securityAuthenticationAuthority string = '' + +@description('Audience for AAD authentication.') +param securityAuthenticationAudience string = '' + +@description('Resource ID of UAMI used to pull from ACR.') +param acrPullUserAssignedManagedIdentityResourceId string + +@description('Enable export operations.') +param enableExport bool = true + +@description('Enable import operations.') +param enableImport bool = true + +@description('Enable ConvertData operations.') +param enableConvertData bool = true + +@description('Enable reindex operations.') +param enableReindex bool = true + +@description('Minimum number of replicas.') +param minReplicas int = 0 + +@description('Maximum number of replicas.') +param maxReplicas int = 8 + +@description('CPU cores per container.') +param containerCpu string = '0.5' + +@description('Memory per container.') +param containerMemory string = '1Gi' + +@description('Additional environment variables from pipeline.') +param additionalEnvVars array = [] + +// ────────────────────────────────────────────── +// Variables +// ────────────────────────────────────────────── + +var normalizedCosmosDbAccountName = toLower(cosmosDbAccountName) + +var userAssignedIdentities = { + '${acrPullUserAssignedManagedIdentityResourceId}': {} +} + +var datastoreEnvVars = [ + { name: 'DataStore', value: 'CosmosDb' } + { name: 'CosmosDb__UseManagedIdentity', value: 'true' } + { name: 'CosmosDb__ContinuationTokenSizeLimitInKb', value: '1' } + { name: 'CosmosDb__UseQueueClientJobs', value: 'true' } + { + name: 'FhirServer__ResourceManager__DataStoreResourceId' + value: resourceId('Microsoft.DocumentDB/databaseAccounts', normalizedCosmosDbAccountName) + } +] + +// ────────────────────────────────────────────── +// Modules +// ────────────────────────────────────────────── + +module common 'modules/fhir-aca-common.bicep' = { + name: '${toLower(containerAppName)}-common' + params: { + containerAppName: containerAppName + containerAppsEnvironmentName: containerAppsEnvironmentName + keyVaultName: keyVaultName + fhirVersion: fhirVersion + registryName: registryName + imageTag: imageTag + securityAuthenticationAuthority: securityAuthenticationAuthority + securityAuthenticationAudience: securityAuthenticationAudience + acrPullUserAssignedManagedIdentityResourceId: acrPullUserAssignedManagedIdentityResourceId + enableExport: enableExport + enableImport: enableImport + enableConvertData: enableConvertData + enableReindex: enableReindex + minReplicas: minReplicas + maxReplicas: maxReplicas + containerCpu: containerCpu + containerMemory: containerMemory + additionalEnvVars: additionalEnvVars + datastoreEnvVars: datastoreEnvVars + userAssignedIdentities: userAssignedIdentities + } +} + +// ────────────────────────────────────────────── +// Cosmos-specific resources +// ────────────────────────────────────────────── + +resource existingCosmosDbAccount 'Microsoft.DocumentDB/databaseAccounts@2021-06-15' existing = { + name: normalizedCosmosDbAccountName +} + +resource cosmosDbHostSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + name: '${toLower(keyVaultName)}/CosmosDb--Host' + properties: { + contentType: 'text/plain' + value: existingCosmosDbAccount.properties.documentEndpoint + } +} + +// ────────────────────────────────────────────── +// Outputs +// ────────────────────────────────────────────── + +output containerAppName string = common.outputs.containerAppName +output containerAppFqdn string = common.outputs.containerAppFqdn +output containerAppUrl string = common.outputs.containerAppUrl +output exportStorageUri string = common.outputs.exportStorageUri +output integrationStorageUri string = common.outputs.integrationStorageUri diff --git a/samples/templates/aca/fhir-sql-aca.bicep b/samples/templates/aca/fhir-sql-aca.bicep new file mode 100644 index 0000000000..af0e774ab2 --- /dev/null +++ b/samples/templates/aca/fhir-sql-aca.bicep @@ -0,0 +1,154 @@ +// FHIR Server on ACA with SQL Server datastore. + +// ────────────────────────────────────────────── +// Parameters +// ────────────────────────────────────────────── + +@description('Name of the Azure Container App hosting FHIR.') +param containerAppName string + +@description('Name of the Container Apps Environment.') +param containerAppsEnvironmentName string + +@description('Name of the Key Vault used by FHIR server.') +@maxLength(24) +param keyVaultName string + +@description('FHIR version to deploy.') +@allowed(['Stu3', 'R4', 'R4B', 'R5']) +param fhirVersion string = 'R4' + +@description('Container registry server host name.') +param registryName string + +@description('Docker image tag.') +param imageTag string = 'latest' + +@description('Existing SQL server name.') +param sqlServerName string + +@description('Schema automatic updates mode.') +@allowed(['auto', 'tool']) +param sqlSchemaAutomaticUpdatesEnabled string = 'auto' + +@description('Authority URL for AAD authentication.') +param securityAuthenticationAuthority string = '' + +@description('Audience for AAD authentication.') +param securityAuthenticationAudience string = '' + +@description('Resource ID of UAMI used to pull from ACR.') +param acrPullUserAssignedManagedIdentityResourceId string + +@description('Enable export operations.') +param enableExport bool = true + +@description('Enable import operations.') +param enableImport bool = true + +@description('Enable ConvertData operations.') +param enableConvertData bool = true + +@description('Enable reindex operations.') +param enableReindex bool = true + +@description('Minimum number of replicas.') +param minReplicas int = 0 + +@description('Maximum number of replicas.') +param maxReplicas int = 8 + +@description('CPU cores per container.') +param containerCpu string = '0.5' + +@description('Memory per container.') +param containerMemory string = '1Gi' + +@description('Additional environment variables from pipeline.') +param additionalEnvVars array = [] + +// ────────────────────────────────────────────── +// Variables +// ────────────────────────────────────────────── + +var normalizedSqlServerName = toLower(sqlServerName) +var sqlManagedIdentityName = '${normalizedSqlServerName}-uami' +var sqlDatabaseName = 'FHIR${fhirVersion}' + +var sqlManagedIdentityResourceId = resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', sqlManagedIdentityName) + +var userAssignedIdentities = { + '${sqlManagedIdentityResourceId}': {} + '${acrPullUserAssignedManagedIdentityResourceId}': {} +} + +var datastoreEnvVars = [ + { name: 'DataStore', value: 'SqlServer' } + { name: 'SqlServer__Initialize', value: 'true' } + { + name: 'SqlServer__SchemaOptions__AutomaticUpdatesEnabled' + value: sqlSchemaAutomaticUpdatesEnabled == 'auto' ? 'true' : 'false' + } + { name: 'SqlServer__DeleteAllDataOnStartup', value: 'false' } + { name: 'SqlServer__AllowDatabaseCreation', value: 'true' } +] + +// ────────────────────────────────────────────── +// Modules +// ────────────────────────────────────────────── + +module common 'modules/fhir-aca-common.bicep' = { + name: '${toLower(containerAppName)}-common' + params: { + containerAppName: containerAppName + containerAppsEnvironmentName: containerAppsEnvironmentName + keyVaultName: keyVaultName + fhirVersion: fhirVersion + registryName: registryName + imageTag: imageTag + securityAuthenticationAuthority: securityAuthenticationAuthority + securityAuthenticationAudience: securityAuthenticationAudience + acrPullUserAssignedManagedIdentityResourceId: acrPullUserAssignedManagedIdentityResourceId + enableExport: enableExport + enableImport: enableImport + enableConvertData: enableConvertData + enableReindex: enableReindex + minReplicas: minReplicas + maxReplicas: maxReplicas + containerCpu: containerCpu + containerMemory: containerMemory + additionalEnvVars: additionalEnvVars + datastoreEnvVars: datastoreEnvVars + userAssignedIdentities: userAssignedIdentities + } +} + +// ────────────────────────────────────────────── +// SQL-specific resources +// ────────────────────────────────────────────── + +resource existingSqlServer 'Microsoft.Sql/servers@2021-11-01' existing = { + name: normalizedSqlServerName +} + +resource existingSqlUami 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' existing = { + name: sqlManagedIdentityName +} + +resource sqlConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2022-07-01' = { + name: '${toLower(keyVaultName)}/SqlServer--ConnectionString' + properties: { + contentType: 'text/plain' + value: 'Server=tcp:${existingSqlServer.properties.fullyQualifiedDomainName},1433;Initial Catalog=${sqlDatabaseName};Persist Security Info=False;Authentication=ActiveDirectoryMSI;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;User Id=${existingSqlUami.properties.clientId};' + } +} + +// ────────────────────────────────────────────── +// Outputs +// ────────────────────────────────────────────── + +output containerAppName string = common.outputs.containerAppName +output containerAppFqdn string = common.outputs.containerAppFqdn +output containerAppUrl string = common.outputs.containerAppUrl +output exportStorageUri string = common.outputs.exportStorageUri +output integrationStorageUri string = common.outputs.integrationStorageUri diff --git a/samples/templates/aca/modules/fhir-aca-common.bicep b/samples/templates/aca/modules/fhir-aca-common.bicep new file mode 100644 index 0000000000..c7895560e7 --- /dev/null +++ b/samples/templates/aca/modules/fhir-aca-common.bicep @@ -0,0 +1,267 @@ +// Common FHIR ACA module — shared resources for both SQL and Cosmos deployments. + +// ────────────────────────────────────────────── +// Parameters +// ────────────────────────────────────────────── + +@description('Name of the Azure Container App hosting FHIR.') +param containerAppName string + +@description('Name of the Container Apps Environment.') +param containerAppsEnvironmentName string + +@description('Name of the Key Vault used by FHIR server.') +@maxLength(24) +param keyVaultName string + +@description('FHIR version to deploy.') +@allowed(['Stu3', 'R4', 'R4B', 'R5']) +param fhirVersion string = 'R4' + +@description('Container registry server host name.') +param registryName string + +@description('Docker image tag.') +param imageTag string = 'latest' + +@description('Authority URL for AAD authentication.') +param securityAuthenticationAuthority string = '' + +@description('Audience for AAD authentication.') +param securityAuthenticationAudience string = '' + +@description('Resource ID of UAMI used to pull from ACR.') +param acrPullUserAssignedManagedIdentityResourceId string + +@description('Enable export operations.') +param enableExport bool = true + +@description('Enable import operations.') +param enableImport bool = true + +@description('Enable ConvertData operations.') +param enableConvertData bool = true + +@description('Enable reindex operations.') +param enableReindex bool = true + +@description('Minimum number of replicas.') +param minReplicas int = 0 + +@description('Maximum number of replicas.') +param maxReplicas int = 8 + +@description('CPU cores per container.') +param containerCpu string = '0.5' + +@description('Memory per container.') +param containerMemory string = '1Gi' + +@description('Additional environment variables from pipeline.') +param additionalEnvVars array = [] + +@description('Datastore-specific environment variables (SQL or Cosmos settings).') +param datastoreEnvVars array = [] + +@description('User-assigned managed identities object to attach to the container app.') +param userAssignedIdentities object + +// ────────────────────────────────────────────── +// Variables +// ────────────────────────────────────────────── + +var normalizedAppName = toLower(containerAppName) +var normalizedEnvName = toLower(containerAppsEnvironmentName) +var normalizedKeyVaultName = toLower(keyVaultName) + +var isMAG = contains(resourceGroup().location, 'usgov') || contains(resourceGroup().location, 'usdod') + +var imageRepositoryName = contains(registryName, 'mcr.') + ? '${toLower(fhirVersion)}-fhir-server' + : '${toLower(fhirVersion)}_fhir-server' +var containerImage = '${registryName}/${imageRepositoryName}:${imageTag}' + +var blobStorageUri = isMAG ? '.blob.core.usgovcloudapi.net' : '.blob.${environment().suffixes.storage}' +var storageAccountPrefix = substring(replace(normalizedAppName, '-', ''), 0, min(11, length(replace(normalizedAppName, '-', '')))) +var storageAccountName = '${storageAccountPrefix}${uniqueString(resourceGroup().id, normalizedAppName)}' +var storageAccountUri = 'https://${storageAccountName}${blobStorageUri}' + +var keyVaultEndpoint = isMAG + ? 'https://${normalizedKeyVaultName}.vault.usgovcloudapi.net/' + : 'https://${normalizedKeyVaultName}${environment().suffixes.keyvaultDns}/' + +var enableIntegrationStore = enableExport || enableImport + +var sharedEnvVars = [ + { name: 'ASPNETCORE_FORWARDEDHEADERS_ENABLED', value: 'true' } + { name: 'KeyVault__Endpoint', value: keyVaultEndpoint } + { name: 'FhirServer__Security__Enabled', value: 'true' } + { name: 'FhirServer__Security__EnableAadSmartOnFhirProxy', value: 'true' } + { name: 'FhirServer__Security__Authentication__Authority', value: securityAuthenticationAuthority } + { name: 'FhirServer__Security__Authentication__Audience', value: securityAuthenticationAudience } + { name: 'TaskHosting__Enabled', value: 'true' } + { name: 'TaskHosting__PollingFrequencyInSeconds', value: '1' } + { name: 'TaskHosting__MaxRunningTaskCount', value: '2' } + { name: 'FhirServer__CoreFeatures__SearchParameterCacheRefreshIntervalSeconds', value: '2' } + { name: 'FhirServer__Operations__Export__Enabled', value: enableExport ? 'true' : 'false' } + { name: 'FhirServer__Operations__Export__StorageAccountUri', value: enableExport ? storageAccountUri : 'null' } + { name: 'FhirServer__Operations__Import__Enabled', value: enableImport ? 'true' : 'false' } + { name: 'FhirServer__Operations__IntegrationDataStore__StorageAccountUri', value: enableImport ? storageAccountUri : 'null' } + { name: 'FhirServer__Operations__ConvertData__Enabled', value: enableConvertData ? 'true' : 'false' } + { name: 'FhirServer__Operations__ConvertData__ContainerRegistryServers__0', value: enableConvertData ? registryName : 'null' } + { name: 'FhirServer__Operations__Reindex__Enabled', value: enableReindex ? 'true' : 'false' } +] + +var allEnvVars = concat(sharedEnvVars, datastoreEnvVars, additionalEnvVars) + +// ────────────────────────────────────────────── +// Resources +// ────────────────────────────────────────────── + +resource containerApp 'Microsoft.App/containerApps@2024-03-01' = { + name: normalizedAppName + location: resourceGroup().location + identity: { + type: 'SystemAssigned,UserAssigned' + userAssignedIdentities: userAssignedIdentities + } + properties: { + managedEnvironmentId: resourceId('Microsoft.App/managedEnvironments', normalizedEnvName) + configuration: { + activeRevisionsMode: 'Single' + ingress: { + external: true + targetPort: 8080 + allowInsecure: false + transport: 'auto' + } + registries: [ + { + server: registryName + identity: acrPullUserAssignedManagedIdentityResourceId + } + ] + } + template: { + containers: [ + { + name: normalizedAppName + image: containerImage + resources: { + cpu: json(containerCpu) + memory: containerMemory + } + env: allEnvVars + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/health/check' + port: 8080 + } + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + } + { + type: 'Readiness' + httpGet: { + path: '/health/check' + port: 8080 + } + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + } + ] + } + ] + scale: { + minReplicas: minReplicas + maxReplicas: maxReplicas + rules: [ + { + name: 'http-scaling' + http: { + metadata: { + concurrentRequests: '50' + } + } + } + { + name: 'cpu-scaling' + custom: { + type: 'cpu' + metadata: { + type: 'Utilization' + value: '70' + } + } + } + ] + } + } + } +} + +resource keyVault 'Microsoft.KeyVault/vaults@2022-07-01' = { + name: normalizedKeyVaultName + location: resourceGroup().location + properties: { + sku: { + family: 'A' + name: 'standard' + } + tenantId: subscription().tenantId + accessPolicies: [] + enableRbacAuthorization: true + enabledForDeployment: false + } +} + +resource keyVaultSecretsOfficerRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(uniqueString('KeyVaultSecretsOfficer', normalizedAppName, normalizedKeyVaultName)) + scope: keyVault + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'b86a8fe4-44ce-4948-aee5-eccb2c155cd7') + principalId: containerApp.identity.principalId + } +} + +resource storageAccount 'Microsoft.Storage/storageAccounts@2019-06-01' = if (enableIntegrationStore) { + name: storageAccountName + location: resourceGroup().location + kind: 'Storage' + sku: { + name: 'Standard_LRS' + } + properties: { + supportsHttpsTrafficOnly: true + #disable-next-line BCP037 + allowBlobPublicAccess: false + allowSharedKeyAccess: false + } +} + +resource storageBlobDataContributorRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (enableIntegrationStore) { + name: guid(uniqueString(storageAccountName, normalizedAppName)) + scope: storageAccount + properties: { + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', 'ba92f5b4-2d11-453d-a403-e96b0029c9fe') + principalId: containerApp.identity.principalId + principalType: 'ServicePrincipal' + } +} + +// ────────────────────────────────────────────── +// Outputs +// ────────────────────────────────────────────── + +output containerAppName string = containerApp.name +output containerAppFqdn string = containerApp.properties.configuration.ingress.fqdn +output containerAppUrl string = 'https://${containerApp.properties.configuration.ingress.fqdn}' +output keyVaultName string = keyVault.name +output exportStorageUri string = enableExport ? storageAccountUri : 'null' +output integrationStorageUri string = enableImport ? storageAccountUri : 'null' diff --git a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/CustomHeadersTests.cs b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/CustomHeadersTests.cs index 9367b8fdbe..e02558db25 100644 --- a/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/CustomHeadersTests.cs +++ b/test/Microsoft.Health.Fhir.Shared.Tests.E2E/Rest/CustomHeadersTests.cs @@ -55,7 +55,19 @@ public async Task GivenNoRequestHeader_WhenSendRequest_TheServerShouldntReturnCo Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.NotEmpty(response.Headers.GetValues(KnownHeaders.RequestId)); - Assert.False(response.Headers.Contains(KnownHeaders.CorrelationId)); + + // When running behind a reverse proxy (e.g. Azure Container Apps Envoy), + // the proxy may inject an X-Request-Id header before the request reaches + // the FHIR server, causing the server to return X-Correlation-Id even though + // the test client did not send one. Only assert absence when not behind a proxy. + if (response.Headers.Contains(KnownHeaders.CorrelationId)) + { + // If present, it should NOT match the server-generated X-Request-Id, + // confirming it came from the proxy, not from client input. + var correlationId = response.Headers.GetValues(KnownHeaders.CorrelationId); + var requestId = response.Headers.GetValues(KnownHeaders.RequestId); + Assert.NotEqual(correlationId, requestId); + } } [Theory]