diff --git a/Directory.Packages.props b/Directory.Packages.props
index cb9e062442..e8d56c270c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -6,7 +6,7 @@
5.11.0
10.0.10
6.2.0
- 0.0.163
+ 0.6.73
diff --git a/build/build-variables.yml b/build/build-variables.yml
index 61e5a80201..27ee614caa 100644
--- a/build/build-variables.yml
+++ b/build/build-variables.yml
@@ -17,8 +17,11 @@ variables:
azureContainerRegistryName: 'healthplatformregistry'
azureContainerRegistry: '$(azureContainerRegistryName).azurecr.io'
DeploymentEnvironmentNameSql: '$(DeploymentEnvironmentName)-sql'
+ DeploymentEnvironmentNameSqlVNext: '$(DeploymentEnvironmentName)-svn'
+ SqlVNextElasticPoolName: '$(DeploymentEnvironmentName)-vnext-pool'
DeploymentEnvironmentNameR4: '$(DeploymentEnvironmentName)-r4'
DeploymentEnvironmentNameR4Sql: '$(DeploymentEnvironmentNameR4)-sql'
+ DeploymentEnvironmentNameR4SqlVNext: '$(DeploymentEnvironmentName)-r4vn'
DeploymentEnvironmentNameR4B: '$(DeploymentEnvironmentName)-r4b'
DeploymentEnvironmentNameR4BSql: '$(DeploymentEnvironmentNameR4B)-sql'
DeploymentEnvironmentNameR5: '$(DeploymentEnvironmentName)-r5'
@@ -26,8 +29,10 @@ variables:
AcaEnvironmentName: '$(DeploymentEnvironmentName)-acae'
# Key Vault names (shorter due to 24 character limit)
KeyVaultNameSql: '$(KeyVaultBaseName)-sql'
+ KeyVaultNameSqlVNext: '$(KeyVaultBaseName)-sql-vn'
KeyVaultNameR4: '$(KeyVaultBaseName)-r4'
KeyVaultNameR4Sql: '$(KeyVaultNameR4)-sql'
+ KeyVaultNameR4SqlVNext: '$(KeyVaultBaseName)-r4-vn'
KeyVaultNameR4B: '$(KeyVaultBaseName)-r4b'
KeyVaultNameR4BSql: '$(KeyVaultNameR4B)-sql'
KeyVaultNameR5: '$(KeyVaultBaseName)-r5'
diff --git a/build/ci-deploy.yml b/build/ci-deploy.yml
index a79fdaa075..21df56cdcd 100644
--- a/build/ci-deploy.yml
+++ b/build/ci-deploy.yml
@@ -6,38 +6,74 @@
name: $(SourceBranchName)-$(Date:yyyyMMdd)$(Rev:-r)
trigger: none
+parameters:
+- name: buildImages
+ displayName: Build images
+ type: boolean
+ default: true
+- name: imageTagOverride
+ displayName: Existing image tag (required when images are not built)
+ type: string
+ default: ''
+
variables:
- template: ci-variables.yml
- template: build-variables.yml
stages:
-- stage: UpdateVersion
- displayName: 'Determine Semver'
- dependsOn: []
- jobs:
- - job: Semver
- pool:
- name: '$(InternalPool)'
- demands:
- - ImageOverride -equals $(InternalLinuxImage)
- steps:
- - template: ./jobs/update-semver.yml
+- ${{ if eq(parameters.buildImages, true) }}:
+ - stage: UpdateVersion
+ displayName: 'Determine Semver'
+ dependsOn: []
+ jobs:
+ - job: Semver
+ pool:
+ name: '$(InternalPool)'
+ demands:
+ - ImageOverride -equals $(InternalLinuxImage)
+ steps:
+ - template: ./jobs/update-semver.yml
-- stage: DockerBuild
- displayName: 'Build images'
- dependsOn:
- - UpdateVersion
- variables:
- assemblySemFileVer: $[stageDependencies.UpdateVersion.Semver.outputs['SetVariablesFromGitVersion.assemblySemFileVer']]
- jobs:
- - template: ./jobs/docker-build-all.yml
- parameters:
- tag: $(ImageTag)
- buildPlatform: $(publicDockerImagePlatforms)
+ - stage: DockerBuild
+ displayName: 'Build images'
+ dependsOn:
+ - UpdateVersion
+ variables:
+ assemblySemFileVer: $[stageDependencies.UpdateVersion.Semver.outputs['SetVariablesFromGitVersion.assemblySemFileVer']]
+ jobs:
+ - template: ./jobs/docker-build-all.yml
+ parameters:
+ tag: $(ImageTag)
+ buildPlatform: $(publicDockerImagePlatforms)
+
+- ${{ if eq(parameters.buildImages, false) }}:
+ - stage: ValidateRecoveryParameters
+ displayName: 'Validate recovery parameters'
+ dependsOn: []
+ jobs:
+ - job: ValidateRecoveryParameters
+ displayName: 'Validate recovery parameters'
+ pool:
+ name: '$(AzurePipelinesPool)'
+ vmImage: '$(LinuxVmImage)'
+ steps:
+ - task: PowerShell@2
+ displayName: 'Require an existing image tag'
+ env:
+ IMAGE_TAG_OVERRIDE: ${{ parameters.imageTagOverride }}
+ inputs:
+ targetType: inline
+ pwsh: true
+ script: |
+ if ([string]::IsNullOrWhiteSpace($env:IMAGE_TAG_OVERRIDE)) {
+ throw "Parameter 'imageTagOverride' must be non-empty when 'buildImages' is false."
+ }
- stage: provisionEnvironment
displayName: Provision Environment
- dependsOn: []
+ dependsOn:
+ - ${{ if eq(parameters.buildImages, false) }}:
+ - ValidateRecoveryParameters
jobs:
- template: ./jobs/cleanup-resourcegroup-aad.yml
- job: provision
@@ -168,11 +204,27 @@ stages:
dbMinCapacity: 0
dbMaxCapacity: 8
+- stage: deploySqlVNextElasticPool
+ displayName: 'Deploy SQL vNext Elastic Pool (CI)'
+ dependsOn:
+ - deploySqlServer
+ jobs:
+ - template: ./jobs/provision-sqlElasticPool.yml
+ parameters:
+ resourceGroup: $(ResourceGroupName)
+ sqlServerName: $(DeploymentEnvironmentName)
+ elasticPoolName: $(SqlVNextElasticPoolName)
+ capacity: 4
+ maxSizeBytes: '68719476736'
+ dbMinCapacity: 0
+ dbMaxCapacity: 2
+
- stage: deployStu3
displayName: 'Deploy STU3 CosmosDB Site'
dependsOn:
- aadTestEnvironment
- - DockerBuild
+ - ${{ if eq(parameters.buildImages, true) }}:
+ - DockerBuild
- createNsp
- createAcaEnvironment
jobs:
@@ -186,14 +238,18 @@ stages:
subscription: $(ConnectedServiceName)
resourceGroup: $(ResourceGroupName)
testEnvironmentUrl: $(TestApplicationResource)
- imageTag: $(ImageTag)
+ ${{ if eq(parameters.buildImages, true) }}:
+ imageTag: $(ImageTag)
+ ${{ else }}:
+ imageTag: ${{ parameters.imageTagOverride }}
reindexEnabled: true
- stage: deployStu3Sql
displayName: 'Deploy STU3 SQL Site'
dependsOn:
- aadTestEnvironment
- - DockerBuild
+ - ${{ if eq(parameters.buildImages, true) }}:
+ - DockerBuild
- deploySqlServer
- deploySqlElasticPool
- createAcaEnvironment
@@ -208,18 +264,53 @@ stages:
subscription: $(ConnectedServiceName)
resourceGroup: $(ResourceGroupName)
testEnvironmentUrl: $(TestApplicationResource)
- imageTag: $(ImageTag)
+ ${{ if eq(parameters.buildImages, true) }}:
+ imageTag: $(ImageTag)
+ ${{ else }}:
+ imageTag: ${{ parameters.imageTagOverride }}
schemaAutomaticUpdatesEnabled: 'auto'
sqlServerName: $(DeploymentEnvironmentName)
sqlComputeTier: 'Hyperscale'
sqlElasticPoolName: $(DeploymentEnvironmentName)-pool
reindexEnabled: true
+- stage: deployStu3SqlVNext
+ displayName: 'Deploy STU3 SQL vNext SDK Site'
+ dependsOn:
+ - aadTestEnvironment
+ - ${{ if eq(parameters.buildImages, true) }}:
+ - DockerBuild
+ - deploySqlServer
+ - deploySqlVNextElasticPool
+ - createAcaEnvironment
+ jobs:
+ - template: ./jobs/provision-deploy.yml
+ parameters:
+ dataStore: sql
+ version: Stu3
+ webAppName: $(DeploymentEnvironmentNameSqlVNext)
+ acaEnvironmentName: $(AcaEnvironmentName)
+ keyVaultName: $(KeyVaultNameSqlVNext)
+ subscription: $(ConnectedServiceName)
+ resourceGroup: $(ResourceGroupName)
+ testEnvironmentUrl: $(TestApplicationResource)
+ ${{ if eq(parameters.buildImages, true) }}:
+ imageTag: $(ImageTag)
+ ${{ else }}:
+ imageTag: ${{ parameters.imageTagOverride }}
+ schemaAutomaticUpdatesEnabled: 'auto'
+ sqlServerName: $(DeploymentEnvironmentName)
+ sqlElasticPoolName: $(SqlVNextElasticPoolName)
+ sqlDatabaseName: FHIRStu3VNext
+ fhirSdkProviderDefault: Ignixa
+ reindexEnabled: true
+
- stage: deployR4
displayName: 'Deploy R4 CosmosDB Site'
dependsOn:
- aadTestEnvironment
- - DockerBuild
+ - ${{ if eq(parameters.buildImages, true) }}:
+ - DockerBuild
- createNsp
- createAcaEnvironment
jobs:
@@ -233,14 +324,18 @@ stages:
subscription: $(ConnectedServiceName)
resourceGroup: $(ResourceGroupName)
testEnvironmentUrl: $(TestApplicationResource)
- imageTag: $(ImageTag)
+ ${{ if eq(parameters.buildImages, true) }}:
+ imageTag: $(ImageTag)
+ ${{ else }}:
+ imageTag: ${{ parameters.imageTagOverride }}
reindexEnabled: true
- stage: deployR4Sql
displayName: 'Deploy R4 SQL Site'
dependsOn:
- aadTestEnvironment
- - DockerBuild
+ - ${{ if eq(parameters.buildImages, true) }}:
+ - DockerBuild
- deploySqlServer
- deploySqlElasticPool
- createAcaEnvironment
@@ -255,18 +350,53 @@ stages:
subscription: $(ConnectedServiceName)
resourceGroup: $(ResourceGroupName)
testEnvironmentUrl: $(TestApplicationResource)
- imageTag: $(ImageTag)
+ ${{ if eq(parameters.buildImages, true) }}:
+ imageTag: $(ImageTag)
+ ${{ else }}:
+ imageTag: ${{ parameters.imageTagOverride }}
schemaAutomaticUpdatesEnabled: 'auto'
sqlServerName: $(DeploymentEnvironmentName)
sqlComputeTier: 'Hyperscale'
sqlElasticPoolName: $(DeploymentEnvironmentName)-pool
reindexEnabled: true
+- stage: deployR4SqlVNext
+ displayName: 'Deploy R4 SQL vNext SDK Site'
+ dependsOn:
+ - aadTestEnvironment
+ - ${{ if eq(parameters.buildImages, true) }}:
+ - DockerBuild
+ - deploySqlServer
+ - deploySqlVNextElasticPool
+ - createAcaEnvironment
+ jobs:
+ - template: ./jobs/provision-deploy.yml
+ parameters:
+ dataStore: sql
+ version: R4
+ webAppName: $(DeploymentEnvironmentNameR4SqlVNext)
+ acaEnvironmentName: $(AcaEnvironmentName)
+ keyVaultName: $(KeyVaultNameR4SqlVNext)
+ subscription: $(ConnectedServiceName)
+ resourceGroup: $(ResourceGroupName)
+ testEnvironmentUrl: $(TestApplicationResource)
+ ${{ if eq(parameters.buildImages, true) }}:
+ imageTag: $(ImageTag)
+ ${{ else }}:
+ imageTag: ${{ parameters.imageTagOverride }}
+ schemaAutomaticUpdatesEnabled: 'auto'
+ sqlServerName: $(DeploymentEnvironmentName)
+ sqlElasticPoolName: $(SqlVNextElasticPoolName)
+ sqlDatabaseName: FHIRR4VNext
+ fhirSdkProviderDefault: Ignixa
+ reindexEnabled: true
+
- stage: deployR5Sql
displayName: 'Deploy R5 SQL Site'
dependsOn:
- aadTestEnvironment
- - DockerBuild
+ - ${{ if eq(parameters.buildImages, true) }}:
+ - DockerBuild
- deploySqlServer
- deploySqlElasticPool
- createAcaEnvironment
@@ -281,7 +411,10 @@ stages:
subscription: $(ConnectedServiceName)
resourceGroup: $(ResourceGroupName)
testEnvironmentUrl: $(TestApplicationResource)
- imageTag: $(ImageTag)
+ ${{ if eq(parameters.buildImages, true) }}:
+ imageTag: $(ImageTag)
+ ${{ else }}:
+ imageTag: ${{ parameters.imageTagOverride }}
schemaAutomaticUpdatesEnabled: 'auto'
sqlServerName: $(DeploymentEnvironmentName)
sqlComputeTier: 'Hyperscale'
diff --git a/build/ci-pipeline.yml b/build/ci-pipeline.yml
index 886ffa3691..e1109d8725 100644
--- a/build/ci-pipeline.yml
+++ b/build/ci-pipeline.yml
@@ -130,6 +130,18 @@ stages:
- ImageOverride -equals $(InternalWindowsImage)
steps:
- template: ./jobs/analyze.yml
+ - job: ValidateAcaSqlDeploymentPlan
+ displayName: 'Validate ACA SQL deployment plan'
+ pool:
+ name: '$(InternalPool)'
+ demands:
+ - ImageOverride -equals $(InternalLinuxImage)
+ steps:
+ - task: PowerShell@2
+ displayName: 'Validate SQL vNext deployment plan'
+ inputs:
+ filePath: '$(System.DefaultWorkingDirectory)/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1'
+ pwsh: true
- stage: DockerBuild
displayName: 'Build images'
@@ -170,6 +182,19 @@ stages:
imageTag: $(ImageTag)
resourceGroup: $(ResourceGroupName)
+- stage: redeployStu3SqlVNext
+ displayName: 'Redeploy STU3 SQL vNext SDK Site'
+ dependsOn:
+ - DockerBuild
+ jobs:
+ - template: ./jobs/redeploy-webapp.yml
+ parameters:
+ version: Stu3
+ webAppName: $(DeploymentEnvironmentNameSqlVNext)
+ subscription: $(ConnectedServiceName)
+ resourceGroup: $(ResourceGroupName)
+ imageTag: $(ImageTag)
+
- stage: testStu3Cosmos
displayName: 'Run Stu3 Cosmos Tests'
dependsOn:
@@ -200,6 +225,27 @@ stages:
runReindexJob: false
runBulkUpdateJob: false
+- stage: testStu3SqlVNext
+ displayName: 'Run Stu3 SQL vNext SDK Tests'
+ dependsOn:
+ - BuildArtifacts
+ - redeployStu3SqlVNext
+ jobs:
+ - template: ./jobs/run-sql-tests.yml
+ parameters:
+ version: Stu3
+ keyVaultName: $(KeyVaultNameSqlVNext)
+ containerAppName: $(DeploymentEnvironmentNameSqlVNext)
+ integrationSqlServerName: $(DeploymentEnvironmentName)inttest
+ runIntegrationTests: false
+ runBulkUpdateJob: false
+ testRunTitleSuffix: ' vNext SDK'
+ expectedFhirSdkProviderDefault: Ignixa
+ expectedKeyVaultName: $(KeyVaultNameSqlVNext)
+ expectedSqlServerName: $(DeploymentEnvironmentName)
+ expectedSqlDatabaseName: FHIRStu3VNext
+ expectedSqlElasticPoolName: $(SqlVNextElasticPoolName)
+
# *********************** R4 ***********************
- stage: redeployR4
displayName: 'Redeploy R4 CosmosDB Site'
@@ -227,6 +273,19 @@ stages:
imageTag: $(ImageTag)
resourceGroup: $(ResourceGroupName)
+- stage: redeployR4SqlVNext
+ displayName: 'Redeploy R4 SQL vNext SDK Site'
+ dependsOn:
+ - DockerBuild
+ jobs:
+ - template: ./jobs/redeploy-webapp.yml
+ parameters:
+ version: R4
+ webAppName: $(DeploymentEnvironmentNameR4SqlVNext)
+ subscription: $(ConnectedServiceName)
+ resourceGroup: $(ResourceGroupName)
+ imageTag: $(ImageTag)
+
- stage: testR4Cosmos
displayName: 'Run R4 Cosmos Tests'
dependsOn:
@@ -257,6 +316,27 @@ stages:
runReindexJob: false
runBulkUpdateJob: false
+- stage: testR4SqlVNext
+ displayName: 'Run R4 SQL vNext SDK Tests'
+ dependsOn:
+ - BuildArtifacts
+ - redeployR4SqlVNext
+ jobs:
+ - template: ./jobs/run-sql-tests.yml
+ parameters:
+ version: R4
+ keyVaultName: $(KeyVaultNameR4SqlVNext)
+ containerAppName: $(DeploymentEnvironmentNameR4SqlVNext)
+ integrationSqlServerName: $(DeploymentEnvironmentName)inttest
+ runIntegrationTests: false
+ runBulkUpdateJob: false
+ testRunTitleSuffix: ' vNext SDK'
+ expectedFhirSdkProviderDefault: Ignixa
+ expectedKeyVaultName: $(KeyVaultNameR4SqlVNext)
+ expectedSqlServerName: $(DeploymentEnvironmentName)
+ expectedSqlDatabaseName: FHIRR4VNext
+ expectedSqlElasticPoolName: $(SqlVNextElasticPoolName)
+
# *********************** R5 ***********************
- stage: redeployR5Sql
displayName: 'Redeploy R5 SQL Site'
@@ -313,8 +393,10 @@ stages:
dependsOn:
- testStu3Cosmos
- testStu3Sql
+ - testStu3SqlVNext
- testR4Cosmos
- testR4Sql
+ - testR4SqlVNext
- testR5Sql
# Only scale down on success. On failure we leave the apps at min=3 so developers can investigate
# via the live endpoints and ADO "Rerun failed stages" (which does NOT re-execute scaleUpContainerApps)
diff --git a/build/jobs/e2e-tests.yml b/build/jobs/e2e-tests.yml
index 0874229659..7e0f2f75ed 100644
--- a/build/jobs/e2e-tests.yml
+++ b/build/jobs/e2e-tests.yml
@@ -11,6 +11,21 @@ parameters:
- name: testRunTitleSuffix
type: string
default: ''
+- name: expectedFhirSdkProviderDefault
+ type: string
+ default: ''
+- name: expectedKeyVaultName
+ type: string
+ default: ''
+- name: expectedSqlServerName
+ type: string
+ default: ''
+- name: expectedSqlDatabaseName
+ type: string
+ default: ''
+- name: expectedSqlElasticPoolName
+ type: string
+ default: ''
steps:
- template: e2e-tests-extract.yml
@@ -22,6 +37,11 @@ steps:
containerAppName: ${{ parameters.containerAppName }}
version: ${{ parameters.version }}
appServiceType: ${{ parameters.appServiceType }}
+ expectedFhirSdkProviderDefault: '${{ parameters.expectedFhirSdkProviderDefault }}'
+ expectedKeyVaultName: '${{ parameters.expectedKeyVaultName }}'
+ expectedSqlServerName: '${{ parameters.expectedSqlServerName }}'
+ expectedSqlDatabaseName: '${{ parameters.expectedSqlDatabaseName }}'
+ expectedSqlElasticPoolName: '${{ parameters.expectedSqlElasticPoolName }}'
- task: PowerShell@2
displayName: 'E2E ${{ parameters.version }} ${{parameters.appServiceType}}${{ parameters.testRunTitleSuffix }}'
diff --git a/build/jobs/provision-deploy.yml b/build/jobs/provision-deploy.yml
index fa832a9f73..9ece500fda 100644
--- a/build/jobs/provision-deploy.yml
+++ b/build/jobs/provision-deploy.yml
@@ -31,6 +31,12 @@ parameters:
- name: sqlElasticPoolName
type: string
default: ''
+- name: sqlDatabaseName
+ type: string
+ default: ''
+- name: fhirSdkProviderDefault
+ type: string
+ default: ''
- name: keyVaultName
type: string
@@ -71,6 +77,8 @@ jobs:
-TenantIdGuid "$(tenant-id-guid)"
-SqlServerName "${{ parameters.sqlServerName }}"
-SqlElasticPoolName "${{ parameters.sqlElasticPoolName }}"
+ -SqlDatabaseName "${{ parameters.sqlDatabaseName }}"
+ -FhirSdkProviderDefault "${{ parameters.fhirSdkProviderDefault }}"
-SchemaAutomaticUpdatesEnabled "${{ parameters.schemaAutomaticUpdatesEnabled }}"
-ReindexEnabled "${{ parameters.reindexEnabled }}"
-MinReplicas $(minReplicas)
diff --git a/build/jobs/run-sql-tests.yml b/build/jobs/run-sql-tests.yml
index 45d9a82d85..6a463d5115 100644
--- a/build/jobs/run-sql-tests.yml
+++ b/build/jobs/run-sql-tests.yml
@@ -16,95 +16,117 @@ parameters:
- name: runBulkUpdateJob
type: boolean
default: true
+- name: runIntegrationTests
+ type: boolean
+ default: true
+- name: testRunTitleSuffix
+ type: string
+ default: ''
+- name: expectedFhirSdkProviderDefault
+ type: string
+ default: ''
+- name: expectedKeyVaultName
+ type: string
+ default: ''
+- name: expectedSqlServerName
+ type: string
+ default: ''
+- name: expectedSqlDatabaseName
+ type: string
+ default: ''
+- name: expectedSqlElasticPoolName
+ type: string
+ default: ''
jobs:
-- job: "SqlIntegrationTests"
- timeoutInMinutes: 75
- pool:
- name: '$(InternalPool)'
- demands:
- - ImageOverride -equals $(InternalLinuxImage)
- variables:
- AllowPtrToDetectTestRunRetryFiles: true
- steps:
- - checkout: self
- fetchDepth: 1
- fetchTags: false
- path: source
+- ${{ if eq(parameters.runIntegrationTests, true) }}:
+ - job: "SqlIntegrationTests"
+ timeoutInMinutes: 75
+ pool:
+ name: '$(InternalPool)'
+ demands:
+ - ImageOverride -equals $(InternalLinuxImage)
+ variables:
+ AllowPtrToDetectTestRunRetryFiles: true
+ steps:
+ - checkout: self
+ fetchDepth: 1
+ fetchTags: false
+ path: source
- - template: integration-setup.yml
+ - template: integration-setup.yml
- - template: integration-tests-extract.yml
- parameters:
- version: ${{ parameters.version }}
+ - template: integration-tests-extract.yml
+ parameters:
+ version: ${{ parameters.version }}
- - task: AzureKeyVault@1
- displayName: 'Azure Key Vault: ${{ parameters.keyVaultName }}'
- inputs:
- azureSubscription: $(ConnectedServiceName)
- KeyVaultName: '${{ parameters.keyVaultName }}'
+ - task: AzureKeyVault@1
+ displayName: 'Azure Key Vault: ${{ parameters.keyVaultName }}'
+ inputs:
+ azureSubscription: $(ConnectedServiceName)
+ KeyVaultName: '${{ parameters.keyVaultName }}'
- - task: AzurePowerShell@5
- displayName: 'Set Workload Identity Variables'
- inputs:
- azureSubscription: $(ConnectedServiceName)
- azurePowerShellVersion: latestVersion
- pwsh: true
- ScriptType: inlineScript
- Inline: |
- 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"
+ - task: AzurePowerShell@5
+ displayName: 'Set Workload Identity Variables'
+ inputs:
+ azureSubscription: $(ConnectedServiceName)
+ azurePowerShellVersion: latestVersion
+ pwsh: true
+ ScriptType: inlineScript
+ Inline: |
+ 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"
- - task: DotNetCoreCLI@2
- displayName: 'Build Integration Test Projects'
- inputs:
- command: build
- projects: '$(Pipeline.Workspace)/source/test/**/*${{ parameters.version }}.Tests.Integration.csproj'
- arguments: '--configuration $(buildConfiguration) -f $(defaultBuildFramework)'
+ - task: DotNetCoreCLI@2
+ displayName: 'Build Integration Test Projects'
+ inputs:
+ command: build
+ projects: '$(Pipeline.Workspace)/source/test/**/*${{ parameters.version }}.Tests.Integration.csproj'
+ arguments: '--configuration $(buildConfiguration) -f $(defaultBuildFramework)'
- - task: DotNetCoreCLI@2
- displayName: 'Run SQL Integration Tests with coverage'
- inputs:
- command: test
- projects: '$(Pipeline.Workspace)/source/test/**/*${{ parameters.version }}.Tests.Integration.csproj'
- arguments: '--configuration $(buildConfiguration) --no-build -f $(defaultBuildFramework) -- --filter "FullyQualifiedName!~CosmosDb" --retry-failed-tests 3 --coverage --coverage-output-format cobertura --coverage-settings "$(System.DefaultWorkingDirectory)/CodeCoverage.Mtp.settings.xml" --report-trx'
- testRunTitle: '${{ parameters.version }} SQL Integration Tests'
- # Disable the task's built-in (non-retry-aware) result publishing so the
- # explicit retry-aware PublishTestResults@2 task below is the sole publisher.
- publishTestResults: false
- env:
- 'SqlServer:ConnectionString': 'Server=tcp:${{ parameters.integrationSqlServerName }}.database.windows.net,1433;Initial Catalog=master;Persist Security Info=False;Authentication=ActiveDirectoryWorkloadIdentity;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;User Id=$(AZURESUBSCRIPTION_CLIENT_ID);'
- platformOptions__resultDirectory: '$(Agent.TempDirectory)/coverage'
- 'AZURESUBSCRIPTION_CLIENT_ID': '$(AZURESUBSCRIPTION_CLIENT_ID)'
- 'AZURESUBSCRIPTION_TENANT_ID': '$(AZURESUBSCRIPTION_TENANT_ID)'
- 'AZURESUBSCRIPTION_SERVICE_CONNECTION_ID': '$(AZURESUBSCRIPTION_SERVICE_CONNECTION_ID)'
- 'SYSTEM_ACCESSTOKEN': $(System.AccessToken)
+ - task: DotNetCoreCLI@2
+ displayName: 'Run SQL Integration Tests with coverage'
+ inputs:
+ command: test
+ projects: '$(Pipeline.Workspace)/source/test/**/*${{ parameters.version }}.Tests.Integration.csproj'
+ arguments: '--configuration $(buildConfiguration) --no-build -f $(defaultBuildFramework) -- --filter "FullyQualifiedName!~CosmosDb" --retry-failed-tests 3 --coverage --coverage-output-format cobertura --coverage-settings "$(System.DefaultWorkingDirectory)/CodeCoverage.Mtp.settings.xml" --report-trx'
+ testRunTitle: '${{ parameters.version }} SQL Integration Tests'
+ # Disable the task's built-in (non-retry-aware) result publishing so the
+ # explicit retry-aware PublishTestResults@2 task below is the sole publisher.
+ publishTestResults: false
+ env:
+ 'SqlServer:ConnectionString': 'Server=tcp:${{ parameters.integrationSqlServerName }}.database.windows.net,1433;Initial Catalog=master;Persist Security Info=False;Authentication=ActiveDirectoryWorkloadIdentity;MultipleActiveResultSets=False;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;User Id=$(AZURESUBSCRIPTION_CLIENT_ID);'
+ platformOptions__resultDirectory: '$(Agent.TempDirectory)/coverage'
+ 'AZURESUBSCRIPTION_CLIENT_ID': '$(AZURESUBSCRIPTION_CLIENT_ID)'
+ 'AZURESUBSCRIPTION_TENANT_ID': '$(AZURESUBSCRIPTION_TENANT_ID)'
+ 'AZURESUBSCRIPTION_SERVICE_CONNECTION_ID': '$(AZURESUBSCRIPTION_SERVICE_CONNECTION_ID)'
+ 'SYSTEM_ACCESSTOKEN': $(System.AccessToken)
- - task: PublishTestResults@2
- displayName: 'Publish integration test results'
- inputs:
- testResultsFormat: 'VSTest'
- testResultsFiles: '$(Agent.TempDirectory)/coverage/**/*.trx'
- mergeTestResults: true
- testRunTitle: '${{ parameters.version }} SQL Integration Tests'
- failTaskOnFailedTests: true
- condition: succeededOrFailed()
+ - task: PublishTestResults@2
+ displayName: 'Publish integration test results'
+ inputs:
+ testResultsFormat: 'VSTest'
+ testResultsFiles: '$(Agent.TempDirectory)/coverage/**/*.trx'
+ mergeTestResults: true
+ testRunTitle: '${{ parameters.version }} SQL Integration Tests'
+ failTaskOnFailedTests: true
+ condition: succeededOrFailed()
- - task: reportgenerator@5
- displayName: 'Aggregate SQL integration test coverage'
- condition: succeededOrFailed()
- inputs:
- reports: '$(Agent.TempDirectory)/coverage/**/*.cobertura.xml'
- reporttypes: 'Cobertura'
- targetdir: '$(Agent.TempDirectory)/coverage-aggregated'
+ - task: reportgenerator@5
+ displayName: 'Aggregate SQL integration test coverage'
+ condition: succeededOrFailed()
+ inputs:
+ reports: '$(Agent.TempDirectory)/coverage/**/*.cobertura.xml'
+ reporttypes: 'Cobertura'
+ targetdir: '$(Agent.TempDirectory)/coverage-aggregated'
- - task: PublishBuildArtifacts@1
- displayName: 'Publish SQL integration test coverage'
- inputs:
- pathToPublish: '$(Agent.TempDirectory)/coverage-aggregated'
- artifactName: 'Coverage_IntegrationTests_Sql_${{ parameters.version }}'
- artifactType: 'container'
+ - task: PublishBuildArtifacts@1
+ displayName: 'Publish SQL integration test coverage'
+ inputs:
+ pathToPublish: '$(Agent.TempDirectory)/coverage-aggregated'
+ artifactName: 'Coverage_IntegrationTests_Sql_${{ parameters.version }}'
+ artifactType: 'container'
- job: 'sqlE2eTests'
timeoutInMinutes: 120
@@ -123,6 +145,12 @@ jobs:
containerAppName: '${{ parameters.containerAppName }}'
appServiceType: 'SqlServer'
categoryFilter: '${{ parameters.mainCategoryFilter }}'
+ testRunTitleSuffix: '${{ parameters.testRunTitleSuffix }}'
+ expectedFhirSdkProviderDefault: '${{ parameters.expectedFhirSdkProviderDefault }}'
+ expectedKeyVaultName: '${{ parameters.expectedKeyVaultName }}'
+ expectedSqlServerName: '${{ parameters.expectedSqlServerName }}'
+ expectedSqlDatabaseName: '${{ parameters.expectedSqlDatabaseName }}'
+ expectedSqlElasticPoolName: '${{ parameters.expectedSqlElasticPoolName }}'
- ${{ if eq(parameters.runReindexJob, true) }}:
- job: 'sqlE2eTests_Reindex'
@@ -142,7 +170,12 @@ jobs:
containerAppName: '${{ parameters.containerAppName }}'
appServiceType: 'SqlServer'
categoryFilter: 'Category=IndexAndReindex'
- testRunTitleSuffix: ' Reindex'
+ testRunTitleSuffix: '${{ parameters.testRunTitleSuffix }} Reindex'
+ expectedFhirSdkProviderDefault: '${{ parameters.expectedFhirSdkProviderDefault }}'
+ expectedKeyVaultName: '${{ parameters.expectedKeyVaultName }}'
+ expectedSqlServerName: '${{ parameters.expectedSqlServerName }}'
+ expectedSqlDatabaseName: '${{ parameters.expectedSqlDatabaseName }}'
+ expectedSqlElasticPoolName: '${{ parameters.expectedSqlElasticPoolName }}'
- ${{ if eq(parameters.runBulkUpdateJob, true) }}:
- job: 'sqlE2eTests_BulkUpdate'
@@ -162,4 +195,9 @@ jobs:
containerAppName: '${{ parameters.containerAppName }}'
appServiceType: 'SqlServer'
categoryFilter: 'Category=BulkUpdate'
- testRunTitleSuffix: ' BulkUpdate'
+ testRunTitleSuffix: '${{ parameters.testRunTitleSuffix }} BulkUpdate'
+ expectedFhirSdkProviderDefault: '${{ parameters.expectedFhirSdkProviderDefault }}'
+ expectedKeyVaultName: '${{ parameters.expectedKeyVaultName }}'
+ expectedSqlServerName: '${{ parameters.expectedSqlServerName }}'
+ expectedSqlDatabaseName: '${{ parameters.expectedSqlDatabaseName }}'
+ expectedSqlElasticPoolName: '${{ parameters.expectedSqlElasticPoolName }}'
diff --git a/build/jobs/scripts/Assert-AcaSqlTopology.ps1 b/build/jobs/scripts/Assert-AcaSqlTopology.ps1
new file mode 100644
index 0000000000..89e2e04610
--- /dev/null
+++ b/build/jobs/scripts/Assert-AcaSqlTopology.ps1
@@ -0,0 +1,179 @@
+function Assert-AcaSqlTopology {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyCollection()]
+ [array] $EnvironmentSettings,
+
+ [Parameter(Mandatory = $true)]
+ [string] $ContainerAppName,
+
+ [Parameter(Mandatory = $false)]
+ [AllowEmptyString()]
+ [string] $ResourceGroupName = '',
+
+ [Parameter(Mandatory = $false)]
+ [AllowEmptyString()]
+ [string] $ExpectedKeyVaultName = '',
+
+ [Parameter(Mandatory = $false)]
+ [AllowEmptyString()]
+ [string] $ExpectedSqlServerName = '',
+
+ [Parameter(Mandatory = $false)]
+ [AllowEmptyString()]
+ [string] $ExpectedSqlDatabaseName = '',
+
+ [Parameter(Mandatory = $false)]
+ [AllowEmptyString()]
+ [string] $ExpectedSqlElasticPoolName = '',
+
+ [Parameter(Mandatory = $false)]
+ [scriptblock] $SecretResolver = {
+ param($VaultName, $SecretName)
+ Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName -AsPlainText -ErrorAction Stop
+ },
+
+ [Parameter(Mandatory = $false)]
+ [scriptblock] $DatabaseResolver = {
+ param($ResourceGroup, $ServerName, $DatabaseName)
+ Get-AzSqlDatabase -ResourceGroupName $ResourceGroup -ServerName $ServerName -DatabaseName $DatabaseName -ErrorAction Stop
+ },
+
+ [Parameter(Mandatory = $false)]
+ [scriptblock] $AzureEnvironmentResolver = {
+ $context = Get-AzContext -ErrorAction Stop
+ if ($null -eq $context -or $null -eq $context.Environment) {
+ throw 'No active Azure context is available.'
+ }
+
+ $environmentName = if ($context.Environment -is [string]) {
+ $context.Environment
+ }
+ else {
+ [string]$context.Environment.Name
+ }
+
+ if ([string]::IsNullOrWhiteSpace($environmentName)) {
+ throw 'The active Azure context does not identify an Azure environment.'
+ }
+
+ $environment = Get-AzEnvironment -Name $environmentName -ErrorAction Stop
+ [pscustomobject]@{
+ KeyVaultDnsSuffix = $environment.AzureKeyVaultDnsSuffix
+ SqlDatabaseDnsSuffix = $environment.SqlDatabaseDnsSuffix
+ }
+ }
+ )
+
+ $expectations = @(
+ $ExpectedKeyVaultName,
+ $ExpectedSqlServerName,
+ $ExpectedSqlDatabaseName,
+ $ExpectedSqlElasticPoolName
+ )
+ $suppliedExpectationCount = @($expectations | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count
+ if ($suppliedExpectationCount -eq 0) {
+ return
+ }
+
+ if ($suppliedExpectationCount -ne $expectations.Count -or [string]::IsNullOrWhiteSpace($ResourceGroupName)) {
+ throw 'ACA SQL topology validation requires a resource group and all four expected topology values.'
+ }
+
+ try {
+ $azureEnvironment = & $AzureEnvironmentResolver
+ }
+ catch {
+ $message = 'SQL vNext topology validation could not resolve the active Azure environment. Verify that the Azure service connection is authenticated and configured for the target cloud.'
+ throw [System.InvalidOperationException]::new($message, $_.Exception)
+ }
+
+ if ($null -eq $azureEnvironment) {
+ throw 'SQL vNext topology validation could not resolve the active Azure environment.'
+ }
+
+ $keyVaultDnsSuffix = ([string]$azureEnvironment.KeyVaultDnsSuffix).Trim().Trim('.')
+ $sqlDatabaseDnsSuffix = ([string]$azureEnvironment.SqlDatabaseDnsSuffix).Trim().Trim('.')
+ if ([string]::IsNullOrWhiteSpace($keyVaultDnsSuffix)) {
+ throw 'SQL vNext topology validation requires the active Azure environment to define a Key Vault DNS suffix.'
+ }
+
+ if ([string]::IsNullOrWhiteSpace($sqlDatabaseDnsSuffix)) {
+ throw 'SQL vNext topology validation requires the active Azure environment to define a SQL Database DNS suffix.'
+ }
+
+ $keyVaultSettingName = 'KeyVault__Endpoint'
+ $keyVaultSettings = @($EnvironmentSettings | Where-Object { $_.name -eq $keyVaultSettingName })
+ if ($keyVaultSettings.Count -ne 1) {
+ throw "Container App '$ContainerAppName' must define '$keyVaultSettingName' exactly once."
+ }
+
+ $keyVaultEndpoint = [string]$keyVaultSettings[0].value
+ $endpointUri = $null
+ if (-not [uri]::TryCreate($keyVaultEndpoint, [System.UriKind]::Absolute, [ref]$endpointUri) -or
+ $endpointUri.Scheme -ne [System.Uri]::UriSchemeHttps) {
+ throw "Container App '$ContainerAppName' has invalid '$keyVaultSettingName' value '$keyVaultEndpoint'."
+ }
+
+ $actualKeyVaultHost = $endpointUri.DnsSafeHost.TrimEnd('.')
+ $expectedKeyVaultHost = "$ExpectedKeyVaultName.$keyVaultDnsSuffix"
+ if ($actualKeyVaultHost -ine $expectedKeyVaultHost) {
+ throw "Container App '$ContainerAppName' targets Key Vault host '$actualKeyVaultHost'; expected '$expectedKeyVaultHost'."
+ }
+
+ $connectionString = [string](& $SecretResolver $ExpectedKeyVaultName 'SqlServer--ConnectionString')
+ if ([string]::IsNullOrWhiteSpace($connectionString)) {
+ throw "Key Vault '$ExpectedKeyVaultName' secret 'SqlServer--ConnectionString' is empty."
+ }
+
+ try {
+ $connectionStringBuilder = [System.Data.Common.DbConnectionStringBuilder]::new()
+ $connectionStringBuilder.set_ConnectionString($connectionString)
+ }
+ catch {
+ throw "Key Vault '$ExpectedKeyVaultName' secret 'SqlServer--ConnectionString' is not a valid connection string."
+ }
+
+ $serverValue = $null
+ foreach ($serverKey in @('Server', 'Data Source')) {
+ if ($connectionStringBuilder.ContainsKey($serverKey)) {
+ $serverValue = [string]$connectionStringBuilder[$serverKey]
+ break
+ }
+ }
+
+ $databaseValue = $null
+ foreach ($databaseKey in @('Initial Catalog', 'Database')) {
+ if ($connectionStringBuilder.ContainsKey($databaseKey)) {
+ $databaseValue = [string]$connectionStringBuilder[$databaseKey]
+ break
+ }
+ }
+
+ if ([string]::IsNullOrWhiteSpace($serverValue)) {
+ throw "Key Vault '$ExpectedKeyVaultName' SQL connection string does not define a server; expected '$ExpectedSqlServerName'."
+ }
+
+ $actualServerHost = ($serverValue -replace '^(?i:tcp):', '').Split(',')[0].Trim()
+ $expectedServerHost = "$ExpectedSqlServerName.$sqlDatabaseDnsSuffix"
+ if ([string]::IsNullOrWhiteSpace($actualServerHost) -or $actualServerHost.TrimEnd('.') -ine $expectedServerHost) {
+ throw "Key Vault '$ExpectedKeyVaultName' SQL connection string targets server '$actualServerHost'; expected '$expectedServerHost'."
+ }
+
+ if ([string]::IsNullOrWhiteSpace($databaseValue) -or $databaseValue -ine $ExpectedSqlDatabaseName) {
+ throw "Key Vault '$ExpectedKeyVaultName' SQL connection string targets database '$databaseValue'; expected '$ExpectedSqlDatabaseName'."
+ }
+
+ $database = & $DatabaseResolver $ResourceGroupName $ExpectedSqlServerName $ExpectedSqlDatabaseName
+ if ($null -eq $database) {
+ throw "SQL database '$ExpectedSqlDatabaseName' was not found on server '$ExpectedSqlServerName'."
+ }
+
+ $actualElasticPoolName = [string]$database.ElasticPoolName
+ if ($actualElasticPoolName -ine $ExpectedSqlElasticPoolName) {
+ throw "SQL database '$ExpectedSqlDatabaseName' belongs to elastic pool '$actualElasticPoolName'; expected '$ExpectedSqlElasticPoolName'."
+ }
+
+ Write-Host "Verified Container App '$ContainerAppName' targets Key Vault '$ExpectedKeyVaultName', SQL database '$ExpectedSqlDatabaseName' on server '$ExpectedSqlServerName', and elastic pool '$ExpectedSqlElasticPoolName'."
+}
diff --git a/build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1 b/build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1
new file mode 100644
index 0000000000..0a426c4178
--- /dev/null
+++ b/build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1
@@ -0,0 +1,36 @@
+function Assert-EffectiveFhirSdkProvider {
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory = $true)]
+ [AllowEmptyCollection()]
+ [array] $EnvironmentSettings,
+
+ [Parameter(Mandatory = $false)]
+ [AllowEmptyString()]
+ [string] $ExpectedProvider = '',
+
+ [Parameter(Mandatory = $true)]
+ [string] $ContainerAppName
+ )
+
+ if ([string]::IsNullOrWhiteSpace($ExpectedProvider)) {
+ return
+ }
+
+ $settingName = 'FhirServer__CoreFeatures__FhirSdkProvider__Default'
+ $matchingSettings = @($EnvironmentSettings | Where-Object { $_.name -eq $settingName })
+ if ($matchingSettings.Count -eq 0) {
+ throw "Container App '$ContainerAppName' does not define '$settingName'; expected '$ExpectedProvider'."
+ }
+
+ if ($matchingSettings.Count -gt 1) {
+ throw "Container App '$ContainerAppName' defines '$settingName' more than once."
+ }
+
+ $actualProvider = [string]$matchingSettings[0].value
+ if ($actualProvider -ne $ExpectedProvider) {
+ throw "Container App '$ContainerAppName' has '$settingName' set to '$actualProvider'; expected '$ExpectedProvider'."
+ }
+
+ Write-Host "Verified Container App '$ContainerAppName' uses FHIR SDK provider '$actualProvider'."
+}
diff --git a/build/jobs/scripts/Provision-AcaDeploy.ps1 b/build/jobs/scripts/Provision-AcaDeploy.ps1
index 77d9c9239a..2a3f66fe3f 100644
--- a/build/jobs/scripts/Provision-AcaDeploy.ps1
+++ b/build/jobs/scripts/Provision-AcaDeploy.ps1
@@ -27,6 +27,8 @@ param(
[Parameter(Mandatory = $false)] [string] $SqlServerName = '',
[Parameter(Mandatory = $false)] [string] $SqlElasticPoolName = '',
+ [Parameter(Mandatory = $false)] [string] $SqlDatabaseName = '',
+ [Parameter(Mandatory = $false)] [ValidateSet('', 'Firely', 'Ignixa')] [string] $FhirSdkProviderDefault = '',
[Parameter(Mandatory = $false)] [string] $SchemaAutomaticUpdatesEnabled = 'auto',
[Parameter(Mandatory = $false)] [string] $ReindexEnabled = 'true',
@@ -68,6 +70,22 @@ $additionalProperties["FhirServer__CoreFeatures__SystemConformanceProviderRefres
$additionalProperties["FhirServer__Operations__Reindex__CacheRefreshWaitMultiplier"] = $ReindexCacheRefreshWaitMultiplier
$additionalProperties["ASPNETCORE_FORWARDEDHEADERS_ENABLED"] = "true"
+$fhirSdkProviderSettingName = "FhirServer__CoreFeatures__FhirSdkProvider__Default"
+$sqlDeploymentPlan = $null
+if ($DataStore -eq 'sql') {
+ $configuredFhirSdkProviderDefault = if ($additionalProperties.ContainsKey($fhirSdkProviderSettingName)) {
+ [string]$additionalProperties[$fhirSdkProviderSettingName]
+ } else {
+ ''
+ }
+
+ $sqlDeploymentPlan = & "$PSScriptRoot/Resolve-AcaSqlDeploymentPlan.ps1" `
+ -Version $Version `
+ -SqlDatabaseName $SqlDatabaseName `
+ -FhirSdkProviderDefault $FhirSdkProviderDefault `
+ -ConfiguredFhirSdkProviderDefault $configuredFhirSdkProviderDefault
+}
+
$staticEnvNames = @(
"ASPNETCORE_FORWARDEDHEADERS_ENABLED",
"KeyVault__Endpoint",
@@ -93,7 +111,8 @@ if ($DataStore -eq 'sql') {
"SqlServer__Initialize",
"SqlServer__SchemaOptions__AutomaticUpdatesEnabled",
"SqlServer__DeleteAllDataOnStartup",
- "SqlServer__AllowDatabaseCreation"
+ "SqlServer__AllowDatabaseCreation",
+ $fhirSdkProviderSettingName
)
} else {
$staticEnvNames += @(
@@ -140,7 +159,7 @@ $resourceGroupName = $ResourceGroup
# --- Data-store-specific pre-deploy setup ---
if ($DataStore -eq 'sql') {
$sqlServerName = $SqlServerName.ToLowerInvariant()
- $sqlDatabaseName = "FHIR$Version"
+ $sqlDatabaseName = $sqlDeploymentPlan.SqlDatabaseName
$sqlElasticPoolName = $SqlElasticPoolName
$existingDb = Get-AzSqlDatabase -ResourceGroupName $resourceGroupName -ServerName $sqlServerName -DatabaseName $sqlDatabaseName -ErrorAction SilentlyContinue
if ($null -eq $existingDb) {
@@ -272,6 +291,10 @@ $templateParameters = @{
if ($DataStore -eq 'sql') {
$templateParameters["sqlServerName"] = $sqlServerName
+ $templateParameters["sqlDatabaseName"] = $sqlDatabaseName
+ if ($sqlDeploymentPlan.EmitFhirSdkProviderEnvironmentVariable) {
+ $templateParameters["fhirSdkProviderDefault"] = $sqlDeploymentPlan.FhirSdkProviderDefault
+ }
$templateParameters["sqlSchemaAutomaticUpdatesEnabled"] = $SchemaAutomaticUpdatesEnabled
} else {
$templateParameters["cosmosDbAccountName"] = $cosmosDbAccountName
diff --git a/build/jobs/scripts/Resolve-AcaSqlDeploymentPlan.ps1 b/build/jobs/scripts/Resolve-AcaSqlDeploymentPlan.ps1
new file mode 100644
index 0000000000..81d500f310
--- /dev/null
+++ b/build/jobs/scripts/Resolve-AcaSqlDeploymentPlan.ps1
@@ -0,0 +1,47 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory = $true)]
+ [ValidateSet('Stu3', 'R4', 'R4B', 'R5')]
+ [string] $Version,
+
+ [Parameter(Mandatory = $false)]
+ [string] $SqlDatabaseName = '',
+
+ [Parameter(Mandatory = $false)]
+ [ValidateSet('', 'Firely', 'Ignixa')]
+ [string] $FhirSdkProviderDefault = '',
+
+ [Parameter(Mandatory = $false)]
+ [string] $ConfiguredFhirSdkProviderDefault = ''
+)
+
+$resolvedSqlDatabaseName = if ([string]::IsNullOrWhiteSpace($SqlDatabaseName)) {
+ "FHIR$Version"
+} else {
+ $SqlDatabaseName
+}
+
+$hasRequestedProvider = -not [string]::IsNullOrWhiteSpace($FhirSdkProviderDefault)
+$hasConfiguredProvider = -not [string]::IsNullOrWhiteSpace($ConfiguredFhirSdkProviderDefault)
+
+if ($hasConfiguredProvider -and $ConfiguredFhirSdkProviderDefault -notin @('Firely', 'Ignixa')) {
+ throw "Configured FHIR SDK provider '$ConfiguredFhirSdkProviderDefault' is unsupported. Expected 'Firely' or 'Ignixa'."
+}
+
+if ($hasRequestedProvider -and $hasConfiguredProvider -and $FhirSdkProviderDefault -ne $ConfiguredFhirSdkProviderDefault) {
+ throw "Deployment FHIR SDK provider '$FhirSdkProviderDefault' conflicts with configured provider '$ConfiguredFhirSdkProviderDefault'."
+}
+
+$effectiveProvider = if ($hasRequestedProvider) {
+ $FhirSdkProviderDefault
+} elseif ($hasConfiguredProvider) {
+ $ConfiguredFhirSdkProviderDefault
+} else {
+ 'Firely'
+}
+
+[pscustomobject]@{
+ SqlDatabaseName = $resolvedSqlDatabaseName
+ FhirSdkProviderDefault = $effectiveProvider
+ EmitFhirSdkProviderEnvironmentVariable = $hasConfiguredProvider -or $effectiveProvider -ne 'Firely'
+}
diff --git a/build/jobs/scripts/tests/Test-AcaSqlTopology.ps1 b/build/jobs/scripts/tests/Test-AcaSqlTopology.ps1
new file mode 100644
index 0000000000..6c07f2498f
--- /dev/null
+++ b/build/jobs/scripts/tests/Test-AcaSqlTopology.ps1
@@ -0,0 +1,198 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+$assertionPath = Join-Path $PSScriptRoot '../Assert-AcaSqlTopology.ps1'
+. $assertionPath
+
+function Assert-Throws {
+ param(
+ [Parameter(Mandatory = $true)] [scriptblock] $Action,
+ [Parameter(Mandatory = $true)] [string] $ExpectedMessage,
+ [Parameter(Mandatory = $true)] [string] $Description,
+ [Parameter(Mandatory = $false)] [string] $ExpectedInnerMessage
+ )
+
+ try {
+ & $Action
+ }
+ catch {
+ if ($_.Exception.Message -notlike "*$ExpectedMessage*") {
+ throw "$Description. Expected error containing '$ExpectedMessage', actual '$($_.Exception.Message)'."
+ }
+
+ if (-not [string]::IsNullOrEmpty($ExpectedInnerMessage) -and
+ ($null -eq $_.Exception.InnerException -or $_.Exception.InnerException.Message -notlike "*$ExpectedInnerMessage*")) {
+ throw "$Description. Expected inner error containing '$ExpectedInnerMessage'."
+ }
+
+ return
+ }
+
+ throw "$Description. Expected an exception."
+}
+
+$matchingEnvironment = @(
+ [pscustomobject]@{
+ name = 'KeyVault__Endpoint'
+ value = 'https://expected-vault.vault.azure.net/'
+ }
+)
+$matchingSecretResolver = {
+ param($VaultName, $SecretName)
+ if ($VaultName -ne 'expected-vault' -or $SecretName -ne 'SqlServer--ConnectionString') {
+ throw "Unexpected secret request for '$VaultName/$SecretName'."
+ }
+
+ 'Server=tcp:shared-sql.database.windows.net,1433;Initial Catalog=FHIRR4VNext;Encrypt=True'
+}
+$matchingDatabaseResolver = {
+ param($ResourceGroup, $ServerName, $DatabaseName)
+ if ($ResourceGroup -ne 'expected-rg' -or $ServerName -ne 'shared-sql' -or $DatabaseName -ne 'FHIRR4VNext') {
+ throw "Unexpected database request for '$ResourceGroup/$ServerName/$DatabaseName'."
+ }
+
+ [pscustomobject]@{ ElasticPoolName = 'vnext-pool' }
+}
+$matchingAzureEnvironmentResolver = {
+ [pscustomobject]@{
+ KeyVaultDnsSuffix = 'vault.azure.net'
+ SqlDatabaseDnsSuffix = '.database.windows.net'
+ }
+}
+$matchingArguments = @{
+ EnvironmentSettings = $matchingEnvironment
+ ContainerAppName = 'expected-app'
+ ResourceGroupName = 'expected-rg'
+ ExpectedKeyVaultName = 'expected-vault'
+ ExpectedSqlServerName = 'shared-sql'
+ ExpectedSqlDatabaseName = 'FHIRR4VNext'
+ ExpectedSqlElasticPoolName = 'vnext-pool'
+ SecretResolver = $matchingSecretResolver
+ DatabaseResolver = $matchingDatabaseResolver
+ AzureEnvironmentResolver = $matchingAzureEnvironmentResolver
+}
+
+Assert-AcaSqlTopology @matchingArguments
+
+$nullAzureEnvironmentResolver = {
+ $null
+}
+Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -AzureEnvironmentResolver $nullAzureEnvironmentResolver
+} -ExpectedMessage 'SQL vNext topology validation could not resolve the active Azure environment' -Description 'Null Azure environment metadata was accepted'
+
+foreach ($azureEnvironmentMetadata in @(
+ [pscustomobject]@{ SqlDatabaseDnsSuffix = '.database.windows.net' },
+ [pscustomobject]@{ KeyVaultDnsSuffix = ' '; SqlDatabaseDnsSuffix = '.database.windows.net' }
+)) {
+ $missingKeyVaultDnsSuffixResolver = {
+ $azureEnvironmentMetadata
+ }.GetNewClosure()
+ Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -AzureEnvironmentResolver $missingKeyVaultDnsSuffixResolver
+ } -ExpectedMessage 'define a Key Vault DNS suffix' -Description 'Missing or blank Key Vault DNS suffix was accepted'
+}
+
+foreach ($azureEnvironmentMetadata in @(
+ [pscustomobject]@{ KeyVaultDnsSuffix = 'vault.azure.net' },
+ [pscustomobject]@{ KeyVaultDnsSuffix = 'vault.azure.net'; SqlDatabaseDnsSuffix = ' ' }
+)) {
+ $missingSqlDnsSuffixResolver = {
+ $azureEnvironmentMetadata
+ }.GetNewClosure()
+ Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -AzureEnvironmentResolver $missingSqlDnsSuffixResolver
+ } -ExpectedMessage 'define a SQL Database DNS suffix' -Description 'Missing or blank SQL DNS suffix was accepted'
+}
+
+$throwingAzureEnvironmentResolver = {
+ throw 'Simulated Azure context failure.'
+}
+Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -AzureEnvironmentResolver $throwingAzureEnvironmentResolver
+} `
+ -ExpectedMessage 'SQL vNext topology validation could not resolve the active Azure environment. Verify that the Azure service connection is authenticated and configured for the target cloud.' `
+ -ExpectedInnerMessage 'Simulated Azure context failure.' `
+ -Description 'Azure environment resolver failure was not wrapped'
+
+$wrongKeyVaultEnvironment = @(
+ [pscustomobject]@{
+ name = 'KeyVault__Endpoint'
+ value = 'https://wrong-vault.vault.azure.net/'
+ }
+)
+Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -EnvironmentSettings $wrongKeyVaultEnvironment
+} -ExpectedMessage "targets Key Vault host 'wrong-vault.vault.azure.net'" -Description 'Wrong Key Vault endpoint was accepted'
+
+$lookalikeKeyVaultEnvironment = @(
+ [pscustomobject]@{
+ name = 'KeyVault__Endpoint'
+ value = 'https://expected-vault.example.invalid/'
+ }
+)
+Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -EnvironmentSettings $lookalikeKeyVaultEnvironment
+} -ExpectedMessage "expected-vault.vault.azure.net" -Description 'Lookalike Key Vault DNS suffix was accepted'
+
+$wrongServerResolver = {
+ 'Server=tcp:wrong-sql.database.windows.net,1433;Initial Catalog=FHIRR4VNext;Encrypt=True'
+}
+Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -SecretResolver $wrongServerResolver
+} -ExpectedMessage "targets server 'wrong-sql.database.windows.net'" -Description 'Wrong SQL server was accepted'
+
+$lookalikeServerResolver = {
+ 'Server=tcp:shared-sql.example.invalid,1433;Initial Catalog=FHIRR4VNext;Encrypt=True'
+}
+Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -SecretResolver $lookalikeServerResolver
+} -ExpectedMessage "expected 'shared-sql.database.windows.net'" -Description 'Lookalike SQL DNS suffix was accepted'
+
+$wrongDatabaseResolver = {
+ 'Server=tcp:shared-sql.database.windows.net,1433;Initial Catalog=WrongDatabase;Encrypt=True'
+}
+Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -SecretResolver $wrongDatabaseResolver
+} -ExpectedMessage "targets database 'WrongDatabase'" -Description 'Wrong SQL database was accepted'
+
+$wrongPoolResolver = {
+ [pscustomobject]@{ ElasticPoolName = 'wrong-pool' }
+}
+Assert-Throws -Action {
+ Assert-AcaSqlTopology @matchingArguments -DatabaseResolver $wrongPoolResolver
+} -ExpectedMessage "belongs to elastic pool 'wrong-pool'" -Description 'Wrong SQL elastic pool was accepted'
+
+$unexpectedResolver = {
+ throw 'A credential-backed resolver was called for a legacy lane.'
+}
+Assert-AcaSqlTopology `
+ -EnvironmentSettings @() `
+ -ContainerAppName 'legacy-app' `
+ -SecretResolver $unexpectedResolver `
+ -DatabaseResolver $unexpectedResolver `
+ -AzureEnvironmentResolver $unexpectedResolver
+
+$sovereignEnvironment = @(
+ [pscustomobject]@{
+ name = 'KeyVault__Endpoint'
+ value = 'https://expected-vault.vault.azure.cn/'
+ }
+)
+$sovereignSecretResolver = {
+ 'Server=tcp:shared-sql.database.chinacloudapi.cn,1433;Initial Catalog=FHIRR4VNext;Encrypt=True'
+}
+$sovereignAzureEnvironmentResolver = {
+ [pscustomobject]@{
+ KeyVaultDnsSuffix = '.vault.azure.cn.'
+ SqlDatabaseDnsSuffix = 'database.chinacloudapi.cn'
+ }
+}
+Assert-AcaSqlTopology `
+ @matchingArguments `
+ -EnvironmentSettings $sovereignEnvironment `
+ -SecretResolver $sovereignSecretResolver `
+ -AzureEnvironmentResolver $sovereignAzureEnvironmentResolver
+
+Write-Host 'ACA SQL topology behavioral tests passed.'
diff --git a/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1 b/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1
new file mode 100644
index 0000000000..4e0892fa39
--- /dev/null
+++ b/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1
@@ -0,0 +1,540 @@
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+$repositoryRoot = (Resolve-Path "$PSScriptRoot/../../../..").Path
+$resolver = Join-Path $repositoryRoot 'build/jobs/scripts/Resolve-AcaSqlDeploymentPlan.ps1'
+$providerAssertion = Join-Path $repositoryRoot 'build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1'
+. $providerAssertion
+
+function Assert-Equal {
+ param(
+ [Parameter(Mandatory = $true)] $Expected,
+ [Parameter(Mandatory = $true)] $Actual,
+ [Parameter(Mandatory = $true)] [string] $Description
+ )
+
+ if ($Expected -ne $Actual) {
+ throw "$Description. Expected '$Expected', actual '$Actual'."
+ }
+}
+
+function Assert-True {
+ param(
+ [Parameter(Mandatory = $true)] [bool] $Condition,
+ [Parameter(Mandatory = $true)] [string] $Description
+ )
+
+ if (-not $Condition) {
+ throw $Description
+ }
+}
+
+function Assert-SequenceEqual {
+ param(
+ [Parameter(Mandatory = $true)] [object[]] $Expected,
+ [Parameter(Mandatory = $true)] [object[]] $Actual,
+ [Parameter(Mandatory = $true)] [string] $Description
+ )
+
+ Assert-Equal -Expected ($Expected -join ',') -Actual ($Actual -join ',') -Description $Description
+}
+
+function Assert-Throws {
+ param(
+ [Parameter(Mandatory = $true)] [scriptblock] $Action,
+ [Parameter(Mandatory = $true)] [string] $ExpectedMessage,
+ [Parameter(Mandatory = $true)] [string] $Description
+ )
+
+ try {
+ & $Action
+ }
+ catch {
+ if ($_.Exception.Message -notlike "*$ExpectedMessage*") {
+ throw "$Description. Expected error containing '$ExpectedMessage', actual '$($_.Exception.Message)'."
+ }
+
+ return
+ }
+
+ throw "$Description. Expected an exception."
+}
+
+function Get-PythonWithPyYaml {
+ $pythonCommand = $null
+ foreach ($commandName in @('python', 'python3')) {
+ $pythonCommand = Get-Command $commandName -ErrorAction SilentlyContinue
+ if ($null -ne $pythonCommand) {
+ break
+ }
+ }
+
+ if ($null -eq $pythonCommand) {
+ throw 'Python is required to validate the SQL vNext deployment plan.'
+ }
+
+ & $pythonCommand.Source -c 'import sys' 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Python is required to validate the SQL vNext deployment plan.'
+ }
+
+ & $pythonCommand.Source -c 'import yaml' 2>$null
+ if ($LASTEXITCODE -ne 0) {
+ throw "The Python package 'PyYAML' is required to validate the SQL vNext deployment plan."
+ }
+
+ return $pythonCommand.Source
+}
+
+function Assert-AzureCliBicepAvailable {
+ $azureCli = Get-Command az -ErrorAction SilentlyContinue
+ if ($null -eq $azureCli) {
+ throw 'Azure CLI is required to compile the SQL vNext Bicep template.'
+ }
+
+ & $azureCli.Source bicep version | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ throw 'Azure CLI Bicep is required to compile the SQL vNext Bicep template.'
+ }
+}
+
+function ConvertFrom-YamlFile {
+ param([Parameter(Mandatory = $true)] [string] $Path)
+
+ $yaml = Get-Content -Raw $Path
+ $json = $yaml | & $script:pythonPath -c 'import json, sys, yaml; json.dump(yaml.safe_load(sys.stdin), sys.stdout)'
+ if ($LASTEXITCODE -ne 0) {
+ throw "Failed to parse YAML file '$Path'."
+ }
+
+ return $json | ConvertFrom-Json -Depth 100
+}
+
+function Get-Stage {
+ param(
+ [Parameter(Mandatory = $true)] $Pipeline,
+ [Parameter(Mandatory = $true)] [string] $Name
+ )
+
+ $stage = @($Pipeline.stages | Where-Object { $_.stage -eq $Name })
+ Assert-Equal -Expected 1 -Actual $stage.Count -Description "Stage '$Name' should occur once"
+ return $stage[0]
+}
+
+function Get-ScriptArgument {
+ param(
+ [Parameter(Mandatory = $true)] [string] $Arguments,
+ [Parameter(Mandatory = $true)] [string] $Name
+ )
+
+ $match = [regex]::Match($Arguments, "(?m)-$([regex]::Escape($Name))\s+`"([^`"]*)`"")
+ if (-not $match.Success) {
+ throw "Script argument '-$Name' was not found."
+ }
+
+ return $match.Groups[1].Value
+}
+
+function Find-TemplateInvocation {
+ param(
+ [Parameter(Mandatory = $true)] $Node,
+ [Parameter(Mandatory = $true)] [string] $Template
+ )
+
+ if ($Node -is [pscustomobject]) {
+ if ($Node.PSObject.Properties['template'] -and $Node.template -eq $Template) {
+ Write-Output -NoEnumerate $Node
+ }
+
+ foreach ($property in $Node.PSObject.Properties) {
+ Find-TemplateInvocation -Node $property.Value -Template $Template
+ }
+ } elseif ($Node -is [System.Collections.IEnumerable] -and $Node -isnot [string]) {
+ foreach ($item in $Node) {
+ Find-TemplateInvocation -Node $item -Template $Template
+ }
+ }
+}
+
+function Get-CiDeployStages {
+ param(
+ [Parameter(Mandatory = $true)] $Pipeline,
+ [Parameter(Mandatory = $true)] [bool] $BuildImages
+ )
+
+ $buildCondition = '${{ if eq(parameters.buildImages, true) }}'
+ $recoveryCondition = '${{ if eq(parameters.buildImages, false) }}'
+
+ foreach ($entry in $Pipeline.stages) {
+ if ($entry.PSObject.Properties['stage']) {
+ Write-Output $entry
+ } elseif ($BuildImages -and $entry.PSObject.Properties[$buildCondition]) {
+ Write-Output $entry.PSObject.Properties[$buildCondition].Value
+ } elseif (-not $BuildImages -and $entry.PSObject.Properties[$recoveryCondition]) {
+ Write-Output $entry.PSObject.Properties[$recoveryCondition].Value
+ }
+ }
+}
+
+function Get-CiDeployDependencies {
+ param(
+ [Parameter(Mandatory = $true)] $Stage,
+ [Parameter(Mandatory = $true)] [bool] $BuildImages
+ )
+
+ $buildCondition = '${{ if eq(parameters.buildImages, true) }}'
+ $recoveryCondition = '${{ if eq(parameters.buildImages, false) }}'
+
+ foreach ($dependency in @($Stage.dependsOn)) {
+ if ($dependency -is [string]) {
+ Write-Output $dependency
+ } elseif ($BuildImages -and $dependency.PSObject.Properties[$buildCondition]) {
+ Write-Output $dependency.PSObject.Properties[$buildCondition].Value
+ } elseif (-not $BuildImages -and $dependency.PSObject.Properties[$recoveryCondition]) {
+ Write-Output $dependency.PSObject.Properties[$recoveryCondition].Value
+ }
+ }
+}
+
+function Get-CiDeployImageTag {
+ param(
+ [Parameter(Mandatory = $true)] $Stage,
+ [Parameter(Mandatory = $true)] [bool] $BuildImages
+ )
+
+ $condition = if ($BuildImages) {
+ '${{ if eq(parameters.buildImages, true) }}'
+ } else {
+ '${{ else }}'
+ }
+
+ $parameters = $Stage.jobs[0].parameters
+ $conditionalParameters = $parameters.PSObject.Properties[$condition].Value
+ return $conditionalParameters.imageTag
+}
+
+$script:pythonPath = Get-PythonWithPyYaml
+Assert-AzureCliBicepAvailable
+
+$topologyTests = Join-Path $repositoryRoot 'build/jobs/scripts/tests/Test-AcaSqlTopology.ps1'
+& $topologyTests
+
+$legacyPlan = & $resolver -Version Stu3
+Assert-Equal -Expected 'FHIRStu3' -Actual $legacyPlan.SqlDatabaseName -Description 'Legacy database default changed'
+Assert-Equal -Expected 'Firely' -Actual $legacyPlan.FhirSdkProviderDefault -Description 'Legacy SDK provider default changed'
+Assert-Equal -Expected $false -Actual $legacyPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'Legacy deployment would emit a new provider setting'
+
+$configuredPlan = & $resolver -Version R4 -ConfiguredFhirSdkProviderDefault Ignixa
+Assert-Equal -Expected 'Ignixa' -Actual $configuredPlan.FhirSdkProviderDefault -Description 'Configured provider was not preserved'
+Assert-Equal -Expected $true -Actual $configuredPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'Configured provider would not be emitted'
+
+$configuredFirelyPlan = & $resolver -Version R4 -ConfiguredFhirSdkProviderDefault Firely
+Assert-Equal -Expected $true -Actual $configuredFirelyPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'Flattened Firely setting would be discarded'
+
+Assert-Throws -Action {
+ & $resolver -Version R4 -FhirSdkProviderDefault Ignixa -ConfiguredFhirSdkProviderDefault Firely
+} -ExpectedMessage 'conflicts with configured provider' -Description 'Conflicting providers were accepted'
+
+$stu3VNextPlan = & $resolver -Version Stu3 -SqlDatabaseName FHIRStu3VNext -FhirSdkProviderDefault Ignixa
+$r4VNextPlan = & $resolver -Version R4 -SqlDatabaseName FHIRR4VNext -FhirSdkProviderDefault Ignixa
+Assert-Equal -Expected 'FHIRStu3VNext' -Actual $stu3VNextPlan.SqlDatabaseName -Description 'STU3 vNext database is not isolated'
+Assert-Equal -Expected 'FHIRR4VNext' -Actual $r4VNextPlan.SqlDatabaseName -Description 'R4 vNext database is not isolated'
+Assert-Equal -Expected $true -Actual $stu3VNextPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'STU3 vNext provider would not be emitted'
+Assert-Equal -Expected $true -Actual $r4VNextPlan.EmitFhirSdkProviderEnvironmentVariable -Description 'R4 vNext provider would not be emitted'
+
+$matchingEnvironment = @([pscustomobject]@{ name = 'FhirServer__CoreFeatures__FhirSdkProvider__Default'; value = 'Ignixa' })
+Assert-EffectiveFhirSdkProvider -EnvironmentSettings $matchingEnvironment -ExpectedProvider Ignixa -ContainerAppName matching-app
+Assert-EffectiveFhirSdkProvider -EnvironmentSettings @() -ExpectedProvider '' -ContainerAppName legacy-app
+Assert-Throws -Action {
+ Assert-EffectiveFhirSdkProvider -EnvironmentSettings @() -ExpectedProvider Ignixa -ContainerAppName missing-app
+} -ExpectedMessage 'does not define' -Description 'Missing expected provider was accepted'
+Assert-Throws -Action {
+ Assert-EffectiveFhirSdkProvider -EnvironmentSettings $matchingEnvironment -ExpectedProvider Firely -ContainerAppName mismatch-app
+} -ExpectedMessage "expected 'Firely'" -Description 'Mismatched provider was accepted'
+
+$yamlPaths = @(
+ 'build/build-variables.yml',
+ 'build/ci-deploy.yml',
+ 'build/ci-pipeline.yml',
+ 'build/jobs/e2e-tests.yml',
+ 'build/jobs/provision-deploy.yml',
+ 'build/jobs/run-sql-tests.yml',
+ 'build/pr-pipeline.yml',
+ 'build/tasks/e2e-set-variables.yml'
+)
+$yamlDocuments = @{}
+foreach ($relativePath in $yamlPaths) {
+ $yamlDocuments[$relativePath] = ConvertFrom-YamlFile (Join-Path $repositoryRoot $relativePath)
+}
+
+$variables = $yamlDocuments['build/build-variables.yml'].variables
+Assert-Equal -Expected '$(DeploymentEnvironmentName)-svn' -Actual $variables.DeploymentEnvironmentNameSqlVNext -Description 'STU3 vNext app name is not distinct'
+Assert-Equal -Expected '$(DeploymentEnvironmentName)-r4vn' -Actual $variables.DeploymentEnvironmentNameR4SqlVNext -Description 'R4 vNext app name is not distinct'
+Assert-Equal -Expected '$(KeyVaultBaseName)-sql-vn' -Actual $variables.KeyVaultNameSqlVNext -Description 'STU3 vNext Key Vault is not distinct'
+Assert-Equal -Expected '$(KeyVaultBaseName)-r4-vn' -Actual $variables.KeyVaultNameR4SqlVNext -Description 'R4 vNext Key Vault is not distinct'
+
+$prPipeline = $yamlDocuments['build/pr-pipeline.yml']
+$ciDeployPipeline = $yamlDocuments['build/ci-deploy.yml']
+$mainPipeline = $yamlDocuments['build/ci-pipeline.yml']
+
+$buildImagesParameter = @($ciDeployPipeline.parameters | Where-Object { $_.name -eq 'buildImages' })
+Assert-Equal -Expected 1 -Actual $buildImagesParameter.Count -Description 'CI deploy pipeline does not declare buildImages once'
+Assert-Equal -Expected 'boolean' -Actual $buildImagesParameter[0].type -Description 'buildImages parameter is not boolean'
+Assert-Equal -Expected $true -Actual $buildImagesParameter[0].default -Description 'Default CI deploy mode no longer builds images'
+$imageTagOverrideParameter = @($ciDeployPipeline.parameters | Where-Object { $_.name -eq 'imageTagOverride' })
+Assert-Equal -Expected 1 -Actual $imageTagOverrideParameter.Count -Description 'CI deploy pipeline does not declare imageTagOverride once'
+Assert-Equal -Expected 'string' -Actual $imageTagOverrideParameter[0].type -Description 'imageTagOverride parameter is not a string'
+Assert-Equal -Expected '' -Actual $imageTagOverrideParameter[0].default -Description 'imageTagOverride parameter should default to empty'
+
+$defaultCiDeployStages = @(Get-CiDeployStages -Pipeline $ciDeployPipeline -BuildImages $true)
+$recoveryCiDeployStages = @(Get-CiDeployStages -Pipeline $ciDeployPipeline -BuildImages $false)
+$defaultCiDeployStageNames = @($defaultCiDeployStages.stage)
+$recoveryCiDeployStageNames = @($recoveryCiDeployStages.stage)
+$environmentStageNames = @(
+ 'provisionEnvironment',
+ 'createAcaEnvironment',
+ 'createNsp',
+ 'aadTestEnvironment',
+ 'deploySqlServer',
+ 'deploySqlElasticPool',
+ 'deploySqlVNextElasticPool',
+ 'deployStu3',
+ 'deployStu3Sql',
+ 'deployStu3SqlVNext',
+ 'deployR4',
+ 'deployR4Sql',
+ 'deployR4SqlVNext',
+ 'deployR5Sql'
+)
+Assert-SequenceEqual -Expected (@('UpdateVersion', 'DockerBuild') + $environmentStageNames) -Actual $defaultCiDeployStageNames -Description 'Default CI deploy stage plan changed'
+Assert-SequenceEqual -Expected (@('ValidateRecoveryParameters') + $environmentStageNames) -Actual $recoveryCiDeployStageNames -Description 'Recovery CI deploy stage plan is incomplete'
+Assert-True -Condition ('UpdateVersion' -in $defaultCiDeployStageNames) -Description 'Default CI deploy plan omits UpdateVersion'
+Assert-True -Condition ('DockerBuild' -in $defaultCiDeployStageNames) -Description 'Default CI deploy plan omits DockerBuild'
+Assert-True -Condition ('ValidateRecoveryParameters' -notin $defaultCiDeployStageNames) -Description 'Default CI deploy plan includes recovery validation'
+Assert-True -Condition ('UpdateVersion' -notin $recoveryCiDeployStageNames) -Description 'Recovery CI deploy plan includes UpdateVersion'
+Assert-True -Condition ('DockerBuild' -notin $recoveryCiDeployStageNames) -Description 'Recovery CI deploy plan includes DockerBuild'
+Assert-True -Condition ('ValidateRecoveryParameters' -in $recoveryCiDeployStageNames) -Description 'Recovery CI deploy plan omits parameter validation'
+
+$deploymentStageNames = @(
+ 'deployStu3',
+ 'deployStu3Sql',
+ 'deployStu3SqlVNext',
+ 'deployR4',
+ 'deployR4Sql',
+ 'deployR4SqlVNext',
+ 'deployR5Sql'
+)
+foreach ($stageName in $deploymentStageNames) {
+ $defaultStage = @($defaultCiDeployStages | Where-Object { $_.stage -eq $stageName })[0]
+ $recoveryStage = @($recoveryCiDeployStages | Where-Object { $_.stage -eq $stageName })[0]
+ Assert-True -Condition ('DockerBuild' -in @(Get-CiDeployDependencies -Stage $defaultStage -BuildImages $true)) -Description "$stageName default plan does not depend on DockerBuild"
+ Assert-True -Condition ('DockerBuild' -notin @(Get-CiDeployDependencies -Stage $recoveryStage -BuildImages $false)) -Description "$stageName recovery plan depends on DockerBuild"
+ Assert-Equal -Expected '$(ImageTag)' -Actual (Get-CiDeployImageTag -Stage $defaultStage -BuildImages $true) -Description "$stageName default image tag changed"
+ Assert-Equal -Expected '${{ parameters.imageTagOverride }}' -Actual (Get-CiDeployImageTag -Stage $recoveryStage -BuildImages $false) -Description "$stageName recovery image tag does not use the override"
+}
+
+$defaultProvisionStage = @($defaultCiDeployStages | Where-Object { $_.stage -eq 'provisionEnvironment' })[0]
+$recoveryProvisionStage = @($recoveryCiDeployStages | Where-Object { $_.stage -eq 'provisionEnvironment' })[0]
+Assert-Equal -Expected 0 -Actual @(Get-CiDeployDependencies -Stage $defaultProvisionStage -BuildImages $true).Count -Description 'Default environment provisioning dependencies changed'
+Assert-SequenceEqual -Expected @('ValidateRecoveryParameters') -Actual @(Get-CiDeployDependencies -Stage $recoveryProvisionStage -BuildImages $false) -Description 'Recovery provisioning can start before parameter validation'
+
+$recoveryValidationStage = @($recoveryCiDeployStages | Where-Object { $_.stage -eq 'ValidateRecoveryParameters' })[0]
+$recoveryValidationTask = $recoveryValidationStage.jobs[0].steps[0]
+Assert-Equal -Expected '${{ parameters.imageTagOverride }}' -Actual $recoveryValidationTask.env.IMAGE_TAG_OVERRIDE -Description 'Recovery validation does not receive imageTagOverride'
+$recoveryValidationScript = [scriptblock]::Create($recoveryValidationTask.inputs.script)
+$originalImageTagOverride = $env:IMAGE_TAG_OVERRIDE
+try {
+ $env:IMAGE_TAG_OVERRIDE = 'master'
+ & $recoveryValidationScript
+
+ $env:IMAGE_TAG_OVERRIDE = ' '
+ Assert-Throws -Action {
+ & $recoveryValidationScript
+ } -ExpectedMessage "Parameter 'imageTagOverride' must be non-empty when 'buildImages' is false." -Description 'Blank recovery image tag was accepted'
+}
+finally {
+ $env:IMAGE_TAG_OVERRIDE = $originalImageTagOverride
+}
+
+foreach ($pipelinePlan in @(
+ @{ Pipeline = $prPipeline; Stu3Stage = 'deployStu3SqlVNext'; R4Stage = 'deployR4SqlVNext'; ResourceGroup = '$(UniqueResourceGroupName)' },
+ @{ Pipeline = $ciDeployPipeline; Stu3Stage = 'deployStu3SqlVNext'; R4Stage = 'deployR4SqlVNext'; ResourceGroup = '$(ResourceGroupName)' }
+)) {
+ $stu3Parameters = (Get-Stage -Pipeline $pipelinePlan.Pipeline -Name $pipelinePlan.Stu3Stage).jobs[0].parameters
+ $r4Parameters = (Get-Stage -Pipeline $pipelinePlan.Pipeline -Name $pipelinePlan.R4Stage).jobs[0].parameters
+
+ Assert-Equal -Expected 'FHIRStu3VNext' -Actual $stu3Parameters.sqlDatabaseName -Description "$($pipelinePlan.Stu3Stage) database flow is incorrect"
+ Assert-Equal -Expected 'FHIRR4VNext' -Actual $r4Parameters.sqlDatabaseName -Description "$($pipelinePlan.R4Stage) database flow is incorrect"
+ Assert-Equal -Expected '$(SqlVNextElasticPoolName)' -Actual $stu3Parameters.sqlElasticPoolName -Description "$($pipelinePlan.Stu3Stage) pool flow is incorrect"
+ Assert-Equal -Expected '$(SqlVNextElasticPoolName)' -Actual $r4Parameters.sqlElasticPoolName -Description "$($pipelinePlan.R4Stage) pool flow is incorrect"
+ Assert-Equal -Expected $pipelinePlan.ResourceGroup -Actual $stu3Parameters.resourceGroup -Description "$($pipelinePlan.Stu3Stage) resource group is incorrect"
+ Assert-Equal -Expected $pipelinePlan.ResourceGroup -Actual $r4Parameters.resourceGroup -Description "$($pipelinePlan.R4Stage) resource group is incorrect"
+}
+
+$prStu3SqlParameters = (Get-Stage -Pipeline $prPipeline -Name deployStu3Sql).jobs[0].parameters
+$prR4SqlParameters = (Get-Stage -Pipeline $prPipeline -Name deployR4Sql).jobs[0].parameters
+Assert-True -Condition (-not $prStu3SqlParameters.PSObject.Properties['sqlElasticPoolName']) -Description 'Existing PR STU3 database was moved to the vNext pool'
+Assert-True -Condition (-not $prR4SqlParameters.PSObject.Properties['sqlElasticPoolName']) -Description 'Existing PR R4 database was moved to the vNext pool'
+
+$ciStu3SqlParameters = (Get-Stage -Pipeline $ciDeployPipeline -Name deployStu3Sql).jobs[0].parameters
+$ciR4SqlParameters = (Get-Stage -Pipeline $ciDeployPipeline -Name deployR4Sql).jobs[0].parameters
+Assert-Equal -Expected '$(DeploymentEnvironmentName)-pool' -Actual $ciStu3SqlParameters.sqlElasticPoolName -Description 'Existing CI STU3 database pool changed'
+Assert-Equal -Expected '$(DeploymentEnvironmentName)-pool' -Actual $ciR4SqlParameters.sqlElasticPoolName -Description 'Existing CI R4 database pool changed'
+
+foreach ($pipeline in @($prPipeline, $ciDeployPipeline)) {
+ $poolStage = Get-Stage -Pipeline $pipeline -Name deploySqlVNextElasticPool
+ Assert-Equal -Expected 1 -Actual @($poolStage.jobs).Count -Description 'vNext pool stage contains unrelated jobs'
+ $poolParameters = $poolStage.jobs[0].parameters
+ Assert-Equal -Expected '$(SqlVNextElasticPoolName)' -Actual $poolParameters.elasticPoolName -Description 'vNext pool name is incorrect'
+ Assert-Equal -Expected 4 -Actual $poolParameters.capacity -Description 'vNext pool capacity does not support both canaries'
+ Assert-Equal -Expected 2 -Actual $poolParameters.dbMaxCapacity -Description 'vNext per-database cap is incorrect'
+}
+Assert-Equal -Expected 2 -Actual @((Get-Stage -Pipeline $prPipeline -Name deploySqlServer).jobs).Count -Description 'PR SQL server stage topology changed'
+
+foreach ($stageName in @('redeployStu3SqlVNext', 'redeployR4SqlVNext')) {
+ $stage = Get-Stage -Pipeline $mainPipeline -Name $stageName
+ Assert-Equal -Expected './jobs/redeploy-webapp.yml' -Actual $stage.jobs[0].template -Description "$stageName does not use persistent redeployment"
+}
+
+foreach ($pipeline in @($prPipeline, $mainPipeline)) {
+ $validationStage = Get-Stage -Pipeline $pipeline -Name AnalyzeSecurity
+ $validationJob = @($validationStage.jobs | Where-Object { $_.job -eq 'ValidateAcaSqlDeploymentPlan' })
+ Assert-Equal -Expected 1 -Actual $validationJob.Count -Description 'Deployment-plan validation job is not wired into CI'
+ Assert-Equal -Expected '$(System.DefaultWorkingDirectory)/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1' -Actual $validationJob[0].steps[0].inputs.filePath -Description 'CI validation runs the wrong deployment-plan test'
+}
+
+foreach ($pipeline in @($prPipeline, $mainPipeline)) {
+ foreach ($testPlan in @(
+ @{
+ Stage = 'testStu3SqlVNext'
+ KeyVault = '$(KeyVaultNameSqlVNext)'
+ Database = 'FHIRStu3VNext'
+ },
+ @{
+ Stage = 'testR4SqlVNext'
+ KeyVault = '$(KeyVaultNameR4SqlVNext)'
+ Database = 'FHIRR4VNext'
+ }
+ )) {
+ $parameters = (Get-Stage -Pipeline $pipeline -Name $testPlan.Stage).jobs[0].parameters
+ Assert-Equal -Expected $testPlan.KeyVault -Actual $parameters.expectedKeyVaultName -Description "$($testPlan.Stage) Key Vault expectation is incorrect"
+ Assert-Equal -Expected '$(DeploymentEnvironmentName)' -Actual $parameters.expectedSqlServerName -Description "$($testPlan.Stage) SQL server expectation is incorrect"
+ Assert-Equal -Expected $testPlan.Database -Actual $parameters.expectedSqlDatabaseName -Description "$($testPlan.Stage) SQL database expectation is incorrect"
+ Assert-Equal -Expected '$(SqlVNextElasticPoolName)' -Actual $parameters.expectedSqlElasticPoolName -Description "$($testPlan.Stage) SQL pool expectation is incorrect"
+ }
+}
+
+$aggregateDependencies = @(Get-Stage -Pipeline $mainPipeline -Name aggregateCoverage).dependsOn
+$tagDependencies = @(Get-Stage -Pipeline $mainPipeline -Name DockerAddTag).dependsOn
+$scaleDependencies = @(Get-Stage -Pipeline $mainPipeline -Name scaleDownContainerApps).dependsOn
+foreach ($canary in @('testStu3SqlVNext', 'testR4SqlVNext')) {
+ Assert-True -Condition ($canary -notin $aggregateDependencies) -Description "$canary blocks aggregate coverage"
+ Assert-True -Condition ($canary -notin $tagDependencies) -Description "$canary blocks Docker tag promotion"
+ Assert-True -Condition ($canary -in $scaleDependencies) -Description "$canary is missing from scale-down dependencies"
+}
+
+$provisionTemplate = $yamlDocuments['build/jobs/provision-deploy.yml']
+$provisionTask = @($provisionTemplate.jobs[0].steps | Where-Object { $_.name -eq 'SetAcaOutputs' })[0]
+Assert-Equal -Expected '${{ parameters.sqlDatabaseName }}' -Actual (Get-ScriptArgument -Arguments $provisionTask.inputs.ScriptArguments -Name SqlDatabaseName) -Description 'SQL database parameter is not forwarded'
+Assert-Equal -Expected '${{ parameters.fhirSdkProviderDefault }}' -Actual (Get-ScriptArgument -Arguments $provisionTask.inputs.ScriptArguments -Name FhirSdkProviderDefault) -Description 'Provider parameter is not forwarded'
+
+$runSqlTemplate = $yamlDocuments['build/jobs/run-sql-tests.yml']
+$integrationGateName = '${{ if eq(parameters.runIntegrationTests, true) }}'
+$integrationGates = @($runSqlTemplate.jobs | Where-Object { $_.PSObject.Properties[$integrationGateName] })
+Assert-Equal -Expected 1 -Actual $integrationGates.Count -Description 'SQL integration tests are not compile-time gated'
+$integrationJob = $integrationGates[0].PSObject.Properties[$integrationGateName].Value[0]
+Assert-Equal -Expected 'SqlIntegrationTests' -Actual $integrationJob.job -Description 'Compile-time integration gate contains the wrong job'
+Assert-True -Condition (-not $integrationJob.PSObject.Properties['condition']) -Description 'SQL integration job still uses runtime gating'
+$e2eInvocations = @(Find-TemplateInvocation -Node $runSqlTemplate.jobs -Template 'e2e-tests.yml')
+Assert-Equal -Expected 3 -Actual $e2eInvocations.Count -Description 'SQL E2E expectation propagation count changed'
+Assert-True -Condition ('${{ parameters.mainCategoryFilter }}' -in @($e2eInvocations.parameters.categoryFilter)) -Description 'Main SQL E2E invocation is missing'
+Assert-True -Condition ('Category=IndexAndReindex' -in @($e2eInvocations.parameters.categoryFilter)) -Description 'Reindex SQL E2E invocation is missing'
+foreach ($invocation in $e2eInvocations) {
+ Assert-Equal -Expected '${{ parameters.expectedFhirSdkProviderDefault }}' -Actual $invocation.parameters.expectedFhirSdkProviderDefault -Description 'run-sql-tests provider expectation is not forwarded'
+ Assert-Equal -Expected '${{ parameters.expectedKeyVaultName }}' -Actual $invocation.parameters.expectedKeyVaultName -Description 'run-sql-tests Key Vault expectation is not forwarded'
+ Assert-Equal -Expected '${{ parameters.expectedSqlServerName }}' -Actual $invocation.parameters.expectedSqlServerName -Description 'run-sql-tests SQL server expectation is not forwarded'
+ Assert-Equal -Expected '${{ parameters.expectedSqlDatabaseName }}' -Actual $invocation.parameters.expectedSqlDatabaseName -Description 'run-sql-tests SQL database expectation is not forwarded'
+ Assert-Equal -Expected '${{ parameters.expectedSqlElasticPoolName }}' -Actual $invocation.parameters.expectedSqlElasticPoolName -Description 'run-sql-tests SQL pool expectation is not forwarded'
+}
+
+$e2eTemplate = $yamlDocuments['build/jobs/e2e-tests.yml']
+$variableInvocation = @($e2eTemplate.steps | Where-Object { $_.template -eq '../tasks/e2e-set-variables.yml' })[0]
+Assert-Equal -Expected '../tasks/e2e-set-variables.yml' -Actual $e2eTemplate.steps[1].template -Description 'E2E variable and topology validation no longer runs before the test command'
+Assert-Equal -Expected '${{ parameters.expectedFhirSdkProviderDefault }}' -Actual $variableInvocation.parameters.expectedFhirSdkProviderDefault -Description 'e2e-tests provider expectation is not forwarded'
+Assert-Equal -Expected '${{ parameters.expectedKeyVaultName }}' -Actual $variableInvocation.parameters.expectedKeyVaultName -Description 'e2e-tests Key Vault expectation is not forwarded'
+Assert-Equal -Expected '${{ parameters.expectedSqlServerName }}' -Actual $variableInvocation.parameters.expectedSqlServerName -Description 'e2e-tests SQL server expectation is not forwarded'
+Assert-Equal -Expected '${{ parameters.expectedSqlDatabaseName }}' -Actual $variableInvocation.parameters.expectedSqlDatabaseName -Description 'e2e-tests SQL database expectation is not forwarded'
+Assert-Equal -Expected '${{ parameters.expectedSqlElasticPoolName }}' -Actual $variableInvocation.parameters.expectedSqlElasticPoolName -Description 'e2e-tests SQL pool expectation is not forwarded'
+$providerParameter = @($yamlDocuments['build/tasks/e2e-set-variables.yml'].parameters | Where-Object { $_.name -eq 'expectedFhirSdkProviderDefault' })
+Assert-Equal -Expected 1 -Actual $providerParameter.Count -Description 'e2e-set-variables does not declare the provider expectation'
+foreach ($parameterName in @('expectedKeyVaultName', 'expectedSqlServerName', 'expectedSqlDatabaseName', 'expectedSqlElasticPoolName')) {
+ foreach ($templatePath in @('build/jobs/run-sql-tests.yml', 'build/jobs/e2e-tests.yml', 'build/tasks/e2e-set-variables.yml')) {
+ $topologyParameter = @($yamlDocuments[$templatePath].parameters | Where-Object { $_.name -eq $parameterName })
+ Assert-Equal -Expected 1 -Actual $topologyParameter.Count -Description "$templatePath does not declare '$parameterName'"
+ Assert-Equal -Expected '' -Actual $topologyParameter[0].default -Description "$templatePath '$parameterName' default changes legacy lanes"
+ }
+}
+$setVariablesTask = $yamlDocuments['build/tasks/e2e-set-variables.yml'].steps[0]
+$inlineTokens = $null
+$inlineParseErrors = $null
+$normalizedInline = [regex]::Replace($setVariablesTask.inputs.Inline, '\$\{\{.*?\}\}', 'TemplateValue')
+$inlineAst = [System.Management.Automation.Language.Parser]::ParseInput($normalizedInline, [ref]$inlineTokens, [ref]$inlineParseErrors)
+Assert-Equal -Expected 0 -Actual $inlineParseErrors.Count -Description 'e2e-set-variables inline PowerShell has parse errors'
+$secretVariableFunction = @($inlineAst.FindAll({
+ param($node)
+ $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
+ $node.Name -eq 'Set-SecretsAsPipelineVariables'
+}, $true))
+Assert-Equal -Expected 1 -Actual $secretVariableFunction.Count -Description 'Secret pipeline-variable helper is missing'
+$secretLoggingCommands = @($secretVariableFunction[0].Body.FindAll({
+ param($node)
+ $node -is [System.Management.Automation.Language.CommandAst] -and
+ $node.GetCommandName() -eq 'Write-Host' -and
+ $node.Extent.Text.Contains('##vso[task.setvariable')
+}, $true))
+Assert-Equal -Expected 1 -Actual $secretLoggingCommands.Count -Description 'Secret pipeline-variable command shape changed'
+Assert-True `
+ -Condition ($secretLoggingCommands[0].Extent.Text.Contains(';issecret=true]')) `
+ -Description 'Key Vault values are not emitted as secret Azure Pipelines variables'
+$providerAssertionCommands = @($inlineAst.FindAll({
+ param($node)
+ $node -is [System.Management.Automation.Language.CommandAst] -and
+ $node.GetCommandName() -eq 'Assert-EffectiveFhirSdkProvider'
+}, $true))
+Assert-Equal -Expected 1 -Actual $providerAssertionCommands.Count -Description 'e2e-set-variables does not invoke effective-provider validation'
+$topologyAssertionCommands = @($inlineAst.FindAll({
+ param($node)
+ $node -is [System.Management.Automation.Language.CommandAst] -and
+ $node.GetCommandName() -eq 'Assert-AcaSqlTopology'
+}, $true))
+Assert-Equal -Expected 1 -Actual $topologyAssertionCommands.Count -Description 'e2e-set-variables does not invoke SQL topology validation'
+
+$provisionScriptPath = Join-Path $repositoryRoot 'build/jobs/scripts/Provision-AcaDeploy.ps1'
+$tokens = $null
+$parseErrors = $null
+$provisionAst = [System.Management.Automation.Language.Parser]::ParseFile($provisionScriptPath, [ref]$tokens, [ref]$parseErrors)
+Assert-Equal -Expected 0 -Actual $parseErrors.Count -Description 'Provision-AcaDeploy.ps1 has parse errors'
+$assignments = @($provisionAst.FindAll({ param($node) $node -is [System.Management.Automation.Language.AssignmentStatementAst] }, $true))
+$databaseAssignment = @($assignments | Where-Object { $_.Left.Extent.Text -eq '$templateParameters["sqlDatabaseName"]' })
+Assert-Equal -Expected 1 -Actual $databaseAssignment.Count -Description 'Provision script does not assign the resolved database template parameter'
+Assert-Equal -Expected '$sqlDatabaseName' -Actual $databaseAssignment[0].Right.Extent.Text -Description 'Provision script database template parameter uses the wrong value'
+$providerAssignment = @($assignments | Where-Object { $_.Left.Extent.Text -eq '$templateParameters["fhirSdkProviderDefault"]' })
+Assert-Equal -Expected 1 -Actual $providerAssignment.Count -Description 'Provision script does not assign the resolved provider template parameter'
+Assert-Equal -Expected '$sqlDeploymentPlan.FhirSdkProviderDefault' -Actual $providerAssignment[0].Right.Extent.Text -Description 'Provision script provider template parameter bypasses the deployment plan'
+
+$bicepPath = Join-Path $repositoryRoot 'samples/templates/aca/fhir-sql.bicep'
+$armJson = & az bicep build --file $bicepPath --stdout
+if ($LASTEXITCODE -ne 0) {
+ throw 'Bicep compilation failed.'
+}
+
+$arm = $armJson | ConvertFrom-Json -Depth 100
+Assert-Equal -Expected '' -Actual $arm.parameters.fhirSdkProviderDefault.defaultValue -Description 'Legacy ARM provider default changed'
+Assert-True -Condition ($arm.parameters.sqlDatabaseName.defaultValue -like "*parameters('fhirVersion')*") -Description 'ARM database default is not version-specific'
+$secret = @($arm.resources | Where-Object { $_.type -eq 'Microsoft.KeyVault/vaults/secrets' })[0]
+Assert-True -Condition ($secret.properties.value -like "*parameters('sqlDatabaseName')*") -Description 'Compiled SQL connection string does not use sqlDatabaseName'
+Assert-True -Condition ($arm.variables.sdkProviderEnvVars -like "*FhirServer__CoreFeatures__FhirSdkProvider__Default*") -Description 'Compiled ARM does not emit the provider environment variable'
+Assert-True -Condition ($arm.variables.sdkProviderEnvVars -like "*empty(parameters('fhirSdkProviderDefault'))*") -Description 'Compiled ARM changes the legacy environment'
+
+Write-Host 'SQL vNext deployment plan validation passed.'
diff --git a/build/pr-pipeline.yml b/build/pr-pipeline.yml
index bfddd87d0a..b830ead5fb 100644
--- a/build/pr-pipeline.yml
+++ b/build/pr-pipeline.yml
@@ -90,6 +90,18 @@ stages:
- ImageOverride -equals $(InternalWindowsImage)
steps:
- template: ./jobs/analyze.yml
+ - job: ValidateAcaSqlDeploymentPlan
+ displayName: 'Validate ACA SQL deployment plan'
+ pool:
+ name: '$(InternalPool)'
+ demands:
+ - ImageOverride -equals $(InternalLinuxImage)
+ steps:
+ - task: PowerShell@2
+ displayName: 'Validate SQL vNext deployment plan'
+ inputs:
+ filePath: '$(System.DefaultWorkingDirectory)/build/jobs/scripts/tests/Test-AcaSqlVNextDeploymentPlan.ps1'
+ pwsh: true
- stage: DockerBuild
displayName: 'Build images'
@@ -266,6 +278,21 @@ stages:
deploymentName: "IntegrationTests"
nspName: 'nsp-$(UniqueResourceGroupName)'
+- stage: deploySqlVNextElasticPool
+ displayName: 'Deploy SQL vNext Elastic Pool (PR)'
+ dependsOn:
+ - deploySqlServer
+ jobs:
+ - template: ./jobs/provision-sqlElasticPool.yml
+ parameters:
+ resourceGroup: $(UniqueResourceGroupName)
+ sqlServerName: $(DeploymentEnvironmentName)
+ elasticPoolName: $(SqlVNextElasticPoolName)
+ capacity: 4
+ maxSizeBytes: '68719476736'
+ dbMinCapacity: 0
+ dbMaxCapacity: 2
+
- stage: deployStu3
displayName: 'Deploy STU3 CosmosDB Site'
dependsOn:
@@ -308,6 +335,32 @@ stages:
sqlServerName: $(DeploymentEnvironmentName)
reindexEnabled: true
+- stage: deployStu3SqlVNext
+ displayName: 'Deploy STU3 SQL vNext SDK Site'
+ dependsOn:
+ - DockerBuild
+ - setupEnvironment
+ - deploySqlServer
+ - deploySqlVNextElasticPool
+ jobs:
+ - template: ./jobs/provision-deploy.yml
+ parameters:
+ dataStore: sql
+ version: Stu3
+ webAppName: $(DeploymentEnvironmentNameSqlVNext)
+ acaEnvironmentName: $(AcaEnvironmentName)
+ keyVaultName: $(KeyVaultNameSqlVNext)
+ subscription: $(ConnectedServiceName)
+ resourceGroup: $(UniqueResourceGroupName)
+ testEnvironmentUrl: $(TestApplicationResource)
+ imageTag: $(ImageTag)
+ schemaAutomaticUpdatesEnabled: 'auto'
+ sqlServerName: $(DeploymentEnvironmentName)
+ sqlElasticPoolName: $(SqlVNextElasticPoolName)
+ sqlDatabaseName: FHIRStu3VNext
+ fhirSdkProviderDefault: Ignixa
+ reindexEnabled: true
+
- stage: deployR4
displayName: 'Deploy R4 CosmosDB Site'
dependsOn:
@@ -350,6 +403,32 @@ stages:
sqlServerName: $(DeploymentEnvironmentName)
reindexEnabled: true
+- stage: deployR4SqlVNext
+ displayName: 'Deploy R4 SQL vNext SDK Site'
+ dependsOn:
+ - DockerBuild
+ - setupEnvironment
+ - deploySqlServer
+ - deploySqlVNextElasticPool
+ jobs:
+ - template: ./jobs/provision-deploy.yml
+ parameters:
+ dataStore: sql
+ version: R4
+ webAppName: $(DeploymentEnvironmentNameR4SqlVNext)
+ acaEnvironmentName: $(AcaEnvironmentName)
+ keyVaultName: $(KeyVaultNameR4SqlVNext)
+ subscription: $(ConnectedServiceName)
+ resourceGroup: $(UniqueResourceGroupName)
+ testEnvironmentUrl: $(TestApplicationResource)
+ imageTag: $(ImageTag)
+ schemaAutomaticUpdatesEnabled: 'auto'
+ sqlServerName: $(DeploymentEnvironmentName)
+ sqlElasticPoolName: $(SqlVNextElasticPoolName)
+ sqlDatabaseName: FHIRR4VNext
+ fhirSdkProviderDefault: Ignixa
+ reindexEnabled: true
+
- stage: deployR5Sql
displayName: 'Deploy R5 SQL Site'
dependsOn:
@@ -405,6 +484,31 @@ stages:
containerAppName: $(DeploymentEnvironmentNameSql)
integrationSqlServerName: $(DeploymentEnvironmentName)inttest
+- stage: testStu3SqlVNext
+ displayName: 'Run Stu3 SQL vNext SDK Tests'
+ dependsOn:
+ - BuildArtifacts
+ - setupEnvironment
+ - deployStu3SqlVNext
+ variables:
+ TestEnvironmentUrl_Stu3_Sql: $[stageDependencies.deployStu3SqlVNext.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Stu3_Sql']]
+ TestEnvironmentUrl_Sql: $[stageDependencies.deployStu3SqlVNext.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Sql']]
+ jobs:
+ - template: ./jobs/run-sql-tests.yml
+ parameters:
+ version: Stu3
+ keyVaultName: $(KeyVaultNameSqlVNext)
+ containerAppName: $(DeploymentEnvironmentNameSqlVNext)
+ integrationSqlServerName: $(DeploymentEnvironmentName)inttest
+ runIntegrationTests: false
+ runBulkUpdateJob: false
+ testRunTitleSuffix: ' vNext SDK'
+ expectedFhirSdkProviderDefault: Ignixa
+ expectedKeyVaultName: $(KeyVaultNameSqlVNext)
+ expectedSqlServerName: $(DeploymentEnvironmentName)
+ expectedSqlDatabaseName: FHIRStu3VNext
+ expectedSqlElasticPoolName: $(SqlVNextElasticPoolName)
+
- stage: testR4Cosmos
displayName: 'Run R4 Cosmos Tests'
dependsOn:
@@ -438,6 +542,31 @@ stages:
containerAppName: $(DeploymentEnvironmentNameR4Sql)
integrationSqlServerName: $(DeploymentEnvironmentName)inttest
+- stage: testR4SqlVNext
+ displayName: 'Run R4 SQL vNext SDK Tests'
+ dependsOn:
+ - BuildArtifacts
+ - setupEnvironment
+ - deployR4SqlVNext
+ variables:
+ TestEnvironmentUrl_R4_Sql: $[stageDependencies.deployR4SqlVNext.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_R4_Sql']]
+ TestEnvironmentUrl_Sql: $[stageDependencies.deployR4SqlVNext.provisionEnvironment.outputs['SetAcaOutputs.TestEnvironmentUrl_Sql']]
+ jobs:
+ - template: ./jobs/run-sql-tests.yml
+ parameters:
+ version: R4
+ keyVaultName: $(KeyVaultNameR4SqlVNext)
+ containerAppName: $(DeploymentEnvironmentNameR4SqlVNext)
+ integrationSqlServerName: $(DeploymentEnvironmentName)inttest
+ runIntegrationTests: false
+ runBulkUpdateJob: false
+ testRunTitleSuffix: ' vNext SDK'
+ expectedFhirSdkProviderDefault: Ignixa
+ expectedKeyVaultName: $(KeyVaultNameR4SqlVNext)
+ expectedSqlServerName: $(DeploymentEnvironmentName)
+ expectedSqlDatabaseName: FHIRR4VNext
+ expectedSqlElasticPoolName: $(SqlVNextElasticPoolName)
+
- stage: testR5Sql
displayName: 'Run R5 SQL Tests'
dependsOn:
diff --git a/build/tasks/e2e-set-variables.yml b/build/tasks/e2e-set-variables.yml
index 22eb8c4a5b..0137f018ac 100644
--- a/build/tasks/e2e-set-variables.yml
+++ b/build/tasks/e2e-set-variables.yml
@@ -5,6 +5,21 @@ parameters:
type: string
- name: appServiceType
type: string
+- name: expectedFhirSdkProviderDefault
+ type: string
+ default: ''
+- name: expectedKeyVaultName
+ type: string
+ default: ''
+- name: expectedSqlServerName
+ type: string
+ default: ''
+- name: expectedSqlDatabaseName
+ type: string
+ default: ''
+- name: expectedSqlElasticPoolName
+ type: string
+ default: ''
steps:
- task: AzurePowerShell@5
@@ -32,7 +47,8 @@ steps:
throw "$($secret.Name) is empty"
}
- Write-Host "##vso[task.setvariable variable=$($environmentVariableName)]$($plainValue)"
+ # Secret variables are masked by Azure Pipelines when later tasks reference them.
+ Write-Host "##vso[task.setvariable variable=$($environmentVariableName);issecret=true]$($plainValue)"
}
}
@@ -87,6 +103,9 @@ steps:
Write-Host "##vso[task.setvariable variable=$versionVariableName]$url"
}
+ . "$(System.DefaultWorkingDirectory)/build/jobs/scripts/Assert-EffectiveFhirSdkProvider.ps1"
+ . "$(System.DefaultWorkingDirectory)/build/jobs/scripts/Assert-AcaSqlTopology.ps1"
+
$keyVault = "$(KeyVaultBaseName)-ts"
Set-SecretsAsPipelineVariables -VaultName $keyVault
@@ -102,6 +121,17 @@ steps:
throw "Container App '$containerAppName' has no environment variables configured."
}
+ $expectedFhirSdkProviderDefault = '${{ parameters.expectedFhirSdkProviderDefault }}'
+ Assert-EffectiveFhirSdkProvider -EnvironmentSettings $envSettings -ExpectedProvider $expectedFhirSdkProviderDefault -ContainerAppName $containerAppName
+ Assert-AcaSqlTopology `
+ -EnvironmentSettings $envSettings `
+ -ContainerAppName $containerAppName `
+ -ResourceGroupName '$(UniqueResourceGroupName)' `
+ -ExpectedKeyVaultName '${{ parameters.expectedKeyVaultName }}' `
+ -ExpectedSqlServerName '${{ parameters.expectedSqlServerName }}' `
+ -ExpectedSqlDatabaseName '${{ parameters.expectedSqlDatabaseName }}' `
+ -ExpectedSqlElasticPoolName '${{ parameters.expectedSqlElasticPoolName }}'
+
Set-TestEnvironmentUrlVariables -ContainerAppData $containerAppData -Version '${{ parameters.version }}' -AppServiceType '${{ parameters.appServiceType }}'
$acrLoginServer = Get-ContainerEnvValue -EnvSettings $envSettings -Name "FhirServer__Operations__ConvertData__ContainerRegistryServers__0"
diff --git a/docs/arch/adr-2607-ignixa-import-phase0.md b/docs/arch/adr-2607-ignixa-import-phase0.md
deleted file mode 100644
index 0b84e518ce..0000000000
--- a/docs/arch/adr-2607-ignixa-import-phase0.md
+++ /dev/null
@@ -1,92 +0,0 @@
-# ADR 2607: Incremental Ignixa SDK Migration — Phase 0 ($import)
-
-## Context
-
-The server is migrating from the Firely FHIR SDK to Ignixa, a new FHIR SDK offering faster FHIRPath evaluation and serialization. An earlier full attempt (`feature/ignixa-sdk`, later extended in `personal/bkowitz/ignixa-sdk-next-steps-fable`) wired Ignixa natively across import, persistence, HTTP formatters, and validation in a single large change — 37 new/modified files in the Ignixa-specific surface alone, more once formatter and feature-flag wiring is included. That scope is too large for one reviewable PR and creates a single point of failure if any one seam has a problem.
-
-We need a migration strategy that lands real, mergeable progress in small increments without destabilizing the default (Firely) request path, and without accumulating a facade or abstraction layer that outlives its usefulness once Firely is eventually removed.
-
-## Decision
-
-Integrate Ignixa one feature seam at a time behind a global two-state provider setting:
-
-```csharp
-public enum FhirSdkProvider
-{
- Firely = 0,
- Ignixa = 1,
-}
-```
-
-- `CoreFeatureConfiguration.FhirSdkProvider` defaults to `Firely`. There is no `Hybrid` mode and no runtime fallback from Ignixa to Firely — shadow comparison is a testing technique, not a production mode. (Superseded in part by [ADR 2608](adr-2608-ignixa-fhirpath-seam.md): this setting became a nested section — `Default`, plus a nullable override per seam — once FHIRPath needed to roll out independently of import. The default, the absence of `Hybrid`, and the no-fallback rule are unchanged.)
-- Selecting `Ignixa` means every feature seam already migrated uses its Ignixa implementation; every other seam keeps using Firely until that seam is migrated in its own PR.
-- Startup logs the configured provider and the seams it currently controls (`FhirSdkProviderStartupLogger`: `"FHIR SDK provider configured: {FhirSdkProvider}; migrated seams: Import."`), so the global setting never creates a false impression that the whole server has moved.
-- We do not introduce an `IFhirSdkProvider` facade — it would accumulate unrelated serialization, validation, FHIRPath, and persistence responsibilities. Each migrated feature keeps its existing narrow contract (Phase 0 reuses `IImportResourceParser` unchanged) or introduces one narrow contract if none exists.
-
-**Phase 0 migrates only `$import` parsing.**
-
-- Four version-specific Firely provider projects (`Microsoft.Health.Fhir.{Stu3,R4,R4B,R5}.FirelySdk`) share one `FirelyImportResourceParser` source file so behavior can't drift by version.
-- One `Microsoft.Health.Fhir.Ignixa` project carries all Ignixa code and targets `net10.0`, matching the repo's single target framework (`Directory.Build.props`, `net10.0` since .NET 8 build targets were retired in #5686). The pinned package version (`IgnixaPackageVersion` = `0.0.163` in `Directory.Packages.props`) ships only `net9.0` binaries, which a `net10.0` project consumes fine through ordinary NuGet TFM compatibility; newer Ignixa releases (`0.6.4`, seen in the local package cache) already ship a `net10.0` target directly.
-- `OperationsModule` registers exactly one `IImportResourceParser` from the configured provider at startup; it never resolves both parsers per resource or catches an Ignixa failure to retry with Firely.
-
-The Ignixa parser intentionally converts its parsed node to the existing Firely-shaped `ResourceElement` before calling the existing `IResourceWrapperFactory`, rather than preserving the native Ignixa node end-to-end. This keeps Phase 0 at the ~10-file guardrail (see below) and leaves the entire downstream pipeline (search indexing, raw-resource creation, storage) unchanged for either provider. The deliberate cost: `RawResourceFactory` still rebuilds a full Firely POCO and serializes through Firely's `FhirJsonSerializer` regardless of which parser produced the resource, so Ignixa mode is performance-neutral-to-slightly-slower and higher-allocating than Firely mode on `$import` today. Recovering that win is Phase 2a below (the write-side persistence codec), which is scheduled immediately after Phase 0 for exactly that reason — see Adverse Effects and Execution order.
-
-Within the parser, soft-delete detection and removal use genuinely native Ignixa APIs rather than a Firely adapter or raw-JSON code:
-
-- Soft-delete detection evaluates the same `Resource.meta.extension...` predicate the Firely parser runs via `ResourceElement.IsSoftDeleted()`, but directly against the native `IElement` through `Ignixa.FhirPath`'s `IElement.Predicate(path, EvaluationContext)`. This is a deliberate, narrow exception to the "no FHIRPath in Phase 0" scope guideline — one predicate, fully contained inside the import parser. Search-parameter extraction for indexing is unaffected: it still runs through Firely's engine over the Firely-shaped `ResourceElement`, and stays scoped to Phase 3 below. `Ignixa.FhirPath` is consequently a genuinely new package dependency, scoped to `Microsoft.Health.Fhir.Ignixa.csproj` only.
-- The matched extension is removed through the typed `SourceNodeExtensions.RemoveExtension(MetaJsonNode, url)` helper rather than manual `JsonObject`/`JsonArray` traversal — verified empirically that it removes only one match per call (looped to mirror Firely's `Meta.RemoveExtension`, which removes every match), and that `ResourceJsonNode` caches its converted `IElement` per instance (mutating the node requires `InvalidateCaches()` before the next `ToElement()` call, or it silently returns the stale element).
-
-Both providers must preserve the current import policy exactly:
-
-- Valid resource ID required; conditional references rejected on initial load, allowed on incremental load.
-- `meta` initialized when absent; `lastUpdated` normalized to milliseconds and rejected if in the future.
-- Version preserved when valid, otherwise reset to `1`; soft-deleted resources detected and their extension stripped before persistence.
-- Conditional-reference detection — the highest-risk behavior in the Ignixa parser — covers the resource's own schema-declared reference fields (`IFhirSchemaProvider.ReferenceMetadataProvider`) with the same semantics as Firely's `GetAllChildren()`, each read through the typed `ReferenceJsonNode` model. This is scoped to what the search indexer itself already treats as in scope (see Neutral Effects), and intentionally strict about malformed shapes: a `reference` property holding a non-string scalar throws, and a reference field that is present but isn't a JSON object at all (confirmed empirically that `resource.ToElement(schema)` does not reject this shape on its own) also throws, rather than either case being silently skipped. A missing `reference` property (identifier-only or display-only references, both valid FHIR) is correctly treated as "no conditional reference" rather than dereferenced — a real `NullReferenceException` here was caught and fixed during review.
-
-### Migration ladder (subsequent PRs, one seam each)
-
-1. **Export NDJSON serialization** — switch `IResourceToByteArraySerializer`; byte/semantic parity corpus across all versions; no formatter or persistence changes.
-2. **Persistence codecs** (two PRs) — **2a, writes:** provider-selected `IRawResourceFactory` with a native-serialize branch, with `ResourceElement` carrying the native Ignixa node via its existing internal two-arg constructor. This is what recovers Phase 0's deferred performance win. **2b, reads:** provider-selected `IResourceDeserializer`, preserving the native node after a database read. Both halves must continue returning existing Core types to SQL and Cosmos, prove parity for raw JSON, search values, history, and deleted resources, and keep rollback possible without rewriting stored data.
-3. **FHIRPath and search indexing** — provider-neutral FHIRPath evaluation context, a Firely-authoritative parity corpus per generated search parameter per version, then switch indexing and reindex together (they must use the same provider to avoid index drift; `resolve()` behavior is a release blocker). Specified in [ADR 2608](adr-2608-ignixa-fhirpath-seam.md), which lands this as **one** PR rather than the three estimated here: the seam turns out to be a single narrow contract with ~27 mechanical `using` swaps behind it, and splitting it would ship a half-migrated engine. FHIRPath Patch is excluded and stays Firely-backed until Phase 7 — Ignixa cannot preserve the `ElementNode` identity the patch operations mutate through.
-4. **Ordinary HTTP JSON ingress** — single-resource create/update only; Bundles, Parameters, JSON/FHIRPath Patch, and XML explicitly excluded.
-5. **HTTP JSON egress** (three PRs) — single-resource responses, then search/history bundles, then `_summary`/`_elements` projection.
-6. **Validation** (three PRs) — primitive/structural, then conformance-resource, then profile/terminology-backed. Ignixa success must never synchronously invoke Firely validation as a check.
-7. **Complex write semantics** — one operation family per PR (conditional-reference mutation, transaction/batch bundles, JSON Patch, FHIRPath Patch, bulk update/codecs).
-8. **Remaining surfaces** — CapabilityStatement/conformance, terminology, resource-parser tools, XML (XML needs an explicit retain/replace/remove decision; Firely can't be deleted while supported XML behavior depends on it).
-9. **Firely removal** — only after an inventory shows zero remaining Firely runtime seams: delete the four Firely provider projects, remove Firely packages/adapters, remove provider-selection code, remove now-unneeded compatibility conversions.
-
-### Execution order
-
-The ladder above is a *dependency* order, not a commitment to execute it top to bottom.
-
-The first delivery target is a demonstrable `$import` performance win reachable through a supported configuration change — the benefit measured on the throwaway `feature/ignixa-sdk` branch, reproduced on the production toggle path. That is a vertical slice of **Phase 0 + Phase 2a**, and it does not require Phase 1 or Phase 2b: `$import` is a write path, so `ResourceWrapperFactory` calls `IRawResourceFactory` on every imported resource, while `IResourceDeserializer` is not on the import path at all.
-
-Phase 2a is therefore scheduled ahead of both Phase 1 and Phase 2b, followed by a benchmark gate comparing Ignixa and Firely modes on one binary with only configuration differing. If that gate does not reproduce the `feature/ignixa-sdk` delta, the bottleneck is identified and written up before the ladder resumes. Phase 1 and Phase 2b follow the gate.
-
-Phase numbering stays fixed regardless of execution order — the backlog references these numbers, so they are identifiers, not a schedule.
-
-Every migration PR changes exactly one feature seam, preserves Firely as the default until final cutover, has one composition-root decision point, avoids hidden fallback, states its rollback action (typically: reset the provider setting; no data migration required), and states which seams remain Firely-backed. As a review heuristic, we target no more than roughly ten modified production files per seam PR (excluding new provider-project scaffolding and mechanical solution/Docker entries) — exceeding that isn't automatically wrong, but it requires explaining why the seam can't be split further. Phase 0 lands at exactly ten.
-
-## Status
-
-Accepted
-
-## Consequences
-
-### Benefits
-
-- Each seam lands as an independently reviewable, independently revertible PR instead of one large cutover.
-- Firely stays the default and fully functional throughout the migration; rollback at any point is a configuration change, not a data migration.
-- Reusing existing narrow contracts (`IImportResourceParser`, `IResourceWrapperFactory`) means downstream consumers (indexing, storage, job processing) require zero changes for Phase 0, and the parity test suite can assert byte-level equivalence between providers.
-- The migration ladder gives reviewers a shared map of what's left, preventing "is this seam actually migrated?" ambiguity.
-- The parser is a worked example of idiomatic native Ignixa usage (`IElement.Predicate`, `MetaJsonNode`/`ReferenceJsonNode`, `InvalidateCaches()`) for later migration-ladder PRs to build on.
-
-### Adverse Effects
-
-- Phase 0 delivers no performance improvement for Ignixa-mode `$import` — likely a slight regression versus Firely mode, from paying both an Ignixa parse and a full Firely POCO rebuild + serialize. Call this out explicitly wherever the migration's performance rationale is cited, so reviewers and operators don't assume Phase 0 alone delivers the documented Ignixa speedup; recovering it is Phase 2a (the write-side persistence codec), scheduled immediately after Phase 0 — see Execution order.
-- The Ignixa→Firely `ResourceElement` conversion is a known, intentional shim. The line where the native node is dropped (the one-argument `ResourceElement` constructor, which leaves `ResourceInstance` unset) is marked in code as the Phase 2a flip point, so it's a planned one-line change plus a new native-serialize decorator, not a rediscovered defect.
-- A two-state provider enum will need reconciling later if a `Hybrid`/shadow-comparison mode is ever wanted for a specific seam — deliberately excluded from Phase 0, not a limitation of the enum shape.
-
-### Neutral Effects
-
-- Conditional-reference checking in Ignixa mode does not recurse into `contained` resources or Bundle entries. This matches the search indexer's existing behavior (which also never indexes into `contained`) and import's NDJSON-of-individual-resources model — an intentional scope boundary, not a parity gap to close later.
diff --git a/docs/arch/adr-2608-ignixa-fhirpath-seam.md b/docs/arch/adr-2608-ignixa-fhirpath-seam.md
deleted file mode 100644
index 36f970c2a0..0000000000
--- a/docs/arch/adr-2608-ignixa-fhirpath-seam.md
+++ /dev/null
@@ -1,167 +0,0 @@
-# ADR 2608: Incremental Ignixa SDK Migration — Phase 3 (FHIRPath evaluation)
-
-## Context
-
-[ADR 2607](adr-2607-ignixa-import-phase0.md) established an incremental migration from the Firely SDK to Ignixa, one feature seam per PR, behind a provider setting that defaults to Firely. Phase 0 migrated `$import` parsing. This ADR covers Phase 3, FHIRPath evaluation — the seam the migration's performance rationale ultimately rests on, since FHIRPath is what extracts every search index entry on every write.
-
-Today FHIRPath reaches the server through two unrelated doors:
-
-- **The extension methods.** `Hl7.FhirPath.IValueProviderFPExtensions` adds `Select`/`Scalar`/`Predicate`/`IsTrue`/`IsBoolean` to `ITypedElement`. Roughly 27 production files call these — the twenty search-value converters, the bundle wrappers, `CompartmentDefinitionManager`, `SearchParameterDefinitionBuilder`, `NarrativeValidator`, and others. A file opts in merely by writing `using Hl7.FhirPath;`, so there is no seam to intercept and no way to tell from a call site which engine will run.
-- **Direct compilation.** `TypedElementSearchIndexer` holds its own `FhirPathCompiler` and expression cache and invokes the compiled delegate directly.
-
-A third group — `SearchParameterToTypeResolver`, `SearchParameterSupportResolver`, `SearchParameterComparer` — consumes Firely's `Hl7.FhirPath.Expressions` AST for type inference. These never evaluate against a resource.
-
-There is no abstraction over any of this. Replacing the engine therefore means either touching every call site or introducing a seam that the call sites can be moved onto mechanically. Prior full-cutover prototypes (`feature/ignixa-sdk`, `personal/bkowitz/ignixa-sdk-next-steps-fable`) changed 144 files and were not reviewable.
-
-## Decision
-
-Introduce one narrow FHIRPath seam in Core, move every evaluating call site onto it, and select the implementation from configuration at a single composition point.
-
-### The seam
-
-```csharp
-public interface IFhirPathProvider { ICompiledFhirPath Compile(string expression); }
-
-public interface ICompiledFhirPath
-{
- string Expression { get; }
- IEnumerable Select(ITypedElement input, EvaluationContext context = null);
-}
-```
-
-`Select` is the only primitive. `Scalar`, `Predicate`, `IsTrue`, and `IsBoolean` are derived once, in the seam's extension class, from `Select`. This is deliberate: the existing Ignixa prototype's `Predicate` returned `false` for an empty result where Firely returns `true`, and `ConformanceProviderBase` gates every capability query on `Predicate`. Deriving once removes that class of drift entirely rather than asking two implementations to agree.
-
-The derivation must reproduce Firely 5.11.4 exactly, and its semantics are subtler than they look:
-
-- `Predicate` is `BooleanEval`, not "empty or truthy": empty yields `true`; a single element whose `Value` is a `bool` yields that bool (so `active = false` yields **false**); any other non-empty content yields `true`. `BooleanEval` is `internal` in the SDK, so the seam reimplements it.
-- `Scalar` takes two results and calls `Single()`, so **two or more results throw `InvalidOperationException`**. Firely SDK 6 changed this to return null; we are on 5.11.4 and pin the throw.
-- Every extension method applies `ToScopedNode()` to its input before evaluating. That wrap is what makes `%resource` and `%rootResource` resolve, and it is part of the observable contract.
-- Firely's `Closure.Root` mutates the caller's `EvaluationContext`. The concrete `FhirEvaluationContext` must flow through the seam untouched, or `ElementResolver` — and with it `resolve()` — is silently dropped.
-
-These are pinned by characterization tests written against the Firely provider before any Ignixa code exists.
-
-`FirelyFhirPathProvider` and `FirelyCompiledFhirPath` live in Core beside the interfaces. Core already references `Hl7.Fhir.Base`, which contains the engine, `EvaluationContext`, and `AddFhirExtensions`, so this adds no dependency and avoids touching the four version-specific `*.FirelySdk` projects that Phase 0 created. Final cutover deletes two files.
-
-**The seam owns symbol-table registration.** `FhirModule` currently calls `FhirPathCompiler.DefaultSymbolTable.AddFhirExtensions()` in two places; that global mutation is what puts `resolve()` into the engine. Leaving it there means a provider constructed outside full server startup — exactly what the characterization tests do — cannot compile `resolve()` expressions. `FirelyFhirPathProvider` performs the registration itself, and `FhirModule` drops both calls.
-
-### Provider selection
-
-The provider is a process-wide ambient, following the pattern `ModelInfoProvider` already establishes in this codebase, because the call sites include static extension methods (`SoftDeletedFhirPathExtension`, the converter helpers, `SearchParameterInfo`) that have no access to DI.
-
-```csharp
-public static class FhirPathProvider
-{
- private static Func _factory = static () => new FirelyFhirPathProvider();
- private static Lazy _instance = new(() => _factory());
-
- public static IFhirPathProvider Instance => _instance.Value;
-
- public static void SetProviderFactory(Func factory)
- {
- _factory = EnsureArg.IsNotNull(factory, nameof(factory));
- _instance = new Lazy(() => _factory());
- }
-}
-```
-
-Two properties matter. The default is Firely, so **nothing has to call the setter for current behaviour to hold** — roughly a thousand unit tests that construct converters directly need no fixture change, unlike `ModelInfoProvider`, which throws when unset. And resolution is lazy behind a `Lazy`, so the provider is built after `ModelInfoProvider` is set and two threads cannot race into two expression caches. `SetProviderFactory` replaces the `Lazy`, so a pre-registration read cannot latch Firely permanently.
-
-`SearchModule` is the single composition point — it already takes `FhirServerConfiguration` and owns the feature area, where `FhirModule` is parameterless:
-
-```csharp
-FhirPathProvider.SetProviderFactory(
- _configuration.CoreFeatures.FhirSdkProvider.EffectiveFhirPath == FhirSdkProvider.Ignixa
- ? () => new IgnixaFhirPathProvider(new IgnixaSchemaContext(ModelInfoProvider.Instance))
- : () => new FirelyFhirPathProvider());
-
-services.AddSingleton(_ => FhirPathProvider.Instance);
-```
-
-The DI registration delegates to the ambient rather than constructing a second provider, so there is exactly one expression cache per process. `TypedElementSearchIndexer` takes `IFhirPathProvider` through its constructor rather than reaching for the ambient; it is DI-constructed, and injecting it is what allows the parity corpus to drive both providers in one test process.
-
-### Configuration
-
-Phase 0's scalar `CoreFeatureConfiguration.FhirSdkProvider` becomes a nested section so seams can be rolled out independently:
-
-```csharp
-public class FhirSdkProviderConfiguration
-{
- public FhirSdkProvider Default { get; set; } = FhirSdkProvider.Firely;
- public FhirSdkProvider? Import { get; set; }
- public FhirSdkProvider? FhirPath { get; set; }
-
- public FhirSdkProvider EffectiveImport => Import ?? Default;
- public FhirSdkProvider EffectiveFhirPath => FhirPath ?? Default;
-}
-```
-
-```json
-"FhirSdkProvider": { "Default": "Firely", "FhirPath": "Ignixa" }
-```
-
-FHIRPath changes search index *content*, where import parsing does not, so the two must be flippable separately. `FhirSdkProviderStartupLogger` logs every effective per-seam value rather than one enum.
-
-### Scope
-
-**In:** the ~27 extension-method call sites, `TypedElementSearchIndexer`, `ResourceElement`, `ConformanceProviderBase`, and reindex (which shares the same `ISearchIndexer` singleton, so it cannot diverge from indexing within a process).
-
-**Out, and permanently marked so:**
-
-- **FHIRPath Patch.** The six `Operation*.cs` files select nodes and then mutate the returned nodes: `ElementModelExtensions.ToElementNode` is `(element is ElementNode el) ? el : ElementNode.FromElement(element)`, and `OperationDelete` then calls `Target.Parent.Remove(Target)`. Firely returns the input tree's own `ElementNode` instances, so the mutation lands. An Ignixa provider returns adapter-wrapped nodes, fails the type test, receives a **detached copy**, and patches the copy — the operation reports success and the resource is unchanged. Ignixa structurally cannot honour node identity across the adapter boundary. Patch is Phase 7 in ADR 2607 regardless; these files keep `Hl7.FhirPath` and are whitelisted in the seam test with this reason.
-- **The three AST consumers.** They perform type inference at definition time, never evaluate against a resource, and porting them to Ignixa's AST plus `FhirPathAnalyzer` is a large visitor rewrite with no runtime benefit. They keep `Hl7.FhirPath` for `FhirPathCompiler` and are whitelisted.
-- **`ITypedElement` and `EvaluationContext` stay in the seam signatures.** Both ship in `Hl7.Fhir.Base`, which Core keeps for the element model regardless, so abstracting `EvaluationContext` alone removes no dependency while adding churn. They get replaced together when the element model moves.
-
-### Locking the seam
-
-A test asserts that no file imports `Hl7.FhirPath` outside the Firely provider and the documented whitelist. It must also ban `Hl7.Fhir.FhirPath` — the POCO-based `Select`/`Scalar` extensions live there with their own always-FHIR-enabled cache, so a call site on a POCO would neither collide with the seam nor be caught. It must not prefix-match `Hl7.FhirPath.Sprache`, which is Firely's embedded parser-combinator library and unrelated to the engine (`SqlServerFhirDataStore` and `StringExtensions` use it legitimately). The repo has no `BannedApiAnalyzers` package; a test is cheaper than adding one.
-
-### Cache policy
-
-Stated explicitly because three different policies exist today and none is written down: the extension path uses a shared static 500-entry LRU, `TypedElementSearchIndexer` uses a private unbounded dictionary, and Ignixa keeps its own static unbounded AST and delegate caches. Expressions are influenced by user input through custom search parameters, so unbounded caching is a slow leak.
-
-Each provider owns a **bounded** compile cache. `TypedElementSearchIndexer` keeps holding `ICompiledFhirPath` handles in its own dictionary keyed by search parameter — the `Compile`-returns-a-handle shape means the hot path never consults a string-keyed cache at all, which is strictly better than today and independent of the LRU size. R4 alone ships roughly 1,400 search-parameter expressions, so routing them through a 500-entry LRU would thrash every extract cycle.
-
-### Failure handling
-
-`TypedElementSearchIndexer` catches all exceptions from expression evaluation, logs a warning, and yields an empty index entry set. Ignixa throws `NotSupportedException` for unimplemented functions where Firely would return empty, so that catch is the exact mechanism by which a conformance gap becomes silent index drift. In Ignixa mode, evaluation failure is surfaced as a metric and is a bake-in gate, not a swallowed warning.
-
-## Status
-
-Proposed
-
-## Consequences
-
-### Benefits
-
-- One seam, one composition point, one engine per process. There is no mode in which two FHIRPath implementations run within a single indexing pass.
-- The change is dominated by mechanical edits: ~27 files change only their `using` line, bodies untouched. Keeping both namespace imports is a `CS0121` ambiguity, so the compiler proves no call site was missed.
-- Firely stays the default and fully functional; rollback is a configuration change with no data migration.
-- Deriving four helpers from one primitive closes a real defect class, evidenced by the prototype's inverted `Predicate` and its `Scalar` that used `FirstOrDefault` with no single-item enforcement.
-- Final cutover deletes `FirelyFhirPathProvider`, `FirelyCompiledFhirPath`, and the flag, with no call-site changes.
-- Ignixa's FHIRPath conformance is not a gating concern: the official HL7 suite passes 2906 of 2906 across R4/R4B/R5, with nine soft-passes for `conformsTo()` and `%terminologies`, neither of which appears in any search-parameter expression in any supported version.
-
-### Adverse Effects
-
-- **The performance rationale does not apply at this phase, and this must not be cited as if it does.** HTTP ingress (Phase 4) and read codecs (Phase 2b) are still Firely, so every element reaching the indexer is a Firely POCO. Evaluation therefore runs through a per-call `ToIgnixaElement()` adapter and returns through `TypedElementAdapter`. Ignixa's published 3,220x figure is measured on native Ignixa elements and does not describe this path. The benchmark gate for enabling Ignixa in production must measure *adapter-input* evaluation specifically; if it does not show a win, Phase 3 is a correctness-and-seam change only, exactly as Phase 0 was.
-- **A process-wide static cannot express per-server configuration.** `TestFhirServerFactory` caches multiple in-process servers, so two in-proc servers configured with different providers cross-contaminate — which is precisely the shape an Ignixa-versus-Firely E2E comparison would take. Constructor injection into `TypedElementSearchIndexer` covers the parity corpus; a genuine per-server FHIRPath provider would require removing the ambient, which in turn requires the ~1,000 direct-`new` converter tests to gain a fixture. Deferred, and the E2E constraint is documented at the static.
-- **`$patch` remains Firely-backed until Phase 7** even when the flag says Ignixa. The startup log names the seams the setting actually controls, so this does not silently mislead operators.
-- **Reshaping the config node is a breaking change.** An existing scalar `"FhirSdkProvider": "Firely"` binds to the new object type as *nothing*, so an operator who had set `Ignixa` silently reverts to Firely. The direction is fail-safe, and Phase 0's flag is opt-in and unreleased, but the startup log must make the effective values unambiguous.
-- **A prerequisite lands in another repository.** The Ignixa adapters passed `Value` through untranslated in both directions, so Firely's `P.DateTime` reached Ignixa's comparison helpers — which narrow operands through a `string`/`DateTime`/`DateTimeOffset` switch and fall through to `null` — turning every date comparison into an empty result instead of a boolean, silently. Fixed in [ignixa-fhir#398](https://github.com/brendankowitz/ignixa-fhir/pull/398); enabling Ignixa in production is blocked on a package release containing it.
-
-### Neutral Effects
-
-- `%context` is not bound by name in Ignixa's `GetEnvironmentVariable`; it falls through to the generic environment dictionary. The evaluation-context bridge binds it explicitly, along with `%resource` and `%rootResource` and the `ElementResolver` that backs `resolve()` — which appears 76 times across the R4, R4B, and R5 search parameters and is the single highest-risk behaviour in the bridge.
-- `TypedElementSearchIndexer` moving onto the seam changes two behaviours that were never deliberate: it gains the `ToScopedNode()` wrap the extension path always applied, and it loses its unbounded private cache. Both are pinned by characterization tests before the move.
-- Custom search parameters are validated through Firely's AST tooling (out of scope) but indexed through Ignixa, so an accept-versus-index mismatch is possible. The parity corpus covers generated parameters; custom-parameter parity is a bake-in observation, not a pre-merge gate.
-
-### Delivery
-
-One PR. The blast radius is roughly 45 files, of which ~27 are single-line `using` swaps, which sits inside ADR 2607's review guardrail once mechanical edits are excluded on the same basis as its solution and Docker entries.
-
-Verification, in order:
-
-1. **Characterization tests** pinning `Select`/`Scalar`/`Predicate`/`IsTrue`/`IsBoolean` and the indexer's current behaviour against Firely 5.11.4 — written and passing before the Ignixa provider exists, so the seam's fidelity is established independently of it.
-2. **Parity corpus** — every generated search parameter, every FHIR version, a resource corpus, run through both providers, asserting equal `Select` results and equal `SearchIndexEntry` sets. This is the gate on enabling Ignixa.
-3. **Benchmark** of adapter-input evaluation, per the first adverse effect.
-
-Rollback is resetting `CoreFeatures:FhirSdkProvider:FhirPath`. No data migration; index rows written under either provider stay valid, and the parity corpus is what justifies that claim.
diff --git a/docs/arch/adr-2608-ignixa-sdk-migration.md b/docs/arch/adr-2608-ignixa-sdk-migration.md
new file mode 100644
index 0000000000..1324b3d384
--- /dev/null
+++ b/docs/arch/adr-2608-ignixa-sdk-migration.md
@@ -0,0 +1,37 @@
+# ADR-2608: Incrementally adopt the Ignixa SDK
+
+**Status**: Accepted
+**Date**: 2026-08-31
+**Feature**: Ignixa SDK migration
+
+## Context
+
+The server is deeply coupled to the Firely SDK across parsing, serialization, validation, FHIRPath evaluation, and resource processing. Production-shaped measurements show that Ignixa can materially reduce FHIRPath and element-model costs, while its modular design provides greater control over capabilities that are currently supplied by one broad dependency. Retaining Firely indefinitely would preserve the status quo but would also forgo those benefits and provide no path to reduce that coupling.
+
+Replacing the SDK across the entire server in one change would create an unreviewable blast radius and make behavioral regressions, index drift, and rollback difficult to manage.
+
+FHIR behavior and persisted data must remain compatible throughout the migration. Individual capabilities have different correctness risks, performance characteristics, and dependencies, so they cannot all be enabled safely at the same time.
+
+## Options Considered
+
+1. **Continue using Firely as the only SDK** - This minimizes near-term change but retains the current coupling and forgoes measured performance opportunities. *(rejected)*
+2. **Replace Firely with Ignixa in one release** - A single cutover avoids temporary adapters but creates excessive implementation and rollback risk. *(rejected)*
+3. **Run both SDKs indefinitely and compare every operation** - Continuous shadow execution provides evidence but permanently doubles complexity and can conceal which implementation is authoritative. *(rejected)*
+4. **Adopt Ignixa incrementally at explicit feature seams** - Migrate independently selectable capabilities while Firely remains the default for unmigrated behavior. *(selected)*
+
+## Decision
+
+We will adopt Ignixa incrementally through narrow, capability-specific seams. Each seam selects exactly one implementation at startup, defaults to Firely until Ignixa is approved for that capability, and supports rollback through configuration without rewriting persisted data. Production requests will not silently fall back from Ignixa to Firely; failures must remain observable.
+
+We will not introduce a single facade for the entire FHIR SDK. Existing focused contracts will be reused, and new abstractions will be limited to capabilities that do not already have an appropriate boundary. A seam must include every path that produces or regenerates the same persisted or externally visible representation; for example, indexing and reindexing cannot select different providers.
+
+A seam may be enabled only after production-shaped tests demonstrate semantic parity, compatibility with supported FHIR versions, and acceptable performance. Firely will be removed only after all supported runtime behavior has migrated and the compatibility layer is no longer required.
+
+## Consequences
+
+- Migration changes remain reviewable, independently deployable, and reversible.
+- Firely and Ignixa dependencies, adapters, and configuration coexist temporarily.
+- Different capabilities may intentionally use different SDKs during the transition, so startup diagnostics must identify the effective provider for each migrated seam.
+- Every migrated seam requires parity tests and operational failure signals; performance-motivated changes also require measurements of the actual server path rather than isolated SDK claims.
+- Ignixa performance benefits may be limited while a path still crosses Firely compatibility adapters.
+- Removing Firely becomes a deliberate final migration step rather than an incidental consequence of an individual feature change.
diff --git a/samples/templates/aca/fhir-sql.bicep b/samples/templates/aca/fhir-sql.bicep
index 08da16e369..5a7caf57df 100644
--- a/samples/templates/aca/fhir-sql.bicep
+++ b/samples/templates/aca/fhir-sql.bicep
@@ -27,6 +27,13 @@ param imageTag string = 'latest'
@description('Existing SQL server name.')
param sqlServerName string
+@description('SQL database name. Defaults to the version-specific legacy name.')
+param sqlDatabaseName string = 'FHIR${fhirVersion}'
+
+@description('Default FHIR SDK provider. Empty preserves the legacy deployment environment.')
+@allowed(['', 'Firely', 'Ignixa'])
+param fhirSdkProviderDefault string = ''
+
@description('Schema automatic updates mode.')
@allowed(['auto', 'tool'])
param sqlSchemaAutomaticUpdatesEnabled string = 'auto'
@@ -73,8 +80,6 @@ param additionalEnvVars array = []
var normalizedSqlServerName = toLower(sqlServerName)
var sqlManagedIdentityName = '${normalizedSqlServerName}-uami'
-var sqlDatabaseName = 'FHIR${fhirVersion}'
-
var sqlManagedIdentityResourceId = resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', sqlManagedIdentityName)
var userAssignedIdentities = {
@@ -82,7 +87,11 @@ var userAssignedIdentities = {
'${acrPullUserAssignedManagedIdentityResourceId}': {}
}
-var datastoreEnvVars = [
+var sdkProviderEnvVars = empty(fhirSdkProviderDefault) ? [] : [
+ { name: 'FhirServer__CoreFeatures__FhirSdkProvider__Default', value: fhirSdkProviderDefault }
+]
+
+var datastoreEnvVars = concat([
{ name: 'DataStore', value: 'SqlServer' }
{ name: 'SqlServer__Initialize', value: 'true' }
{
@@ -91,7 +100,7 @@ var datastoreEnvVars = [
}
{ name: 'SqlServer__DeleteAllDataOnStartup', value: 'false' }
{ name: 'SqlServer__AllowDatabaseCreation', value: 'true' }
-]
+], sdkProviderEnvVars)
// ──────────────────────────────────────────────
// Modules
diff --git a/src/Microsoft.Health.Fhir.Core.UnitTests/Config/FhirSdkProviderConfigurationTests.cs b/src/Microsoft.Health.Fhir.Core.UnitTests/Config/FhirSdkProviderConfigurationTests.cs
index fafa826a88..a322134d2c 100644
--- a/src/Microsoft.Health.Fhir.Core.UnitTests/Config/FhirSdkProviderConfigurationTests.cs
+++ b/src/Microsoft.Health.Fhir.Core.UnitTests/Config/FhirSdkProviderConfigurationTests.cs
@@ -18,7 +18,8 @@ public class FhirSdkProviderConfigurationTests
public void GivenDefaultConfiguration_WhenProviderRead_ThenFirelyIsSelected()
{
var configuration = new CoreFeatureConfiguration();
- Assert.Equal(FhirSdkProvider.Firely, configuration.FhirSdkProvider);
+ Assert.Equal(FhirSdkProvider.Firely, configuration.FhirSdkProvider.EffectiveImport);
+ Assert.Equal(FhirSdkProvider.Firely, configuration.FhirSdkProvider.EffectiveFhirPath);
}
[Fact]
@@ -26,9 +27,29 @@ public void GivenIgnixaConfigured_WhenProviderRead_ThenIgnixaIsSelected()
{
var configuration = new CoreFeatureConfiguration
{
- FhirSdkProvider = FhirSdkProvider.Ignixa,
+ FhirSdkProvider = new FhirSdkProviderConfiguration
+ {
+ Default = FhirSdkProvider.Ignixa,
+ },
};
- Assert.Equal(FhirSdkProvider.Ignixa, configuration.FhirSdkProvider);
+ Assert.Equal(FhirSdkProvider.Ignixa, configuration.FhirSdkProvider.EffectiveImport);
+ Assert.Equal(FhirSdkProvider.Ignixa, configuration.FhirSdkProvider.EffectiveFhirPath);
+ }
+
+ [Fact]
+ public void GivenSeamOverrides_WhenProvidersRead_ThenSelectionsAreIndependent()
+ {
+ var configuration = new CoreFeatureConfiguration
+ {
+ FhirSdkProvider = new FhirSdkProviderConfiguration
+ {
+ Default = FhirSdkProvider.Firely,
+ Import = FhirSdkProvider.Ignixa,
+ },
+ };
+
+ Assert.Equal(FhirSdkProvider.Ignixa, configuration.FhirSdkProvider.EffectiveImport);
+ Assert.Equal(FhirSdkProvider.Firely, configuration.FhirSdkProvider.EffectiveFhirPath);
}
}
}
diff --git a/src/Microsoft.Health.Fhir.Core.UnitTests/Features/FhirPath/FhirPathSeamTests.cs b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/FhirPath/FhirPathSeamTests.cs
new file mode 100644
index 0000000000..5656a01c52
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Core.UnitTests/Features/FhirPath/FhirPathSeamTests.cs
@@ -0,0 +1,102 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using Microsoft.Health.Fhir.Tests.Common;
+using Microsoft.Health.Test.Utilities;
+using Xunit;
+
+namespace Microsoft.Health.Fhir.Core.UnitTests.Features.FhirPath
+{
+ [Trait(Traits.OwningTeam, OwningTeam.Fhir)]
+ [Trait(Traits.Category, Categories.Search)]
+ public class FhirPathSeamTests
+ {
+ private static readonly HashSet AllowedFiles = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathExtensions.cs",
+ "src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyCompiledFhirPath.cs",
+ "src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyFhirPathProvider.cs",
+ "src/Microsoft.Health.Fhir.Core/Features/FhirPath/ICompiledFhirPath.cs",
+
+ // The composition root unconditionally invokes Firely's guarded, idempotent registration
+ // because FHIRPath Patch remains Firely-backed when the evaluation provider is Ignixa.
+ "src/Microsoft.Health.Fhir.Shared.Api/Modules/SearchModule.cs",
+
+ // FHIRPath Patch mutates the selected Firely ElementNode instances. Ignixa adapters cannot
+ // preserve that node identity, so Patch remains Firely-backed until migration phase 7.
+ "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationAdd.cs",
+ "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationDelete.cs",
+ "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationInsert.cs",
+ "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationMove.cs",
+ "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationReplace.cs",
+ "src/Microsoft.Health.Fhir.Shared.Core/Features/Resources/Patch/FhirPathPatch/Operations/OperationUpsert.cs",
+
+ // These consumers inspect Firely's AST and never evaluate an expression.
+ "src/Microsoft.Health.Fhir.Shared.Core/Features/Search/Parameters/SearchParameterComparer.cs",
+ "src/Microsoft.Health.Fhir.Shared.Core/Features/Search/Parameters/SearchParameterSupportResolver.cs",
+ "src/Microsoft.Health.Fhir.Shared.Core/Features/Search/Parameters/SearchParameterToTypeResolver.cs",
+ };
+
+ [Fact]
+ public void GivenProductionSource_WhenEngineNamespacesAreImported_ThenOnlyDocumentedExceptionsRemain()
+ {
+ string root = FindRepositoryRoot();
+ string[] sourceRoots =
+ [
+ Path.Join(root, "src"),
+ Path.Join(root, "tools", "Microsoft.Health.Fhir.R4.ResourceParser"),
+ ];
+
+ string[] violations = sourceRoots.SelectMany(sourceRoot => Directory.EnumerateFiles(sourceRoot, "*.cs", SearchOption.AllDirectories))
+ .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
+ .Where(path => !path.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.OrdinalIgnoreCase))
+ .Where(path => !path.Contains("UnitTests", StringComparison.OrdinalIgnoreCase))
+ .Where(ImportsFirelyEngine)
+ .Select(path => Path.GetRelativePath(root, path).Replace('\\', '/'))
+ .Where(path => !AllowedFiles.Contains(path))
+ .OrderBy(path => path, StringComparer.Ordinal)
+ .ToArray();
+
+ Assert.True(violations.Length == 0, $"Direct Firely FHIRPath imports bypass the provider seam:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
+ }
+
+ private static bool ImportsFirelyEngine(string path)
+ {
+ string source = File.ReadAllText(path);
+ return Regex.IsMatch(
+ source,
+ @"using\s+(?:\w+\s*=\s*)?(?:global::)?Hl7\.FhirPath\s*;",
+ RegexOptions.CultureInvariant) ||
+ Regex.IsMatch(
+ source,
+ @"using\s+(?:\w+\s*=\s*)?(?:global::)?Hl7\.Fhir\.FhirPath\s*;",
+ RegexOptions.CultureInvariant) ||
+ Regex.IsMatch(
+ source,
+ @"(?:global::)?Hl7\.FhirPath\.(?!(?:Expressions|Sprache|EvaluationContext)\b)",
+ RegexOptions.CultureInvariant) ||
+ Regex.IsMatch(
+ source,
+ @"(?:global::)?Hl7\.Fhir\.FhirPath\.(?!FhirEvaluationContext\b)",
+ RegexOptions.CultureInvariant);
+ }
+
+ private static string FindRepositoryRoot()
+ {
+ var directory = new DirectoryInfo(AppContext.BaseDirectory);
+ while (directory is not null && !File.Exists(Path.Join(directory.FullName, "Microsoft.Health.Fhir.sln")))
+ {
+ directory = directory.Parent;
+ }
+
+ return directory?.FullName ?? throw new InvalidOperationException("Could not locate the repository root.");
+ }
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Core/Configs/CoreFeatureConfiguration.cs b/src/Microsoft.Health.Fhir.Core/Configs/CoreFeatureConfiguration.cs
index d1bd7249fb..004b062a4c 100644
--- a/src/Microsoft.Health.Fhir.Core/Configs/CoreFeatureConfiguration.cs
+++ b/src/Microsoft.Health.Fhir.Core/Configs/CoreFeatureConfiguration.cs
@@ -38,7 +38,7 @@ public class CoreFeatureConfiguration
/// Gets or sets the preferred FHIR SDK at feature seams that support provider selection.
/// Firely remains the default until the final migration cutover.
///
- public FhirSdkProvider FhirSdkProvider { get; set; } = FhirSdkProvider.Firely;
+ public FhirSdkProviderConfiguration FhirSdkProvider { get; set; } = new();
///
/// Gets or sets the maximum value for _count in search.
diff --git a/src/Microsoft.Health.Fhir.Core/Configs/FhirSdkProviderConfiguration.cs b/src/Microsoft.Health.Fhir.Core/Configs/FhirSdkProviderConfiguration.cs
new file mode 100644
index 0000000000..17746b0932
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Core/Configs/FhirSdkProviderConfiguration.cs
@@ -0,0 +1,38 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+namespace Microsoft.Health.Fhir.Core.Configs
+{
+ ///
+ /// Selects the SDK provider independently for each migrated seam.
+ ///
+ public sealed class FhirSdkProviderConfiguration
+ {
+ ///
+ /// Gets or sets the default provider.
+ ///
+ public FhirSdkProvider Default { get; set; } = FhirSdkProvider.Firely;
+
+ ///
+ /// Gets or sets the import provider override.
+ ///
+ public FhirSdkProvider? Import { get; set; }
+
+ ///
+ /// Gets or sets the FHIRPath provider override.
+ ///
+ public FhirSdkProvider? FhirPath { get; set; }
+
+ ///
+ /// Gets the effective import provider.
+ ///
+ public FhirSdkProvider EffectiveImport => Import ?? Default;
+
+ ///
+ /// Gets the effective FHIRPath provider.
+ ///
+ public FhirSdkProvider EffectiveFhirPath => FhirPath ?? Default;
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Core/Extensions/TypedElementExtensions.cs b/src/Microsoft.Health.Fhir.Core/Extensions/TypedElementExtensions.cs
index 7ee96fd3f6..1eaff1c029 100644
--- a/src/Microsoft.Health.Fhir.Core/Extensions/TypedElementExtensions.cs
+++ b/src/Microsoft.Health.Fhir.Core/Extensions/TypedElementExtensions.cs
@@ -7,7 +7,7 @@
using System.Linq;
using EnsureThat;
using Hl7.Fhir.ElementModel;
-using Hl7.FhirPath;
+using Microsoft.Health.Fhir.Core.Features.FhirPath;
using Microsoft.Health.Fhir.Core.Features.Search.SearchValues;
namespace Microsoft.Health.Fhir.Core.Extensions
diff --git a/src/Microsoft.Health.Fhir.Core/Features/Conformance/ConformanceProviderBase.cs b/src/Microsoft.Health.Fhir.Core/Features/Conformance/ConformanceProviderBase.cs
index c29c20c6a4..d61ab09aaf 100644
--- a/src/Microsoft.Health.Fhir.Core/Features/Conformance/ConformanceProviderBase.cs
+++ b/src/Microsoft.Health.Fhir.Core/Features/Conformance/ConformanceProviderBase.cs
@@ -10,7 +10,7 @@
using System.Threading;
using System.Threading.Tasks;
using EnsureThat;
-using Hl7.FhirPath;
+using Microsoft.Health.Fhir.Core.Features.FhirPath;
using Microsoft.Health.Fhir.Core.Models;
namespace Microsoft.Health.Fhir.Core.Features.Conformance
diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleEntryWrapper.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleEntryWrapper.cs
index 031b46b4b3..21519e264a 100644
--- a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleEntryWrapper.cs
+++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleEntryWrapper.cs
@@ -7,7 +7,7 @@
using System.Linq;
using EnsureThat;
using Hl7.Fhir.ElementModel;
-using Hl7.FhirPath;
+using Microsoft.Health.Fhir.Core.Features.FhirPath;
namespace Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers
{
diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleWrapper.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleWrapper.cs
index bccb1a0cb6..90e83b77fb 100644
--- a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleWrapper.cs
+++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/BundleWrapper.cs
@@ -8,7 +8,7 @@
using System.Linq;
using EnsureThat;
using Hl7.Fhir.ElementModel;
-using Hl7.FhirPath;
+using Microsoft.Health.Fhir.Core.Features.FhirPath;
using Microsoft.Health.Fhir.Core.Models;
namespace Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers
diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/SearchParameterWrapper.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/SearchParameterWrapper.cs
index 7686816232..8aea965def 100644
--- a/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/SearchParameterWrapper.cs
+++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/BundleWrappers/SearchParameterWrapper.cs
@@ -8,8 +8,8 @@
using System.Linq;
using EnsureThat;
using Hl7.Fhir.ElementModel;
-using Hl7.FhirPath;
using Microsoft.Health.Fhir.Core.Extensions;
+using Microsoft.Health.Fhir.Core.Features.FhirPath;
using Microsoft.Health.Fhir.Core.Models;
namespace Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers
diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/CompartmentDefinitionManager.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/CompartmentDefinitionManager.cs
index 7d0773ef1f..dab057d06c 100644
--- a/src/Microsoft.Health.Fhir.Core/Features/Definition/CompartmentDefinitionManager.cs
+++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/CompartmentDefinitionManager.cs
@@ -14,12 +14,12 @@
using Hl7.Fhir.ElementModel;
using Hl7.Fhir.Serialization;
using Hl7.Fhir.Utility;
-using Hl7.FhirPath;
using Microsoft.Extensions.Hosting;
using Microsoft.Health.Fhir.Core.Data;
using Microsoft.Health.Fhir.Core.Exceptions;
using Microsoft.Health.Fhir.Core.Extensions;
using Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers;
+using Microsoft.Health.Fhir.Core.Features.FhirPath;
using Microsoft.Health.Fhir.Core.Models;
using Newtonsoft.Json;
using CompartmentType = Microsoft.Health.Fhir.ValueSets.CompartmentType;
diff --git a/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionBuilder.cs b/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionBuilder.cs
index 8b9054850b..e760cccf78 100644
--- a/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionBuilder.cs
+++ b/src/Microsoft.Health.Fhir.Core/Features/Definition/SearchParameterDefinitionBuilder.cs
@@ -14,11 +14,11 @@
using EnsureThat;
using Hl7.Fhir.ElementModel;
using Hl7.Fhir.Utility;
-using Hl7.FhirPath;
using Microsoft.Extensions.Logging;
using Microsoft.Health.Fhir.Core.Data;
using Microsoft.Health.Fhir.Core.Exceptions;
using Microsoft.Health.Fhir.Core.Features.Definition.BundleWrappers;
+using Microsoft.Health.Fhir.Core.Features.FhirPath;
using Microsoft.Health.Fhir.Core.Features.Persistence;
using Microsoft.Health.Fhir.Core.Features.Search;
using Microsoft.Health.Fhir.Core.Features.Search.Parameters;
diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathExtensions.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathExtensions.cs
new file mode 100644
index 0000000000..4f369cf455
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathExtensions.cs
@@ -0,0 +1,70 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Hl7.Fhir.ElementModel;
+using Hl7.FhirPath;
+
+namespace Microsoft.Health.Fhir.Core.Features.FhirPath
+{
+ ///
+ /// Evaluates FHIRPath expressions through the configured provider.
+ ///
+ public static class FhirPathExtensions
+ {
+ ///
+ /// Evaluates an expression and returns its selected elements.
+ ///
+ public static IEnumerable Select(this ITypedElement input, string expression, EvaluationContext context = null)
+ => Compile(input, expression).Select(input, context);
+
+ ///
+ /// Evaluates an expression and returns its single primitive value, or null when empty.
+ ///
+ public static object Scalar(this ITypedElement input, string expression, EvaluationContext context = null)
+ {
+ ITypedElement[] result = Compile(input, expression).Select(input, context).Take(2).ToArray();
+ return result.Length == 0 ? null : result.Single().Value;
+ }
+
+ ///
+ /// Returns true when the expression evaluates to true or empty.
+ ///
+ public static bool Predicate(this ITypedElement input, string expression, EvaluationContext context = null)
+ => BooleanEval(Compile(input, expression).Select(input, context)) is not false;
+
+ ///
+ /// Returns true when the expression evaluates to true.
+ ///
+ public static bool IsTrue(this ITypedElement input, string expression, EvaluationContext context = null)
+ => BooleanEval(Compile(input, expression).Select(input, context)) is true;
+
+ ///
+ /// Returns true when the expression evaluates to the supplied boolean.
+ ///
+ public static bool IsBoolean(this ITypedElement input, string expression, bool value, EvaluationContext context = null)
+ => BooleanEval(Compile(input, expression).Select(input, context)) is bool result && result == value;
+
+ private static ICompiledFhirPath Compile(ITypedElement input, string expression)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ ArgumentException.ThrowIfNullOrWhiteSpace(expression);
+ return FhirPathProvider.Instance.Compile(expression);
+ }
+
+ private static bool? BooleanEval(IEnumerable elements)
+ {
+ ITypedElement[] result = elements.Take(2).ToArray();
+ return result.Length switch
+ {
+ 0 => null,
+ 1 when result[0].Value is bool value => value,
+ _ => true,
+ };
+ }
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathProvider.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathProvider.cs
new file mode 100644
index 0000000000..e902c6573b
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FhirPathProvider.cs
@@ -0,0 +1,43 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System;
+using System.Threading;
+using EnsureThat;
+
+namespace Microsoft.Health.Fhir.Core.Features.FhirPath
+{
+ ///
+ /// Provides process-wide access to the configured FHIRPath engine.
+ ///
+ ///
+ /// This ambient is required by static extension call sites. In-process servers configured with
+ /// different providers are not supported; DI consumers should inject .
+ ///
+ public static class FhirPathProvider
+ {
+ private static Lazy _instance = CreateLazy(static () => new FirelyFhirPathProvider());
+
+ ///
+ /// Gets the configured provider.
+ ///
+ public static IFhirPathProvider Instance => Volatile.Read(ref _instance).Value;
+
+ ///
+ /// Replaces the provider factory. The provider is created lazily and exactly once for each factory.
+ ///
+ /// The provider factory.
+ public static void SetProviderFactory(Func factory)
+ {
+ Func providerFactory = EnsureArg.IsNotNull(factory, nameof(factory));
+ Interlocked.Exchange(ref _instance, CreateLazy(providerFactory));
+ }
+
+ private static Lazy CreateLazy(Func factory)
+ => new(
+ () => factory() ?? throw new InvalidOperationException("The FHIRPath provider factory returned null."),
+ LazyThreadSafetyMode.ExecutionAndPublication);
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyCompiledFhirPath.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyCompiledFhirPath.cs
new file mode 100644
index 0000000000..7afe67af1a
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyCompiledFhirPath.cs
@@ -0,0 +1,41 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System;
+using System.Collections.Generic;
+using Hl7.Fhir.ElementModel;
+using Hl7.FhirPath;
+
+namespace Microsoft.Health.Fhir.Core.Features.FhirPath
+{
+ ///
+ /// Executes a Firely-compiled FHIRPath expression.
+ ///
+ public sealed class FirelyCompiledFhirPath : ICompiledFhirPath
+ {
+ private readonly CompiledExpression _compiledExpression;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The source expression.
+ /// The Firely compiled delegate.
+ public FirelyCompiledFhirPath(string expression, CompiledExpression compiledExpression)
+ {
+ Expression = expression ?? throw new ArgumentNullException(nameof(expression));
+ _compiledExpression = compiledExpression ?? throw new ArgumentNullException(nameof(compiledExpression));
+ }
+
+ ///
+ public string Expression { get; }
+
+ ///
+ public IEnumerable Select(ITypedElement input, EvaluationContext context = null)
+ {
+ ArgumentNullException.ThrowIfNull(input);
+ return _compiledExpression(input.ToScopedNode(), context ?? new EvaluationContext());
+ }
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyFhirPathProvider.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyFhirPathProvider.cs
new file mode 100644
index 0000000000..cae70a57c2
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/FirelyFhirPathProvider.cs
@@ -0,0 +1,36 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System;
+using Hl7.Fhir.FhirPath;
+using Hl7.FhirPath;
+
+namespace Microsoft.Health.Fhir.Core.Features.FhirPath
+{
+ ///
+ /// Compiles expressions with the Firely FHIRPath engine.
+ ///
+ public sealed class FirelyFhirPathProvider : IFhirPathProvider
+ {
+ private const int CacheSize = 4096;
+ private readonly FhirPathCompilerCache _cache;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public FirelyFhirPathProvider()
+ {
+ ElementNavFhirExtensions.PrepareFhirSymbolTableFunctions();
+ _cache = new FhirPathCompilerCache(new FhirPathCompiler(FhirPathCompiler.DefaultSymbolTable), CacheSize);
+ }
+
+ ///
+ public ICompiledFhirPath Compile(string expression)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(expression);
+ return new FirelyCompiledFhirPath(expression, _cache.GetCompiledExpression(expression));
+ }
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/ICompiledFhirPath.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/ICompiledFhirPath.cs
new file mode 100644
index 0000000000..87976bbc65
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/ICompiledFhirPath.cs
@@ -0,0 +1,30 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+using System.Collections.Generic;
+using Hl7.Fhir.ElementModel;
+using Hl7.FhirPath;
+
+namespace Microsoft.Health.Fhir.Core.Features.FhirPath
+{
+ ///
+ /// Represents a compiled FHIRPath expression.
+ ///
+ public interface ICompiledFhirPath
+ {
+ ///
+ /// Gets the source expression.
+ ///
+ string Expression { get; }
+
+ ///
+ /// Evaluates the expression against an input element.
+ ///
+ /// The input element.
+ /// The evaluation context.
+ /// The selected elements.
+ IEnumerable Select(ITypedElement input, EvaluationContext context = null);
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Core/Features/FhirPath/IFhirPathProvider.cs b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/IFhirPathProvider.cs
new file mode 100644
index 0000000000..9f14cf2a04
--- /dev/null
+++ b/src/Microsoft.Health.Fhir.Core/Features/FhirPath/IFhirPathProvider.cs
@@ -0,0 +1,20 @@
+// -------------------------------------------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
+// -------------------------------------------------------------------------------------------------
+
+namespace Microsoft.Health.Fhir.Core.Features.FhirPath
+{
+ ///
+ /// Compiles FHIRPath expressions for evaluation.
+ ///
+ public interface IFhirPathProvider
+ {
+ ///
+ /// Compiles an expression into an executable handle.
+ ///
+ /// The FHIRPath expression.
+ /// The compiled expression.
+ ICompiledFhirPath Compile(string expression);
+ }
+}
diff --git a/src/Microsoft.Health.Fhir.Core/Features/Operations/Resources/Patch/PatchPayload.cs b/src/Microsoft.Health.Fhir.Core/Features/Operations/Resources/Patch/PatchPayload.cs
index e031c611a7..77dd924bbd 100644
--- a/src/Microsoft.Health.Fhir.Core/Features/Operations/Resources/Patch/PatchPayload.cs
+++ b/src/Microsoft.Health.Fhir.Core/Features/Operations/Resources/Patch/PatchPayload.cs
@@ -8,9 +8,9 @@
using System.Linq;
using EnsureThat;
using Hl7.Fhir.ElementModel;
-using Hl7.FhirPath;
using Microsoft.Health.Fhir.Core.Exceptions;
using Microsoft.Health.Fhir.Core.Extensions;
+using Microsoft.Health.Fhir.Core.Features.FhirPath;
using Microsoft.Health.Fhir.Core.Features.Persistence;
using Microsoft.Health.Fhir.Core.Models;
@@ -24,7 +24,7 @@ public abstract class PatchPayload
"Resource.id",
"Resource.meta.lastUpdated",
"Resource.meta.versionId",
- "Resource.text.div",
+ "Resource.text.`div`",
"Resource.text.status",
};
@@ -42,7 +42,7 @@ public ResourceElement Patch(ResourceWrapper resourceToPatch)
ResourceElement patchedResource = GetPatchedResourceElement(resourceToPatch);
// Check if any immutable properties were changed
- (string path, object result)[] postState = ImmutableProperties.Select(x => (path: x, result: patchedResource.Scalar