From 64a9af9765da34488456facdbd0648500031bf6b Mon Sep 17 00:00:00 2001 From: Jin Lee Date: Fri, 26 Sep 2025 12:02:36 -0500 Subject: [PATCH 1/7] Add AI Foundry module and integrate Azure OpenAI and Speech Services resources --- infra/terraform/ai-foundry.tf | 54 ++++++ .../{ai-services.tf => ai-services.tf.back} | 0 infra/terraform/communication.tf | 9 +- infra/terraform/containers.tf | 15 +- infra/terraform/main.tf | 1 + infra/terraform/modules/ai/foundry.tf | 116 ++++++++++++ infra/terraform/modules/ai/outputs.tf | 34 ++++ .../modules/ai/project_capability_host.tf | 129 +++++++++++++ .../modules/ai/project_connections.tf | 170 ++++++++++++++++++ infra/terraform/modules/ai/providers.tf | 19 ++ infra/terraform/modules/ai/variables.tf | 140 +++++++++++++++ infra/terraform/outputs.tf | 21 +-- 12 files changed, 679 insertions(+), 29 deletions(-) create mode 100644 infra/terraform/ai-foundry.tf rename infra/terraform/{ai-services.tf => ai-services.tf.back} (100%) create mode 100644 infra/terraform/modules/ai/foundry.tf create mode 100644 infra/terraform/modules/ai/outputs.tf create mode 100644 infra/terraform/modules/ai/project_capability_host.tf create mode 100644 infra/terraform/modules/ai/project_connections.tf create mode 100644 infra/terraform/modules/ai/providers.tf create mode 100644 infra/terraform/modules/ai/variables.tf diff --git a/infra/terraform/ai-foundry.tf b/infra/terraform/ai-foundry.tf new file mode 100644 index 00000000..67184989 --- /dev/null +++ b/infra/terraform/ai-foundry.tf @@ -0,0 +1,54 @@ +# AI Foundry module wiring for the core deployment + +locals { + foundry_name_seed = lower(replace("aif${var.name}${var.environment_name}", "-", "")) + foundry_name_prefix = substr(local.foundry_name_seed, 0, 16) + foundry_account_name = substr("${local.foundry_name_prefix}${local.resource_token}", 0, 24) + foundry_project_name = substr("${local.foundry_account_name}proj", 0, 24) + foundry_project_display = "AI Foundry ${var.environment_name}" + foundry_project_desc = "AI Foundry project for ${var.environment_name} environment" +} + +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.foundry_account_name + foundry_custom_subdomain_name = local.foundry_account_name + + project_name = local.foundry_project_name + project_display_name = local.foundry_project_display + project_description = local.foundry_project_desc + + 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 + ]) +} + +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/ai-services.tf b/infra/terraform/ai-services.tf.back similarity index 100% rename from infra/terraform/ai-services.tf rename to infra/terraform/ai-services.tf.back diff --git a/infra/terraform/communication.tf b/infra/terraform/communication.tf index efe3dd92..f653be88 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 @@ -195,7 +188,7 @@ 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 + source_resource_id = azapi_resource.acs.id topic_type = "Microsoft.Communication.CommunicationServices" tags = local.tags } diff --git a/infra/terraform/containers.tf b/infra/terraform/containers.tf index 1578f59f..fe320c25 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/main.tf b/infra/terraform/main.tf index 5e789d6c..cfeb4514 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -76,6 +76,7 @@ locals { "environment" = var.environment_name "deployment" = "terraform" "deployed_by" = coalesce(var.deployed_by, local.principal_id) + "SecurityControl" = "Ignore" } # Resource naming with Azure standard abbreviations diff --git a/infra/terraform/modules/ai/foundry.tf b/infra/terraform/modules/ai/foundry.tf new file mode 100644 index 00000000..6be4ef81 --- /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..858b27db --- /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..c353f56e --- /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..7e7feddf 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -19,7 +19,8 @@ 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 + # value = azurerm_cognitive_account.openai.endpoint } output "AZURE_OPENAI_CHAT_DEPLOYMENT_ID" { @@ -32,29 +33,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 From 2ab76fa747b763cb5357e7fe00f4284b2de59e99 Mon Sep 17 00:00:00 2001 From: Jin Lee Date: Fri, 26 Sep 2025 12:20:19 -0500 Subject: [PATCH 2/7] pre-commit formatting/docs --- devops/scripts/azd/helpers/generate-env.sh | 8 +- infra/terraform/README.md | 6 +- infra/terraform/ai-foundry.tf | 39 +---- infra/terraform/ai-services.tf.back | 90 +++++------ infra/terraform/communication.tf | 12 +- infra/terraform/containers.tf | 2 +- infra/terraform/data.tf | 21 --- infra/terraform/main.tf | 17 +- infra/terraform/modules/ai/foundry.tf | 2 +- .../modules/ai/project_capability_host.tf | 150 +++++++++--------- .../modules/ai/project_connections.tf | 18 +-- infra/terraform/outputs.tf | 21 +++ infra/terraform/params/main.tfvars.dev.json | 2 +- infra/terraform/params/main.tfvars.prod.json | 2 +- .../terraform/params/main.tfvars.staging.json | 2 +- infra/terraform/terraform.tfvars.example | 2 +- infra/terraform/variables.tf | 2 +- 17 files changed, 186 insertions(+), 210 deletions(-) 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..83a886a1 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 | +| [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 | | [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 index 67184989..c309c8ae 100644 --- a/infra/terraform/ai-foundry.tf +++ b/infra/terraform/ai-foundry.tf @@ -1,14 +1,3 @@ -# AI Foundry module wiring for the core deployment - -locals { - foundry_name_seed = lower(replace("aif${var.name}${var.environment_name}", "-", "")) - foundry_name_prefix = substr(local.foundry_name_seed, 0, 16) - foundry_account_name = substr("${local.foundry_name_prefix}${local.resource_token}", 0, 24) - foundry_project_name = substr("${local.foundry_account_name}proj", 0, 24) - foundry_project_display = "AI Foundry ${var.environment_name}" - foundry_project_desc = "AI Foundry project for ${var.environment_name} environment" -} - module "ai_foundry" { source = "./modules/ai" @@ -17,13 +6,15 @@ module "ai_foundry" { tags = local.tags disable_local_auth = var.disable_local_auth - foundry_account_name = local.foundry_account_name - foundry_custom_subdomain_name = local.foundry_account_name + foundry_account_name = local.resource_names.foundry_account + foundry_custom_subdomain_name = local.resource_names.foundry_account - project_name = local.foundry_project_name + 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, @@ -32,23 +23,3 @@ module "ai_foundry" { local.principal_id ]) } - -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/ai-services.tf.back b/infra/terraform/ai-services.tf.back index e48e9abe..f08e9afb 100644 --- a/infra/terraform/ai-services.tf.back +++ 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 f653be88..5a7127de 100644 --- a/infra/terraform/communication.tf +++ b/infra/terraform/communication.tf @@ -185,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_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 fe320c25..3f336825 100644 --- a/infra/terraform/containers.tf +++ b/infra/terraform/containers.tf @@ -312,7 +312,7 @@ resource "azurerm_container_app" "backend" { 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 = module.ai_foundry.account_id 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 cfeb4514..e660e19a 100644 --- a/infra/terraform/main.tf +++ b/infra/terraform/main.tf @@ -70,12 +70,12 @@ 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" } @@ -95,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 index 6be4ef81..499e1276 100644 --- a/infra/terraform/modules/ai/foundry.tf +++ b/infra/terraform/modules/ai/foundry.tf @@ -16,7 +16,7 @@ locals { } data "azurerm_resource_group" "rg" { - name = var.resource_group_name + name = var.resource_group_name } resource "azapi_resource" "ai_foundry_account" { diff --git a/infra/terraform/modules/ai/project_capability_host.tf b/infra/terraform/modules/ai/project_capability_host.tf index 858b27db..e33eaeb8 100644 --- a/infra/terraform/modules/ai/project_capability_host.tf +++ b/infra/terraform/modules/ai/project_capability_host.tf @@ -1,102 +1,102 @@ locals { - create_ai_foundry_capability_host = ( - var.ai_search_id != null && - var.storage_account_id != null && - var.cosmosdb_account_id != null - ) + 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 - ] - } + 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 + 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 + 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 + 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 + 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'}) diff --git a/infra/terraform/modules/ai/project_connections.tf b/infra/terraform/modules/ai/project_connections.tf index c353f56e..a7c92400 100644 --- a/infra/terraform/modules/ai/project_connections.tf +++ b/infra/terraform/modules/ai/project_connections.tf @@ -9,17 +9,17 @@ resource "time_sleep" "wait_project_identities" { } ## 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), "") - : "") + 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), "") - : "") + 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), "") - : "") + 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" { diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index 7e7feddf..dea59104 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -206,3 +206,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 From 601163eb94c3771804a491fd71a1817e8ac8b2c5 Mon Sep 17 00:00:00 2001 From: Jin Lee Date: Fri, 26 Sep 2025 12:35:28 -0500 Subject: [PATCH 3/7] actions identity token permissions --- .github/workflows/deploy-azd.yml | 103 ++++++++++++++++++++++++++----- 1 file changed, 88 insertions(+), 15 deletions(-) diff --git a/.github/workflows/deploy-azd.yml b/.github/workflows/deploy-azd.yml index a9326149..67404675 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 - additional permissions handled 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,12 @@ jobs: name: ${{ github.event_name == 'pull_request' && '📋 Preview Changes' || '🚀 Deploy with AZD' }} runs-on: ubuntu-latest + # Job-specific permissions + permissions: + contents: read + id-token: write # Required for OIDC authentication with Azure + pull-requests: write # Required to comment on PRs + # Environment selection logic environment: >- ${{ @@ -120,10 +125,29 @@ jobs: - name: 🔐 Azure Login (OIDC) uses: azure/login@v2 + continue-on-error: true # Don't fail if OIDC permissions are denied + id: azure-login with: client-id: ${{ env.AZURE_CLIENT_ID }} tenant-id: ${{ env.AZURE_TENANT_ID }} subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }} + + - name: 🚨 Check Authentication Status + run: | + if [ "${{ steps.azure-login.outcome }}" = "failure" ]; then + echo "âš ī¸ OIDC authentication failed - this may be due to insufficient permissions" + echo "The workflow will continue but may not be able to provision actual resources" + echo "This is normal for forked repositories or when id-token permissions are restricted" + + if [ "${{ github.event_name }}" = "pull_request" ]; then + echo "â„šī¸ For PR previews, this may limit the preview functionality" + else + echo "❌ For deployments, authentication is required" + exit 1 + fi + else + echo "✅ Azure authentication successful" + fi - name: âš™ī¸ Setup Azure Developer CLI uses: Azure/setup-azd@v2 @@ -134,17 +158,26 @@ jobs: terraform_version: 1.9.0 - name: 🔐 Log in with Azure Developer CLI (OIDC) + 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 [ "${{ steps.azure-login.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" + 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.outcome == 'success' run: | echo "🔧 Setting up azd environment: ${{ env.AZURE_ENV_NAME }}" @@ -164,6 +197,7 @@ jobs: echo "✅ AZD environment configured" - name: âš™ī¸ Setup Terraform Parameters + if: steps.azure-login.outcome == 'success' run: | echo "🔧 Setting up Terraform parameters..." @@ -192,6 +226,7 @@ jobs: echo "✅ Parameters configured for environment: ${{ env.AZURE_ENV_NAME }}" - name: 🔧 Configure Terraform Backend + if: steps.azure-login.outcome == 'success' run: | echo "🔧 Configuring Terraform backend..." echo "Backend: ${{ env.RS_STORAGE_ACCOUNT }}/${{ env.RS_CONTAINER_NAME }}/${{ env.AZURE_ENV_NAME }}.tfstate" @@ -230,7 +265,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.outcome == 'success' id: preview run: | echo "🔍 Running infrastructure preview via AZD..." @@ -275,11 +310,11 @@ jobs: ARM_TENANT_ID: ${{ env.AZURE_TENANT_ID }} ARM_SUBSCRIPTION_ID: ${{ env.AZURE_SUBSCRIPTION_ID }} ARM_USE_OIDC: true - - + - 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,12 +359,50 @@ 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) From 167a9ceafe9a1715203558e25edfd2593aaad38e Mon Sep 17 00:00:00 2001 From: Jin Lee Date: Fri, 26 Sep 2025 12:57:58 -0500 Subject: [PATCH 4/7] Refactor Azure deployment workflow to enhance OIDC authentication handling and provide fallback for Service Principal authentication; improve error messaging and preview functionality. --- .github/workflows/deploy-azd.yml | 142 +++++++++++++++++++++++++------ 1 file changed, 114 insertions(+), 28 deletions(-) diff --git a/.github/workflows/deploy-azd.yml b/.github/workflows/deploy-azd.yml index 67404675..4b748c16 100644 --- a/.github/workflows/deploy-azd.yml +++ b/.github/workflows/deploy-azd.yml @@ -65,7 +65,7 @@ env: CI: true GITHUB_ACTIONS: true -# Minimal permissions - additional permissions handled per job +# Minimal permissions - OIDC handled conditionally per job permissions: contents: read # Required to checkout repository @@ -77,11 +77,11 @@ jobs: name: ${{ github.event_name == 'pull_request' && '📋 Preview Changes' || '🚀 Deploy with AZD' }} runs-on: ubuntu-latest - # Job-specific permissions + # Try to request OIDC permissions, fall back gracefully if denied permissions: contents: read - id-token: write # Required for OIDC authentication with Azure - pull-requests: write # Required to comment on PRs + id-token: write # Will be denied if not available, that's ok + pull-requests: write # Will be denied if not available, that's ok # Environment selection logic environment: >- @@ -93,9 +93,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: >- ${{ @@ -124,29 +129,62 @@ 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 + 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: | - if [ "${{ steps.azure-login.outcome }}" = "failure" ]; then - echo "âš ī¸ OIDC authentication failed - this may be due to insufficient permissions" - echo "The workflow will continue but may not be able to provision actual resources" - echo "This is normal for forked repositories or when id-token permissions are restricted" - - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "â„šī¸ For PR previews, this may limit the preview functionality" + 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 "❌ For deployments, authentication is required" - exit 1 + echo "✅ Azure OIDC authentication successful" + echo "auth_success=true" >> $GITHUB_OUTPUT fi else - echo "✅ Azure authentication successful" + 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 @@ -157,16 +195,22 @@ 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: | - if [ "${{ steps.azure-login.outcome }}" = "success" ]; then + 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 @@ -177,7 +221,7 @@ jobs: # SHARED CONFIGURATION STEPS # ======================================================================== - name: âš™ī¸ Setup AZD Environment - if: steps.azure-login.outcome == 'success' + if: (steps.azure-login-oidc.outcome == 'success') || (steps.azure-login-sp.outcome == 'success') run: | echo "🔧 Setting up azd environment: ${{ env.AZURE_ENV_NAME }}" @@ -197,7 +241,7 @@ jobs: echo "✅ AZD environment configured" - name: âš™ī¸ Setup Terraform Parameters - if: steps.azure-login.outcome == 'success' + if: (steps.azure-login-oidc.outcome == 'success') || (steps.azure-login-sp.outcome == 'success') run: | echo "🔧 Setting up Terraform parameters..." @@ -211,22 +255,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.outcome == 'success' + 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" @@ -248,7 +303,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 @@ -265,7 +321,7 @@ jobs: # PREVIEW MODE (for PRs) # ======================================================================== - name: 📋 Run Infrastructure Preview - if: github.event_name == 'pull_request' && steps.azure-login.outcome == 'success' + 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..." @@ -309,7 +365,36 @@ 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' @@ -408,7 +493,7 @@ jobs: # 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' }}" @@ -449,7 +534,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 From d70d2f1cfd4b1864e41aa218f1af7560a80d5c82 Mon Sep 17 00:00:00 2001 From: Jin Lee Date: Fri, 26 Sep 2025 13:00:12 -0500 Subject: [PATCH 5/7] testing with sp --- .github/workflows/deploy-azd.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.github/workflows/deploy-azd.yml b/.github/workflows/deploy-azd.yml index 4b748c16..90a75d39 100644 --- a/.github/workflows/deploy-azd.yml +++ b/.github/workflows/deploy-azd.yml @@ -78,11 +78,7 @@ jobs: runs-on: ubuntu-latest # Try to request OIDC permissions, fall back gracefully if denied - permissions: - contents: read - id-token: write # Will be denied if not available, that's ok - pull-requests: write # Will be denied if not available, that's ok - + # Environment selection logic environment: >- ${{ From e2a80cef217427527650cf57a44c63b7c338bec3 Mon Sep 17 00:00:00 2001 From: Jin Lee <94473824+JinLee794@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:03:16 -0500 Subject: [PATCH 6/7] Update infra/terraform/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- infra/terraform/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/terraform/README.md b/infra/terraform/README.md index 83a886a1..cd01742a 100644 --- a/infra/terraform/README.md +++ b/infra/terraform/README.md @@ -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 | From 9e4b99bf70da1fe20fbf63dbd2316bd36f60be7f Mon Sep 17 00:00:00 2001 From: Jin Lee <94473824+JinLee794@users.noreply.github.com> Date: Fri, 26 Sep 2025 13:03:26 -0500 Subject: [PATCH 7/7] Update infra/terraform/outputs.tf Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- infra/terraform/outputs.tf | 1 - 1 file changed, 1 deletion(-) diff --git a/infra/terraform/outputs.tf b/infra/terraform/outputs.tf index dea59104..aa226f0a 100644 --- a/infra/terraform/outputs.tf +++ b/infra/terraform/outputs.tf @@ -20,7 +20,6 @@ output "AZURE_LOCATION" { output "AZURE_OPENAI_ENDPOINT" { description = "Azure OpenAI endpoint" value = module.ai_foundry.openai_endpoint - # value = azurerm_cognitive_account.openai.endpoint } output "AZURE_OPENAI_CHAT_DEPLOYMENT_ID" {