diff --git a/.github/workflows/deploy-azd.yml b/.github/workflows/deploy-azd.yml
index a9326149..90a75d39 100644
--- a/.github/workflows/deploy-azd.yml
+++ b/.github/workflows/deploy-azd.yml
@@ -65,10 +65,9 @@ env:
CI: true
GITHUB_ACTIONS: true
+# Minimal permissions - OIDC handled conditionally per job
permissions:
- id-token: write # Required for OIDC authentication
contents: read # Required to checkout repository
- pull-requests: write # Required to comment on PRs
jobs:
# ============================================================================
@@ -78,6 +77,8 @@ jobs:
name: ${{ github.event_name == 'pull_request' && 'đ Preview Changes' || 'đ Deploy with AZD' }}
runs-on: ubuntu-latest
+ # Try to request OIDC permissions, fall back gracefully if denied
+
# Environment selection logic
environment: >-
${{
@@ -88,9 +89,14 @@ jobs:
}}
env:
+ # OIDC Authentication (preferred)
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
+ # Service Principal Authentication (fallback)
+ AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
+ # Authentication method detection
+ USE_OIDC: ${{ secrets.AZURE_CLIENT_SECRET == '' && 'true' || 'false' }}
# Environment name logic
AZURE_ENV_NAME: >-
${{
@@ -119,11 +125,63 @@ jobs:
uses: actions/checkout@v4
- name: đ Azure Login (OIDC)
+ if: env.USE_OIDC == 'true'
uses: azure/login@v2
+ continue-on-error: true # Don't fail if OIDC permissions are denied
+ id: azure-login-oidc
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
+
+ - name: đ Azure Login (Service Principal)
+ if: env.USE_OIDC == 'false'
+ uses: azure/login@v2
+ id: azure-login-sp
+ with:
+ creds: '{"clientId":"${{ env.AZURE_CLIENT_ID }}","clientSecret":"${{ env.AZURE_CLIENT_SECRET }}","subscriptionId":"${{ env.AZURE_SUBSCRIPTION_ID }}","tenantId":"${{ env.AZURE_TENANT_ID }}"}'
+
+ - name: đ¨ Check Authentication Status
+ run: |
+ OIDC_OUTCOME="${{ steps.azure-login-oidc.outcome }}"
+ SP_OUTCOME="${{ steps.azure-login-sp.outcome }}"
+
+ if [ "${{ env.USE_OIDC }}" = "true" ]; then
+ if [ "$OIDC_OUTCOME" = "failure" ] || [ "$OIDC_OUTCOME" = "skipped" ]; then
+ echo "â ī¸ OIDC authentication failed - insufficient permissions or missing federated credentials"
+ echo "This is normal for:"
+ echo " - Forked repositories"
+ echo " - Repositories without 'id-token: write' permissions"
+ echo " - Missing Azure AD federated identity credentials"
+ echo ""
+ echo "đĄ Solutions:"
+ echo " 1. Add AZURE_CLIENT_SECRET to repository secrets for service principal auth"
+ echo " 2. Configure federated identity credentials in Azure AD"
+ echo " 3. Enable 'id-token: write' permissions in repository settings"
+
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ echo "âšī¸ For PR previews, functionality will be limited"
+ echo "auth_success=false" >> $GITHUB_OUTPUT
+ else
+ echo "â For deployments, authentication is required"
+ exit 1
+ fi
+ else
+ echo "â
Azure OIDC authentication successful"
+ echo "auth_success=true" >> $GITHUB_OUTPUT
+ fi
+ else
+ if [ "$SP_OUTCOME" = "success" ]; then
+ echo "â
Azure Service Principal authentication successful"
+ echo "auth_success=true" >> $GITHUB_OUTPUT
+ else
+ echo "â Service Principal authentication failed"
+ if [ "${{ github.event_name }}" != "pull_request" ]; then
+ exit 1
+ fi
+ echo "auth_success=false" >> $GITHUB_OUTPUT
+ fi
+ fi
- name: âī¸ Setup Azure Developer CLI
uses: Azure/setup-azd@v2
@@ -133,18 +191,33 @@ jobs:
with:
terraform_version: 1.9.0
- - name: đ Log in with Azure Developer CLI (OIDC)
+ - name: đ Log in with Azure Developer CLI
+ continue-on-error: true # Don't fail if authentication doesn't work
+ id: azd-login
run: |
- azd auth login `
- --client-id "$Env:AZURE_CLIENT_ID" `
- --federated-credential-provider "github" `
- --tenant-id "$Env:AZURE_TENANT_ID"
+ if [ "${{ env.USE_OIDC }}" = "true" ] && [ "${{ steps.azure-login-oidc.outcome }}" = "success" ]; then
+ echo "đ Attempting azd authentication with OIDC..."
+ azd auth login `
+ --client-id "$Env:AZURE_CLIENT_ID" `
+ --federated-credential-provider "github" `
+ --tenant-id "$Env:AZURE_TENANT_ID"
+ elif [ "${{ env.USE_OIDC }}" = "false" ] && [ "${{ steps.azure-login-sp.outcome }}" = "success" ]; then
+ echo "đ Attempting azd authentication with Service Principal..."
+ azd auth login `
+ --client-id "$Env:AZURE_CLIENT_ID" `
+ --client-secret "$Env:AZURE_CLIENT_SECRET" `
+ --tenant-id "$Env:AZURE_TENANT_ID"
+ else
+ echo "â ī¸ Skipping azd login due to failed Azure authentication"
+ exit 1
+ fi
shell: pwsh
# ========================================================================
# SHARED CONFIGURATION STEPS
# ========================================================================
- name: âī¸ Setup AZD Environment
+ if: (steps.azure-login-oidc.outcome == 'success') || (steps.azure-login-sp.outcome == 'success')
run: |
echo "đ§ Setting up azd environment: ${{ env.AZURE_ENV_NAME }}"
@@ -164,6 +237,7 @@ jobs:
echo "â
AZD environment configured"
- name: âī¸ Setup Terraform Parameters
+ if: (steps.azure-login-oidc.outcome == 'success') || (steps.azure-login-sp.outcome == 'success')
run: |
echo "đ§ Setting up Terraform parameters..."
@@ -177,21 +251,33 @@ jobs:
BASE_PARAMS=$(cat "infra/terraform/params/main.tfvars.${TFVARS_ENV}.json")
echo "Base: $(echo "$BASE_PARAMS" | jq -c .)"
+ # Determine authentication method for Terraform
+ if [ "${{ env.USE_OIDC }}" = "true" ]; then
+ PRINCIPAL_TYPE="ServicePrincipal"
+ AUTH_METHOD="OIDC"
+ else
+ PRINCIPAL_TYPE="ServicePrincipal"
+ AUTH_METHOD="ClientSecret"
+ fi
+
# Add dynamic parameters
FINAL_PARAMS=$(echo "$BASE_PARAMS" | jq \
--arg env "${{ env.AZURE_ENV_NAME }}" \
- --arg principal_type "ServicePrincipal" \
+ --arg principal_type "$PRINCIPAL_TYPE" \
--arg deployed_by "${GITHUB_ACTOR}" \
+ --arg auth_method "$AUTH_METHOD" \
'. + {
environment_name: $env,
principal_type: $principal_type,
- deployed_by: $deployed_by
+ deployed_by: $deployed_by,
+ auth_method: $auth_method
}')
echo "$FINAL_PARAMS" > infra/terraform/main.tfvars.json
- echo "â
Parameters configured for environment: ${{ env.AZURE_ENV_NAME }}"
+ echo "â
Parameters configured for environment: ${{ env.AZURE_ENV_NAME }} (Auth: $AUTH_METHOD)"
- name: đ§ Configure Terraform Backend
+ if: (steps.azure-login-oidc.outcome == 'success') || (steps.azure-login-sp.outcome == 'success')
run: |
echo "đ§ Configuring Terraform backend..."
echo "Backend: ${{ env.RS_STORAGE_ACCOUNT }}/${{ env.RS_CONTAINER_NAME }}/${{ env.AZURE_ENV_NAME }}.tfstate"
@@ -213,7 +299,8 @@ jobs:
ARM_CLIENT_ID: ${{ env.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ env.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ env.AZURE_SUBSCRIPTION_ID }}
- ARM_USE_OIDC: true
+ ARM_USE_OIDC: ${{ env.USE_OIDC }}
+ ARM_CLIENT_SECRET: ${{ env.USE_OIDC == 'false' && env.AZURE_CLIENT_SECRET || '' }}
# - name: Whitelist GitHub Runner IP
# uses: azure/CLI@v1
@@ -230,7 +317,7 @@ jobs:
# PREVIEW MODE (for PRs)
# ========================================================================
- name: đ Run Infrastructure Preview
- if: github.event_name == 'pull_request'
+ if: github.event_name == 'pull_request' && ((steps.azure-login-oidc.outcome == 'success') || (steps.azure-login-sp.outcome == 'success'))
id: preview
run: |
echo "đ Running infrastructure preview via AZD..."
@@ -274,12 +361,41 @@ jobs:
ARM_CLIENT_ID: ${{ env.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ env.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ env.AZURE_SUBSCRIPTION_ID }}
- ARM_USE_OIDC: true
-
+ ARM_USE_OIDC: ${{ env.USE_OIDC }}
+ ARM_CLIENT_SECRET: ${{ env.USE_OIDC == 'false' && env.AZURE_CLIENT_SECRET || '' }}
+
+ - name: đ Handle Limited Preview (No Authentication)
+ if: github.event_name == 'pull_request' && !((steps.azure-login-oidc.outcome == 'success') || (steps.azure-login-sp.outcome == 'success'))
+ run: |
+ echo "â ī¸ Limited preview mode - authentication not available" > "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "This may be due to:" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "- Running from a forked repository" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "- Missing 'id-token: write' permissions (for OIDC)" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "- Missing AZURE_CLIENT_SECRET (for Service Principal)" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "- Missing Azure service principal configuration" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "đ§ To enable full preview functionality:" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "Option 1 - OIDC Authentication (Preferred):" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "1. Enable 'id-token: write' permissions in repository settings" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "2. Configure federated identity credentials in Azure AD" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "3. Set secrets: AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "Option 2 - Service Principal Authentication (Fallback):" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "1. Create Azure service principal with appropriate permissions" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "2. Set secrets: AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, AZURE_SUBSCRIPTION_ID" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "Current configuration:" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "- USE_OIDC: ${{ env.USE_OIDC }}" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "- Has CLIENT_SECRET: ${{ env.AZURE_CLIENT_SECRET != '' && 'Yes' || 'No' }}" >> "$GITHUB_WORKSPACE/azd-preview.txt"
+ echo "preview-success=false" >> $GITHUB_OUTPUT
+
- name: đŦ Comment PR with Plan Summary
if: github.event_name == 'pull_request'
uses: actions/github-script@v7
+ continue-on-error: true # Don't fail if permissions are denied
with:
script: |
const fs = require('fs');
@@ -324,18 +440,56 @@ jobs:
**Note:** This is a preview - no actual resources will be created until merged to main.
${previewSection}`;
- await github.rest.issues.createComment({
- issue_number: context.issue.number,
- owner: context.repo.owner,
- repo: context.repo.repo,
- body: output
- });
+ try {
+ await github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body: output
+ });
+ console.log('â
PR comment posted successfully');
+ } catch (error) {
+ console.log('â ī¸ Could not post PR comment (insufficient permissions):', error.message);
+ console.log('đ Preview content would have been:');
+ console.log(output);
+ }
+
+ - name: đ Add Preview to Job Summary
+ if: github.event_name == 'pull_request'
+ run: |
+ echo "## đī¸ Infrastructure Preview" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Environment:** dev (PR preview)" >> $GITHUB_STEP_SUMMARY
+ echo "**Action:** Infrastructure provision preview via Azure Developer CLI" >> $GITHUB_STEP_SUMMARY
+
+ if [ "${{ steps.preview.outputs.preview-success }}" = "true" ]; then
+ echo "**Status:** â
Preview completed successfully" >> $GITHUB_STEP_SUMMARY
+ else
+ echo "**Status:** â ī¸ Preview completed with warnings" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### đ Changes Summary" >> $GITHUB_STEP_SUMMARY
+ echo "- đī¸ Infrastructure changes will be applied via \`azd provision\`" >> $GITHUB_STEP_SUMMARY
+ echo "- đ Application changes will be deployed via \`azd deploy\`" >> $GITHUB_STEP_SUMMARY
+ echo "- đĻ Full deployment available via \`azd up\`" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Note:** This is a preview - no actual resources will be created until merged to main." >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### đ ī¸ AZD Preview Output" >> $GITHUB_STEP_SUMMARY
+ echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
+ if [ -f "azd-preview.txt" ]; then
+ head -n 100 azd-preview.txt >> $GITHUB_STEP_SUMMARY
+ else
+ echo "Azure Developer CLI preview output not available." >> $GITHUB_STEP_SUMMARY
+ fi
+ echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
# ========================================================================
# DEPLOYMENT MODE (for push/dispatch/call)
# ========================================================================
- name: đ Execute AZD Command
- if: github.event_name != 'pull_request'
+ if: github.event_name != 'pull_request' && ((steps.azure-login-oidc.outcome == 'success') || (steps.azure-login-sp.outcome == 'success'))
run: |
ACTION="${{ inputs.action || 'up' }}"
@@ -376,7 +530,8 @@ jobs:
ARM_CLIENT_ID: ${{ env.AZURE_CLIENT_ID }}
ARM_TENANT_ID: ${{ env.AZURE_TENANT_ID }}
ARM_SUBSCRIPTION_ID: ${{ env.AZURE_SUBSCRIPTION_ID }}
- ARM_USE_OIDC: true
+ ARM_USE_OIDC: ${{ env.USE_OIDC }}
+ ARM_CLIENT_SECRET: ${{ env.USE_OIDC == 'false' && env.AZURE_CLIENT_SECRET || '' }}
- name: đ¤ Extract Deployment Outputs
id: extract-outputs
diff --git a/devops/scripts/azd/helpers/generate-env.sh b/devops/scripts/azd/helpers/generate-env.sh
index 0cb2e359..49d152fd 100755
--- a/devops/scripts/azd/helpers/generate-env.sh
+++ b/devops/scripts/azd/helpers/generate-env.sh
@@ -90,12 +90,12 @@ AZURE_OPENAI_CHAT_DEPLOYMENT_VERSION=2024-10-01-preview
# Pool Configuration for Optimal Performance
AOAI_POOL_ENABLED=$(get_azd_value "AOAI_POOL_ENABLED" "true")
-AOAI_POOL_SIZE=$(get_azd_value "AOAI_POOL_SIZE" "50")
-POOL_SIZE_TTS=$(get_azd_value "POOL_SIZE_TTS" "100")
-POOL_SIZE_STT=$(get_azd_value "POOL_SIZE_STT" "100")
+AOAI_POOL_SIZE=$(get_azd_value "AOAI_POOL_SIZE" "5")
+POOL_SIZE_TTS=$(get_azd_value "POOL_SIZE_TTS" "10")
+POOL_SIZE_STT=$(get_azd_value "POOL_SIZE_STT" "10")
TTS_POOL_PREWARMING_ENABLED=$(get_azd_value "TTS_POOL_PREWARMING_ENABLED" "true")
STT_POOL_PREWARMING_ENABLED=$(get_azd_value "STT_POOL_PREWARMING_ENABLED" "true")
-POOL_PREWARMING_BATCH_SIZE=$(get_azd_value "POOL_PREWARMING_BATCH_SIZE" "10")
+POOL_PREWARMING_BATCH_SIZE=$(get_azd_value "POOL_PREWARMING_BATCH_SIZE" "5")
CLIENT_MAX_AGE_SECONDS=$(get_azd_value "CLIENT_MAX_AGE_SECONDS" "3600")
CLEANUP_INTERVAL_SECONDS=$(get_azd_value "CLEANUP_INTERVAL_SECONDS" "180")
diff --git a/infra/terraform/README.md b/infra/terraform/README.md
index 63ac6bd3..cd01742a 100644
--- a/infra/terraform/README.md
+++ b/infra/terraform/README.md
@@ -172,7 +172,7 @@ The **domain endpoint** is specifically used for ACS integration, while the **re
| `location` | Azure region | - | â
|
| `name` | Application base name | `rtaudioagent` | |
| `disable_local_auth` | Use managed identity only | `true` | |
-| `openai_models` | Model deployments | `[gpt-4o]` | |
+| `model_deployments` | Model deployments | `[gpt-4o]` | |
| `redis_sku` | Redis Enterprise SKU | `MemoryOptimized_M10` | |
### đ Container Apps Deployment
@@ -236,7 +236,7 @@ az containerapp create \
| [azurerm_application_insights.main](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/application_insights) | resource |
| [azurerm_cognitive_account.openai](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cognitive_account) | resource |
| [azurerm_cognitive_account.speech](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cognitive_account) | resource |
-| [azurerm_cognitive_deployment.openai_models](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cognitive_deployment) | resource |
+| [azurerm_cognitive_deployment.model_deployments](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/cognitive_deployment) | resource |
| [azurerm_communication_service.main](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/communication_service) | resource |
| [azurerm_container_app.backend](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app) | resource |
| [azurerm_container_app.frontend](https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/container_app) | resource |
@@ -289,7 +289,7 @@ az containerapp create \
| [mongo\_collection\_name](#input\_mongo\_collection\_name) | Name of the MongoDB collection | `string` | `"audioagentcollection"` | no |
| [mongo\_database\_name](#input\_mongo\_database\_name) | Name of the MongoDB database | `string` | `"audioagentdb"` | no |
| [name](#input\_name) | Base name for the real-time audio agent application | `string` | `"rtaudioagent"` | no |
-| [openai\_models](#input\_openai\_models) | Azure OpenAI model deployments | ```list(object({ name = string version = string sku_name = string capacity = number }))``` | ```[ { "capacity": 50, "name": "gpt-4o", "sku_name": "Standard", "version": "2024-11-20" } ]``` | no |
+| [model_deployments](#input_model_deployments) | Azure OpenAI model deployments | ```list(object({ name = string version = string sku_name = string capacity = number }))``` | ```[ { "capacity": 50, "name": "gpt-4o", "sku_name": "Standard", "version": "2024-11-20" } ]``` | no |
| [principal\_id](#input\_principal\_id) | Principal ID of the user or service principal to assign application roles | `string` | `null` | no |
| [principal\_type](#input\_principal\_type) | Type of principal (User or ServicePrincipal) | `string` | `"User"` | no |
| [redis\_port](#input\_redis\_port) | Port for Azure Managed Redis | `number` | `10000` | no |
diff --git a/infra/terraform/ai-foundry.tf b/infra/terraform/ai-foundry.tf
new file mode 100644
index 00000000..c309c8ae
--- /dev/null
+++ b/infra/terraform/ai-foundry.tf
@@ -0,0 +1,25 @@
+module "ai_foundry" {
+ source = "./modules/ai"
+
+ resource_group_name = azurerm_resource_group.main.name
+ location = azurerm_resource_group.main.location
+ tags = local.tags
+
+ disable_local_auth = var.disable_local_auth
+ foundry_account_name = local.resource_names.foundry_account
+ foundry_custom_subdomain_name = local.resource_names.foundry_account
+
+ project_name = local.resource_names.foundry_project
+ project_display_name = local.foundry_project_display
+ project_description = local.foundry_project_desc
+
+ model_deployments = var.model_deployments
+
+ log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
+ account_principal_ids = distinct([
+ azurerm_user_assigned_identity.backend.principal_id,
+ azurerm_user_assigned_identity.frontend.principal_id,
+ azapi_resource.acs.identity[0].principal_id,
+ local.principal_id
+ ])
+}
diff --git a/infra/terraform/ai-services.tf b/infra/terraform/ai-services.tf.back
similarity index 94%
rename from infra/terraform/ai-services.tf
rename to infra/terraform/ai-services.tf.back
index e48e9abe..f08e9afb 100644
--- a/infra/terraform/ai-services.tf
+++ b/infra/terraform/ai-services.tf.back
@@ -2,7 +2,7 @@
# AZURE OPENAI
# ============================================================================
-resource "azurerm_cognitive_account" "openai" {
+resource "azurerm_cognitive_account" "openai" {
name = local.resource_names.openai
location = var.openai_location != null ? var.openai_location : var.location
resource_group_name = azurerm_resource_group.main.name
@@ -16,31 +16,31 @@ resource "azurerm_cognitive_account" "openai" {
local_auth_enabled = !var.disable_local_auth
tags = local.tags
-}
-
-# Diagnostic settings for Azure OpenAI
-resource "azurerm_monitor_diagnostic_setting" "openai_diagnostics" {
- name = "${azurerm_cognitive_account.openai.name}-diagnostics"
- target_resource_id = azurerm_cognitive_account.openai.id
- log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
-
- # Common Cognitive Services categories
- enabled_log {
- category = "Audit"
- }
-
- enabled_log {
- category = "RequestResponse"
- }
-
- enabled_metric {
- category = "AllMetrics"
- }
-}
+}
+
+# Diagnostic settings for Azure OpenAI
+resource "azurerm_monitor_diagnostic_setting" "openai_diagnostics" {
+ name = "${azurerm_cognitive_account.openai.name}-diagnostics"
+ target_resource_id = azurerm_cognitive_account.openai.id
+ log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
+
+ # Common Cognitive Services categories
+ enabled_log {
+ category = "Audit"
+ }
+
+ enabled_log {
+ category = "RequestResponse"
+ }
+
+ enabled_metric {
+ category = "AllMetrics"
+ }
+}
# OpenAI model deployments
-resource "azurerm_cognitive_deployment" "openai_models" {
- for_each = { for idx, model in var.openai_models : model.name => model }
+resource "azurerm_cognitive_deployment" "model_deployments" {
+ for_each = { for idx, model in var.model_deployments : model.name => model }
name = each.value.name
cognitive_account_id = azurerm_cognitive_account.openai.id
@@ -84,7 +84,7 @@ resource "azurerm_key_vault_secret" "openai_key" {
# AZURE SPEECH SERVICES
# ============================================================================
-resource "azurerm_cognitive_account" "speech" {
+resource "azurerm_cognitive_account" "speech" {
name = local.resource_names.speech
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
@@ -98,26 +98,26 @@ resource "azurerm_cognitive_account" "speech" {
#local_auth_enabled = !var.disable_local_auth
tags = local.tags
-}
-
-# Diagnostic settings for Speech Services
-resource "azurerm_monitor_diagnostic_setting" "speech_diagnostics" {
- name = "${azurerm_cognitive_account.speech.name}-diagnostics"
- target_resource_id = azurerm_cognitive_account.speech.id
- log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
-
- enabled_log {
- category = "Audit"
- }
-
- enabled_log {
- category = "RequestResponse"
- }
-
- enabled_metric {
- category = "AllMetrics"
- }
-}
+}
+
+# Diagnostic settings for Speech Services
+resource "azurerm_monitor_diagnostic_setting" "speech_diagnostics" {
+ name = "${azurerm_cognitive_account.speech.name}-diagnostics"
+ target_resource_id = azurerm_cognitive_account.speech.id
+ log_analytics_workspace_id = azurerm_log_analytics_workspace.main.id
+
+ enabled_log {
+ category = "Audit"
+ }
+
+ enabled_log {
+ category = "RequestResponse"
+ }
+
+ enabled_metric {
+ category = "AllMetrics"
+ }
+}
# RBAC assignments for Speech Services
resource "azurerm_role_assignment" "speech_backend_user" {
diff --git a/infra/terraform/communication.tf b/infra/terraform/communication.tf
index efe3dd92..5a7127de 100644
--- a/infra/terraform/communication.tf
+++ b/infra/terraform/communication.tf
@@ -57,13 +57,6 @@ resource "azurerm_key_vault_secret" "acs_connection_string" {
# - Enables real-time STT/TTS operations
# - Required for Call Automation with speech features
#
-resource "azurerm_role_assignment" "acs_speech_user" {
- scope = azurerm_cognitive_account.speech.id
- role_definition_name = "Cognitive Services User"
- principal_id = azapi_resource.acs.identity[0].principal_id
-
-}
-
# ============================================================================
# DIAGNOSTIC SETTINGS FOR AZURE COMMUNICATION SERVICES
@@ -192,12 +185,12 @@ resource "azurerm_monitor_diagnostic_setting" "acs_diagnostics" {
# ============================================================================
resource "azurerm_eventgrid_system_topic" "acs" {
- name = "eg-topic-acs-${local.resource_token}"
- resource_group_name = azurerm_resource_group.main.name
- location = "global"
- source_arm_resource_id = azapi_resource.acs.id
- topic_type = "Microsoft.Communication.CommunicationServices"
- tags = local.tags
+ name = "eg-topic-acs-${local.resource_token}"
+ resource_group_name = azurerm_resource_group.main.name
+ location = "global"
+ source_resource_id = azapi_resource.acs.id
+ topic_type = "Microsoft.Communication.CommunicationServices"
+ tags = local.tags
}
# # Event Grid System Topic Event Subscription for Incoming Calls
diff --git a/infra/terraform/containers.tf b/infra/terraform/containers.tf
index 1578f59f..3f336825 100644
--- a/infra/terraform/containers.tf
+++ b/infra/terraform/containers.tf
@@ -303,22 +303,25 @@ resource "azurerm_container_app" "backend" {
# Azure Speech Services
env {
name = "AZURE_SPEECH_ENDPOINT"
- value = "https://${azurerm_cognitive_account.speech.custom_subdomain_name}.cognitiveservices.azure.com/"
+ value = module.ai_foundry.endpoint
+ # value = "https://${azurerm_cognitive_account.speech.custom_subdomain_name}.cognitiveservices.azure.com/"
}
env {
name = "AZURE_SPEECH_DOMAIN_ENDPOINT"
- value = "https://${azurerm_cognitive_account.speech.custom_subdomain_name}.cognitiveservices.azure.com/"
+ value = module.ai_foundry.openai_endpoint
+ # value = "https://${azurerm_cognitive_account.speech.custom_subdomain_name}.cognitiveservices.azure.com/"
}
env {
name = "AZURE_SPEECH_RESOURCE_ID"
- value = azurerm_cognitive_account.speech.id
+ value = module.ai_foundry.account_id
+ # value = azurerm_cognitive_account.speech.id
}
env {
name = "AZURE_SPEECH_REGION"
- value = azurerm_cognitive_account.speech.location
+ value = module.ai_foundry.location
}
dynamic "env" {
@@ -357,7 +360,7 @@ resource "azurerm_container_app" "backend" {
# Azure OpenAI
env {
name = "AZURE_OPENAI_ENDPOINT"
- value = azurerm_cognitive_account.openai.endpoint
+ value = module.ai_foundry.openai_endpoint
}
env {
diff --git a/infra/terraform/data.tf b/infra/terraform/data.tf
index 069ef414..500a337c 100644
--- a/infra/terraform/data.tf
+++ b/infra/terraform/data.tf
@@ -61,27 +61,6 @@ resource "azurerm_role_assignment" "storage_principal_contributor" {
# ============================================================================
# COSMOS DB (MONGODB API)
# ============================================================================
-# # Cosmos DB vCore MongoDB Cluster (M30 with 128GB disk)
-# resource "azurerm_mongo_cluster" "main" {
-# name = local.resource_names.cosmos
-# resource_group_name = azurerm_resource_group.main.name
-# location = azurerm_resource_group.main.location
-
-# administrator_username = "adminuser"
-# administrator_password = random_password.cosmos_admin.result
-
-# compute_tier = "M30"
-# high_availability_mode = "Disabled"
-# public_network_access = "Enabled"
-# shard_count = 1
-# storage_size_in_gb = 128
-# version = "5.0"
-
-
-
-# tags = local.tags
-# }
-
resource "azapi_resource" "mongoCluster" {
type = "Microsoft.DocumentDB/mongoClusters@2025-04-01-preview"
parent_id = azurerm_resource_group.main.id
diff --git a/infra/terraform/main.tf b/infra/terraform/main.tf
index 5e789d6c..e660e19a 100644
--- a/infra/terraform/main.tf
+++ b/infra/terraform/main.tf
@@ -70,12 +70,13 @@ locals {
# Common tags
tags = {
- "azd-env-name" = var.environment_name
- "hidden-title" = "Real Time Audio ${var.environment_name}"
- "project" = "gbb-ai-audio-agent"
- "environment" = var.environment_name
- "deployment" = "terraform"
- "deployed_by" = coalesce(var.deployed_by, local.principal_id)
+ "azd-env-name" = var.environment_name
+ "hidden-title" = "Real Time Audio ${var.environment_name}"
+ "project" = "gbb-ai-audio-agent"
+ "environment" = var.environment_name
+ "deployment" = "terraform"
+ "deployed_by" = coalesce(var.deployed_by, local.principal_id)
+ "SecurityControl" = "Ignore"
}
# Resource naming with Azure standard abbreviations
@@ -94,5 +95,10 @@ locals {
log_analytics = "log-${local.resource_token}"
app_insights = "ai-${local.resource_token}"
container_env = "cae-${var.name}-${var.environment_name}-${local.resource_token}"
+ foundry_account = "aif${var.name}${var.environment_name}"
+ foundry_project = "aif${var.name}${var.environment_name}proj"
}
+
+ foundry_project_display = "AI Foundry ${var.environment_name}"
+ foundry_project_desc = "AI Foundry project for ${var.environment_name} environment"
}
diff --git a/infra/terraform/modules/ai/foundry.tf b/infra/terraform/modules/ai/foundry.tf
new file mode 100644
index 00000000..499e1276
--- /dev/null
+++ b/infra/terraform/modules/ai/foundry.tf
@@ -0,0 +1,116 @@
+# Terraform module for provisioning Azure AI Foundry aligned with the ai-services deployment.
+
+locals {
+ account_name_raw = lower(trimspace(var.foundry_account_name))
+ custom_subdomain_name_raw = var.foundry_custom_subdomain_name != null && trimspace(var.foundry_custom_subdomain_name) != "" ? lower(trimspace(var.foundry_custom_subdomain_name)) : local.account_name_raw
+
+ project_name_raw = var.project_name != null && trimspace(var.project_name) != "" ? lower(trimspace(var.project_name)) : "${local.account_name_raw}-project"
+
+ project_display_name_raw = var.project_display_name != null && trimspace(var.project_display_name) != "" ? trimspace(var.project_display_name) : local.project_name_raw
+
+ project_description_raw = var.project_description != null && trimspace(var.project_description) != "" ? trimspace(var.project_description) : "Azure AI Foundry project ${local.project_display_name_raw}"
+
+ project_id_guid = "${substr(azapi_resource.ai_foundry_project.output.properties.internalId, 0, 8)}-${substr(azapi_resource.ai_foundry_project.output.properties.internalId, 8, 4)}-${substr(azapi_resource.ai_foundry_project.output.properties.internalId, 12, 4)}-${substr(azapi_resource.ai_foundry_project.output.properties.internalId, 16, 4)}-${substr(azapi_resource.ai_foundry_project.output.properties.internalId, 20, 12)}"
+
+ account_principal_map = { for idx, pid in tolist(nonsensitive(var.account_principal_ids)) : idx => pid if pid != null && pid != "" }
+}
+
+data "azurerm_resource_group" "rg" {
+ name = var.resource_group_name
+}
+
+resource "azapi_resource" "ai_foundry_account" {
+ type = "Microsoft.CognitiveServices/accounts@2025-06-01"
+ name = local.account_name_raw
+ parent_id = data.azurerm_resource_group.rg.id
+ location = var.location
+ schema_validation_enabled = false
+ tags = var.tags
+
+ body = {
+ kind = "AIServices"
+ sku = {
+ name = var.foundry_sku_name
+ }
+ identity = {
+ type = "SystemAssigned"
+ }
+ properties = {
+ allowProjectManagement = true
+ disableLocalAuth = var.disable_local_auth
+ customSubDomainName = local.custom_subdomain_name_raw
+ }
+ }
+}
+
+resource "azurerm_monitor_diagnostic_setting" "ai_foundry_account" {
+ count = var.log_analytics_workspace_id != null && var.log_analytics_workspace_id != "" ? 1 : 0
+ name = "${local.account_name_raw}-diagnostics"
+ target_resource_id = azapi_resource.ai_foundry_account.id
+ log_analytics_workspace_id = var.log_analytics_workspace_id
+
+ enabled_log {
+ category = "Audit"
+ }
+
+ enabled_log {
+ category = "RequestResponse"
+ }
+
+ enabled_metric {
+ category = "AllMetrics"
+ }
+}
+
+resource "azurerm_cognitive_deployment" "model" {
+ for_each = { for deployment in var.model_deployments : deployment.name => deployment }
+
+ name = each.value.name
+ cognitive_account_id = azapi_resource.ai_foundry_account.id
+
+ sku {
+ name = each.value.sku_name
+ capacity = each.value.capacity
+ }
+
+ model {
+ format = "OpenAI"
+ name = each.value.name
+ version = each.value.version
+ }
+}
+
+resource "azapi_resource" "ai_foundry_project" {
+ type = "Microsoft.CognitiveServices/accounts/projects@2025-06-01"
+ name = local.project_name_raw
+ parent_id = azapi_resource.ai_foundry_account.id
+ location = var.location
+ schema_validation_enabled = false
+
+ body = {
+
+ identity = {
+ type = "SystemAssigned"
+ }
+ sku = {
+ name = var.project_sku_name
+ }
+ properties = {
+ displayName = local.project_display_name_raw
+ description = local.project_description_raw
+ }
+ }
+ response_export_values = [
+ "identity.principalId",
+ "properties.internalId"
+ ]
+}
+
+resource "azurerm_role_assignment" "ai_foundry_account" {
+ for_each = local.account_principal_map
+
+ scope = azapi_resource.ai_foundry_account.id
+ role_definition_name = var.account_principal_role_definition_name
+ principal_id = each.value
+}
+
diff --git a/infra/terraform/modules/ai/outputs.tf b/infra/terraform/modules/ai/outputs.tf
new file mode 100644
index 00000000..7b25fd13
--- /dev/null
+++ b/infra/terraform/modules/ai/outputs.tf
@@ -0,0 +1,34 @@
+output "account_id" {
+ description = "Resource ID of the AI Foundry account."
+ value = azapi_resource.ai_foundry_account.id
+}
+
+output "endpoint" {
+ description = "Endpoint for the AI Foundry account. Use this endpoint for Speech services, Voice Live, Doc Intel, etc."
+ value = try(azapi_resource.ai_foundry_account.output.properties.endpoint, null)
+}
+
+output "openai_endpoint" {
+ description = "Endpoint for the AI Foundry account. Use this endpoint for OpenAI services."
+ value = try(azapi_resource.ai_foundry_account.output.properties.endpoints["OpenAI Language Model Instance API"], null)
+}
+
+output "project_id" {
+ description = "Resource ID of the AI Foundry project."
+ value = azapi_resource.ai_foundry_project.id
+}
+
+output "project_name" {
+ description = "Name of the AI Foundry project."
+ value = azapi_resource.ai_foundry_project.name
+}
+
+output "project_identity_principal_id" {
+ description = "Principal ID of the AI Foundry project managed identity."
+ value = try(azapi_resource.ai_foundry_project.output.identity.principalId, null)
+}
+
+output "location" {
+ description = "Azure region of the AI Foundry account."
+ value = var.location
+}
\ No newline at end of file
diff --git a/infra/terraform/modules/ai/project_capability_host.tf b/infra/terraform/modules/ai/project_capability_host.tf
new file mode 100644
index 00000000..e33eaeb8
--- /dev/null
+++ b/infra/terraform/modules/ai/project_capability_host.tf
@@ -0,0 +1,129 @@
+locals {
+ create_ai_foundry_capability_host = (
+ var.ai_search_id != null &&
+ var.storage_account_id != null &&
+ var.cosmosdb_account_id != null
+ )
+}
+
+## Create the AI Foundry project capability host (only when required IDs provided)
+##
+resource "azapi_resource" "ai_foundry_project_capability_host" {
+ count = local.create_ai_foundry_capability_host ? 1 : 0
+
+ depends_on = [
+ azapi_resource.conn_aisearch,
+ azapi_resource.conn_cosmosdb,
+ azapi_resource.conn_storage,
+ time_sleep.wait_rbac
+ ]
+ type = "Microsoft.CognitiveServices/accounts/projects/capabilityHosts@2025-04-01-preview"
+ name = "caphostproj"
+ parent_id = azapi_resource.ai_foundry_project.id
+ schema_validation_enabled = false
+
+ body = {
+ properties = {
+ capabilityHostKind = "Agents"
+ vectorStoreConnections = [
+ local.aisearch_name_from_id
+ ]
+ storageConnections = [
+ local.storage_name_from_id
+ ]
+ threadStorageConnections = [
+ local.cosmos_name_from_id
+ ]
+ }
+ }
+}
+
+
+## Create the necessary data plane role assignments to the CosmosDb databases created by the AI Foundry Project
+##
+resource "azurerm_cosmosdb_sql_role_assignment" "cosmosdb_db_sql_role_aifp_user_thread_message_store" {
+ count = local.create_ai_foundry_capability_host ? 1 : 0
+
+ depends_on = [
+ azapi_resource.ai_foundry_project_capability_host
+ ]
+ name = uuidv5("dns", "${azapi_resource.ai_foundry_project.name}${azapi_resource.ai_foundry_project.output.identity.principalId}userthreadmessage_dbsqlrole")
+ resource_group_name = var.resource_group_name
+ account_name = local.cosmos_name_from_id
+ scope = "${local.cosmos_name_from_id}/dbs/enterprise_memory/colls/${local.project_id_guid}-thread-message-store"
+ role_definition_id = "${local.cosmos_name_from_id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002"
+ principal_id = azapi_resource.ai_foundry_project.output.identity.principalId
+}
+
+resource "azurerm_cosmosdb_sql_role_assignment" "cosmosdb_db_sql_role_aifp_system_thread_name" {
+ count = local.create_ai_foundry_capability_host ? 1 : 0
+
+ depends_on = [
+ azurerm_cosmosdb_sql_role_assignment.cosmosdb_db_sql_role_aifp_user_thread_message_store
+ ]
+ name = uuidv5("dns", "${azapi_resource.ai_foundry_project.name}${azapi_resource.ai_foundry_project.output.identity.principalId}systemthread_dbsqlrole")
+ resource_group_name = var.resource_group_name
+ account_name = local.cosmos_name_from_id
+ scope = "${var.cosmosdb_account_id}/dbs/enterprise_memory/colls/${local.project_id_guid}-system-thread-message-store"
+ role_definition_id = "${var.cosmosdb_account_id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002"
+ principal_id = azapi_resource.ai_foundry_project.output.identity.principalId
+}
+
+resource "azurerm_cosmosdb_sql_role_assignment" "cosmosdb_db_sql_role_aifp_entity_store_name" {
+ count = local.create_ai_foundry_capability_host ? 1 : 0
+
+ depends_on = [
+ azurerm_cosmosdb_sql_role_assignment.cosmosdb_db_sql_role_aifp_system_thread_name
+ ]
+ name = uuidv5("dns", "${azapi_resource.ai_foundry_project.name}${azapi_resource.ai_foundry_project.output.identity.principalId}entitystore_dbsqlrole")
+ resource_group_name = var.resource_group_name
+ account_name = local.cosmos_name_from_id
+ scope = "${var.cosmosdb_account_id}/dbs/enterprise_memory/colls/${local.project_id_guid}-agent-entity-store"
+ role_definition_id = "${var.cosmosdb_account_id}/sqlRoleDefinitions/00000000-0000-0000-0000-000000000002"
+ principal_id = azapi_resource.ai_foundry_project.output.identity.principalId
+}
+
+## Create the necessary data plane role assignments to the Azure Storage Account containers created by the AI Foundry Project
+##
+resource "azurerm_role_assignment" "storage_blob_data_owner_ai_foundry_project" {
+ count = local.create_ai_foundry_capability_host ? 1 : 0
+
+ depends_on = [
+ azapi_resource.ai_foundry_project_capability_host
+ ]
+ name = uuidv5("dns", "${azapi_resource.ai_foundry_project.name}${azapi_resource.ai_foundry_project.output.identity.principalId}${local.storage_name_from_id}storageblobdataowner")
+ scope = local.storage_name_from_id
+ role_definition_name = "Storage Blob Data Owner"
+ principal_id = azapi_resource.ai_foundry_project.output.identity.principalId
+ condition_version = "2.0"
+ condition = <<-EOT
+ (
+ (
+ !(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/read'})
+ AND !(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/filter/action'})
+ AND !(ActionMatches{'Microsoft.Storage/storageAccounts/blobServices/containers/blobs/tags/write'})
+ )
+ OR
+ (@Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringStartsWithIgnoreCase '${local.project_id_guid}'
+ AND @Resource[Microsoft.Storage/storageAccounts/blobServices/containers:name] StringLikeIgnoreCase '*-azureml-agent')
+ )
+ EOT
+}
+
+# ## Added AI Foundry account purger to avoid running into InUseSubnetCannotBeDeleted-lock caused by the agent subnet delegation.
+# ## The azapi_resource_action.purge_ai_foundry (only gets executed during destroy) purges the AI foundry account removing /subnets/snet-agent/serviceAssociationLinks/legionservicelink so the agent subnet can get properly removed.
+
+# resource "azapi_resource_action" "purge_ai_foundry" {
+# method = "DELETE"
+# resource_id = "/subscriptions/${data.azurerm_client_config.current.subscription_id}/providers/Microsoft.CognitiveServices/locations/${azurerm_resource_group.rg.location}/resourceGroups/${var.resource_group_name}/deletedAccounts/aifoundry${random_string.unique.result}"
+# type = "Microsoft.Resources/resourceGroups/deletedAccounts@2021-04-30"
+# when = "destroy"
+
+# depends_on = [time_sleep.purge_ai_foundry_cooldown]
+# }
+
+# resource "time_sleep" "purge_ai_foundry_cooldown" {
+# destroy_duration = "900s" # 10-15m is enough time to let the backend remove the /subnets/snet-agent/serviceAssociationLinks/legionservicelink
+
+# depends_on = [azurerm_subnet.subnet_agent]
+# }
\ No newline at end of file
diff --git a/infra/terraform/modules/ai/project_connections.tf b/infra/terraform/modules/ai/project_connections.tf
new file mode 100644
index 00000000..a7c92400
--- /dev/null
+++ b/infra/terraform/modules/ai/project_connections.tf
@@ -0,0 +1,170 @@
+
+## Wait 10 seconds for the AI Foundry project system-assigned managed identity to be created and to replicate
+## through Entra ID
+resource "time_sleep" "wait_project_identities" {
+ depends_on = [
+ azapi_resource.ai_foundry_project
+ ]
+ create_duration = "10s"
+}
+## Create AI Foundry project connections (only if id and endpoint vars are provided)
+locals {
+ cosmos_name_from_id = ((var.cosmosdb_account_id != null && var.cosmosdb_account_id != "")
+ ? try(element(split("/", var.cosmosdb_account_id), length(split("/", var.cosmosdb_account_id)) - 1), "")
+ : "")
+
+ storage_name_from_id = ((var.storage_account_id != null && var.storage_account_id != "")
+ ? try(element(split("/", var.storage_account_id), length(split("/", var.storage_account_id)) - 1), "")
+ : "")
+
+ aisearch_name_from_id = ((var.ai_search_id != null && var.ai_search_id != "")
+ ? try(element(split("/", var.ai_search_id), length(split("/", var.ai_search_id)) - 1), "")
+ : "")
+}
+
+resource "azapi_resource" "conn_cosmosdb" {
+ count = (var.cosmosdb_account_id != null && var.cosmosdb_account_id != "" && var.cosmosdb_account_endpoint != null && var.cosmosdb_account_endpoint != "") ? 1 : 0
+ type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-06-01"
+ name = local.cosmos_name_from_id
+ parent_id = azapi_resource.ai_foundry_project.id
+ schema_validation_enabled = false
+
+ depends_on = [
+ azapi_resource.ai_foundry_project
+ ]
+
+ body = {
+ name = local.cosmos_name_from_id
+ properties = {
+ category = "CosmosDb"
+ target = var.cosmosdb_account_endpoint
+ authType = "AAD"
+ metadata = {
+ ApiType = "Azure"
+ ResourceId = var.cosmosdb_account_id
+ location = var.location
+ }
+ }
+ }
+}
+
+resource "azapi_resource" "conn_storage" {
+ count = (var.storage_account_id != null && var.storage_account_id != "" && var.storage_account_primary_blob_endpoint != null && var.storage_account_primary_blob_endpoint != "") ? 1 : 0
+ type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-06-01"
+ name = local.storage_name_from_id
+ parent_id = azapi_resource.ai_foundry_project.id
+ schema_validation_enabled = false
+
+ depends_on = [
+ azapi_resource.ai_foundry_project
+ ]
+
+ body = {
+ name = local.storage_name_from_id
+ properties = {
+ category = "AzureStorageAccount"
+ target = var.storage_account_primary_blob_endpoint
+ authType = "AAD"
+ metadata = {
+ ApiType = "Azure"
+ ResourceId = var.storage_account_id
+ location = var.location
+ }
+ }
+ }
+
+ response_export_values = [
+ "identity.principalId"
+ ]
+}
+
+resource "azapi_resource" "conn_aisearch" {
+ count = (var.ai_search_id != null && var.ai_search_id != "" && var.ai_search_endpoint != null && var.ai_search_endpoint != "") ? 1 : 0
+ type = "Microsoft.CognitiveServices/accounts/projects/connections@2025-06-01"
+ name = local.aisearch_name_from_id
+ parent_id = azapi_resource.ai_foundry_project.id
+ schema_validation_enabled = false
+
+ depends_on = [
+ azapi_resource.ai_foundry_project
+ ]
+
+ body = {
+ name = local.aisearch_name_from_id
+ properties = {
+ category = "CognitiveSearch"
+ target = var.ai_search_endpoint
+ authType = "AAD"
+ metadata = {
+ ApiType = "Azure"
+ ApiVersion = "2025-05-01-preview"
+ ResourceId = var.ai_search_id
+ location = var.location
+ }
+ }
+ }
+
+ response_export_values = [
+ "identity.principalId"
+ ]
+}
+
+resource "azurerm_role_assignment" "cosmosdb_operator_ai_foundry_project" {
+ count = (var.cosmosdb_account_id != null && var.cosmosdb_account_id != "") ? 1 : 0
+
+ depends_on = [
+ resource.time_sleep.wait_project_identities
+ ]
+ name = uuidv5("dns", "${azapi_resource.ai_foundry_project.name}${azapi_resource.ai_foundry_project.output.identity.principalId}${local.cosmos_name_from_id}cosmosdboperator")
+ scope = var.cosmosdb_account_id
+ role_definition_name = "Cosmos DB Operator"
+ principal_id = azapi_resource.ai_foundry_project.output.identity.principalId
+}
+
+resource "azurerm_role_assignment" "storage_blob_data_contributor_ai_foundry_project" {
+ count = (var.storage_account_id != null && var.storage_account_id != "") ? 1 : 0
+
+ depends_on = [
+ resource.time_sleep.wait_project_identities
+ ]
+ name = uuidv5("dns", "${azapi_resource.ai_foundry_project.name}${azapi_resource.ai_foundry_project.output.identity.principalId}${local.storage_name_from_id}storageblobdatacontributor")
+ scope = var.storage_account_id
+ role_definition_name = "Storage Blob Data Contributor"
+ principal_id = azapi_resource.ai_foundry_project.output.identity.principalId
+}
+
+resource "azurerm_role_assignment" "search_index_data_contributor_ai_foundry_project" {
+ count = (var.ai_search_id != null && var.ai_search_id != "") ? 1 : 0
+
+ depends_on = [
+ resource.time_sleep.wait_project_identities
+ ]
+ name = uuidv5("dns", "${azapi_resource.ai_foundry_project.name}${azapi_resource.ai_foundry_project.output.identity.principalId}${local.aisearch_name_from_id}searchindexdatacontributor")
+ scope = var.ai_search_id
+ role_definition_name = "Search Index Data Contributor"
+ principal_id = azapi_resource.ai_foundry_project.output.identity.principalId
+}
+
+resource "azurerm_role_assignment" "search_service_contributor_ai_foundry_project" {
+ count = (var.ai_search_id != null && var.ai_search_id != "") ? 1 : 0
+
+ depends_on = [
+ resource.time_sleep.wait_project_identities
+ ]
+ name = uuidv5("dns", "${azapi_resource.ai_foundry_project.name}${azapi_resource.ai_foundry_project.output.identity.principalId}${local.aisearch_name_from_id}searchservicecontributor")
+ scope = var.ai_search_id
+ role_definition_name = "Search Service Contributor"
+ principal_id = azapi_resource.ai_foundry_project.output.identity.principalId
+}
+
+## Pause 60 seconds to allow for role assignments to propagate
+##
+resource "time_sleep" "wait_rbac" {
+ depends_on = [
+ azurerm_role_assignment.cosmosdb_operator_ai_foundry_project,
+ azurerm_role_assignment.storage_blob_data_contributor_ai_foundry_project,
+ azurerm_role_assignment.search_index_data_contributor_ai_foundry_project,
+ azurerm_role_assignment.search_service_contributor_ai_foundry_project
+ ]
+ create_duration = "60s"
+}
diff --git a/infra/terraform/modules/ai/providers.tf b/infra/terraform/modules/ai/providers.tf
new file mode 100644
index 00000000..030715df
--- /dev/null
+++ b/infra/terraform/modules/ai/providers.tf
@@ -0,0 +1,19 @@
+terraform {
+ required_providers {
+ azurerm = {
+ source = "hashicorp/azurerm"
+ version = "~> 4.0"
+ }
+ azapi = {
+ source = "Azure/azapi"
+ version = "~> 2.0"
+ }
+ }
+}
+
+provider "azurerm" {
+ features {}
+ storage_use_azuread = true
+}
+
+provider "azapi" {}
diff --git a/infra/terraform/modules/ai/variables.tf b/infra/terraform/modules/ai/variables.tf
new file mode 100644
index 00000000..3c357ff8
--- /dev/null
+++ b/infra/terraform/modules/ai/variables.tf
@@ -0,0 +1,140 @@
+variable "resource_group_name" {
+ description = "Resource group name (used for diagnostics configuration)."
+ type = string
+}
+
+variable "location" {
+ description = "Azure region for AI Foundry resources."
+ type = string
+}
+
+variable "tags" {
+ description = "Tags to inherit from the core deployment."
+ type = map(string)
+ default = {}
+}
+
+variable "disable_local_auth" {
+ description = "Disable local (key-based) authentication for the AI Foundry account."
+ type = bool
+ default = true
+}
+
+variable "foundry_account_name" {
+ description = "Name for the Azure AI Foundry account (3-24 lowercase alphanumeric characters)."
+ type = string
+ validation {
+ condition = length(trimspace(var.foundry_account_name)) >= 3 && length(trimspace(var.foundry_account_name)) <= 24
+ error_message = "Foundry account name must be between 3 and 24 characters."
+ }
+}
+
+variable "foundry_custom_subdomain_name" {
+ description = "Optional custom subdomain for the AI Foundry endpoint. Defaults to the account name."
+ type = string
+ default = null
+}
+
+variable "foundry_sku_name" {
+ description = "SKU for the AI Foundry account."
+ type = string
+ default = "S0"
+}
+
+variable "project_name" {
+ description = "Name for the AI Foundry project. Defaults to -project."
+ type = string
+ default = null
+}
+
+variable "project_display_name" {
+ description = "Display name for the AI Foundry project."
+ type = string
+ default = null
+}
+
+variable "project_description" {
+ description = "Description for the AI Foundry project."
+ type = string
+ default = null
+}
+
+variable "project_sku_name" {
+ description = "SKU for the AI Foundry project."
+ type = string
+ default = "S0"
+}
+
+variable "model_deployments" {
+ description = "Model deployments to create within the AI Foundry account."
+ type = list(object({
+ name = string
+ version = string
+ sku_name = string
+ capacity = number
+ }))
+ default = [
+ {
+ name = "gpt-4o"
+ version = "2024-11-20"
+ sku_name = "GlobalStandard"
+ capacity = 1
+ }
+ ]
+}
+
+variable "log_analytics_workspace_id" {
+ description = "Optional Log Analytics workspace ID used for diagnostics."
+ type = string
+ default = null
+}
+
+variable "account_principal_ids" {
+ description = "Principal IDs to assign Cognitive Services access to the AI Foundry account."
+ type = list(string)
+ default = []
+}
+
+variable "account_principal_role_definition_name" {
+ description = "Role definition to use for AI Foundry account assignments (defaults to Cognitive Services User)."
+ type = string
+ default = "Cognitive Services User"
+}
+
+# ============================================================================
+
+variable "cosmosdb_account_id" {
+ description = "Optional Cosmos DB account ID for AI Foundry to use for storage."
+ type = string
+ default = null
+}
+
+variable "cosmosdb_account_endpoint" {
+ description = "Optional Cosmos DB account endpoint for AI Foundry to use for storage."
+ type = string
+ default = null
+}
+
+variable "storage_account_id" {
+ description = "Optional Storage account ID for AI Foundry to use for storage."
+ type = string
+ default = null
+}
+
+variable "storage_account_primary_blob_endpoint" {
+ description = "Optional Storage account primary blob endpoint for AI Foundry to use for storage."
+ type = string
+ default = null
+}
+
+variable "ai_search_id" {
+ description = "Optional Azure AI Search resource ID for AI Foundry to use for search capabilities."
+ type = string
+ default = null
+}
+
+variable "ai_search_endpoint" {
+ description = "Optional Azure AI Search resource endpoint for AI Foundry to use for search capabilities."
+ type = string
+ default = null
+}
\ No newline at end of file
diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf
index 89121567..aa226f0a 100644
--- a/infra/terraform/outputs.tf
+++ b/infra/terraform/outputs.tf
@@ -19,7 +19,7 @@ output "AZURE_LOCATION" {
# AI Services
output "AZURE_OPENAI_ENDPOINT" {
description = "Azure OpenAI endpoint"
- value = azurerm_cognitive_account.openai.endpoint
+ value = module.ai_foundry.openai_endpoint
}
output "AZURE_OPENAI_CHAT_DEPLOYMENT_ID" {
@@ -32,29 +32,19 @@ output "AZURE_OPENAI_API_VERSION" {
value = "2025-01-01-preview"
}
-output "AZURE_OPENAI_RESOURCE_ID" {
- description = "Azure OpenAI resource ID"
- value = azurerm_cognitive_account.openai.id
-}
-
output "AZURE_SPEECH_ENDPOINT" {
description = "Azure Speech Services endpoint"
- value = azurerm_cognitive_account.speech.endpoint
+ value = module.ai_foundry.endpoint
}
output "AZURE_SPEECH_RESOURCE_ID" {
description = "Azure Speech Services resource ID"
- value = azurerm_cognitive_account.speech.id
+ value = module.ai_foundry.account_id
}
output "AZURE_SPEECH_REGION" {
- description = "Azure Speech Services region"
- value = azurerm_cognitive_account.speech.location
-}
-
-output "AZURE_SPEECH_DOMAIN_ENDPOINT" {
- description = "Azure Speech Services domain endpoint for ACS integration"
- value = "https://${azurerm_cognitive_account.speech.custom_subdomain_name}.cognitiveservices.azure.com/"
+ description = "Azure Speech Services location"
+ value = module.ai_foundry.location
}
# Communication Services
@@ -215,3 +205,24 @@ output "REDIS_SKU_OPTIMIZED" {
description = "Redis Enterprise SKU for optimal performance"
value = var.redis_sku
}
+
+
+output "ai_foundry_account_id" {
+ description = "Resource ID of the AI Foundry account"
+ value = module.ai_foundry.account_id
+}
+
+output "ai_foundry_account_endpoint" {
+ description = "Endpoint URI for the AI Foundry account"
+ value = module.ai_foundry.endpoint
+}
+
+output "ai_foundry_project_id" {
+ description = "Resource ID of the AI Foundry project"
+ value = module.ai_foundry.project_id
+}
+
+output "ai_foundry_project_identity_principal_id" {
+ description = "Managed identity principal ID assigned to the AI Foundry project"
+ value = module.ai_foundry.project_identity_principal_id
+}
diff --git a/infra/terraform/params/main.tfvars.dev.json b/infra/terraform/params/main.tfvars.dev.json
index fb3edbd6..dd10cc66 100644
--- a/infra/terraform/params/main.tfvars.dev.json
+++ b/infra/terraform/params/main.tfvars.dev.json
@@ -10,7 +10,7 @@
"enable_redis_ha": false,
"redis_sku": "MemoryOptimized_M10",
"redis_port": 10000,
- "openai_models": [
+ "model_deployments": [
{
"name": "o3-mini",
"version": "2025-01-31",
diff --git a/infra/terraform/params/main.tfvars.prod.json b/infra/terraform/params/main.tfvars.prod.json
index 86f58978..a867d8b1 100644
--- a/infra/terraform/params/main.tfvars.prod.json
+++ b/infra/terraform/params/main.tfvars.prod.json
@@ -10,7 +10,7 @@
"enable_redis_ha": false,
"redis_sku": "MemoryOptimized_M10",
"redis_port": 10000,
- "openai_models": [
+ "model_deployments": [
{
"name": "o3-mini",
"version": "2025-01-31",
diff --git a/infra/terraform/params/main.tfvars.staging.json b/infra/terraform/params/main.tfvars.staging.json
index fc05ac29..b92fde24 100644
--- a/infra/terraform/params/main.tfvars.staging.json
+++ b/infra/terraform/params/main.tfvars.staging.json
@@ -10,7 +10,7 @@
"enable_redis_ha": false,
"redis_sku": "MemoryOptimized_M10",
"redis_port": 10000,
- "openai_models": [
+ "model_deployments": [
{
"name": "o3-mini",
"version": "2025-01-31",
diff --git a/infra/terraform/terraform.tfvars.example b/infra/terraform/terraform.tfvars.example
index bda894ba..c5ffc892 100644
--- a/infra/terraform/terraform.tfvars.example
+++ b/infra/terraform/terraform.tfvars.example
@@ -21,7 +21,7 @@ disable_local_auth = true
redis_sku = "MemoryOptimized_M10"
# OpenAI model deployments
-openai_models = [
+model_deployments = [
{
name = "gpt-4o"
version = "2024-11-20"
diff --git a/infra/terraform/variables.tf b/infra/terraform/variables.tf
index 5bf9eb49..594a926b 100644
--- a/infra/terraform/variables.tf
+++ b/infra/terraform/variables.tf
@@ -122,7 +122,7 @@ variable "redis_port" {
default = 10000
}
-variable "openai_models" {
+variable "model_deployments" {
description = "Azure OpenAI model deployments optimized for high performance"
type = list(object({
name = string