diff --git a/README.md b/README.md index 6414230e..0a333f19 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ For detailed technical information, see the component READMEs: Foundry IQ provides fast, accurate product discovery and policy document retrieval using semantic and keyword search, enabling natural language queries across product catalogs and knowledge bases. Specialized agents access search indexes to retrieve relevant information from product catalogs and policy documents. - **Natural language interaction** - Microsoft Foundry's Agent Framework orchestrates multi-agent workflows using GPT-4.1-mini to deliver conversational, context-aware responses that understand customer intent. The framework maintains conversation threads and context across sessions, enabling natural, flowing conversations with specialized agents. + Microsoft Foundry's Agent Framework orchestrates multi-agent workflows using GPT-5.4-mini to deliver conversational, context-aware responses that understand customer intent. The framework maintains conversation threads and context across sessions, enabling natural, flowing conversations with specialized agents. - **Modern e-commerce experience** React-based frontend with dual-panel layout featuring product browsing and integrated AI chat assistant for seamless shopping and support experiences @@ -100,7 +100,7 @@ _Note: This is not meant to outline all costs as selected SKUs, scaled use, cust | Product | Description | Tier / Expected Usage Notes | Cost | |---|---|---|---| | [Microsoft Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry) | Used to orchestrate and build AI workflows with specialized agents for customer service. | Free Tier | [Pricing](https://azure.microsoft.com/pricing/details/ai-studio/) | -| [Azure AI Services (OpenAI)](https://learn.microsoft.com/en-us/azure/cognitive-services/openai/overview) | Enables language understanding and chat capabilities using GPT models for conversational AI. | S0 Tier; pricing depends on token volume and model used (e.g., GPT-4.1-mini). | [Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/) | +| [Azure AI Services (OpenAI)](https://learn.microsoft.com/en-us/azure/cognitive-services/openai/overview) | Enables language understanding and chat capabilities using GPT models for conversational AI. | S0 Tier; pricing depends on token volume and model used (e.g., GPT-5.4-mini). | [Pricing](https://azure.microsoft.com/pricing/details/cognitive-services/) | | [Foundry IQ](https://learn.microsoft.com/en-us/azure/search/search-what-is-azure-search) | Provides hybrid search capabilities for product catalogs and policy documents with semantic and keyword search. | Basic Tier; pricing based on search units and data storage. | [Pricing](https://azure.microsoft.com/pricing/details/search/) | | [Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/overview) | Hosts the frontend React application and backend FastAPI services. | Basic or Standard plan; includes a free tier for development. | [Pricing](https://azure.microsoft.com/pricing/details/app-service/windows/) | | [Azure Container Registry](https://learn.microsoft.com/en-us/azure/container-registry/container-registry-intro) | Stores and serves container images used by Azure App Service. | Basic Tier; fixed daily cost per registry. | [Pricing](https://azure.microsoft.com/pricing/details/container-registry/) | diff --git a/azure.yaml b/azure.yaml index f423a948..13c326c6 100644 --- a/azure.yaml +++ b/azure.yaml @@ -4,7 +4,7 @@ metadata: template: customer-chatbot-solution-accelerator@1.0 requiredVersions: - azd: ">= 1.18.0 != 1.23.9" + azd: ">= 1.18.0 != 1.23.9" hooks: postprovision: @@ -21,10 +21,13 @@ hooks: Write-Host " Post-Deployment Steps" -ForegroundColor Yellow Write-Host "----------------------------------------`n" -ForegroundColor Yellow - Write-Host "1. Run the data setup script to load sample product data:`n" + Write-Host "1. Build and push the backend/frontend container images to ACR, then point the web apps at them:`n" + Write-Host " infra\scripts\build_push_images.ps1`n" -ForegroundColor Cyan + + Write-Host "2. Run the data setup script to load sample product data:`n" Write-Host " infra\scripts\data_scripts\run_upload_data_scripts.ps1`n" -ForegroundColor Cyan - Write-Host "2. Run the agent setup script to create AI Foundry agents:`n" + Write-Host "3. Run the agent setup script to create AI Foundry agents:`n" Write-Host " infra\scripts\agent_scripts\run_create_agents_scripts.ps1`n" -ForegroundColor Cyan shell: pwsh continueOnError: false @@ -46,12 +49,17 @@ hooks: echo "----------------------------------------" echo "" - echo "1. Run the data setup script to load sample product data:" + echo "1. Build and push the backend/frontend container images to ACR, then point the web apps at them:" + echo "" + echo " bash ./infra/scripts/build_push_images.sh" + echo "" + + echo "2. Run the data setup script to load sample product data:" echo "" echo " bash ./infra/scripts/data_scripts/run_upload_data_scripts.sh" echo "" - echo "2. Run the agent setup script to create AI Foundry agents:" + echo "3. Run the agent setup script to create AI Foundry agents:" echo "" echo " bash ./infra/scripts/agent_scripts/run_create_agents_scripts.sh" echo "" diff --git a/documents/AzureGPTQuotaSettings.md b/documents/AzureGPTQuotaSettings.md index df1ec607..9f3e69a3 100644 --- a/documents/AzureGPTQuotaSettings.md +++ b/documents/AzureGPTQuotaSettings.md @@ -5,6 +5,6 @@ 3. **Go to** the `Management Center` from the bottom-left navigation menu. 4. Select `Quota` - Click on the `GlobalStandard` dropdown. - - Select the required **GPT model** (`GPT-4.1-mini`) or **Embeddings model** (`text-embedding-3-small`). + - Select the required **GPT model** (`gpt-5.4-mini`) or **Embeddings model** (`text-embedding-3-small`). - Choose the **region** where the deployment is hosted. 5. Request More Quota or delete any unused model deployments as needed. diff --git a/documents/CustomizingAzdParameters.md b/documents/CustomizingAzdParameters.md index 7c22973c..8eb572c2 100644 --- a/documents/CustomizingAzdParameters.md +++ b/documents/CustomizingAzdParameters.md @@ -13,8 +13,8 @@ By default this template will use the environment name as the prefix to prevent | `AZURE_ENV_NAME` | string | `docgen` | Sets the environment name prefix for all Azure resources. | | | `AZURE_ENV_AI_SERVICE_LOCATION` | string | `` | Sets the Azure region for AI service resource deployment. | | `AZURE_ENV_MODEL_DEPLOYMENT_TYPE` | string | `Standard` | Defines the model deployment type (allowed: `Standard`, `GlobalStandard`). | -| `AZURE_ENV_GPT_MODEL_NAME` | string | `gpt-4.1-mini` | Specifies the GPT model name (allowed: `gpt-4.1-mini`). | -| `AZURE_ENV_GPT_MODEL_VERSION` | string | `2025-04-14` | Set the Azure model version. | +| `AZURE_ENV_GPT_MODEL_NAME` | string | `gpt-5.4-mini` | Specifies the GPT model name (e.g., `gpt-5.4-mini`). | +| `AZURE_ENV_GPT_MODEL_VERSION` | string | `2026-03-17` | Set the Azure model version. | | `AZURE_ENV_GPT_MODEL_CAPACITY` | integer | `10` | Sets the GPT model capacity (based on what's available in your subscription). | | `AZURE_ENV_EMBEDDING_DEPLOYMENT_CAPACITY` | integer | `10` | Sets the embedding model deployment capacity (minimum: 10). | | `AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT` | string | `ccbcontainerreg.azurecr.io` | Sets the Azure Container Registry login server/endpoint (for example: `ccbcontainerreg.azurecr.io`). | diff --git a/documents/DeploymentGuide.md b/documents/DeploymentGuide.md index 4eefc9d0..87440ce9 100644 --- a/documents/DeploymentGuide.md +++ b/documents/DeploymentGuide.md @@ -46,7 +46,7 @@ Ensure you have access to an [Azure subscription](https://azure.microsoft.com/fr **Required Azure Services:** - [Azure AI Foundry](https://learn.microsoft.com/en-us/azure/ai-foundry/) - For Agent Framework orchestration and AI project management -- [Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/ai-services/openai/) - For GPT-4.1-mini model deployments +- [Azure OpenAI Service](https://learn.microsoft.com/en-us/azure/ai-services/openai/) - For GPT-5.4-mini model deployments - [Azure AI Search](https://learn.microsoft.com/en-us/azure/search/) - For hybrid search across product catalogs and policy documents - [Azure Cosmos DB](https://learn.microsoft.com/en-us/azure/cosmos-db/) - For storing product catalogs, orders, and chat history - [Azure App Service](https://learn.microsoft.com/en-us/azure/app-service/) - For hosting frontend and backend applications @@ -62,10 +62,10 @@ Ensure you have access to an [Azure subscription](https://azure.microsoft.com/fr 📖 **Follow:** [Quota Check Instructions](./QuotaCheck.md) to ensure sufficient capacity. **Default Quota Configuration:** -- **gpt-4.1-mini:** 50k tokens +- **gpt-5.4-mini:** 50k tokens **Recommended Configuration:** -- **Minimum:** 50k tokens for Global Standard GPT-4.1-mini +- **Minimum:** 50k tokens for Global Standard GPT-5.4-mini - **Optimal:** More than 50k tokens (for best performance) > **Note:** When you run `azd up`, the deployment will automatically show you regions with available quota, so this pre-check is optional but helpful for planning purposes. You can customize these settings later in [Step 3.3: Advanced Configuration](#33-advanced-configuration-optional). @@ -335,7 +335,38 @@ source .venv/bin/activate ### 5.2 Initialize Data and Agents -**Step 1: Populate Product Catalogs and Search Indexes** +**Step 1: Build and push container images** + +The initial deployment configures the App Services with a placeholder container. Run the ACR build script to build the real backend/frontend images inside Azure Container Registry and point both web apps at them: + +- **For PowerShell (Windows/Linux/macOS):** + ```shell + infra\scripts\build_push_images.ps1 + ``` +- **For Bash (Linux/macOS/WSL):** + ```bash + bash ./infra/scripts/build_push_images.sh + ``` + +**If you deployed using `AVM`:** + +- **For PowerShell (Windows/Linux/macOS):** + ```shell + infra\scripts\build_push_images.ps1 -ResourceGroup "" + ``` +- **For Bash (Linux/macOS/WSL):** + ```bash + bash ./infra/scripts/build_push_images.sh --resource-group "" + ``` + +This script will: +- Build the backend (`src/api`) and frontend (`src/App`) images remotely using `az acr build` (no local Docker required) +- Push them to your Azure Container Registry +- Update both App Services (`api-` and `app-`) to run the new image and restart them + +> **Tip:** Pass `-ImageTag ` (PowerShell) or `--image-tag ` (bash) to publish a specific tag. Pass `-ShowLogs` / `--show-logs` to stream the full build output. Each run generates a fresh timestamp tag by default. + +**Step 2: Populate Product Catalogs and Search Indexes** Run the data setup script to load sample product data @@ -364,7 +395,7 @@ This script will: - Create and configure Azure AI Search indexes - Populate search indexes with product and policy documents -**Step 2: Create AI Foundry Agents** +**Step 3: Create AI Foundry Agents** Run the data setup script to load sample product data and create search indexes in Azure AI Search: - **For PowerShell (Windows/Linux/macOS):** diff --git a/documents/LocalDevelopmentSetup.md b/documents/LocalDevelopmentSetup.md index c82555be..fbac4fd5 100644 --- a/documents/LocalDevelopmentSetup.md +++ b/documents/LocalDevelopmentSetup.md @@ -221,12 +221,12 @@ ALLOWED_ORIGINS_STR="*" # Azure AI Foundry AZURE_AI_AGENT_ENDPOINT="https://your-ai-services.services.ai.azure.com/api/projects/your-project" -AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME="gpt-4.1-mini" +AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME="gpt-5.4-mini" AZURE_FOUNDRY_ENDPOINT="https://your-ai-services.services.ai.azure.com/api/projects/your-project" # Azure OpenAI AZURE_OPENAI_ENDPOINT="https://your-openai.openai.azure.com/" -AZURE_OPENAI_DEPLOYMENT_NAME="gpt-4.1-mini" +AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" AZURE_OPENAI_API_VERSION="2025-01-01-preview" # Azure AI Search diff --git a/documents/QuotaCheck.md b/documents/QuotaCheck.md index 2765ab69..ee2100a2 100644 --- a/documents/QuotaCheck.md +++ b/documents/QuotaCheck.md @@ -10,7 +10,7 @@ azd auth login ### 📌 Default Models & Capacities: ``` -gpt-4.1-mini:50,text-embedding-3-small:10,gpt-realtime-mini:1 +gpt-5.4-mini:50,text-embedding-3-small:10,gpt-realtime-mini:1 ``` ### 📌 Default Regions: ``` @@ -48,7 +48,7 @@ eastus, uksouth, eastus2, northcentralus, swedencentral, westus, westus2, southc ``` ✔️ All parameters combined: ``` - ./quota_check_params.sh --models gpt-4:150,gpt-4.1-mini:150 --regions eastus,westus --verbose + ./quota_check_params.sh --models gpt-4:150,gpt-5.4-mini:150 --regions eastus,westus --verbose ``` ### **Sample Output** diff --git a/documents/TechnicalArchitecture.md b/documents/TechnicalArchitecture.md index f02fd759..b7e85318 100644 --- a/documents/TechnicalArchitecture.md +++ b/documents/TechnicalArchitecture.md @@ -12,7 +12,7 @@ Orchestrates multi-agent workflows with an intelligent orchestrator agent that u - **Policy/Knowledge Agent**: Retrieves information from policy documents and knowledge bases to answer customer support questions about warranties, returns, and company policies ### Azure OpenAI Service -Provides large language model (LLM) capabilities using GPT-4.1-mini to power natural language understanding and conversational responses across all AI agents. +Provides large language model (LLM) capabilities using GPT-5.4-mini to power natural language understanding and conversational responses across all AI agents. ### Azure AI Search Provides hybrid search capabilities combining semantic and keyword search across product catalogs and policy documents. Enables fast, accurate retrieval of relevant information for specialized agents. diff --git a/infra/main.bicep b/infra/main.bicep index 987fd247..5fc7b896 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -24,13 +24,13 @@ param solutionUniqueText string = take(uniqueString(subscription().id, resourceG ]) param location string -// Restricting deployment to regions that support all deployed models: gpt-4.1-mini, text-embedding-3-small, and gpt-realtime-mini (GlobalStandard) -@allowed(['eastus2', 'francecentral', 'swedencentral']) +// Restricting deployment to regions that support all deployed models: gpt-5.4-mini, text-embedding-3-small, and gpt-realtime-mini (GlobalStandard) +@allowed(['eastus2', 'francecentral', 'swedencentral', 'centralus', 'southindia']) @metadata({ azd:{ type: 'location' usageName: [ - 'OpenAI.GlobalStandard.gpt4.1-mini,50' + 'OpenAI.GlobalStandard.gpt-5.4-mini,50' 'OpenAI.GlobalStandard.gpt-realtime-mini,1' ] } @@ -43,10 +43,10 @@ param secondaryLocation string = 'canadacentral' @minLength(1) @description('Optional. Name of the GPT model to deploy:') -param gptModelName string = 'gpt-4.1-mini' +param gptModelName string = 'gpt-5.4-mini' -@description('Optional. Version of the GPT model to deploy. Defaults to 2025-04-14.') -param gptModelVersion string = '2025-04-14' +@description('Optional. Version of the GPT model to deploy. Defaults to 2026-03-17.') +param gptModelVersion string = '2026-03-17' @description('Optional. Version of the OpenAI API.') param azureOpenaiAPIVersion string = '2025-01-01-preview' @@ -106,13 +106,6 @@ param vmAdminPassword string? @description('Optional. Size of the Jumpbox Virtual Machine. Allows to customize VM size if `enablePrivateNetworking` is set to true. See https://learn.microsoft.com/azure/virtual-machines/sizes for available sizes.') param vmSize string = 'Standard_D2s_v5' -// These parameters are changed for testing - please reset as part of publication -@description('Optional. The host (excluding https://) of an existing container registry. This is the `loginServer` when using Azure Container Registry.') -param containerRegistryEndpoint string = 'ccbcontainerreg.azurecr.io' - -@description('Optional. The image tag to use for container images. Defaults to "latest_v2".') -param imageTag string = 'latest_v2' - @description('Optional. Enable/Disable usage telemetry for module.') param enableTelemetry bool = true @@ -301,6 +294,7 @@ module applicationInsights 'br/public:avm/res/insights/component:0.7.1' = if (en flowType: 'Bluefield' // WAF aligned configuration for Monitoring workspaceResourceId: enableMonitoring ? logAnalyticsWorkspaceResourceId : '' + diagnosticSettings: enableMonitoring ? [{ workspaceResourceId: logAnalyticsWorkspaceResourceId }] : null } } @@ -611,6 +605,7 @@ var privateDnsZones = [ 'privatelink.documents.azure.com' 'privatelink.search.windows.net' 'privatelink.azurewebsites.net' + 'privatelink.azurecr.io' ] // DNS Zone Index Constants @@ -621,6 +616,7 @@ var dnsZoneIndex = { cosmosDb: 3 search: 4 webApp: 5 + containerRegistry: 4 } // List of DNS zone indices that correspond to AI-related services. @@ -1082,6 +1078,61 @@ module cosmosDb 'br/public:avm/res/document-db/database-account:0.19.0' = { } } +// ========== Container Registry ========== // +module containerRegistry 'br/public:avm/res/container-registry/registry:0.12.1' = { + name: take('avm.res.container-registry.registry.cr${solutionSuffix}', 64) + params: { + name: 'cr${solutionSuffix}' + acrAdminUserEnabled: false + // Premium is required for private endpoints and network rules + acrSku: (enableScalability || enablePrivateNetworking) ? 'Premium' : 'Basic' + azureADAuthenticationAsArmPolicyStatus: 'enabled' + exportPolicyStatus: 'enabled' + location: location + softDeletePolicyDays: 7 + softDeletePolicyStatus: 'disabled' + tags: tags + publicNetworkAccess: enablePrivateNetworking ? 'Disabled' : 'Enabled' + privateEndpoints: enablePrivateNetworking + ? [ + { + name: 'pep-cr${solutionSuffix}' + customNetworkInterfaceName: 'nic-cr${solutionSuffix}' + privateDnsZoneGroup: { + privateDnsZoneGroupConfigs: [ + { privateDnsZoneResourceId: avmPrivateDnsZones[dnsZoneIndex.containerRegistry]!.outputs.resourceId } + ] + } + service: 'registry' + subnetResourceId: virtualNetwork!.outputs.backendSubnetResourceId + } + ] + : [] + // networkRuleBypassOptions and networkRuleSet are Premium-only; suppress them on Basic. + // The AVM module emits a networkRuleSet whenever defaultAction is 'Deny' (its default) with public access enabled, + // which ARM rejects with 'NetworkRuleNotSupported' on the Basic SKU. + networkRuleBypassOptions: (enableScalability || enablePrivateNetworking) ? 'AzureServices' : null + networkRuleSetDefaultAction: (enableScalability || enablePrivateNetworking) ? 'Deny' : 'Allow' + roleAssignments: [ + { + roleDefinitionIdOrName: acrPullRole + principalType: 'ServicePrincipal' + principalId: webSiteBackend.outputs.systemAssignedMIPrincipalId! + } + { + roleDefinitionIdOrName: acrPullRole + principalType: 'ServicePrincipal' + principalId: webSite.outputs.systemAssignedMIPrincipalId! + } + ] + } +} + +var acrPullRole = subscriptionResourceId( + 'Microsoft.Authorization/roleDefinitions', + '7f951dda-4ed3-4680-a7ca-43fe172d538d' +) + // ========== Web server farm ========== // // WAF best practices for Web Application Services: https://learn.microsoft.com/en-us/azure/well-architected/service-guides/app-service-web-apps // PSRule for Web Server Farm: https://azure.github.io/PSRule.Rules.Azure/en/rules/resource/#app-service @@ -1128,11 +1179,13 @@ module webSiteBackend 'modules/web-sites.bicep' = { systemAssigned: true } siteConfig: { - linuxFxVersion: 'DOCKER|${containerRegistryEndpoint}/backend:${imageTag}' + linuxFxVersion: 'DOCKER|mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' minTlsVersion: '1.2' healthCheckPath: '/health' webSocketsEnabled: true + acrUseManagedIdentityCreds: true } + e2eEncryptionEnabled: true configs: [ { name: 'appsettings' @@ -1215,7 +1268,7 @@ module webSiteBackend 'modules/web-sites.bicep' = { } } -// ========== Additional Cosmos DB Role Assignment for Backend App Service ==========// +// ========== Additional Cosmos DB Role Assignment for Backend App Service ========== // // Add the backend App Service's system-assigned managed identity to Cosmos DB role resource cosmosDbBackendRoleAssignment 'Microsoft.DocumentDB/databaseAccounts/sqlRoleAssignments@2025-11-01-preview' = { name: '${cosmosDbResourceName}/${guid(subscription().id, resourceGroup().id, backendWebSiteResourceName, 'CosmosDBDataContributor')}' @@ -1318,10 +1371,14 @@ module webSite 'modules/web-sites.bicep' = { tags: tags location: location kind: 'app,linux,container' + managedIdentities: { + systemAssigned: true + } serverFarmResourceId: webServerFarm.?outputs.resourceId siteConfig: { - linuxFxVersion: 'DOCKER|${containerRegistryEndpoint}/frontend:${imageTag}' + linuxFxVersion: 'DOCKER|mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' minTlsVersion: '1.2' + acrUseManagedIdentityCreds: true } configs: [ { @@ -1428,12 +1485,6 @@ output AZURE_FOUNDRY_ENDPOINT string = aiFoundryAiProjectEndpoint @description('Azure AI Agent model deployment name') output AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME string = gptModelName -@description('Name of the Azure Container Registry') -output ACR_NAME string = split(containerRegistryEndpoint, '.')[0] - -@description('Container image tag for the backend service') -output AZURE_ENV_IMAGETAG string = imageTag - @description('Name of the Azure AI Services resource') output AI_SERVICE_NAME string = aiFoundryAiServicesResourceName @@ -1472,3 +1523,9 @@ output AI_SEARCH_SERVICE_RESOURCE_ID string = searchService.id @description('Application environment (Production)') output APP_ENV string = 'Prod' + +@description('Azure Container Registry endpoint URL') +output AZURE_CONTAINER_REGISTRY_ENDPOINT string = containerRegistry.outputs.loginServer + +@description('Azure Container Registry name') +output AZURE_CONTAINER_REGISTRY_NAME string = containerRegistry.outputs.name diff --git a/infra/main.parameters.json b/infra/main.parameters.json index eed5cd39..25b63fec 100644 --- a/infra/main.parameters.json +++ b/infra/main.parameters.json @@ -32,12 +32,6 @@ "existingFoundryProjectResourceId": { "value": "${AZURE_EXISTING_AIPROJECT_RESOURCE_ID}" }, - "containerRegistryEndpoint": { - "value": "${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT}" - }, - "imageTag": { - "value": "${AZURE_ENV_IMAGETAG=latest_v2}" - }, "embeddingDeploymentCapacity": { "value": "${AZURE_ENV_EMBEDDING_DEPLOYMENT_CAPACITY}" }, diff --git a/infra/main.waf.parameters.json b/infra/main.waf.parameters.json index 75da35b2..7d66ad34 100644 --- a/infra/main.waf.parameters.json +++ b/infra/main.waf.parameters.json @@ -38,12 +38,6 @@ "existingFoundryProjectResourceId": { "value": "${AZURE_EXISTING_AIPROJECT_RESOURCE_ID}" }, - "containerRegistryEndpoint": { - "value": "${AZURE_ENV_CONTAINER_REGISTRY_ENDPOINT}" - }, - "imageTag": { - "value": "${AZURE_ENV_IMAGETAG=latest_v2}" - }, "enableMonitoring": { "value": true }, diff --git a/infra/scripts/build_push_images.ps1 b/infra/scripts/build_push_images.ps1 new file mode 100644 index 00000000..3870303d --- /dev/null +++ b/infra/scripts/build_push_images.ps1 @@ -0,0 +1,267 @@ +#!/usr/bin/env pwsh +#Requires -Version 7.0 + +<# +.SYNOPSIS + Builds the backend (src/api) and frontend (src/App) container images inside + Azure Container Registry using `az acr build`, then updates the two App + Services (api- and app-) to run those images. + +.DESCRIPTION + Uses `az acr build` so image build/push happens in ACR (no local Docker + required). Values are read from the current `azd` environment when + possible, otherwise from the last successful ARM deployment in the + resource group. All parameters can be overridden on the command line. + +.PARAMETER ResourceGroup + Azure resource group that contains ACR and the App Services. +.PARAMETER AcrName + Azure Container Registry name (without the .azurecr.io suffix). +.PARAMETER BackendAppName + Backend App Service name (defaults to api-). +.PARAMETER FrontendAppName + Frontend App Service name (defaults to app-). +.PARAMETER ImageTag + Tag to apply to both images. Defaults to a UTC timestamp. +.PARAMETER BackendImage + Backend image repository name. Defaults to "backend". +.PARAMETER FrontendImage + Frontend image repository name. Defaults to "frontend". +.PARAMETER SkipBackend + Skip building/updating the backend. +.PARAMETER SkipFrontend + Skip building/updating the frontend. + +.EXAMPLE + ./build_push_images.ps1 + +.EXAMPLE + ./build_push_images.ps1 -ResourceGroup rg-ccsa -ImageTag v1.2.3 +#> + +param( + [string]$ResourceGroup, + [string]$AcrName, + [string]$BackendAppName, + [string]$FrontendAppName, + [string]$ImageTag, + [string]$BackendImage = 'backend', + [string]$FrontendImage = 'frontend', + [switch]$SkipBackend, + [switch]$SkipFrontend, + [switch]$ShowLogs +) + +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir '..' '..') +$backendCtx = Join-Path $repoRoot 'src/api' +$frontendCtx = Join-Path $repoRoot 'src/App' + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +function Test-CommandAvailable { + param([string]$Name) + return [bool](Get-Command $Name -ErrorAction SilentlyContinue) +} + +function Get-AzdEnvValue { + param([string]$Key) + if (-not (Test-CommandAvailable 'azd')) { return $null } + $value = azd env get-value $Key 2>$null + if ($LASTEXITCODE -ne 0) { return $null } + if ([string]::IsNullOrWhiteSpace($value)) { return $null } + # Newer azd may print a "key not found" error to stdout for missing keys. + if ($value -match 'ERROR:' -or $value -match 'not found in environment') { return $null } + return $value.Trim() +} + +function Get-DeploymentOutputs { + param([string]$Rg) + $deploymentName = az group show --name $Rg --query 'tags.DeploymentName' -o tsv 2>$null + if ([string]::IsNullOrWhiteSpace($deploymentName)) { + # Fall back to most recent deployment + $deploymentName = az deployment group list --resource-group $Rg --query '[0].name' -o tsv 2>$null + } + if ([string]::IsNullOrWhiteSpace($deploymentName)) { return $null } + $json = az deployment group show --resource-group $Rg --name $deploymentName --query 'properties.outputs' -o json 2>$null + if ([string]::IsNullOrWhiteSpace($json)) { return $null } + return ($json | ConvertFrom-Json) +} + +function Get-OutputValue { + param($Outputs, [string[]]$Keys) + if ($null -eq $Outputs) { return $null } + foreach ($k in $Keys) { + $prop = $Outputs.PSObject.Properties[$k] + if ($prop -and -not [string]::IsNullOrWhiteSpace($prop.Value.value)) { + return $prop.Value.value.ToString().Trim() + } + } + return $null +} + +function Invoke-AcrBuild { + param( + [string]$Registry, + [string]$Image, + [string]$Tag, + [string]$Context + ) + Write-Host "Building $Registry/$Image`:$Tag from $Context" -ForegroundColor Cyan + + if ($ShowLogs) { + az acr build ` + --registry $Registry ` + --image "$Image`:$Tag" ` + --image "$Image`:latest" ` + --only-show-errors ` + $Context + if ($LASTEXITCODE -ne 0) { throw "az acr build failed for image $Image" } + return + } + + # Quiet mode: no log stream, no CLI warnings, no JSON dump. Only run ID + result. + Write-Host " (streaming logs suppressed; pass -ShowLogs to see full output)" -ForegroundColor DarkGray + $jsonRaw = az acr build ` + --registry $Registry ` + --image "$Image`:$Tag" ` + --image "$Image`:latest" ` + --no-logs ` + --only-show-errors ` + -o json ` + $Context 2>&1 + $exit = $LASTEXITCODE + + $runObj = $null + try { $runObj = ($jsonRaw | Out-String) | ConvertFrom-Json -ErrorAction Stop } catch { } + + if ($exit -ne 0) { + # Success path emits JSON with .runId; failure path emits only + # `ERROR: The run with ID 'caXX' finished with unsuccessful status ...` + $runId = if ($runObj) { $runObj.runId } else { $null } + if (-not $runId) { + $text = ($jsonRaw | Out-String) + $m = [regex]::Match($text, "run with ID '([^']+)'") + if ($m.Success) { $runId = $m.Groups[1].Value } + } + + if ($runId) { + Write-Host "" + Write-Host "Build failed (runId: $runId). Fetching logs..." -ForegroundColor Red + az acr task logs --registry $Registry --run-id $runId --only-show-errors + } else { + $jsonRaw | ForEach-Object { Write-Host $_ } + } + throw "az acr build failed for image $Image" + } + + if ($runObj) { + Write-Host " Succeeded (runId: $($runObj.runId))" -ForegroundColor Green + } +} + +function Set-WebAppContainer { + param( + [string]$Rg, + [string]$AppName, + [string]$AcrLoginServer, + [string]$Image, + [string]$Tag + ) + $fullImage = "$AcrLoginServer/$Image`:$Tag" + Write-Host "Updating web app '$AppName' -> $fullImage" -ForegroundColor Cyan + az webapp config container set ` + --name $AppName ` + --resource-group $Rg ` + --container-image-name $fullImage ` + --only-show-errors | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to update container config for $AppName" } + + Write-Host "Restarting '$AppName'..." -ForegroundColor Cyan + az webapp restart --name $AppName --resource-group $Rg --only-show-errors | Out-Null + if ($LASTEXITCODE -ne 0) { throw "Failed to restart $AppName" } +} + +# --------------------------------------------------------------------------- +# Pre-flight checks +# --------------------------------------------------------------------------- +if (-not (Test-CommandAvailable 'az')) { + throw "Azure CLI ('az') is required but was not found on PATH." +} + +# --------------------------------------------------------------------------- +# Resolve inputs +# --------------------------------------------------------------------------- +if (-not $ResourceGroup) { $ResourceGroup = Get-AzdEnvValue 'AZURE_RESOURCE_GROUP' } +if (-not $AcrName) { $AcrName = Get-AzdEnvValue 'AZURE_CONTAINER_REGISTRY_NAME' } +if (-not $AcrName) { $AcrName = Get-AzdEnvValue 'ACR_NAME' } +if (-not $BackendAppName) { $BackendAppName = Get-AzdEnvValue 'API_APP_NAME' } +$solutionSuffix = Get-AzdEnvValue 'SOLUTION_NAME' +if (-not $FrontendAppName -and $solutionSuffix) { $FrontendAppName = "app-$solutionSuffix" } +if (-not $ImageTag) { $ImageTag = Get-AzdEnvValue 'AZURE_ENV_IMAGETAG' } + +# Fall back to deployment outputs for anything still missing +$needDeployment = -not ($ResourceGroup -and $AcrName -and $BackendAppName -and $FrontendAppName) +if ($needDeployment) { + if (-not $ResourceGroup) { + throw "ResourceGroup is required. Pass -ResourceGroup or run inside an azd environment." + } + Write-Host "Fetching deployment outputs from resource group '$ResourceGroup'..." -ForegroundColor DarkGray + $outputs = Get-DeploymentOutputs -Rg $ResourceGroup + if (-not $AcrName) { $AcrName = Get-OutputValue $outputs @('AZURE_CONTAINER_REGISTRY_NAME','azureContainerRegistryName','ACR_NAME','acrName') } + if (-not $BackendAppName) { $BackendAppName = Get-OutputValue $outputs @('API_APP_NAME','apiAppName') } + if (-not $solutionSuffix) { $solutionSuffix = Get-OutputValue $outputs @('SOLUTION_NAME','solutionName') } + if (-not $FrontendAppName -and $solutionSuffix) { $FrontendAppName = "app-$solutionSuffix" } +} + +if (-not $ImageTag) { + $ImageTag = (Get-Date -Format 'yyyyMMddHHmmss') +} + +if (-not $ResourceGroup) { throw "Missing ResourceGroup." } +if (-not $AcrName) { throw "Missing AcrName." } +if (-not $SkipBackend -and -not $BackendAppName) { throw "Missing BackendAppName." } +if (-not $SkipFrontend -and -not $FrontendAppName) { throw "Missing FrontendAppName." } + +$acrLoginServer = "$AcrName.azurecr.io" + +Write-Host "" +Write-Host "Configuration" -ForegroundColor Green +Write-Host " Resource group : $ResourceGroup" +Write-Host " ACR : $acrLoginServer" +Write-Host " Backend app : $BackendAppName" +Write-Host " Frontend app : $FrontendAppName" +Write-Host " Image tag : $ImageTag" +Write-Host "" + +# --------------------------------------------------------------------------- +# Build & deploy +# --------------------------------------------------------------------------- +if (-not $SkipBackend) { + if (-not (Test-Path (Join-Path $backendCtx 'Dockerfile'))) { + throw "Backend Dockerfile not found at $backendCtx/Dockerfile" + } + Invoke-AcrBuild -Registry $AcrName -Image $BackendImage -Tag $ImageTag -Context $backendCtx + Set-WebAppContainer -Rg $ResourceGroup -AppName $BackendAppName -AcrLoginServer $acrLoginServer -Image $BackendImage -Tag $ImageTag +} else { + Write-Host "Skipping backend (SkipBackend)." -ForegroundColor Yellow +} + +if (-not $SkipFrontend) { + if (-not (Test-Path (Join-Path $frontendCtx 'Dockerfile'))) { + throw "Frontend Dockerfile not found at $frontendCtx/Dockerfile" + } + Invoke-AcrBuild -Registry $AcrName -Image $FrontendImage -Tag $ImageTag -Context $frontendCtx + Set-WebAppContainer -Rg $ResourceGroup -AppName $FrontendAppName -AcrLoginServer $acrLoginServer -Image $FrontendImage -Tag $ImageTag +} else { + Write-Host "Skipping frontend (SkipFrontend)." -ForegroundColor Yellow +} + +Write-Host "" +Write-Host "Done. Images published with tag '$ImageTag'." -ForegroundColor Green diff --git a/infra/scripts/build_push_images.sh b/infra/scripts/build_push_images.sh new file mode 100644 index 00000000..7b937286 --- /dev/null +++ b/infra/scripts/build_push_images.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# Builds the backend (src/api) and frontend (src/App) container images inside +# Azure Container Registry using `az acr build`, then updates the two App +# Services (api- and app-) to run those images. +# +# Values are pulled from the current `azd` environment when available, with +# fallback to the last ARM deployment in the resource group. Any value can be +# overridden on the command line. +# +# Usage: +# ./build_push_images.sh [options] +# +# Options: +# --resource-group Azure resource group +# --acr-name ACR name (without .azurecr.io) +# --backend-app Backend App Service name (api-) +# --frontend-app Frontend App Service name (app-) +# --image-tag Image tag (defaults to UTC timestamp) +# --backend-image Backend repository name (default: backend) +# --frontend-image Frontend repository name (default: frontend) +# --skip-backend Skip backend build/deploy +# --skip-frontend Skip frontend build/deploy +# --show-logs Stream full ACR build logs (default: quiet, only dump logs on failure) +# -h, --help Show this help +# --------------------------------------------------------------------------- +set -euo pipefail + +RESOURCE_GROUP="" +ACR_NAME="" +BACKEND_APP="" +FRONTEND_APP="" +IMAGE_TAG="" +BACKEND_IMAGE="backend" +FRONTEND_IMAGE="frontend" +SKIP_BACKEND=false +SKIP_FRONTEND=false +SHOW_LOGS=false + +usage() { + sed -n '2,25p' "$0" + exit "${1:-0}" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --resource-group) RESOURCE_GROUP="$2"; shift 2 ;; + --acr-name) ACR_NAME="$2"; shift 2 ;; + --backend-app) BACKEND_APP="$2"; shift 2 ;; + --frontend-app) FRONTEND_APP="$2"; shift 2 ;; + --image-tag) IMAGE_TAG="$2"; shift 2 ;; + --backend-image) BACKEND_IMAGE="$2"; shift 2 ;; + --frontend-image) FRONTEND_IMAGE="$2"; shift 2 ;; + --skip-backend) SKIP_BACKEND=true; shift ;; + --skip-frontend) SKIP_FRONTEND=true; shift ;; + --show-logs) SHOW_LOGS=true; shift ;; + -h|--help) usage 0 ;; + *) echo "Unknown option: $1" >&2; usage 1 ;; + esac +done + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +BACKEND_CTX="$REPO_ROOT/src/api" +FRONTEND_CTX="$REPO_ROOT/src/App" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +command -v az >/dev/null 2>&1 || { echo "ERROR: Azure CLI ('az') is required." >&2; exit 1; } + +azd_available() { command -v azd >/dev/null 2>&1; } + +azd_get() { + local key="$1" value="" + azd_available || return 0 + # A missing key makes azd exit non-zero (and may print an error to stdout); + # treat that as "not set" so callers fall back to their defaults. + value="$(azd env get-value "$key" 2>/dev/null)" || return 0 + case "$value" in + *ERROR:*|*"not found in environment"*) return 0 ;; + esac + printf '%s' "$value" +} + +# Extract a top-level output value from `az deployment group show` JSON. Uses +# case-insensitive key match and tolerates absence of jq (falls back to grep). +extract_output() { + local json="$1"; shift + local key + for key in "$@"; do + local val="" + if command -v jq >/dev/null 2>&1; then + val="$(echo "$json" | jq -r --arg k "$key" ' + to_entries + | map(select(.key | ascii_downcase == ($k | ascii_downcase))) + | .[0].value.value // empty')" + else + val="$(echo "$json" | grep -i -A 3 "\"$key\"" | grep '"value"' | sed 's/.*"value": *"\([^"]*\)".*/\1/' | head -1)" + fi + val="$(echo -n "$val" | xargs || true)" + if [[ -n "$val" ]]; then + printf '%s' "$val" + return 0 + fi + done +} + +fetch_deployment_outputs() { + local rg="$1" + local dep + dep="$(az group show --name "$rg" --query 'tags.DeploymentName' -o tsv 2>/dev/null || true)" + if [[ -z "$dep" ]]; then + dep="$(az deployment group list --resource-group "$rg" --query '[0].name' -o tsv 2>/dev/null || true)" + fi + [[ -z "$dep" ]] && return 0 + az deployment group show --resource-group "$rg" --name "$dep" --query 'properties.outputs' -o json 2>/dev/null || true +} + +run_acr_build() { + local registry="$1" image="$2" tag="$3" context="$4" + echo "" + echo ">>> Building ${registry}.azurecr.io/${image}:${tag}" + + if $SHOW_LOGS; then + az acr build \ + --registry "$registry" \ + --image "${image}:${tag}" \ + --image "${image}:latest" \ + --only-show-errors \ + "$context" + return + fi + + echo " (streaming logs suppressed; pass --show-logs to see full output)" + local out exit_code=0 run_id="" + set +e + out="$(az acr build \ + --registry "$registry" \ + --image "${image}:${tag}" \ + --image "${image}:latest" \ + --no-logs \ + --only-show-errors \ + -o json \ + "$context" 2>&1)" + exit_code=$? + set -e + + if command -v jq >/dev/null 2>&1; then + run_id="$(printf '%s' "$out" | jq -r '.runId // empty' 2>/dev/null || true)" + else + run_id="$(printf '%s' "$out" | grep -oE '"runId":[[:space:]]*"[^"]+"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/')" + fi + # On failure the CLI does not emit JSON, only: + # ERROR: The run with ID 'caXX' finished with unsuccessful status ... + if [[ -z "$run_id" ]]; then + run_id="$(printf '%s' "$out" | grep -oE "run with ID '[^']+'" | head -1 | sed "s/.*'\([^']*\)'.*/\1/")" + fi + + if [[ $exit_code -ne 0 ]]; then + if [[ -n "$run_id" ]]; then + echo "" + echo "Build failed (runId: $run_id). Fetching logs..." >&2 + az acr task logs --registry "$registry" --run-id "$run_id" --only-show-errors || true + else + printf '%s\n' "$out" >&2 + fi + echo "ERROR: az acr build failed for image $image" >&2 + return $exit_code + fi + + if [[ -n "$run_id" ]]; then + echo " Succeeded (runId: $run_id)" + fi +} + +update_webapp() { + local rg="$1" app="$2" login_server="$3" image="$4" tag="$5" + local full="${login_server}/${image}:${tag}" + echo "" + echo ">>> Updating web app '$app' -> $full" + az webapp config container set \ + --name "$app" \ + --resource-group "$rg" \ + --container-image-name "$full" \ + --only-show-errors >/dev/null + + echo ">>> Restarting '$app'..." + az webapp restart --name "$app" --resource-group "$rg" --only-show-errors >/dev/null +} + +# --------------------------------------------------------------------------- +# Resolve inputs +# --------------------------------------------------------------------------- +[[ -z "$RESOURCE_GROUP" ]] && RESOURCE_GROUP="$(azd_get AZURE_RESOURCE_GROUP)" +[[ -z "$ACR_NAME" ]] && ACR_NAME="$(azd_get AZURE_CONTAINER_REGISTRY_NAME)" +[[ -z "$ACR_NAME" ]] && ACR_NAME="$(azd_get ACR_NAME)" +[[ -z "$BACKEND_APP" ]] && BACKEND_APP="$(azd_get API_APP_NAME)" +SOLUTION_SUFFIX="$(azd_get SOLUTION_NAME)" +[[ -z "$FRONTEND_APP" && -n "$SOLUTION_SUFFIX" ]] && FRONTEND_APP="app-${SOLUTION_SUFFIX}" +[[ -z "$IMAGE_TAG" ]] && IMAGE_TAG="$(azd_get AZURE_ENV_IMAGETAG)" + +if [[ -z "$ACR_NAME" || -z "$BACKEND_APP" || -z "$FRONTEND_APP" ]]; then + [[ -z "$RESOURCE_GROUP" ]] && { echo "ERROR: --resource-group required (or run inside an azd env)." >&2; exit 1; } + echo "Fetching deployment outputs from '$RESOURCE_GROUP'..." + OUTPUTS_JSON="$(fetch_deployment_outputs "$RESOURCE_GROUP")" + if [[ -n "$OUTPUTS_JSON" ]]; then + [[ -z "$ACR_NAME" ]] && ACR_NAME="$(extract_output "$OUTPUTS_JSON" AZURE_CONTAINER_REGISTRY_NAME azureContainerRegistryName ACR_NAME acrName)" + [[ -z "$BACKEND_APP" ]] && BACKEND_APP="$(extract_output "$OUTPUTS_JSON" API_APP_NAME apiAppName)" + if [[ -z "$SOLUTION_SUFFIX" ]]; then + SOLUTION_SUFFIX="$(extract_output "$OUTPUTS_JSON" SOLUTION_NAME solutionName)" + fi + [[ -z "$FRONTEND_APP" && -n "$SOLUTION_SUFFIX" ]] && FRONTEND_APP="app-${SOLUTION_SUFFIX}" + fi +fi + +[[ -z "$IMAGE_TAG" ]] && IMAGE_TAG="$(date -u +%Y%m%d%H%M%S)" + +[[ -z "$RESOURCE_GROUP" ]] && { echo "ERROR: Missing resource group." >&2; exit 1; } +[[ -z "$ACR_NAME" ]] && { echo "ERROR: Missing ACR name." >&2; exit 1; } +$SKIP_BACKEND || [[ -n "$BACKEND_APP" ]] || { echo "ERROR: Missing backend app name." >&2; exit 1; } +$SKIP_FRONTEND || [[ -n "$FRONTEND_APP" ]] || { echo "ERROR: Missing frontend app name." >&2; exit 1; } + +ACR_LOGIN_SERVER="${ACR_NAME}.azurecr.io" + +cat <&2; exit 1; } + run_acr_build "$ACR_NAME" "$BACKEND_IMAGE" "$IMAGE_TAG" "$BACKEND_CTX" + update_webapp "$RESOURCE_GROUP" "$BACKEND_APP" "$ACR_LOGIN_SERVER" "$BACKEND_IMAGE" "$IMAGE_TAG" +else + echo "Skipping backend (--skip-backend)." +fi + +if ! $SKIP_FRONTEND; then + [[ -f "$FRONTEND_CTX/Dockerfile" ]] || { echo "ERROR: $FRONTEND_CTX/Dockerfile not found." >&2; exit 1; } + run_acr_build "$ACR_NAME" "$FRONTEND_IMAGE" "$IMAGE_TAG" "$FRONTEND_CTX" + update_webapp "$RESOURCE_GROUP" "$FRONTEND_APP" "$ACR_LOGIN_SERVER" "$FRONTEND_IMAGE" "$IMAGE_TAG" +else + echo "Skipping frontend (--skip-frontend)." +fi + +echo "" +echo "Done. Images published with tag '$IMAGE_TAG'." diff --git a/src/App/src/lib/textParsers.ts b/src/App/src/lib/textParsers.ts index 9c775bb9..f2f7d3e4 100644 --- a/src/App/src/lib/textParsers.ts +++ b/src/App/src/lib/textParsers.ts @@ -194,8 +194,16 @@ export function parseProductsFromText(text: string): { products: Product[], intr let introText = ''; let outroText = ''; - - if (parts.length > 0) { + + // JS String.split with a zero-width lookahead at position 0 does NOT emit + // an empty leading element, so parts[0] can itself be the first product + // rather than the intro text. Detect that case so the first product isn't + // rendered as raw markdown. + const productHeadingRegex = /^\s*(?:\d+\.\s*\*\*[^*]+\*\*|\*\*\d+\.\s*[^*]+\*\*)/; + const firstPartIsProduct = parts.length > 0 && productHeadingRegex.test(parts[0]); + const productStartIndex = firstPartIsProduct ? 0 : 1; + + if (parts.length > 0 && !firstPartIsProduct) { introText = parts[0].trim(); introText = introText.replace(/^###\s*[^\n]*\n?/gm, '').trim(); @@ -213,14 +221,14 @@ export function parseProductsFromText(text: string): { products: Product[], intr } } - for (let i = 1; i < parts.length; i++) { + for (let i = productStartIndex; i < parts.length; i++) { const product = parseProductSection(parts[i]); if (product) { products.push(product); } } - const hasImageOrLink = parts.length === 1 && (parts[0].includes('![') || /\[[^\]]+\]\([^)]+\.(jpg|jpeg|png|gif|webp|svg)/i.test(parts[0])); + const hasImageOrLink = parts.length === 1 && !firstPartIsProduct && (parts[0].includes('![') || /\[[^\]]+\]\([^)]+\.(jpg|jpeg|png|gif|webp|svg)/i.test(parts[0])); if (hasImageOrLink) { const product = parseProductSection(parts[0]); if (product) {