diff --git a/azure/AGENTS.md b/azure/AGENTS.md index 07433fe5..a5b1486e 100644 --- a/azure/AGENTS.md +++ b/azure/AGENTS.md @@ -1,38 +1,37 @@ --- -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 - [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). - [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`. -- 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. @@ -41,16 +40,15 @@ 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. ## 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,9 +59,10 @@ 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: +Load into the current shell before running ad-hoc `az` commands: ```bash set -a && eval "$(azd env get-values)" && set +a @@ -82,39 +81,62 @@ azd deploy --all --no-prompt # code-only redeploy. azd down --force --purge --no-prompt # tear down. ``` -### Terraform +## Helper scripts -```bash -cd azure/terraform -./apply.sh dev plan -./apply.sh dev apply -``` +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. -`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`. +When editing either helper script, keep the bash and PowerShell variants behaviorally identical. ## 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`. +- **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 ce4820ff..c84e1b11 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' ``` @@ -79,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 ``` @@ -130,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 @@ -138,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" ``` @@ -155,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}" ``` @@ -170,6 +217,43 @@ 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 \ + --insecure +``` + +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: @@ -189,11 +273,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 @@ -201,7 +287,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` -- `service-bus-connection-string` +- `postgres-admin-password` +- `postgres-connection-string` Retrieve them: @@ -212,8 +299,8 @@ 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 -# Service Bus connection string -az keyvault secret show --vault-name $KV --name service-bus-connection-string -o tsv --query value +# PostgreSQL connection string +az keyvault secret show --vault-name $KV --name postgres-connection-string -o tsv --query value ``` ### Update E2E configuration @@ -225,13 +312,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=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=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", - "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;" } } } @@ -267,9 +352,16 @@ 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`. +- 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 e967ac54..7e72e588 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 = {} @@ -57,6 +60,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 @@ -68,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' @@ -88,7 +117,7 @@ module keyVault './modules/key-vault.bicep' = { name: 'keyVaultDeploy' params: { location: location - name: keyVaultName + name: resolvedKeyVaultName tags: mergedTags } } @@ -143,6 +172,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,8 +202,9 @@ module appServices './modules/app-services.bicep' = { appInsightsResourceId: appInsights.outputs.id appInsightsInstrumentationKey: appInsights.outputs.instrumentationKey sqlConnectionString: sql.outputs.connectionString - redisConnectionString: redis.outputs.connectionString + postgresConnectionString: postgres.outputs.connectionString serviceBusConnectionString: serviceBus.outputs.connectionString + redisConnectionString: redis.outputs.connectionString otlpHttpEndpoint: aspireDashboard.outputs.otlpHttpEndpoint } } @@ -180,6 +227,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 +238,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..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", @@ -57,6 +60,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.json b/azure/infra/main.json index 200cce04..149e2ce3 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": "11663173647746982515" } }, "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": {}, @@ -58,6 +65,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 +95,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,28 +126,76 @@ "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." } } }, "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": [ @@ -157,8 +225,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "13606231085913354784" + "version": "0.43.8.12551", + "templateHash": "1054162708713925368" } }, "parameters": { @@ -196,6 +264,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]" } } } @@ -215,7 +291,7 @@ "value": "[parameters('location')]" }, "name": { - "value": "[variables('keyVaultName')]" + "value": "[variables('resolvedKeyVaultName')]" }, "tags": { "value": "[variables('mergedTags')]" @@ -227,8 +303,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "2576417288351020778" + "version": "0.43.8.12551", + "templateHash": "6366741801556338859" } }, "parameters": { @@ -316,8 +392,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "12329281375890776410" + "version": "0.43.8.12551", + "templateHash": "17043110285077169943" } }, "parameters": { @@ -401,8 +477,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "14328657583515942296" + "version": "0.43.8.12551", + "templateHash": "17950824884306378259" } }, "parameters": { @@ -449,6 +525,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 +582,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 +617,8 @@ "skuName": { "value": "[parameters('redisSkuName')]" }, - "skuFamily": { - "value": "[parameters('redisSkuFamily')]" - }, - "skuCapacity": { - "value": "[parameters('redisSkuCapacity')]" + "highAvailability": { + "value": "[parameters('redisHighAvailability')]" }, "tags": { "value": "[variables('mergedTags')]" @@ -521,8 +630,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "15208332006810395159" + "version": "0.43.8.12551", + "templateHash": "4334939735410165283" } }, "parameters": { @@ -535,11 +644,12 @@ "skuName": { "type": "string" }, - "skuFamily": { - "type": "string" - }, - "skuCapacity": { - "type": "int" + "highAvailability": { + "type": "string", + "allowedValues": [ + "Enabled", + "Disabled" + ] }, "tags": { "type": "object", @@ -548,27 +658,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 +700,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 +735,9 @@ "adminPassword": { "value": "[parameters('sqlAdminPassword')]" }, + "clientIp": { + "value": "[parameters('sqlFirewallClientIp')]" + }, "skuName": { "value": "[parameters('sqlSkuName')]" }, @@ -633,8 +760,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "14902580470469121549" + "version": "0.43.8.12551", + "templateHash": "12964997318502549886" } }, "parameters": { @@ -653,6 +780,10 @@ "adminPassword": { "type": "securestring" }, + "clientIp": { + "type": "string", + "defaultValue": "" + }, "skuName": { "type": "string" }, @@ -697,6 +828,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 +882,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 +1076,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 +1091,26 @@ "appInsightsConnectionString": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy'), '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]" + }, "sqlConnectionString": { "value": "[reference(resourceId('Microsoft.Resources/deployments', 'sqlDeploy'), '2025-04-01').outputs.connectionString.value]" }, - "redisConnectionString": { - "value": "[reference(resourceId('Microsoft.Resources/deployments', 'redisDeploy'), '2025-04-01').outputs.connectionString.value]" + "postgresConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'postgresDeploy'), '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]" + "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]" } }, "template": { @@ -785,8 +1119,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.42.1.51946", - "templateHash": "432677751176830928" + "version": "0.43.8.12551", + "templateHash": "3293862563743385029" } }, "parameters": { @@ -796,6 +1130,9 @@ "appServicePlanId": { "type": "string" }, + "appServiceLinuxFxVersion": { + "type": "string" + }, "environmentType": { "type": "string" }, @@ -809,16 +1146,25 @@ "appInsightsConnectionString": { "type": "string" }, + "appInsightsResourceId": { + "type": "string" + }, + "appInsightsInstrumentationKey": { + "type": "string" + }, "sqlConnectionString": { "type": "string" }, - "redisConnectionString": { + "postgresConnectionString": { "type": "string" }, "serviceBusConnectionString": { "type": "string" }, - "otlpGrpcEndpoint": { + "redisConnectionString": { + "type": "string" + }, + "otlpHttpEndpoint": { "type": "string" } }, @@ -833,8 +1179,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", @@ -846,11 +1216,23 @@ }, { "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": "[parameters('sqlConnectionString')]" + } + ], + "postgresDbAppSettings": [ + { + "name": "Aspire__Npgsql__ConnectionString", + "value": "[parameters('postgresConnectionString')]" } ] }, @@ -860,15 +1242,26 @@ "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'))]" } } }, @@ -877,15 +1270,26 @@ "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": [ @@ -897,15 +1301,26 @@ "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'))]" } } }, @@ -914,15 +1329,26 @@ "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'))]" } } }, @@ -931,15 +1357,26 @@ "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'))]" } } }, @@ -948,15 +1385,26 @@ "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'))]" } } } @@ -993,6 +1441,7 @@ "[resourceId('Microsoft.Resources/deployments', 'appInsightsDeploy')]", "[resourceId('Microsoft.Resources/deployments', 'appServicePlanDeploy')]", "[resourceId('Microsoft.Resources/deployments', 'aspireDashboardDeploy')]", + "[resourceId('Microsoft.Resources/deployments', 'postgresDeploy')]", "[resourceId('Microsoft.Resources/deployments', 'redisDeploy')]", "[resourceId('Microsoft.Resources/deployments', 'serviceBusDeploy')]", "[resourceId('Microsoft.Resources/deployments', 'sqlDeploy')]" @@ -1011,6 +1460,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 +1479,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 +1502,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": [ { - "protocol": "TCP", - "port": 18888 + "name": "WEBSITES_PORT", + "value": "18888" }, { - "protocol": "TCP", - "port": 18889 + "name": "ASPNETCORE_URLS", + "value": "http://0.0.0.0:18888" }, { - "protocol": "TCP", - "port": 18890 + "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" } ] - }, - "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 +1604,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 +1636,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/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..06085256 100644 --- a/azure/infra/modules/app-services.bicep +++ b/azure/infra/modules/app-services.bicep @@ -8,8 +8,9 @@ param appInsightsConnectionString string param appInsightsResourceId string param appInsightsInstrumentationKey string param sqlConnectionString string -param redisConnectionString string +param postgresConnectionString string param serviceBusConnectionString string +param redisConnectionString string param otlpHttpEndpoint string var sharedAppSettings = [ @@ -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 @@ -79,6 +90,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 @@ -93,7 +107,7 @@ resource productsApi 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, postgresDbAppSettings) } } } @@ -106,6 +120,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 @@ -120,7 +137,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}' @@ -138,6 +155,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 @@ -152,7 +172,7 @@ resource productsOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, postgresDbAppSettings) } } } @@ -165,6 +185,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 @@ -179,7 +202,7 @@ resource shoppingOutboxRelay 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, sqlDbAppSettings) } } } @@ -192,6 +215,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 @@ -206,7 +232,7 @@ resource productsSubscribe 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, postgresDbAppSettings) } } } @@ -219,6 +245,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 @@ -233,7 +262,7 @@ resource shoppingSubscribe 'Microsoft.Web/sites@2023-12-01' = { ftpsState: 'Disabled' http20Enabled: true alwaysOn: true - appSettings: sharedAppSettings + appSettings: concat(sharedAppSettings, sqlDbAppSettings) } } } @@ -244,3 +273,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..a7a4e8a1 --- /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;' diff --git a/azure/infra/scripts/store-secrets.ps1 b/azure/infra/scripts/store-secrets.ps1 index 5a2ce805..132e497d 100644 --- a/azure/infra/scripts/store-secrets.ps1 +++ b/azure/infra/scripts/store-secrets.ps1 @@ -12,6 +12,32 @@ 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 } + +$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 } +$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.' +} + +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) @@ -34,15 +60,21 @@ catch { Write-Host 'Storing sql-admin-password...' az keyvault secret set --vault-name $kvName --name 'sql-admin-password' --value $sqlPassword --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 'Storing postgres-admin-password...' +az keyvault secret set --vault-name $kvName --name 'postgres-admin-password' --value $postgresPassword --output none + +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 'Building Postgres connection string...' +$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 + 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 +88,12 @@ 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" + +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 old mode 100644 new mode 100755 index c53be7bc..8ebcb5b2 --- a/azure/infra/scripts/store-secrets.sh +++ b/azure/infra/scripts/store-secrets.sh @@ -5,6 +5,34 @@ 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:-}}" +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 + 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 +59,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 +76,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;" + +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 +103,12 @@ 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" + +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/infra/scripts/use-dev-params.ps1 b/azure/infra/scripts/use-dev-params.ps1 index 6544adf8..159522a4 100644 --- a/azure/infra/scripts/use-dev-params.ps1 +++ b/azure/infra/scripts/use-dev-params.ps1 @@ -7,10 +7,18 @@ 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." } +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 { @@ -50,6 +58,30 @@ $appServiceLinuxFxVersion = switch ($targetFramework) { default { throw "Unsupported target framework '$targetFramework'. Expected net8.0, net9.0, or net10.0." } } +$keyVaultName = '' +try { + $existingKeyVaultNameRaw = (azd env get-value keyVaultName 2>$null).Trim() +} +catch { + $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 + 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' @@ -58,15 +90,20 @@ 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.parameters.keyVaultName.value = $keyVaultName $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) + $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 old mode 100644 new mode 100755 index 764d434d..8e2d627e --- a/azure/infra/scripts/use-dev-params.sh +++ b/azure/infra/scripts/use-dev-params.sh @@ -9,11 +9,20 @@ 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 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)" @@ -46,14 +55,33 @@ case "${target_framework}" in ;; esac +key_vault_name="" +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}" + 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 \ --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' \ + --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 @@ -61,14 +89,20 @@ 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/$/\\/') 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 diff --git a/azure/scripts/ensure-sql-firewall-rule.ps1 b/azure/scripts/ensure-sql-firewall-rule.ps1 index d6a4ae7f..c0d8028e 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,68 @@ function Invoke-WithRetry { } } -$sqlServer = (azd env get-value sqlServerName).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 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." + } +} + +function Get-AzdEnvValue { + param( + [Parameter(Mandatory = $true)] + [string] $Key + ) -if ([string]::IsNullOrWhiteSpace($sqlServer) -or [string]::IsNullOrWhiteSpace($azureResourceGroup)) { - throw 'Unable to resolve sqlServerName/AZURE_RESOURCE_GROUP from the active azd environment.' + $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.' } $clientIp = '' @@ -71,31 +126,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..525a5fd4 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,63 @@ 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')" -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 +} -if [[ -z "${sql_server}" || -z "${azure_resource_group}" ]]; then - echo "Unable to resolve sqlServerName/AZURE_RESOURCE_GROUP from the active azd environment." >&2 +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 exit 1 fi @@ -61,26 +101,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/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/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/run-products-db-migrations.ps1 b/azure/scripts/run-products-db-migrations.ps1 index f014af46..57dbe0f1 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;" $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..a792c4ad --- 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;" 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/scripts/setup-e2e-runner.ps1 b/azure/scripts/setup-e2e-runner.ps1 new file mode 100644 index 00000000..250be236 --- /dev/null +++ b/azure/scripts/setup-e2e-runner.ps1 @@ -0,0 +1,164 @@ +#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, + + [Alias('i')] + [Alias('SkipCertificateValidation')] + [switch] $Insecure, + + [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', + [switch] $Insecure + ) + + $code = $null + try { + $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] { + $code = [int]$_.Exception.Response.StatusCode + } + catch { + Write-Error "Validation failed for ${Label}: ${Method} ${Url} — $($_.Exception.Message)" + exit 1 + } + + if (-not $code -or $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" -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. +$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" +$targetFramework = $env:AZD_DOTNET_TARGET_FRAMEWORK ?? $env:DOTNET_TARGET_FRAMEWORK +Write-Host " dotnet run --framework `"$targetFramework`"" diff --git a/azure/scripts/setup-e2e-runner.sh b/azure/scripts/setup-e2e-runner.sh new file mode 100755 index 00000000..f4bf5e9c --- /dev/null +++ b/azure/scripts/setup-e2e-runner.sh @@ -0,0 +1,228 @@ +#!/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] [--insecure] + +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. + --insecure Disable TLS certificate verification for validation requests. + --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" +insecure="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 + ;; + --insecure) + insecure="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 + 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 "${curl_args[@]}" "${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 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 c04128b5..00000000 --- a/azure/terraform/dev.tfvars +++ /dev/null @@ -1,32 +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 - -# Derived from current public IP by apply script. -sql_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 77078af0..00000000 --- a/azure/terraform/main.tf +++ /dev/null @@ -1,676 +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}" - dashboard_name = "app-aspire-dashboard-${var.environment_type}-${local.suffix}" - - 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;" -} - -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" "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__Microsoft__Data__SqlClient__ConnectionString" - value = local.sql_connection_string - }, - { - 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 - } - ] -} - -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 = local.app_services_common_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, [ - { - 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 = local.app_services_common_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 = local.app_services_common_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 = local.app_services_common_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 = local.app_services_common_settings - } - } - } -} - -data "azurerm_client_config" "current" {} diff --git a/azure/terraform/outputs.tf b/azure/terraform/outputs.tf deleted file mode 100644 index 6a7ed241..00000000 --- a/azure/terraform/outputs.tf +++ /dev/null @@ -1,64 +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 "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 2a4a8fed..00000000 --- a/azure/terraform/prod.tfvars +++ /dev/null @@ -1,31 +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 - -# Derived from current public IP by apply script. -sql_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 4215135b..00000000 --- a/azure/terraform/test.tfvars +++ /dev/null @@ -1,31 +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 - -# Derived from current public IP by apply script. -sql_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 cfe30934..00000000 --- a/azure/terraform/variables.tf +++ /dev/null @@ -1,116 +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 "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" {}