From 8347b232ff0bff02ef707a8ced220f05de2f01dc Mon Sep 17 00:00:00 2001 From: spruit-avanade Date: Tue, 19 May 2026 15:46:32 -0700 Subject: [PATCH 01/14] Updating azure infra build to move Products to Postgres Co-authored-by: Copilot Signed-off-by: Aaron Spruit --- azure/AGENTS.md | 12 +- azure/README.md | 25 +++- azure/infra/main.bicep | 46 +++++++ azure/infra/main.dev.bicepparam | 8 ++ azure/infra/main.dev.parameters.json | 24 ++++ azure/infra/main.prod.bicepparam | 8 ++ azure/infra/main.test.bicepparam | 8 ++ azure/infra/modules/app-services.bicep | 32 +++-- azure/infra/modules/postgres-database.bicep | 70 +++++++++++ azure/infra/scripts/store-secrets.ps1 | 16 +++ azure/infra/scripts/store-secrets.sh | 51 +++++++- azure/infra/scripts/use-dev-params.ps1 | 7 ++ azure/infra/scripts/use-dev-params.sh | 10 +- azure/scripts/run-products-db-migrations.ps1 | 84 +++++++++++-- azure/scripts/run-products-db-migrations.sh | 82 +++++++++++-- azure/terraform/dev.tfvars | 8 ++ azure/terraform/main.tf | 119 +++++++++++++++++-- azure/terraform/outputs.tf | 8 ++ azure/terraform/prod.tfvars | 8 ++ azure/terraform/test.tfvars | 8 ++ azure/terraform/variables.tf | 43 +++++++ 21 files changed, 619 insertions(+), 58 deletions(-) create mode 100644 azure/infra/modules/postgres-database.bicep mode change 100644 => 100755 azure/infra/scripts/store-secrets.sh mode change 100644 => 100755 azure/infra/scripts/use-dev-params.sh mode change 100644 => 100755 azure/scripts/run-products-db-migrations.sh diff --git a/azure/AGENTS.md b/azure/AGENTS.md index 07433fe5..bb53f2e9 100644 --- a/azure/AGENTS.md +++ b/azure/AGENTS.md @@ -20,7 +20,7 @@ This file applies to anything under `azure/`. For application code, see the rele - [azure.yaml](azure.yaml) — azd project manifest. Declares the 6 services and the pre/post hooks. - [infra/](infra/) — Bicep templates (primary IaC for `azd`). - [infra/main.bicep](infra/main.bicep) — Entry template. - - [infra/modules/](infra/modules/) — Per-resource modules (`app-service-plan`, `app-services`, `aspire-dashboard`, `database`, `service-bus`, `redis`, `key-vault`, `application-insights`). + - [infra/modules/](infra/modules/) — Per-resource modules (`app-service-plan`, `app-services`, `aspire-dashboard`, `database`, `postgres-database`, `service-bus`, `redis`, `key-vault`, `application-insights`). - [infra/scripts/](infra/scripts/) — Hook scripts (`use-dev-params.*`, `store-secrets.*`). - `main.{dev,test,prod}.bicepparam` — Environment parameter files. - [terraform/](terraform/) — Terraform implementation that mirrors the Bicep deployment (parity must be maintained when changing one or the other). @@ -32,7 +32,8 @@ Both Bicep and Terraform provision the same resource set: - Linux App Service Plan. - 7 Web Apps: `aspire-dashboard`, `products-api`, `shopping-api`, `products-outbox-relay`, `shopping-outbox-relay`, `products-subscribe`, `shopping-subscribe`. -- Azure SQL Server + Database (with firewall rules). +- Azure SQL Server + Database (Shopping and Orders domains, with firewall rules). +- Azure Database for PostgreSQL Flexible Server + Database (Products domain). - Azure Service Bus (Standard) — namespace + topic + subscriptions. - Azure Managed Redis. - Application Insights. @@ -49,8 +50,8 @@ Both Bicep and Terraform provision the same resource set: ## azd hooks (defined in [azure.yaml](azure.yaml)) - `preprovision` → `infra/scripts/use-dev-params.{sh,ps1}` — selects the dev parameter file, injects `AZURE_LOCATION` and `AZURE_SQL_ADMIN_PASSWORD` into `main.parameters.json`, maps the .NET TFM to the App Service runtime. -- `predeploy` → `scripts/run-products-db-migrations.{sh,ps1}` — runs DB migrations against the provisioned SQL DB before app code deploys. -- `postprovision` → `infra/scripts/store-secrets.{sh,ps1}` — grants the provisioning user `Key Vault Administrator` and stores `sql-admin-password`, `sql-connection-string`, `service-bus-connection-string` in Key Vault. +- `predeploy` → `scripts/run-products-db-migrations.{sh,ps1}` — runs domain-specific DB migrations before app code deploys: Shopping/Orders on SQL; Products on PostgreSQL. Products uses `Migrate` + `Schema` + `ResetAndData` sequence. +- `postprovision` → `infra/scripts/store-secrets.{sh,ps1}` — grants the provisioning user `Key Vault Administrator` and stores `sql-admin-password`, `sql-connection-string`, `postgres-admin-password`, `postgres-connection-string`, `service-bus-connection-string` in Key Vault. When editing hook scripts, keep the bash and PowerShell variants behaviorally identical. @@ -61,6 +62,7 @@ Set in the azd environment (`azd env set `): - `AZURE_SUBSCRIPTION_ID` — target subscription. - `AZURE_LOCATION` — e.g. `eastus2`. - `AZURE_SQL_ADMIN_PASSWORD` — strong password; consumed by hooks, never committed. +- `AZURE_POSTGRES_ADMIN_PASSWORD` — optional; if omitted, hooks default to `AZURE_SQL_ADMIN_PASSWORD`. - `AZD_DOTNET_TARGET_FRAMEWORK` — one of `net8.0`, `net9.0`, `net10.0`. Load into the current shell before running ad-hoc `az` / `terraform` commands: @@ -114,6 +116,8 @@ Do not run `azd up`, `azd down`, `terraform apply`, or `terraform destroy` witho - **Multi-target publish error (NETSDK1129)** — set `AZD_DOTNET_TARGET_FRAMEWORK` and reload env. - **SQL password missing** — set `AZURE_SQL_ADMIN_PASSWORD` before `azd provision` / `terraform apply`. +- **PostgreSQL password missing** — set `AZURE_POSTGRES_ADMIN_PASSWORD` when different from SQL admin password. +- **Predeploy missing output keys** — run `azd provision --no-prompt` before `azd deploy --all --no-prompt` to refresh `sql*` and `postgres*` output values in azd env. - **API returns 404 at `/`** — expected; probe `/api/...`, `/health/ready/detailed`, or `/swagger`. - **Aspire Dashboard requires token** — fetch from `az webapp log tail` (see [README.md](README.md#accessing-the-aspire-dashboard)). - **`azd init` says no project** — run from `azure/`, not the repo root. diff --git a/azure/README.md b/azure/README.md index ce4820ff..26c176f2 100644 --- a/azure/README.md +++ b/azure/README.md @@ -6,7 +6,7 @@ This folder contains the Azure Developer CLI (azd) project for deploying the Con Infrastructure (Bicep): - App Service Plan (Linux). -- 6 Web Apps: +- 7 Web Apps: - aspire-dashboard - products-api - shopping-api @@ -14,7 +14,8 @@ Infrastructure (Bicep): - shopping-outbox-relay - products-subscribe - shopping-subscribe -- Azure SQL Database. +- Azure SQL Database (Shopping and Orders domains). +- Azure Database for PostgreSQL Flexible Server (Products domain). - Azure Service Bus (Standard). - Azure Managed Redis. - Application Insights. @@ -26,6 +27,9 @@ Infrastructure (Bicep): - The `postprovision` hook grants the provisioning user `Key Vault Administrator` on the deployed Key Vault and stores E2E connection secrets. - Deployment `location` is sourced from `AZURE_LOCATION` and injected by the preprovision hook. - SQL admin password is injected at runtime from `AZURE_SQL_ADMIN_PASSWORD` by [infra/scripts/use-dev-params.sh](infra/scripts/use-dev-params.sh). +- PostgreSQL admin password is injected from `AZURE_POSTGRES_ADMIN_PASSWORD` when set; otherwise it defaults to `AZURE_SQL_ADMIN_PASSWORD`. +- The `predeploy` migration hook runs domain-specific providers: Shopping and Orders on SQL Server, Products on PostgreSQL. +- For Products, the migration hook runs `Migrate` + `Schema` + `ResetAndData` to avoid the DbEx PostgreSQL create-stage issue in Azure Flexible Server. - Key Vault name is unique per deployment (generated in [infra/main.bicep](infra/main.bicep)). - Multi-targeted .NET projects use a configurable publish framework via environment variable: - `AZD_DOTNET_TARGET_FRAMEWORK` (preferred), or @@ -37,7 +41,7 @@ Infrastructure (Bicep): - Azure Developer CLI (`azd`). - .NET SDK installed. - Access to an Azure subscription. -- Outbound port 1433 access to run DB updates. +- Outbound port 1433 and 5432 access to run DB updates. ## One-time setup @@ -68,6 +72,7 @@ Set required values: azd env set AZURE_SUBSCRIPTION_ID azd env set AZURE_LOCATION eastus2 azd env set AZURE_SQL_ADMIN_PASSWORD '' +azd env set AZURE_POSTGRES_ADMIN_PASSWORD '' # Optional; defaults to AZURE_SQL_ADMIN_PASSWORD. azd env set AZD_DOTNET_TARGET_FRAMEWORK 'net10.0' ``` @@ -201,6 +206,8 @@ curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/swagger" After `azd provision` or `azd up`, the postprovision hook automatically stores the following secrets in Key Vault: - `sql-admin-password` - `sql-connection-string` +- `postgres-admin-password` +- `postgres-connection-string` - `service-bus-connection-string` Retrieve them: @@ -212,6 +219,9 @@ KV=$(az keyvault list --resource-group --query '[0].name' # SQL connection string az keyvault secret show --vault-name $KV --name sql-connection-string -o tsv --query value +# PostgreSQL connection string +az keyvault secret show --vault-name $KV --name postgres-connection-string -o tsv --query value + # Service Bus connection string az keyvault secret show --vault-name $KV --name service-bus-connection-string -o tsv --query value ``` @@ -225,7 +235,7 @@ Edit [../samples/tests/Contoso.E2E.Runner/appsettings.json](../samples/tests/Con "E2E": { "Products": { "BaseAddress": "https://app-products-api-dev-{suffix}.azurewebsites.net", - "ConnectionString": "Server=sql-dev-{suffix}.database.windows.net;Database=Contoso;User Id=sqladmin;Password={password};Encrypt=true;TrustServerCertificate=false;", + "ConnectionString": "Server=pg-dev-{suffix}.postgres.database.azure.com;Port=5432;Database=coreexdev;User Id={postgres-admin};Password={password};Ssl Mode=Require;Trust Server Certificate=true;", "ServiceBus": "Endpoint=sb://sb-dev-{suffix}.servicebus.windows.net/;SharedAccessKeyName=rootManageSharedAccessKey;SharedAccessKey={key};" }, "Shopping": { @@ -267,6 +277,13 @@ azd auth login --tenant-id SQL password missing: - Ensure `AZURE_SQL_ADMIN_PASSWORD` is set before `azd provision` or `azd up`. +PostgreSQL password missing: +- Set `AZURE_POSTGRES_ADMIN_PASSWORD` if your SQL and PostgreSQL admin passwords differ. +- If omitted, deployment hooks default PostgreSQL admin password to `AZURE_SQL_ADMIN_PASSWORD`. + +`predeploy` hook fails with missing database outputs: +- Run `azd provision --no-prompt` first to refresh azd environment outputs (`sqlServerName`, `sqlDatabaseName`, `postgresServerName`, `postgresDatabaseName`) before `azd deploy --all --no-prompt`. + Multi-target publish error (NETSDK1129): - Ensure `AZD_DOTNET_TARGET_FRAMEWORK` is set in your azd environment: `azd env set AZD_DOTNET_TARGET_FRAMEWORK net10.0`. - Load it into your current shell: `et -a && eval "$(azd env get-values)" && set +a`. diff --git a/azure/infra/main.bicep b/azure/infra/main.bicep index e967ac54..8bc1d8ef 100644 --- a/azure/infra/main.bicep +++ b/azure/infra/main.bicep @@ -57,6 +57,31 @@ param sqlMinCapacity string @description('Azure SQL auto-pause delay in minutes. Set to -1 to disable.') param sqlAutoPauseDelay int +@description('Azure PostgreSQL administrator login name.') +param postgresAdminLogin string + +@secure() +@description('Azure PostgreSQL administrator password.') +param postgresAdminPassword string + +@description('Azure PostgreSQL database name used by Products domain.') +param postgresDatabaseName string + +@description('Current runner public IPv4 address to allow through the Azure PostgreSQL firewall.') +param postgresFirewallClientIp string = '' + +@description('Azure PostgreSQL flexible server SKU name. Example: Standard_B1ms.') +param postgresSkuName string + +@description('Azure PostgreSQL flexible server tier. Example: Burstable.') +param postgresSkuTier string + +@description('Azure PostgreSQL major version. Example: 16.') +param postgresVersion string + +@description('Azure PostgreSQL storage size in GB.') +param postgresStorageSizeGb int + @description('Azure Managed Redis SKU name. Example: Balanced_B0.') param redisSkuName string @@ -143,6 +168,23 @@ module sql './modules/database.bicep' = { } } +module postgres './modules/postgres-database.bicep' = { + name: 'postgresDeploy' + params: { + location: location + serverName: 'pg-${environmentType}-${suffix}' + databaseName: postgresDatabaseName + adminLogin: postgresAdminLogin + adminPassword: postgresAdminPassword + clientIp: postgresFirewallClientIp + skuName: postgresSkuName + skuTier: postgresSkuTier + version: postgresVersion + storageSizeGb: postgresStorageSizeGb + tags: mergedTags + } +} + module appServices './modules/app-services.bicep' = { name: 'appServicesDeploy' params: { @@ -156,6 +198,7 @@ module appServices './modules/app-services.bicep' = { appInsightsResourceId: appInsights.outputs.id appInsightsInstrumentationKey: appInsights.outputs.instrumentationKey sqlConnectionString: sql.outputs.connectionString + postgresConnectionString: postgres.outputs.connectionString redisConnectionString: redis.outputs.connectionString serviceBusConnectionString: serviceBus.outputs.connectionString otlpHttpEndpoint: aspireDashboard.outputs.otlpHttpEndpoint @@ -180,6 +223,8 @@ output serviceBusNamespaceName string = serviceBus.outputs.namespaceName output redisHostName string = redis.outputs.hostName output sqlServerName string = sql.outputs.serverName output sqlDatabaseName string = sql.outputs.databaseName +output postgresServerName string = postgres.outputs.serverName +output postgresDatabaseName string = postgres.outputs.databaseName output productsApiAppName string = appServices.outputs.productsApiName output shoppingApiAppName string = appServices.outputs.shoppingApiName output productsOutboxRelayAppName string = appServices.outputs.productsOutboxRelayName @@ -189,3 +234,4 @@ output shoppingSubscribeAppName string = appServices.outputs.shoppingSubscribeNa output aspireDashboardAppName string = aspireDashboard.outputs.appName output aspireDashboardUri string = aspireDashboard.outputs.dashboardUri output aspireDashboardOtlpGrpcEndpoint string = aspireDashboard.outputs.otlpGrpcEndpoint + diff --git a/azure/infra/main.dev.bicepparam b/azure/infra/main.dev.bicepparam index f82f70f1..09320e08 100644 --- a/azure/infra/main.dev.bicepparam +++ b/azure/infra/main.dev.bicepparam @@ -29,6 +29,14 @@ param sqlTier = 'GeneralPurpose' param sqlMinCapacity = '0.5' param sqlAutoPauseDelay = 60 +param postgresAdminLogin = 'coreexpgadmin' +param postgresAdminPassword = readEnvironmentVariable('AZURE_POSTGRES_ADMIN_PASSWORD', readEnvironmentVariable('AZURE_SQL_ADMIN_PASSWORD')) +param postgresDatabaseName = 'coreexdev' +param postgresSkuName = 'Standard_B1ms' +param postgresSkuTier = 'Burstable' +param postgresVersion = '16' +param postgresStorageSizeGb = 32 + // Entry Azure Managed Redis tier. param redisSkuName = 'Balanced_B0' param redisHighAvailability = 'Disabled' diff --git a/azure/infra/main.dev.parameters.json b/azure/infra/main.dev.parameters.json index f5efaa4a..c89df79f 100644 --- a/azure/infra/main.dev.parameters.json +++ b/azure/infra/main.dev.parameters.json @@ -57,6 +57,30 @@ "sqlAutoPauseDelay": { "value": 60 }, + "postgresAdminLogin": { + "value": "coreexpgadmin" + }, + "postgresAdminPassword": { + "value": "__AZURE_POSTGRES_ADMIN_PASSWORD__" + }, + "postgresDatabaseName": { + "value": "coreexdev" + }, + "postgresFirewallClientIp": { + "value": "__AZURE_CLIENT_IP__" + }, + "postgresSkuName": { + "value": "Standard_B1ms" + }, + "postgresSkuTier": { + "value": "Burstable" + }, + "postgresVersion": { + "value": "16" + }, + "postgresStorageSizeGb": { + "value": 32 + }, "redisSkuName": { "value": "Balanced_B0" }, diff --git a/azure/infra/main.prod.bicepparam b/azure/infra/main.prod.bicepparam index f9a3d933..ccd94aca 100644 --- a/azure/infra/main.prod.bicepparam +++ b/azure/infra/main.prod.bicepparam @@ -29,6 +29,14 @@ param sqlTier = 'GeneralPurpose' param sqlMinCapacity = '0.5' param sqlAutoPauseDelay = 60 +param postgresAdminLogin = 'coreexpgadmin' +param postgresAdminPassword = readEnvironmentVariable('AZURE_POSTGRES_ADMIN_PASSWORD', readEnvironmentVariable('AZURE_SQL_ADMIN_PASSWORD')) +param postgresDatabaseName = 'coreexprod' +param postgresSkuName = 'Standard_B1ms' +param postgresSkuTier = 'Burstable' +param postgresVersion = '16' +param postgresStorageSizeGb = 32 + // TODO: Replace with production-grade cache tier. param redisSkuName = 'Balanced_B0' param redisHighAvailability = 'Enabled' diff --git a/azure/infra/main.test.bicepparam b/azure/infra/main.test.bicepparam index 02458129..7da3dd7e 100644 --- a/azure/infra/main.test.bicepparam +++ b/azure/infra/main.test.bicepparam @@ -29,6 +29,14 @@ param sqlTier = 'GeneralPurpose' param sqlMinCapacity = '0.5' param sqlAutoPauseDelay = 60 +param postgresAdminLogin = 'coreexpgadmin' +param postgresAdminPassword = readEnvironmentVariable('AZURE_POSTGRES_ADMIN_PASSWORD', readEnvironmentVariable('AZURE_SQL_ADMIN_PASSWORD')) +param postgresDatabaseName = 'coreextest' +param postgresSkuName = 'Standard_B1ms' +param postgresSkuTier = 'Burstable' +param postgresVersion = '16' +param postgresStorageSizeGb = 32 + // TODO: Confirm cache tier for test. param redisSkuName = 'Balanced_B0' param redisHighAvailability = 'Enabled' diff --git a/azure/infra/modules/app-services.bicep b/azure/infra/modules/app-services.bicep index 9d3e6642..f0342145 100644 --- a/azure/infra/modules/app-services.bicep +++ b/azure/infra/modules/app-services.bicep @@ -8,6 +8,7 @@ param appInsightsConnectionString string param appInsightsResourceId string param appInsightsInstrumentationKey string param sqlConnectionString string +param postgresConnectionString string param redisConnectionString string param serviceBusConnectionString string param otlpHttpEndpoint string @@ -49,10 +50,6 @@ var sharedAppSettings = [ name: 'APPINSIGHTS_SNAPSHOTFEATURE_VERSION' value: '1.0.0' } - { - name: 'Aspire__Microsoft__Data__SqlClient__ConnectionString' - value: sqlConnectionString - } { name: 'Aspire__StackExchange__Redis__ConnectionString' value: redisConnectionString @@ -71,6 +68,20 @@ var sharedAppSettings = [ } ] +var sqlDbAppSettings = [ + { + name: 'Aspire__Microsoft__Data__SqlClient__ConnectionString' + value: sqlConnectionString + } +] + +var postgresDbAppSettings = [ + { + name: 'Aspire__Npgsql__ConnectionString' + value: postgresConnectionString + } +] + resource productsApi 'Microsoft.Web/sites@2023-12-01' = { name: 'app-products-api-${environmentType}-${suffix}' location: location @@ -93,7 +104,7 @@ resource productsApi 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, postgresDbAppSettings) } } } @@ -120,7 +131,7 @@ resource shoppingApi 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: concat(sharedAppSettings, [ + appSettings: concat(sharedAppSettings, sqlDbAppSettings, [ { name: 'ProductsApi__BaseAddress' value: 'https://${productsApi.properties.defaultHostName}' @@ -152,7 +163,7 @@ resource productsOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, postgresDbAppSettings) } } } @@ -179,7 +190,7 @@ resource shoppingOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, sqlDbAppSettings) } } } @@ -206,7 +217,7 @@ resource productsSubscribe 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, postgresDbAppSettings) } } } @@ -233,7 +244,7 @@ resource shoppingSubscribe 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, sqlDbAppSettings) } } } @@ -244,3 +255,4 @@ output productsOutboxRelayName string = productsOutboxRelay.name output shoppingOutboxRelayName string = shoppingOutboxRelay.name output productsSubscribeName string = productsSubscribe.name output shoppingSubscribeName string = shoppingSubscribe.name + diff --git a/azure/infra/modules/postgres-database.bicep b/azure/infra/modules/postgres-database.bicep new file mode 100644 index 00000000..6741d9ff --- /dev/null +++ b/azure/infra/modules/postgres-database.bicep @@ -0,0 +1,70 @@ +param location string +param serverName string +param databaseName string +param adminLogin string +@secure() +param adminPassword string +param clientIp string = '' +param skuName string +param skuTier string +param version string +param storageSizeGb int +param tags object = {} + +resource server 'Microsoft.DBforPostgreSQL/flexibleServers@2023-12-01-preview' = { + name: serverName + location: location + tags: tags + sku: { + name: skuName + tier: skuTier + } + properties: { + administratorLogin: adminLogin + administratorLoginPassword: adminPassword + version: version + publicNetworkAccess: 'Enabled' + storage: { + storageSizeGB: storageSizeGb + } + highAvailability: { + mode: 'Disabled' + } + backup: { + backupRetentionDays: 7 + geoRedundantBackup: 'Disabled' + } + } +} + +resource azureFirewallRule 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-12-01-preview' = { + parent: server + name: 'AllowAzureServices' + properties: { + startIpAddress: '0.0.0.0' + endIpAddress: '0.0.0.0' + } +} + +resource clientFirewallRule 'Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-12-01-preview' = if (!empty(clientIp)) { + parent: server + name: 'AllowCurrentRunner-${replace(clientIp, '.', '-')}' + properties: { + startIpAddress: clientIp + endIpAddress: clientIp + } +} + +resource db 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2023-12-01-preview' = { + parent: server + name: databaseName + properties: { + charset: 'UTF8' + collation: 'en_US.utf8' + } +} + +output serverName string = server.name +output databaseName string = db.name +output fullyQualifiedDomainName string = '${server.name}.postgres.database.azure.com' +output connectionString string = 'Server=${server.name}.postgres.database.azure.com;Port=5432;Database=${databaseName};User Id=${adminLogin};Password=${adminPassword};Ssl Mode=Require;Trust Server Certificate=true;' diff --git a/azure/infra/scripts/store-secrets.ps1 b/azure/infra/scripts/store-secrets.ps1 index 5a2ce805..23f93e81 100644 --- a/azure/infra/scripts/store-secrets.ps1 +++ b/azure/infra/scripts/store-secrets.ps1 @@ -12,6 +12,8 @@ if ([string]::IsNullOrWhiteSpace($sqlPassword)) { throw 'AZURE_SQL_ADMIN_PASSWORD is not set.' } +$postgresPassword = if ([string]::IsNullOrWhiteSpace($env:AZURE_POSTGRES_ADMIN_PASSWORD)) { $sqlPassword } else { $env:AZURE_POSTGRES_ADMIN_PASSWORD } + Write-Host "Locating Key Vault in resource group '$rg'..." $kvName = (az keyvault list --resource-group $rg --query '[0].name' -o tsv) $kvId = (az keyvault show --name $kvName --resource-group $rg --query id -o tsv) @@ -34,6 +36,9 @@ catch { Write-Host 'Storing sql-admin-password...' az keyvault secret set --vault-name $kvName --name 'sql-admin-password' --value $sqlPassword --output none +Write-Host 'Storing postgres-admin-password...' +az keyvault secret set --vault-name $kvName --name 'postgres-admin-password' --value $postgresPassword --output none + Write-Host 'Locating SQL Server...' $sqlServer = (az sql server list --resource-group $rg --query '[0].name' -o tsv) $sqlLogin = (az sql server show --resource-group $rg --name $sqlServer --query administratorLogin -o tsv) @@ -43,6 +48,15 @@ $sqlConn = "Server=tcp:${sqlServer}.database.windows.net,1433;Database=${sqlDb Write-Host 'Storing sql-connection-string...' az keyvault secret set --vault-name $kvName --name 'sql-connection-string' --value $sqlConn --output none +Write-Host 'Locating Postgres server...' +$postgresServer = (az postgres flexible-server list --resource-group $rg --query '[0].name' -o tsv) +$postgresLogin = (az postgres flexible-server show --resource-group $rg --name $postgresServer --query administratorLogin -o tsv) +$postgresDb = (az postgres flexible-server db list --resource-group $rg --server-name $postgresServer --query '[0].name' -o tsv) +$postgresConn = "Server=$postgresServer.postgres.database.azure.com;Port=5432;Database=$postgresDb;User Id=$postgresLogin;Password=$postgresPassword;Ssl Mode=Require;Trust Server Certificate=true;" + +Write-Host 'Storing postgres-connection-string...' +az keyvault secret set --vault-name $kvName --name 'postgres-connection-string' --value $postgresConn --output none + Write-Host 'Locating Service Bus namespace...' $sbName = (az servicebus namespace list --resource-group $rg --query '[0].name' -o tsv) $sbConn = (az servicebus namespace authorization-rule keys list ` @@ -56,5 +70,7 @@ az keyvault secret set --vault-name $kvName --name 'service-bus-connection-strin Write-Host "Secrets stored successfully in Key Vault '$kvName':" Write-Host " - sql-admin-password" +Write-Host " - postgres-admin-password" Write-Host " - sql-connection-string" +Write-Host " - postgres-connection-string" Write-Host " - service-bus-connection-string" diff --git a/azure/infra/scripts/store-secrets.sh b/azure/infra/scripts/store-secrets.sh old mode 100644 new mode 100755 index c53be7bc..ce59d744 --- a/azure/infra/scripts/store-secrets.sh +++ b/azure/infra/scripts/store-secrets.sh @@ -5,6 +5,33 @@ set -euo pipefail rg="${AZURE_RESOURCE_GROUP:?AZURE_RESOURCE_GROUP is not set}" sql_password="${AZURE_SQL_ADMIN_PASSWORD:?AZURE_SQL_ADMIN_PASSWORD is not set}" +postgres_password="${AZURE_POSTGRES_ADMIN_PASSWORD:-${AZURE_SQL_ADMIN_PASSWORD}}" +sql_server="${AZURE_SQL_SERVER:-${sqlServerName:-}}" +sql_login="${AZURE_SQL_ADMIN_LOGIN:-coreexadmin}" +sql_db="${AZURE_SQL_DB_NAME:-${sqlDatabaseName:-}}" +postgres_server="${AZURE_POSTGRES_SERVER:-${postgresServerName:-}}" +postgres_login="${AZURE_POSTGRES_ADMIN_LOGIN:-coreexpgadmin}" +postgres_db="${AZURE_POSTGRES_DB_NAME:-${postgresDatabaseName:-}}" + +if [[ -z "${sql_server}" ]]; then + echo "AZURE_SQL_SERVER (or azd output sqlServerName) is not set." >&2 + exit 1 +fi + +if [[ -z "${sql_db}" ]]; then + echo "AZURE_SQL_DB_NAME (or azd output sqlDatabaseName) is not set." >&2 + exit 1 +fi + +if [[ -z "${postgres_server}" ]]; then + echo "AZURE_POSTGRES_SERVER (or azd output postgresServerName) is not set." >&2 + exit 1 +fi + +if [[ -z "${postgres_db}" ]]; then + echo "AZURE_POSTGRES_DB_NAME (or azd output postgresDatabaseName) is not set." >&2 + exit 1 +fi echo "Locating Key Vault in resource group '${rg}'..." kv_name=$(az keyvault list --resource-group "${rg}" --query "[0].name" -o tsv) @@ -31,10 +58,14 @@ az keyvault secret set \ --value "${sql_password}" \ --output none -echo "Locating SQL Server..." -sql_server=$(az sql server list --resource-group "${rg}" --query "[0].name" -o tsv) -sql_login=$(az sql server show --resource-group "${rg}" --name "${sql_server}" --query administratorLogin -o tsv) -sql_db=$(az sql db list --resource-group "${rg}" --server "${sql_server}" --query "[?name!='master'].name | [0]" -o tsv) +echo "Storing postgres-admin-password..." +az keyvault secret set \ + --vault-name "${kv_name}" \ + --name "postgres-admin-password" \ + --value "${postgres_password}" \ + --output none + +echo "Building SQL Server connection string..." sql_conn="Server=tcp:${sql_server}.database.windows.net,1433;Database=${sql_db};User Id=${sql_login};Password=${sql_password};Encrypt=true;TrustServerCertificate=false;" echo "Storing sql-connection-string..." @@ -44,6 +75,16 @@ az keyvault secret set \ --value "${sql_conn}" \ --output none +echo "Building Postgres connection string..." +postgres_conn="Server=${postgres_server}.postgres.database.azure.com;Port=5432;Database=${postgres_db};User Id=${postgres_login};Password=${postgres_password};Ssl Mode=Require;Trust Server Certificate=true;" + +echo "Storing postgres-connection-string..." +az keyvault secret set \ + --vault-name "${kv_name}" \ + --name "postgres-connection-string" \ + --value "${postgres_conn}" \ + --output none + echo "Locating Service Bus namespace..." sb_name=$(az servicebus namespace list --resource-group "${rg}" --query "[0].name" -o tsv) sb_conn=$(az servicebus namespace authorization-rule keys list \ @@ -61,5 +102,7 @@ az keyvault secret set \ echo "Secrets stored successfully in Key Vault '${kv_name}':" echo " - sql-admin-password" +echo " - postgres-admin-password" echo " - sql-connection-string" +echo " - postgres-connection-string" echo " - service-bus-connection-string" diff --git a/azure/infra/scripts/use-dev-params.ps1 b/azure/infra/scripts/use-dev-params.ps1 index 6544adf8..5cb25b12 100644 --- a/azure/infra/scripts/use-dev-params.ps1 +++ b/azure/infra/scripts/use-dev-params.ps1 @@ -7,6 +7,10 @@ if ([string]::IsNullOrWhiteSpace($env:AZURE_SQL_ADMIN_PASSWORD)) { throw 'AZURE_SQL_ADMIN_PASSWORD is not set. Set it before running azd provision.' } +if ([string]::IsNullOrWhiteSpace($env:AZURE_POSTGRES_ADMIN_PASSWORD)) { + $env:AZURE_POSTGRES_ADMIN_PASSWORD = $env:AZURE_SQL_ADMIN_PASSWORD +} + if ([string]::IsNullOrWhiteSpace($env:AZURE_LOCATION)) { throw "AZURE_LOCATION is not set. Set it via 'azd env set AZURE_LOCATION ' before running azd provision." } @@ -58,14 +62,17 @@ try { $json = Get-Content -Raw -Path $templatePath | ConvertFrom-Json $json.parameters.location.value = $env:AZURE_LOCATION $json.parameters.sqlAdminPassword.value = $env:AZURE_SQL_ADMIN_PASSWORD + $json.parameters.postgresAdminPassword.value = $env:AZURE_POSTGRES_ADMIN_PASSWORD $json.parameters.appServiceLinuxFxVersion.value = $appServiceLinuxFxVersion $json.parameters.sqlFirewallClientIp.value = $clientIp + $json.parameters.postgresFirewallClientIp.value = $clientIp $json | ConvertTo-Json -Depth 100 | Set-Content -Path $outputPath -NoNewline } catch { # Fallback: direct string replacement $content = Get-Content -Raw -Path $templatePath $content = $content.Replace('__AZURE_LOCATION__', $env:AZURE_LOCATION) $content = $content.Replace('__AZURE_SQL_ADMIN_PASSWORD__', $env:AZURE_SQL_ADMIN_PASSWORD) + $content = $content.Replace('__AZURE_POSTGRES_ADMIN_PASSWORD__', $env:AZURE_POSTGRES_ADMIN_PASSWORD) $content = $content.Replace('__APP_SERVICE_LINUX_FX_VERSION__', $appServiceLinuxFxVersion) $content = $content.Replace('__AZURE_CLIENT_IP__', $clientIp) Set-Content -Path $outputPath -Value $content -NoNewline diff --git a/azure/infra/scripts/use-dev-params.sh b/azure/infra/scripts/use-dev-params.sh old mode 100644 new mode 100755 index 764d434d..ba55c81e --- a/azure/infra/scripts/use-dev-params.sh +++ b/azure/infra/scripts/use-dev-params.sh @@ -9,6 +9,10 @@ if [[ -z "${AZURE_SQL_ADMIN_PASSWORD:-}" ]]; then exit 1 fi +if [[ -z "${AZURE_POSTGRES_ADMIN_PASSWORD:-}" ]]; then + AZURE_POSTGRES_ADMIN_PASSWORD="${AZURE_SQL_ADMIN_PASSWORD}" +fi + if [[ -z "${AZURE_LOCATION:-}" ]]; then echo "AZURE_LOCATION is not set. Set it via 'azd env set AZURE_LOCATION ' before running azd provision." >&2 exit 1 @@ -50,10 +54,11 @@ esac if command -v jq &> /dev/null; then jq \ --arg pwd "${AZURE_SQL_ADMIN_PASSWORD}" \ + --arg pgpwd "${AZURE_POSTGRES_ADMIN_PASSWORD}" \ --arg loc "${AZURE_LOCATION}" \ --arg fx "${app_service_linux_fx_version}" \ --arg ip "${client_ip}" \ - '.parameters.location.value = $loc | .parameters.sqlAdminPassword.value = $pwd | .parameters.appServiceLinuxFxVersion.value = $fx | .parameters.sqlFirewallClientIp.value = $ip' \ + '.parameters.location.value = $loc | .parameters.sqlAdminPassword.value = $pwd | .parameters.postgresAdminPassword.value = $pgpwd | .parameters.appServiceLinuxFxVersion.value = $fx | .parameters.sqlFirewallClientIp.value = $ip | .parameters.postgresFirewallClientIp.value = $ip' \ "${infra_dir}/main.dev.parameters.json" > "${infra_dir}/main.parameters.json" else # Fallback: use printf to safely escape and substitute @@ -61,6 +66,8 @@ else escaped_location=${escaped_location%\\} escaped_password=$(printf '%s\n' "${AZURE_SQL_ADMIN_PASSWORD}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') escaped_password=${escaped_password%\\} + escaped_postgres_password=$(printf '%s\n' "${AZURE_POSTGRES_ADMIN_PASSWORD}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') + escaped_postgres_password=${escaped_postgres_password%\\} escaped_fx=$(printf '%s\n' "${app_service_linux_fx_version}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') escaped_fx=${escaped_fx%\\} escaped_ip=$(printf '%s\n' "${client_ip}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') @@ -68,6 +75,7 @@ else sed \ -e "s/__AZURE_LOCATION__/${escaped_location}/g" \ -e "s/__AZURE_SQL_ADMIN_PASSWORD__/${escaped_password}/g" \ + -e "s/__AZURE_POSTGRES_ADMIN_PASSWORD__/${escaped_postgres_password}/g" \ -e "s/__APP_SERVICE_LINUX_FX_VERSION__/${escaped_fx}/g" \ -e "s/__AZURE_CLIENT_IP__/${escaped_ip}/g" \ "${infra_dir}/main.dev.parameters.json" > "${infra_dir}/main.parameters.json" diff --git a/azure/scripts/run-products-db-migrations.ps1 b/azure/scripts/run-products-db-migrations.ps1 index f014af46..808632d1 100644 --- a/azure/scripts/run-products-db-migrations.ps1 +++ b/azure/scripts/run-products-db-migrations.ps1 @@ -1,6 +1,7 @@ $ErrorActionPreference = 'Stop' -# Runs all Contoso *.Database DbEx migrations against the provisioned Azure SQL database. +# Runs Contoso *.Database DbEx migrations against their provisioned database providers. +# Products runs on Postgres, and all other domains continue to run on Azure SQL. $scriptDir = Split-Path -Parent $PSCommandPath $azureDir = Resolve-Path (Join-Path $scriptDir '..') @@ -10,6 +11,26 @@ if (-not (Get-Command azd -ErrorAction SilentlyContinue)) { throw "The 'azd' command is required to resolve environment values." } +function Get-AzdEnvValue { + param( + [Parameter(Mandatory = $true)] + [string] $Key + ) + + try { + $value = (azd env get-value $Key 2>$null).Trim() + } + catch { + return $null + } + + if ([string]::IsNullOrWhiteSpace($value) -or $value.StartsWith('ERROR:')) { + return $null + } + + return $value +} + $targetFramework = if ($env:AZD_DOTNET_TARGET_FRAMEWORK) { $env:AZD_DOTNET_TARGET_FRAMEWORK } @@ -25,26 +46,36 @@ elseif ((dotnet --list-runtimes) -match 'Microsoft\.NETCore\.App 9\.') { else { 'net8.0' } -$sqlServer = (azd env get-value sqlServerName).Trim() -$sqlDatabase = (azd env get-value sqlDatabaseName).Trim() +$sqlServer = Get-AzdEnvValue -Key 'sqlServerName' +$sqlDatabase = Get-AzdEnvValue -Key 'sqlDatabaseName' +$postgresServer = Get-AzdEnvValue -Key 'postgresServerName' +$postgresDatabase = Get-AzdEnvValue -Key 'postgresDatabaseName' $sqlAdminLogin = if ($env:AZURE_SQL_ADMIN_LOGIN) { $env:AZURE_SQL_ADMIN_LOGIN } else { 'coreexadmin' } $sqlPassword = $env:AZURE_SQL_ADMIN_PASSWORD +$postgresAdminLogin = if ($env:AZURE_POSTGRES_ADMIN_LOGIN) { $env:AZURE_POSTGRES_ADMIN_LOGIN } else { 'coreexpgadmin' } +$postgresPassword = $env:AZURE_POSTGRES_ADMIN_PASSWORD -if ([string]::IsNullOrWhiteSpace($sqlServer) -or [string]::IsNullOrWhiteSpace($sqlDatabase)) { - throw 'Unable to resolve sqlServerName/sqlDatabaseName from the active azd environment.' +if ([string]::IsNullOrWhiteSpace($sqlServer) -or [string]::IsNullOrWhiteSpace($sqlDatabase) -or [string]::IsNullOrWhiteSpace($postgresServer) -or [string]::IsNullOrWhiteSpace($postgresDatabase)) { + throw "Missing required azd environment outputs (sqlServerName/sqlDatabaseName/postgresServerName/postgresDatabaseName). Run 'azd provision --no-prompt' (or 'azd up --no-prompt') to refresh environment outputs before deploy." } if ([string]::IsNullOrWhiteSpace($sqlPassword)) { - $sqlPassword = (azd env get-value AZURE_SQL_ADMIN_PASSWORD).Trim() + $sqlPassword = Get-AzdEnvValue -Key 'AZURE_SQL_ADMIN_PASSWORD' } -if ([string]::IsNullOrWhiteSpace($sqlPassword)) { - throw 'AZURE_SQL_ADMIN_PASSWORD is required to run DbEx migrations.' +if ([string]::IsNullOrWhiteSpace($postgresPassword)) { + $postgresPassword = Get-AzdEnvValue -Key 'AZURE_POSTGRES_ADMIN_PASSWORD' } -& (Join-Path $scriptDir 'ensure-sql-firewall-rule.ps1') +if ([string]::IsNullOrWhiteSpace($postgresPassword)) { + $postgresPassword = $sqlPassword +} -$connectionString = "Server=tcp:$sqlServer.database.windows.net,1433;Initial Catalog=$sqlDatabase;User ID=$sqlAdminLogin;Password=$sqlPassword;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" +if ([string]::IsNullOrWhiteSpace($sqlPassword) -or [string]::IsNullOrWhiteSpace($postgresPassword)) { + throw 'Database admin passwords are required to run DbEx migrations.' +} +$sqlConnectionString = "Server=tcp:$sqlServer.database.windows.net,1433;Initial Catalog=$sqlDatabase;User ID=$sqlAdminLogin;Password=$sqlPassword;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" +$postgresConnectionString = "Server=$postgresServer.postgres.database.azure.com;Port=5432;Database=$postgresDatabase;User Id=$postgresAdminLogin;Password=$postgresPassword;Ssl Mode=Require;Trust Server Certificate=true;" $projects = Get-ChildItem -LiteralPath (Join-Path $repoRoot 'samples/src') -Recurse -File -Filter 'Contoso.*.Database.csproj' | Sort-Object FullName @@ -52,7 +83,21 @@ if ($projects.Count -eq 0) { throw 'No Contoso database projects were found under samples/src.' } -Write-Host "Running DbEx migrations for $($projects.Count) database project(s) using framework '$targetFramework' against database '$sqlDatabase'." +Write-Host "Running DbEx migrations for $($projects.Count) database project(s) using framework '$targetFramework' with domain-specific database providers." + +$requiresSqlFirewall = $false +foreach ($project in $projects) { + $projectName = [System.IO.Path]::GetFileNameWithoutExtension($project.Name) + if ($projectName -ne 'Contoso.Products.Database') { + $requiresSqlFirewall = $true + break + } +} + +if ($requiresSqlFirewall) { + & (Join-Path $scriptDir 'ensure-sql-firewall-rule.ps1') +} + foreach ($project in $projects) { $projectDir = $project.Directory.FullName $projectName = [System.IO.Path]::GetFileNameWithoutExtension($project.Name) @@ -88,6 +133,21 @@ foreach ($project in $projects) { } } - Write-Host "Running $projectName migrations ($migrationCommand)." + $connectionString = $sqlConnectionString + $connectionTarget = "Azure SQL database '$sqlDatabase'" + if ($projectName -eq 'Contoso.Products.Database') { + $connectionString = $postgresConnectionString + $connectionTarget = "Azure Postgres database '$postgresDatabase'" + } + + if ($projectName -eq 'Contoso.Products.Database' -and $migrationCommand -eq 'ResetAndAll') { + Write-Host "Running $projectName migrations (Migrate + Schema + ResetAndData) against $connectionTarget." + dotnet run --project $project.FullName -c Release -f $targetFramework -- --connection-string $connectionString Migrate + dotnet run --project $project.FullName -c Release -f $targetFramework -- --connection-string $connectionString Schema + dotnet run --project $project.FullName -c Release -f $targetFramework -- --connection-string $connectionString @promptArgs @extraArgs ResetAndData + continue + } + + Write-Host "Running $projectName migrations ($migrationCommand) against $connectionTarget." dotnet run --project $project.FullName -c Release -f $targetFramework -- --connection-string $connectionString @promptArgs @extraArgs $migrationCommand } diff --git a/azure/scripts/run-products-db-migrations.sh b/azure/scripts/run-products-db-migrations.sh old mode 100644 new mode 100755 index 0bfe7941..a32e1a82 --- a/azure/scripts/run-products-db-migrations.sh +++ b/azure/scripts/run-products-db-migrations.sh @@ -1,7 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -# Runs all Contoso *.Database DbEx migrations against the provisioned Azure SQL database. +# Runs Contoso *.Database DbEx migrations against their provisioned database providers. +# Products runs on Postgres, and all other domains continue to run on Azure SQL. script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" azure_dir="$(cd "${script_dir}/.." && pwd)" @@ -12,6 +13,20 @@ if ! command -v azd >/dev/null 2>&1; then exit 1 fi +get_azd_value() { + local key="${1:?key is required}" + local value + if ! value="$(azd env get-value "${key}" 2>/dev/null | tr -d '\r')"; then + return 1 + fi + + if [[ -z "${value}" || "${value}" == ERROR:* ]]; then + return 1 + fi + + printf '%s' "${value}" +} + target_framework="${AZD_DOTNET_TARGET_FRAMEWORK:-${DOTNET_TARGET_FRAMEWORK:-}}" if [[ -z "${target_framework}" ]]; then if dotnet --list-runtimes | grep -q "Microsoft.NETCore.App 10\."; then @@ -22,28 +37,40 @@ if [[ -z "${target_framework}" ]]; then target_framework="net8.0" fi fi -sql_server="$(azd env get-value sqlServerName | tr -d '\r')" -sql_database="$(azd env get-value sqlDatabaseName | tr -d '\r')" +sql_server="$(get_azd_value sqlServerName || true)" +sql_database="$(get_azd_value sqlDatabaseName || true)" +postgres_server="$(get_azd_value postgresServerName || true)" +postgres_database="$(get_azd_value postgresDatabaseName || true)" sql_admin_login="${AZURE_SQL_ADMIN_LOGIN:-coreexadmin}" sql_password="${AZURE_SQL_ADMIN_PASSWORD:-}" +postgres_admin_login="${AZURE_POSTGRES_ADMIN_LOGIN:-coreexpgadmin}" +postgres_password="${AZURE_POSTGRES_ADMIN_PASSWORD:-}" -if [[ -z "${sql_server}" || -z "${sql_database}" ]]; then - echo "Unable to resolve sqlServerName/sqlDatabaseName from the active azd environment." >&2 +if [[ -z "${sql_server}" || -z "${sql_database}" || -z "${postgres_server}" || -z "${postgres_database}" ]]; then + echo "Missing required azd environment outputs (sqlServerName/sqlDatabaseName/postgresServerName/postgresDatabaseName)." >&2 + echo "Run 'azd provision --no-prompt' (or 'azd up --no-prompt') to refresh environment outputs before deploy." >&2 exit 1 fi if [[ -z "${sql_password}" ]]; then - sql_password="$(azd env get-value AZURE_SQL_ADMIN_PASSWORD | tr -d '\r')" + sql_password="$(get_azd_value AZURE_SQL_ADMIN_PASSWORD || true)" fi -if [[ -z "${sql_password}" ]]; then - echo "AZURE_SQL_ADMIN_PASSWORD is required to run DbEx migrations." >&2 - exit 1 +if [[ -z "${postgres_password}" ]]; then + postgres_password="$(get_azd_value AZURE_POSTGRES_ADMIN_PASSWORD || true)" +fi + +if [[ -z "${postgres_password}" ]]; then + postgres_password="${sql_password}" fi -bash "${script_dir}/ensure-sql-firewall-rule.sh" +if [[ -z "${sql_password}" || -z "${postgres_password}" ]]; then + echo "Database admin passwords are required to run DbEx migrations." >&2 + exit 1 +fi -connection_string="Server=tcp:${sql_server}.database.windows.net,1433;Initial Catalog=${sql_database};User ID=${sql_admin_login};Password=${sql_password};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" +sql_connection_string="Server=tcp:${sql_server}.database.windows.net,1433;Initial Catalog=${sql_database};User ID=${sql_admin_login};Password=${sql_password};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" +postgres_connection_string="Server=${postgres_server}.postgres.database.azure.com;Port=5432;Database=${postgres_database};User Id=${postgres_admin_login};Password=${postgres_password};Ssl Mode=Require;Trust Server Certificate=true;" readarray -t projects < <(find "${repo_root}/samples/src" -maxdepth 2 -type f -name 'Contoso.*.Database.csproj' | sort) if [[ ${#projects[@]} -eq 0 ]]; then @@ -51,7 +78,21 @@ if [[ ${#projects[@]} -eq 0 ]]; then exit 1 fi -echo "Running DbEx migrations for ${#projects[@]} database project(s) using framework '${target_framework}' against database '${sql_database}'." +echo "Running DbEx migrations for ${#projects[@]} database project(s) using framework '${target_framework}' with domain-specific database providers." + +requires_sql_firewall=0 +for project in "${projects[@]}"; do + project_name="$(basename "${project}" .csproj)" + if [[ "${project_name}" != "Contoso.Products.Database" ]]; then + requires_sql_firewall=1 + break + fi +done + +if [[ ${requires_sql_firewall} -eq 1 ]]; then + bash "${script_dir}/ensure-sql-firewall-rule.sh" +fi + for project in "${projects[@]}"; do project_dir="$(dirname "${project}")" project_file_name="$(basename "${project}")" @@ -88,6 +129,21 @@ for project in "${projects[@]}"; do fi fi - echo "Running ${project_name} migrations (${migration_command})." + connection_string="${sql_connection_string}" + connection_target="Azure SQL database '${sql_database}'" + if [[ "${project_name}" == "Contoso.Products.Database" ]]; then + connection_string="${postgres_connection_string}" + connection_target="Azure Postgres database '${postgres_database}'" + fi + + if [[ "${project_name}" == "Contoso.Products.Database" && "${migration_command}" == "ResetAndAll" ]]; then + echo "Running ${project_name} migrations (Migrate + Schema + ResetAndData) against ${connection_target}." + dotnet run --project "${project}" -c Release -f "${target_framework}" -- --connection-string "${connection_string}" Migrate + dotnet run --project "${project}" -c Release -f "${target_framework}" -- --connection-string "${connection_string}" Schema + dotnet run --project "${project}" -c Release -f "${target_framework}" -- --connection-string "${connection_string}" "${prompt_args[@]}" "${extra_args[@]}" ResetAndData + continue + fi + + echo "Running ${project_name} migrations (${migration_command}) against ${connection_target}." dotnet run --project "${project}" -c Release -f "${target_framework}" -- --connection-string "${connection_string}" "${prompt_args[@]}" "${extra_args[@]}" "${migration_command}" done diff --git a/azure/terraform/dev.tfvars b/azure/terraform/dev.tfvars index c04128b5..cc672f65 100644 --- a/azure/terraform/dev.tfvars +++ b/azure/terraform/dev.tfvars @@ -25,8 +25,16 @@ sql_sku_tier = "GeneralPurpose" sql_min_capacity = 0.5 sql_auto_pause_delay = 60 +postgres_admin_login = "coreexpgadmin" +postgres_database_name = "coreexdev" +postgres_sku_name = "Standard_B1ms" +postgres_sku_tier = "Burstable" +postgres_version = "16" +postgres_storage_mb = 32768 + # Derived from current public IP by apply script. sql_firewall_client_ip = "" +postgres_firewall_client_ip = "" redis_sku_name = "Balanced_B0" redis_high_availability = "Disabled" diff --git a/azure/terraform/main.tf b/azure/terraform/main.tf index 77078af0..95a83b86 100644 --- a/azure/terraform/main.tf +++ b/azure/terraform/main.tf @@ -30,7 +30,9 @@ locals { service_bus_name = "sb-${var.environment_type}-${local.suffix}" redis_name = "redis-${var.environment_type}-${local.suffix}" sql_server_name = "sql-${var.environment_type}-${local.suffix}" + postgres_server_name = "pg-${var.environment_type}-${local.suffix}" dashboard_name = "app-aspire-dashboard-${var.environment_type}-${local.suffix}" + postgres_admin_password_effective = coalesce(var.postgres_admin_password, var.sql_admin_password) key_vault_name = substr("kv${var.environment_type}${local.suffix}${random_id.kv.hex}", 0, 24) } @@ -339,6 +341,79 @@ resource "azapi_resource" "sql_database" { locals { sql_fqdn = "${local.sql_server_name}.database.windows.net" sql_connection_string = "Data Source=tcp:${local.sql_fqdn},1433;Initial Catalog=${var.sql_database_name};User id=${var.sql_admin_login};Password=${var.sql_admin_password};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" + postgres_fqdn = "${local.postgres_server_name}.postgres.database.azure.com" + postgres_connection_string = "Host=${local.postgres_fqdn};Port=5432;Database=${var.postgres_database_name};Username=${var.postgres_admin_login};Password=${local.postgres_admin_password_effective};Ssl Mode=Require;Trust Server Certificate=true;" +} + +resource "azapi_resource" "postgres_server" { + type = "Microsoft.DBforPostgreSQL/flexibleServers@2023-12-01-preview" + parent_id = azurerm_resource_group.rg.id + name = local.postgres_server_name + location = var.location + tags = local.merged_tags + + body = { + sku = { + name = var.postgres_sku_name + tier = var.postgres_sku_tier + } + properties = { + administratorLogin = var.postgres_admin_login + administratorLoginPassword = local.postgres_admin_password_effective + version = var.postgres_version + publicNetworkAccess = "Enabled" + storage = { + storageSizeGB = var.postgres_storage_mb / 1024 + } + highAvailability = { + mode = "Disabled" + } + backup = { + backupRetentionDays = 7 + geoRedundantBackup = "Disabled" + } + } + } +} + +resource "azapi_resource" "postgres_firewall_azure" { + type = "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-12-01-preview" + parent_id = azapi_resource.postgres_server.id + name = "AllowAzureServices" + + body = { + properties = { + startIpAddress = "0.0.0.0" + endIpAddress = "0.0.0.0" + } + } +} + +resource "azapi_resource" "postgres_firewall_client" { + count = var.postgres_firewall_client_ip == "" ? 0 : 1 + type = "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-12-01-preview" + parent_id = azapi_resource.postgres_server.id + name = "AllowCurrentRunner-${replace(var.postgres_firewall_client_ip, ".", "-")}" + + body = { + properties = { + startIpAddress = var.postgres_firewall_client_ip + endIpAddress = var.postgres_firewall_client_ip + } + } +} + +resource "azapi_resource" "postgres_database" { + type = "Microsoft.DBforPostgreSQL/flexibleServers/databases@2023-12-01-preview" + parent_id = azapi_resource.postgres_server.id + name = var.postgres_database_name + + body = { + properties = { + charset = "UTF8" + collation = "en_US.utf8" + } + } } resource "azurerm_key_vault_secret" "sql_admin_password" { @@ -357,6 +432,22 @@ resource "azurerm_key_vault_secret" "sql_connection_string" { depends_on = [time_sleep.wait_for_key_vault_rbac] } +resource "azurerm_key_vault_secret" "postgres_admin_password" { + name = "postgres-admin-password" + value = local.postgres_admin_password_effective + key_vault_id = azapi_resource.key_vault.id + + depends_on = [time_sleep.wait_for_key_vault_rbac] +} + +resource "azurerm_key_vault_secret" "postgres_connection_string" { + name = "postgres-connection-string" + value = local.postgres_connection_string + key_vault_id = azapi_resource.key_vault.id + + depends_on = [time_sleep.wait_for_key_vault_rbac] +} + resource "azurerm_key_vault_secret" "service_bus_connection_string" { name = "service-bus-connection-string" value = local.service_bus_keys_output.primaryConnectionString @@ -453,10 +544,6 @@ locals { name = "APPINSIGHTS_SNAPSHOTFEATURE_VERSION" value = "1.0.0" }, - { - name = "Aspire__Microsoft__Data__SqlClient__ConnectionString" - value = local.sql_connection_string - }, { name = "Aspire__StackExchange__Redis__ConnectionString" value = local.redis_connection_string @@ -474,6 +561,18 @@ locals { value = local.otlp_http_endpoint } ] + app_services_sql_settings = [ + { + name = "Aspire__Microsoft__Data__SqlClient__ConnectionString" + value = local.sql_connection_string + } + ] + app_services_postgres_settings = [ + { + name = "Aspire__Npgsql__ConnectionString" + value = local.postgres_connection_string + } + ] } resource "azapi_resource" "products_api" { @@ -501,7 +600,7 @@ resource "azapi_resource" "products_api" { ftpsState = "Disabled" http20Enabled = true alwaysOn = true - appSettings = local.app_services_common_settings + appSettings = concat(local.app_services_common_settings, local.app_services_postgres_settings) } } } @@ -538,7 +637,7 @@ resource "azapi_resource" "shopping_api" { ftpsState = "Disabled" http20Enabled = true alwaysOn = true - appSettings = concat(local.app_services_common_settings, [ + appSettings = concat(local.app_services_common_settings, local.app_services_sql_settings, [ { name = "ProductsApi__BaseAddress" value = "https://${local.products_api_output.properties.defaultHostName}" @@ -574,7 +673,7 @@ resource "azapi_resource" "products_outbox_relay" { ftpsState = "Disabled" http20Enabled = true alwaysOn = true - appSettings = local.app_services_common_settings + appSettings = concat(local.app_services_common_settings, local.app_services_postgres_settings) } } } @@ -605,7 +704,7 @@ resource "azapi_resource" "shopping_outbox_relay" { ftpsState = "Disabled" http20Enabled = true alwaysOn = true - appSettings = local.app_services_common_settings + appSettings = concat(local.app_services_common_settings, local.app_services_sql_settings) } } } @@ -636,7 +735,7 @@ resource "azapi_resource" "products_subscribe" { ftpsState = "Disabled" http20Enabled = true alwaysOn = true - appSettings = local.app_services_common_settings + appSettings = concat(local.app_services_common_settings, local.app_services_postgres_settings) } } } @@ -667,7 +766,7 @@ resource "azapi_resource" "shopping_subscribe" { ftpsState = "Disabled" http20Enabled = true alwaysOn = true - appSettings = local.app_services_common_settings + appSettings = concat(local.app_services_common_settings, local.app_services_sql_settings) } } } diff --git a/azure/terraform/outputs.tf b/azure/terraform/outputs.tf index 6a7ed241..82237b54 100644 --- a/azure/terraform/outputs.tf +++ b/azure/terraform/outputs.tf @@ -27,6 +27,14 @@ output "sql_database_name" { value = azapi_resource.sql_database.name } +output "postgres_server_name" { + value = azapi_resource.postgres_server.name +} + +output "postgres_database_name" { + value = azapi_resource.postgres_database.name +} + output "products_api_app_name" { value = azapi_resource.products_api.name } diff --git a/azure/terraform/prod.tfvars b/azure/terraform/prod.tfvars index 2a4a8fed..f1fe4ef4 100644 --- a/azure/terraform/prod.tfvars +++ b/azure/terraform/prod.tfvars @@ -24,8 +24,16 @@ sql_sku_tier = "GeneralPurpose" sql_min_capacity = 0.5 sql_auto_pause_delay = 60 +postgres_admin_login = "coreexpgadmin" +postgres_database_name = "coreexprod" +postgres_sku_name = "Standard_B1ms" +postgres_sku_tier = "Burstable" +postgres_version = "16" +postgres_storage_mb = 32768 + # Derived from current public IP by apply script. sql_firewall_client_ip = "" +postgres_firewall_client_ip = "" redis_sku_name = "Balanced_B0" redis_high_availability = "Enabled" diff --git a/azure/terraform/test.tfvars b/azure/terraform/test.tfvars index 4215135b..2e0c303b 100644 --- a/azure/terraform/test.tfvars +++ b/azure/terraform/test.tfvars @@ -24,8 +24,16 @@ sql_sku_tier = "GeneralPurpose" sql_min_capacity = 0.5 sql_auto_pause_delay = 60 +postgres_admin_login = "coreexpgadmin" +postgres_database_name = "coreextest" +postgres_sku_name = "Standard_B1ms" +postgres_sku_tier = "Burstable" +postgres_version = "16" +postgres_storage_mb = 32768 + # Derived from current public IP by apply script. sql_firewall_client_ip = "" +postgres_firewall_client_ip = "" redis_sku_name = "Balanced_B0" redis_high_availability = "Enabled" diff --git a/azure/terraform/variables.tf b/azure/terraform/variables.tf index cfe30934..19c7781f 100644 --- a/azure/terraform/variables.tf +++ b/azure/terraform/variables.tf @@ -101,6 +101,49 @@ variable "sql_auto_pause_delay" { type = number } +variable "postgres_admin_login" { + description = "Postgres admin username." + type = string +} + +variable "postgres_admin_password" { + description = "Postgres admin password. Defaults to SQL admin password when omitted." + type = string + sensitive = true + default = null +} + +variable "postgres_database_name" { + description = "Postgres database name used by Products domain." + type = string +} + +variable "postgres_firewall_client_ip" { + description = "Optional public IPv4 for Postgres firewall allow rule." + type = string + default = "" +} + +variable "postgres_sku_name" { + description = "Postgres flexible server SKU name." + type = string +} + +variable "postgres_sku_tier" { + description = "Postgres flexible server SKU tier." + type = string +} + +variable "postgres_version" { + description = "Postgres major version." + type = string +} + +variable "postgres_storage_mb" { + description = "Postgres storage size in MB." + type = number +} + variable "redis_sku_name" { description = "Azure Managed Redis SKU name. Example: Balanced_B0." type = string From 482e19205c652d1e64811b94304ef57fe35742f3 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Wed, 20 May 2026 11:07:00 -0700 Subject: [PATCH 02/14] fix: README.md updated for clean clone setup Signed-off-by: Aaron Spruit --- azure/README.md | 36 +++++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/azure/README.md b/azure/README.md index 26c176f2..d5037b35 100644 --- a/azure/README.md +++ b/azure/README.md @@ -84,24 +84,36 @@ Load environment variables into your current bash session: set -a && eval "$(azd env get-values)" && set +a ``` -The preprovision hook maps the selected target framework to the App Service Linux runtime automatically: -- `net8.0` -> `DOTNETCORE|8.0` -- `net9.0` -> `DOTNETCORE|9.0` -- `net10.0` -> `DOTNETCORE|10.0` +> **Important**: This step must be repeated every time you open a new shell session. The `preprovision` hook scripts read directly from shell environment variables, not from the azd environment store alone. + +Run the preprovision hook manually to generate `infra/main.parameters.json`: + +```bash +./infra/scripts/use-dev-params.sh +``` + +This script: +- Validates that `AZURE_SQL_ADMIN_PASSWORD` and `AZURE_LOCATION` are set. +- Detects your current public IP for SQL and PostgreSQL firewall rules. +- Maps `AZD_DOTNET_TARGET_FRAMEWORK` to the App Service Linux runtime: + - `net8.0` -> `DOTNETCORE|8.0` + - `net9.0` -> `DOTNETCORE|9.0` + - `net10.0` -> `DOTNETCORE|10.0` +- Writes `infra/main.parameters.json` (used by all subsequent azd and az commands). ## Validate before deploy +> **Note**: `azd provision --preview` does not run the `preprovision` hook, so `infra/main.parameters.json` must already exist (generated above) before previewing. + Preview infra changes: ```bash azd provision --preview --no-prompt ``` -Note: If you need the full planned resource list, run: +If you need the full ARM what-if output: ```bash -set -a && eval "$(azd env get-values)" && set +a -./infra/scripts/use-dev-params.sh az deployment group what-if --resource-group --template-file ./infra/main.bicep --parameters ./infra/main.parameters.json --no-pretty-print ``` @@ -208,7 +220,6 @@ After `azd provision` or `azd up`, the postprovision hook automatically stores t - `sql-connection-string` - `postgres-admin-password` - `postgres-connection-string` -- `service-bus-connection-string` Retrieve them: @@ -221,9 +232,6 @@ az keyvault secret show --vault-name $KV --name sql-connection-string -o tsv --q # PostgreSQL connection string az keyvault secret show --vault-name $KV --name postgres-connection-string -o tsv --query value - -# Service Bus connection string -az keyvault secret show --vault-name $KV --name service-bus-connection-string -o tsv --query value ``` ### Update E2E configuration @@ -235,13 +243,11 @@ Edit [../samples/tests/Contoso.E2E.Runner/appsettings.json](../samples/tests/Con "E2E": { "Products": { "BaseAddress": "https://app-products-api-dev-{suffix}.azurewebsites.net", - "ConnectionString": "Server=pg-dev-{suffix}.postgres.database.azure.com;Port=5432;Database=coreexdev;User Id={postgres-admin};Password={password};Ssl Mode=Require;Trust Server Certificate=true;", - "ServiceBus": "Endpoint=sb://sb-dev-{suffix}.servicebus.windows.net/;SharedAccessKeyName=rootManageSharedAccessKey;SharedAccessKey={key};" + "ConnectionString": "Server=pg-dev-{suffix}.postgres.database.azure.com;Port=5432;Database=coreexdev;User Id={postgres-admin};Password={password};Ssl Mode=Require;Trust Server Certificate=true;" }, "Shopping": { "BaseAddress": "https://app-shopping-api-dev-{suffix}.azurewebsites.net", - "ConnectionString": "Server=sql-dev-{suffix}.database.windows.net;Database=Contoso;User Id=sqladmin;Password={password};Encrypt=true;TrustServerCertificate=false;", - "ServiceBus": "Endpoint=sb://sb-dev-{suffix}.servicebus.windows.net/;SharedAccessKeyName=rootManageSharedAccessKey;SharedAccessKey={key};" + "ConnectionString": "Server=sql-dev-{suffix}.database.windows.net;Database=Contoso;User Id=sqladmin;Password={password};Encrypt=true;TrustServerCertificate=false;" } } } From b16667ca79eda566bc21c34edd2fe5a298ba97e8 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Wed, 20 May 2026 11:45:14 -0700 Subject: [PATCH 03/14] adding helper scripts for aspire dashboard tokens and E2E runner config setup Signed-off-by: Aaron Spruit --- azure/README.md | 78 ++++++- azure/scripts/get-aspire-dashboard-login.sh | 182 ++++++++++++++++ azure/scripts/setup-e2e-runner.sh | 217 ++++++++++++++++++++ 3 files changed, 472 insertions(+), 5 deletions(-) create mode 100755 azure/scripts/get-aspire-dashboard-login.sh create mode 100755 azure/scripts/setup-e2e-runner.sh diff --git a/azure/README.md b/azure/README.md index d5037b35..c8098c2e 100644 --- a/azure/README.md +++ b/azure/README.md @@ -147,6 +147,27 @@ azd provision --no-prompt After deployment, the Aspire Dashboard is publicly accessible from a dedicated HTTPS-enabled App Service. The six deployed services are configured to export OTLP telemetry to it automatically. +Default (recommended): use the helper script. + +```bash +./scripts/get-aspire-dashboard-login.sh --resource-group +``` + +Optional arguments: + +```bash +./scripts/get-aspire-dashboard-login.sh \ + --resource-group \ + --dashboard-app-name \ + --token-timeout-seconds 30 +``` + +The script prints: +- Dashboard URL. +- Login URL with token when a token is found in the last 60 minutes of the dashboard runtime log via SCM/Kudu, or during live tailing. + +Manual fallback (if you do not want to use the script): + Find the dashboard URL: ```bash @@ -155,16 +176,22 @@ az webapp show --resource-group --name ` in a browser to view the Aspire Dashboard. -If the dashboard prompts for a browser token, retrieve it from the container logs: +If the dashboard prompts for a browser token, query the dashboard runtime log via SCM/Kudu: ```bash -az webapp log tail --resource-group --name +USER=$(az webapp deployment list-publishing-credentials --resource-group --name --query publishingUserName -o tsv) +PASS=$(az webapp deployment list-publishing-credentials --resource-group --name --query publishingPassword -o tsv) +PAYLOAD='{"command":"grep \"Login to the dashboard\" /appsvctmp/volatile/logs/runtime/container.log","dir":"/home"}' +curl -fsS -u "$USER:$PASS" -H 'Content-Type: application/json' -d "$PAYLOAD" "https://.scm.azurewebsites.net/api/command" ``` Extract only the token (bash): ```bash -TOKEN=$(az webapp log tail --resource-group --name 2>&1 | grep -oE 'login\?t=[A-Za-z0-9]+' | head -n1 | cut -d= -f2) +USER=$(az webapp deployment list-publishing-credentials --resource-group --name --query publishingUserName -o tsv) +PASS=$(az webapp deployment list-publishing-credentials --resource-group --name --query publishingPassword -o tsv) +PAYLOAD='{"command":"grep \"Login to the dashboard\" /appsvctmp/volatile/logs/runtime/container.log","dir":"/home"}' +TOKEN=$(curl -fsS -u "$USER:$PASS" -H 'Content-Type: application/json' -d "$PAYLOAD" "https://.scm.azurewebsites.net/api/command" | grep -oE 'login\?t=[A-Za-z0-9]+' | head -n1 | cut -d= -f2) echo "$TOKEN" ``` @@ -172,7 +199,10 @@ Print a ready-to-open login URL: ```bash HOST=$(az webapp show --resource-group --name --query defaultHostName -o tsv) -TOKEN=$(az webapp log tail --resource-group --name 2>&1 | grep -oE 'login\?t=[A-Za-z0-9]+' | head -n1 | cut -d= -f2) +USER=$(az webapp deployment list-publishing-credentials --resource-group --name --query publishingUserName -o tsv) +PASS=$(az webapp deployment list-publishing-credentials --resource-group --name --query publishingPassword -o tsv) +PAYLOAD='{"command":"grep \"Login to the dashboard\" /appsvctmp/volatile/logs/runtime/container.log","dir":"/home"}' +TOKEN=$(curl -fsS -u "$USER:$PASS" -H 'Content-Type: application/json' -d "$PAYLOAD" "https://.scm.azurewebsites.net/api/command" | grep -oE 'login\?t=[A-Za-z0-9]+' | head -n1 | cut -d= -f2) echo "https://${HOST}/login?t=${TOKEN}" ``` @@ -187,6 +217,42 @@ Note: standalone Aspire Dashboard mode does not provide the full Aspire resource After deploying with `azd up`, you can run the E2E test runner against the deployed services. +Default (recommended): use the helper script. + +```bash +./scripts/setup-e2e-runner.sh --resource-group +``` + +Optional arguments: + +```bash +./scripts/setup-e2e-runner.sh \ + --resource-group \ + --appsettings-path ../samples/tests/Contoso.E2E.Runner/appsettings.json \ + --key-vault-name \ + --products-app-name \ + --shopping-app-name \ + --skip-validation +``` + +The script: +- Discovers the deployed Products and Shopping API host names. +- Validates the deployed APIs using the same API and health paths described below. +- Retrieves the SQL Server and PostgreSQL connection strings from Key Vault. +- Updates [../samples/tests/Contoso.E2E.Runner/appsettings.json](../samples/tests/Contoso.E2E.Runner/appsettings.json) for Products and Shopping. +- Creates an `appsettings.json.bak` backup before writing changes. + +Note: Shopping API validation uses `POST /api/customers/test/baskets`, which creates a test basket. Use `--skip-validation` if you only want to update configuration. + +After the script completes, run the E2E runner: + +```bash +cd ../samples/tests/Contoso.E2E.Runner +dotnet run --framework "${AZD_DOTNET_TARGET_FRAMEWORK:-$DOTNET_TARGET_FRAMEWORK}" +``` + +Manual fallback (if you do not want to use the script): + ### Get deployed endpoint URLs Retrieve the deployed App Service endpoints: @@ -206,11 +272,13 @@ Validate the deployed APIs using API/health/swagger paths (not root `/`): curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/api/products" # Shopping API -curl -i "https://app-shopping-api-dev-{suffix}.azurewebsites.net/api/baskets" +curl -i -X POST "https://app-shopping-api-dev-{suffix}.azurewebsites.net/api/customers/test/baskets" # Common liveness and docs paths curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/health/ready/detailed" curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/swagger" +curl -i "https://app-shopping-api-dev-{suffix}.azurewebsites.net/health/ready/detailed" +curl -i "https://app-shopping-api-dev-{suffix}.azurewebsites.net/swagger" ``` ### Retrieve connection strings diff --git a/azure/scripts/get-aspire-dashboard-login.sh b/azure/scripts/get-aspire-dashboard-login.sh new file mode 100755 index 00000000..1816b094 --- /dev/null +++ b/azure/scripts/get-aspire-dashboard-login.sh @@ -0,0 +1,182 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/get-aspire-dashboard-login.sh --resource-group [--dashboard-app-name ] [--token-timeout-seconds ] + +Description: + Prints the Aspire Dashboard URL. + Attempts to retrieve a browser login token from the last 60 minutes of App Service logs and prints a ready-to-open login URL. + +Options: + --resource-group, -g Azure resource group name (required). + --dashboard-app-name, -n Dashboard web app name (optional; auto-detected when omitted). + --token-timeout-seconds, -t Timeout when waiting for token logs (default: 20). + --help, -h Show this help. +EOF +} + +resource_group="" +dashboard_app_name="" +token_timeout_seconds="20" +log_lookback_minutes="60" + +cleanup() { + if [[ -n "${temp_dir:-}" && -d "${temp_dir}" ]]; then + rm -rf "${temp_dir}" + fi +} + +extract_token_from_recent_logs() { + local publish_user publish_password zip_path logs_dir token_value current_epoch cutoff_epoch command_payload command_response token_line token_timestamp token_epoch + + if ! command -v curl >/dev/null 2>&1; then + return 0 + fi + + if ! command -v unzip >/dev/null 2>&1; then + return 0 + fi + + publish_user="$(az webapp deployment list-publishing-credentials --resource-group "${resource_group}" --name "${dashboard_app_name}" --query publishingUserName -o tsv 2>/dev/null || true)" + publish_password="$(az webapp deployment list-publishing-credentials --resource-group "${resource_group}" --name "${dashboard_app_name}" --query publishingPassword -o tsv 2>/dev/null || true)" + + if [[ -z "${publish_user}" || -z "${publish_password}" ]]; then + return 0 + fi + + current_epoch="$(date -u +%s)" + cutoff_epoch="$((current_epoch - (log_lookback_minutes * 60)))" + + command_payload=$(printf '{"command":"grep \\\"Login to the dashboard\\\" /appsvctmp/volatile/logs/runtime/container.log","dir":"/home"}') + command_response="$(curl -fsS -u "${publish_user}:${publish_password}" -H 'Content-Type: application/json' -d "${command_payload}" "https://${dashboard_app_name}.scm.azurewebsites.net/api/command" 2>/dev/null || true)" + + token_line="$(printf '%s\n' "${command_response}" | grep -oE '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?Z[^\"]*login\?t=[A-Za-z0-9]+' | head -n1 || true)" + + if [[ -n "${token_line}" ]]; then + token_timestamp="$(printf '%s\n' "${token_line}" | grep -oE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}' | head -n1 || true)" + if [[ -n "${token_timestamp}" ]]; then + token_epoch="$(date -u -d "${token_timestamp}Z" +%s 2>/dev/null || true)" + if [[ -n "${token_epoch}" && "${token_epoch}" -ge "${cutoff_epoch}" ]]; then + token_value="$(printf '%s\n' "${token_line}" | grep -oE 'login\?t=[A-Za-z0-9]+' | head -n1 | cut -d= -f2 || true)" + fi + fi + fi + + if [[ -n "${token_value}" ]]; then + printf '%s\n' "${token_value}" + return 0 + fi + + temp_dir="$(mktemp -d)" + trap cleanup EXIT + zip_path="${temp_dir}/logfiles.zip" + logs_dir="${temp_dir}/logs" + + if ! curl -fsS -u "${publish_user}:${publish_password}" "https://${dashboard_app_name}.scm.azurewebsites.net/api/zip/LogFiles/" -o "${zip_path}"; then + return 0 + fi + + mkdir -p "${logs_dir}" + if ! unzip -qq -o "${zip_path}" -d "${logs_dir}"; then + return 0 + fi + + token_value="$(find "${logs_dir}" -type f -print 2>/dev/null | while IFS= read -r file_path; do + [[ -n "${file_path}" ]] || continue + awk -v cutoff_epoch="${cutoff_epoch}" ' + match($0, /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?Z/, ts) { + entry_epoch = mktime(ts[1] " " ts[2] " " ts[3] " " ts[4] " " ts[5] " " ts[6], 1) + if (entry_epoch >= cutoff_epoch && match($0, /login\?t=[A-Za-z0-9]+/)) { + token = substr($0, RSTART, RLENGTH) + sub(/^login\?t=/, "", token) + print token + exit + } + } + ' "${file_path}" + done | head -n1 || true)" + + if [[ -z "${token_value}" ]]; then + return 0 + fi + + if [[ -n "${token_value}" ]]; then + printf '%s\n' "${token_value}" + fi +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --resource-group|-g) + resource_group="${2:-}" + shift 2 + ;; + --dashboard-app-name|-n) + dashboard_app_name="${2:-}" + shift 2 + ;; + --token-timeout-seconds|-t) + token_timeout_seconds="${2:-}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "${resource_group}" ]]; then + echo "Missing required argument: --resource-group" >&2 + usage + exit 1 +fi + +if ! command -v az >/dev/null 2>&1; then + echo "Azure CLI 'az' is not installed or not on PATH." >&2 + exit 1 +fi + +if [[ -z "${dashboard_app_name}" ]]; then + dashboard_app_name="$(az webapp list --resource-group "${resource_group}" --query "[?contains(name, 'aspire-dashboard')].name | [0]" -o tsv)" +fi + +if [[ -z "${dashboard_app_name}" ]]; then + echo "Unable to auto-detect the dashboard app name in resource group '${resource_group}'." >&2 + echo "Re-run with --dashboard-app-name ." >&2 + exit 1 +fi + +host_name="$(az webapp show --resource-group "${resource_group}" --name "${dashboard_app_name}" --query defaultHostName -o tsv)" + +if [[ -z "${host_name}" ]]; then + echo "Unable to resolve dashboard host name for app '${dashboard_app_name}'." >&2 + exit 1 +fi + +token="" +token="$(extract_token_from_recent_logs)" + +if [[ -z "${token}" ]] && command -v timeout >/dev/null 2>&1; then + token="$(timeout "${token_timeout_seconds}s" az webapp log tail --resource-group "${resource_group}" --name "${dashboard_app_name}" 2>&1 | grep -oEm1 'login\?t=[A-Za-z0-9]+' | cut -d= -f2 || true)" +elif [[ -z "${token}" ]]; then + token="$(az webapp log tail --resource-group "${resource_group}" --name "${dashboard_app_name}" 2>&1 | grep -oEm1 'login\?t=[A-Za-z0-9]+' | cut -d= -f2 || true)" +fi + +echo "Dashboard app: ${dashboard_app_name}" +echo "Dashboard URL: https://${host_name}" + +if [[ -n "${token}" ]]; then + echo "Login URL: https://${host_name}/login?t=${token}" +else + echo "Token not found in the last ${log_lookback_minutes} minutes of logs or within ${token_timeout_seconds}s of live tailing." + echo "Open the dashboard URL and, if prompted, run this script again with a larger timeout." +fi diff --git a/azure/scripts/setup-e2e-runner.sh b/azure/scripts/setup-e2e-runner.sh new file mode 100755 index 00000000..03c7cac7 --- /dev/null +++ b/azure/scripts/setup-e2e-runner.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/setup-e2e-runner.sh --resource-group [--appsettings-path ] [--key-vault-name ] [--products-app-name ] [--shopping-app-name ] [--skip-validation] + +Description: + Discovers the deployed Products and Shopping API endpoints, retrieves the + database connection strings from Key Vault, validates the deployed APIs, and + updates the E2E runner appsettings.json for Products and Shopping. + +Options: + --resource-group, -g Azure resource group name (required). + --appsettings-path, -a Path to the E2E runner appsettings.json file. + Defaults to samples/tests/Contoso.E2E.Runner/appsettings.json. + --key-vault-name, -k Key Vault name. Auto-detected when omitted. + --products-app-name, -p Products API web app name. Auto-detected when omitted. + --shopping-app-name, -s Shopping API web app name. Auto-detected when omitted. + --skip-validation Skip the endpoint validation checks. + --help, -h Show this help. +EOF +} + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "${script_dir}/../.." && pwd)" + +resource_group="" +appsettings_path="${repo_root}/samples/tests/Contoso.E2E.Runner/appsettings.json" +key_vault_name="" +products_app_name="" +shopping_app_name="" +skip_validation="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --resource-group|-g) + resource_group="${2:-}" + shift 2 + ;; + --appsettings-path|-a) + appsettings_path="${2:-}" + shift 2 + ;; + --key-vault-name|-k) + key_vault_name="${2:-}" + shift 2 + ;; + --products-app-name|-p) + products_app_name="${2:-}" + shift 2 + ;; + --shopping-app-name|-s) + shopping_app_name="${2:-}" + shift 2 + ;; + --skip-validation) + skip_validation="true" + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "${resource_group}" ]]; then + echo "Missing required argument: --resource-group" >&2 + usage + exit 1 +fi + +if ! command -v az >/dev/null 2>&1; then + echo "Azure CLI 'az' is not installed or not on PATH." >&2 + exit 1 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required but was not found on PATH." >&2 + exit 1 +fi + +if [[ ! -f "${appsettings_path}" ]]; then + echo "The E2E runner appsettings file was not found at '${appsettings_path}'." >&2 + exit 1 +fi + +get_webapp_host() { + local app_name="$1" + az webapp show --resource-group "${resource_group}" --name "${app_name}" --query defaultHostName -o tsv +} + +validate_request() { + local label="$1" + local url="$2" + local method="${3:-GET}" + local status_code + + status_code="$(curl -k -sS -o /dev/null -w '%{http_code}' -X "${method}" "${url}" || true)" + if [[ ! "${status_code}" =~ ^[23] ]]; then + echo "Validation failed for ${label}: ${method} ${url} returned HTTP ${status_code:-unknown}." >&2 + exit 1 + fi + + echo "Validated ${label}: ${method} ${url} (${status_code})" +} + +if [[ -z "${products_app_name}" ]]; then + products_app_name="$(az webapp list --resource-group "${resource_group}" --query "[?contains(name, 'products-api')].name | [0]" -o tsv)" +fi + +if [[ -z "${shopping_app_name}" ]]; then + shopping_app_name="$(az webapp list --resource-group "${resource_group}" --query "[?contains(name, 'shopping-api')].name | [0]" -o tsv)" +fi + +if [[ -z "${products_app_name}" || -z "${shopping_app_name}" ]]; then + echo "Unable to auto-detect the Products or Shopping API app names in resource group '${resource_group}'." >&2 + echo "Re-run with --products-app-name and --shopping-app-name." >&2 + exit 1 +fi + +products_host="$(get_webapp_host "${products_app_name}")" +shopping_host="$(get_webapp_host "${shopping_app_name}")" + +if [[ -z "${products_host}" || -z "${shopping_host}" ]]; then + echo "Unable to resolve one or more app host names." >&2 + exit 1 +fi + +if [[ -z "${key_vault_name}" ]]; then + key_vault_name="$(az keyvault list --resource-group "${resource_group}" --query '[0].name' -o tsv)" +fi + +if [[ -z "${key_vault_name}" ]]; then + echo "Unable to auto-detect the Key Vault in resource group '${resource_group}'." >&2 + echo "Re-run with --key-vault-name ." >&2 + exit 1 +fi + +postgres_connection_string="$(az keyvault secret show --vault-name "${key_vault_name}" --name postgres-connection-string --query value -o tsv)" +sql_connection_string="$(az keyvault secret show --vault-name "${key_vault_name}" --name sql-connection-string --query value -o tsv)" + +if [[ -z "${postgres_connection_string}" || -z "${sql_connection_string}" ]]; then + echo "Unable to retrieve one or more connection strings from Key Vault '${key_vault_name}'." >&2 + exit 1 +fi + +if [[ "${skip_validation}" != "true" ]]; then + validate_request "Products API" "https://${products_host}/api/products" + validate_request "Shopping API" "https://${shopping_host}/api/customers/test/baskets" POST + validate_request "Products health" "https://${products_host}/health/ready/detailed" + validate_request "Products swagger" "https://${products_host}/swagger" + validate_request "Shopping health" "https://${shopping_host}/health/ready/detailed" + validate_request "Shopping swagger" "https://${shopping_host}/swagger" +fi + +backup_path="${appsettings_path}.bak" +cp "${appsettings_path}" "${backup_path}" + +temp_path="$(mktemp)" + +if command -v jq >/dev/null 2>&1; then + jq \ + --arg productsBase "https://${products_host}" \ + --arg productsConnectionString "${postgres_connection_string}" \ + --arg shoppingBase "https://${shopping_host}" \ + --arg shoppingConnectionString "${sql_connection_string}" \ + '.E2E.Products.BaseAddress = $productsBase + | .E2E.Products.ConnectionString = $productsConnectionString + | .E2E.Shopping.BaseAddress = $shoppingBase + | .E2E.Shopping.ConnectionString = $shoppingConnectionString' \ + "${appsettings_path}" > "${temp_path}" +elif command -v python3 >/dev/null 2>&1; then + python3 - "${appsettings_path}" "${temp_path}" "https://${products_host}" "${postgres_connection_string}" "https://${shopping_host}" "${sql_connection_string}" <<'PY' +import json +import sys + +source_path, temp_path, products_base, products_cs, shopping_base, shopping_cs = sys.argv[1:7] + +with open(source_path, encoding='utf-8') as file: + data = json.load(file) + +data.setdefault('E2E', {}) +data['E2E'].setdefault('Products', {}) +data['E2E'].setdefault('Shopping', {}) +data['E2E']['Products']['BaseAddress'] = products_base +data['E2E']['Products']['ConnectionString'] = products_cs +data['E2E']['Shopping']['BaseAddress'] = shopping_base +data['E2E']['Shopping']['ConnectionString'] = shopping_cs + +with open(temp_path, 'w', encoding='utf-8') as file: + json.dump(data, file, indent=2) + file.write('\n') +PY +else + echo "Either jq or python3 is required to update '${appsettings_path}'." >&2 + exit 1 +fi + +mv "${temp_path}" "${appsettings_path}" + +echo "Updated E2E runner configuration: ${appsettings_path}" +echo "Backup created: ${backup_path}" +echo "Products BaseAddress: https://${products_host}" +echo "Shopping BaseAddress: https://${shopping_host}" +echo "Key Vault: ${key_vault_name}" +echo "" +echo "Next step:" +echo " cd ${repo_root}/samples/tests/Contoso.E2E.Runner" +echo " dotnet run --framework \"\${AZD_DOTNET_TARGET_FRAMEWORK:-\$DOTNET_TARGET_FRAMEWORK}\"" \ No newline at end of file From 194bb3a7d7fc883f0406cf2b7cc678a96031d485 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Wed, 20 May 2026 11:51:34 -0700 Subject: [PATCH 04/14] adding ps1 variants of helper scripts. Signed-off-by: Aaron Spruit --- azure/scripts/get-aspire-dashboard-login.ps1 | 179 +++++++++++++++++++ azure/scripts/setup-e2e-runner.ps1 | 146 +++++++++++++++ 2 files changed, 325 insertions(+) create mode 100644 azure/scripts/get-aspire-dashboard-login.ps1 create mode 100644 azure/scripts/setup-e2e-runner.ps1 diff --git a/azure/scripts/get-aspire-dashboard-login.ps1 b/azure/scripts/get-aspire-dashboard-login.ps1 new file mode 100644 index 00000000..3bd56384 --- /dev/null +++ b/azure/scripts/get-aspire-dashboard-login.ps1 @@ -0,0 +1,179 @@ +#Requires -Version 7 +[CmdletBinding()] +param ( + [Alias('g')] + [Parameter(Mandatory)] + [string] $ResourceGroup, + + [Alias('n')] + [string] $DashboardAppName, + + [Alias('t')] + [int] $TokenTimeoutSeconds = 20, + + [int] $LogLookbackMinutes = 60 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Get-TokenFromRuntimeLog { + param ( + [string] $PublishUser, + [string] $PublishPassword, + [string] $AppName, + [long] $CutoffEpoch + ) + + $payload = '{"command":"grep \"Login to the dashboard\" /appsvctmp/volatile/logs/runtime/container.log","dir":"/home"}' + $base64 = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${PublishUser}:${PublishPassword}")) + $headers = @{ Authorization = "Basic $base64"; 'Content-Type' = 'application/json' } + $scmUrl = "https://${AppName}.scm.azurewebsites.net/api/command" + + try { + $response = Invoke-RestMethod -Uri $scmUrl -Method Post -Headers $headers -Body $payload -ErrorAction Stop + } + catch { + return $null + } + + $output = $response.Output + if (-not $output) { return $null } + + # Match timestamp + token on the same line. + if ($output -match '(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?Z[^"]*login\?t=([A-Za-z0-9]+)') { + $tsString = $Matches[1] + 'Z' + $entryTime = [DateTimeOffset]::Parse($tsString, $null, [Globalization.DateTimeStyles]::AssumeUniversal) + $entryEpoch = $entryTime.ToUnixTimeSeconds() + if ($entryEpoch -ge $CutoffEpoch) { + return $Matches[2] + } + } + + return $null +} + +function Get-TokenFromLogArchive { + param ( + [string] $PublishUser, + [string] $PublishPassword, + [string] $AppName, + [long] $CutoffEpoch + ) + + $base64 = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("${PublishUser}:${PublishPassword}")) + $headers = @{ Authorization = "Basic $base64" } + $scmUrl = "https://${AppName}.scm.azurewebsites.net/api/zip/LogFiles/" + $tmpZip = [IO.Path]::GetTempFileName() + '.zip' + $tmpDir = [IO.Path]::Combine([IO.Path]::GetTempPath(), [IO.Path]::GetRandomFileName()) + + try { + try { + Invoke-WebRequest -Uri $scmUrl -Headers $headers -OutFile $tmpZip -ErrorAction Stop + } + catch { + return $null + } + + $null = New-Item -ItemType Directory -Path $tmpDir -Force + Expand-Archive -Path $tmpZip -DestinationPath $tmpDir -Force + + $files = Get-ChildItem -Path $tmpDir -Recurse -File + + foreach ($file in $files) { + $lines = Get-Content -Path $file.FullName -ErrorAction SilentlyContinue + foreach ($line in $lines) { + if ($line -match '^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.\d+)?Z.*login\?t=([A-Za-z0-9]+)') { + $tsString = $Matches[1] + 'Z' + $entryTime = [DateTimeOffset]::Parse($tsString, $null, [Globalization.DateTimeStyles]::AssumeUniversal) + if ($entryTime.ToUnixTimeSeconds() -ge $CutoffEpoch) { + return $Matches[2] + } + } + } + } + } + finally { + if (Test-Path $tmpZip) { Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue } + if (Test-Path $tmpDir) { Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue } + } + + return $null +} + +# Verify az CLI. +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Error "Azure CLI 'az' is not installed or not on PATH." + exit 1 +} + +# Auto-detect dashboard app if not supplied. +if (-not $DashboardAppName) { + $DashboardAppName = (az webapp list --resource-group $ResourceGroup --query "[?contains(name, 'aspire-dashboard')].name | [0]" -o tsv) +} + +if (-not $DashboardAppName) { + Write-Error "Unable to auto-detect the dashboard app name in resource group '$ResourceGroup'. Re-run with -DashboardAppName ." + exit 1 +} + +$hostName = (az webapp show --resource-group $ResourceGroup --name $DashboardAppName --query defaultHostName -o tsv) + +if (-not $hostName) { + Write-Error "Unable to resolve dashboard host name for app '$DashboardAppName'." + exit 1 +} + +$cutoffEpoch = [DateTimeOffset]::UtcNow.AddMinutes(-$LogLookbackMinutes).ToUnixTimeSeconds() + +# Attempt 1: query runtime container log via SCM Kudu. +$token = $null +try { + $creds = (az webapp deployment list-publishing-credentials --resource-group $ResourceGroup --name $DashboardAppName --output json 2>$null | ConvertFrom-Json) + $publishUser = $creds.publishingUserName + $publishPass = $creds.publishingPassword + + if ($publishUser -and $publishPass) { + $token = Get-TokenFromRuntimeLog -PublishUser $publishUser -PublishPassword $publishPass -AppName $DashboardAppName -CutoffEpoch $cutoffEpoch + + # Attempt 2: fall back to archived log files. + if (-not $token) { + $token = Get-TokenFromLogArchive -PublishUser $publishUser -PublishPassword $publishPass -AppName $DashboardAppName -CutoffEpoch $cutoffEpoch + } + } +} +catch { + # Non-fatal; will fall through to live tail. +} + +# Attempt 3: live log tail. +if (-not $token) { + $deadline = [DateTime]::UtcNow.AddSeconds($TokenTimeoutSeconds) + $job = Start-Job -ScriptBlock { + param($rg, $app) + az webapp log tail --resource-group $rg --name $app 2>&1 + } -ArgumentList $ResourceGroup, $DashboardAppName + + while ([DateTime]::UtcNow -lt $deadline -and $job.State -eq 'Running') { + $partial = Receive-Job -Job $job -Keep 2>$null + $partialText = ($partial | Out-String) + if ($partialText -match 'login\?t=([A-Za-z0-9]+)') { + $token = $Matches[1] + break + } + Start-Sleep -Milliseconds 500 + } + Stop-Job -Job $job -ErrorAction SilentlyContinue + Remove-Job -Job $job -Force -ErrorAction SilentlyContinue +} + +Write-Host "Dashboard app: $DashboardAppName" +Write-Host "Dashboard URL: https://$hostName" + +if ($token) { + Write-Host "Login URL: https://${hostName}/login?t=${token}" +} +else { + Write-Host "Token not found in the last $LogLookbackMinutes minutes of logs or within ${TokenTimeoutSeconds}s of live tailing." + Write-Host "Open the dashboard URL and, if prompted, run this script again with a larger -TokenTimeoutSeconds value." +} diff --git a/azure/scripts/setup-e2e-runner.ps1 b/azure/scripts/setup-e2e-runner.ps1 new file mode 100644 index 00000000..3263c347 --- /dev/null +++ b/azure/scripts/setup-e2e-runner.ps1 @@ -0,0 +1,146 @@ +#Requires -Version 7 +[CmdletBinding()] +param ( + [Alias('g')] + [Parameter(Mandatory)] + [string] $ResourceGroup, + + [Alias('a')] + [string] $AppsettingsPath, + + [Alias('k')] + [string] $KeyVaultName, + + [Alias('p')] + [string] $ProductsAppName, + + [Alias('s')] + [string] $ShoppingAppName, + + [switch] $SkipValidation +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir '../..') + +if (-not $AppsettingsPath) { + $AppsettingsPath = Join-Path $repoRoot 'samples/tests/Contoso.E2E.Runner/appsettings.json' +} + +# Verify prerequisites. +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Error "Azure CLI 'az' is not installed or not on PATH." + exit 1 +} + +if (-not (Test-Path $AppsettingsPath)) { + Write-Error "The E2E runner appsettings file was not found at '$AppsettingsPath'." + exit 1 +} + +function Invoke-ValidateRequest { + param ( + [string] $Label, + [string] $Url, + [string] $Method = 'GET' + ) + + try { + $response = Invoke-WebRequest -Uri $Url -Method $Method -SkipCertificateCheck -UseBasicParsing -ErrorAction Stop + $code = [int]$response.StatusCode + } + catch [System.Net.Http.HttpRequestException] { + $code = [int]$_.Exception.Response.StatusCode + } + catch { + Write-Error "Validation failed for ${Label}: ${Method} ${Url} — $($_.Exception.Message)" + exit 1 + } + + if ($code -lt 200 -or $code -ge 400) { + Write-Error "Validation failed for ${Label}: ${Method} ${Url} returned HTTP ${code}." + exit 1 + } + + Write-Host "Validated ${Label}: ${Method} ${Url} (${code})" +} + +# Auto-detect app names. +if (-not $ProductsAppName) { + $ProductsAppName = (az webapp list --resource-group $ResourceGroup --query "[?contains(name, 'products-api')].name | [0]" -o tsv) +} + +if (-not $ShoppingAppName) { + $ShoppingAppName = (az webapp list --resource-group $ResourceGroup --query "[?contains(name, 'shopping-api')].name | [0]" -o tsv) +} + +if (-not $ProductsAppName -or -not $ShoppingAppName) { + Write-Error "Unable to auto-detect the Products or Shopping API app names in resource group '$ResourceGroup'. Re-run with -ProductsAppName and -ShoppingAppName." + exit 1 +} + +$productsHost = (az webapp show --resource-group $ResourceGroup --name $ProductsAppName --query defaultHostName -o tsv) +$shoppingHost = (az webapp show --resource-group $ResourceGroup --name $ShoppingAppName --query defaultHostName -o tsv) + +if (-not $productsHost -or -not $shoppingHost) { + Write-Error "Unable to resolve one or more app host names." + exit 1 +} + +# Auto-detect Key Vault. +if (-not $KeyVaultName) { + $KeyVaultName = (az keyvault list --resource-group $ResourceGroup --query '[0].name' -o tsv) +} + +if (-not $KeyVaultName) { + Write-Error "Unable to auto-detect the Key Vault in resource group '$ResourceGroup'. Re-run with -KeyVaultName ." + exit 1 +} + +$postgresConnectionString = (az keyvault secret show --vault-name $KeyVaultName --name postgres-connection-string --query value -o tsv) +$sqlConnectionString = (az keyvault secret show --vault-name $KeyVaultName --name sql-connection-string --query value -o tsv) + +if (-not $postgresConnectionString -or -not $sqlConnectionString) { + Write-Error "Unable to retrieve one or more connection strings from Key Vault '$KeyVaultName'." + exit 1 +} + +# Validate endpoints. +if (-not $SkipValidation) { + Invoke-ValidateRequest -Label 'Products API' -Url "https://${productsHost}/api/products" + Invoke-ValidateRequest -Label 'Shopping API' -Url "https://${shoppingHost}/api/customers/test/baskets" -Method POST + Invoke-ValidateRequest -Label 'Products health' -Url "https://${productsHost}/health/ready/detailed" + Invoke-ValidateRequest -Label 'Products swagger' -Url "https://${productsHost}/swagger" + Invoke-ValidateRequest -Label 'Shopping health' -Url "https://${shoppingHost}/health/ready/detailed" + Invoke-ValidateRequest -Label 'Shopping swagger' -Url "https://${shoppingHost}/swagger" +} + +# Update appsettings.json. +$backupPath = "${AppsettingsPath}.bak" +Copy-Item -Path $AppsettingsPath -Destination $backupPath -Force + +$settings = Get-Content $AppsettingsPath -Raw | ConvertFrom-Json -AsHashtable + +if (-not $settings.ContainsKey('E2E')) { $settings['E2E'] = @{} } +if (-not $settings['E2E'].ContainsKey('Products')) { $settings['E2E']['Products'] = @{} } +if (-not $settings['E2E'].ContainsKey('Shopping')) { $settings['E2E']['Shopping'] = @{} } + +$settings['E2E']['Products']['BaseAddress'] = "https://${productsHost}" +$settings['E2E']['Products']['ConnectionString'] = $postgresConnectionString +$settings['E2E']['Shopping']['BaseAddress'] = "https://${shoppingHost}" +$settings['E2E']['Shopping']['ConnectionString'] = $sqlConnectionString + +$settings | ConvertTo-Json -Depth 10 | Set-Content -Path $AppsettingsPath -Encoding utf8 + +Write-Host "Updated E2E runner configuration: $AppsettingsPath" +Write-Host "Backup created: $backupPath" +Write-Host "Products BaseAddress: https://$productsHost" +Write-Host "Shopping BaseAddress: https://$shoppingHost" +Write-Host "Key Vault: $KeyVaultName" +Write-Host "" +Write-Host "Next step:" +Write-Host " cd $repoRoot/samples/tests/Contoso.E2E.Runner" +Write-Host " dotnet run --framework `"`${env:AZD_DOTNET_TARGET_FRAMEWORK ?? `$env:DOTNET_TARGET_FRAMEWORK}`"" From 221228c2575dbf216b3c57890a5fb25243587131 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Wed, 20 May 2026 12:52:57 -0700 Subject: [PATCH 05/14] fix: updating copilot review findings for the scripts Signed-off-by: Aaron Spruit --- azure/README.md | 3 +- azure/infra/scripts/store-secrets.ps1 | 35 +++++-- azure/scripts/ensure-sql-firewall-rule.ps1 | 107 ++++++++++++++++----- azure/scripts/ensure-sql-firewall-rule.sh | 91 +++++++++++++----- azure/scripts/setup-e2e-runner.sh | 15 ++- 5 files changed, 189 insertions(+), 62 deletions(-) diff --git a/azure/README.md b/azure/README.md index c8098c2e..7d3d9afd 100644 --- a/azure/README.md +++ b/azure/README.md @@ -232,7 +232,8 @@ Optional arguments: --key-vault-name \ --products-app-name \ --shopping-app-name \ - --skip-validation + --skip-validation \ + --insecure ``` The script: diff --git a/azure/infra/scripts/store-secrets.ps1 b/azure/infra/scripts/store-secrets.ps1 index 23f93e81..80d98792 100644 --- a/azure/infra/scripts/store-secrets.ps1 +++ b/azure/infra/scripts/store-secrets.ps1 @@ -14,6 +14,29 @@ if ([string]::IsNullOrWhiteSpace($sqlPassword)) { $postgresPassword = if ([string]::IsNullOrWhiteSpace($env:AZURE_POSTGRES_ADMIN_PASSWORD)) { $sqlPassword } else { $env:AZURE_POSTGRES_ADMIN_PASSWORD } +$sqlServer = if ([string]::IsNullOrWhiteSpace($env:AZURE_SQL_SERVER)) { (azd env get-value sqlServerName).Trim() } else { $env:AZURE_SQL_SERVER } +$sqlLogin = if ([string]::IsNullOrWhiteSpace($env:AZURE_SQL_ADMIN_LOGIN)) { 'coreexadmin' } else { $env:AZURE_SQL_ADMIN_LOGIN } +$sqlDb = if ([string]::IsNullOrWhiteSpace($env:AZURE_SQL_DB_NAME)) { (azd env get-value sqlDatabaseName).Trim() } else { $env:AZURE_SQL_DB_NAME } +$postgresServer = if ([string]::IsNullOrWhiteSpace($env:AZURE_POSTGRES_SERVER)) { (azd env get-value postgresServerName).Trim() } else { $env:AZURE_POSTGRES_SERVER } +$postgresLogin = if ([string]::IsNullOrWhiteSpace($env:AZURE_POSTGRES_ADMIN_LOGIN)) { 'coreexpgadmin' } else { $env:AZURE_POSTGRES_ADMIN_LOGIN } +$postgresDb = if ([string]::IsNullOrWhiteSpace($env:AZURE_POSTGRES_DB_NAME)) { (azd env get-value postgresDatabaseName).Trim() } else { $env:AZURE_POSTGRES_DB_NAME } + +if ([string]::IsNullOrWhiteSpace($sqlServer)) { + throw 'AZURE_SQL_SERVER (or azd output sqlServerName) is not set.' +} + +if ([string]::IsNullOrWhiteSpace($sqlDb)) { + throw 'AZURE_SQL_DB_NAME (or azd output sqlDatabaseName) is not set.' +} + +if ([string]::IsNullOrWhiteSpace($postgresServer)) { + throw 'AZURE_POSTGRES_SERVER (or azd output postgresServerName) is not set.' +} + +if ([string]::IsNullOrWhiteSpace($postgresDb)) { + throw 'AZURE_POSTGRES_DB_NAME (or azd output postgresDatabaseName) is not set.' +} + Write-Host "Locating Key Vault in resource group '$rg'..." $kvName = (az keyvault list --resource-group $rg --query '[0].name' -o tsv) $kvId = (az keyvault show --name $kvName --resource-group $rg --query id -o tsv) @@ -39,20 +62,14 @@ az keyvault secret set --vault-name $kvName --name 'sql-admin-password' --value Write-Host 'Storing postgres-admin-password...' az keyvault secret set --vault-name $kvName --name 'postgres-admin-password' --value $postgresPassword --output none -Write-Host 'Locating SQL Server...' -$sqlServer = (az sql server list --resource-group $rg --query '[0].name' -o tsv) -$sqlLogin = (az sql server show --resource-group $rg --name $sqlServer --query administratorLogin -o tsv) -$sqlDb = (az sql db list --resource-group $rg --server $sqlServer --query "[?name!='master'].name | [0]" -o tsv) +Write-Host 'Building SQL Server connection string...' $sqlConn = "Server=tcp:${sqlServer}.database.windows.net,1433;Database=${sqlDb};User Id=${sqlLogin};Password=${sqlPassword};Encrypt=true;TrustServerCertificate=false;" Write-Host 'Storing sql-connection-string...' az keyvault secret set --vault-name $kvName --name 'sql-connection-string' --value $sqlConn --output none -Write-Host 'Locating Postgres server...' -$postgresServer = (az postgres flexible-server list --resource-group $rg --query '[0].name' -o tsv) -$postgresLogin = (az postgres flexible-server show --resource-group $rg --name $postgresServer --query administratorLogin -o tsv) -$postgresDb = (az postgres flexible-server db list --resource-group $rg --server-name $postgresServer --query '[0].name' -o tsv) -$postgresConn = "Server=$postgresServer.postgres.database.azure.com;Port=5432;Database=$postgresDb;User Id=$postgresLogin;Password=$postgresPassword;Ssl Mode=Require;Trust Server Certificate=true;" +Write-Host 'Building Postgres connection string...' +$postgresConn = "Server=${postgresServer}.postgres.database.azure.com;Port=5432;Database=${postgresDb};User Id=${postgresLogin};Password=${postgresPassword};Ssl Mode=Require;Trust Server Certificate=true;" Write-Host 'Storing postgres-connection-string...' az keyvault secret set --vault-name $kvName --name 'postgres-connection-string' --value $postgresConn --output none diff --git a/azure/scripts/ensure-sql-firewall-rule.ps1 b/azure/scripts/ensure-sql-firewall-rule.ps1 index d6a4ae7f..af7d023f 100644 --- a/azure/scripts/ensure-sql-firewall-rule.ps1 +++ b/azure/scripts/ensure-sql-firewall-rule.ps1 @@ -1,13 +1,13 @@ $ErrorActionPreference = 'Stop' -# Ensures the current runner public IP has an Azure SQL firewall rule. +# Ensures the current runner public IP has Azure SQL and PostgreSQL firewall rules. if (-not (Get-Command azd -ErrorAction SilentlyContinue)) { throw "The 'azd' command is required to resolve environment values." } if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - throw "The 'az' command is required to manage Azure SQL firewall rules." + throw "The 'az' command is required to manage Azure firewall rules." } function Invoke-WithRetry { @@ -38,13 +38,54 @@ function Invoke-WithRetry { } } +function Ensure-FirewallRule { + param( + [Parameter(Mandatory = $true)] + [string] $DbType, + [Parameter(Mandatory = $true)] + [string] $ServerName, + [Parameter(Mandatory = $true)] + [string] $ClientIp, + [Parameter(Mandatory = $true)] + [string] $FirewallRuleName, + [Parameter(Mandatory = $true)] + [string[]] $AzArgs + ) + + if ($DbType -eq 'sql') { + az sql server firewall-rule show @AzArgs --name $FirewallRuleName *> $null + if ($LASTEXITCODE -eq 0) { + Write-Host "Updating Azure SQL firewall rule '$FirewallRuleName' for $ClientIp." + az sql server firewall-rule update @AzArgs --name $FirewallRuleName --start-ip-address $ClientIp --end-ip-address $ClientIp | Out-Null + } + else { + Write-Host "Creating Azure SQL firewall rule '$FirewallRuleName' for $ClientIp." + az sql server firewall-rule create @AzArgs --name $FirewallRuleName --start-ip-address $ClientIp --end-ip-address $ClientIp | Out-Null + } + Write-Host "Azure SQL firewall rule '$FirewallRuleName' is ready." + } + elseif ($DbType -eq 'postgres') { + az postgres flexible-server firewall-rule show @AzArgs --rule-name $FirewallRuleName *> $null + if ($LASTEXITCODE -eq 0) { + Write-Host "Updating Azure PostgreSQL firewall rule '$FirewallRuleName' for $ClientIp." + az postgres flexible-server firewall-rule update @AzArgs --rule-name $FirewallRuleName --start-ip-address $ClientIp --end-ip-address $ClientIp | Out-Null + } + else { + Write-Host "Creating Azure PostgreSQL firewall rule '$FirewallRuleName' for $ClientIp." + az postgres flexible-server firewall-rule create @AzArgs --rule-name $FirewallRuleName --start-ip-address $ClientIp --end-ip-address $ClientIp | Out-Null + } + Write-Host "Azure PostgreSQL firewall rule '$FirewallRuleName' is ready." + } +} + $sqlServer = (azd env get-value sqlServerName).Trim() +$postgresServer = (azd env get-value postgresServerName).Trim() $azureResourceGroup = (azd env get-value AZURE_RESOURCE_GROUP).Trim() $azureSubscriptionId = (azd env get-value AZURE_SUBSCRIPTION_ID).Trim() $azureEnvName = (azd env get-value AZURE_ENV_NAME).Trim() -if ([string]::IsNullOrWhiteSpace($sqlServer) -or [string]::IsNullOrWhiteSpace($azureResourceGroup)) { - throw 'Unable to resolve sqlServerName/AZURE_RESOURCE_GROUP from the active azd environment.' +if (([string]::IsNullOrWhiteSpace($sqlServer) -and [string]::IsNullOrWhiteSpace($postgresServer)) -or [string]::IsNullOrWhiteSpace($azureResourceGroup)) { + throw 'Unable to resolve sqlServerName and/or postgresServerName and AZURE_RESOURCE_GROUP from the active azd environment.' } $clientIp = '' @@ -71,31 +112,45 @@ if ($clientIp -notmatch '^([0-9]{1,3}\.){3}[0-9]{1,3}$') { $effectiveEnvName = if ([string]::IsNullOrWhiteSpace($azureEnvName)) { 'env' } else { $azureEnvName } $firewallRuleName = "azd-$effectiveEnvName-$($clientIp -replace '\.', '-')" -$azServerArgs = @('--resource-group', $azureResourceGroup, '--name', $sqlServer) -$azFirewallArgs = @('--resource-group', $azureResourceGroup, '--server', $sqlServer) -if (-not [string]::IsNullOrWhiteSpace($azureSubscriptionId)) { - $azServerArgs += @('--subscription', $azureSubscriptionId) - $azFirewallArgs += @('--subscription', $azureSubscriptionId) -} -if ($env:AZD_SQL_FIREWALL_WAIT_FOR_SERVER -eq '1') { - Write-Host "Waiting for Azure SQL server '$sqlServer' to become available." - Invoke-WithRetry -Attempts 12 -DelaySeconds 10 -Description 'Azure SQL server readiness' -ScriptBlock { +if (-not [string]::IsNullOrWhiteSpace($sqlServer)) { + $azServerArgs = @('--resource-group', $azureResourceGroup, '--name', $sqlServer) + $azFirewallArgs = @('--resource-group', $azureResourceGroup, '--server', $sqlServer) + if (-not [string]::IsNullOrWhiteSpace($azureSubscriptionId)) { + $azServerArgs += @('--subscription', $azureSubscriptionId) + $azFirewallArgs += @('--subscription', $azureSubscriptionId) + } + + if ($env:AZD_SQL_FIREWALL_WAIT_FOR_SERVER -eq '1') { + Write-Host "Waiting for Azure SQL server '$sqlServer' to become available." + Invoke-WithRetry -Attempts 12 -DelaySeconds 10 -Description 'Azure SQL server readiness' -ScriptBlock { + az sql server show @azServerArgs | Out-Null + } + } + else { az sql server show @azServerArgs | Out-Null } -} -else { - az sql server show @azServerArgs | Out-Null -} -az sql server firewall-rule show @azFirewallArgs --name $firewallRuleName *> $null -if ($LASTEXITCODE -eq 0) { - Write-Host "Updating Azure SQL firewall rule '$firewallRuleName' for $clientIp." - az sql server firewall-rule update @azFirewallArgs --name $firewallRuleName --start-ip-address $clientIp --end-ip-address $clientIp | Out-Null -} -else { - Write-Host "Creating Azure SQL firewall rule '$firewallRuleName' for $clientIp." - az sql server firewall-rule create @azFirewallArgs --name $firewallRuleName --start-ip-address $clientIp --end-ip-address $clientIp | Out-Null + Ensure-FirewallRule -DbType 'sql' -ServerName $sqlServer -ClientIp $clientIp -FirewallRuleName $firewallRuleName -AzArgs $azFirewallArgs } -Write-Host "Azure SQL firewall rule '$firewallRuleName' is ready." \ No newline at end of file +if (-not [string]::IsNullOrWhiteSpace($postgresServer)) { + $azPostgresServerArgs = @('--resource-group', $azureResourceGroup, '--name', $postgresServer) + $azPostgresFirewallArgs = @('--resource-group', $azureResourceGroup, '--name', $postgresServer) + if (-not [string]::IsNullOrWhiteSpace($azureSubscriptionId)) { + $azPostgresServerArgs += @('--subscription', $azureSubscriptionId) + $azPostgresFirewallArgs += @('--subscription', $azureSubscriptionId) + } + + if ($env:AZD_POSTGRES_FIREWALL_WAIT_FOR_SERVER -eq '1') { + Write-Host "Waiting for Azure PostgreSQL server '$postgresServer' to become available." + Invoke-WithRetry -Attempts 12 -DelaySeconds 10 -Description 'Azure PostgreSQL server readiness' -ScriptBlock { + az postgres flexible-server show @azPostgresServerArgs | Out-Null + } + } + else { + az postgres flexible-server show @azPostgresServerArgs | Out-Null + } + + Ensure-FirewallRule -DbType 'postgres' -ServerName $postgresServer -ClientIp $clientIp -FirewallRuleName $firewallRuleName -AzArgs $azPostgresFirewallArgs +} \ No newline at end of file diff --git a/azure/scripts/ensure-sql-firewall-rule.sh b/azure/scripts/ensure-sql-firewall-rule.sh index c0845b27..a5fb5cce 100644 --- a/azure/scripts/ensure-sql-firewall-rule.sh +++ b/azure/scripts/ensure-sql-firewall-rule.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Ensures the current runner public IP has an Azure SQL firewall rule. +# Ensures the current runner public IP has Azure SQL and PostgreSQL firewall rules. retry_command() { local attempts="${1:?attempt count is required}" @@ -25,23 +25,53 @@ retry_command() { done } +ensure_firewall_rule() { + local db_type="${1:?db_type is required (sql or postgres)}" + local server_name="${2:?server_name is required}" + local client_ip="${3:?client_ip is required}" + local firewall_rule_name="${4:?firewall_rule_name is required}" + shift 4 + local az_args=("$@") + + if [[ "${db_type}" == "sql" ]]; then + if az sql server firewall-rule show "${az_args[@]}" --name "${firewall_rule_name}" >/dev/null 2>&1; then + echo "Updating Azure SQL firewall rule '${firewall_rule_name}' for ${client_ip}." + az sql server firewall-rule update "${az_args[@]}" --name "${firewall_rule_name}" --start-ip-address "${client_ip}" --end-ip-address "${client_ip}" >/dev/null + else + echo "Creating Azure SQL firewall rule '${firewall_rule_name}' for ${client_ip}." + az sql server firewall-rule create "${az_args[@]}" --name "${firewall_rule_name}" --start-ip-address "${client_ip}" --end-ip-address "${client_ip}" >/dev/null + fi + echo "Azure SQL firewall rule '${firewall_rule_name}' is ready." + elif [[ "${db_type}" == "postgres" ]]; then + if az postgres flexible-server firewall-rule show "${az_args[@]}" --rule-name "${firewall_rule_name}" >/dev/null 2>&1; then + echo "Updating Azure PostgreSQL firewall rule '${firewall_rule_name}' for ${client_ip}." + az postgres flexible-server firewall-rule update "${az_args[@]}" --rule-name "${firewall_rule_name}" --start-ip-address "${client_ip}" --end-ip-address "${client_ip}" >/dev/null + else + echo "Creating Azure PostgreSQL firewall rule '${firewall_rule_name}' for ${client_ip}." + az postgres flexible-server firewall-rule create "${az_args[@]}" --rule-name "${firewall_rule_name}" --start-ip-address "${client_ip}" --end-ip-address "${client_ip}" >/dev/null + fi + echo "Azure PostgreSQL firewall rule '${firewall_rule_name}' is ready." + fi +} + if ! command -v azd >/dev/null 2>&1; then echo "The 'azd' command is required to resolve environment values." >&2 exit 1 fi if ! command -v az >/dev/null 2>&1; then - echo "The 'az' command is required to manage Azure SQL firewall rules." >&2 + echo "The 'az' command is required to manage Azure firewall rules." >&2 exit 1 fi sql_server="$(azd env get-value sqlServerName | tr -d '\r')" +postgres_server="$(azd env get-value postgresServerName | tr -d '\r')" azure_resource_group="$(azd env get-value AZURE_RESOURCE_GROUP | tr -d '\r')" azure_subscription_id="$(azd env get-value AZURE_SUBSCRIPTION_ID | tr -d '\r')" azure_env_name="$(azd env get-value AZURE_ENV_NAME | tr -d '\r')" -if [[ -z "${sql_server}" || -z "${azure_resource_group}" ]]; then - echo "Unable to resolve sqlServerName/AZURE_RESOURCE_GROUP from the active azd environment." >&2 +if [[ -z "${sql_server}" && -z "${postgres_server}" ]] || [[ -z "${azure_resource_group}" ]]; then + echo "Unable to resolve sqlServerName and/or postgresServerName and AZURE_RESOURCE_GROUP from the active azd environment." >&2 exit 1 fi @@ -61,26 +91,39 @@ if [[ ! "${client_ip}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then fi firewall_rule_name="azd-${azure_env_name:-env}-$(echo "${client_ip}" | tr '.' '-')" -az_server_args=(--resource-group "${azure_resource_group}" --name "${sql_server}") -az_firewall_args=(--resource-group "${azure_resource_group}" --server "${sql_server}") -if [[ -n "${azure_subscription_id}" ]]; then - az_server_args+=(--subscription "${azure_subscription_id}") - az_firewall_args+=(--subscription "${azure_subscription_id}") -fi - -if [[ "${AZD_SQL_FIREWALL_WAIT_FOR_SERVER:-0}" == "1" ]]; then - echo "Waiting for Azure SQL server '${sql_server}' to become available." - retry_command 12 10 "Azure SQL server readiness" az sql server show "${az_server_args[@]}" >/dev/null -else - az sql server show "${az_server_args[@]}" >/dev/null -fi -if az sql server firewall-rule show "${az_firewall_args[@]}" --name "${firewall_rule_name}" >/dev/null 2>&1; then - echo "Updating Azure SQL firewall rule '${firewall_rule_name}' for ${client_ip}." - az sql server firewall-rule update "${az_firewall_args[@]}" --name "${firewall_rule_name}" --start-ip-address "${client_ip}" --end-ip-address "${client_ip}" >/dev/null -else - echo "Creating Azure SQL firewall rule '${firewall_rule_name}' for ${client_ip}." - az sql server firewall-rule create "${az_firewall_args[@]}" --name "${firewall_rule_name}" --start-ip-address "${client_ip}" --end-ip-address "${client_ip}" >/dev/null +if [[ -n "${sql_server}" ]]; then + az_server_args=(--resource-group "${azure_resource_group}" --name "${sql_server}") + az_firewall_args=(--resource-group "${azure_resource_group}" --server "${sql_server}") + if [[ -n "${azure_subscription_id}" ]]; then + az_server_args+=(--subscription "${azure_subscription_id}") + az_firewall_args+=(--subscription "${azure_subscription_id}") + fi + + if [[ "${AZD_SQL_FIREWALL_WAIT_FOR_SERVER:-0}" == "1" ]]; then + echo "Waiting for Azure SQL server '${sql_server}' to become available." + retry_command 12 10 "Azure SQL server readiness" az sql server show "${az_server_args[@]}" >/dev/null + else + az sql server show "${az_server_args[@]}" >/dev/null + fi + + ensure_firewall_rule "sql" "${sql_server}" "${client_ip}" "${firewall_rule_name}" "${az_firewall_args[@]}" fi -echo "Azure SQL firewall rule '${firewall_rule_name}' is ready." \ No newline at end of file +if [[ -n "${postgres_server}" ]]; then + az_postgres_server_args=(--resource-group "${azure_resource_group}" --name "${postgres_server}") + az_postgres_firewall_args=(--resource-group "${azure_resource_group}" --name "${postgres_server}") + if [[ -n "${azure_subscription_id}" ]]; then + az_postgres_server_args+=(--subscription "${azure_subscription_id}") + az_postgres_firewall_args+=(--subscription "${azure_subscription_id}") + fi + + if [[ "${AZD_POSTGRES_FIREWALL_WAIT_FOR_SERVER:-0}" == "1" ]]; then + echo "Waiting for Azure PostgreSQL server '${postgres_server}' to become available." + retry_command 12 10 "Azure PostgreSQL server readiness" az postgres flexible-server show "${az_postgres_server_args[@]}" >/dev/null + else + az postgres flexible-server show "${az_postgres_server_args[@]}" >/dev/null + fi + + ensure_firewall_rule "postgres" "${postgres_server}" "${client_ip}" "${firewall_rule_name}" "${az_postgres_firewall_args[@]}" +fi \ No newline at end of file diff --git a/azure/scripts/setup-e2e-runner.sh b/azure/scripts/setup-e2e-runner.sh index 03c7cac7..f4bf5e9c 100755 --- a/azure/scripts/setup-e2e-runner.sh +++ b/azure/scripts/setup-e2e-runner.sh @@ -4,7 +4,7 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - ./scripts/setup-e2e-runner.sh --resource-group [--appsettings-path ] [--key-vault-name ] [--products-app-name ] [--shopping-app-name ] [--skip-validation] + ./scripts/setup-e2e-runner.sh --resource-group [--appsettings-path ] [--key-vault-name ] [--products-app-name ] [--shopping-app-name ] [--skip-validation] [--insecure] Description: Discovers the deployed Products and Shopping API endpoints, retrieves the @@ -19,6 +19,7 @@ Options: --products-app-name, -p Products API web app name. Auto-detected when omitted. --shopping-app-name, -s Shopping API web app name. Auto-detected when omitted. --skip-validation Skip the endpoint validation checks. + --insecure Disable TLS certificate verification for validation requests. --help, -h Show this help. EOF } @@ -32,6 +33,7 @@ key_vault_name="" products_app_name="" shopping_app_name="" skip_validation="false" +insecure="false" while [[ $# -gt 0 ]]; do case "$1" in @@ -59,6 +61,10 @@ while [[ $# -gt 0 ]]; do skip_validation="true" shift ;; + --insecure) + insecure="true" + shift + ;; --help|-h) usage exit 0 @@ -102,8 +108,13 @@ validate_request() { local url="$2" local method="${3:-GET}" local status_code + local curl_args=(-sS -o /dev/null -w '%{http_code}' -X "${method}") + + if [[ "${insecure}" == "true" ]]; then + curl_args=(-k "${curl_args[@]}") + fi - status_code="$(curl -k -sS -o /dev/null -w '%{http_code}' -X "${method}" "${url}" || true)" + status_code="$(curl "${curl_args[@]}" "${url}" || true)" if [[ ! "${status_code}" =~ ^[23] ]]; then echo "Validation failed for ${label}: ${method} ${url} returned HTTP ${status_code:-unknown}." >&2 exit 1 From 7158927b53ea396e1fa36cca0b373353148eb5e2 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Wed, 20 May 2026 13:08:04 -0700 Subject: [PATCH 06/14] fix: removing the psql connection string parameter to disable cert validation. Signed-off-by: Aaron Spruit --- azure/README.md | 2 +- azure/infra/modules/postgres-database.bicep | 2 +- azure/infra/scripts/store-secrets.ps1 | 2 +- azure/infra/scripts/store-secrets.sh | 2 +- azure/scripts/run-products-db-migrations.ps1 | 2 +- azure/scripts/run-products-db-migrations.sh | 2 +- azure/terraform/main.tf | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/azure/README.md b/azure/README.md index 7d3d9afd..baa0d5b5 100644 --- a/azure/README.md +++ b/azure/README.md @@ -312,7 +312,7 @@ Edit [../samples/tests/Contoso.E2E.Runner/appsettings.json](../samples/tests/Con "E2E": { "Products": { "BaseAddress": "https://app-products-api-dev-{suffix}.azurewebsites.net", - "ConnectionString": "Server=pg-dev-{suffix}.postgres.database.azure.com;Port=5432;Database=coreexdev;User Id={postgres-admin};Password={password};Ssl Mode=Require;Trust Server Certificate=true;" + "ConnectionString": "Server=pg-dev-{suffix}.postgres.database.azure.com;Port=5432;Database=coreexdev;User Id={postgres-admin};Password={password};Ssl Mode=Require" }, "Shopping": { "BaseAddress": "https://app-shopping-api-dev-{suffix}.azurewebsites.net", diff --git a/azure/infra/modules/postgres-database.bicep b/azure/infra/modules/postgres-database.bicep index 6741d9ff..a7a4e8a1 100644 --- a/azure/infra/modules/postgres-database.bicep +++ b/azure/infra/modules/postgres-database.bicep @@ -67,4 +67,4 @@ resource db 'Microsoft.DBforPostgreSQL/flexibleServers/databases@2023-12-01-prev output serverName string = server.name output databaseName string = db.name output fullyQualifiedDomainName string = '${server.name}.postgres.database.azure.com' -output connectionString string = 'Server=${server.name}.postgres.database.azure.com;Port=5432;Database=${databaseName};User Id=${adminLogin};Password=${adminPassword};Ssl Mode=Require;Trust Server Certificate=true;' +output connectionString string = 'Server=${server.name}.postgres.database.azure.com;Port=5432;Database=${databaseName};User Id=${adminLogin};Password=${adminPassword};Ssl Mode=Require;' diff --git a/azure/infra/scripts/store-secrets.ps1 b/azure/infra/scripts/store-secrets.ps1 index 80d98792..83a3534a 100644 --- a/azure/infra/scripts/store-secrets.ps1 +++ b/azure/infra/scripts/store-secrets.ps1 @@ -69,7 +69,7 @@ Write-Host 'Storing sql-connection-string...' az keyvault secret set --vault-name $kvName --name 'sql-connection-string' --value $sqlConn --output none Write-Host 'Building Postgres connection string...' -$postgresConn = "Server=${postgresServer}.postgres.database.azure.com;Port=5432;Database=${postgresDb};User Id=${postgresLogin};Password=${postgresPassword};Ssl Mode=Require;Trust Server Certificate=true;" +$postgresConn = "Server=${postgresServer}.postgres.database.azure.com;Port=5432;Database=${postgresDb};User Id=${postgresLogin};Password=${postgresPassword};Ssl Mode=Require;" Write-Host 'Storing postgres-connection-string...' az keyvault secret set --vault-name $kvName --name 'postgres-connection-string' --value $postgresConn --output none diff --git a/azure/infra/scripts/store-secrets.sh b/azure/infra/scripts/store-secrets.sh index ce59d744..4dd8a870 100755 --- a/azure/infra/scripts/store-secrets.sh +++ b/azure/infra/scripts/store-secrets.sh @@ -76,7 +76,7 @@ az keyvault secret set \ --output none echo "Building Postgres connection string..." -postgres_conn="Server=${postgres_server}.postgres.database.azure.com;Port=5432;Database=${postgres_db};User Id=${postgres_login};Password=${postgres_password};Ssl Mode=Require;Trust Server Certificate=true;" +postgres_conn="Server=${postgres_server}.postgres.database.azure.com;Port=5432;Database=${postgres_db};User Id=${postgres_login};Password=${postgres_password};Ssl Mode=Require;" echo "Storing postgres-connection-string..." az keyvault secret set \ diff --git a/azure/scripts/run-products-db-migrations.ps1 b/azure/scripts/run-products-db-migrations.ps1 index 808632d1..57dbe0f1 100644 --- a/azure/scripts/run-products-db-migrations.ps1 +++ b/azure/scripts/run-products-db-migrations.ps1 @@ -75,7 +75,7 @@ if ([string]::IsNullOrWhiteSpace($sqlPassword) -or [string]::IsNullOrWhiteSpace( throw 'Database admin passwords are required to run DbEx migrations.' } $sqlConnectionString = "Server=tcp:$sqlServer.database.windows.net,1433;Initial Catalog=$sqlDatabase;User ID=$sqlAdminLogin;Password=$sqlPassword;Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" -$postgresConnectionString = "Server=$postgresServer.postgres.database.azure.com;Port=5432;Database=$postgresDatabase;User Id=$postgresAdminLogin;Password=$postgresPassword;Ssl Mode=Require;Trust Server Certificate=true;" +$postgresConnectionString = "Server=$postgresServer.postgres.database.azure.com;Port=5432;Database=$postgresDatabase;User Id=$postgresAdminLogin;Password=$postgresPassword;Ssl Mode=Require;" $projects = Get-ChildItem -LiteralPath (Join-Path $repoRoot 'samples/src') -Recurse -File -Filter 'Contoso.*.Database.csproj' | Sort-Object FullName diff --git a/azure/scripts/run-products-db-migrations.sh b/azure/scripts/run-products-db-migrations.sh index a32e1a82..a792c4ad 100755 --- a/azure/scripts/run-products-db-migrations.sh +++ b/azure/scripts/run-products-db-migrations.sh @@ -70,7 +70,7 @@ if [[ -z "${sql_password}" || -z "${postgres_password}" ]]; then fi sql_connection_string="Server=tcp:${sql_server}.database.windows.net,1433;Initial Catalog=${sql_database};User ID=${sql_admin_login};Password=${sql_password};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" -postgres_connection_string="Server=${postgres_server}.postgres.database.azure.com;Port=5432;Database=${postgres_database};User Id=${postgres_admin_login};Password=${postgres_password};Ssl Mode=Require;Trust Server Certificate=true;" +postgres_connection_string="Server=${postgres_server}.postgres.database.azure.com;Port=5432;Database=${postgres_database};User Id=${postgres_admin_login};Password=${postgres_password};Ssl Mode=Require;" readarray -t projects < <(find "${repo_root}/samples/src" -maxdepth 2 -type f -name 'Contoso.*.Database.csproj' | sort) if [[ ${#projects[@]} -eq 0 ]]; then diff --git a/azure/terraform/main.tf b/azure/terraform/main.tf index 95a83b86..d46ae26a 100644 --- a/azure/terraform/main.tf +++ b/azure/terraform/main.tf @@ -342,7 +342,7 @@ locals { sql_fqdn = "${local.sql_server_name}.database.windows.net" sql_connection_string = "Data Source=tcp:${local.sql_fqdn},1433;Initial Catalog=${var.sql_database_name};User id=${var.sql_admin_login};Password=${var.sql_admin_password};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" postgres_fqdn = "${local.postgres_server_name}.postgres.database.azure.com" - postgres_connection_string = "Host=${local.postgres_fqdn};Port=5432;Database=${var.postgres_database_name};Username=${var.postgres_admin_login};Password=${local.postgres_admin_password_effective};Ssl Mode=Require;Trust Server Certificate=true;" + postgres_connection_string = "Host=${local.postgres_fqdn};Port=5432;Database=${var.postgres_database_name};Username=${var.postgres_admin_login};Password=${local.postgres_admin_password_effective};Ssl Mode=Require;" } resource "azapi_resource" "postgres_server" { From 915b653e474aa5eb5de9fdaf1b80d972723ce332 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Wed, 20 May 2026 13:12:06 -0700 Subject: [PATCH 07/14] removing terraform and it's references it was infra only anyways without any code deploy Signed-off-by: Aaron Spruit --- azure/AGENTS.md | 38 +- azure/terraform/README.md | 212 ------- azure/terraform/apply.sh | 123 ---- azure/terraform/dev.tfvars | 40 -- azure/terraform/main.tf | 775 ----------------------- azure/terraform/outputs.tf | 72 --- azure/terraform/prod.tfvars | 39 -- azure/terraform/terraform.tfvars.example | 29 - azure/terraform/test.tfvars | 39 -- azure/terraform/variables.tf | 159 ----- azure/terraform/versions.tf | 28 - 11 files changed, 11 insertions(+), 1543 deletions(-) delete mode 100644 azure/terraform/README.md delete mode 100644 azure/terraform/apply.sh delete mode 100644 azure/terraform/dev.tfvars delete mode 100644 azure/terraform/main.tf delete mode 100644 azure/terraform/outputs.tf delete mode 100644 azure/terraform/prod.tfvars delete mode 100644 azure/terraform/terraform.tfvars.example delete mode 100644 azure/terraform/test.tfvars delete mode 100644 azure/terraform/variables.tf delete mode 100644 azure/terraform/versions.tf diff --git a/azure/AGENTS.md b/azure/AGENTS.md index bb53f2e9..b46c0f59 100644 --- a/azure/AGENTS.md +++ b/azure/AGENTS.md @@ -1,19 +1,18 @@ --- -description: "Operational guidance for AI agents deploying Contoso sample services to Azure via azd/Bicep or Terraform" +description: "Operational guidance for AI agents deploying Contoso sample services to Azure via azd/Bicep" scope: "azure/" -tags: ["azure", "deployment", "iac", "bicep", "terraform"] +tags: ["azure", "deployment", "iac", "bicep"] --- # AGENTS.md — Azure Deployment -Operational guide for AI agents working in the `azure/` folder of this repository. This deploys the Contoso sample services (under `samples/src/`) to Azure using either **Azure Developer CLI (azd) + Bicep** or **Terraform**. +Operational guide for AI agents working in the `azure/` folder of this repository. This deploys the Contoso sample services (under `samples/src/`) to Azure using **Azure Developer CLI (azd) + Bicep**. ## Scope This file applies to anything under `azure/`. For application code, see the relevant `samples/` projects. Companion human-facing docs: - [README.md](README.md) — azd + Bicep workflow. -- [terraform/README.md](terraform/README.md) — Terraform workflow. ## Folder layout @@ -23,12 +22,11 @@ This file applies to anything under `azure/`. For application code, see the rele - [infra/modules/](infra/modules/) — Per-resource modules (`app-service-plan`, `app-services`, `aspire-dashboard`, `database`, `postgres-database`, `service-bus`, `redis`, `key-vault`, `application-insights`). - [infra/scripts/](infra/scripts/) — Hook scripts (`use-dev-params.*`, `store-secrets.*`). - `main.{dev,test,prod}.bicepparam` — Environment parameter files. -- [terraform/](terraform/) — Terraform implementation that mirrors the Bicep deployment (parity must be maintained when changing one or the other). - [scripts/](scripts/) — Higher-level deployment helper scripts (DB migrations, SQL firewall, packaging). ## What gets deployed -Both Bicep and Terraform provision the same resource set: +The Bicep deployment provisions the following resource set: - Linux App Service Plan. - 7 Web Apps: `aspire-dashboard`, `products-api`, `shopping-api`, `products-outbox-relay`, `shopping-outbox-relay`, `products-subscribe`, `shopping-subscribe`. @@ -42,8 +40,7 @@ Both Bicep and Terraform provision the same resource set: ## Conventions - Comments end with a period/fullstop (repo-wide rule from [.github/copilot-instructions.md](../.github/copilot-instructions.md)). -- Keep Bicep and Terraform in sync. Any new resource, parameter, or wiring change in `infra/` must have an equivalent change in `terraform/` (and vice versa). Cross-reference by env: `main.dev.bicepparam` ↔ `dev.tfvars`, etc. -- Resource naming, SKUs, and per-environment values are driven by the parameter/tfvars files — do not hardcode environment-specific values in templates. +- Resource naming, SKUs, and per-environment values are driven by the parameter files — do not hardcode environment-specific values in templates. - Key Vault name is generated uniquely per deployment in [infra/main.bicep](infra/main.bicep). Do not assume a fixed name. - Multi-targeted .NET projects must be published with a single TFM. The TFM is sourced from `AZD_DOTNET_TARGET_FRAMEWORK` (preferred) or `DOTNET_TARGET_FRAMEWORK` and mapped to App Service Linux `DOTNETCORE|` by the preprovision hook. @@ -65,7 +62,7 @@ Set in the azd environment (`azd env set `): - `AZURE_POSTGRES_ADMIN_PASSWORD` — optional; if omitted, hooks default to `AZURE_SQL_ADMIN_PASSWORD`. - `AZD_DOTNET_TARGET_FRAMEWORK` — one of `net8.0`, `net9.0`, `net10.0`. -Load into the current shell before running ad-hoc `az` / `terraform` commands: +Load into the current shell before running ad-hoc `az` commands: ```bash set -a && eval "$(azd env get-values)" && set +a @@ -84,38 +81,25 @@ azd deploy --all --no-prompt # code-only redeploy. azd down --force --purge --no-prompt # tear down. ``` -### Terraform - -```bash -cd azure/terraform -./apply.sh dev plan -./apply.sh dev apply -``` - -`apply.sh` loads `azd env` values, resolves the runner public IP for SQL firewall, and maps `AZD_DOTNET_TARGET_FRAMEWORK` to `app_service_linux_fx_version`. - ## Validating changes Before declaring an infra change complete: 1. **Bicep**: `azd provision --preview --no-prompt` from `azure/`, or run `az deployment group what-if` against `infra/main.bicep` with `infra/main.parameters.json` after the preprovision hook has populated it. -2. **Terraform**: `./apply.sh plan` and confirm no unintended destroy/replace operations. -3. **Parity**: diff the resource set between Bicep `what-if` and Terraform `plan` when changing either side. -4. **Hooks**: if you touched `infra/scripts/` or `scripts/`, run the bash and PowerShell variants (or read them carefully) to confirm parity. +2. **Hooks**: if you touched `infra/scripts/` or `scripts/`, run the bash and PowerShell variants (or read them carefully) to confirm parity. -Do not run `azd up`, `azd down`, `terraform apply`, or `terraform destroy` without explicit user approval — these touch live Azure resources. +Do not run `azd up` or `azd down` without explicit user approval — these touch live Azure resources. ## Secrets and safety -- Never commit `AZURE_SQL_ADMIN_PASSWORD`, generated parameter files containing secrets, `terraform.tfstate*`, or any Key Vault content. +- Never commit `AZURE_SQL_ADMIN_PASSWORD`, generated parameter files containing secrets, or any Key Vault content. - The post-provision hook is the canonical source for runtime secrets in Key Vault. Do not duplicate secret-storage logic elsewhere. -- Treat `terraform.tfstate` as sensitive; do not print or echo it. -- Avoid destructive Azure operations (`az group delete`, `azd down`, `terraform destroy`, dropping SQL DBs) unless the user has confirmed in this turn. +- Avoid destructive Azure operations (`az group delete`, `azd down`, dropping SQL DBs) unless the user has confirmed in this turn. ## Troubleshooting cheatsheet - **Multi-target publish error (NETSDK1129)** — set `AZD_DOTNET_TARGET_FRAMEWORK` and reload env. -- **SQL password missing** — set `AZURE_SQL_ADMIN_PASSWORD` before `azd provision` / `terraform apply`. +- **SQL password missing** — set `AZURE_SQL_ADMIN_PASSWORD` before `azd provision`. - **PostgreSQL password missing** — set `AZURE_POSTGRES_ADMIN_PASSWORD` when different from SQL admin password. - **Predeploy missing output keys** — run `azd provision --no-prompt` before `azd deploy --all --no-prompt` to refresh `sql*` and `postgres*` output values in azd env. - **API returns 404 at `/`** — expected; probe `/api/...`, `/health/ready/detailed`, or `/swagger`. diff --git a/azure/terraform/README.md b/azure/terraform/README.md deleted file mode 100644 index 44c6998e..00000000 --- a/azure/terraform/README.md +++ /dev/null @@ -1,212 +0,0 @@ -# Terraform Equivalent of Azure Bicep Deployment - -This folder provides a Terraform implementation that mirrors the current resources provisioned by `azure/infra` Bicep templates. - -> [!NOTE] -> This does NOT deploy any application code or run DB migrations. It only deploys the base infrastructure. -> The AZD command in azure/infra deploys both the infrastructure and the code. - -## Prerequisites - -Before running `terraform plan` (or `./apply.sh plan`), ensure all of the following are complete. - -### Tooling - -- Terraform CLI installed (compatible with [versions.tf](versions.tf)). -- Azure CLI (`az`) installed. -- `curl` available (required by `apply.sh` to resolve public IP). -- Azure Developer CLI (`azd`) installed (required by `apply.sh` to load environment values). - -### Azure Authentication and Subscription - -- Logged in to Azure CLI: - -```bash -az login -``` - -- Correct subscription selected: - -```bash -az account set --subscription -``` - -### Required Identity Permissions - -The deploying identity must be able to create/update all resources in scope and perform RBAC/secret operations, including: - -- Create/update role assignments (for Key Vault Administrator assignment on the deployed vault). -- Set Key Vault secrets. -- Create/update App Service, SQL, Service Bus, Redis, Application Insights, and Key Vault resources. - -### Required Input Values - -- `AZURE_SQL_ADMIN_PASSWORD` must be set before running `./apply.sh`. -- Environment tfvars file must exist for your target environment (`dev.tfvars`, `test.tfvars`, or `prod.tfvars`). - -Example: - -```bash -export AZURE_SQL_ADMIN_PASSWORD='' -``` - -### Framework Selection (for `apply.sh`) - -`apply.sh` derives `app_service_linux_fx_version` from one of: - -- `AZD_DOTNET_TARGET_FRAMEWORK` (preferred), or -- `DOTNET_TARGET_FRAMEWORK`. - -Supported values: `net8.0`, `net9.0`, `net10.0`. - -Example: - -```bash -export AZD_DOTNET_TARGET_FRAMEWORK='net10.0' -``` - -### Terraform Initialization - -Initialize providers in the Terraform folder before planning: - -```bash -cd azure/terraform -terraform init -``` - -Optional validation: - -```bash -terraform fmt -recursive -terraform validate -``` - -## What It Deploys - -- Linux App Service Plan. -- 6 Linux Web Apps: - - `products-api` - - `shopping-api` - - `products-outbox-relay` - - `shopping-outbox-relay` - - `products-subscribe` - - `shopping-subscribe` -- Aspire Dashboard Linux container Web App. -- Application Insights. -- Key Vault. -- Service Bus namespace + topic + subscriptions. -- Azure SQL server + database + firewall rules. -- Azure Managed Redis (redisEnterprise) + default database. - -## Environment Files - -- `dev.tfvars` matches `azure/infra/main.dev.bicepparam` values. -- `test.tfvars` matches `azure/infra/main.test.bicepparam` values. -- `prod.tfvars` matches `azure/infra/main.prod.bicepparam` values. - -## Usage - -```bash -cd azure/terraform -./apply.sh dev plan -./apply.sh dev apply -``` - -Or run Terraform manually with one of the environment files: - -```bash -cd azure/terraform -export TF_VAR_sql_admin_password="$AZURE_SQL_ADMIN_PASSWORD" -terraform init -terraform plan -var-file=dev.tfvars -terraform apply -var-file=dev.tfvars -``` - -## Notes - -- This deployment creates/manages the resource group named by `resource_group_name` if it does not already exist. -- The `apply.sh` script loads `azd env` values (if available), resolves current public runner IP, and maps `AZD_DOTNET_TARGET_FRAMEWORK` to `app_service_linux_fx_version`. -- Sensitive values such as `sql_admin_password` should come from secure sources and are injected via `TF_VAR_sql_admin_password`. -- Naming and wiring are aligned with the Bicep setup in `azure/infra`. - -## Running E2E Tests - -After deploying with `azd up`, you can run the E2E test runner against the deployed services. - -### Get deployed endpoint URLs - -Retrieve the deployed App Service endpoints: - -```bash -az webapp list --resource-group --query "[].hostNames[0]" -o tsv -``` - -This will show URLs like: -- `app-products-api-dev-{suffix}.azurewebsites.net` -- `app-shopping-api-dev-{suffix}.azurewebsites.net` - -Validate the deployed APIs using API/health/swagger paths (not root `/`): - -```bash -# Products API -curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/api/products" - -# Shopping API -curl -i "https://app-shopping-api-dev-{suffix}.azurewebsites.net/api/baskets" - -# Common liveness and docs paths -curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/health/ready/detailed" -curl -i "https://app-products-api-dev-{suffix}.azurewebsites.net/swagger" -``` - -### Retrieve connection strings - -After `azd provision` or `azd up`, the postprovision hook automatically stores the following secrets in Key Vault: -- `sql-admin-password` -- `sql-connection-string` -- `service-bus-connection-string` - -Retrieve them: - -```bash -# Get Key Vault name -KV=$(az keyvault list --resource-group --query '[0].name' -o tsv) - -# SQL connection string -az keyvault secret show --vault-name $KV --name sql-connection-string -o tsv --query value - -# Service Bus connection string -az keyvault secret show --vault-name $KV --name service-bus-connection-string -o tsv --query value -``` - -### Update E2E configuration - -Edit [../samples/tests/Contoso.E2E.Runner/appsettings.json](../samples/tests/Contoso.E2E.Runner/appsettings.json) with the deployed endpoints and connection strings: - -```json -{ - "E2E": { - "Products": { - "BaseAddress": "https://app-products-api-dev-{suffix}.azurewebsites.net", - "ConnectionString": "Server=sql-dev-{suffix}.database.windows.net;Database=Contoso;User Id=sqladmin;Password={password};Encrypt=true;TrustServerCertificate=false;", - "ServiceBus": "Endpoint=sb://sb-dev-{suffix}.servicebus.windows.net/;SharedAccessKeyName=rootManageSharedAccessKey;SharedAccessKey={key};" - }, - "Shopping": { - "BaseAddress": "https://app-shopping-api-dev-{suffix}.azurewebsites.net", - "ConnectionString": "Server=sql-dev-{suffix}.database.windows.net;Database=Contoso;User Id=sqladmin;Password={password};Encrypt=true;TrustServerCertificate=false;", - "ServiceBus": "Endpoint=sb://sb-dev-{suffix}.servicebus.windows.net/;SharedAccessKeyName=rootManageSharedAccessKey;SharedAccessKey={key};" - } - } -} -``` - -### Run E2E scenarios - -From the repository root: - -```bash -cd samples/tests/Contoso.E2E.Runner -dotnet run -``` - -This launches an interactive CLI menu to select and execute test scenarios or run load simulations against the deployed APIs. diff --git a/azure/terraform/apply.sh b/azure/terraform/apply.sh deleted file mode 100644 index f70c9062..00000000 --- a/azure/terraform/apply.sh +++ /dev/null @@ -1,123 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "${script_dir}" - -usage() { - cat < [plan|apply|destroy] - -Examples: - ./apply.sh dev plan - ./apply.sh dev apply - ./apply.sh test apply - ./apply.sh prod destroy -EOF -} - -if [[ $# -lt 1 || $# -gt 2 ]]; then - usage - exit 1 -fi - -env_name="$1" -action="${2:-apply}" - -case "${env_name}" in - dev|test|prod) ;; - *) - echo "Invalid environment '${env_name}'. Use dev, test, or prod." >&2 - exit 1 - ;; -esac - -case "${action}" in - plan|apply|destroy) ;; - *) - echo "Invalid action '${action}'. Use plan, apply, or destroy." >&2 - exit 1 - ;; -esac - -# Load azd environment values when available. -if command -v azd >/dev/null 2>&1; then - if azd env get-values >/dev/null 2>&1; then - set -a - eval "$(azd env get-values)" - set +a - fi -fi - -if [[ -z "${AZURE_SQL_ADMIN_PASSWORD:-}" ]]; then - echo "AZURE_SQL_ADMIN_PASSWORD is required." >&2 - exit 1 -fi - -target_framework="${AZD_DOTNET_TARGET_FRAMEWORK:-${DOTNET_TARGET_FRAMEWORK:-net10.0}}" -case "${target_framework}" in - net8.0) - app_service_linux_fx_version='DOTNETCORE|8.0' - ;; - net9.0) - app_service_linux_fx_version='DOTNETCORE|9.0' - ;; - net10.0) - app_service_linux_fx_version='DOTNETCORE|10.0' - ;; - *) - echo "Unsupported target framework '${target_framework}'." >&2 - exit 1 - ;; -esac - -client_ip="$(curl -fsS https://api.ipify.org 2>/dev/null || true)" -if [[ -z "${client_ip}" ]]; then - client_ip="$(curl -fsS https://ifconfig.me/ip 2>/dev/null || true)" -fi - -if [[ -z "${client_ip}" ]]; then - echo "Unable to determine public IPv4 address." >&2 - exit 1 -fi - -if [[ ! "${client_ip}" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then - echo "Resolved public IP '${client_ip}' is not a valid IPv4 address." >&2 - exit 1 -fi - -tfvars_file="${env_name}.tfvars" -if [[ ! -f "${tfvars_file}" ]]; then - echo "Missing tfvars file '${tfvars_file}'." >&2 - exit 1 -fi - -export TF_VAR_sql_admin_password="${AZURE_SQL_ADMIN_PASSWORD}" - -terraform fmt -recursive -terraform init -terraform validate - -case "${action}" in - plan) - terraform plan \ - -var-file="${tfvars_file}" \ - -var "app_service_linux_fx_version=${app_service_linux_fx_version}" \ - -var "sql_firewall_client_ip=${client_ip}" \ - -var "key_vault_firewall_client_ip=${client_ip}" - ;; - apply) - terraform apply -auto-approve \ - -var-file="${tfvars_file}" \ - -var "app_service_linux_fx_version=${app_service_linux_fx_version}" \ - -var "sql_firewall_client_ip=${client_ip}" \ - -var "key_vault_firewall_client_ip=${client_ip}" - ;; - destroy) - terraform destroy -auto-approve \ - -var-file="${tfvars_file}" \ - -var "app_service_linux_fx_version=${app_service_linux_fx_version}" \ - -var "sql_firewall_client_ip=${client_ip}" \ - -var "key_vault_firewall_client_ip=${client_ip}" - ;; -esac diff --git a/azure/terraform/dev.tfvars b/azure/terraform/dev.tfvars deleted file mode 100644 index cc672f65..00000000 --- a/azure/terraform/dev.tfvars +++ /dev/null @@ -1,40 +0,0 @@ -resource_group_name = "rg-dev" -environment_type = "dev" -location = "westus3" -name_suffix = "dev01" - -tags = { - workload = "coreex" - environment = "dev" - costProfile = "minimum-practical" -} - -app_service_plan_sku_name = "B2" -app_service_plan_sku_tier = "Basic" -app_service_plan_capacity = 1 - -# Derived from AZD_DOTNET_TARGET_FRAMEWORK by apply script. -app_service_linux_fx_version = "DOTNETCORE|10.0" - -service_bus_sku_name = "Standard" - -sql_admin_login = "coreexadmin" -sql_database_name = "coreexdev" -sql_sku_name = "GP_S_Gen5_1" -sql_sku_tier = "GeneralPurpose" -sql_min_capacity = 0.5 -sql_auto_pause_delay = 60 - -postgres_admin_login = "coreexpgadmin" -postgres_database_name = "coreexdev" -postgres_sku_name = "Standard_B1ms" -postgres_sku_tier = "Burstable" -postgres_version = "16" -postgres_storage_mb = 32768 - -# Derived from current public IP by apply script. -sql_firewall_client_ip = "" -postgres_firewall_client_ip = "" - -redis_sku_name = "Balanced_B0" -redis_high_availability = "Disabled" diff --git a/azure/terraform/main.tf b/azure/terraform/main.tf deleted file mode 100644 index d46ae26a..00000000 --- a/azure/terraform/main.tf +++ /dev/null @@ -1,775 +0,0 @@ -resource "azurerm_resource_group" "rg" { - name = var.resource_group_name - location = var.location - tags = merge(var.tags, { - environment = var.environment_type - managedBy = "azd" - "azd-env-name" = var.environment_type - }) -} - -resource "random_id" "kv" { - byte_length = 6 - keepers = { - environment = var.environment_type - suffix = var.name_suffix - } -} - -locals { - suffix = lower(var.name_suffix) - - merged_tags = merge(var.tags, { - environment = var.environment_type - managedBy = "azd" - "azd-env-name" = var.environment_type - }) - - app_service_plan_name = "asp-${var.environment_type}-${local.suffix}" - app_insights_name = "appi-${var.environment_type}-${local.suffix}" - service_bus_name = "sb-${var.environment_type}-${local.suffix}" - redis_name = "redis-${var.environment_type}-${local.suffix}" - sql_server_name = "sql-${var.environment_type}-${local.suffix}" - postgres_server_name = "pg-${var.environment_type}-${local.suffix}" - dashboard_name = "app-aspire-dashboard-${var.environment_type}-${local.suffix}" - postgres_admin_password_effective = coalesce(var.postgres_admin_password, var.sql_admin_password) - - key_vault_name = substr("kv${var.environment_type}${local.suffix}${random_id.kv.hex}", 0, 24) -} - -resource "azapi_resource" "app_insights" { - type = "Microsoft.Insights/components@2020-02-02" - parent_id = azurerm_resource_group.rg.id - name = local.app_insights_name - location = var.location - tags = local.merged_tags - - body = { - kind = "web" - properties = { - Application_Type = "web" - Flow_Type = "Bluefield" - Request_Source = "rest" - } - } - - response_export_values = [ - "id", - "name", - "properties.ConnectionString", - "properties.InstrumentationKey" - ] -} - -locals { - app_insights_output = azapi_resource.app_insights.output -} - -resource "azapi_resource" "key_vault" { - type = "Microsoft.KeyVault/vaults@2023-07-01" - parent_id = azurerm_resource_group.rg.id - name = local.key_vault_name - location = var.location - tags = local.merged_tags - - body = { - properties = { - sku = { - family = "A" - name = "standard" - } - tenantId = data.azurerm_client_config.current.tenant_id - enableRbacAuthorization = true - enabledForDeployment = true - enabledForTemplateDeployment = true - enabledForDiskEncryption = false - publicNetworkAccess = "Enabled" - networkAcls = { - bypass = "AzureServices" - defaultAction = var.key_vault_firewall_client_ip == "" ? "Allow" : "Deny" - ipRules = var.key_vault_firewall_client_ip == "" ? [] : [ - { - value = var.key_vault_firewall_client_ip - } - ] - virtualNetworkRules = [] - } - } - } - - response_export_values = ["id", "name", "properties.vaultUri"] -} - -resource "azurerm_role_assignment" "key_vault_admin" { - scope = azapi_resource.key_vault.id - role_definition_name = "Key Vault Administrator" - principal_id = data.azurerm_client_config.current.object_id -} - -resource "time_sleep" "wait_for_key_vault_rbac" { - depends_on = [azurerm_role_assignment.key_vault_admin] - create_duration = "20s" -} - -resource "azapi_resource" "app_service_plan" { - type = "Microsoft.Web/serverfarms@2023-12-01" - parent_id = azurerm_resource_group.rg.id - name = local.app_service_plan_name - location = var.location - tags = local.merged_tags - - body = { - kind = "linux" - sku = { - name = var.app_service_plan_sku_name - tier = var.app_service_plan_sku_tier - capacity = var.app_service_plan_capacity - } - properties = { - reserved = true - } - } -} - -resource "azapi_resource" "service_bus" { - type = "Microsoft.ServiceBus/namespaces@2023-01-01-preview" - parent_id = azurerm_resource_group.rg.id - name = local.service_bus_name - location = var.location - tags = local.merged_tags - - body = { - sku = { - name = var.service_bus_sku_name - tier = var.service_bus_sku_name - } - properties = { - publicNetworkAccess = "Enabled" - minimumTlsVersion = "1.2" - } - } -} - -resource "azapi_resource" "service_bus_topic" { - type = "Microsoft.ServiceBus/namespaces/topics@2023-01-01-preview" - parent_id = azapi_resource.service_bus.id - name = "contoso" - - body = { - properties = { - defaultMessageTimeToLive = "P14D" - requiresDuplicateDetection = true - duplicateDetectionHistoryTimeWindow = "PT10M" - } - } -} - -resource "azapi_resource" "service_bus_subscription_products" { - type = "Microsoft.ServiceBus/namespaces/topics/subscriptions@2023-01-01-preview" - parent_id = azapi_resource.service_bus_topic.id - name = "products" - - body = { - properties = { - requiresSession = true - maxDeliveryCount = 10 - lockDuration = "PT5M" - deadLetteringOnMessageExpiration = true - } - } -} - -resource "azapi_resource" "service_bus_subscription_shopping" { - type = "Microsoft.ServiceBus/namespaces/topics/subscriptions@2023-01-01-preview" - parent_id = azapi_resource.service_bus_topic.id - name = "shopping" - - body = { - properties = { - requiresSession = true - maxDeliveryCount = 10 - lockDuration = "PT5M" - deadLetteringOnMessageExpiration = true - } - } -} - -resource "azapi_resource" "service_bus_auth_rule" { - type = "Microsoft.ServiceBus/namespaces/AuthorizationRules@2023-01-01-preview" - parent_id = azapi_resource.service_bus.id - name = "app" - - body = { - properties = { - rights = ["Listen", "Send", "Manage"] - } - } -} - -resource "azapi_resource_action" "service_bus_keys" { - type = "Microsoft.ServiceBus/namespaces/AuthorizationRules@2023-01-01-preview" - resource_id = azapi_resource.service_bus_auth_rule.id - action = "listKeys" - method = "POST" - - response_export_values = ["primaryConnectionString"] -} - -locals { - service_bus_keys_output = azapi_resource_action.service_bus_keys.output -} - -resource "azapi_resource" "redis" { - type = "Microsoft.Cache/redisEnterprise@2025-07-01" - parent_id = azurerm_resource_group.rg.id - name = local.redis_name - location = var.location - tags = local.merged_tags - - body = { - sku = { - name = var.redis_sku_name - } - properties = { - highAvailability = var.redis_high_availability - minimumTlsVersion = "1.2" - publicNetworkAccess = "Enabled" - encryption = {} - } - } - - response_export_values = ["properties.hostName"] -} - -resource "azapi_resource" "redis_default_db" { - type = "Microsoft.Cache/redisEnterprise/databases@2025-04-01" - parent_id = azapi_resource.redis.id - name = "default" - - body = { - properties = { - clientProtocol = "Encrypted" - clusteringPolicy = "OSSCluster" - evictionPolicy = "VolatileLRU" - modules = [] - port = 10000 - } - } -} - -resource "azapi_resource_action" "redis_keys" { - type = "Microsoft.Cache/redisEnterprise/databases@2025-04-01" - resource_id = azapi_resource.redis_default_db.id - action = "listKeys" - method = "POST" - - response_export_values = ["primaryKey"] -} - -locals { - redis_output = azapi_resource.redis.output - redis_keys_output = azapi_resource_action.redis_keys.output - - redis_connection_string = "${local.redis_output.properties.hostName}:10000,password=${local.redis_keys_output.primaryKey},ssl=True,abortConnect=False" -} - -resource "azapi_resource" "sql_server" { - type = "Microsoft.Sql/servers@2023-08-01-preview" - parent_id = azurerm_resource_group.rg.id - name = local.sql_server_name - location = var.location - tags = local.merged_tags - - body = { - properties = { - administratorLogin = var.sql_admin_login - administratorLoginPassword = var.sql_admin_password - version = "12.0" - publicNetworkAccess = "Enabled" - minimalTlsVersion = "1.2" - } - } -} - -resource "azapi_resource" "sql_firewall_azure" { - type = "Microsoft.Sql/servers/firewallRules@2023-08-01-preview" - parent_id = azapi_resource.sql_server.id - name = "AllowAzureServices" - - body = { - properties = { - startIpAddress = "0.0.0.0" - endIpAddress = "0.0.0.0" - } - } -} - -resource "azapi_resource" "sql_firewall_client" { - count = var.sql_firewall_client_ip == "" ? 0 : 1 - type = "Microsoft.Sql/servers/firewallRules@2023-08-01-preview" - parent_id = azapi_resource.sql_server.id - name = "AllowCurrentRunner-${replace(var.sql_firewall_client_ip, ".", "-")}" - - body = { - properties = { - startIpAddress = var.sql_firewall_client_ip - endIpAddress = var.sql_firewall_client_ip - } - } -} - -resource "azapi_resource" "sql_database" { - type = "Microsoft.Sql/servers/databases@2023-08-01-preview" - parent_id = azapi_resource.sql_server.id - name = var.sql_database_name - location = var.location - tags = local.merged_tags - - body = { - sku = { - name = var.sql_sku_name - tier = var.sql_sku_tier - } - properties = { - collation = "SQL_Latin1_General_CP1_CI_AS" - minCapacity = var.sql_min_capacity - autoPauseDelay = var.sql_auto_pause_delay - } - } -} - -locals { - sql_fqdn = "${local.sql_server_name}.database.windows.net" - sql_connection_string = "Data Source=tcp:${local.sql_fqdn},1433;Initial Catalog=${var.sql_database_name};User id=${var.sql_admin_login};Password=${var.sql_admin_password};Encrypt=True;TrustServerCertificate=False;Connection Timeout=30;" - postgres_fqdn = "${local.postgres_server_name}.postgres.database.azure.com" - postgres_connection_string = "Host=${local.postgres_fqdn};Port=5432;Database=${var.postgres_database_name};Username=${var.postgres_admin_login};Password=${local.postgres_admin_password_effective};Ssl Mode=Require;" -} - -resource "azapi_resource" "postgres_server" { - type = "Microsoft.DBforPostgreSQL/flexibleServers@2023-12-01-preview" - parent_id = azurerm_resource_group.rg.id - name = local.postgres_server_name - location = var.location - tags = local.merged_tags - - body = { - sku = { - name = var.postgres_sku_name - tier = var.postgres_sku_tier - } - properties = { - administratorLogin = var.postgres_admin_login - administratorLoginPassword = local.postgres_admin_password_effective - version = var.postgres_version - publicNetworkAccess = "Enabled" - storage = { - storageSizeGB = var.postgres_storage_mb / 1024 - } - highAvailability = { - mode = "Disabled" - } - backup = { - backupRetentionDays = 7 - geoRedundantBackup = "Disabled" - } - } - } -} - -resource "azapi_resource" "postgres_firewall_azure" { - type = "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-12-01-preview" - parent_id = azapi_resource.postgres_server.id - name = "AllowAzureServices" - - body = { - properties = { - startIpAddress = "0.0.0.0" - endIpAddress = "0.0.0.0" - } - } -} - -resource "azapi_resource" "postgres_firewall_client" { - count = var.postgres_firewall_client_ip == "" ? 0 : 1 - type = "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules@2023-12-01-preview" - parent_id = azapi_resource.postgres_server.id - name = "AllowCurrentRunner-${replace(var.postgres_firewall_client_ip, ".", "-")}" - - body = { - properties = { - startIpAddress = var.postgres_firewall_client_ip - endIpAddress = var.postgres_firewall_client_ip - } - } -} - -resource "azapi_resource" "postgres_database" { - type = "Microsoft.DBforPostgreSQL/flexibleServers/databases@2023-12-01-preview" - parent_id = azapi_resource.postgres_server.id - name = var.postgres_database_name - - body = { - properties = { - charset = "UTF8" - collation = "en_US.utf8" - } - } -} - -resource "azurerm_key_vault_secret" "sql_admin_password" { - name = "sql-admin-password" - value = var.sql_admin_password - key_vault_id = azapi_resource.key_vault.id - - depends_on = [time_sleep.wait_for_key_vault_rbac] -} - -resource "azurerm_key_vault_secret" "sql_connection_string" { - name = "sql-connection-string" - value = local.sql_connection_string - key_vault_id = azapi_resource.key_vault.id - - depends_on = [time_sleep.wait_for_key_vault_rbac] -} - -resource "azurerm_key_vault_secret" "postgres_admin_password" { - name = "postgres-admin-password" - value = local.postgres_admin_password_effective - key_vault_id = azapi_resource.key_vault.id - - depends_on = [time_sleep.wait_for_key_vault_rbac] -} - -resource "azurerm_key_vault_secret" "postgres_connection_string" { - name = "postgres-connection-string" - value = local.postgres_connection_string - key_vault_id = azapi_resource.key_vault.id - - depends_on = [time_sleep.wait_for_key_vault_rbac] -} - -resource "azurerm_key_vault_secret" "service_bus_connection_string" { - name = "service-bus-connection-string" - value = local.service_bus_keys_output.primaryConnectionString - key_vault_id = azapi_resource.key_vault.id - - depends_on = [time_sleep.wait_for_key_vault_rbac] -} - -resource "azapi_resource" "aspire_dashboard" { - type = "Microsoft.Web/sites@2023-12-01" - parent_id = azurerm_resource_group.rg.id - name = local.dashboard_name - location = var.location - tags = merge(local.merged_tags, { - role = "aspire-dashboard" - }) - - body = { - kind = "app,linux,container" - properties = { - serverFarmId = azapi_resource.app_service_plan.id - httpsOnly = true - siteConfig = { - linuxFxVersion = "DOCKER|mcr.microsoft.com/dotnet/aspire-dashboard:latest" - ftpsState = "Disabled" - alwaysOn = true - http20Enabled = true - appSettings = [ - { - name = "WEBSITES_PORT" - value = "18888" - }, - { - name = "ASPNETCORE_URLS" - value = "http://0.0.0.0:18888" - }, - { - name = "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL" - value = "http://0.0.0.0:18889" - }, - { - name = "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL" - value = "http://0.0.0.0:18888" - }, - { - name = "DASHBOARD__UI__DISABLERESOURCEGRAPH" - value = "true" - } - ] - } - } - } - - response_export_values = ["properties.defaultHostName"] -} - -locals { - aspire_dashboard_output = azapi_resource.aspire_dashboard.output - otlp_http_endpoint = "https://${local.aspire_dashboard_output.properties.defaultHostName}" - app_services_common_settings = [ - { - name = "ASPNETCORE_ENVIRONMENT" - value = "Development" - }, - { - name = "APPLICATIONINSIGHTS_CONNECTION_STRING" - value = local.app_insights_output.properties.ConnectionString - }, - { - name = "APPINSIGHTS_INSTRUMENTATIONKEY" - value = local.app_insights_output.properties.InstrumentationKey - }, - { - name = "ApplicationInsightsAgent_EXTENSION_VERSION" - value = "~3" - }, - { - name = "XDT_MicrosoftApplicationInsights_Mode" - value = "recommended" - }, - { - name = "XDT_MicrosoftApplicationInsights_PreemptSdk" - value = "disabled" - }, - { - name = "DiagnosticServices_EXTENSION_VERSION" - value = "~3" - }, - { - name = "APPINSIGHTS_PROFILERFEATURE_VERSION" - value = "1.0.0" - }, - { - name = "APPINSIGHTS_SNAPSHOTFEATURE_VERSION" - value = "1.0.0" - }, - { - name = "Aspire__StackExchange__Redis__ConnectionString" - value = local.redis_connection_string - }, - { - name = "Aspire__Azure__Messaging__ServiceBus__ConnectionString" - value = local.service_bus_keys_output.primaryConnectionString - }, - { - name = "OTEL_EXPORTER_OTLP_PROTOCOL" - value = "http/protobuf" - }, - { - name = "OTEL_EXPORTER_OTLP_ENDPOINT" - value = local.otlp_http_endpoint - } - ] - app_services_sql_settings = [ - { - name = "Aspire__Microsoft__Data__SqlClient__ConnectionString" - value = local.sql_connection_string - } - ] - app_services_postgres_settings = [ - { - name = "Aspire__Npgsql__ConnectionString" - value = local.postgres_connection_string - } - ] -} - -resource "azapi_resource" "products_api" { - type = "Microsoft.Web/sites@2023-12-01" - parent_id = azurerm_resource_group.rg.id - name = "app-products-api-${var.environment_type}-${local.suffix}" - location = var.location - tags = merge(local.merged_tags, { - "azd-service-name" = "products-api" - "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id - }) - - body = { - kind = "app,linux" - properties = { - serverFarmId = azapi_resource.app_service_plan.id - httpsOnly = true - endToEndEncryptionEnabled = true - siteConfig = { - linuxFxVersion = var.app_service_linux_fx_version - minTlsVersion = "1.3" - minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" - scmMinTlsVersion = "1.3" - netFrameworkVersion = "" - ftpsState = "Disabled" - http20Enabled = true - alwaysOn = true - appSettings = concat(local.app_services_common_settings, local.app_services_postgres_settings) - } - } - } - - response_export_values = ["properties.defaultHostName"] -} - -locals { - products_api_output = azapi_resource.products_api.output -} - -resource "azapi_resource" "shopping_api" { - type = "Microsoft.Web/sites@2023-12-01" - parent_id = azurerm_resource_group.rg.id - name = "app-shopping-api-${var.environment_type}-${local.suffix}" - location = var.location - tags = merge(local.merged_tags, { - "azd-service-name" = "shopping-api" - "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id - }) - - body = { - kind = "app,linux" - properties = { - serverFarmId = azapi_resource.app_service_plan.id - httpsOnly = true - endToEndEncryptionEnabled = true - siteConfig = { - linuxFxVersion = var.app_service_linux_fx_version - minTlsVersion = "1.3" - minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" - scmMinTlsVersion = "1.3" - netFrameworkVersion = "" - ftpsState = "Disabled" - http20Enabled = true - alwaysOn = true - appSettings = concat(local.app_services_common_settings, local.app_services_sql_settings, [ - { - name = "ProductsApi__BaseAddress" - value = "https://${local.products_api_output.properties.defaultHostName}" - } - ]) - } - } - } -} - -resource "azapi_resource" "products_outbox_relay" { - type = "Microsoft.Web/sites@2023-12-01" - parent_id = azurerm_resource_group.rg.id - name = "app-products-outbox-relay-${var.environment_type}-${local.suffix}" - location = var.location - tags = merge(local.merged_tags, { - "azd-service-name" = "products-outbox-relay" - "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id - }) - - body = { - kind = "app,linux" - properties = { - serverFarmId = azapi_resource.app_service_plan.id - httpsOnly = true - endToEndEncryptionEnabled = true - siteConfig = { - linuxFxVersion = var.app_service_linux_fx_version - minTlsVersion = "1.3" - minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" - scmMinTlsVersion = "1.3" - netFrameworkVersion = "" - ftpsState = "Disabled" - http20Enabled = true - alwaysOn = true - appSettings = concat(local.app_services_common_settings, local.app_services_postgres_settings) - } - } - } -} - -resource "azapi_resource" "shopping_outbox_relay" { - type = "Microsoft.Web/sites@2023-12-01" - parent_id = azurerm_resource_group.rg.id - name = "app-shopping-outbox-relay-${var.environment_type}-${local.suffix}" - location = var.location - tags = merge(local.merged_tags, { - "azd-service-name" = "shopping-outbox-relay" - "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id - }) - - body = { - kind = "app,linux" - properties = { - serverFarmId = azapi_resource.app_service_plan.id - httpsOnly = true - endToEndEncryptionEnabled = true - siteConfig = { - linuxFxVersion = var.app_service_linux_fx_version - minTlsVersion = "1.3" - minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" - scmMinTlsVersion = "1.3" - netFrameworkVersion = "" - ftpsState = "Disabled" - http20Enabled = true - alwaysOn = true - appSettings = concat(local.app_services_common_settings, local.app_services_sql_settings) - } - } - } -} - -resource "azapi_resource" "products_subscribe" { - type = "Microsoft.Web/sites@2023-12-01" - parent_id = azurerm_resource_group.rg.id - name = "app-products-subscribe-${var.environment_type}-${local.suffix}" - location = var.location - tags = merge(local.merged_tags, { - "azd-service-name" = "products-subscribe" - "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id - }) - - body = { - kind = "app,linux" - properties = { - serverFarmId = azapi_resource.app_service_plan.id - httpsOnly = true - endToEndEncryptionEnabled = true - siteConfig = { - linuxFxVersion = var.app_service_linux_fx_version - minTlsVersion = "1.3" - minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" - scmMinTlsVersion = "1.3" - netFrameworkVersion = "" - ftpsState = "Disabled" - http20Enabled = true - alwaysOn = true - appSettings = concat(local.app_services_common_settings, local.app_services_postgres_settings) - } - } - } -} - -resource "azapi_resource" "shopping_subscribe" { - type = "Microsoft.Web/sites@2023-12-01" - parent_id = azurerm_resource_group.rg.id - name = "app-shopping-subscribe-${var.environment_type}-${local.suffix}" - location = var.location - tags = merge(local.merged_tags, { - "azd-service-name" = "shopping-subscribe" - "hidden-link: /app-insights-resource-id" = azapi_resource.app_insights.id - }) - - body = { - kind = "app,linux" - properties = { - serverFarmId = azapi_resource.app_service_plan.id - httpsOnly = true - endToEndEncryptionEnabled = true - siteConfig = { - linuxFxVersion = var.app_service_linux_fx_version - minTlsVersion = "1.3" - minTlsCipherSuite = "TLS_AES_256_GCM_SHA384" - scmMinTlsVersion = "1.3" - netFrameworkVersion = "" - ftpsState = "Disabled" - http20Enabled = true - alwaysOn = true - appSettings = concat(local.app_services_common_settings, local.app_services_sql_settings) - } - } - } -} - -data "azurerm_client_config" "current" {} diff --git a/azure/terraform/outputs.tf b/azure/terraform/outputs.tf deleted file mode 100644 index 82237b54..00000000 --- a/azure/terraform/outputs.tf +++ /dev/null @@ -1,72 +0,0 @@ -output "app_service_plan_name" { - value = azapi_resource.app_service_plan.name -} - -output "app_insights_connection_string" { - value = local.app_insights_output.properties.ConnectionString - sensitive = true -} - -output "key_vault_name" { - value = azapi_resource.key_vault.name -} - -output "service_bus_namespace_name" { - value = azapi_resource.service_bus.name -} - -output "redis_host_name" { - value = local.redis_output.properties.hostName -} - -output "sql_server_name" { - value = azapi_resource.sql_server.name -} - -output "sql_database_name" { - value = azapi_resource.sql_database.name -} - -output "postgres_server_name" { - value = azapi_resource.postgres_server.name -} - -output "postgres_database_name" { - value = azapi_resource.postgres_database.name -} - -output "products_api_app_name" { - value = azapi_resource.products_api.name -} - -output "shopping_api_app_name" { - value = azapi_resource.shopping_api.name -} - -output "products_outbox_relay_app_name" { - value = azapi_resource.products_outbox_relay.name -} - -output "shopping_outbox_relay_app_name" { - value = azapi_resource.shopping_outbox_relay.name -} - -output "products_subscribe_app_name" { - value = azapi_resource.products_subscribe.name -} - -output "shopping_subscribe_app_name" { - value = azapi_resource.shopping_subscribe.name -} - -output "aspire_dashboard_app_name" { - value = azapi_resource.aspire_dashboard.name -} - -output "aspire_dashboard_uri" { - value = "https://${local.aspire_dashboard_output.properties.defaultHostName}" -} - -output "aspire_dashboard_otlp_http_endpoint" { - value = "https://${local.aspire_dashboard_output.properties.defaultHostName}" -} diff --git a/azure/terraform/prod.tfvars b/azure/terraform/prod.tfvars deleted file mode 100644 index f1fe4ef4..00000000 --- a/azure/terraform/prod.tfvars +++ /dev/null @@ -1,39 +0,0 @@ -resource_group_name = "rg-prod" -environment_type = "prod" -location = "eastus" -name_suffix = "prod01" - -tags = { - workload = "coreex" - environment = "prod" -} - -app_service_plan_sku_name = "B1" -app_service_plan_sku_tier = "Basic" -app_service_plan_capacity = 1 - -# Derived from AZD_DOTNET_TARGET_FRAMEWORK by apply script. -app_service_linux_fx_version = "DOTNETCORE|10.0" - -service_bus_sku_name = "Standard" - -sql_admin_login = "coreexadmin" -sql_database_name = "coreexprod" -sql_sku_name = "GP_S_Gen5_1" -sql_sku_tier = "GeneralPurpose" -sql_min_capacity = 0.5 -sql_auto_pause_delay = 60 - -postgres_admin_login = "coreexpgadmin" -postgres_database_name = "coreexprod" -postgres_sku_name = "Standard_B1ms" -postgres_sku_tier = "Burstable" -postgres_version = "16" -postgres_storage_mb = 32768 - -# Derived from current public IP by apply script. -sql_firewall_client_ip = "" -postgres_firewall_client_ip = "" - -redis_sku_name = "Balanced_B0" -redis_high_availability = "Enabled" diff --git a/azure/terraform/terraform.tfvars.example b/azure/terraform/terraform.tfvars.example deleted file mode 100644 index 71c79a7d..00000000 --- a/azure/terraform/terraform.tfvars.example +++ /dev/null @@ -1,29 +0,0 @@ -resource_group_name = "rg-dev" -environment_type = "dev" -location = "westus3" -name_suffix = "dev01" - -tags = { - workload = "coreex" - environment = "dev" - costProfile = "minimum-practical" -} - -app_service_plan_sku_name = "B2" -app_service_plan_sku_tier = "Basic" -app_service_plan_capacity = 1 -app_service_linux_fx_version = "DOTNETCORE|10.0" - -service_bus_sku_name = "Standard" - -sql_admin_login = "coreexadmin" -sql_admin_password = "" -sql_database_name = "coreexdev" -sql_firewall_client_ip = "" -sql_sku_name = "GP_S_Gen5_1" -sql_sku_tier = "GeneralPurpose" -sql_min_capacity = 0.5 -sql_auto_pause_delay = 60 - -redis_sku_name = "Balanced_B0" -redis_high_availability = "Disabled" diff --git a/azure/terraform/test.tfvars b/azure/terraform/test.tfvars deleted file mode 100644 index 2e0c303b..00000000 --- a/azure/terraform/test.tfvars +++ /dev/null @@ -1,39 +0,0 @@ -resource_group_name = "rg-test" -environment_type = "test" -location = "eastus" -name_suffix = "test01" - -tags = { - workload = "coreex" - environment = "test" -} - -app_service_plan_sku_name = "B1" -app_service_plan_sku_tier = "Basic" -app_service_plan_capacity = 1 - -# Derived from AZD_DOTNET_TARGET_FRAMEWORK by apply script. -app_service_linux_fx_version = "DOTNETCORE|10.0" - -service_bus_sku_name = "Standard" - -sql_admin_login = "coreexadmin" -sql_database_name = "coreextest" -sql_sku_name = "GP_S_Gen5_1" -sql_sku_tier = "GeneralPurpose" -sql_min_capacity = 0.5 -sql_auto_pause_delay = 60 - -postgres_admin_login = "coreexpgadmin" -postgres_database_name = "coreextest" -postgres_sku_name = "Standard_B1ms" -postgres_sku_tier = "Burstable" -postgres_version = "16" -postgres_storage_mb = 32768 - -# Derived from current public IP by apply script. -sql_firewall_client_ip = "" -postgres_firewall_client_ip = "" - -redis_sku_name = "Balanced_B0" -redis_high_availability = "Enabled" diff --git a/azure/terraform/variables.tf b/azure/terraform/variables.tf deleted file mode 100644 index 19c7781f..00000000 --- a/azure/terraform/variables.tf +++ /dev/null @@ -1,159 +0,0 @@ -variable "resource_group_name" { - description = "Existing resource group where all resources will be deployed." - type = string -} - -variable "environment_type" { - description = "Deployment environment." - type = string - validation { - condition = contains(["dev", "test", "prod"], var.environment_type) - error_message = "environment_type must be one of: dev, test, prod." - } -} - -variable "location" { - description = "Azure region for all resources." - type = string -} - -variable "name_suffix" { - description = "Short lowercase suffix used in resource names." - type = string -} - -variable "tags" { - description = "Base tags applied to all resources." - type = map(string) - default = {} -} - -variable "app_service_plan_sku_name" { - description = "App Service Plan SKU name." - type = string -} - -variable "app_service_plan_sku_tier" { - description = "App Service Plan SKU tier." - type = string -} - -variable "app_service_plan_capacity" { - description = "App Service Plan instance count." - type = number -} - -variable "app_service_linux_fx_version" { - description = "Linux runtime stack for code-based app services. Example: DOTNETCORE|10.0." - type = string -} - -variable "service_bus_sku_name" { - description = "Service Bus namespace SKU name." - type = string -} - -variable "sql_admin_login" { - description = "SQL admin username." - type = string -} - -variable "sql_admin_password" { - description = "SQL admin password." - type = string - sensitive = true -} - -variable "sql_database_name" { - description = "SQL database name." - type = string -} - -variable "sql_firewall_client_ip" { - description = "Optional public IPv4 for runner firewall allow rule." - type = string - default = "" -} - -variable "key_vault_firewall_client_ip" { - description = "Optional public IPv4 to allow through Key Vault firewall." - type = string - default = "" -} - -variable "sql_sku_name" { - description = "SQL database SKU name." - type = string -} - -variable "sql_sku_tier" { - description = "SQL database SKU tier." - type = string -} - -variable "sql_min_capacity" { - description = "SQL serverless min capacity." - type = number -} - -variable "sql_auto_pause_delay" { - description = "SQL auto-pause delay in minutes." - type = number -} - -variable "postgres_admin_login" { - description = "Postgres admin username." - type = string -} - -variable "postgres_admin_password" { - description = "Postgres admin password. Defaults to SQL admin password when omitted." - type = string - sensitive = true - default = null -} - -variable "postgres_database_name" { - description = "Postgres database name used by Products domain." - type = string -} - -variable "postgres_firewall_client_ip" { - description = "Optional public IPv4 for Postgres firewall allow rule." - type = string - default = "" -} - -variable "postgres_sku_name" { - description = "Postgres flexible server SKU name." - type = string -} - -variable "postgres_sku_tier" { - description = "Postgres flexible server SKU tier." - type = string -} - -variable "postgres_version" { - description = "Postgres major version." - type = string -} - -variable "postgres_storage_mb" { - description = "Postgres storage size in MB." - type = number -} - -variable "redis_sku_name" { - description = "Azure Managed Redis SKU name. Example: Balanced_B0." - type = string -} - -variable "redis_high_availability" { - description = "Azure Managed Redis high availability mode." - type = string - validation { - condition = contains(["Enabled", "Disabled"], var.redis_high_availability) - error_message = "redis_high_availability must be Enabled or Disabled." - } -} diff --git a/azure/terraform/versions.tf b/azure/terraform/versions.tf deleted file mode 100644 index bd4ad390..00000000 --- a/azure/terraform/versions.tf +++ /dev/null @@ -1,28 +0,0 @@ -terraform { - required_version = ">= 1.6.0" - - required_providers { - azurerm = { - source = "hashicorp/azurerm" - version = ">= 3.112.0" - } - azapi = { - source = "Azure/azapi" - version = ">= 1.14.0" - } - random = { - source = "hashicorp/random" - version = ">= 3.6.0" - } - time = { - source = "hashicorp/time" - version = ">= 0.12.0" - } - } -} - -provider "azurerm" { - features {} -} - -provider "azapi" {} From fb7b8af94f0fe857f26b4ba21f9c5a36e448e2b3 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Wed, 20 May 2026 13:22:28 -0700 Subject: [PATCH 08/14] fix: updated ps1 to correctly get statuscodes Signed-off-by: Aaron Spruit --- azure/scripts/setup-e2e-runner.ps1 | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/azure/scripts/setup-e2e-runner.ps1 b/azure/scripts/setup-e2e-runner.ps1 index 3263c347..d931a66e 100644 --- a/azure/scripts/setup-e2e-runner.ps1 +++ b/azure/scripts/setup-e2e-runner.ps1 @@ -48,11 +48,12 @@ function Invoke-ValidateRequest { [string] $Method = 'GET' ) + $code = $null try { $response = Invoke-WebRequest -Uri $Url -Method $Method -SkipCertificateCheck -UseBasicParsing -ErrorAction Stop $code = [int]$response.StatusCode } - catch [System.Net.Http.HttpRequestException] { + catch [Microsoft.PowerShell.Commands.HttpResponseException] { $code = [int]$_.Exception.Response.StatusCode } catch { @@ -60,7 +61,7 @@ function Invoke-ValidateRequest { exit 1 } - if ($code -lt 200 -or $code -ge 400) { + if (-not $code -or $code -lt 200 -or $code -ge 400) { Write-Error "Validation failed for ${Label}: ${Method} ${Url} returned HTTP ${code}." exit 1 } From 18fcbefcf11948f9f41c50de134437f13c727c27 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Wed, 20 May 2026 13:40:12 -0700 Subject: [PATCH 09/14] updating agents.md to include helper script info Signed-off-by: Aaron Spruit --- azure/AGENTS.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/azure/AGENTS.md b/azure/AGENTS.md index b46c0f59..ff953ccd 100644 --- a/azure/AGENTS.md +++ b/azure/AGENTS.md @@ -81,6 +81,39 @@ azd deploy --all --no-prompt # code-only redeploy. azd down --force --purge --no-prompt # tear down. ``` +## Helper scripts + +The `azure/scripts/` folder includes two helper scripts that should be preferred over manual command chains when validating a deployment. + +### get-aspire-dashboard-login + +- Files: `scripts/get-aspire-dashboard-login.sh`, `scripts/get-aspire-dashboard-login.ps1`. +- Purpose: prints the Aspire Dashboard URL and a ready-to-open login URL when a dashboard token can be found. +- Required argument: `--resource-group` / `-ResourceGroup`. +- Optional arguments: dashboard app name and token timeout. +- Discovery behavior: auto-detects the dashboard app when not explicitly provided. +- Token retrieval order: + 1. SCM/Kudu command API query of runtime `container.log` (last 60 minutes). + 2. SCM/Kudu log archive scan. + 3. Live `az webapp log tail` fallback with timeout. +- Output always includes dashboard app name and dashboard URL; login URL is printed only when token extraction succeeds. + +### setup-e2e-runner + +- Files: `scripts/setup-e2e-runner.sh`, `scripts/setup-e2e-runner.ps1`. +- Purpose: wires `samples/tests/Contoso.E2E.Runner/appsettings.json` to deployed Azure endpoints and connection strings. +- Required argument: `--resource-group` / `-ResourceGroup`. +- Optional arguments: appsettings path, key vault name, Products app name, Shopping app name, skip-validation, insecure validation mode. +- Discovery behavior: auto-detects Products and Shopping app names plus Key Vault when omitted. +- Secret retrieval: reads `postgres-connection-string` and `sql-connection-string` from Key Vault. +- Validation behavior (unless skipped): + - `GET /api/products` for Products. + - `POST /api/customers/test/baskets` for Shopping. + - health and swagger endpoints for both services. +- Update behavior: creates `appsettings.json.bak` before writing updated `E2E.Products` and `E2E.Shopping` values. + +When editing either helper script, keep the bash and PowerShell variants behaviorally identical. + ## Validating changes Before declaring an infra change complete: From 1b6ac41bf4e0ebdaeab1b0ee4665a7fd17445de6 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Wed, 20 May 2026 15:04:11 -0700 Subject: [PATCH 10/14] fix: updated webapps to use keyvault connection strings for database and servicebus Signed-off-by: Aaron Spruit --- azure/AGENTS.md | 10 + azure/README.md | 26 + azure/infra/main.bicep | 5 +- azure/infra/main.json | 808 ++++++++++++++---- azure/infra/modules/app-services.bicep | 98 ++- azure/infra/scripts/store-secrets.ps1 | 6 + azure/infra/scripts/store-secrets.sh | 6 + .../scripts/refresh-keyvault-appsettings.ps1 | 56 ++ azure/scripts/refresh-keyvault-appsettings.sh | 103 +++ 9 files changed, 956 insertions(+), 162 deletions(-) create mode 100644 azure/scripts/refresh-keyvault-appsettings.ps1 create mode 100644 azure/scripts/refresh-keyvault-appsettings.sh diff --git a/azure/AGENTS.md b/azure/AGENTS.md index ff953ccd..8a33e6c5 100644 --- a/azure/AGENTS.md +++ b/azure/AGENTS.md @@ -112,6 +112,15 @@ The `azure/scripts/` folder includes two helper scripts that should be preferred - health and swagger endpoints for both services. - Update behavior: creates `appsettings.json.bak` before writing updated `E2E.Products` and `E2E.Shopping` values. +### refresh-keyvault-appsettings + +- Files: `scripts/refresh-keyvault-appsettings.sh`, `scripts/refresh-keyvault-appsettings.ps1`. +- Purpose: refreshes App Service Key Vault appsetting references and restarts targeted web apps. +- Required argument: `--resource-group` / `-ResourceGroup`. +- Optional arguments: one or more app names and wait-after-restart seconds. +- Discovery behavior: when app names are omitted, all web apps in the resource group are targeted. +- Primary use case: recover from startup failures immediately after deploy when Key Vault-backed settings have not been fully applied yet. + When editing either helper script, keep the bash and PowerShell variants behaviorally identical. ## Validating changes @@ -136,6 +145,7 @@ Do not run `azd up` or `azd down` without explicit user approval — these touch - **PostgreSQL password missing** — set `AZURE_POSTGRES_ADMIN_PASSWORD` when different from SQL admin password. - **Predeploy missing output keys** — run `azd provision --no-prompt` before `azd deploy --all --no-prompt` to refresh `sql*` and `postgres*` output values in azd env. - **API returns 404 at `/`** — expected; probe `/api/...`, `/health/ready/detailed`, or `/swagger`. +- **Startup fails with invalid Service Bus/connection string right after deploy** — run `./scripts/refresh-keyvault-appsettings.sh --resource-group ` (or the PowerShell variant) to refresh Key Vault references and restart apps. - **Aspire Dashboard requires token** — fetch from `az webapp log tail` (see [README.md](README.md#accessing-the-aspire-dashboard)). - **`azd init` says no project** — run from `azure/`, not the repo root. diff --git a/azure/README.md b/azure/README.md index baa0d5b5..f94b0bee 100644 --- a/azure/README.md +++ b/azure/README.md @@ -143,6 +143,22 @@ Re-run infra only: azd provision --no-prompt ``` +If apps that use Key Vault-backed app settings fail on startup immediately after deploy, refresh Key Vault app setting references and restart the web apps: + +```bash +./scripts/refresh-keyvault-appsettings.sh --resource-group +``` + +Optional arguments: + +```bash +./scripts/refresh-keyvault-appsettings.sh \ + --resource-group \ + --app-name \ + --app-name \ + --wait-after-restart-seconds 20 +``` + ## Accessing the Aspire Dashboard After deployment, the Aspire Dashboard is publicly accessible from a dedicated HTTPS-enabled App Service. The six deployed services are configured to export OTLP telemetry to it automatically. @@ -336,6 +352,16 @@ This launches an interactive CLI menu to select and execute test scenarios or ru ## Troubleshooting +Key Vault app setting reference timing: + +- Symptom: app startup fails with invalid connection string errors (for example Service Bus) immediately after `azd up`. +- Cause: app setting reference resolution can lag after role assignment and secret updates. +- Recommended fix: + +```bash +./scripts/refresh-keyvault-appsettings.sh --resource-group +``` + No project exists / run azd init: ```bash diff --git a/azure/infra/main.bicep b/azure/infra/main.bicep index 8bc1d8ef..662cf7ab 100644 --- a/azure/infra/main.bicep +++ b/azure/infra/main.bicep @@ -197,10 +197,9 @@ module appServices './modules/app-services.bicep' = { appInsightsConnectionString: appInsights.outputs.connectionString appInsightsResourceId: appInsights.outputs.id appInsightsInstrumentationKey: appInsights.outputs.instrumentationKey - sqlConnectionString: sql.outputs.connectionString - postgresConnectionString: postgres.outputs.connectionString + keyVaultName: keyVault.outputs.name + keyVaultUri: keyVault.outputs.vaultUri redisConnectionString: redis.outputs.connectionString - serviceBusConnectionString: serviceBus.outputs.connectionString otlpHttpEndpoint: aspireDashboard.outputs.otlpHttpEndpoint } } diff --git a/azure/infra/main.json b/azure/infra/main.json index 200cce04..dcf3ef0d 100644 --- a/azure/infra/main.json +++ b/azure/infra/main.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "11332895255838187634" + "version": "0.43.8.12551", + "templateHash": "9143201238151831317" } }, "parameters": { @@ -58,6 +58,12 @@ "description": "App Service Plan instance count." } }, + "appServiceLinuxFxVersion": { + "type": "string", + "metadata": { + "description": "App Service Linux runtime stack. Example: DOTNETCORE|10.0." + } + }, "serviceBusSkuName": { "type": "string", "metadata": { @@ -82,6 +88,13 @@ "description": "Azure SQL database name." } }, + "sqlFirewallClientIp": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Current runner public IPv4 address to allow through the Azure SQL firewall." + } + }, "sqlSkuName": { "type": "string", "metadata": { @@ -106,22 +119,69 @@ "description": "Azure SQL auto-pause delay in minutes. Set to -1 to disable." } }, - "redisSkuName": { + "postgresAdminLogin": { + "type": "string", + "metadata": { + "description": "Azure PostgreSQL administrator login name." + } + }, + "postgresAdminPassword": { + "type": "securestring", + "metadata": { + "description": "Azure PostgreSQL administrator password." + } + }, + "postgresDatabaseName": { + "type": "string", + "metadata": { + "description": "Azure PostgreSQL database name used by Products domain." + } + }, + "postgresFirewallClientIp": { "type": "string", + "defaultValue": "", "metadata": { - "description": "Redis SKU name. Basic, Standard, or Premium." + "description": "Current runner public IPv4 address to allow through the Azure PostgreSQL firewall." } }, - "redisSkuFamily": { + "postgresSkuName": { "type": "string", "metadata": { - "description": "Redis SKU family. Typically C for Basic/Standard." + "description": "Azure PostgreSQL flexible server SKU name. Example: Standard_B1ms." } }, - "redisSkuCapacity": { + "postgresSkuTier": { + "type": "string", + "metadata": { + "description": "Azure PostgreSQL flexible server tier. Example: Burstable." + } + }, + "postgresVersion": { + "type": "string", + "metadata": { + "description": "Azure PostgreSQL major version. Example: 16." + } + }, + "postgresStorageSizeGb": { "type": "int", "metadata": { - "description": "Redis SKU capacity." + "description": "Azure PostgreSQL storage size in GB." + } + }, + "redisSkuName": { + "type": "string", + "metadata": { + "description": "Azure Managed Redis SKU name. Example: Balanced_B0." + } + }, + "redisHighAvailability": { + "type": "string", + "allowedValues": [ + "Enabled", + "Disabled" + ], + "metadata": { + "description": "Azure Managed Redis high availability mode." } } }, @@ -157,8 +217,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "13606231085913354784" + "version": "0.43.8.12551", + "templateHash": "1054162708713925368" } }, "parameters": { @@ -196,6 +256,14 @@ "connectionString": { "type": "string", "value": "[reference(resourceId('Microsoft.Insights/components', parameters('name')), '2020-02-02').ConnectionString]" + }, + "id": { + "type": "string", + "value": "[resourceId('Microsoft.Insights/components', parameters('name'))]" + }, + "instrumentationKey": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Insights/components', parameters('name')), '2020-02-02').InstrumentationKey]" } } } @@ -227,8 +295,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "2576417288351020778" + "version": "0.43.8.12551", + "templateHash": "6366741801556338859" } }, "parameters": { @@ -316,8 +384,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "12329281375890776410" + "version": "0.43.8.12551", + "templateHash": "17043110285077169943" } }, "parameters": { @@ -401,8 +469,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "14328657583515942296" + "version": "0.43.8.12551", + "templateHash": "17950824884306378259" } }, "parameters": { @@ -449,6 +517,34 @@ "[resourceId('Microsoft.ServiceBus/namespaces', parameters('namespaceName'))]" ] }, + { + "type": "Microsoft.ServiceBus/namespaces/topics/subscriptions", + "apiVersion": "2023-01-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('namespaceName'), 'contoso', 'products')]", + "properties": { + "requiresSession": true, + "maxDeliveryCount": 10, + "lockDuration": "PT5M", + "deadLetteringOnMessageExpiration": true + }, + "dependsOn": [ + "[resourceId('Microsoft.ServiceBus/namespaces/topics', parameters('namespaceName'), 'contoso')]" + ] + }, + { + "type": "Microsoft.ServiceBus/namespaces/topics/subscriptions", + "apiVersion": "2023-01-01-preview", + "name": "[format('{0}/{1}/{2}', parameters('namespaceName'), 'contoso', 'shopping')]", + "properties": { + "requiresSession": true, + "maxDeliveryCount": 10, + "lockDuration": "PT5M", + "deadLetteringOnMessageExpiration": true + }, + "dependsOn": [ + "[resourceId('Microsoft.ServiceBus/namespaces/topics', parameters('namespaceName'), 'contoso')]" + ] + }, { "type": "Microsoft.ServiceBus/namespaces/AuthorizationRules", "apiVersion": "2023-01-01-preview", @@ -478,6 +574,14 @@ "type": "string", "value": "contoso" }, + "productsSubscriptionName": { + "type": "string", + "value": "products" + }, + "shoppingSubscriptionName": { + "type": "string", + "value": "shopping" + }, "connectionString": { "type": "string", "value": "[listKeys(resourceId('Microsoft.ServiceBus/namespaces/AuthorizationRules', parameters('namespaceName'), 'app'), '2023-01-01-preview').primaryConnectionString]" @@ -505,11 +609,8 @@ "skuName": { "value": "[parameters('redisSkuName')]" }, - "skuFamily": { - "value": "[parameters('redisSkuFamily')]" - }, - "skuCapacity": { - "value": "[parameters('redisSkuCapacity')]" + "highAvailability": { + "value": "[parameters('redisHighAvailability')]" }, "tags": { "value": "[variables('mergedTags')]" @@ -521,8 +622,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "15208332006810395159" + "version": "0.43.8.12551", + "templateHash": "4334939735410165283" } }, "parameters": { @@ -535,11 +636,12 @@ "skuName": { "type": "string" }, - "skuFamily": { - "type": "string" - }, - "skuCapacity": { - "type": "int" + "highAvailability": { + "type": "string", + "allowedValues": [ + "Enabled", + "Disabled" + ] }, "tags": { "type": "object", @@ -548,27 +650,41 @@ }, "resources": [ { - "type": "Microsoft.Cache/redis", - "apiVersion": "2023-08-01", + "type": "Microsoft.Cache/redisEnterprise", + "apiVersion": "2025-07-01", "name": "[parameters('cacheName')]", "location": "[parameters('location')]", "tags": "[parameters('tags')]", + "sku": { + "name": "[parameters('skuName')]" + }, "properties": { - "enableNonSslPort": false, + "highAvailability": "[parameters('highAvailability')]", "minimumTlsVersion": "1.2", "publicNetworkAccess": "Enabled", - "sku": { - "name": "[parameters('skuName')]", - "family": "[parameters('skuFamily')]", - "capacity": "[parameters('skuCapacity')]" - } + "encryption": {} } + }, + { + "type": "Microsoft.Cache/redisEnterprise/databases", + "apiVersion": "2025-04-01", + "name": "[format('{0}/{1}', parameters('cacheName'), 'default')]", + "properties": { + "clientProtocol": "Encrypted", + "clusteringPolicy": "OSSCluster", + "evictionPolicy": "VolatileLRU", + "modules": [], + "port": 10000 + }, + "dependsOn": [ + "[resourceId('Microsoft.Cache/redisEnterprise', parameters('cacheName'))]" + ] } ], "outputs": { "id": { "type": "string", - "value": "[resourceId('Microsoft.Cache/redis', parameters('cacheName'))]" + "value": "[resourceId('Microsoft.Cache/redisEnterprise', parameters('cacheName'))]" }, "name": { "type": "string", @@ -576,11 +692,11 @@ }, "hostName": { "type": "string", - "value": "[reference(resourceId('Microsoft.Cache/redis', parameters('cacheName')), '2023-08-01').hostName]" + "value": "[reference(resourceId('Microsoft.Cache/redisEnterprise', parameters('cacheName')), '2025-07-01').hostName]" }, "connectionString": { "type": "string", - "value": "[format('{0}:6380,password={1},ssl=True,abortConnect=False', reference(resourceId('Microsoft.Cache/redis', parameters('cacheName')), '2023-08-01').hostName, listKeys(resourceId('Microsoft.Cache/redis', parameters('cacheName')), '2023-08-01').primaryKey)]" + "value": "[format('{0}:10000,password={1},ssl=True,abortConnect=False', reference(resourceId('Microsoft.Cache/redisEnterprise', parameters('cacheName')), '2025-07-01').hostName, listKeys(resourceId('Microsoft.Cache/redisEnterprise/databases', parameters('cacheName'), 'default'), '2025-04-01').primaryKey)]" } } } @@ -611,6 +727,9 @@ "adminPassword": { "value": "[parameters('sqlAdminPassword')]" }, + "clientIp": { + "value": "[parameters('sqlFirewallClientIp')]" + }, "skuName": { "value": "[parameters('sqlSkuName')]" }, @@ -633,8 +752,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "14902580470469121549" + "version": "0.43.8.12551", + "templateHash": "12964997318502549886" } }, "parameters": { @@ -653,6 +772,10 @@ "adminPassword": { "type": "securestring" }, + "clientIp": { + "type": "string", + "defaultValue": "" + }, "skuName": { "type": "string" }, @@ -697,6 +820,19 @@ "[resourceId('Microsoft.Sql/servers', parameters('serverName'))]" ] }, + { + "condition": "[not(empty(parameters('clientIp')))]", + "type": "Microsoft.Sql/servers/firewallRules", + "apiVersion": "2023-08-01-preview", + "name": "[format('{0}/{1}', parameters('serverName'), format('AllowCurrentRunner-{0}', replace(parameters('clientIp'), '.', '-')))]", + "properties": { + "startIpAddress": "[parameters('clientIp')]", + "endIpAddress": "[parameters('clientIp')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Sql/servers', parameters('serverName'))]" + ] + }, { "type": "Microsoft.Sql/servers/databases", "apiVersion": "2023-08-01-preview", @@ -738,6 +874,184 @@ } } }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "postgresDeploy", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "serverName": { + "value": "[format('pg-{0}-{1}', parameters('environmentType'), variables('suffix'))]" + }, + "databaseName": { + "value": "[parameters('postgresDatabaseName')]" + }, + "adminLogin": { + "value": "[parameters('postgresAdminLogin')]" + }, + "adminPassword": { + "value": "[parameters('postgresAdminPassword')]" + }, + "clientIp": { + "value": "[parameters('postgresFirewallClientIp')]" + }, + "skuName": { + "value": "[parameters('postgresSkuName')]" + }, + "skuTier": { + "value": "[parameters('postgresSkuTier')]" + }, + "version": { + "value": "[parameters('postgresVersion')]" + }, + "storageSizeGb": { + "value": "[parameters('postgresStorageSizeGb')]" + }, + "tags": { + "value": "[variables('mergedTags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.43.8.12551", + "templateHash": "17061784073968427037" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "serverName": { + "type": "string" + }, + "databaseName": { + "type": "string" + }, + "adminLogin": { + "type": "string" + }, + "adminPassword": { + "type": "securestring" + }, + "clientIp": { + "type": "string", + "defaultValue": "" + }, + "skuName": { + "type": "string" + }, + "skuTier": { + "type": "string" + }, + "version": { + "type": "string" + }, + "storageSizeGb": { + "type": "int" + }, + "tags": { + "type": "object", + "defaultValue": {} + } + }, + "resources": [ + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers", + "apiVersion": "2023-12-01-preview", + "name": "[parameters('serverName')]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "[parameters('skuName')]", + "tier": "[parameters('skuTier')]" + }, + "properties": { + "administratorLogin": "[parameters('adminLogin')]", + "administratorLoginPassword": "[parameters('adminPassword')]", + "version": "[parameters('version')]", + "publicNetworkAccess": "Enabled", + "storage": { + "storageSizeGB": "[parameters('storageSizeGb')]" + }, + "highAvailability": { + "mode": "Disabled" + }, + "backup": { + "backupRetentionDays": 7, + "geoRedundantBackup": "Disabled" + } + } + }, + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules", + "apiVersion": "2023-12-01-preview", + "name": "[format('{0}/{1}', parameters('serverName'), 'AllowAzureServices')]", + "properties": { + "startIpAddress": "0.0.0.0", + "endIpAddress": "0.0.0.0" + }, + "dependsOn": [ + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('serverName'))]" + ] + }, + { + "condition": "[not(empty(parameters('clientIp')))]", + "type": "Microsoft.DBforPostgreSQL/flexibleServers/firewallRules", + "apiVersion": "2023-12-01-preview", + "name": "[format('{0}/{1}', parameters('serverName'), format('AllowCurrentRunner-{0}', replace(parameters('clientIp'), '.', '-')))]", + "properties": { + "startIpAddress": "[parameters('clientIp')]", + "endIpAddress": "[parameters('clientIp')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('serverName'))]" + ] + }, + { + "type": "Microsoft.DBforPostgreSQL/flexibleServers/databases", + "apiVersion": "2023-12-01-preview", + "name": "[format('{0}/{1}', parameters('serverName'), parameters('databaseName'))]", + "properties": { + "charset": "UTF8", + "collation": "en_US.utf8" + }, + "dependsOn": [ + "[resourceId('Microsoft.DBforPostgreSQL/flexibleServers', parameters('serverName'))]" + ] + } + ], + "outputs": { + "serverName": { + "type": "string", + "value": "[parameters('serverName')]" + }, + "databaseName": { + "type": "string", + "value": "[parameters('databaseName')]" + }, + "fullyQualifiedDomainName": { + "type": "string", + "value": "[format('{0}.postgres.database.azure.com', parameters('serverName'))]" + }, + "connectionString": { + "type": "string", + "value": "[format('Server={0}.postgres.database.azure.com;Port=5432;Database={1};User Id={2};Password={3};Ssl Mode=Require;', parameters('serverName'), parameters('databaseName'), parameters('adminLogin'), parameters('adminPassword'))]" + } + } + } + } + }, { "type": "Microsoft.Resources/deployments", "apiVersion": "2025-04-01", @@ -754,6 +1068,9 @@ "appServicePlanId": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicePlanDeploy'), '2025-04-01').outputs.id.value]" }, + "appServiceLinuxFxVersion": { + "value": "[parameters('appServiceLinuxFxVersion')]" + }, "environmentType": { "value": "[parameters('environmentType')]" }, @@ -766,17 +1083,23 @@ "appInsightsConnectionString": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy'), '2025-04-01').outputs.connectionString.value]" }, - "sqlConnectionString": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'sqlDeploy'), '2025-04-01').outputs.connectionString.value]" + "appInsightsResourceId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy'), '2025-04-01').outputs.id.value]" + }, + "appInsightsInstrumentationKey": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy'), '2025-04-01').outputs.instrumentationKey.value]" + }, + "keyVaultName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'keyVaultDeploy'), '2025-04-01').outputs.name.value]" + }, + "keyVaultUri": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'keyVaultDeploy'), '2025-04-01').outputs.vaultUri.value]" }, "redisConnectionString": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'redisDeploy'), '2025-04-01').outputs.connectionString.value]" }, - "serviceBusConnectionString": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'serviceBusDeploy'), '2025-04-01').outputs.connectionString.value]" - }, - "otlpGrpcEndpoint": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.otlpGrpcEndpoint.value]" + "otlpHttpEndpoint": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.otlpHttpEndpoint.value]" } }, "template": { @@ -785,8 +1108,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "432677751176830928" + "version": "0.43.8.12551", + "templateHash": "776203396049316067" } }, "parameters": { @@ -796,6 +1119,9 @@ "appServicePlanId": { "type": "string" }, + "appServiceLinuxFxVersion": { + "type": "string" + }, "environmentType": { "type": "string" }, @@ -809,20 +1135,30 @@ "appInsightsConnectionString": { "type": "string" }, - "sqlConnectionString": { + "appInsightsResourceId": { "type": "string" }, - "redisConnectionString": { + "appInsightsInstrumentationKey": { "type": "string" }, - "serviceBusConnectionString": { + "keyVaultName": { "type": "string" }, - "otlpGrpcEndpoint": { + "keyVaultUri": { + "type": "string" + }, + "redisConnectionString": { + "type": "string" + }, + "otlpHttpEndpoint": { "type": "string" } }, "variables": { + "keyVaultSecretsUserRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", + "sqlConnectionStringKeyVaultReference": "[format('@Microsoft.KeyVault(SecretUri={0}secrets/sql-connection-string/)', parameters('keyVaultUri'))]", + "postgresConnectionStringKeyVaultReference": "[format('@Microsoft.KeyVault(SecretUri={0}secrets/postgres-connection-string/)', parameters('keyVaultUri'))]", + "serviceBusConnectionStringKeyVaultReference": "[format('@Microsoft.KeyVault(SecretUri={0}secrets/service-bus-connection-string/)', parameters('keyVaultUri'))]", "sharedAppSettings": [ { "name": "ASPNETCORE_ENVIRONMENT", @@ -833,8 +1169,32 @@ "value": "[parameters('appInsightsConnectionString')]" }, { - "name": "Aspire__Microsoft__Data__SqlClient__ConnectionString", - "value": "[parameters('sqlConnectionString')]" + "name": "APPINSIGHTS_INSTRUMENTATIONKEY", + "value": "[parameters('appInsightsInstrumentationKey')]" + }, + { + "name": "ApplicationInsightsAgent_EXTENSION_VERSION", + "value": "~3" + }, + { + "name": "XDT_MicrosoftApplicationInsights_Mode", + "value": "recommended" + }, + { + "name": "XDT_MicrosoftApplicationInsights_PreemptSdk", + "value": "disabled" + }, + { + "name": "DiagnosticServices_EXTENSION_VERSION", + "value": "~3" + }, + { + "name": "APPINSIGHTS_PROFILERFEATURE_VERSION", + "value": "1.0.0" + }, + { + "name": "APPINSIGHTS_SNAPSHOTFEATURE_VERSION", + "value": "1.0.0" }, { "name": "Aspire__StackExchange__Redis__ConnectionString", @@ -842,15 +1202,27 @@ }, { "name": "Aspire__Azure__Messaging__ServiceBus__ConnectionString", - "value": "[parameters('serviceBusConnectionString')]" + "value": "[variables('serviceBusConnectionStringKeyVaultReference')]" }, { "name": "OTEL_EXPORTER_OTLP_PROTOCOL", - "value": "grpc" + "value": "http/protobuf" }, { "name": "OTEL_EXPORTER_OTLP_ENDPOINT", - "value": "[parameters('otlpGrpcEndpoint')]" + "value": "[parameters('otlpHttpEndpoint')]" + } + ], + "sqlDbAppSettings": [ + { + "name": "Aspire__Microsoft__Data__SqlClient__ConnectionString", + "value": "[variables('sqlConnectionStringKeyVaultReference')]" + } + ], + "postgresDbAppSettings": [ + { + "name": "Aspire__Npgsql__ConnectionString", + "value": "[variables('postgresConnectionStringKeyVaultReference')]" } ] }, @@ -860,105 +1232,255 @@ "apiVersion": "2023-12-01", "name": "[format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", "location": "[parameters('location')]", - "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-api'))]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-api', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", + "identity": { + "type": "SystemAssigned" + }, "properties": { "serverFarmId": "[parameters('appServicePlanId')]", "httpsOnly": true, + "sshEnabled": false, + "endToEndEncryptionEnabled": true, "siteConfig": { - "linuxFxVersion": "DOTNET|8.0", + "linuxFxVersion": "[parameters('appServiceLinuxFxVersion')]", + "minTlsVersion": "1.3", + "minTlsCipherSuite": "TLS_AES_256_GCM_SHA384", + "scmMinTlsVersion": "1.3", + "netFrameworkVersion": "", + "ftpsState": "Disabled", + "http20Enabled": true, "alwaysOn": true, - "appSettings": "[variables('sharedAppSettings')]" + "appSettings": "[concat(variables('sharedAppSettings'), variables('postgresDbAppSettings'))]" } } }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" + ] + }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", "name": "[format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", "location": "[parameters('location')]", - "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-api'))]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-api', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", + "identity": { + "type": "SystemAssigned" + }, "properties": { "serverFarmId": "[parameters('appServicePlanId')]", "httpsOnly": true, + "sshEnabled": false, + "endToEndEncryptionEnabled": true, "siteConfig": { - "linuxFxVersion": "DOTNET|8.0", + "linuxFxVersion": "[parameters('appServiceLinuxFxVersion')]", + "minTlsVersion": "1.3", + "minTlsCipherSuite": "TLS_AES_256_GCM_SHA384", + "scmMinTlsVersion": "1.3", + "netFrameworkVersion": "", + "ftpsState": "Disabled", + "http20Enabled": true, "alwaysOn": true, - "appSettings": "[concat(variables('sharedAppSettings'), createArray(createObject('name', 'ProductsApi__BaseAddress', 'value', format('https://{0}', reference(resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01').defaultHostName))))]" + "appSettings": "[concat(variables('sharedAppSettings'), variables('sqlDbAppSettings'), createArray(createObject('name', 'ProductsApi__BaseAddress', 'value', format('https://{0}', reference(resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01').defaultHostName))))]" } }, "dependsOn": [ "[resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" ] }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" + ] + }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", "name": "[format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", "location": "[parameters('location')]", - "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-outbox-relay'))]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-outbox-relay', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", + "identity": { + "type": "SystemAssigned" + }, "properties": { "serverFarmId": "[parameters('appServicePlanId')]", "httpsOnly": true, + "sshEnabled": false, + "endToEndEncryptionEnabled": true, "siteConfig": { - "linuxFxVersion": "DOTNET|8.0", + "linuxFxVersion": "[parameters('appServiceLinuxFxVersion')]", + "minTlsVersion": "1.3", + "minTlsCipherSuite": "TLS_AES_256_GCM_SHA384", + "scmMinTlsVersion": "1.3", + "netFrameworkVersion": "", + "ftpsState": "Disabled", + "http20Enabled": true, "alwaysOn": true, - "appSettings": "[variables('sharedAppSettings')]" + "appSettings": "[concat(variables('sharedAppSettings'), variables('postgresDbAppSettings'))]" } } }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" + ] + }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", "name": "[format('app-shopping-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", "location": "[parameters('location')]", - "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-outbox-relay'))]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-outbox-relay', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", + "identity": { + "type": "SystemAssigned" + }, "properties": { "serverFarmId": "[parameters('appServicePlanId')]", "httpsOnly": true, + "sshEnabled": false, + "endToEndEncryptionEnabled": true, "siteConfig": { - "linuxFxVersion": "DOTNET|8.0", + "linuxFxVersion": "[parameters('appServiceLinuxFxVersion')]", + "minTlsVersion": "1.3", + "minTlsCipherSuite": "TLS_AES_256_GCM_SHA384", + "scmMinTlsVersion": "1.3", + "netFrameworkVersion": "", + "ftpsState": "Disabled", + "http20Enabled": true, "alwaysOn": true, - "appSettings": "[variables('sharedAppSettings')]" + "appSettings": "[concat(variables('sharedAppSettings'), variables('sqlDbAppSettings'))]" } } }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-shopping-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-shopping-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-shopping-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" + ] + }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", "name": "[format('app-products-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", "location": "[parameters('location')]", - "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-subscribe'))]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-subscribe', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", + "identity": { + "type": "SystemAssigned" + }, "properties": { "serverFarmId": "[parameters('appServicePlanId')]", "httpsOnly": true, + "sshEnabled": false, + "endToEndEncryptionEnabled": true, "siteConfig": { - "linuxFxVersion": "DOTNET|8.0", + "linuxFxVersion": "[parameters('appServiceLinuxFxVersion')]", + "minTlsVersion": "1.3", + "minTlsCipherSuite": "TLS_AES_256_GCM_SHA384", + "scmMinTlsVersion": "1.3", + "netFrameworkVersion": "", + "ftpsState": "Disabled", + "http20Enabled": true, "alwaysOn": true, - "appSettings": "[variables('sharedAppSettings')]" + "appSettings": "[concat(variables('sharedAppSettings'), variables('postgresDbAppSettings'))]" } } }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-products-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-products-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-products-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" + ] + }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", "name": "[format('app-shopping-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", "location": "[parameters('location')]", - "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-subscribe'))]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-subscribe', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", + "identity": { + "type": "SystemAssigned" + }, "properties": { "serverFarmId": "[parameters('appServicePlanId')]", "httpsOnly": true, + "sshEnabled": false, + "endToEndEncryptionEnabled": true, "siteConfig": { - "linuxFxVersion": "DOTNET|8.0", + "linuxFxVersion": "[parameters('appServiceLinuxFxVersion')]", + "minTlsVersion": "1.3", + "minTlsCipherSuite": "TLS_AES_256_GCM_SHA384", + "scmMinTlsVersion": "1.3", + "netFrameworkVersion": "", + "ftpsState": "Disabled", + "http20Enabled": true, "alwaysOn": true, - "appSettings": "[variables('sharedAppSettings')]" + "appSettings": "[concat(variables('sharedAppSettings'), variables('sqlDbAppSettings'))]" } } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", + "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-shopping-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", + "properties": { + "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-shopping-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/sites', format('app-shopping-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" + ] } ], "outputs": { @@ -993,9 +1515,8 @@ "[resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy')]", "[resourceId('Microsoft.Resources/deployments', 'appServicePlanDeploy')]", "[resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy')]", - "[resourceId('Microsoft.Resources/deployments', 'redisDeploy')]", - "[resourceId('Microsoft.Resources/deployments', 'serviceBusDeploy')]", - "[resourceId('Microsoft.Resources/deployments', 'sqlDeploy')]" + "[resourceId('Microsoft.Resources/deployments', 'keyVaultDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'redisDeploy')]" ] }, { @@ -1011,6 +1532,9 @@ "location": { "value": "[parameters('location')]" }, + "appServicePlanId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicePlanDeploy'), '2025-04-01').outputs.id.value]" + }, "environmentType": { "value": "[parameters('environmentType')]" }, @@ -1027,14 +1551,17 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "1908226983178580210" + "version": "0.43.8.12551", + "templateHash": "11051920204948211246" } }, "parameters": { "location": { "type": "string" }, + "appServicePlanId": { + "type": "string" + }, "environmentType": { "type": "string" }, @@ -1047,110 +1574,77 @@ } }, "variables": { - "dashboardName": "[format('aci-aspire-dashboard-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", - "dnsLabel": "[take(toLower(format('aspire{0}{1}{2}', parameters('environmentType'), parameters('suffix'), uniqueString(resourceGroup().id, deployment().name))), 63)]" + "dashboardName": "[format('app-aspire-dashboard-{0}-{1}', parameters('environmentType'), parameters('suffix'))]" }, "resources": [ { - "type": "Microsoft.ContainerInstance/containerGroups", - "apiVersion": "2023-05-01", + "type": "Microsoft.Web/sites", + "apiVersion": "2023-12-01", "name": "[variables('dashboardName')]", "location": "[parameters('location')]", "tags": "[union(parameters('tags'), createObject('role', 'aspire-dashboard'))]", + "kind": "app,linux,container", "properties": { - "osType": "Linux", - "restartPolicy": "Always", - "ipAddress": { - "type": "Public", - "dnsNameLabel": "[variables('dnsLabel')]", - "ports": [ + "serverFarmId": "[parameters('appServicePlanId')]", + "httpsOnly": true, + "siteConfig": { + "linuxFxVersion": "DOCKER|mcr.microsoft.com/dotnet/aspire-dashboard:latest", + "ftpsState": "Disabled", + "alwaysOn": true, + "http20Enabled": true, + "appSettings": [ + { + "name": "WEBSITES_PORT", + "value": "18888" + }, + { + "name": "ASPNETCORE_URLS", + "value": "http://0.0.0.0:18888" + }, { - "protocol": "TCP", - "port": 18888 + "name": "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", + "value": "http://0.0.0.0:18889" }, { - "protocol": "TCP", - "port": 18889 + "name": "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL", + "value": "http://0.0.0.0:18888" }, { - "protocol": "TCP", - "port": 18890 + "name": "DASHBOARD__UI__DISABLERESOURCEGRAPH", + "value": "true" } ] - }, - "containers": [ - { - "name": "aspire-dashboard", - "properties": { - "image": "mcr.microsoft.com/dotnet/aspire-dashboard:latest", - "environmentVariables": [ - { - "name": "ASPNETCORE_URLS", - "value": "http://0.0.0.0:18888" - }, - { - "name": "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL", - "value": "http://0.0.0.0:18889" - }, - { - "name": "ASPIRE_DASHBOARD_OTLP_HTTP_ENDPOINT_URL", - "value": "http://0.0.0.0:18890" - }, - { - "name": "DASHBOARD__UI__DISABLERESOURCEGRAPH", - "value": "true" - } - ], - "ports": [ - { - "protocol": "TCP", - "port": 18888 - }, - { - "protocol": "TCP", - "port": 18889 - }, - { - "protocol": "TCP", - "port": 18890 - } - ], - "resources": { - "requests": { - "cpu": 1, - "memoryInGB": 2 - } - } - } - } - ] + } } } ], "outputs": { "id": { "type": "string", - "value": "[resourceId('Microsoft.ContainerInstance/containerGroups', variables('dashboardName'))]" + "value": "[resourceId('Microsoft.Web/sites', variables('dashboardName'))]" }, - "containerGroupName": { + "appName": { "type": "string", "value": "[variables('dashboardName')]" }, "dashboardUri": { "type": "string", - "value": "[format('http://{0}:18888', reference(resourceId('Microsoft.ContainerInstance/containerGroups', variables('dashboardName')), '2023-05-01').ipAddress.fqdn)]" + "value": "[format('https://{0}', reference(resourceId('Microsoft.Web/sites', variables('dashboardName')), '2023-12-01').defaultHostName)]" }, "otlpGrpcEndpoint": { "type": "string", - "value": "[format('http://{0}:18889', reference(resourceId('Microsoft.ContainerInstance/containerGroups', variables('dashboardName')), '2023-05-01').ipAddress.fqdn)]" + "value": "[format('https://{0}', reference(resourceId('Microsoft.Web/sites', variables('dashboardName')), '2023-12-01').defaultHostName)]" }, "otlpHttpEndpoint": { "type": "string", - "value": "[format('http://{0}:18890', reference(resourceId('Microsoft.ContainerInstance/containerGroups', variables('dashboardName')), '2023-05-01').ipAddress.fqdn)]" + "value": "[format('https://{0}', reference(resourceId('Microsoft.Web/sites', variables('dashboardName')), '2023-12-01').defaultHostName)]" } } } - } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'appServicePlanDeploy')]" + ] } ], "outputs": { @@ -1182,6 +1676,14 @@ "type": "string", "value": "[reference(resourceId('Microsoft.Resources/deployments', 'sqlDeploy'), '2025-04-01').outputs.databaseName.value]" }, + "postgresServerName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'postgresDeploy'), '2025-04-01').outputs.serverName.value]" + }, + "postgresDatabaseName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'postgresDeploy'), '2025-04-01').outputs.databaseName.value]" + }, "productsApiAppName": { "type": "string", "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicesDeploy'), '2025-04-01').outputs.productsApiName.value]" @@ -1206,9 +1708,9 @@ "type": "string", "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appServicesDeploy'), '2025-04-01').outputs.shoppingSubscribeName.value]" }, - "aspireDashboardContainerGroupName": { + "aspireDashboardAppName": { "type": "string", - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.containerGroupName.value]" + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.appName.value]" }, "aspireDashboardUri": { "type": "string", diff --git a/azure/infra/modules/app-services.bicep b/azure/infra/modules/app-services.bicep index f0342145..52cc5b99 100644 --- a/azure/infra/modules/app-services.bicep +++ b/azure/infra/modules/app-services.bicep @@ -7,12 +7,20 @@ param tags object = {} param appInsightsConnectionString string param appInsightsResourceId string param appInsightsInstrumentationKey string -param sqlConnectionString string -param postgresConnectionString string +param keyVaultName string +param keyVaultUri string param redisConnectionString string -param serviceBusConnectionString string param otlpHttpEndpoint string +resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { + name: keyVaultName +} + +var keyVaultSecretsUserRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') +var sqlConnectionStringKeyVaultReference = '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/sql-connection-string/)' +var postgresConnectionStringKeyVaultReference = '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/postgres-connection-string/)' +var serviceBusConnectionStringKeyVaultReference = '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/service-bus-connection-string/)' + var sharedAppSettings = [ { name: 'ASPNETCORE_ENVIRONMENT' @@ -56,7 +64,7 @@ var sharedAppSettings = [ } { name: 'Aspire__Azure__Messaging__ServiceBus__ConnectionString' - value: serviceBusConnectionString + value: serviceBusConnectionStringKeyVaultReference } { name: 'OTEL_EXPORTER_OTLP_PROTOCOL' @@ -71,14 +79,14 @@ var sharedAppSettings = [ var sqlDbAppSettings = [ { name: 'Aspire__Microsoft__Data__SqlClient__ConnectionString' - value: sqlConnectionString + value: sqlConnectionStringKeyVaultReference } ] var postgresDbAppSettings = [ { name: 'Aspire__Npgsql__ConnectionString' - value: postgresConnectionString + value: postgresConnectionStringKeyVaultReference } ] @@ -90,6 +98,9 @@ resource productsApi 'Microsoft.Web/sites@2023-12-01' = { 'hidden-link: /app-insights-resource-id': appInsightsResourceId }) kind: 'app,linux' + identity: { + type: 'SystemAssigned' + } properties: { serverFarmId: appServicePlanId httpsOnly: true @@ -109,6 +120,16 @@ resource productsApi 'Microsoft.Web/sites@2023-12-01' = { } } +resource productsApiKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, productsApi.id, 'KeyVaultSecretsUser') + scope: keyVault + properties: { + principalId: productsApi.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleDefinitionId + } +} + resource shoppingApi 'Microsoft.Web/sites@2023-12-01' = { name: 'app-shopping-api-${environmentType}-${suffix}' location: location @@ -117,6 +138,9 @@ resource shoppingApi 'Microsoft.Web/sites@2023-12-01' = { 'hidden-link: /app-insights-resource-id': appInsightsResourceId }) kind: 'app,linux' + identity: { + type: 'SystemAssigned' + } properties: { serverFarmId: appServicePlanId httpsOnly: true @@ -141,6 +165,16 @@ resource shoppingApi 'Microsoft.Web/sites@2023-12-01' = { } } +resource shoppingApiKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, shoppingApi.id, 'KeyVaultSecretsUser') + scope: keyVault + properties: { + principalId: shoppingApi.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleDefinitionId + } +} + resource productsOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { name: 'app-products-outbox-relay-${environmentType}-${suffix}' location: location @@ -149,6 +183,9 @@ resource productsOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { 'hidden-link: /app-insights-resource-id': appInsightsResourceId }) kind: 'app,linux' + identity: { + type: 'SystemAssigned' + } properties: { serverFarmId: appServicePlanId httpsOnly: true @@ -168,6 +205,16 @@ resource productsOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { } } +resource productsOutboxRelayKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, productsOutboxRelay.id, 'KeyVaultSecretsUser') + scope: keyVault + properties: { + principalId: productsOutboxRelay.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleDefinitionId + } +} + resource shoppingOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { name: 'app-shopping-outbox-relay-${environmentType}-${suffix}' location: location @@ -176,6 +223,9 @@ resource shoppingOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { 'hidden-link: /app-insights-resource-id': appInsightsResourceId }) kind: 'app,linux' + identity: { + type: 'SystemAssigned' + } properties: { serverFarmId: appServicePlanId httpsOnly: true @@ -195,6 +245,16 @@ resource shoppingOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { } } +resource shoppingOutboxRelayKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, shoppingOutboxRelay.id, 'KeyVaultSecretsUser') + scope: keyVault + properties: { + principalId: shoppingOutboxRelay.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleDefinitionId + } +} + resource productsSubscribe 'Microsoft.Web/sites@2023-12-01' = { name: 'app-products-subscribe-${environmentType}-${suffix}' location: location @@ -203,6 +263,9 @@ resource productsSubscribe 'Microsoft.Web/sites@2023-12-01' = { 'hidden-link: /app-insights-resource-id': appInsightsResourceId }) kind: 'app,linux' + identity: { + type: 'SystemAssigned' + } properties: { serverFarmId: appServicePlanId httpsOnly: true @@ -222,6 +285,16 @@ resource productsSubscribe 'Microsoft.Web/sites@2023-12-01' = { } } +resource productsSubscribeKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, productsSubscribe.id, 'KeyVaultSecretsUser') + scope: keyVault + properties: { + principalId: productsSubscribe.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleDefinitionId + } +} + resource shoppingSubscribe 'Microsoft.Web/sites@2023-12-01' = { name: 'app-shopping-subscribe-${environmentType}-${suffix}' location: location @@ -230,6 +303,9 @@ resource shoppingSubscribe 'Microsoft.Web/sites@2023-12-01' = { 'hidden-link: /app-insights-resource-id': appInsightsResourceId }) kind: 'app,linux' + identity: { + type: 'SystemAssigned' + } properties: { serverFarmId: appServicePlanId httpsOnly: true @@ -249,6 +325,16 @@ resource shoppingSubscribe 'Microsoft.Web/sites@2023-12-01' = { } } +resource shoppingSubscribeKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(keyVault.id, shoppingSubscribe.id, 'KeyVaultSecretsUser') + scope: keyVault + properties: { + principalId: shoppingSubscribe.identity.principalId + principalType: 'ServicePrincipal' + roleDefinitionId: keyVaultSecretsUserRoleDefinitionId + } +} + output productsApiName string = productsApi.name output shoppingApiName string = shoppingApi.name output productsOutboxRelayName string = productsOutboxRelay.name diff --git a/azure/infra/scripts/store-secrets.ps1 b/azure/infra/scripts/store-secrets.ps1 index 83a3534a..132e497d 100644 --- a/azure/infra/scripts/store-secrets.ps1 +++ b/azure/infra/scripts/store-secrets.ps1 @@ -20,6 +20,7 @@ $sqlDb = if ([string]::IsNullOrWhiteSpace($env:AZURE_SQL_DB_NAME)) { (azd $postgresServer = if ([string]::IsNullOrWhiteSpace($env:AZURE_POSTGRES_SERVER)) { (azd env get-value postgresServerName).Trim() } else { $env:AZURE_POSTGRES_SERVER } $postgresLogin = if ([string]::IsNullOrWhiteSpace($env:AZURE_POSTGRES_ADMIN_LOGIN)) { 'coreexpgadmin' } else { $env:AZURE_POSTGRES_ADMIN_LOGIN } $postgresDb = if ([string]::IsNullOrWhiteSpace($env:AZURE_POSTGRES_DB_NAME)) { (azd env get-value postgresDatabaseName).Trim() } else { $env:AZURE_POSTGRES_DB_NAME } +$keyVaultReferenceWaitSeconds = if ([string]::IsNullOrWhiteSpace($env:AZD_KEYVAULT_REFERENCE_WAIT_SECONDS)) { 60 } else { [int]$env:AZD_KEYVAULT_REFERENCE_WAIT_SECONDS } if ([string]::IsNullOrWhiteSpace($sqlServer)) { throw 'AZURE_SQL_SERVER (or azd output sqlServerName) is not set.' @@ -91,3 +92,8 @@ Write-Host " - postgres-admin-password" Write-Host " - sql-connection-string" Write-Host " - postgres-connection-string" Write-Host " - service-bus-connection-string" + +if ($keyVaultReferenceWaitSeconds -gt 0) { + Write-Host "Waiting $keyVaultReferenceWaitSeconds seconds for Key Vault reference RBAC propagation before deploy." + Start-Sleep -Seconds $keyVaultReferenceWaitSeconds +} diff --git a/azure/infra/scripts/store-secrets.sh b/azure/infra/scripts/store-secrets.sh index 4dd8a870..8ebcb5b2 100755 --- a/azure/infra/scripts/store-secrets.sh +++ b/azure/infra/scripts/store-secrets.sh @@ -12,6 +12,7 @@ sql_db="${AZURE_SQL_DB_NAME:-${sqlDatabaseName:-}}" postgres_server="${AZURE_POSTGRES_SERVER:-${postgresServerName:-}}" postgres_login="${AZURE_POSTGRES_ADMIN_LOGIN:-coreexpgadmin}" postgres_db="${AZURE_POSTGRES_DB_NAME:-${postgresDatabaseName:-}}" +keyvault_reference_wait_seconds="${AZD_KEYVAULT_REFERENCE_WAIT_SECONDS:-60}" if [[ -z "${sql_server}" ]]; then echo "AZURE_SQL_SERVER (or azd output sqlServerName) is not set." >&2 @@ -106,3 +107,8 @@ echo " - postgres-admin-password" echo " - sql-connection-string" echo " - postgres-connection-string" echo " - service-bus-connection-string" + +if [[ "${keyvault_reference_wait_seconds}" =~ ^[0-9]+$ ]] && (( keyvault_reference_wait_seconds > 0 )); then + echo "Waiting ${keyvault_reference_wait_seconds}s for Key Vault reference RBAC propagation before deploy." + sleep "${keyvault_reference_wait_seconds}" +fi diff --git a/azure/scripts/refresh-keyvault-appsettings.ps1 b/azure/scripts/refresh-keyvault-appsettings.ps1 new file mode 100644 index 00000000..d76ec784 --- /dev/null +++ b/azure/scripts/refresh-keyvault-appsettings.ps1 @@ -0,0 +1,56 @@ +#Requires -Version 7 +[CmdletBinding()] +param ( + [Alias('g')] + [Parameter(Mandatory)] + [string] $ResourceGroup, + + [Alias('n')] + [string[]] $AppName, + + [Alias('w')] + [ValidateRange(0, 600)] + [int] $WaitAfterRestartSeconds = 20 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw "Azure CLI 'az' is not installed or not on PATH." +} + +$appNames = @() +if ($AppName -and $AppName.Count -gt 0) { + $appNames = $AppName +} +else { + $appNames = @(az webapp list --resource-group $ResourceGroup --query '[].name' -o tsv) +} + +$appNames = @($appNames | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + +if ($appNames.Count -eq 0) { + throw "No web apps found in resource group '$ResourceGroup'." +} + +foreach ($app in $appNames) { + Write-Host "Refreshing Key Vault app settings references for '$app'." + + $appId = (az webapp show --resource-group $ResourceGroup --name $app --query id -o tsv) + if ([string]::IsNullOrWhiteSpace($appId)) { + throw "Unable to resolve app id for '$app'." + } + + az rest --method post --url "https://management.azure.com${appId}/config/configreferences/appsettings/refresh?api-version=2023-12-01" --output none + + Write-Host "Restarting '$app'." + az webapp restart --resource-group $ResourceGroup --name $app --output none + + if ($WaitAfterRestartSeconds -gt 0) { + Write-Host "Waiting $WaitAfterRestartSeconds seconds for '$app' startup." + Start-Sleep -Seconds $WaitAfterRestartSeconds + } +} + +Write-Host "Completed Key Vault app settings refresh for $($appNames.Count) app(s)." diff --git a/azure/scripts/refresh-keyvault-appsettings.sh b/azure/scripts/refresh-keyvault-appsettings.sh new file mode 100644 index 00000000..709954cc --- /dev/null +++ b/azure/scripts/refresh-keyvault-appsettings.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: + ./scripts/refresh-keyvault-appsettings.sh --resource-group [--app-name ]... [--wait-after-restart-seconds ] + +Description: + Refreshes App Service Key Vault app setting references and restarts web apps. + When no app names are supplied, all web apps in the resource group are targeted. + +Options: + --resource-group, -g Azure resource group name (required). + --app-name, -n Specific web app name to refresh; repeat for multiple apps. + --wait-after-restart-seconds, -w Delay after each restart (default: 20). + --help, -h Show this help. +EOF +} + +resource_group="" +wait_after_restart_seconds="20" +declare -a app_names=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --resource-group|-g) + resource_group="${2:-}" + shift 2 + ;; + --app-name|-n) + app_names+=("${2:-}") + shift 2 + ;; + --wait-after-restart-seconds|-w) + wait_after_restart_seconds="${2:-}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage + exit 1 + ;; + esac +done + +if [[ -z "${resource_group}" ]]; then + echo "Missing required argument: --resource-group" >&2 + usage + exit 1 +fi + +if ! command -v az >/dev/null 2>&1; then + echo "Azure CLI 'az' is not installed or not on PATH." >&2 + exit 1 +fi + +if [[ ! "${wait_after_restart_seconds}" =~ ^[0-9]+$ ]]; then + echo "--wait-after-restart-seconds must be a non-negative integer." >&2 + exit 1 +fi + +if [[ ${#app_names[@]} -eq 0 ]]; then + mapfile -t app_names < <(az webapp list --resource-group "${resource_group}" --query "[].name" -o tsv) +fi + +if [[ ${#app_names[@]} -eq 0 ]]; then + echo "No web apps found in resource group '${resource_group}'." >&2 + exit 1 +fi + +for app_name in "${app_names[@]}"; do + if [[ -z "${app_name}" ]]; then + continue + fi + + echo "Refreshing Key Vault app settings references for '${app_name}'." + app_id="$(az webapp show --resource-group "${resource_group}" --name "${app_name}" --query id -o tsv)" + + if [[ -z "${app_id}" ]]; then + echo "Unable to resolve app id for '${app_name}'." >&2 + exit 1 + fi + + az rest \ + --method post \ + --url "https://management.azure.com${app_id}/config/configreferences/appsettings/refresh?api-version=2023-12-01" \ + --output none + + echo "Restarting '${app_name}'." + az webapp restart --resource-group "${resource_group}" --name "${app_name}" --output none + + if (( wait_after_restart_seconds > 0 )); then + echo "Waiting ${wait_after_restart_seconds}s for '${app_name}' startup." + sleep "${wait_after_restart_seconds}" + fi +done + +echo "Completed Key Vault app settings refresh for ${#app_names[@]} app(s)." From 31868c56e5fdaaa857ab474fcd13757cadb7c1db Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Thu, 21 May 2026 09:00:02 -0700 Subject: [PATCH 11/14] reverting use of keyvault for webapps & fixing keyvault reuse Signed-off-by: Aaron Spruit --- azure/AGENTS.md | 4 +- azure/README.md | 2 +- azure/infra/main.bicep | 8 ++- azure/infra/main.dev.parameters.json | 3 + azure/infra/main.json | 77 +++++++++++--------------- azure/infra/scripts/use-dev-params.ps1 | 24 ++++++++ azure/infra/scripts/use-dev-params.sh | 22 +++++++- 7 files changed, 88 insertions(+), 52 deletions(-) diff --git a/azure/AGENTS.md b/azure/AGENTS.md index 8a33e6c5..62a6a06e 100644 --- a/azure/AGENTS.md +++ b/azure/AGENTS.md @@ -16,7 +16,7 @@ This file applies to anything under `azure/`. For application code, see the rele ## Folder layout -- [azure.yaml](azure.yaml) — azd project manifest. Declares the 6 services and the pre/post hooks. +- [azure.yaml](azure.yaml) — azd project manifest. Declares the 8 services and the pre/post hooks. - [infra/](infra/) — Bicep templates (primary IaC for `azd`). - [infra/main.bicep](infra/main.bicep) — Entry template. - [infra/modules/](infra/modules/) — Per-resource modules (`app-service-plan`, `app-services`, `aspire-dashboard`, `database`, `postgres-database`, `service-bus`, `redis`, `key-vault`, `application-insights`). @@ -29,7 +29,7 @@ This file applies to anything under `azure/`. For application code, see the rele The Bicep deployment provisions the following resource set: - Linux App Service Plan. -- 7 Web Apps: `aspire-dashboard`, `products-api`, `shopping-api`, `products-outbox-relay`, `shopping-outbox-relay`, `products-subscribe`, `shopping-subscribe`. +- 9 Web Apps: `aspire-dashboard`, `products-api`, `shopping-api`, `orders-api`, `order-workflow-worker`, `products-outbox-relay`, `shopping-outbox-relay`, `products-subscribe`, `shopping-subscribe`. - Azure SQL Server + Database (Shopping and Orders domains, with firewall rules). - Azure Database for PostgreSQL Flexible Server + Database (Products domain). - Azure Service Bus (Standard) — namespace + topic + subscriptions. diff --git a/azure/README.md b/azure/README.md index f94b0bee..1ca36832 100644 --- a/azure/README.md +++ b/azure/README.md @@ -328,7 +328,7 @@ Edit [../samples/tests/Contoso.E2E.Runner/appsettings.json](../samples/tests/Con "E2E": { "Products": { "BaseAddress": "https://app-products-api-dev-{suffix}.azurewebsites.net", - "ConnectionString": "Server=pg-dev-{suffix}.postgres.database.azure.com;Port=5432;Database=coreexdev;User Id={postgres-admin};Password={password};Ssl Mode=Require" + "ConnectionString": "Server=pg-dev-{suffix}.postgres.database.azure.com;Port=5432;Database=coreexdev;User Id={postgres-admin};Password={password};Ssl Mode=Require;" }, "Shopping": { "BaseAddress": "https://app-shopping-api-dev-{suffix}.azurewebsites.net", diff --git a/azure/infra/main.bicep b/azure/infra/main.bicep index 662cf7ab..0a484b89 100644 --- a/azure/infra/main.bicep +++ b/azure/infra/main.bicep @@ -14,6 +14,9 @@ param location string = resourceGroup().location @description('Unique suffix for globally unique resource names. Use a short lowercase token, e.g. a1b2c3.') param nameSuffix string +@description('Optional Key Vault name override. When empty, a unique Key Vault name is generated.') +param keyVaultName string = '' + @description('Tags applied to all resources.') param tags object = {} @@ -93,7 +96,8 @@ param redisSkuName string param redisHighAvailability string var suffix = toLower(nameSuffix) -var keyVaultName = take('kv${environmentType}${suffix}${uniqueString(deployment().name, resourceGroup().id)}', 24) +var computedKeyVaultName = take('kv${environmentType}${suffix}${uniqueString(deployment().name, resourceGroup().id)}', 24) +var resolvedKeyVaultName = empty(keyVaultName) ? computedKeyVaultName : keyVaultName var mergedTags = union(tags, { environment: environmentType managedBy: 'azd' @@ -113,7 +117,7 @@ module keyVault './modules/key-vault.bicep' = { name: 'keyVaultDeploy' params: { location: location - name: keyVaultName + name: resolvedKeyVaultName tags: mergedTags } } diff --git a/azure/infra/main.dev.parameters.json b/azure/infra/main.dev.parameters.json index c89df79f..de3f7f06 100644 --- a/azure/infra/main.dev.parameters.json +++ b/azure/infra/main.dev.parameters.json @@ -11,6 +11,9 @@ "nameSuffix": { "value": "dev01" }, + "keyVaultName": { + "value": "__KEY_VAULT_NAME__" + }, "tags": { "value": { "workload": "coreex", diff --git a/azure/infra/main.json b/azure/infra/main.json index dcf3ef0d..13dac5d2 100644 --- a/azure/infra/main.json +++ b/azure/infra/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.43.8.12551", - "templateHash": "9143201238151831317" + "templateHash": "5283003750108238735" } }, "parameters": { @@ -33,6 +33,13 @@ "description": "Unique suffix for globally unique resource names. Use a short lowercase token, e.g. a1b2c3." } }, + "keyVaultName": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional Key Vault name override. When empty, a unique Key Vault name is generated." + } + }, "tags": { "type": "object", "defaultValue": {}, @@ -187,7 +194,8 @@ }, "variables": { "suffix": "[toLower(parameters('nameSuffix'))]", - "keyVaultName": "[take(format('kv{0}{1}{2}', parameters('environmentType'), variables('suffix'), uniqueString(deployment().name, resourceGroup().id)), 24)]", + "computedKeyVaultName": "[take(format('kv{0}{1}{2}', parameters('environmentType'), variables('suffix'), uniqueString(deployment().name, resourceGroup().id)), 24)]", + "resolvedKeyVaultName": "[if(empty(parameters('keyVaultName')), variables('computedKeyVaultName'), parameters('keyVaultName'))]", "mergedTags": "[union(parameters('tags'), createObject('environment', parameters('environmentType'), 'managedBy', 'azd', 'azd-env-name', parameters('environmentType')))]" }, "resources": [ @@ -283,7 +291,7 @@ "value": "[parameters('location')]" }, "name": { - "value": "[variables('keyVaultName')]" + "value": "[variables('resolvedKeyVaultName')]" }, "tags": { "value": "[variables('mergedTags')]" @@ -1089,15 +1097,18 @@ "appInsightsInstrumentationKey": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy'), '2025-04-01').outputs.instrumentationKey.value]" }, - "keyVaultName": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'keyVaultDeploy'), '2025-04-01').outputs.name.value]" + "sqlConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'sqlDeploy'), '2025-04-01').outputs.connectionString.value]" }, - "keyVaultUri": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'keyVaultDeploy'), '2025-04-01').outputs.vaultUri.value]" + "postgresConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'postgresDeploy'), '2025-04-01').outputs.connectionString.value]" }, "redisConnectionString": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'redisDeploy'), '2025-04-01').outputs.connectionString.value]" }, + "serviceBusConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'serviceBusDeploy'), '2025-04-01').outputs.connectionString.value]" + }, "otlpHttpEndpoint": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.otlpHttpEndpoint.value]" } @@ -1109,7 +1120,7 @@ "_generator": { "name": "bicep", "version": "0.43.8.12551", - "templateHash": "776203396049316067" + "templateHash": "4869075254663625650" } }, "parameters": { @@ -1141,15 +1152,18 @@ "appInsightsInstrumentationKey": { "type": "string" }, - "keyVaultName": { + "sqlConnectionString": { "type": "string" }, - "keyVaultUri": { + "postgresConnectionString": { "type": "string" }, "redisConnectionString": { "type": "string" }, + "serviceBusConnectionString": { + "type": "string" + }, "otlpHttpEndpoint": { "type": "string" } @@ -1216,13 +1230,13 @@ "sqlDbAppSettings": [ { "name": "Aspire__Microsoft__Data__SqlClient__ConnectionString", - "value": "[variables('sqlConnectionStringKeyVaultReference')]" + "value": "[parameters('sqlConnectionString')]" } ], "postgresDbAppSettings": [ { "name": "Aspire__Npgsql__ConnectionString", - "value": "[variables('postgresConnectionStringKeyVaultReference')]" + "value": "[parameters('postgresConnectionString')]" } ] }, @@ -1255,20 +1269,6 @@ } } }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", - "properties": { - "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" - ] - }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", @@ -1276,9 +1276,6 @@ "location": "[parameters('location')]", "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-api', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", - "identity": { - "type": "SystemAssigned" - }, "properties": { "serverFarmId": "[parameters('appServicePlanId')]", "httpsOnly": true, @@ -1300,24 +1297,10 @@ "[resourceId('Microsoft.Web/sites', format('app-products-api-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" ] }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", - "properties": { - "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" - ] - }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", - "name": "[format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", + "name": "[format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", "location": "[parameters('location')]", "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-outbox-relay', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", @@ -1515,8 +1498,10 @@ "[resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy')]", "[resourceId('Microsoft.Resources/deployments', 'appServicePlanDeploy')]", "[resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy')]", - "[resourceId('Microsoft.Resources/deployments', 'keyVaultDeploy')]", - "[resourceId('Microsoft.Resources/deployments', 'redisDeploy')]" + "[resourceId('Microsoft.Resources/deployments', 'postgresDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'redisDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'serviceBusDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'sqlDeploy')]" ] }, { diff --git a/azure/infra/scripts/use-dev-params.ps1 b/azure/infra/scripts/use-dev-params.ps1 index 5cb25b12..1e8dac92 100644 --- a/azure/infra/scripts/use-dev-params.ps1 +++ b/azure/infra/scripts/use-dev-params.ps1 @@ -15,6 +15,10 @@ if ([string]::IsNullOrWhiteSpace($env:AZURE_LOCATION)) { throw "AZURE_LOCATION is not set. Set it via 'azd env set AZURE_LOCATION ' before running azd provision." } +if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + throw "The 'az' command is required to validate an existing Key Vault from the azd environment." +} + $clientIp = '' foreach ($ipLookup in @('https://api.ipify.org', 'https://ifconfig.me/ip')) { try { @@ -54,6 +58,24 @@ $appServiceLinuxFxVersion = switch ($targetFramework) { default { throw "Unsupported target framework '$targetFramework'. Expected net8.0, net9.0, or net10.0." } } +$keyVaultName = '' +try { + $existingKeyVaultName = (azd env get-value keyVaultName 2>$null).Trim() +} +catch { + $existingKeyVaultName = '' +} +if (-not [string]::IsNullOrWhiteSpace($existingKeyVaultName)) { + az keyvault show --name $existingKeyVaultName --query name -o tsv *> $null + if ($LASTEXITCODE -eq 0) { + $keyVaultName = $existingKeyVaultName + Write-Host "Reusing existing Key Vault '$keyVaultName' from azd environment." + } + else { + Write-Warning "Key Vault '$existingKeyVaultName' from azd environment was not found. A new Key Vault will be provisioned." + } +} + $templatePath = Join-Path $infraDir 'main.dev.parameters.json' $outputPath = Join-Path $infraDir 'main.parameters.json' @@ -66,6 +88,7 @@ try { $json.parameters.appServiceLinuxFxVersion.value = $appServiceLinuxFxVersion $json.parameters.sqlFirewallClientIp.value = $clientIp $json.parameters.postgresFirewallClientIp.value = $clientIp + $json.parameters.keyVaultName.value = $keyVaultName $json | ConvertTo-Json -Depth 100 | Set-Content -Path $outputPath -NoNewline } catch { # Fallback: direct string replacement @@ -75,5 +98,6 @@ try { $content = $content.Replace('__AZURE_POSTGRES_ADMIN_PASSWORD__', $env:AZURE_POSTGRES_ADMIN_PASSWORD) $content = $content.Replace('__APP_SERVICE_LINUX_FX_VERSION__', $appServiceLinuxFxVersion) $content = $content.Replace('__AZURE_CLIENT_IP__', $clientIp) + $content = $content.Replace('__KEY_VAULT_NAME__', $keyVaultName) Set-Content -Path $outputPath -Value $content -NoNewline } diff --git a/azure/infra/scripts/use-dev-params.sh b/azure/infra/scripts/use-dev-params.sh index ba55c81e..566f53d0 100755 --- a/azure/infra/scripts/use-dev-params.sh +++ b/azure/infra/scripts/use-dev-params.sh @@ -18,6 +18,11 @@ if [[ -z "${AZURE_LOCATION:-}" ]]; then exit 1 fi +if ! command -v az >/dev/null 2>&1; then + echo "The 'az' command is required to validate an existing Key Vault from the azd environment." >&2 + exit 1 +fi + client_ip="$(curl -fsS https://api.ipify.org 2>/dev/null || true)" if [[ -z "${client_ip}" ]]; then client_ip="$(curl -fsS https://ifconfig.me/ip 2>/dev/null || true)" @@ -50,6 +55,17 @@ case "${target_framework}" in ;; esac +key_vault_name="" +existing_key_vault_name="$(azd env get-value keyVaultName 2>/dev/null | tr -d '\r' || true)" +if [[ -n "${existing_key_vault_name}" ]]; then + if az keyvault show --name "${existing_key_vault_name}" --query name -o tsv >/dev/null 2>&1; then + key_vault_name="${existing_key_vault_name}" + echo "Reusing existing Key Vault '${key_vault_name}' from azd environment." + else + echo "Key Vault '${existing_key_vault_name}' from azd environment was not found. A new Key Vault will be provisioned." >&2 + fi +fi + # Use jq if available for safe JSON processing, otherwise fallback to sed if command -v jq &> /dev/null; then jq \ @@ -58,7 +74,8 @@ if command -v jq &> /dev/null; then --arg loc "${AZURE_LOCATION}" \ --arg fx "${app_service_linux_fx_version}" \ --arg ip "${client_ip}" \ - '.parameters.location.value = $loc | .parameters.sqlAdminPassword.value = $pwd | .parameters.postgresAdminPassword.value = $pgpwd | .parameters.appServiceLinuxFxVersion.value = $fx | .parameters.sqlFirewallClientIp.value = $ip | .parameters.postgresFirewallClientIp.value = $ip' \ + --arg kv "${key_vault_name}" \ + '.parameters.location.value = $loc | .parameters.sqlAdminPassword.value = $pwd | .parameters.postgresAdminPassword.value = $pgpwd | .parameters.appServiceLinuxFxVersion.value = $fx | .parameters.sqlFirewallClientIp.value = $ip | .parameters.postgresFirewallClientIp.value = $ip | .parameters.keyVaultName.value = $kv' \ "${infra_dir}/main.dev.parameters.json" > "${infra_dir}/main.parameters.json" else # Fallback: use printf to safely escape and substitute @@ -72,11 +89,14 @@ else escaped_fx=${escaped_fx%\\} escaped_ip=$(printf '%s\n' "${client_ip}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') escaped_ip=${escaped_ip%\\} + escaped_key_vault_name=$(printf '%s\n' "${key_vault_name}" | sed -e 's/[&/\\]/\\&/g; s/$/\\/') + escaped_key_vault_name=${escaped_key_vault_name%\\} sed \ -e "s/__AZURE_LOCATION__/${escaped_location}/g" \ -e "s/__AZURE_SQL_ADMIN_PASSWORD__/${escaped_password}/g" \ -e "s/__AZURE_POSTGRES_ADMIN_PASSWORD__/${escaped_postgres_password}/g" \ -e "s/__APP_SERVICE_LINUX_FX_VERSION__/${escaped_fx}/g" \ -e "s/__AZURE_CLIENT_IP__/${escaped_ip}/g" \ + -e "s/__KEY_VAULT_NAME__/${escaped_key_vault_name}/g" \ "${infra_dir}/main.dev.parameters.json" > "${infra_dir}/main.parameters.json" fi From 8f52d9f5a8b1731d81ad8d9b435b1dbd70794b75 Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Thu, 21 May 2026 09:49:14 -0700 Subject: [PATCH 12/14] refactor: remove Key Vault references and update connection strings in Bicep and scripts Signed-off-by: Aaron Spruit --- azure/AGENTS.md | 2 +- azure/README.md | 28 +---- azure/infra/main.bicep | 5 +- azure/infra/main.json | 81 ++------------ azure/infra/modules/app-services.bicep | 80 +------------- .../scripts/refresh-keyvault-appsettings.ps1 | 56 ---------- azure/scripts/refresh-keyvault-appsettings.sh | 103 ------------------ azure/scripts/setup-e2e-runner.ps1 | 32 ++++-- 8 files changed, 47 insertions(+), 340 deletions(-) delete mode 100644 azure/scripts/refresh-keyvault-appsettings.ps1 delete mode 100644 azure/scripts/refresh-keyvault-appsettings.sh diff --git a/azure/AGENTS.md b/azure/AGENTS.md index 62a6a06e..30ee2c33 100644 --- a/azure/AGENTS.md +++ b/azure/AGENTS.md @@ -29,7 +29,7 @@ This file applies to anything under `azure/`. For application code, see the rele The Bicep deployment provisions the following resource set: - Linux App Service Plan. -- 9 Web Apps: `aspire-dashboard`, `products-api`, `shopping-api`, `orders-api`, `order-workflow-worker`, `products-outbox-relay`, `shopping-outbox-relay`, `products-subscribe`, `shopping-subscribe`. +- 7 Web Apps: `aspire-dashboard`, `products-api`, `shopping-api`, `products-outbox-relay`, `shopping-outbox-relay`, `products-subscribe`, `shopping-subscribe`. - Azure SQL Server + Database (Shopping and Orders domains, with firewall rules). - Azure Database for PostgreSQL Flexible Server + Database (Products domain). - Azure Service Bus (Standard) — namespace + topic + subscriptions. diff --git a/azure/README.md b/azure/README.md index 1ca36832..c84e1b11 100644 --- a/azure/README.md +++ b/azure/README.md @@ -143,22 +143,6 @@ Re-run infra only: azd provision --no-prompt ``` -If apps that use Key Vault-backed app settings fail on startup immediately after deploy, refresh Key Vault app setting references and restart the web apps: - -```bash -./scripts/refresh-keyvault-appsettings.sh --resource-group -``` - -Optional arguments: - -```bash -./scripts/refresh-keyvault-appsettings.sh \ - --resource-group \ - --app-name \ - --app-name \ - --wait-after-restart-seconds 20 -``` - ## Accessing the Aspire Dashboard After deployment, the Aspire Dashboard is publicly accessible from a dedicated HTTPS-enabled App Service. The six deployed services are configured to export OTLP telemetry to it automatically. @@ -352,16 +336,6 @@ This launches an interactive CLI menu to select and execute test scenarios or ru ## Troubleshooting -Key Vault app setting reference timing: - -- Symptom: app startup fails with invalid connection string errors (for example Service Bus) immediately after `azd up`. -- Cause: app setting reference resolution can lag after role assignment and secret updates. -- Recommended fix: - -```bash -./scripts/refresh-keyvault-appsettings.sh --resource-group -``` - No project exists / run azd init: ```bash @@ -387,7 +361,7 @@ PostgreSQL password missing: Multi-target publish error (NETSDK1129): - Ensure `AZD_DOTNET_TARGET_FRAMEWORK` is set in your azd environment: `azd env set AZD_DOTNET_TARGET_FRAMEWORK net10.0`. -- Load it into your current shell: `et -a && eval "$(azd env get-values)" && set +a`. +- Load it into your current shell: `set -a && eval "$(azd env get-values)" && set +a`. `command not found` while loading environment values: - This usually means `azd env get-values` was run outside the azd project folder and returned `ERROR: no project exists...`. diff --git a/azure/infra/main.bicep b/azure/infra/main.bicep index 0a484b89..7e72e588 100644 --- a/azure/infra/main.bicep +++ b/azure/infra/main.bicep @@ -201,8 +201,9 @@ module appServices './modules/app-services.bicep' = { appInsightsConnectionString: appInsights.outputs.connectionString appInsightsResourceId: appInsights.outputs.id appInsightsInstrumentationKey: appInsights.outputs.instrumentationKey - keyVaultName: keyVault.outputs.name - keyVaultUri: keyVault.outputs.vaultUri + sqlConnectionString: sql.outputs.connectionString + postgresConnectionString: postgres.outputs.connectionString + serviceBusConnectionString: serviceBus.outputs.connectionString redisConnectionString: redis.outputs.connectionString otlpHttpEndpoint: aspireDashboard.outputs.otlpHttpEndpoint } diff --git a/azure/infra/main.json b/azure/infra/main.json index 13dac5d2..149e2ce3 100644 --- a/azure/infra/main.json +++ b/azure/infra/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.43.8.12551", - "templateHash": "5283003750108238735" + "templateHash": "11663173647746982515" } }, "parameters": { @@ -1103,12 +1103,12 @@ "postgresConnectionString": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'postgresDeploy'), '2025-04-01').outputs.connectionString.value]" }, - "redisConnectionString": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'redisDeploy'), '2025-04-01').outputs.connectionString.value]" - }, "serviceBusConnectionString": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'serviceBusDeploy'), '2025-04-01').outputs.connectionString.value]" }, + "redisConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'redisDeploy'), '2025-04-01').outputs.connectionString.value]" + }, "otlpHttpEndpoint": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy'), '2025-04-01').outputs.otlpHttpEndpoint.value]" } @@ -1120,7 +1120,7 @@ "_generator": { "name": "bicep", "version": "0.43.8.12551", - "templateHash": "4869075254663625650" + "templateHash": "3293862563743385029" } }, "parameters": { @@ -1158,10 +1158,10 @@ "postgresConnectionString": { "type": "string" }, - "redisConnectionString": { + "serviceBusConnectionString": { "type": "string" }, - "serviceBusConnectionString": { + "redisConnectionString": { "type": "string" }, "otlpHttpEndpoint": { @@ -1169,10 +1169,6 @@ } }, "variables": { - "keyVaultSecretsUserRoleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6')]", - "sqlConnectionStringKeyVaultReference": "[format('@Microsoft.KeyVault(SecretUri={0}secrets/sql-connection-string/)', parameters('keyVaultUri'))]", - "postgresConnectionStringKeyVaultReference": "[format('@Microsoft.KeyVault(SecretUri={0}secrets/postgres-connection-string/)', parameters('keyVaultUri'))]", - "serviceBusConnectionStringKeyVaultReference": "[format('@Microsoft.KeyVault(SecretUri={0}secrets/service-bus-connection-string/)', parameters('keyVaultUri'))]", "sharedAppSettings": [ { "name": "ASPNETCORE_ENVIRONMENT", @@ -1216,7 +1212,7 @@ }, { "name": "Aspire__Azure__Messaging__ServiceBus__ConnectionString", - "value": "[variables('serviceBusConnectionStringKeyVaultReference')]" + "value": "[parameters('serviceBusConnectionString')]" }, { "name": "OTEL_EXPORTER_OTLP_PROTOCOL", @@ -1276,6 +1272,9 @@ "location": "[parameters('location')]", "tags": "[union(parameters('tags'), createObject('azd-service-name', 'shopping-api', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", + "identity": { + "type": "SystemAssigned" + }, "properties": { "serverFarmId": "[parameters('appServicePlanId')]", "httpsOnly": true, @@ -1300,7 +1299,7 @@ { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", - "name": "[format('app-shopping-api-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", + "name": "[format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))]", "location": "[parameters('location')]", "tags": "[union(parameters('tags'), createObject('azd-service-name', 'products-outbox-relay', 'hidden-link: /app-insights-resource-id', parameters('appInsightsResourceId')))]", "kind": "app,linux", @@ -1325,20 +1324,6 @@ } } }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", - "properties": { - "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-products-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" - ] - }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", @@ -1367,20 +1352,6 @@ } } }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-shopping-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", - "properties": { - "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-shopping-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-shopping-outbox-relay-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" - ] - }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", @@ -1409,20 +1380,6 @@ } } }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-products-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", - "properties": { - "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-products-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-products-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" - ] - }, { "type": "Microsoft.Web/sites", "apiVersion": "2023-12-01", @@ -1450,20 +1407,6 @@ "appSettings": "[concat(variables('sharedAppSettings'), variables('sqlDbAppSettings'))]" } } - }, - { - "type": "Microsoft.Authorization/roleAssignments", - "apiVersion": "2022-04-01", - "scope": "[resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName'))]", - "name": "[guid(resourceId('Microsoft.KeyVault/vaults', parameters('keyVaultName')), resourceId('Microsoft.Web/sites', format('app-shopping-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))), 'KeyVaultSecretsUser')]", - "properties": { - "principalId": "[reference(resourceId('Microsoft.Web/sites', format('app-shopping-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix'))), '2023-12-01', 'full').identity.principalId]", - "principalType": "ServicePrincipal", - "roleDefinitionId": "[variables('keyVaultSecretsUserRoleDefinitionId')]" - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/sites', format('app-shopping-subscribe-{0}-{1}', parameters('environmentType'), parameters('suffix')))]" - ] } ], "outputs": { diff --git a/azure/infra/modules/app-services.bicep b/azure/infra/modules/app-services.bicep index 52cc5b99..06085256 100644 --- a/azure/infra/modules/app-services.bicep +++ b/azure/infra/modules/app-services.bicep @@ -7,20 +7,12 @@ param tags object = {} param appInsightsConnectionString string param appInsightsResourceId string param appInsightsInstrumentationKey string -param keyVaultName string -param keyVaultUri string +param sqlConnectionString string +param postgresConnectionString string +param serviceBusConnectionString string param redisConnectionString string param otlpHttpEndpoint string -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = { - name: keyVaultName -} - -var keyVaultSecretsUserRoleDefinitionId = subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') -var sqlConnectionStringKeyVaultReference = '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/sql-connection-string/)' -var postgresConnectionStringKeyVaultReference = '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/postgres-connection-string/)' -var serviceBusConnectionStringKeyVaultReference = '@Microsoft.KeyVault(SecretUri=${keyVaultUri}secrets/service-bus-connection-string/)' - var sharedAppSettings = [ { name: 'ASPNETCORE_ENVIRONMENT' @@ -64,7 +56,7 @@ var sharedAppSettings = [ } { name: 'Aspire__Azure__Messaging__ServiceBus__ConnectionString' - value: serviceBusConnectionStringKeyVaultReference + value: serviceBusConnectionString } { name: 'OTEL_EXPORTER_OTLP_PROTOCOL' @@ -79,14 +71,14 @@ var sharedAppSettings = [ var sqlDbAppSettings = [ { name: 'Aspire__Microsoft__Data__SqlClient__ConnectionString' - value: sqlConnectionStringKeyVaultReference + value: sqlConnectionString } ] var postgresDbAppSettings = [ { name: 'Aspire__Npgsql__ConnectionString' - value: postgresConnectionStringKeyVaultReference + value: postgresConnectionString } ] @@ -120,16 +112,6 @@ resource productsApi 'Microsoft.Web/sites@2023-12-01' = { } } -resource productsApiKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(keyVault.id, productsApi.id, 'KeyVaultSecretsUser') - scope: keyVault - properties: { - principalId: productsApi.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: keyVaultSecretsUserRoleDefinitionId - } -} - resource shoppingApi 'Microsoft.Web/sites@2023-12-01' = { name: 'app-shopping-api-${environmentType}-${suffix}' location: location @@ -165,16 +147,6 @@ resource shoppingApi 'Microsoft.Web/sites@2023-12-01' = { } } -resource shoppingApiKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(keyVault.id, shoppingApi.id, 'KeyVaultSecretsUser') - scope: keyVault - properties: { - principalId: shoppingApi.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: keyVaultSecretsUserRoleDefinitionId - } -} - resource productsOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { name: 'app-products-outbox-relay-${environmentType}-${suffix}' location: location @@ -205,16 +177,6 @@ resource productsOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { } } -resource productsOutboxRelayKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(keyVault.id, productsOutboxRelay.id, 'KeyVaultSecretsUser') - scope: keyVault - properties: { - principalId: productsOutboxRelay.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: keyVaultSecretsUserRoleDefinitionId - } -} - resource shoppingOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { name: 'app-shopping-outbox-relay-${environmentType}-${suffix}' location: location @@ -245,16 +207,6 @@ resource shoppingOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { } } -resource shoppingOutboxRelayKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(keyVault.id, shoppingOutboxRelay.id, 'KeyVaultSecretsUser') - scope: keyVault - properties: { - principalId: shoppingOutboxRelay.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: keyVaultSecretsUserRoleDefinitionId - } -} - resource productsSubscribe 'Microsoft.Web/sites@2023-12-01' = { name: 'app-products-subscribe-${environmentType}-${suffix}' location: location @@ -285,16 +237,6 @@ resource productsSubscribe 'Microsoft.Web/sites@2023-12-01' = { } } -resource productsSubscribeKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(keyVault.id, productsSubscribe.id, 'KeyVaultSecretsUser') - scope: keyVault - properties: { - principalId: productsSubscribe.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: keyVaultSecretsUserRoleDefinitionId - } -} - resource shoppingSubscribe 'Microsoft.Web/sites@2023-12-01' = { name: 'app-shopping-subscribe-${environmentType}-${suffix}' location: location @@ -325,16 +267,6 @@ resource shoppingSubscribe 'Microsoft.Web/sites@2023-12-01' = { } } -resource shoppingSubscribeKeyVaultSecretsUserRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { - name: guid(keyVault.id, shoppingSubscribe.id, 'KeyVaultSecretsUser') - scope: keyVault - properties: { - principalId: shoppingSubscribe.identity.principalId - principalType: 'ServicePrincipal' - roleDefinitionId: keyVaultSecretsUserRoleDefinitionId - } -} - output productsApiName string = productsApi.name output shoppingApiName string = shoppingApi.name output productsOutboxRelayName string = productsOutboxRelay.name diff --git a/azure/scripts/refresh-keyvault-appsettings.ps1 b/azure/scripts/refresh-keyvault-appsettings.ps1 deleted file mode 100644 index d76ec784..00000000 --- a/azure/scripts/refresh-keyvault-appsettings.ps1 +++ /dev/null @@ -1,56 +0,0 @@ -#Requires -Version 7 -[CmdletBinding()] -param ( - [Alias('g')] - [Parameter(Mandatory)] - [string] $ResourceGroup, - - [Alias('n')] - [string[]] $AppName, - - [Alias('w')] - [ValidateRange(0, 600)] - [int] $WaitAfterRestartSeconds = 20 -) - -Set-StrictMode -Version Latest -$ErrorActionPreference = 'Stop' - -if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - throw "Azure CLI 'az' is not installed or not on PATH." -} - -$appNames = @() -if ($AppName -and $AppName.Count -gt 0) { - $appNames = $AppName -} -else { - $appNames = @(az webapp list --resource-group $ResourceGroup --query '[].name' -o tsv) -} - -$appNames = @($appNames | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - -if ($appNames.Count -eq 0) { - throw "No web apps found in resource group '$ResourceGroup'." -} - -foreach ($app in $appNames) { - Write-Host "Refreshing Key Vault app settings references for '$app'." - - $appId = (az webapp show --resource-group $ResourceGroup --name $app --query id -o tsv) - if ([string]::IsNullOrWhiteSpace($appId)) { - throw "Unable to resolve app id for '$app'." - } - - az rest --method post --url "https://management.azure.com${appId}/config/configreferences/appsettings/refresh?api-version=2023-12-01" --output none - - Write-Host "Restarting '$app'." - az webapp restart --resource-group $ResourceGroup --name $app --output none - - if ($WaitAfterRestartSeconds -gt 0) { - Write-Host "Waiting $WaitAfterRestartSeconds seconds for '$app' startup." - Start-Sleep -Seconds $WaitAfterRestartSeconds - } -} - -Write-Host "Completed Key Vault app settings refresh for $($appNames.Count) app(s)." diff --git a/azure/scripts/refresh-keyvault-appsettings.sh b/azure/scripts/refresh-keyvault-appsettings.sh deleted file mode 100644 index 709954cc..00000000 --- a/azure/scripts/refresh-keyvault-appsettings.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -usage() { - cat <<'EOF' -Usage: - ./scripts/refresh-keyvault-appsettings.sh --resource-group [--app-name ]... [--wait-after-restart-seconds ] - -Description: - Refreshes App Service Key Vault app setting references and restarts web apps. - When no app names are supplied, all web apps in the resource group are targeted. - -Options: - --resource-group, -g Azure resource group name (required). - --app-name, -n Specific web app name to refresh; repeat for multiple apps. - --wait-after-restart-seconds, -w Delay after each restart (default: 20). - --help, -h Show this help. -EOF -} - -resource_group="" -wait_after_restart_seconds="20" -declare -a app_names=() - -while [[ $# -gt 0 ]]; do - case "$1" in - --resource-group|-g) - resource_group="${2:-}" - shift 2 - ;; - --app-name|-n) - app_names+=("${2:-}") - shift 2 - ;; - --wait-after-restart-seconds|-w) - wait_after_restart_seconds="${2:-}" - shift 2 - ;; - --help|-h) - usage - exit 0 - ;; - *) - echo "Unknown argument: $1" >&2 - usage - exit 1 - ;; - esac -done - -if [[ -z "${resource_group}" ]]; then - echo "Missing required argument: --resource-group" >&2 - usage - exit 1 -fi - -if ! command -v az >/dev/null 2>&1; then - echo "Azure CLI 'az' is not installed or not on PATH." >&2 - exit 1 -fi - -if [[ ! "${wait_after_restart_seconds}" =~ ^[0-9]+$ ]]; then - echo "--wait-after-restart-seconds must be a non-negative integer." >&2 - exit 1 -fi - -if [[ ${#app_names[@]} -eq 0 ]]; then - mapfile -t app_names < <(az webapp list --resource-group "${resource_group}" --query "[].name" -o tsv) -fi - -if [[ ${#app_names[@]} -eq 0 ]]; then - echo "No web apps found in resource group '${resource_group}'." >&2 - exit 1 -fi - -for app_name in "${app_names[@]}"; do - if [[ -z "${app_name}" ]]; then - continue - fi - - echo "Refreshing Key Vault app settings references for '${app_name}'." - app_id="$(az webapp show --resource-group "${resource_group}" --name "${app_name}" --query id -o tsv)" - - if [[ -z "${app_id}" ]]; then - echo "Unable to resolve app id for '${app_name}'." >&2 - exit 1 - fi - - az rest \ - --method post \ - --url "https://management.azure.com${app_id}/config/configreferences/appsettings/refresh?api-version=2023-12-01" \ - --output none - - echo "Restarting '${app_name}'." - az webapp restart --resource-group "${resource_group}" --name "${app_name}" --output none - - if (( wait_after_restart_seconds > 0 )); then - echo "Waiting ${wait_after_restart_seconds}s for '${app_name}' startup." - sleep "${wait_after_restart_seconds}" - fi -done - -echo "Completed Key Vault app settings refresh for ${#app_names[@]} app(s)." diff --git a/azure/scripts/setup-e2e-runner.ps1 b/azure/scripts/setup-e2e-runner.ps1 index d931a66e..14db96a7 100644 --- a/azure/scripts/setup-e2e-runner.ps1 +++ b/azure/scripts/setup-e2e-runner.ps1 @@ -17,6 +17,10 @@ param ( [Alias('s')] [string] $ShoppingAppName, + [Alias('i')] + [Alias('SkipCertificateValidation')] + [switch] $Insecure, + [switch] $SkipValidation ) @@ -45,12 +49,24 @@ function Invoke-ValidateRequest { param ( [string] $Label, [string] $Url, - [string] $Method = 'GET' + [string] $Method = 'GET', + [switch] $Insecure ) $code = $null try { - $response = Invoke-WebRequest -Uri $Url -Method $Method -SkipCertificateCheck -UseBasicParsing -ErrorAction Stop + $invokeWebRequestParams = @{ + Uri = $Url + Method = $Method + UseBasicParsing = $true + ErrorAction = 'Stop' + } + + if ($Insecure) { + $invokeWebRequestParams['SkipCertificateCheck'] = $true + } + + $response = Invoke-WebRequest @invokeWebRequestParams $code = [int]$response.StatusCode } catch [Microsoft.PowerShell.Commands.HttpResponseException] { @@ -111,12 +127,12 @@ if (-not $postgresConnectionString -or -not $sqlConnectionString) { # Validate endpoints. if (-not $SkipValidation) { - Invoke-ValidateRequest -Label 'Products API' -Url "https://${productsHost}/api/products" - Invoke-ValidateRequest -Label 'Shopping API' -Url "https://${shoppingHost}/api/customers/test/baskets" -Method POST - Invoke-ValidateRequest -Label 'Products health' -Url "https://${productsHost}/health/ready/detailed" - Invoke-ValidateRequest -Label 'Products swagger' -Url "https://${productsHost}/swagger" - Invoke-ValidateRequest -Label 'Shopping health' -Url "https://${shoppingHost}/health/ready/detailed" - Invoke-ValidateRequest -Label 'Shopping swagger' -Url "https://${shoppingHost}/swagger" + Invoke-ValidateRequest -Label 'Products API' -Url "https://${productsHost}/api/products" -Insecure:$Insecure + Invoke-ValidateRequest -Label 'Shopping API' -Url "https://${shoppingHost}/api/customers/test/baskets" -Method POST -Insecure:$Insecure + Invoke-ValidateRequest -Label 'Products health' -Url "https://${productsHost}/health/ready/detailed" -Insecure:$Insecure + Invoke-ValidateRequest -Label 'Products swagger' -Url "https://${productsHost}/swagger" -Insecure:$Insecure + Invoke-ValidateRequest -Label 'Shopping health' -Url "https://${shoppingHost}/health/ready/detailed" -Insecure:$Insecure + Invoke-ValidateRequest -Label 'Shopping swagger' -Url "https://${shoppingHost}/swagger" -Insecure:$Insecure } # Update appsettings.json. From 1fe4eeba5e722800e8c83f0465700c2e3f160eaf Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Thu, 21 May 2026 10:00:35 -0700 Subject: [PATCH 13/14] fix: update dev params for when keyvault env var doesn't exist Signed-off-by: Aaron Spruit --- azure/infra/scripts/use-dev-params.ps1 | 10 ++++++++-- azure/infra/scripts/use-dev-params.sh | 8 +++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/azure/infra/scripts/use-dev-params.ps1 b/azure/infra/scripts/use-dev-params.ps1 index 1e8dac92..159522a4 100644 --- a/azure/infra/scripts/use-dev-params.ps1 +++ b/azure/infra/scripts/use-dev-params.ps1 @@ -60,10 +60,16 @@ $appServiceLinuxFxVersion = switch ($targetFramework) { $keyVaultName = '' try { - $existingKeyVaultName = (azd env get-value keyVaultName 2>$null).Trim() + $existingKeyVaultNameRaw = (azd env get-value keyVaultName 2>$null).Trim() } catch { - $existingKeyVaultName = '' + $existingKeyVaultNameRaw = '' +} +$existingKeyVaultName = if ($existingKeyVaultNameRaw -and -not $existingKeyVaultNameRaw.Contains('ERROR:')) { + $existingKeyVaultNameRaw +} +else { + '' } if (-not [string]::IsNullOrWhiteSpace($existingKeyVaultName)) { az keyvault show --name $existingKeyVaultName --query name -o tsv *> $null diff --git a/azure/infra/scripts/use-dev-params.sh b/azure/infra/scripts/use-dev-params.sh index 566f53d0..8e2d627e 100755 --- a/azure/infra/scripts/use-dev-params.sh +++ b/azure/infra/scripts/use-dev-params.sh @@ -56,7 +56,13 @@ case "${target_framework}" in esac key_vault_name="" -existing_key_vault_name="$(azd env get-value keyVaultName 2>/dev/null | tr -d '\r' || true)" +existing_key_vault_name_raw="$(azd env get-value keyVaultName 2>/dev/null | tr -d '\r' || true)" +existing_key_vault_name="" + +# azd can return key-not-found messages on stdout; only accept non-error values. +if [[ -n "${existing_key_vault_name_raw}" && "${existing_key_vault_name_raw}" != *ERROR:* ]]; then + existing_key_vault_name="${existing_key_vault_name_raw}" +fi if [[ -n "${existing_key_vault_name}" ]]; then if az keyvault show --name "${existing_key_vault_name}" --query name -o tsv >/dev/null 2>&1; then key_vault_name="${existing_key_vault_name}" From c4253141cfb7afa1161247698811df1a7fbbc5ee Mon Sep 17 00:00:00 2001 From: Aaron Spruit Date: Thu, 21 May 2026 10:36:09 -0700 Subject: [PATCH 14/14] fix: copilot review updates Signed-off-by: Aaron Spruit --- azure/AGENTS.md | 11 +--------- azure/scripts/ensure-sql-firewall-rule.ps1 | 24 +++++++++++++++++----- azure/scripts/ensure-sql-firewall-rule.sh | 20 +++++++++++++----- azure/scripts/setup-e2e-runner.ps1 | 3 ++- 4 files changed, 37 insertions(+), 21 deletions(-) diff --git a/azure/AGENTS.md b/azure/AGENTS.md index 30ee2c33..a5b1486e 100644 --- a/azure/AGENTS.md +++ b/azure/AGENTS.md @@ -16,7 +16,7 @@ This file applies to anything under `azure/`. For application code, see the rele ## Folder layout -- [azure.yaml](azure.yaml) — azd project manifest. Declares the 8 services and the pre/post hooks. +- [azure.yaml](azure.yaml) — azd project manifest. Declares the 6 services and the pre/post hooks. - [infra/](infra/) — Bicep templates (primary IaC for `azd`). - [infra/main.bicep](infra/main.bicep) — Entry template. - [infra/modules/](infra/modules/) — Per-resource modules (`app-service-plan`, `app-services`, `aspire-dashboard`, `database`, `postgres-database`, `service-bus`, `redis`, `key-vault`, `application-insights`). @@ -112,15 +112,6 @@ The `azure/scripts/` folder includes two helper scripts that should be preferred - health and swagger endpoints for both services. - Update behavior: creates `appsettings.json.bak` before writing updated `E2E.Products` and `E2E.Shopping` values. -### refresh-keyvault-appsettings - -- Files: `scripts/refresh-keyvault-appsettings.sh`, `scripts/refresh-keyvault-appsettings.ps1`. -- Purpose: refreshes App Service Key Vault appsetting references and restarts targeted web apps. -- Required argument: `--resource-group` / `-ResourceGroup`. -- Optional arguments: one or more app names and wait-after-restart seconds. -- Discovery behavior: when app names are omitted, all web apps in the resource group are targeted. -- Primary use case: recover from startup failures immediately after deploy when Key Vault-backed settings have not been fully applied yet. - When editing either helper script, keep the bash and PowerShell variants behaviorally identical. ## Validating changes diff --git a/azure/scripts/ensure-sql-firewall-rule.ps1 b/azure/scripts/ensure-sql-firewall-rule.ps1 index af7d023f..c0d8028e 100644 --- a/azure/scripts/ensure-sql-firewall-rule.ps1 +++ b/azure/scripts/ensure-sql-firewall-rule.ps1 @@ -78,11 +78,25 @@ function Ensure-FirewallRule { } } -$sqlServer = (azd env get-value sqlServerName).Trim() -$postgresServer = (azd env get-value postgresServerName).Trim() -$azureResourceGroup = (azd env get-value AZURE_RESOURCE_GROUP).Trim() -$azureSubscriptionId = (azd env get-value AZURE_SUBSCRIPTION_ID).Trim() -$azureEnvName = (azd env get-value AZURE_ENV_NAME).Trim() +function Get-AzdEnvValue { + param( + [Parameter(Mandatory = $true)] + [string] $Key + ) + + $value = (azd env get-value $Key 2>&1 | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or $value.StartsWith('ERROR:')) { + return '' + } + + return $value +} + +$sqlServer = Get-AzdEnvValue -Key 'sqlServerName' +$postgresServer = Get-AzdEnvValue -Key 'postgresServerName' +$azureResourceGroup = Get-AzdEnvValue -Key 'AZURE_RESOURCE_GROUP' +$azureSubscriptionId = Get-AzdEnvValue -Key 'AZURE_SUBSCRIPTION_ID' +$azureEnvName = Get-AzdEnvValue -Key 'AZURE_ENV_NAME' if (([string]::IsNullOrWhiteSpace($sqlServer) -and [string]::IsNullOrWhiteSpace($postgresServer)) -or [string]::IsNullOrWhiteSpace($azureResourceGroup)) { throw 'Unable to resolve sqlServerName and/or postgresServerName and AZURE_RESOURCE_GROUP from the active azd environment.' diff --git a/azure/scripts/ensure-sql-firewall-rule.sh b/azure/scripts/ensure-sql-firewall-rule.sh index a5fb5cce..525a5fd4 100644 --- a/azure/scripts/ensure-sql-firewall-rule.sh +++ b/azure/scripts/ensure-sql-firewall-rule.sh @@ -64,11 +64,21 @@ if ! command -v az >/dev/null 2>&1; then exit 1 fi -sql_server="$(azd env get-value sqlServerName | tr -d '\r')" -postgres_server="$(azd env get-value postgresServerName | tr -d '\r')" -azure_resource_group="$(azd env get-value AZURE_RESOURCE_GROUP | tr -d '\r')" -azure_subscription_id="$(azd env get-value AZURE_SUBSCRIPTION_ID | tr -d '\r')" -azure_env_name="$(azd env get-value AZURE_ENV_NAME | tr -d '\r')" +get_azd_value() { + local value + value="$(azd env get-value "$1" 2>/dev/null | tr -d '\r')" + if [[ -z "${value}" || "${value}" == ERROR:* ]]; then + echo "" + else + echo "${value}" + fi +} + +sql_server="$(get_azd_value sqlServerName)" +postgres_server="$(get_azd_value postgresServerName)" +azure_resource_group="$(get_azd_value AZURE_RESOURCE_GROUP)" +azure_subscription_id="$(get_azd_value AZURE_SUBSCRIPTION_ID)" +azure_env_name="$(get_azd_value AZURE_ENV_NAME)" if [[ -z "${sql_server}" && -z "${postgres_server}" ]] || [[ -z "${azure_resource_group}" ]]; then echo "Unable to resolve sqlServerName and/or postgresServerName and AZURE_RESOURCE_GROUP from the active azd environment." >&2 diff --git a/azure/scripts/setup-e2e-runner.ps1 b/azure/scripts/setup-e2e-runner.ps1 index 14db96a7..250be236 100644 --- a/azure/scripts/setup-e2e-runner.ps1 +++ b/azure/scripts/setup-e2e-runner.ps1 @@ -160,4 +160,5 @@ Write-Host "Key Vault: $KeyVaultName" Write-Host "" Write-Host "Next step:" Write-Host " cd $repoRoot/samples/tests/Contoso.E2E.Runner" -Write-Host " dotnet run --framework `"`${env:AZD_DOTNET_TARGET_FRAMEWORK ?? `$env:DOTNET_TARGET_FRAMEWORK}`"" +$targetFramework = $env:AZD_DOTNET_TARGET_FRAMEWORK ?? $env:DOTNET_TARGET_FRAMEWORK +Write-Host " dotnet run --framework `"$targetFramework`""