diff --git a/.github/workflows/deploy-waf.yml b/.github/workflows/deploy-waf.yml index ace2f4dc2..c213a671e 100644 --- a/.github/workflows/deploy-waf.yml +++ b/.github/workflows/deploy-waf.yml @@ -25,6 +25,7 @@ jobs: GPT_MIN_CAPACITY: 1 O4_MINI_MIN_CAPACITY: 1 GPT41_MINI_MIN_CAPACITY: 1 + GPT_IMAGE_MIN_CAPACITY: 1 steps: - name: Checkout Code uses: actions/checkout@v6 @@ -43,6 +44,7 @@ jobs: GPT_MIN_CAPACITY: ${{ env.GPT_MIN_CAPACITY }} O4_MINI_MIN_CAPACITY: ${{ env.O4_MINI_MIN_CAPACITY }} GPT41_MINI_MIN_CAPACITY: ${{ env.GPT41_MINI_MIN_CAPACITY }} + GPT_IMAGE_MIN_CAPACITY: ${{ env.GPT_IMAGE_MIN_CAPACITY }} AZURE_REGIONS: ${{ vars.AZURE_REGIONS }} run: | chmod +x infra/scripts/checkquota.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1e0bd7a2a..d1204a7d3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -20,6 +20,7 @@ env: GPT_MIN_CAPACITY: 150 O4_MINI_MIN_CAPACITY: 50 GPT41_MINI_MIN_CAPACITY: 50 + GPT_IMAGE_MIN_CAPACITY: 4 BRANCH_NAME: ${{ github.head_ref || github.ref_name }} jobs: @@ -50,6 +51,7 @@ jobs: GPT_MIN_CAPACITY: ${{ env.GPT_MIN_CAPACITY }} O4_MINI_MIN_CAPACITY: ${{ env.O4_MINI_MIN_CAPACITY }} GPT41_MINI_MIN_CAPACITY: ${{ env.GPT41_MINI_MIN_CAPACITY }} + GPT_IMAGE_MIN_CAPACITY: ${{ env.GPT_IMAGE_MIN_CAPACITY }} AZURE_REGIONS: ${{ vars.AZURE_REGIONS }} run: | chmod +x infra/scripts/checkquota.sh diff --git a/.github/workflows/job-deploy-linux.yml b/.github/workflows/job-deploy-linux.yml index ad5cfb678..242b3b934 100644 --- a/.github/workflows/job-deploy-linux.yml +++ b/.github/workflows/job-deploy-linux.yml @@ -348,19 +348,34 @@ jobs: INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} AZURE_RESOURCE_GROUP: ${{ inputs.RESOURCE_GROUP_NAME }} + AZURE_ENV_NAME: ${{ steps.get_output_linux.outputs.AZURE_ENV_NAME }} BACKEND_URL: ${{ steps.get_output_linux.outputs.BACKEND_URL }} AZURE_STORAGE_ACCOUNT_NAME: ${{ steps.get_output_linux.outputs.AZURE_STORAGE_ACCOUNT_NAME }} - AZURE_STORAGE_CONTAINER_NAME: sample-dataset AZURE_AI_SEARCH_NAME: ${{ steps.get_output_linux.outputs.AZURE_AI_SEARCH_NAME }} - AZURE_AI_SEARCH_INDEX_NAME: sample-dataset-index - AZURE_ENV_NAME: ${{ steps.get_output_linux.outputs.AZURE_ENV_NAME }} + # Needed by post_deploy.sh to resolve the principal id when the workflow + # is signed in as a service principal (no interactive user). + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} run: | set -e + + # Use the same login pattern as selecting_team_config_and_data.sh: + # confirm an Azure CLI session is active. The preceding "Refresh Azure + # login" step re-establishes credentials, so we only verify here. + if az account show >/dev/null 2>&1; then + echo "Already authenticated with Azure." + else + echo "ERROR: Not authenticated with Azure. The 'Refresh Azure login' step must run before this step." + exit 1 + fi az account set --subscription "${{ secrets.AZURE_SUBSCRIPTION_ID }}" - - # Upload team configurations and index sample data in one step - # Automatically select "6" (All use cases) for non-interactive deployment - echo "6" | bash infra/scripts/selecting_team_config_and_data.sh + + # Run the unified post-deploy script non-interactively. + # --use-case 7 installs all use cases; --resource-group enables fallbacks + # to deployment outputs / naming-convention if azd env values are missing. + bash infra/scripts/post_deploy.sh \ + --resource-group "$INPUT_RESOURCE_GROUP_NAME" \ + --use-case 7 \ + --non-interactive - name: Generate Deployment Summary if: always() diff --git a/.github/workflows/job-deploy-windows.yml b/.github/workflows/job-deploy-windows.yml index a6e9665c1..ee0a6e86e 100644 --- a/.github/workflows/job-deploy-windows.yml +++ b/.github/workflows/job-deploy-windows.yml @@ -348,24 +348,38 @@ jobs: azd auth login --client-id "${{ secrets.AZURE_CLIENT_ID }}" --federated-credential-provider "github" --tenant-id "${{ secrets.AZURE_TENANT_ID }}" - name: Run Post deployment scripts - shell: bash + shell: pwsh env: INPUT_RESOURCE_GROUP_NAME: ${{ inputs.RESOURCE_GROUP_NAME }} AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }} AZURE_RESOURCE_GROUP: ${{ inputs.RESOURCE_GROUP_NAME }} + AZURE_ENV_NAME: ${{ steps.get_output_windows.outputs.AZURE_ENV_NAME }} BACKEND_URL: ${{ steps.get_output_windows.outputs.BACKEND_URL }} AZURE_STORAGE_ACCOUNT_NAME: ${{ steps.get_output_windows.outputs.AZURE_STORAGE_ACCOUNT_NAME }} - AZURE_STORAGE_CONTAINER_NAME: sample-dataset AZURE_AI_SEARCH_NAME: ${{ steps.get_output_windows.outputs.AZURE_AI_SEARCH_NAME }} - AZURE_AI_SEARCH_INDEX_NAME: sample-dataset-index - AZURE_ENV_NAME: ${{ steps.get_output_windows.outputs.AZURE_ENV_NAME }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + PYTHONUTF8: '1' + PYTHONIOENCODING: 'utf-8' run: | - set -e - az account set --subscription "${{ secrets.AZURE_SUBSCRIPTION_ID }}" - - # Upload team configurations and index sample data in one step - # Automatically select "6" (All use cases) for non-interactive deployment - echo "6" | bash infra/scripts/selecting_team_config_and_data.sh + $ErrorActionPreference = "Stop" + # Confirm an Azure CLI session is active. The preceding "Refresh Azure + # login" step re-establishes credentials, so we only verify here. + az account show 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "Not authenticated with Azure. The 'Refresh Azure login' step must run before this step." + exit 1 + } + Write-Host "Already authenticated with Azure." + az account set --subscription "$env:AZURE_SUBSCRIPTION_ID" + + # Run the unified PowerShell post-deploy script non-interactively. + # -UseCase 7 installs all use cases; -ResourceGroup enables fallbacks + # to deployment outputs / naming-convention if azd env values are missing. + ./infra/scripts/post_deploy.ps1 ` + -ResourceGroup "$env:INPUT_RESOURCE_GROUP_NAME" ` + -UseCase 7 ` + -NonInteractive + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - name: Generate Deployment Summary if: always() diff --git a/.github/workflows/job-deploy.yml b/.github/workflows/job-deploy.yml index a5af4831a..e132f0929 100644 --- a/.github/workflows/job-deploy.yml +++ b/.github/workflows/job-deploy.yml @@ -101,6 +101,7 @@ env: GPT_MIN_CAPACITY: 150 O4_MINI_MIN_CAPACITY: 50 GPT41_MINI_MIN_CAPACITY: 50 + GPT_IMAGE_MIN_CAPACITY: 4 BRANCH_NAME: ${{ github.event.workflow_run.head_branch || github.head_ref || github.ref_name }} WAF_ENABLED: ${{ inputs.trigger_type == 'workflow_dispatch' && (inputs.waf_enabled || false) || false }} EXP: ${{ inputs.trigger_type == 'workflow_dispatch' && (inputs.EXP || false) || false }} @@ -309,6 +310,7 @@ jobs: GPT_MIN_CAPACITY: ${{ env.GPT_MIN_CAPACITY }} O4_MINI_MIN_CAPACITY: ${{ env.O4_MINI_MIN_CAPACITY }} GPT41_MINI_MIN_CAPACITY: ${{ env.GPT41_MINI_MIN_CAPACITY }} + GPT_IMAGE_MIN_CAPACITY: ${{ env.GPT_IMAGE_MIN_CAPACITY }} AZURE_REGIONS: ${{ vars.AZURE_REGIONS }} run: | chmod +x infra/scripts/checkquota.sh diff --git a/infra/main.bicep b/infra/main.bicep index 4335eb010..ad4ba74fd 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -33,7 +33,7 @@ var deployerInfo = deployer() var deployingUserPrincipalId = deployerInfo.objectId // Restricting deployment to only supported Azure OpenAI regions validated with GPT-4o model -@allowed(['australiaeast', 'eastus2', 'francecentral', 'japaneast', 'norwayeast', 'swedencentral', 'uksouth', 'westus', 'westus3']) +@allowed(['polandcentral', 'uaenorth', 'australiaeast', 'eastus2', 'francecentral', 'japaneast', 'norwayeast', 'swedencentral', 'uksouth', 'westus', 'westus3']) @metadata({ azd: { type: 'location' @@ -137,8 +137,8 @@ param gpt5MiniModelCapacity int = 50 @description('Optional. GPT image model deployment type. Defaults to GlobalStandard.') param gptImageModelDeploymentType string = 'GlobalStandard' -@description('Optional. gpt-image-1.5 deployment capacity (RPM). Defaults to 5 to support concurrent marketing-image generation across multiple sessions.') -param gptImageModelCapacity int = 5 +@description('Optional. gpt-image-1.5 deployment capacity (RPM). Defaults to 4 to support concurrent marketing-image generation across multiple sessions.') +param gptImageModelCapacity int = 4 @description('Optional. The tags to apply to all deployed Azure resources.') param tags resourceInput<'Microsoft.Resources/resourceGroups@2025-04-01'>.tags = {} diff --git a/infra/main_custom.bicep b/infra/main_custom.bicep index adca367cd..f522e6f4a 100644 --- a/infra/main_custom.bicep +++ b/infra/main_custom.bicep @@ -123,8 +123,8 @@ param gpt4_1ModelCapacity int = 150 @description('Optional. o4-mini deployment capacity (thousand TPM). Used by the Magentic manager (multi-turn planning + reflection). Defaults to 100.') param gptReasoningModelCapacity int = 100 -@description('Optional. gpt-image-1.5 deployment capacity (RPM). Defaults to 5 to support concurrent marketing-image generation across multiple sessions.') -param gptImageModelCapacity int = 5 +@description('Optional. gpt-image-1.5 deployment capacity (RPM). Defaults to 4 to support concurrent marketing-image generation across multiple sessions.') +param gptImageModelCapacity int = 4 @description('Optional. The tags to apply to all deployed Azure resources.') param tags resourceInput<'Microsoft.Resources/resourceGroups@2025-04-01'>.tags = {} diff --git a/infra/scripts/checkquota.sh b/infra/scripts/checkquota.sh index b79815716..b339987ed 100644 --- a/infra/scripts/checkquota.sh +++ b/infra/scripts/checkquota.sh @@ -7,12 +7,13 @@ SUBSCRIPTION_ID="${AZURE_SUBSCRIPTION_ID}" GPT_MIN_CAPACITY="${GPT_MIN_CAPACITY}" O4_MINI_MIN_CAPACITY="${O4_MINI_MIN_CAPACITY}" GPT41_MINI_MIN_CAPACITY="${GPT41_MINI_MIN_CAPACITY}" +GPT_IMAGE_MIN_CAPACITY="${GPT_IMAGE_MIN_CAPACITY:-4}" echo "🔄 Validating required environment variables..." if [[ -z "$SUBSCRIPTION_ID" || -z "$REGIONS" ]]; then echo "❌ ERROR: Missing required environment variables." echo "Required: AZURE_SUBSCRIPTION_ID, AZURE_REGIONS" - echo "Optional: O4_MINI_MIN_CAPACITY (default: 50), GPT41_MINI_MIN_CAPACITY (default: 50)" + echo "Optional: O4_MINI_MIN_CAPACITY (default: 50), GPT41_MINI_MIN_CAPACITY (default: 50), GPT_IMAGE_MIN_CAPACITY (default: 4)" exit 1 fi @@ -28,6 +29,7 @@ declare -A MIN_CAPACITY=( ["OpenAI.GlobalStandard.o4-mini"]="${O4_MINI_MIN_CAPACITY}" ["OpenAI.GlobalStandard.gpt4.1"]="${GPT_MIN_CAPACITY}" ["OpenAI.GlobalStandard.gpt4.1-mini"]="${GPT41_MINI_MIN_CAPACITY}" + ["OpenAI.GlobalStandard.gpt-image-1.5"]="${GPT_IMAGE_MIN_CAPACITY}" ) VALID_REGION="" diff --git a/infra/scripts/post_deploy.ps1 b/infra/scripts/post_deploy.ps1 index 9595d7681..82eacf8ff 100644 --- a/infra/scripts/post_deploy.ps1 +++ b/infra/scripts/post_deploy.ps1 @@ -23,7 +23,10 @@ #> param( - [string]$ResourceGroup + [string]$ResourceGroup, + [ValidateSet("1", "2", "3", "4", "5", "6", "7", "all", "All", "ALL")] + [string]$UseCase, + [switch]$NonInteractive ) Set-StrictMode -Version Latest @@ -250,6 +253,8 @@ function Get-ValuesFromAzdEnv { $script:aiSearchEndpoint = $(azd env get-value AZURE_SEARCH_ENDPOINT 2>$null) $script:openaiEndpoint = $(azd env get-value AZURE_OPENAI_ENDPOINT 2>$null) $script:projectEndpoint = $(azd env get-value AZURE_AI_PROJECT_ENDPOINT 2>$null) + $script:aiFoundryResourceId = $(azd env get-value AI_FOUNDRY_RESOURCE_ID 2>$null) + $script:aiProjectName = $(azd env get-value AZURE_AI_PROJECT_NAME 2>$null) $script:ResourceGroup = $(azd env get-value AZURE_RESOURCE_GROUP 2>$null) if (-not $script:backendUrl -or -not $script:storageAccount -or -not $script:aiSearch -or -not $script:ResourceGroup) { @@ -283,6 +288,8 @@ function Get-ValuesFromAzDeployment { $script:aiSearchEndpoint = Get-DeploymentValue -DeploymentOutputs $deploymentOutputs -PrimaryKey "azurE_SEARCH_ENDPOINT" -FallbackKey "azureSearchEndpoint" $script:openaiEndpoint = Get-DeploymentValue -DeploymentOutputs $deploymentOutputs -PrimaryKey "azurE_OPENAI_ENDPOINT" -FallbackKey "azureOpenaiEndpoint" $script:projectEndpoint = Get-DeploymentValue -DeploymentOutputs $deploymentOutputs -PrimaryKey "azurE_AI_PROJECT_ENDPOINT" -FallbackKey "azureAiProjectEndpoint" + $script:aiFoundryResourceId = Get-DeploymentValue -DeploymentOutputs $deploymentOutputs -PrimaryKey "aI_FOUNDRY_RESOURCE_ID" -FallbackKey "aiFoundryResourceId" + $script:aiProjectName = Get-DeploymentValue -DeploymentOutputs $deploymentOutputs -PrimaryKey "azurE_AI_PROJECT_NAME" -FallbackKey "azureAiProjectName" if (-not $script:storageAccount -or -not $script:aiSearch -or -not $script:backendUrl) { Write-Host "Error: Could not extract all required values from deployment outputs." @@ -488,39 +495,49 @@ try { $currentSubscriptionName = az account show --query name -o tsv if ($script:azSubscriptionId -and $currentSubscriptionId -ne $script:azSubscriptionId) { - Write-Host "Current subscription is $currentSubscriptionName ( $currentSubscriptionId )." - $confirmation = Read-Host "Do you want to continue with this subscription? (y/n)" - if ($confirmation -notin @("y", "Y")) { - $availableSubscriptions = az account list --query "[?state=='Enabled'].[name,id]" --output tsv - $subscriptions = $availableSubscriptions -split "`n" | ForEach-Object { $_.Split("`t") } - - do { - Write-Host "" - Write-Host "Available Subscriptions:" - Write-Host "========================" - for ($i = 0; $i -lt $subscriptions.Count; $i += 2) { - $index = ($i / 2) + 1 - Write-Host "$index. $($subscriptions[$i]) ( $($subscriptions[$i + 1]) )" - } - Write-Host "========================" - - $subscriptionIndex = Read-Host "Enter the number of the subscription (1-$([int]($subscriptions.Count / 2)))" - - if ($subscriptionIndex -match '^\d+$' -and [int]$subscriptionIndex -ge 1 -and [int]$subscriptionIndex -le ($subscriptions.Count / 2)) { - $selectedIndex = ([int]$subscriptionIndex - 1) * 2 - $selectedSubscriptionName = $subscriptions[$selectedIndex] - $selectedSubscriptionId = $subscriptions[$selectedIndex + 1] - az account set --subscription $selectedSubscriptionId - Write-Host "Switched to subscription: $selectedSubscriptionName ( $selectedSubscriptionId )" - $script:azSubscriptionId = $selectedSubscriptionId - break - } else { - Write-Host "Invalid selection. Please try again." -ForegroundColor Red - } - } while ($true) + if ($NonInteractive) { + # In non-interactive mode, auto-switch to target subscription + $targetSubscriptionName = az account show --subscription $script:azSubscriptionId --query name -o tsv + Write-Host "Switching to target subscription: $targetSubscriptionName ( $script:azSubscriptionId )" + az account set --subscription $script:azSubscriptionId + $currentSubscriptionId = $script:azSubscriptionId + $currentSubscriptionName = $targetSubscriptionName } else { - az account set --subscription $currentSubscriptionId - $script:azSubscriptionId = $currentSubscriptionId + # In interactive mode, prompt user + Write-Host "Current subscription is $currentSubscriptionName ( $currentSubscriptionId )." + $confirmation = Read-Host "Do you want to continue with this subscription? (y/n)" + if ($confirmation -notin @("y", "Y")) { + $availableSubscriptions = az account list --query "[?state=='Enabled'].[name,id]" --output tsv + $subscriptions = $availableSubscriptions -split "`n" | ForEach-Object { $_.Split("`t") } + + do { + Write-Host "" + Write-Host "Available Subscriptions:" + Write-Host "========================" + for ($i = 0; $i -lt $subscriptions.Count; $i += 2) { + $index = ($i / 2) + 1 + Write-Host "$index. $($subscriptions[$i]) ( $($subscriptions[$i + 1]) )" + } + Write-Host "========================" + + $subscriptionIndex = Read-Host "Enter the number of the subscription (1-$([int]($subscriptions.Count / 2)))" + + if ($subscriptionIndex -match '^\d+$' -and [int]$subscriptionIndex -ge 1 -and [int]$subscriptionIndex -le ($subscriptions.Count / 2)) { + $selectedIndex = ([int]$subscriptionIndex - 1) * 2 + $selectedSubscriptionName = $subscriptions[$selectedIndex] + $selectedSubscriptionId = $subscriptions[$selectedIndex + 1] + az account set --subscription $selectedSubscriptionId + Write-Host "Switched to subscription: $selectedSubscriptionName ( $selectedSubscriptionId )" + $script:azSubscriptionId = $selectedSubscriptionId + break + } else { + Write-Host "Invalid selection. Please try again." -ForegroundColor Red + } + } while ($true) + } else { + az account set --subscription $currentSubscriptionId + $script:azSubscriptionId = $currentSubscriptionId + } } } else { Write-Host "Proceeding with subscription: $currentSubscriptionName ( $currentSubscriptionId )" @@ -549,37 +566,56 @@ try { } # ── Use case selection ──────────────────────────────────────────────────── - Write-Host "" - Write-Host "===============================================" - Write-Host "Available Use Cases:" - Write-Host "===============================================" - Write-Host "1. RFP Evaluation" - Write-Host "2. Retail Customer Satisfaction" - Write-Host "3. HR Employee Onboarding" - Write-Host "4. Marketing Press Release" - Write-Host "5. Contract Compliance Review" - Write-Host "6. Content Generation" - Write-Host "7. All" - Write-Host "===============================================" - Write-Host "" + $useCaseLabels = @{ + "1" = "RFP Evaluation" + "2" = "Retail Customer Satisfaction" + "3" = "HR Employee Onboarding" + "4" = "Marketing Press Release" + "5" = "Contract Compliance Review" + "6" = "Content Generation" + "7" = "All" + } + + if ($UseCase) { + $useCaseSelection = if ($UseCase -in @("all", "All", "ALL")) { "7" } else { $UseCase } + $selectedUseCase = $useCaseLabels[$useCaseSelection] + Write-Host "Use case pre-selected via -UseCase parameter: $selectedUseCase ($useCaseSelection)" + } elseif ($NonInteractive) { + Write-Host "Error: -UseCase is required when running with -NonInteractive." -ForegroundColor Red + exit 1 + } else { + Write-Host "" + Write-Host "===============================================" + Write-Host "Available Use Cases:" + Write-Host "===============================================" + Write-Host "1. RFP Evaluation" + Write-Host "2. Retail Customer Satisfaction" + Write-Host "3. HR Employee Onboarding" + Write-Host "4. Marketing Press Release" + Write-Host "5. Contract Compliance Review" + Write-Host "6. Content Generation" + Write-Host "7. All" + Write-Host "===============================================" + Write-Host "" - do { - $useCaseSelection = Read-Host "Please enter the number of the use case you would like to install (1-7)" - switch ($useCaseSelection) { - "1" { $selectedUseCase = "RFP Evaluation"; $useCaseValid = $true } - "2" { $selectedUseCase = "Retail Customer Satisfaction"; $useCaseValid = $true } - "3" { $selectedUseCase = "HR Employee Onboarding"; $useCaseValid = $true } - "4" { $selectedUseCase = "Marketing Press Release"; $useCaseValid = $true } - "5" { $selectedUseCase = "Contract Compliance Review"; $useCaseValid = $true } - "6" { $selectedUseCase = "Content Generation"; $useCaseValid = $true } - "7" { $selectedUseCase = "All"; $useCaseValid = $true } - "all" { $useCaseSelection = "7"; $selectedUseCase = "All"; $useCaseValid = $true } - default { - $useCaseValid = $false - Write-Host "Invalid selection. Please enter a number from 1-7." -ForegroundColor Red + do { + $useCaseSelection = Read-Host "Please enter the number of the use case you would like to install (1-7)" + switch ($useCaseSelection) { + "1" { $selectedUseCase = "RFP Evaluation"; $useCaseValid = $true } + "2" { $selectedUseCase = "Retail Customer Satisfaction"; $useCaseValid = $true } + "3" { $selectedUseCase = "HR Employee Onboarding"; $useCaseValid = $true } + "4" { $selectedUseCase = "Marketing Press Release"; $useCaseValid = $true } + "5" { $selectedUseCase = "Contract Compliance Review"; $useCaseValid = $true } + "6" { $selectedUseCase = "Content Generation"; $useCaseValid = $true } + "7" { $selectedUseCase = "All"; $useCaseValid = $true } + "all" { $useCaseSelection = "7"; $selectedUseCase = "All"; $useCaseValid = $true } + default { + $useCaseValid = $false + Write-Host "Invalid selection. Please enter a number from 1-7." -ForegroundColor Red + } } - } - } while (-not $useCaseValid) + } while (-not $useCaseValid) + } Write-Host "" Write-Host "===============================================" @@ -596,9 +632,22 @@ try { Write-Host "" # ── Signed-in user principal id (for backend API auth header) ───────────── - $script:userPrincipalId = az ad signed-in-user show --query id -o tsv + # In CI the workflow logs in as a service principal (OIDC), so + # `az ad signed-in-user show` returns nothing. Fall back to an explicit + # USER_PRINCIPAL_ID env var, then to the SP object id looked up via + # AZURE_CLIENT_ID. + if ($env:USER_PRINCIPAL_ID) { + $script:userPrincipalId = $env:USER_PRINCIPAL_ID + Write-Host "Using principal id from USER_PRINCIPAL_ID env var." + } else { + $script:userPrincipalId = az ad signed-in-user show --query id -o tsv 2>$null + if (-not $script:userPrincipalId -and $env:AZURE_CLIENT_ID) { + Write-Host "No interactive user — falling back to service principal object id (AZURE_CLIENT_ID=$($env:AZURE_CLIENT_ID))." + $script:userPrincipalId = az ad sp show --id $env:AZURE_CLIENT_ID --query id -o tsv 2>$null + } + } if (-not $script:userPrincipalId) { - Write-Host "Error: Could not retrieve signed-in user principal id." -ForegroundColor Red + Write-Host "Error: Could not retrieve signed-in user principal id. In CI, set USER_PRINCIPAL_ID or ensure AZURE_CLIENT_ID is exported and the SP is visible to Microsoft Graph." -ForegroundColor Red exit 1 } @@ -636,6 +685,40 @@ try { if ($script:aiSearchEndpoint) { $env:AZURE_AI_SEARCH_ENDPOINT = $script:aiSearchEndpoint } if ($script:openaiEndpoint) { $env:AZURE_OPENAI_ENDPOINT = $script:openaiEndpoint } + # Resolve AI Foundry account resource ID and project name. Prefer values + # from azd env / deployment outputs; otherwise fall back to az CLI lookup. + # These are exported so seed_kb_connections.py can construct the project + # ARM resource ID directly, avoiding fragile data-plane discovery that + # fails for service principals on fresh deployments. + if (-not $script:aiFoundryResourceId -and $script:openaiEndpoint -and $script:ResourceGroup) { + $foundryAccountName = $null + if ($script:openaiEndpoint -match '^https?://([^.]+)\.') { + $foundryAccountName = $Matches[1] + } + if ($foundryAccountName) { + $script:aiFoundryResourceId = az cognitiveservices account show --name $foundryAccountName --resource-group $script:ResourceGroup --query id -o tsv 2>$null + } + } + if (-not $script:aiProjectName -and $script:projectEndpoint) { + # Project endpoint format: https://{account}.services.ai.azure.com/api/projects/{project} + $script:aiProjectName = ($script:projectEndpoint.TrimEnd('/') -split '/')[-1] + } + + if ($script:aiFoundryResourceId) { $env:AI_FOUNDRY_RESOURCE_ID = $script:aiFoundryResourceId } + if ($script:aiProjectName) { $env:AZURE_AI_PROJECT_NAME = $script:aiProjectName } + if ($script:azSubscriptionId) { + $env:AZURE_AI_SUBSCRIPTION_ID = $script:azSubscriptionId + $env:AZURE_SUBSCRIPTION_ID = $script:azSubscriptionId + } + if ($script:ResourceGroup) { + $env:AZURE_AI_RESOURCE_GROUP = $script:ResourceGroup + $env:AZURE_RESOURCE_GROUP = $script:ResourceGroup + } + + if (-not $script:aiFoundryResourceId -or -not $script:aiProjectName) { + Write-Host "Warning: AI_FOUNDRY_RESOURCE_ID or AZURE_AI_PROJECT_NAME not resolved. KB MCP connection provisioning may fall back to data-plane discovery." -ForegroundColor Yellow + } + # ── WAF: temporarily enable public access for use cases that need data ── $usesData = $useCaseSelection -in @("1","2","5","6","7") if ($usesData) { diff --git a/infra/scripts/post_deploy.sh b/infra/scripts/post_deploy.sh index 05a0e0347..77332e632 100644 --- a/infra/scripts/post_deploy.sh +++ b/infra/scripts/post_deploy.sh @@ -11,12 +11,18 @@ ai_search="" ai_search_endpoint="" openai_endpoint="" project_endpoint="" +ai_foundry_resource_id="" +ai_project_name="" az_subscription_id="" resource_group="" user_principal_id="" python_cmd="" venv_path="$SCRIPT_DIR/scriptenv" +selected_use_case="" +selected_use_case_label="" +non_interactive=false + st_is_public_access_disabled=false srch_is_public_access_disabled=false ai_foundry_is_public_access_disabled=false @@ -55,12 +61,34 @@ parse_args() { resource_group="$2" shift 2 ;; + -u|--use-case) + if [ -z "${2-}" ]; then + fatal "Missing value for $1" + fi + case "$2" in + 1) selected_use_case="1"; selected_use_case_label="RFP Evaluation" ;; + 2) selected_use_case="2"; selected_use_case_label="Retail Customer Satisfaction" ;; + 3) selected_use_case="3"; selected_use_case_label="HR Employee Onboarding" ;; + 4) selected_use_case="4"; selected_use_case_label="Marketing Press Release" ;; + 5) selected_use_case="5"; selected_use_case_label="Contract Compliance Review" ;; + 6) selected_use_case="6"; selected_use_case_label="Content Generation" ;; + 7|all|All|ALL) selected_use_case="7"; selected_use_case_label="All" ;; + *) fatal "Invalid value for --use-case: '$2'. Valid values: 1-7 or 'all'." ;; + esac + shift 2 + ;; + --non-interactive) + non_interactive=true + shift + ;; --help|-h) cat <<'EOF' -Usage: post_deploy.sh [--resource-group ] +Usage: post_deploy.sh [--resource-group ] [--use-case <1-7|all>] [--non-interactive] Options: -g, --resource-group Resource group name for deployment fallback resolution + -u, --use-case Use case to install (1-7 or 'all'). Skips interactive prompt. + --non-interactive Do not prompt; fail if a required input is missing. -h, --help Show this help message EOF exit 0 @@ -227,22 +255,27 @@ enable_public_access_if_waf() { } get_value_from_deployment() { - local deployment_outputs="$1" - local primary_key="$2" - local fallback_key="$3" - - python3 - </dev/null || true)" dep_ai_search="$(printf '%s' "$deployment_outputs" | get_value_from_deployment "azurE_AI_SEARCH_NAME" "azureAiSearchName" 2>/dev/null || true)" dep_backend_url="$(printf '%s' "$deployment_outputs" | get_value_from_deployment "backenD_URL" "backendUrl" 2>/dev/null || true)" @@ -307,6 +342,8 @@ get_values_from_az_deployment() { if [ -z "$dep_project_endpoint" ]; then dep_project_endpoint="$(printf '%s' "$deployment_outputs" | get_value_from_deployment "azurE_AI_AGENT_ENDPOINT" "azureAiAgentEndpoint" 2>/dev/null || true)" fi + dep_ai_foundry_resource_id="$(printf '%s' "$deployment_outputs" | get_value_from_deployment "aI_FOUNDRY_RESOURCE_ID" "aiFoundryResourceId" 2>/dev/null || true)" + dep_ai_project_name="$(printf '%s' "$deployment_outputs" | get_value_from_deployment "azurE_AI_PROJECT_NAME" "azureAiProjectName" 2>/dev/null || true)" if [ -n "$dep_storage_account" ]; then storage_account="$dep_storage_account" @@ -326,6 +363,12 @@ get_values_from_az_deployment() { if [ -n "$dep_project_endpoint" ]; then project_endpoint="$dep_project_endpoint" fi + if [ -n "$dep_ai_foundry_resource_id" ]; then + ai_foundry_resource_id="$dep_ai_foundry_resource_id" + fi + if [ -n "$dep_ai_project_name" ]; then + ai_project_name="$dep_ai_project_name" + fi if [ -z "$storage_account" ] || [ -z "$ai_search" ] || [ -z "$backend_url" ]; then error "Could not extract all required values from deployment outputs." @@ -497,6 +540,15 @@ upload_team_config() { } select_use_case() { + if [ -n "$selected_use_case" ]; then + info "Use case pre-selected via argument: $selected_use_case_label ($selected_use_case)" + return 0 + fi + + if [ "$non_interactive" = true ]; then + fatal "--non-interactive set but no --use-case provided. Pass --use-case <1-7|all>." + fi + echo "" echo "===============================================" echo "Available Use Cases:" @@ -538,6 +590,11 @@ select_subscription() { current_subscription_name="$(az account show --query name -o tsv 2>/dev/null || true)" if [ -n "$az_subscription_id" ] && [ "$current_subscription_id" != "$az_subscription_id" ]; then + if [ "$non_interactive" = true ]; then + info "Non-interactive mode: switching to subscription $az_subscription_id" + az account set --subscription "$az_subscription_id" + return 0 + fi echo "Current subscription is $current_subscription_name ($current_subscription_id)." read -rp "Do you want to continue with this subscription? (y/n) " continue_choice if [[ ! "$continue_choice" =~ ^[Yy]$ ]]; then @@ -638,9 +695,22 @@ main() { echo "===============================================" echo "" - user_principal_id="$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)" + # Resolve the principal id to use for team-config uploads. In CI the workflow + # logs in as a service principal (OIDC), so `az ad signed-in-user show` returns + # nothing. Fall back to an explicit USER_PRINCIPAL_ID env var, then to the SP + # object id looked up via AZURE_CLIENT_ID. + if [ -n "${USER_PRINCIPAL_ID:-}" ]; then + user_principal_id="$USER_PRINCIPAL_ID" + info "Using principal id from USER_PRINCIPAL_ID env var." + else + user_principal_id="$(az ad signed-in-user show --query id -o tsv 2>/dev/null || true)" + if [ -z "$user_principal_id" ] && [ -n "${AZURE_CLIENT_ID:-}" ]; then + info "No interactive user — falling back to service principal object id (AZURE_CLIENT_ID=$AZURE_CLIENT_ID)." + user_principal_id="$(az ad sp show --id "$AZURE_CLIENT_ID" --query id -o tsv 2>/dev/null || true)" + fi + fi if [ -z "$user_principal_id" ]; then - fatal "Could not retrieve signed-in user principal id." + fatal "Could not retrieve signed-in user principal id. In CI, set USER_PRINCIPAL_ID or ensure AZURE_CLIENT_ID is exported and the SP is visible to Microsoft Graph." fi activate_python_env @@ -665,6 +735,45 @@ main() { warn "AZURE_OPENAI_ENDPOINT is not set. Knowledge base reasoning may fall back to default or fail." fi + # Resolve AI Foundry account resource ID and project name. Prefer the values + # already retrieved from azd env / deployment outputs; otherwise fall back to + # querying the resource group with az CLI. These are exported so that + # seed_kb_connections.py can construct the project ARM resource ID directly, + # avoiding fragile data-plane discovery that fails for service principals on + # fresh deployments. + if [ -z "$ai_foundry_resource_id" ] && [ -n "$resource_group" ] && [ -n "$openai_endpoint" ]; then + local foundry_account_name="" + if [[ "$openai_endpoint" =~ ^https?://([^.]+)\. ]]; then + foundry_account_name="${BASH_REMATCH[1]}" + fi + if [ -n "$foundry_account_name" ]; then + ai_foundry_resource_id="$(az cognitiveservices account show --name "$foundry_account_name" --resource-group "$resource_group" --query id -o tsv 2>/dev/null || true)" + fi + fi + if [ -z "$ai_project_name" ] && [ -n "$project_endpoint" ]; then + # Project endpoint format: https://{account}.services.ai.azure.com/api/projects/{project} + ai_project_name="${project_endpoint##*/}" + fi + + if [ -n "$ai_foundry_resource_id" ]; then + export AI_FOUNDRY_RESOURCE_ID="$ai_foundry_resource_id" + fi + if [ -n "$ai_project_name" ]; then + export AZURE_AI_PROJECT_NAME="$ai_project_name" + fi + if [ -n "$az_subscription_id" ]; then + export AZURE_AI_SUBSCRIPTION_ID="$az_subscription_id" + export AZURE_SUBSCRIPTION_ID="$az_subscription_id" + fi + if [ -n "$resource_group" ]; then + export AZURE_AI_RESOURCE_GROUP="$resource_group" + export AZURE_RESOURCE_GROUP="$resource_group" + fi + + if [ -z "$ai_foundry_resource_id" ] || [ -z "$ai_project_name" ]; then + warn "AI_FOUNDRY_RESOURCE_ID or AZURE_AI_PROJECT_NAME not resolved. KB MCP connection provisioning may fall back to data-plane discovery." + fi + local uses_data=false case "$selected_use_case" in 1|2|5|6|7) uses_data=true ;; diff --git a/infra/scripts/seed_kb_connections.py b/infra/scripts/seed_kb_connections.py index cb77df4a0..dfbe7d10a 100644 --- a/infra/scripts/seed_kb_connections.py +++ b/infra/scripts/seed_kb_connections.py @@ -13,6 +13,15 @@ - AZURE_AI_SEARCH_ENDPOINT - AZURE_AI_PROJECT_ENDPOINT (the full project endpoint URL) +Optional (preferred — used to construct the project ARM resource ID directly, +avoiding fragile data-plane discovery that fails for service principals on +fresh deployments): + - AI_FOUNDRY_RESOURCE_ID (the AI Foundry / Cognitive Services account ARM id) + - AZURE_AI_PROJECT_NAME (the project name under the account) + - AZURE_AI_PROJECT_RESOURCE_ID (explicit full project ARM id; overrides the above) + - AZURE_AI_SUBSCRIPTION_ID (or AZURE_SUBSCRIPTION_ID) + - AZURE_AI_RESOURCE_GROUP (or AZURE_RESOURCE_GROUP) + Authentication: DefaultAzureCredential — caller needs Contributor on the AI project. Idempotent: PUTs connections (creates or updates). """ @@ -20,6 +29,7 @@ import os import sys from pathlib import Path +from urllib.parse import urlparse import httpx from azure.identity import DefaultAzureCredential @@ -115,6 +125,63 @@ def _discover_project_resource_id(credential: DefaultAzureCredential) -> str: return "" +def _build_project_resource_id_from_env() -> str: + """Construct the project ARM resource ID from environment variables. + + Preferred resolution order: + 1. AZURE_AI_PROJECT_RESOURCE_ID (explicit override) + 2. AI_FOUNDRY_RESOURCE_ID + AZURE_AI_PROJECT_NAME + 3. (subscription, resource group) + (account, project) parsed from PROJECT_ENDPOINT + + Returns empty string if it cannot be constructed. + """ + explicit = os.environ.get("AZURE_AI_PROJECT_RESOURCE_ID", "").strip().rstrip("/") + if explicit: + return explicit + + foundry_id = os.environ.get("AI_FOUNDRY_RESOURCE_ID", "").strip().rstrip("/") + project_name_env = os.environ.get("AZURE_AI_PROJECT_NAME", "").strip() + + # Parse account name and project name from the project endpoint as a fallback. + parsed = urlparse(PROJECT_ENDPOINT) if PROJECT_ENDPOINT else None + parsed_account = "" + parsed_project = "" + if parsed and parsed.hostname: + parsed_account = parsed.hostname.split(".", 1)[0] + path_parts = [p for p in (parsed.path or "").split("/") if p] + if path_parts: + parsed_project = path_parts[-1] + + project_name = project_name_env or parsed_project + + # Path 2: foundry resource id + project name + if foundry_id and project_name: + # Ensure the id points to the account (not the project itself) before appending. + if "/projects/" in foundry_id.lower(): + return foundry_id + return f"{foundry_id}/projects/{project_name}" + + # Path 3: build from sub + rg + parsed account/project + sub = ( + os.environ.get("AZURE_AI_SUBSCRIPTION_ID") + or os.environ.get("AZURE_SUBSCRIPTION_ID") + or "" + ).strip() + rg = ( + os.environ.get("AZURE_AI_RESOURCE_GROUP") + or os.environ.get("AZURE_RESOURCE_GROUP") + or "" + ).strip() + if sub and rg and parsed_account and project_name: + return ( + f"/subscriptions/{sub}/resourceGroups/{rg}" + f"/providers/Microsoft.CognitiveServices/accounts/{parsed_account}" + f"/projects/{project_name}" + ) + + return "" + + def _create_connection_via_arm( resource_id: str, connection_name: str, target_url: str, credential: DefaultAzureCredential ) -> bool: @@ -171,27 +238,47 @@ def main() -> None: credential = DefaultAzureCredential() - # Discover the ARM resource ID of the project - print("Discovering project resource ID...") - resource_id = _discover_project_resource_id(credential) - if not resource_id: - # Try to get it from an existing connection - print(" Could not discover via data-plane. Trying existing connection...") - from azure.ai.projects import AIProjectClient - - client = AIProjectClient(endpoint=PROJECT_ENDPOINT, credential=credential) - connections = list(client.connections.list()) - if connections: - # Parse resource ID from any connection's ID - # Format: /subscriptions/.../connections/name - conn_id = connections[0].id - # Remove the /connections/... suffix to get the project resource ID - resource_id = conn_id.rsplit("/connections/", 1)[0] - client.close() + # Resolve the project ARM resource ID. + # Preferred path: build it directly from environment variables exported by + # post_deploy.{sh,ps1} (these come from azd env / deployment outputs). This + # avoids fragile data-plane discovery that fails for service principals on + # fresh deployments where no connections exist yet. + print("Resolving project resource ID...") + resource_id = _build_project_resource_id_from_env() + if resource_id: + print(f" Using project resource ID from environment: {resource_id}") + else: + print(" Env-based resolution unavailable. Trying data-plane discovery...") + resource_id = _discover_project_resource_id(credential) + if not resource_id: + # Last-resort: parse from any existing connection. + print(" Could not discover via data-plane. Trying existing connection...") + try: + from azure.ai.projects import AIProjectClient + + client = AIProjectClient(endpoint=PROJECT_ENDPOINT, credential=credential) + try: + connections = list(client.connections.list()) + except Exception as exc: # noqa: BLE001 + print(f" Connection list failed: {exc}") + connections = [] + finally: + client.close() + if connections: + # Format: /subscriptions/.../connections/name + conn_id = connections[0].id + resource_id = conn_id.rsplit("/connections/", 1)[0] + except Exception as exc: # noqa: BLE001 + print(f" AIProjectClient fallback failed: {exc}") if not resource_id: print("ERROR: Could not determine project ARM resource ID.") - print(" Ensure at least one connection exists or AZURE_AI_PROJECT_ENDPOINT is correct.") + print( + " Set one of the following so the script can build the resource ID:\n" + " - AZURE_AI_PROJECT_RESOURCE_ID (full ARM id), OR\n" + " - AI_FOUNDRY_RESOURCE_ID + AZURE_AI_PROJECT_NAME, OR\n" + " - AZURE_AI_SUBSCRIPTION_ID + AZURE_AI_RESOURCE_GROUP + AZURE_AI_PROJECT_ENDPOINT." + ) sys.exit(1) print(f" Project resource ID: {resource_id}")