From be0f0a20fadd15500104d0ab1b1c96465135f03f Mon Sep 17 00:00:00 2001 From: Jurie Smit Date: Sun, 10 May 2026 17:09:04 +0200 Subject: [PATCH 1/3] chore(infra): rename to convolens + adopt NL naming standard with ADR-0027 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled changes in one PR (per follow-up to audit + rename plan): 1. Project rename: bicep projectName default + scripts + workflows flip from 'whatssummarize' to 'convolens'. 2. Resource naming convention migrates from the legacy `--` (`rg-whatssummarize-dev`, `kvwhatssummarizedev`, `cosmos-whatssummarize-dev`, etc.) to the NL Azure Naming Standard with the region suffix dropped per mystira ADR-0027 — applied here for cross-project consistency: {org}-{env}-{project}-{type} (hyphens for most resources) {org}{env}{project}{type} (alphanumeric only — Storage) Region is now expressed by the resource group's `location` property and the `region` resource tag. Surfaces touched: - `infra/bicep/main.bicep` — added `org` parameter (default `'nl'`, allowed `nl|pvc|tws|mys`); replaced `resourcePrefix`/`resourcePrefixClean` with `base`/`baseAlphanumeric`; rewrote every module's `name` interpolation to the new pattern (`${base}-kv`, `${baseAlphanumeric}st`, `${base}-oai`, `${base}-cosmos`, `${base}-redis`, `${base}-appi`, `${base}-cae`, `${base}-api`, `${base}-swa`, `${base}-budget`). Tags object grew an `org` field. - `infra/parameters/{dev,staging,prod}.bicepparam` — `projectName` flipped to `convolens`; `org = 'nl'` added explicitly; tags include `org`; `adminEmail` defaults updated to `convolens.com` placeholders (note: the convolens.* domain is **not yet registered** — these are inert until the domain question is resolved, same as the previous `whatssummarize.com` placeholders were). - `infra/scripts/deploy.{sh,ps1}` — added `ORG`/`Org`, derived `BASE` / `Base` and `BASE_ALPHANUMERIC` / `BaseAlphanumeric`; resource group + key-vault names rebuilt from `BASE`. - `infra/scripts/validate-resources.{sh,ps1}` — same pattern; every resource lookup retargets to the NL-pattern names. - `.github/workflows/infrastructure.yml` — `AZURE_RESOURCE_GROUP_*` env vars + dynamic `RG_NAME` use `nl-${env}-convolens-rg`. - `.github/workflows/release-validation.yml` — full rebuild of resource name construction inside the validate step (`BASE`, `${BASE}-kv`, `${ORG}${ENV}${PROJECT}st`, etc.). - `infra/README.md` — leading note rewritten to describe both renames; Naming Convention section rewritten with the new pattern + 10 worked examples; Azure CLI examples (`az group create --name nl-dev-convolens-rg`, `az keyvault set-policy --name nl-dev-convolens-kv`) and post-deployment endpoints updated. Cutover plan (separate manual ops, not in this PR): 1. Land this PR — code retargets; no Azure resources are touched. 2. Fix the AAD federated-credential subjects (`AADSTS700213` failure on `Preview Changes (dev)` since 2025-12-07 — same bug as on omnipost PR #128). See org-meta handoff `2026-05-10-azure-rename-plan-omnipost-convolens.md`. 3. Run the dev infrastructure workflow → creates `nl-dev-convolens-rg` and child resources cleanly. 4. Verify with `infra/scripts/validate-resources.sh dev`. 5. No old resources to decommission — convolens has nothing deployed under any historical name (verified across all subscriptions). Out of scope (deferred): - `.github/settings.yml` repo `name` field — handled with the GitHub repo rename, not this PR. - `.github/whatssummarize-mcp-app.json` — webhook live/stale decision. - `convolens.*` domain registration — open question 2 in the rename audit. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/infrastructure.yml | 14 +++--- .github/workflows/release-validation.yml | 23 +++++----- infra/README.md | 55 +++++++++++++++++------- infra/bicep/main.bicep | 37 ++++++++++------ infra/parameters/dev.bicepparam | 6 ++- infra/parameters/prod.bicepparam | 8 ++-- infra/parameters/staging.bicepparam | 8 ++-- infra/scripts/deploy.ps1 | 10 +++-- infra/scripts/deploy.sh | 16 ++++--- infra/scripts/validate-resources.ps1 | 28 ++++++------ infra/scripts/validate-resources.sh | 31 +++++++------ 11 files changed, 144 insertions(+), 92 deletions(-) diff --git a/.github/workflows/infrastructure.yml b/.github/workflows/infrastructure.yml index c74718b..10c4162 100644 --- a/.github/workflows/infrastructure.yml +++ b/.github/workflows/infrastructure.yml @@ -40,9 +40,9 @@ permissions: pull-requests: write env: - AZURE_RESOURCE_GROUP_DEV: rg-whatssummarize-dev - AZURE_RESOURCE_GROUP_STAGING: rg-whatssummarize-staging - AZURE_RESOURCE_GROUP_PROD: rg-whatssummarize-prod + AZURE_RESOURCE_GROUP_DEV: nl-dev-convolens-rg + AZURE_RESOURCE_GROUP_STAGING: nl-staging-convolens-rg + AZURE_RESOURCE_GROUP_PROD: nl-prod-convolens-rg AZURE_LOCATION: eastus jobs: @@ -126,7 +126,7 @@ jobs: - name: Ensure Resource Group run: | - RG_NAME="rg-whatssummarize-${{ matrix.environment }}" + RG_NAME="nl-${{ matrix.environment }}-convolens-rg" if ! az group show --name "$RG_NAME" &>/dev/null; then echo "Creating resource group: $RG_NAME" az group create --name "$RG_NAME" --location "${{ env.AZURE_LOCATION }}" @@ -135,7 +135,7 @@ jobs: - name: What-If Analysis id: whatif run: | - RG_NAME="rg-whatssummarize-${{ matrix.environment }}" + RG_NAME="nl-${{ matrix.environment }}-convolens-rg" echo "Running what-if analysis for ${{ matrix.environment }}..." az deployment group what-if \ @@ -233,7 +233,7 @@ jobs: - name: Ensure Resource Group run: | - RG_NAME="rg-whatssummarize-${{ matrix.environment }}" + RG_NAME="nl-${{ matrix.environment }}-convolens-rg" if ! az group show --name "$RG_NAME" &>/dev/null; then echo "Creating resource group: $RG_NAME" az group create \ @@ -245,7 +245,7 @@ jobs: - name: Deploy Infrastructure id: deploy run: | - RG_NAME="rg-whatssummarize-${{ matrix.environment }}" + RG_NAME="nl-${{ matrix.environment }}-convolens-rg" DEPLOYMENT_NAME="deploy-${{ github.run_id }}-${{ github.run_attempt }}" echo "Deploying to ${{ matrix.environment }}..." diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml index e459933..c37c612 100644 --- a/.github/workflows/release-validation.yml +++ b/.github/workflows/release-validation.yml @@ -89,8 +89,11 @@ jobs: id: validate run: | ENV="${{ matrix.environment }}" - PROJECT="whatssummarize" - RG="rg-${PROJECT}-${ENV}" + ORG="nl" + PROJECT="convolens" + # Naming pattern per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} + BASE="${ORG}-${ENV}-${PROJECT}" + RG="${BASE}-rg" echo "==============================================" echo "Validating Azure Resources for Release" @@ -112,8 +115,7 @@ jobs: fi # Check Key Vault - KV_NAME="kv${PROJECT}${ENV}" - KV_NAME="${KV_NAME//-/}" + KV_NAME="${BASE}-kv" echo "Checking Key Vault: $KV_NAME..." if ! az keyvault show --name "$KV_NAME" &>/dev/null; then echo "❌ Key Vault not found" @@ -133,9 +135,8 @@ jobs: done fi - # Check Storage Account - STORAGE_NAME="st${PROJECT}${ENV}" - STORAGE_NAME="${STORAGE_NAME//-/}" + # Check Storage Account (alphanumeric only) + STORAGE_NAME="${ORG}${ENV}${PROJECT}st" echo "Checking Storage Account: $STORAGE_NAME..." if ! az storage account show --name "$STORAGE_NAME" --resource-group "$RG" &>/dev/null; then echo "❌ Storage account not found" @@ -146,7 +147,7 @@ jobs: fi # Check Azure OpenAI - OPENAI_NAME="oai-${PROJECT}-${ENV}" + OPENAI_NAME="${BASE}-oai" echo "Checking Azure OpenAI: $OPENAI_NAME..." if ! az cognitiveservices account show --name "$OPENAI_NAME" --resource-group "$RG" &>/dev/null; then echo "⚠️ Azure OpenAI not found (optional)" @@ -164,7 +165,7 @@ jobs: fi # Check Cosmos DB - COSMOS_NAME="cosmos-${PROJECT}-${ENV}" + COSMOS_NAME="${BASE}-cosmos" echo "Checking Cosmos DB: $COSMOS_NAME..." if ! az cosmosdb show --name "$COSMOS_NAME" --resource-group "$RG" &>/dev/null; then echo "⚠️ Cosmos DB not found (optional)" @@ -174,7 +175,7 @@ jobs: fi # Check Redis - REDIS_NAME="redis-${PROJECT}-${ENV}" + REDIS_NAME="${BASE}-redis" echo "Checking Redis Cache: $REDIS_NAME..." if ! az redis show --name "$REDIS_NAME" --resource-group "$RG" &>/dev/null; then echo "⚠️ Redis Cache not found (optional)" @@ -184,7 +185,7 @@ jobs: fi # Check App Insights - APPI_NAME="appi-${PROJECT}-${ENV}" + APPI_NAME="${BASE}-appi" echo "Checking Application Insights: $APPI_NAME..." if ! az monitor app-insights component show --app "$APPI_NAME" --resource-group "$RG" &>/dev/null; then echo "❌ Application Insights not found" diff --git a/infra/README.md b/infra/README.md index 19bc222..4d35d71 100644 --- a/infra/README.md +++ b/infra/README.md @@ -2,10 +2,16 @@ Azure infrastructure as code using Bicep templates for ConvoLens. -> **Note:** Azure resource names (e.g. `rg-whatssummarize-*`, `kvwhatssummarizedev`, -> `cosmos-whatssummarize-dev`, `oai-whatssummarize-dev`) intentionally still -> reference the legacy project name — renaming live Azure resources is a -> separate, user-driven decision tracked in PR4 / cross-repo follow-ups. +> **Note:** On 2026-05-10 the Bicep `projectName` default flipped from +> `whatssummarize` to `convolens` AND the resource-naming convention +> migrated to the **NL standard with ADR-0027 region suffix dropped**: +> `{org}-{env}-{project}-{type}` (e.g. `nl-dev-convolens-rg`, +> `nl-dev-convolens-kv`). The previous `--` / +> `kv` patterns are gone. **No Azure resources currently +> exist under any of these names** — the first deploy creates clean +> resource groups, no migration required. Cutover is gated on the AAD +> federated-credential fix (red on `main` since 2025-12-07; tracked in +> `org-meta/docs/handoffs/2026-05-10-azure-rename-plan-omnipost-convolens.md`). ## Overview @@ -138,12 +144,12 @@ cd infra/scripts ```bash # Create resource group az group create \ - --name rg-whatssummarize-dev \ + --name nl-dev-convolens-rg \ --location eastus # Deploy az deployment group create \ - --resource-group rg-whatssummarize-dev \ + --resource-group nl-dev-convolens-rg \ --template-file bicep/main.bicep \ --parameters parameters/dev.bicepparam ``` @@ -171,7 +177,8 @@ Edit `parameters/.bicepparam` to customize: ```bicep param environment = 'dev' -param projectName = 'whatssummarize' +param org = 'nl' +param projectName = 'convolens' param location = 'eastus' // Enable/disable optional resources @@ -187,13 +194,29 @@ param openAIDeployments = [ ### Resource Naming Convention -Resources follow this naming pattern: -- `--` +Resources follow the **NL Azure Naming Standards**, with the **region suffix +dropped** per [mystira ADR-0027](https://github.com/JustAGhosT/mystira-workspace/blob/main/docs/architecture/adr/0027-azure-resource-naming-convention.md) +(adopted for convolens on 2026-05-10): -Examples: -- `rg-whatssummarize-dev` (Resource Group) -- `kvwhatssummarizedev` (Key Vault - no hyphens) -- `cosmos-whatssummarize-dev` (Cosmos DB) +``` +{org}-{env}-{project}-{type} +``` + +Region is no longer encoded in the resource name. It is expressed by the +resource group's `location` property and the `region` resource tag. + +Examples (dev environment): + +- `nl-dev-convolens-rg` (Resource Group) +- `nl-dev-convolens-kv` (Key Vault — uses hyphens; max 24 chars) +- `nldevconvolensst` (Storage Account — alphanumeric only, max 24 chars) +- `nl-dev-convolens-cosmos` (Cosmos DB) +- `nl-dev-convolens-redis` (Redis Cache) +- `nl-dev-convolens-oai` (Azure OpenAI / Cognitive Services) +- `nl-dev-convolens-appi` (Application Insights) +- `nl-dev-convolens-cae` (Container Apps Environment) +- `nl-dev-convolens-api` (Container App — backend API) +- `nl-dev-convolens-swa` (Static Web App) ## CI/CD Workflows @@ -221,11 +244,11 @@ After deployment, the script generates `.env.azure` with: AZURE_PROVIDER_ENABLED=true # Azure OpenAI -AZURE_OPENAI_ENDPOINT=https://oai-whatssummarize-dev.openai.azure.com +AZURE_OPENAI_ENDPOINT=https://nl-dev-convolens-oai.openai.azure.com AZURE_OPENAI_DEPLOYMENT=gpt-4 # Azure Cosmos DB -AZURE_COSMOS_ENDPOINT=https://cosmos-whatssummarize-dev.documents.azure.com +AZURE_COSMOS_ENDPOINT=https://nl-dev-convolens-cosmos.documents.azure.com # etc... ``` @@ -294,7 +317,7 @@ Azure OpenAI has limited regional availability. Update `location` parameter if n Ensure your identity has Key Vault access: ```bash az keyvault set-policy \ - --name kvwhatssummarizedev \ + --name nl-dev-convolens-kv \ --upn your@email.com \ --secret-permissions get list set delete ``` diff --git a/infra/bicep/main.bicep b/infra/bicep/main.bicep index f66f211..36c6a17 100644 --- a/infra/bicep/main.bicep +++ b/infra/bicep/main.bicep @@ -30,10 +30,14 @@ param environment string = 'dev' @description('Azure region for resources') param location string = resourceGroup().location -@description('Project name used for resource naming. Resource names retain the legacy "whatssummarize" prefix until a separate user-driven Azure rename decision lands.') +@description('Organisation prefix per NL Azure Naming Standards (ADR-0027 — region suffix dropped from resource names)') +@allowed(['nl', 'pvc', 'tws', 'mys']) +param org string = 'nl' + +@description('Project name used for resource naming. Renamed from "whatssummarize" to "convolens" on 2026-05-10 ahead of the GitHub repo transfer + first Azure deploy under the new name. No infra exists under the old name, so this is a clean cutover, not a migration.') @minLength(3) @maxLength(15) -param projectName string = 'whatssummarize' +param projectName string = 'convolens' @description('Logical database name used for Cosmos DB (and future Azure SQL). Decoupled from projectName so the brand rename can land without renaming Azure resources. Override at deploy time only when migrating data.') @minLength(3) @@ -82,6 +86,7 @@ param openAIDeployments array = [ @description('Tags to apply to all resources') param tags object = { + org: org project: projectName environment: environment managedBy: 'bicep' @@ -91,8 +96,12 @@ param tags object = { // Variables // ============================================================================= -var resourcePrefix = '${projectName}-${environment}' -var resourcePrefixClean = replace(resourcePrefix, '-', '') +// Naming pattern per ADR-0027 (no region suffix): +// {org}-{env}-{project}-{type} e.g. nl-dev-convolens-kv +// Storage Account name constraint (alphanumeric only, max 24 chars) requires +// a hyphen-stripped variant; everything else uses the hyphenated form. +var base = '${org}-${environment}-${projectName}' +var baseAlphanumeric = replace(base, '-', '') // SKUs based on environment // Redis: Basic (no SLA) for dev, Standard (SLA) for prod @@ -117,7 +126,7 @@ var containerAppMaxReplicas = environment == 'prod' ? 10 : 3 module keyVault 'modules/key-vault.bicep' = { name: 'keyVault-${environment}' params: { - name: 'kv-${resourcePrefixClean}' + name: '${base}-kv' location: location tags: tags enableSoftDelete: environment == 'prod' @@ -129,7 +138,7 @@ module keyVault 'modules/key-vault.bicep' = { module storage 'modules/storage.bicep' = { name: 'storage-${environment}' params: { - name: 'st${resourcePrefixClean}' + name: '${baseAlphanumeric}st' location: location tags: tags sku: storageSkuName @@ -142,7 +151,7 @@ module storage 'modules/storage.bicep' = { module openAI 'modules/openai.bicep' = if (enableOpenAI) { name: 'openai-${environment}' params: { - name: 'oai-${resourcePrefix}' + name: '${base}-oai' location: location // Note: OpenAI has limited region availability tags: tags deployments: openAIDeployments @@ -154,7 +163,7 @@ module openAI 'modules/openai.bicep' = if (enableOpenAI) { module cosmosDB 'modules/cosmos-db.bicep' = if (enableCosmosDB) { name: 'cosmosdb-${environment}' params: { - name: 'cosmos-${resourcePrefix}' + name: '${base}-cosmos' location: location tags: tags databaseName: databaseName @@ -180,7 +189,7 @@ module cosmosDB 'modules/cosmos-db.bicep' = if (enableCosmosDB) { module redis 'modules/redis.bicep' = if (enableRedis) { name: 'redis-${environment}' params: { - name: 'redis-${resourcePrefix}' + name: '${base}-redis' location: location tags: tags sku: redisSku @@ -194,7 +203,7 @@ module redis 'modules/redis.bicep' = if (enableRedis) { module appInsights 'modules/app-insights.bicep' = { name: 'appinsights-${environment}' params: { - name: 'appi-${resourcePrefix}' + name: '${base}-appi' location: location tags: tags } @@ -204,8 +213,8 @@ module appInsights 'modules/app-insights.bicep' = { module containerApps 'modules/container-apps.bicep' = if (enableContainerApps) { name: 'containerapps-${environment}' params: { - environmentName: 'cae-${resourcePrefix}' - apiAppName: 'ca-${resourcePrefix}-api' + environmentName: '${base}-cae' + apiAppName: '${base}-api' location: location tags: tags appInsightsConnectionString: appInsights.outputs.connectionString @@ -226,7 +235,7 @@ module containerApps 'modules/container-apps.bicep' = if (enableContainerApps) { module staticWebApp 'modules/static-web-app.bicep' = if (enableStaticWebApps) { name: 'staticwebapp-${environment}' params: { - name: 'stapp-${resourcePrefix}' + name: '${base}-swa' location: location tags: tags apiUrl: enableContainerApps ? containerApps.outputs.apiUrl : '' @@ -237,7 +246,7 @@ module staticWebApp 'modules/static-web-app.bicep' = if (enableStaticWebApps) { module budgetAlerts 'modules/budget-alerts.bicep' = if (enableBudgetAlerts && !empty(adminEmail)) { name: 'budget-${environment}' params: { - name: 'budget-${resourcePrefix}' + name: '${base}-budget' amount: monthlyBudgetAmount contactEmails: [adminEmail] environment: environment diff --git a/infra/parameters/dev.bicepparam b/infra/parameters/dev.bicepparam index caa0c41..5f88084 100644 --- a/infra/parameters/dev.bicepparam +++ b/infra/parameters/dev.bicepparam @@ -4,7 +4,8 @@ using '../bicep/main.bicep' param environment = 'dev' -param projectName = 'whatssummarize' +param org = 'nl' +param projectName = 'convolens' param location = 'eastus' // Enable services for development @@ -22,7 +23,8 @@ param openAIDeployments = [] param adminEmail = '' param tags = { - project: 'whatssummarize' + org: 'nl' + project: 'convolens' environment: 'dev' managedBy: 'bicep' costCenter: 'development' diff --git a/infra/parameters/prod.bicepparam b/infra/parameters/prod.bicepparam index 5ae0fcd..78f54d5 100644 --- a/infra/parameters/prod.bicepparam +++ b/infra/parameters/prod.bicepparam @@ -4,7 +4,8 @@ using '../bicep/main.bicep' param environment = 'prod' -param projectName = 'whatssummarize' +param org = 'nl' +param projectName = 'convolens' param location = 'eastus' // Enable services for production @@ -19,10 +20,11 @@ param enableStaticWebApps = true // Configure Azure AI Foundry manually and set AZURE_OPENAI_* env vars param openAIDeployments = [] -param adminEmail = 'admin@whatssummarize.com' +param adminEmail = 'admin@convolens.com' param tags = { - project: 'whatssummarize' + org: 'nl' + project: 'convolens' environment: 'prod' managedBy: 'bicep' costCenter: 'production' diff --git a/infra/parameters/staging.bicepparam b/infra/parameters/staging.bicepparam index e4ea669..7484a76 100644 --- a/infra/parameters/staging.bicepparam +++ b/infra/parameters/staging.bicepparam @@ -7,7 +7,8 @@ using '../bicep/main.bicep' param environment = 'staging' -param projectName = 'whatssummarize' +param org = 'nl' +param projectName = 'convolens' param location = 'eastus' // Enable services for staging (mirrors production) @@ -22,10 +23,11 @@ param enableStaticWebApps = true // Configure Azure AI Foundry manually and set AZURE_OPENAI_* env vars param openAIDeployments = [] -param adminEmail = 'staging-alerts@whatssummarize.com' +param adminEmail = 'staging-alerts@convolens.com' param tags = { - project: 'whatssummarize' + org: 'nl' + project: 'convolens' environment: 'staging' managedBy: 'bicep' costCenter: 'staging' diff --git a/infra/scripts/deploy.ps1 b/infra/scripts/deploy.ps1 index 8398568..18240e6 100644 --- a/infra/scripts/deploy.ps1 +++ b/infra/scripts/deploy.ps1 @@ -36,7 +36,8 @@ param( # ============================================================================= $ErrorActionPreference = 'Stop' -$ProjectName = 'whatssummarize' +$Org = 'nl' +$ProjectName = 'convolens' $Location = 'eastus' $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path @@ -44,7 +45,10 @@ $InfraDir = Split-Path -Parent $ScriptDir $BicepDir = Join-Path $InfraDir 'bicep' $ParamsDir = Join-Path $InfraDir 'parameters' -$ResourceGroup = "rg-$ProjectName-$Environment" +# Naming pattern per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} +$Base = "$Org-$Environment-$ProjectName" +$BaseAlphanumeric = "$Org$Environment$ProjectName" +$ResourceGroup = "$Base-rg" # ============================================================================= # Utility Functions @@ -314,7 +318,7 @@ function New-EnvironmentFile { param([string]$DeploymentName) $envFile = Join-Path $InfraDir '..' 'apps' 'api' '.env.azure' - $kvName = "kv$ProjectName$Environment" -replace '-', '' + $kvName = "$Base-kv" try { $outputs = az deployment group show ` diff --git a/infra/scripts/deploy.sh b/infra/scripts/deploy.sh index d17db3f..23c9d1e 100755 --- a/infra/scripts/deploy.sh +++ b/infra/scripts/deploy.sh @@ -39,7 +39,8 @@ WHAT_IF=false VALIDATE_ONLY=false SKIP_WHAT_IF=false FORCE=false -PROJECT_NAME="whatssummarize" +ORG="nl" +PROJECT_NAME="convolens" LOCATION="eastus" # Parse arguments @@ -106,8 +107,10 @@ INFRA_DIR="$(dirname "$SCRIPT_DIR")" BICEP_DIR="${INFRA_DIR}/bicep" PARAMS_DIR="${INFRA_DIR}/parameters" -# Resource naming -RESOURCE_GROUP="rg-${PROJECT_NAME}-${ENVIRONMENT}" +# Resource naming per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} +BASE="${ORG}-${ENVIRONMENT}-${PROJECT_NAME}" +BASE_ALPHANUMERIC="${ORG}${ENVIRONMENT}${PROJECT_NAME}" +RESOURCE_GROUP="${BASE}-rg" # ============================================================================= # Utility Functions @@ -214,7 +217,7 @@ ensure_resource_group() { run_what_if() { local param_file="${PARAMS_DIR}/${ENVIRONMENT}.bicepparam" - local deployment_name="whatssummarize-${ENVIRONMENT}-whatif-$(date +%Y%m%d-%H%M%S)" + local deployment_name="convolens-${ENVIRONMENT}-whatif-$(date +%Y%m%d-%H%M%S)" echo "" echo -e "${YELLOW}==============================================" @@ -266,7 +269,7 @@ confirm_deployment() { deploy_infrastructure() { local param_file="${PARAMS_DIR}/${ENVIRONMENT}.bicepparam" - local deployment_name="whatssummarize-${ENVIRONMENT}-$(date +%Y%m%d-%H%M%S)" + local deployment_name="convolens-${ENVIRONMENT}-$(date +%Y%m%d-%H%M%S)" log_info "Starting deployment: $deployment_name" log_info "Environment: $ENVIRONMENT" @@ -347,8 +350,7 @@ post_deployment() { generate_env_file() { local env_file="${INFRA_DIR}/../apps/api/.env.azure" - local kv_name="kv${PROJECT_NAME}${ENVIRONMENT}" - kv_name="${kv_name//-/}" + local kv_name="${BASE}-kv" log_info "Generating .env.azure file..." diff --git a/infra/scripts/validate-resources.ps1 b/infra/scripts/validate-resources.ps1 index 034716f..e620f11 100644 --- a/infra/scripts/validate-resources.ps1 +++ b/infra/scripts/validate-resources.ps1 @@ -24,8 +24,12 @@ param( # ============================================================================= $ErrorActionPreference = 'Continue' -$ProjectName = 'whatssummarize' -$ResourceGroup = "rg-$ProjectName-$Environment" +$Org = 'nl' +$ProjectName = 'convolens' +# Naming pattern per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} +$Base = "$Org-$Environment-$ProjectName" +$BaseAlphanumeric = "$Org$Environment$ProjectName" +$ResourceGroup = "$Base-rg" # Track validation results $script:ValidationPassed = $true @@ -103,15 +107,15 @@ function Test-AllResources { -Required $true # Key Vault - $kvName = "kv$ProjectName$Environment" -replace '-', '' + $kvName = "$Base-kv" Test-ResourceExists ` -ResourceName $kvName ` -ResourceType "Key Vault" ` -CheckScript { az keyvault show --name $kvName -o json } ` -Required $true - # Storage Account - $storageName = "st$ProjectName$Environment" -replace '-', '' + # Storage Account (alphanumeric only, max 24 chars) + $storageName = "${BaseAlphanumeric}st" Test-ResourceExists ` -ResourceName $storageName ` -ResourceType "Storage Account" ` @@ -119,7 +123,7 @@ function Test-AllResources { -Required $true # Application Insights - $appiName = "appi-$ProjectName-$Environment" + $appiName = "$Base-appi" Test-ResourceExists ` -ResourceName $appiName ` -ResourceType "Application Insights" ` @@ -127,7 +131,7 @@ function Test-AllResources { -Required $true # Container Apps Environment - $caeName = "cae-$ProjectName-$Environment" + $caeName = "$Base-cae" Test-ResourceExists ` -ResourceName $caeName ` -ResourceType "Container Apps Environment" ` @@ -141,7 +145,7 @@ function Test-AllResources { Write-Host "-------------------" -ForegroundColor Gray # Azure OpenAI / AI Foundry - $openaiName = "oai-$ProjectName-$Environment" + $openaiName = "$Base-oai" Test-ResourceExists ` -ResourceName $openaiName ` -ResourceType "Azure OpenAI" ` @@ -149,7 +153,7 @@ function Test-AllResources { -Required $false # Cosmos DB - $cosmosName = "cosmos-$ProjectName-$Environment" + $cosmosName = "$Base-cosmos" Test-ResourceExists ` -ResourceName $cosmosName ` -ResourceType "Cosmos DB" ` @@ -157,7 +161,7 @@ function Test-AllResources { -Required $false # Redis Cache - $redisName = "redis-$ProjectName-$Environment" + $redisName = "$Base-redis" Test-ResourceExists ` -ResourceName $redisName ` -ResourceType "Redis Cache" ` @@ -165,7 +169,7 @@ function Test-AllResources { -Required $false # Static Web App - $swaName = "swa-$ProjectName-$Environment" + $swaName = "$Base-swa" Test-ResourceExists ` -ResourceName $swaName ` -ResourceType "Static Web App" ` @@ -180,7 +184,7 @@ function Test-AllResources { # ============================================================================= function Test-KeyVaultSecrets { - $kvName = "kv$ProjectName$Environment" -replace '-', '' + $kvName = "$Base-kv" Write-Host "Key Vault Secrets:" -ForegroundColor White Write-Host "------------------" -ForegroundColor Gray diff --git a/infra/scripts/validate-resources.sh b/infra/scripts/validate-resources.sh index 0d592ab..a3cdf8a 100755 --- a/infra/scripts/validate-resources.sh +++ b/infra/scripts/validate-resources.sh @@ -29,8 +29,12 @@ NC='\033[0m' # No Color # Configuration ENVIRONMENT="${1:-dev}" STRICT_MODE="${2:-}" -PROJECT_NAME="whatssummarize" -RESOURCE_PREFIX="${PROJECT_NAME}-${ENVIRONMENT}" +ORG="nl" +PROJECT_NAME="convolens" +# Naming pattern per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} +BASE="${ORG}-${ENVIRONMENT}-${PROJECT_NAME}" +# Hyphen-stripped variant for resources where Azure forbids hyphens (storage) +BASE_ALPHANUMERIC="${ORG}${ENVIRONMENT}${PROJECT_NAME}" # Counters TOTAL_CHECKS=0 @@ -88,7 +92,7 @@ check_resource() { # ============================================================================= validate_resource_group() { - local rg_name="rg-${RESOURCE_PREFIX}" + local rg_name="${BASE}-rg" log_info "Checking Resource Group: $rg_name" ((TOTAL_CHECKS++)) @@ -104,8 +108,7 @@ validate_resource_group() { } validate_key_vault() { - local kv_name="kv${PROJECT_NAME}${ENVIRONMENT}" - kv_name="${kv_name//-/}" # Remove hyphens + local kv_name="${BASE}-kv" log_info "Checking Key Vault..." check_resource "Microsoft.KeyVault/vaults" "$kv_name" "$RESOURCE_GROUP" "true" @@ -127,8 +130,8 @@ validate_key_vault() { } validate_storage() { - local storage_name="st${PROJECT_NAME}${ENVIRONMENT}" - storage_name="${storage_name//-/}" + # Storage Account names: alphanumeric only, max 24 chars + local storage_name="${BASE_ALPHANUMERIC}st" log_info "Checking Storage Account..." check_resource "Microsoft.Storage/storageAccounts" "$storage_name" "$RESOURCE_GROUP" "true" @@ -152,7 +155,7 @@ validate_storage() { } validate_openai() { - local openai_name="oai-${RESOURCE_PREFIX}" + local openai_name="${BASE}-oai" log_info "Checking Azure OpenAI..." @@ -179,7 +182,7 @@ validate_openai() { } validate_cosmos_db() { - local cosmos_name="cosmos-${RESOURCE_PREFIX}" + local cosmos_name="${BASE}-cosmos" log_info "Checking Cosmos DB..." @@ -206,7 +209,7 @@ validate_cosmos_db() { } validate_redis() { - local redis_name="redis-${RESOURCE_PREFIX}" + local redis_name="${BASE}-redis" log_info "Checking Redis Cache..." @@ -228,15 +231,15 @@ validate_redis() { } validate_app_insights() { - local appi_name="appi-${RESOURCE_PREFIX}" + local appi_name="${BASE}-appi" log_info "Checking Application Insights..." check_resource "Microsoft.Insights/components" "$appi_name" "$RESOURCE_GROUP" "true" } validate_container_apps() { - local env_name="cae-${RESOURCE_PREFIX}" - local app_name="ca-${RESOURCE_PREFIX}-api" + local env_name="${BASE}-cae" + local app_name="${BASE}-api" log_info "Checking Container Apps Environment..." @@ -267,7 +270,7 @@ validate_container_apps() { } validate_static_web_app() { - local swa_name="stapp-${RESOURCE_PREFIX}" + local swa_name="${BASE}-swa" log_info "Checking Static Web App..." From 75248803412405715af13ad7758df4371afd30d9 Mon Sep 17 00:00:00 2001 From: Jurie Smit Date: Sun, 10 May 2026 19:05:15 +0200 Subject: [PATCH 2/3] chore(infra): apply review fixes for convolens rename + NL naming - release-validation.yml: fix CAE/CA names to use ${BASE}-cae and ${BASE}-api so they match what bicep deploys (nl-{env}-convolens-*); align MISSING tags (container-app-api -> container-apps-api) with the report-table 'container-apps' substring lookup. - deploy.sh, infrastructure.yml: add org tag to RG creation; standardise managedBy=bicep across all paths (workflow was using managedBy=github-actions). - deploy.sh, deploy.ps1: remove dead BASE_ALPHANUMERIC / $BaseAlphanumeric variable (unused after storage name moved into bicep template). - validate-resources.sh: support COSMOS_DATABASE_NAME env override so the Cosmos check no longer assumes databaseName == projectName. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/infrastructure.yml | 2 +- .github/workflows/release-validation.yml | 8 ++++---- infra/scripts/deploy.ps1 | 3 +-- infra/scripts/deploy.sh | 3 +-- infra/scripts/validate-resources.sh | 4 +++- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/infrastructure.yml b/.github/workflows/infrastructure.yml index 10c4162..2e69d4c 100644 --- a/.github/workflows/infrastructure.yml +++ b/.github/workflows/infrastructure.yml @@ -239,7 +239,7 @@ jobs: az group create \ --name "$RG_NAME" \ --location "${{ env.AZURE_LOCATION }}" \ - --tags project=convolens environment=${{ matrix.environment }} managedBy=github-actions + --tags org=nl project=convolens environment=${{ matrix.environment }} managedBy=bicep fi - name: Deploy Infrastructure diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml index c37c612..2b95150 100644 --- a/.github/workflows/release-validation.yml +++ b/.github/workflows/release-validation.yml @@ -195,8 +195,8 @@ jobs: echo "✅ Application Insights exists" fi - # Check Container Apps - CAE_NAME="cae-${PROJECT}-${ENV}" + # Check Container Apps (names match bicep BASE pattern: ${BASE}-cae, ${BASE}-api) + CAE_NAME="${BASE}-cae" echo "Checking Container Apps Environment: $CAE_NAME..." if ! az containerapp env show --name "$CAE_NAME" --resource-group "$RG" &>/dev/null; then echo "❌ Container Apps Environment not found" @@ -206,10 +206,10 @@ jobs: echo "✅ Container Apps Environment exists" # Check API app - CA_NAME="ca-${PROJECT}-${ENV}-api" + CA_NAME="${BASE}-api" if ! az containerapp show --name "$CA_NAME" --resource-group "$RG" &>/dev/null; then echo "❌ API Container App not found" - MISSING="${MISSING}container-app-api," + MISSING="${MISSING}container-apps-api," PASSED="false" else echo "✅ API Container App exists" diff --git a/infra/scripts/deploy.ps1 b/infra/scripts/deploy.ps1 index 18240e6..8ecfbe0 100644 --- a/infra/scripts/deploy.ps1 +++ b/infra/scripts/deploy.ps1 @@ -47,7 +47,6 @@ $ParamsDir = Join-Path $InfraDir 'parameters' # Naming pattern per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} $Base = "$Org-$Environment-$ProjectName" -$BaseAlphanumeric = "$Org$Environment$ProjectName" $ResourceGroup = "$Base-rg" # ============================================================================= @@ -160,7 +159,7 @@ function Ensure-ResourceGroup { az group create ` --name $ResourceGroup ` --location $Location ` - --tags project=$ProjectName environment=$Environment managedBy=bicep | Out-Null + --tags org=$Org project=$ProjectName environment=$Environment managedBy=bicep | Out-Null Write-SuccessMessage "Resource group created" } } diff --git a/infra/scripts/deploy.sh b/infra/scripts/deploy.sh index 23c9d1e..a5d93ea 100755 --- a/infra/scripts/deploy.sh +++ b/infra/scripts/deploy.sh @@ -109,7 +109,6 @@ PARAMS_DIR="${INFRA_DIR}/parameters" # Resource naming per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} BASE="${ORG}-${ENVIRONMENT}-${PROJECT_NAME}" -BASE_ALPHANUMERIC="${ORG}${ENVIRONMENT}${PROJECT_NAME}" RESOURCE_GROUP="${BASE}-rg" # ============================================================================= @@ -206,7 +205,7 @@ ensure_resource_group() { az group create \ --name "$RESOURCE_GROUP" \ --location "$LOCATION" \ - --tags project="$PROJECT_NAME" environment="$ENVIRONMENT" managedBy="bicep" + --tags org="$ORG" project="$PROJECT_NAME" environment="$ENVIRONMENT" managedBy="bicep" log_success "Resource group created" fi } diff --git a/infra/scripts/validate-resources.sh b/infra/scripts/validate-resources.sh index a3cdf8a..bdd0942 100755 --- a/infra/scripts/validate-resources.sh +++ b/infra/scripts/validate-resources.sh @@ -192,7 +192,9 @@ validate_cosmos_db() { # Check database and containers log_info "Checking Cosmos DB containers..." - local containers=$(az cosmosdb sql container list --account-name "$cosmos_name" --database-name "$PROJECT_NAME" --resource-group "$RESOURCE_GROUP" -o json 2>/dev/null) + # COSMOS_DATABASE_NAME may be set explicitly if bicep databaseName param differs from projectName + local db_name="${COSMOS_DATABASE_NAME:-$PROJECT_NAME}" + local containers=$(az cosmosdb sql container list --account-name "$cosmos_name" --database-name "$db_name" --resource-group "$RESOURCE_GROUP" -o json 2>/dev/null) local required_containers=("users" "chats" "summaries") for container in "${required_containers[@]}"; do From 3396106759ac99de7c60795738be5d72479c568c Mon Sep 17 00:00:00 2001 From: Jurie Smit Date: Mon, 11 May 2026 07:37:58 +0200 Subject: [PATCH 3/3] chore(infra): migrate Bicep -> Terraform Replaces the bicep tree (main + 9 modules + 3 .bicepparam + 4 deploy / validate scripts) with an equivalent Terraform stack at infra/terraform/env/dev/. Why: bicep deployment in eastus repeatedly failed with ServiceUnavailable on Cosmos DB AZ-redundant accounts despite the template requesting non-AZ mode. Rather than chase a region-specific bicep workaround, switch to the AzureRM Terraform pattern already used by sluice; easier cross-project state hygiene and a single tool to teach. Stack (env/dev/main.tf): - RG, Log Analytics, App Insights, Storage Account + 3 blob containers, Cosmos serverless + 3 SQL containers, Key Vault + 5 secrets, Container Apps Environment + API Container App, Static Web App. - Redis and OpenAI flag-gated (off by default in dev). Budget alerts auto-skip when admin_email is empty. - Naming follows ADR-0027: {org}-{env}-{project}-{type}, no region suffix. - Region: eastus2 (healthier Cosmos capacity than eastus). Remote state: - nl-tfstate-rg / nltfstateconvolens / tfstate container. - Key convolens-dev.tfstate. Default azurerm backend (access keys via Contributor RBAC). Auth (configured in tenant 9530cd32-9e33-47f0-9247-ed964730b580): - AAD app convolens-sp (clientId 8f57a349-eb88-40fd-81eb-1065f84e668b). - Federated cred subject: repo:neuralliquid/convolens:environment:dev. - Sub-scoped Contributor for the SP on bb4e3882-... - env-scoped AZURE_CREDENTIALS GitHub secret on environment dev. Workflow: - .github/workflows/infrastructure.yml rewritten: validate (fmt-check + validate), plan (with PR comment), apply (push to main / dispatch), verify. - release-validation.yml: comment-only update referencing TF instead of bicep. Verified locally: terraform apply succeeded; all 23 resources up in nl-dev-convolens-rg. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/infrastructure.yml | 330 ++++++--------- .github/workflows/release-validation.yml | 2 +- docs/AZURE_SETUP.md | 8 +- infra/README.md | 418 ++++++------------- infra/bicep/main.bicep | 280 ------------- infra/bicep/modules/app-insights.bicep | 82 ---- infra/bicep/modules/budget-alerts.bicep | 140 ------- infra/bicep/modules/container-apps.bicep | 189 --------- infra/bicep/modules/cosmos-db.bicep | 137 ------ infra/bicep/modules/key-vault.bicep | 58 --- infra/bicep/modules/openai.bicep | 91 ---- infra/bicep/modules/redis.bicep | 88 ---- infra/bicep/modules/static-web-app.bicep | 78 ---- infra/bicep/modules/storage.bicep | 100 ----- infra/parameters/dev.bicepparam | 31 -- infra/parameters/prod.bicepparam | 32 -- infra/parameters/staging.bicepparam | 36 -- infra/scripts/deploy.ps1 | 432 ------------------- infra/scripts/deploy.sh | 441 -------------------- infra/scripts/validate-resources.ps1 | 307 -------------- infra/scripts/validate-resources.sh | 370 ---------------- infra/terraform/.gitignore | 14 + infra/terraform/env/dev/.terraform.lock.hcl | 42 ++ infra/terraform/env/dev/backend.hcl | 4 + infra/terraform/env/dev/main.tf | 415 ++++++++++++++++++ infra/terraform/env/dev/outputs.tf | 71 ++++ infra/terraform/env/dev/terraform.tf | 28 ++ infra/terraform/env/dev/terraform.tfvars | 22 + infra/terraform/env/dev/variables.tf | 103 +++++ 29 files changed, 956 insertions(+), 3393 deletions(-) delete mode 100644 infra/bicep/main.bicep delete mode 100644 infra/bicep/modules/app-insights.bicep delete mode 100644 infra/bicep/modules/budget-alerts.bicep delete mode 100644 infra/bicep/modules/container-apps.bicep delete mode 100644 infra/bicep/modules/cosmos-db.bicep delete mode 100644 infra/bicep/modules/key-vault.bicep delete mode 100644 infra/bicep/modules/openai.bicep delete mode 100644 infra/bicep/modules/redis.bicep delete mode 100644 infra/bicep/modules/static-web-app.bicep delete mode 100644 infra/bicep/modules/storage.bicep delete mode 100644 infra/parameters/dev.bicepparam delete mode 100644 infra/parameters/prod.bicepparam delete mode 100644 infra/parameters/staging.bicepparam delete mode 100644 infra/scripts/deploy.ps1 delete mode 100755 infra/scripts/deploy.sh delete mode 100644 infra/scripts/validate-resources.ps1 delete mode 100755 infra/scripts/validate-resources.sh create mode 100644 infra/terraform/.gitignore create mode 100644 infra/terraform/env/dev/.terraform.lock.hcl create mode 100644 infra/terraform/env/dev/backend.hcl create mode 100644 infra/terraform/env/dev/main.tf create mode 100644 infra/terraform/env/dev/outputs.tf create mode 100644 infra/terraform/env/dev/terraform.tf create mode 100644 infra/terraform/env/dev/terraform.tfvars create mode 100644 infra/terraform/env/dev/variables.tf diff --git a/.github/workflows/infrastructure.yml b/.github/workflows/infrastructure.yml index 2e69d4c..0785d9d 100644 --- a/.github/workflows/infrastructure.yml +++ b/.github/workflows/infrastructure.yml @@ -31,8 +31,8 @@ on: type: choice options: - validate - - what-if - - deploy + - plan + - apply permissions: id-token: write @@ -40,47 +40,44 @@ permissions: pull-requests: write env: - AZURE_RESOURCE_GROUP_DEV: nl-dev-convolens-rg - AZURE_RESOURCE_GROUP_STAGING: nl-staging-convolens-rg - AZURE_RESOURCE_GROUP_PROD: nl-prod-convolens-rg - AZURE_LOCATION: eastus + TF_VERSION: '1.14.7' + TF_IN_AUTOMATION: 'true' + TF_INPUT: '0' + ARM_USE_OIDC: 'true' jobs: # ============================================================================= - # Validate Bicep Templates + # Validate Terraform # ============================================================================= validate: - name: Validate Templates + name: Validate Terraform runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - name: Install Bicep CLI - run: | - az bicep install - az config set bicep.use_binary_from_path=false + - name: Setup Terraform + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ env.TF_VERSION }} - - name: Validate Bicep Syntax - run: | - echo "Validating Bicep files..." - az bicep build --file infra/bicep/main.bicep --stdout > /dev/null - echo "✅ Bicep syntax validation passed" + - name: Terraform Format Check + run: terraform -chdir=infra/terraform/env/dev fmt -check -recursive - - name: Lint Bicep Files - run: | - echo "Linting Bicep files..." - # Install bicep linter if available - az bicep lint --file infra/bicep/main.bicep 2>/dev/null || echo "Linting not available, skipping..." + - name: Terraform Init (no backend) + run: terraform -chdir=infra/terraform/env/dev init -backend=false + + - name: Terraform Validate + run: terraform -chdir=infra/terraform/env/dev validate # ============================================================================= - # Preview Changes (What-If) + # Plan Changes (PR preview / workflow_dispatch=plan) # ============================================================================= - preview: - name: Preview Changes (${{ matrix.environment }}) + plan: + name: Plan (${{ matrix.environment }}) runs-on: ubuntu-latest needs: validate - if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'what-if') + if: github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'plan') strategy: matrix: environment: ${{ github.event_name == 'workflow_dispatch' && fromJson(format('["{0}"]', github.event.inputs.environment)) || fromJson('["dev"]') }} @@ -90,6 +87,11 @@ jobs: - name: Checkout uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Setup Terraform + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ env.TF_VERSION }} + - name: Parse Azure Credentials id: azure-creds env: @@ -99,104 +101,91 @@ jobs: CLIENT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r '.clientId') TENANT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r '.tenantId') SUBSCRIPTION_ID=$(echo "$AZURE_CREDENTIALS" | jq -r '.subscriptionId') - - # Validate that parsing succeeded - if [ "$CLIENT_ID" = "null" ] || [ -z "$CLIENT_ID" ]; then - echo "Error: Failed to parse clientId from AZURE_CREDENTIALS" - exit 1 - fi - if [ "$TENANT_ID" = "null" ] || [ -z "$TENANT_ID" ]; then - echo "Error: Failed to parse tenantId from AZURE_CREDENTIALS" - exit 1 - fi - if [ "$SUBSCRIPTION_ID" = "null" ] || [ -z "$SUBSCRIPTION_ID" ]; then - echo "Error: Failed to parse subscriptionId from AZURE_CREDENTIALS" - exit 1 - fi - - echo "client-id=$CLIENT_ID" >> $GITHUB_OUTPUT - echo "tenant-id=$TENANT_ID" >> $GITHUB_OUTPUT - echo "subscription-id=$SUBSCRIPTION_ID" >> $GITHUB_OUTPUT - - name: Azure Login + for v in "$CLIENT_ID" "$TENANT_ID" "$SUBSCRIPTION_ID"; do + if [ "$v" = "null" ] || [ -z "$v" ]; then + echo "Error: AZURE_CREDENTIALS missing clientId/tenantId/subscriptionId" >&2 + exit 1 + fi + done + echo "client-id=$CLIENT_ID" >> "$GITHUB_OUTPUT" + echo "tenant-id=$TENANT_ID" >> "$GITHUB_OUTPUT" + echo "subscription-id=$SUBSCRIPTION_ID" >> "$GITHUB_OUTPUT" + + - name: Azure Login (OIDC) uses: azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1 with: client-id: ${{ steps.azure-creds.outputs.client-id }} tenant-id: ${{ steps.azure-creds.outputs.tenant-id }} subscription-id: ${{ steps.azure-creds.outputs.subscription-id }} - - name: Ensure Resource Group - run: | - RG_NAME="nl-${{ matrix.environment }}-convolens-rg" - if ! az group show --name "$RG_NAME" &>/dev/null; then - echo "Creating resource group: $RG_NAME" - az group create --name "$RG_NAME" --location "${{ env.AZURE_LOCATION }}" - fi + - name: Terraform Init + env: + ARM_CLIENT_ID: ${{ steps.azure-creds.outputs.client-id }} + ARM_TENANT_ID: ${{ steps.azure-creds.outputs.tenant-id }} + ARM_SUBSCRIPTION_ID: ${{ steps.azure-creds.outputs.subscription-id }} + run: terraform -chdir=infra/terraform/env/${{ matrix.environment }} init -backend-config=backend.hcl - - name: What-If Analysis - id: whatif + - name: Terraform Plan + id: plan + env: + ARM_CLIENT_ID: ${{ steps.azure-creds.outputs.client-id }} + ARM_TENANT_ID: ${{ steps.azure-creds.outputs.tenant-id }} + ARM_SUBSCRIPTION_ID: ${{ steps.azure-creds.outputs.subscription-id }} run: | - RG_NAME="nl-${{ matrix.environment }}-convolens-rg" - echo "Running what-if analysis for ${{ matrix.environment }}..." - - az deployment group what-if \ - --resource-group "$RG_NAME" \ - --template-file infra/bicep/main.bicep \ - --parameters infra/parameters/${{ matrix.environment }}.bicepparam \ - --name "whatif-${{ github.run_id }}" \ - > whatif-output.txt 2>&1 || true - - cat whatif-output.txt - - # Save for PR comment - echo "whatif<> $GITHUB_OUTPUT - cat whatif-output.txt >> $GITHUB_OUTPUT - echo "EOF" >> $GITHUB_OUTPUT + terraform -chdir=infra/terraform/env/${{ matrix.environment }} plan -no-color -out=tfplan | tee plan-output.txt + { + echo "plan<> "$GITHUB_OUTPUT" - name: Comment on PR if: github.event_name == 'pull_request' uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + env: + PLAN_OUTPUT: ${{ steps.plan.outputs.plan }} with: script: | - const output = `### Infrastructure What-If Analysis (${{ matrix.environment }}) - -
- Click to expand - - \`\`\` - ${{ steps.whatif.outputs.whatif }} - \`\`\` - -
- - *Run ID: ${{ github.run_id }}*`; - - github.rest.issues.createComment({ + const planOutput = process.env.PLAN_OUTPUT || ''; + const truncated = planOutput.length > 60000 + ? planOutput.slice(0, 60000) + '\n\n[…output truncated…]' + : planOutput; + const body = `### Terraform Plan (\`${{ matrix.environment }}\`)\n\n
\nClick to expand\n\n\`\`\`\n${truncated}\n\`\`\`\n\n
\n\n*Run ID: ${{ github.run_id }}*`; + await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: output + body }); # ============================================================================= - # Deploy Infrastructure + # Apply (push to main / workflow_dispatch=apply) # ============================================================================= - deploy: - name: Deploy (${{ matrix.environment }}) + apply: + name: Apply (${{ matrix.environment }}) runs-on: ubuntu-latest needs: validate if: | (github.event_name == 'push' && github.ref == 'refs/heads/main') || - (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'deploy') + (github.event_name == 'workflow_dispatch' && github.event.inputs.action == 'apply') strategy: matrix: environment: ${{ github.event_name == 'workflow_dispatch' && fromJson(format('["{0}"]', github.event.inputs.environment)) || fromJson('["dev"]') }} max-parallel: 1 environment: ${{ matrix.environment }} + outputs: + container_app_url: ${{ steps.outputs.outputs.container_app_url }} steps: - name: Checkout uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Setup Terraform + uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: ${{ env.TF_VERSION }} + terraform_wrapper: false + - name: Parse Azure Credentials id: azure-creds env: @@ -206,148 +195,73 @@ jobs: CLIENT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r '.clientId') TENANT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r '.tenantId') SUBSCRIPTION_ID=$(echo "$AZURE_CREDENTIALS" | jq -r '.subscriptionId') - - # Validate that parsing succeeded - if [ "$CLIENT_ID" = "null" ] || [ -z "$CLIENT_ID" ]; then - echo "Error: Failed to parse clientId from AZURE_CREDENTIALS" - exit 1 - fi - if [ "$TENANT_ID" = "null" ] || [ -z "$TENANT_ID" ]; then - echo "Error: Failed to parse tenantId from AZURE_CREDENTIALS" - exit 1 - fi - if [ "$SUBSCRIPTION_ID" = "null" ] || [ -z "$SUBSCRIPTION_ID" ]; then - echo "Error: Failed to parse subscriptionId from AZURE_CREDENTIALS" - exit 1 - fi - - echo "client-id=$CLIENT_ID" >> $GITHUB_OUTPUT - echo "tenant-id=$TENANT_ID" >> $GITHUB_OUTPUT - echo "subscription-id=$SUBSCRIPTION_ID" >> $GITHUB_OUTPUT - - name: Azure Login + for v in "$CLIENT_ID" "$TENANT_ID" "$SUBSCRIPTION_ID"; do + if [ "$v" = "null" ] || [ -z "$v" ]; then + echo "Error: AZURE_CREDENTIALS missing clientId/tenantId/subscriptionId" >&2 + exit 1 + fi + done + echo "client-id=$CLIENT_ID" >> "$GITHUB_OUTPUT" + echo "tenant-id=$TENANT_ID" >> "$GITHUB_OUTPUT" + echo "subscription-id=$SUBSCRIPTION_ID" >> "$GITHUB_OUTPUT" + + - name: Azure Login (OIDC) uses: azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1 with: client-id: ${{ steps.azure-creds.outputs.client-id }} tenant-id: ${{ steps.azure-creds.outputs.tenant-id }} subscription-id: ${{ steps.azure-creds.outputs.subscription-id }} - - name: Ensure Resource Group - run: | - RG_NAME="nl-${{ matrix.environment }}-convolens-rg" - if ! az group show --name "$RG_NAME" &>/dev/null; then - echo "Creating resource group: $RG_NAME" - az group create \ - --name "$RG_NAME" \ - --location "${{ env.AZURE_LOCATION }}" \ - --tags org=nl project=convolens environment=${{ matrix.environment }} managedBy=bicep - fi - - - name: Deploy Infrastructure - id: deploy - run: | - RG_NAME="nl-${{ matrix.environment }}-convolens-rg" - DEPLOYMENT_NAME="deploy-${{ github.run_id }}-${{ github.run_attempt }}" - - echo "Deploying to ${{ matrix.environment }}..." - - az deployment group create \ - --resource-group "$RG_NAME" \ - --template-file infra/bicep/main.bicep \ - --parameters infra/parameters/${{ matrix.environment }}.bicepparam \ - --name "$DEPLOYMENT_NAME" + - name: Terraform Init + env: + ARM_CLIENT_ID: ${{ steps.azure-creds.outputs.client-id }} + ARM_TENANT_ID: ${{ steps.azure-creds.outputs.tenant-id }} + ARM_SUBSCRIPTION_ID: ${{ steps.azure-creds.outputs.subscription-id }} + run: terraform -chdir=infra/terraform/env/${{ matrix.environment }} init -backend-config=backend.hcl - # Get outputs - echo "Getting deployment outputs..." - az deployment group show \ - --resource-group "$RG_NAME" \ - --name "$DEPLOYMENT_NAME" \ - --query "properties.outputs" \ - -o json > deployment-outputs.json + - name: Terraform Apply + env: + ARM_CLIENT_ID: ${{ steps.azure-creds.outputs.client-id }} + ARM_TENANT_ID: ${{ steps.azure-creds.outputs.tenant-id }} + ARM_SUBSCRIPTION_ID: ${{ steps.azure-creds.outputs.subscription-id }} + run: terraform -chdir=infra/terraform/env/${{ matrix.environment }} apply -auto-approve - cat deployment-outputs.json + - name: Capture Outputs + id: outputs + env: + ARM_CLIENT_ID: ${{ steps.azure-creds.outputs.client-id }} + ARM_TENANT_ID: ${{ steps.azure-creds.outputs.tenant-id }} + ARM_SUBSCRIPTION_ID: ${{ steps.azure-creds.outputs.subscription-id }} + run: | + terraform -chdir=infra/terraform/env/${{ matrix.environment }} output -json > tf-outputs.json + CONTAINER_APP_URL=$(jq -r '.container_app_url.value // ""' tf-outputs.json) + echo "container_app_url=$CONTAINER_APP_URL" >> "$GITHUB_OUTPUT" - - name: Upload Deployment Outputs + - name: Upload Outputs uses: actions/upload-artifact@834a144ee995460fba8ed112a2fc961b36a5ec5a # v4.3.6 with: - name: deployment-outputs-${{ matrix.environment }} - path: deployment-outputs.json + name: tf-outputs-${{ matrix.environment }} + path: infra/terraform/env/${{ matrix.environment }}/tf-outputs.json retention-days: 30 - - name: Validate Deployment - run: | - echo "Validating deployed resources..." - chmod +x infra/scripts/validate-resources.sh - ./infra/scripts/validate-resources.sh ${{ matrix.environment }} || true - # ============================================================================= - # Post-Deployment Verification + # Post-Apply Health Check # ============================================================================= verify: name: Verify Deployment runs-on: ubuntu-latest - needs: deploy - if: success() + needs: apply + if: success() && needs.apply.outputs.container_app_url != '' environment: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || 'dev' }} steps: - - name: Checkout - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 - - - name: Download Outputs - uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8 - with: - name: deployment-outputs-${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || 'dev' }} - - - name: Parse Azure Credentials - id: azure-creds - env: - AZURE_CREDENTIALS: ${{ secrets.AZURE_CREDENTIALS }} - run: | - set -euo pipefail - CLIENT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r '.clientId') - TENANT_ID=$(echo "$AZURE_CREDENTIALS" | jq -r '.tenantId') - SUBSCRIPTION_ID=$(echo "$AZURE_CREDENTIALS" | jq -r '.subscriptionId') - - # Validate that parsing succeeded - if [ "$CLIENT_ID" = "null" ] || [ -z "$CLIENT_ID" ]; then - echo "Error: Failed to parse clientId from AZURE_CREDENTIALS" - exit 1 - fi - if [ "$TENANT_ID" = "null" ] || [ -z "$TENANT_ID" ]; then - echo "Error: Failed to parse tenantId from AZURE_CREDENTIALS" - exit 1 - fi - if [ "$SUBSCRIPTION_ID" = "null" ] || [ -z "$SUBSCRIPTION_ID" ]; then - echo "Error: Failed to parse subscriptionId from AZURE_CREDENTIALS" - exit 1 - fi - - echo "client-id=$CLIENT_ID" >> $GITHUB_OUTPUT - echo "tenant-id=$TENANT_ID" >> $GITHUB_OUTPUT - echo "subscription-id=$SUBSCRIPTION_ID" >> $GITHUB_OUTPUT - - name: Azure Login - uses: azure/login@cb79c773a3cfa27f31f25eb3f677781210c9ce3d # v1.6.1 - with: - client-id: ${{ steps.azure-creds.outputs.client-id }} - tenant-id: ${{ steps.azure-creds.outputs.tenant-id }} - subscription-id: ${{ steps.azure-creds.outputs.subscription-id }} - - name: Health Check run: | - echo "Running health checks..." - - # Get API URL from outputs - API_URL=$(cat deployment-outputs.json | jq -r '.containerAppsApiUrl.value // empty') - - if [ -n "$API_URL" ]; then - echo "Checking API health: $API_URL/health" - curl -sf "$API_URL/health" || echo "API health check failed (may not be deployed yet)" - else - echo "API URL not available yet" - fi + API_URL="${{ needs.apply.outputs.container_app_url }}" + echo "Probing $API_URL/health" + curl -sfS --max-time 10 "$API_URL/health" \ + || echo "API health check failed (placeholder image returns 200 / — non-critical)" - name: Notify Success - if: success() run: | - echo "✅ Deployment and verification completed successfully!" - echo "Environment: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment || 'dev' }}" + echo "Apply + verify completed: ${{ needs.apply.outputs.container_app_url }}" diff --git a/.github/workflows/release-validation.yml b/.github/workflows/release-validation.yml index 2b95150..622a7da 100644 --- a/.github/workflows/release-validation.yml +++ b/.github/workflows/release-validation.yml @@ -195,7 +195,7 @@ jobs: echo "✅ Application Insights exists" fi - # Check Container Apps (names match bicep BASE pattern: ${BASE}-cae, ${BASE}-api) + # Check Container Apps (names follow ${BASE}-{cae,api} per infra/terraform/env/{env}/main.tf) CAE_NAME="${BASE}-cae" echo "Checking Container Apps Environment: $CAE_NAME..." if ! az containerapp env show --name "$CAE_NAME" --resource-group "$RG" &>/dev/null; then diff --git a/docs/AZURE_SETUP.md b/docs/AZURE_SETUP.md index 7848ce9..1f36aaf 100644 --- a/docs/AZURE_SETUP.md +++ b/docs/AZURE_SETUP.md @@ -393,11 +393,9 @@ az role assignment list --assignee # List federated credentials az ad app federated-credential list --id -# Test deployment (dry run) -az deployment group what-if \ - --resource-group rg-whatssummarize-dev \ - --template-file infra/bicep/main.bicep \ - --parameters infra/parameters/dev.bicepparam +# Test deployment (dry run) — Terraform +terraform -chdir=infra/terraform/env/dev init -backend-config=backend.hcl +terraform -chdir=infra/terraform/env/dev plan ``` ## Migration from Legacy Credentials diff --git a/infra/README.md b/infra/README.md index 4d35d71..097e1a2 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,338 +1,182 @@ # ConvoLens Infrastructure -Azure infrastructure as code using Bicep templates for ConvoLens. +Azure infrastructure as code for ConvoLens, managed with **Terraform** (AzureRM provider). -> **Note:** On 2026-05-10 the Bicep `projectName` default flipped from -> `whatssummarize` to `convolens` AND the resource-naming convention -> migrated to the **NL standard with ADR-0027 region suffix dropped**: -> `{org}-{env}-{project}-{type}` (e.g. `nl-dev-convolens-rg`, -> `nl-dev-convolens-kv`). The previous `--` / -> `kv` patterns are gone. **No Azure resources currently -> exist under any of these names** — the first deploy creates clean -> resource groups, no migration required. Cutover is gated on the AAD -> federated-credential fix (red on `main` since 2025-12-07; tracked in -> `org-meta/docs/handoffs/2026-05-10-azure-rename-plan-omnipost-convolens.md`). +> Migrated from Bicep on 2026-05-11. Naming follows the NL Azure Naming +> Standard with [ADR-0027](https://github.com/JustAGhosT/mystira-workspace/blob/main/docs/architecture/adr/0027-azure-resource-naming-convention.md) +> (no region suffix): `{org}-{env}-{project}-{type}` → +> `nl-dev-convolens-rg`, `nl-dev-convolens-kv`, etc. -## Overview - -This directory contains all infrastructure configuration for deploying ConvoLens to Azure: +## Layout ``` infra/ -├── bicep/ -│ ├── main.bicep # Main orchestration template -│ └── modules/ -│ ├── key-vault.bicep # Azure Key Vault -│ ├── storage.bicep # Azure Blob Storage -│ ├── openai.bicep # Azure OpenAI Service -│ ├── cosmos-db.bicep # Azure Cosmos DB -│ ├── redis.bicep # Azure Redis Cache -│ ├── app-insights.bicep # Application Insights -│ ├── container-apps.bicep# Azure Container Apps -│ └── static-web-app.bicep# Azure Static Web Apps -├── parameters/ -│ ├── dev.bicepparam # Development environment -│ ├── staging.bicepparam # Staging environment (create as needed) -│ └── prod.bicepparam # Production environment -└── scripts/ - ├── deploy.ps1 # PowerShell deployment (recommended) - ├── deploy.sh # Bash deployment script - ├── validate-resources.ps1 # PowerShell validation - └── validate-resources.sh # Bash validation script +├── terraform/ +│ ├── env/ +│ │ └── dev/ +│ │ ├── terraform.tf # Provider versions + remote backend +│ │ ├── main.tf # All resources (RG, KV, Storage, Cosmos, ACA, SWA, …) +│ │ ├── variables.tf # Input variables +│ │ ├── outputs.tf # Output values (endpoints, names, deploy tokens) +│ │ ├── backend.hcl # Backend init args (state location) +│ │ └── terraform.tfvars # Dev values (auto-loaded by Terraform) +│ └── .gitignore # .terraform/, *.tfstate, etc. +├── scripts/ # (empty — legacy bicep scripts removed) +└── README.md # This file ``` -## Azure Resources - -| Resource | Purpose | Required | -|----------|---------|----------| -| Resource Group | Container for all resources | Yes | -| Key Vault | Secrets management | Yes | -| Storage Account | Blob storage for exports | Yes | -| Azure OpenAI | AI summarization | No* | -| Cosmos DB | NoSQL database | No* | -| Redis Cache | Distributed caching | No* | -| Application Insights | Monitoring & logging | Yes | -| Container Apps | API hosting | Yes | -| Static Web Apps | Frontend hosting | Yes | - -*Optional but recommended for production. - -## Prerequisites - -1. **Azure CLI** installed and logged in - ```bash - az login - az account set --subscription "" - ``` - -2. **Bicep CLI** (usually included with Azure CLI) - ```bash - az bicep install - az bicep version - ``` - -3. **Required permissions**: - - Contributor access to the subscription/resource group - - Key Vault Administrator (for managing secrets) - - Cognitive Services Contributor (for OpenAI) +## Resources provisioned -4. **GitHub Actions Setup** (for CI/CD deployments): - - See [Azure Setup Guide](../docs/AZURE_SETUP.md) for configuring OIDC authentication - - Requires configuring three GitHub secrets: `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` +The `dev` environment ships these by default (toggle via `enable_*` variables): -## Quick Start +| Resource | Name | Notes | +|---|---|---| +| Resource Group | `nl-dev-convolens-rg` | All resources scoped to this RG | +| Log Analytics workspace | `nl-dev-convolens-law` | 30-day retention (90 in prod) | +| Application Insights | `nl-dev-convolens-appi` | Linked to LAW | +| Storage Account | `nldevconvolensst` | LRS, hot tier, blob soft-delete 7 days | +| Blob containers | `chat-exports`, `user-uploads`, `summaries` | Private access | +| Cosmos DB account | `nl-dev-convolens-cosmos` | Serverless, single region, no AZ redundancy | +| Cosmos SQL DB | `convolens` | 3 containers: `users` `/id`, `chats` `/userId`, `summaries` `/chatId` | +| Key Vault | `nl-dev-convolens-kv` | Access-policy mode, soft-delete 7 days | +| Container Apps Environment | `nl-dev-convolens-cae` | Consumption profile only | +| Container App | `nl-dev-convolens-api` | Placeholder helloworld image; the API CD workflow overrides `container_image_api` | +| Static Web App | `nl-dev-convolens-swa` | Free tier | +| KV secrets | `cosmos-db-endpoint`, `cosmos-db-key`, `cosmos-db-connection-string`, `storage-connection-string`, `appinsights-connection-string` | Created by Terraform after the deployer access policy lands | -### Deploy Development Environment (PowerShell - Recommended) +Off by default in dev (set the variable to enable): -```powershell -# From repository root -cd infra/scripts +| Variable | Resource | +|---|---| +| `enable_redis = true` | Azure Cache for Redis Basic C0 (~$15-30/mo) | +| `enable_openai = true` | Azure OpenAI account (Foundry is configured separately today) | +| `enable_budget_alerts = true` + non-empty `admin_email` | RG-scoped consumption budget with 80% actual / 100% forecasted alerts | -# Deploy (what-if runs automatically first, then prompts for confirmation) -./deploy.ps1 -Environment dev +## Remote state -# Skip what-if and deploy directly -./deploy.ps1 -Environment dev -SkipWhatIf -Force +Terraform state lives in Azure Storage. **Bootstrap once per workspace** (see `Bootstrap` below); never recreate by hand. -# Only run what-if analysis -./deploy.ps1 -Environment dev -WhatIfOnly - -# Validate templates only -./deploy.ps1 -Environment dev -ValidateOnly ``` - -### Deploy Production Environment - -```powershell -# Deploy to production (what-if + confirmation by default) -./deploy.ps1 -Environment prod - -# Preview production changes only -./deploy.ps1 -Environment prod -WhatIfOnly +Storage account: nltfstateconvolens +Resource group: nl-tfstate-rg +Container: tfstate +State key (dev): convolens-dev.tfstate ``` -### Bash Alternative - -```bash -./deploy.sh dev # Deploy to dev -./deploy.sh prod --what-if # Preview production changes -./deploy.sh staging --validate-only # Validate staging templates -``` +Backend config is in `infra/terraform/env/dev/backend.hcl`. Passed to `init` via `-backend-config=backend.hcl`. The default `azurerm` backend uses access keys; local users and the GitHub Actions SP (`convolens-sp`) both need Contributor on the tfstate storage account, which their subscription-scoped Contributor already grants. -## Deployment Options +## Deploying -### Using Scripts (Recommended) - -```powershell -# PowerShell (what-if is automatic) -./scripts/deploy.ps1 -Environment # Full deploy with what-if -./scripts/deploy.ps1 -Environment -WhatIfOnly # Preview only -./scripts/deploy.ps1 -Environment -ValidateOnly # Validate templates -./scripts/validate-resources.ps1 -Environment # Validate resources -``` +### Locally ```bash -# Bash alternative -./scripts/deploy.sh # Deploy -./scripts/deploy.sh --what-if # Preview -./scripts/deploy.sh --validate-only # Validate -./scripts/validate-resources.sh # Validate resources +cd infra/terraform/env/dev +terraform init -backend-config=backend.hcl +terraform plan +terraform apply ``` -### Using Azure CLI Directly - -```bash -# Create resource group -az group create \ - --name nl-dev-convolens-rg \ - --location eastus - -# Deploy -az deployment group create \ - --resource-group nl-dev-convolens-rg \ - --template-file bicep/main.bicep \ - --parameters parameters/dev.bicepparam -``` - -### Using GitHub Actions - -1. **Configure Azure credentials** (see [Azure Setup Guide](../docs/AZURE_SETUP.md) for detailed instructions): - - Create Azure AD application (service principal) - - Configure federated credentials for OIDC - - Set up GitHub repository secrets: - - `AZURE_CLIENT_ID` - Service principal client ID - - `AZURE_TENANT_ID` - Azure AD tenant ID - - `AZURE_SUBSCRIPTION_ID` - Azure subscription ID +You need to be logged into Azure with permissions to manage `nl-dev-convolens-rg` and read `nl-tfstate-rg/nltfstateconvolens` keys. -2. **Trigger deployment**: - - Push to `main` branch (auto-deploys to dev) - - Use workflow dispatch for staging/prod - - PRs trigger what-if analysis +### Via CI -## Configuration +The `Infrastructure` GitHub Actions workflow (`.github/workflows/infrastructure.yml`) runs: -### Environment Parameters +- **Validate** — `terraform fmt -check` + `terraform validate` on every push and PR +- **Plan** — `terraform plan` with a posted PR comment on pull requests +- **Apply** — `terraform apply` on push to `main` (or `workflow_dispatch` → `apply`) +- **Verify** — health-checks the Container App URL after apply -Edit `parameters/.bicepparam` to customize: +Auth is OpenID Connect via the AAD app `convolens-sp` and the federated credential for `repo:neuralliquid/convolens:environment:dev`. The `AZURE_CREDENTIALS` env-scoped secret (env `dev`) provides `clientId`/`tenantId`/`subscriptionId` for the workflow. -```bicep -param environment = 'dev' -param org = 'nl' -param projectName = 'convolens' -param location = 'eastus' +## Bootstrap (new tenant / first time) -// Enable/disable optional resources -param enableOpenAI = true -param enableCosmosDB = true -param enableRedis = true +These steps were done for the active tenant (`9530cd32-9e33-47f0-9247-ed964730b580`, sub `bb4e3882-2079-4bab-8974-611bc0b8bb58`) on 2026-05-10. Keep here as a reference for future environments or tenant moves: -// OpenAI model deployments -param openAIDeployments = [ - { name: 'gpt-4', model: 'gpt-4', version: '0613', capacity: 10 } -] -``` - -### Resource Naming Convention - -Resources follow the **NL Azure Naming Standards**, with the **region suffix -dropped** per [mystira ADR-0027](https://github.com/JustAGhosT/mystira-workspace/blob/main/docs/architecture/adr/0027-azure-resource-naming-convention.md) -(adopted for convolens on 2026-05-10): - -``` -{org}-{env}-{project}-{type} +```bash +# 1. AAD app + service principal + federated cred +az ad app create --display-name convolens-sp +APP_OBJECT_ID=$(az ad app list --display-name convolens-sp --query '[0].id' -o tsv) +APP_CLIENT_ID=$(az ad app list --display-name convolens-sp --query '[0].appId' -o tsv) +az ad sp create --id "$APP_CLIENT_ID" +SP_OBJECT_ID=$(az ad sp list --display-name convolens-sp --query '[0].id' -o tsv) + +az ad app federated-credential create --id "$APP_OBJECT_ID" --parameters '{ + "name": "github-actions-dev", + "issuer": "https://token.actions.githubusercontent.com", + "subject": "repo:neuralliquid/convolens:environment:dev", + "audiences": ["api://AzureADTokenExchange"] +}' + +# 2. Sub-scoped Contributor (lets the SP create RGs and provision resources) +az role assignment create \ + --assignee-object-id "$SP_OBJECT_ID" \ + --assignee-principal-type ServicePrincipal \ + --role Contributor \ + --scope /subscriptions/ + +# 3. Remote-state storage (one-time) +az group create --name nl-tfstate-rg --location eastus2 +az storage account create \ + --name nltfstateconvolens \ + --resource-group nl-tfstate-rg \ + --location eastus2 \ + --sku Standard_LRS \ + --kind StorageV2 \ + --min-tls-version TLS1_2 \ + --allow-blob-public-access false +SA_KEY=$(az storage account keys list --account-name nltfstateconvolens --resource-group nl-tfstate-rg --query "[0].value" -o tsv) +az storage container create --name tfstate --account-name nltfstateconvolens --account-key "$SA_KEY" + +# 4. Set the env-scoped GitHub secret +gh secret set AZURE_CREDENTIALS --env dev --repo neuralliquid/convolens --body "$(jq -nc \ + --arg c "$APP_CLIENT_ID" \ + --arg t "" \ + --arg s "" \ + '{clientId:$c, tenantId:$t, subscriptionId:$s}')" ``` -Region is no longer encoded in the resource name. It is expressed by the -resource group's `location` property and the `region` resource tag. +## Adding staging or prod -Examples (dev environment): +1. Copy `infra/terraform/env/dev/` to `infra/terraform/env/{env}/`. +2. Update `terraform.tfvars` (env name, location, sizing flags, admin email). +3. Update `backend.hcl` → `key = "convolens-{env}.tfstate"`. +4. Create a federated credential matching `repo:neuralliquid/convolens:environment:{env}` on the AAD app. +5. Create the GitHub Environment `{env}` and set its `AZURE_CREDENTIALS` secret. +6. PR + merge — the workflow's `apply` job picks up the new environment. -- `nl-dev-convolens-rg` (Resource Group) -- `nl-dev-convolens-kv` (Key Vault — uses hyphens; max 24 chars) -- `nldevconvolensst` (Storage Account — alphanumeric only, max 24 chars) -- `nl-dev-convolens-cosmos` (Cosmos DB) -- `nl-dev-convolens-redis` (Redis Cache) -- `nl-dev-convolens-oai` (Azure OpenAI / Cognitive Services) -- `nl-dev-convolens-appi` (Application Insights) -- `nl-dev-convolens-cae` (Container Apps Environment) -- `nl-dev-convolens-api` (Container App — backend API) -- `nl-dev-convolens-swa` (Static Web App) +## Cost (dev, today) -## CI/CD Workflows +Rough monthly run-rate at idle: -### Infrastructure Workflow +- Cosmos DB serverless: $0 baseline; pay-per-request (RU-based). Empty DB ~free. +- Static Web App Free: $0. +- Storage Account LRS, hot tier, empty: <$1. +- App Insights + LAW: $0 until you ingest meaningful telemetry (per-GB pricing). +- Container Apps Consumption + 0 min replicas: $0 when idle. +- Key Vault Standard: ~$0.03 per 10k operations. -Triggered by: -- Push to `main` (auto-deploy to dev) -- Pull requests (what-if analysis) -- Manual dispatch (any environment) +Expect <$5/mo at idle. The big drivers when active: Container Apps execution time, Cosmos RU consumption, and App Insights ingestion. -### Release Validation +If you flip `enable_redis = true`, add ~$15-30/mo for Basic C0. `enable_openai = true` is metered separately by token usage. -Runs before releases to verify: -- All required Azure resources exist -- Secrets are configured in Key Vault -- Application builds successfully -- Tests pass +## Drift / state recovery -## Post-Deployment - -After deployment, the script generates `.env.azure` with: +If `terraform apply` errors mid-deploy and a resource exists in Azure but is missing from state: ```bash -# Azure Provider -AZURE_PROVIDER_ENABLED=true - -# Azure OpenAI -AZURE_OPENAI_ENDPOINT=https://nl-dev-convolens-oai.openai.azure.com -AZURE_OPENAI_DEPLOYMENT=gpt-4 - -# Azure Cosmos DB -AZURE_COSMOS_ENDPOINT=https://nl-dev-convolens-cosmos.documents.azure.com - -# etc... +cd infra/terraform/env/dev +MSYS_NO_PATHCONV=1 terraform import +terraform apply ``` -Secrets are stored in Key Vault and should be retrieved at runtime. - -## Validation - -Validate resources exist before release: - -```bash -# Run validation script -./scripts/validate-resources.sh prod - -# Or use strict mode (fails on any missing required resource) -./scripts/validate-resources.sh prod --strict -``` - -## Cost Optimization - -### Development -- Uses serverless Cosmos DB -- Basic Redis tier -- Minimal OpenAI capacity - -### Production -- Standard Redis tier -- Higher OpenAI capacity -- Zone redundancy recommended +Common cause: transient ARM API hiccup between Azure responding and Terraform receiving the response. The resource shows up in `az resource list` but `terraform state list` is missing it. Import to recover. -### Cost Estimates (approximate) +## Removing the dev environment -| Environment | Monthly Cost | -|-------------|--------------| -| Dev | ~$50-100 | -| Staging | ~$100-200 | -| Production | ~$300-500+ | - -*Costs vary based on usage. OpenAI costs scale with token usage.* - -## Troubleshooting - -### Deployment Fails - -1. Check Azure CLI login: - ```bash - az account show - ``` - -2. Validate templates: - ```bash - az bicep build --file bicep/main.bicep - ``` - -3. Check resource quotas: - ```bash - az vm list-usage --location eastus - ``` - -### OpenAI Not Available - -Azure OpenAI has limited regional availability. Update `location` parameter if needed. - -### Key Vault Access - -Ensure your identity has Key Vault access: ```bash -az keyvault set-policy \ - --name nl-dev-convolens-kv \ - --upn your@email.com \ - --secret-permissions get list set delete +cd infra/terraform/env/dev +terraform destroy ``` -## Security Considerations - -- Secrets stored in Key Vault, never in code -- Managed identities for service-to-service auth -- HTTPS enforced everywhere -- Private endpoints available for production -- Regular key rotation recommended - -## Related Documentation - -- [Azure Bicep](https://docs.microsoft.com/azure/azure-resource-manager/bicep/) -- [Azure OpenAI](https://docs.microsoft.com/azure/cognitive-services/openai/) -- [Azure Container Apps](https://docs.microsoft.com/azure/container-apps/) -- [Azure Static Web Apps](https://docs.microsoft.com/azure/static-web-apps/) +This tears down everything in `nl-dev-convolens-rg`. The Key Vault is soft-deleted (7-day window in dev); the provider's `purge_soft_delete_on_destroy = true` setting purges it cleanly. diff --git a/infra/bicep/main.bicep b/infra/bicep/main.bicep deleted file mode 100644 index 36c6a17..0000000 --- a/infra/bicep/main.bicep +++ /dev/null @@ -1,280 +0,0 @@ -// ============================================================================= -// ConvoLens - Azure Infrastructure (Main Template) -// ============================================================================= -// This template deploys all Azure resources required for ConvoLens. -// Use with appropriate parameter files for each environment. -// -// Resources deployed: -// - Azure OpenAI Service (AI Foundry) -// - Azure Cosmos DB (NoSQL database) -// - Azure Blob Storage (file storage) -// - Azure Redis Cache (distributed caching) -// - Azure AD B2C (authentication) - requires manual setup -// - Azure Application Insights (monitoring) -// - Azure Key Vault (secrets management) -// - Azure Container Apps (API hosting) -// - Azure Static Web Apps (frontend hosting) -// - Azure Budget Alerts (cost management) -// ============================================================================= - -targetScope = 'resourceGroup' - -// ============================================================================= -// Parameters -// ============================================================================= - -@description('Environment name (dev, staging, prod)') -@allowed(['dev', 'staging', 'prod']) -param environment string = 'dev' - -@description('Azure region for resources') -param location string = resourceGroup().location - -@description('Organisation prefix per NL Azure Naming Standards (ADR-0027 — region suffix dropped from resource names)') -@allowed(['nl', 'pvc', 'tws', 'mys']) -param org string = 'nl' - -@description('Project name used for resource naming. Renamed from "whatssummarize" to "convolens" on 2026-05-10 ahead of the GitHub repo transfer + first Azure deploy under the new name. No infra exists under the old name, so this is a clean cutover, not a migration.') -@minLength(3) -@maxLength(15) -param projectName string = 'convolens' - -@description('Logical database name used for Cosmos DB (and future Azure SQL). Decoupled from projectName so the brand rename can land without renaming Azure resources. Override at deploy time only when migrating data.') -@minLength(3) -@maxLength(40) -param databaseName string = 'convolens' - -@description('Enable Azure OpenAI deployment') -param enableOpenAI bool = true - -@description('Enable Cosmos DB deployment') -param enableCosmosDB bool = true - -@description('Enable Redis Cache deployment') -param enableRedis bool = true - -@description('Enable Container Apps deployment') -param enableContainerApps bool = true - -@description('Enable Static Web Apps deployment') -param enableStaticWebApps bool = true - -@description('Administrator email for alerts') -param adminEmail string = '' - -@description('Enable budget alerts') -param enableBudgetAlerts bool = true - -@description('Monthly budget amount in USD') -param monthlyBudgetAmount int = environment == 'prod' ? 500 : environment == 'staging' ? 200 : 100 - -@description('OpenAI model deployments') -param openAIDeployments array = [ - { - name: 'gpt-4' - model: 'gpt-4' - version: '0613' - capacity: 10 - } - { - name: 'gpt-35-turbo' - model: 'gpt-35-turbo' - version: '0613' - capacity: 30 - } -] - -@description('Tags to apply to all resources') -param tags object = { - org: org - project: projectName - environment: environment - managedBy: 'bicep' -} - -// ============================================================================= -// Variables -// ============================================================================= - -// Naming pattern per ADR-0027 (no region suffix): -// {org}-{env}-{project}-{type} e.g. nl-dev-convolens-kv -// Storage Account name constraint (alphanumeric only, max 24 chars) requires -// a hyphen-stripped variant; everything else uses the hyphenated form. -var base = '${org}-${environment}-${projectName}' -var baseAlphanumeric = replace(base, '-', '') - -// SKUs based on environment -// Redis: Basic (no SLA) for dev, Standard (SLA) for prod -var redisSku = environment == 'prod' ? 'Standard' : 'Basic' -var redisFamily = environment == 'prod' ? 'C' : 'C' -var redisCapacity = environment == 'prod' ? 1 : 0 - -// Storage: Standard for dev, Premium for prod (optional) -var storageSkuName = environment == 'prod' ? 'Standard_GRS' : 'Standard_LRS' - -// Container Apps: Production uses more resources -var containerAppCpu = environment == 'prod' ? '1.0' : '0.5' -var containerAppMemory = environment == 'prod' ? '2.0Gi' : '1.0Gi' -var containerAppMinReplicas = environment == 'prod' ? 2 : 0 -var containerAppMaxReplicas = environment == 'prod' ? 10 : 3 - -// ============================================================================= -// Modules -// ============================================================================= - -// Key Vault (deployed first for secret management) -module keyVault 'modules/key-vault.bicep' = { - name: 'keyVault-${environment}' - params: { - name: '${base}-kv' - location: location - tags: tags - enableSoftDelete: environment == 'prod' - enablePurgeProtection: environment == 'prod' - } -} - -// Storage Account -module storage 'modules/storage.bicep' = { - name: 'storage-${environment}' - params: { - name: '${baseAlphanumeric}st' - location: location - tags: tags - sku: storageSkuName - containerNames: ['chat-exports', 'user-uploads', 'summaries'] - enableVersioning: environment == 'prod' - } -} - -// Azure OpenAI -module openAI 'modules/openai.bicep' = if (enableOpenAI) { - name: 'openai-${environment}' - params: { - name: '${base}-oai' - location: location // Note: OpenAI has limited region availability - tags: tags - deployments: openAIDeployments - keyVaultName: keyVault.outputs.name - } -} - -// Cosmos DB -module cosmosDB 'modules/cosmos-db.bicep' = if (enableCosmosDB) { - name: 'cosmosdb-${environment}' - params: { - name: '${base}-cosmos' - location: location - tags: tags - databaseName: databaseName - containers: [ - { - name: 'users' - partitionKey: '/id' - } - { - name: 'chats' - partitionKey: '/userId' - } - { - name: 'summaries' - partitionKey: '/chatId' - } - ] - keyVaultName: keyVault.outputs.name - } -} - -// Redis Cache -module redis 'modules/redis.bicep' = if (enableRedis) { - name: 'redis-${environment}' - params: { - name: '${base}-redis' - location: location - tags: tags - sku: redisSku - family: redisFamily - capacity: redisCapacity - keyVaultName: keyVault.outputs.name - } -} - -// Application Insights -module appInsights 'modules/app-insights.bicep' = { - name: 'appinsights-${environment}' - params: { - name: '${base}-appi' - location: location - tags: tags - } -} - -// Container Apps Environment & API -module containerApps 'modules/container-apps.bicep' = if (enableContainerApps) { - name: 'containerapps-${environment}' - params: { - environmentName: '${base}-cae' - apiAppName: '${base}-api' - location: location - tags: tags - appInsightsConnectionString: appInsights.outputs.connectionString - keyVaultName: keyVault.outputs.name - cosmosDbEndpoint: enableCosmosDB ? cosmosDB.outputs.endpoint : '' - redisHostname: enableRedis ? redis.outputs.hostname : '' - storageAccountName: storage.outputs.name - openAIEndpoint: enableOpenAI ? openAI.outputs.endpoint : '' - // Environment-specific scaling - apiCpu: containerAppCpu - apiMemory: containerAppMemory - minReplicas: containerAppMinReplicas - maxReplicas: containerAppMaxReplicas - } -} - -// Static Web Apps (Frontend) -module staticWebApp 'modules/static-web-app.bicep' = if (enableStaticWebApps) { - name: 'staticwebapp-${environment}' - params: { - name: '${base}-swa' - location: location - tags: tags - apiUrl: enableContainerApps ? containerApps.outputs.apiUrl : '' - } -} - -// Budget Alerts (Cost Management) -module budgetAlerts 'modules/budget-alerts.bicep' = if (enableBudgetAlerts && !empty(adminEmail)) { - name: 'budget-${environment}' - params: { - name: '${base}-budget' - amount: monthlyBudgetAmount - contactEmails: [adminEmail] - environment: environment - tags: tags - } -} - -// ============================================================================= -// Outputs -// ============================================================================= - -output resourceGroupName string = resourceGroup().name -output keyVaultName string = keyVault.outputs.name -output keyVaultUri string = keyVault.outputs.uri -output storageAccountName string = storage.outputs.name -output storageBlobEndpoint string = storage.outputs.blobEndpoint - -output openAIEndpoint string = enableOpenAI ? openAI.outputs.endpoint : '' -output openAIDeploymentNames array = enableOpenAI ? openAI.outputs.deploymentNames : [] - -output cosmosDBEndpoint string = enableCosmosDB ? cosmosDB.outputs.endpoint : '' -output cosmosDBDatabaseName string = enableCosmosDB ? cosmosDB.outputs.databaseName : '' - -output redisHostname string = enableRedis ? redis.outputs.hostname : '' -output redisPort int = enableRedis ? redis.outputs.port : 0 - -output appInsightsConnectionString string = appInsights.outputs.connectionString -output appInsightsInstrumentationKey string = appInsights.outputs.instrumentationKey - -output containerAppsApiUrl string = enableContainerApps ? containerApps.outputs.apiUrl : '' -output staticWebAppUrl string = enableStaticWebApps ? staticWebApp.outputs.url : '' diff --git a/infra/bicep/modules/app-insights.bicep b/infra/bicep/modules/app-insights.bicep deleted file mode 100644 index 24d3a64..0000000 --- a/infra/bicep/modules/app-insights.bicep +++ /dev/null @@ -1,82 +0,0 @@ -// ============================================================================= -// Azure Application Insights Module -// ============================================================================= - -@description('Name of the Application Insights resource') -param name string - -@description('Azure region') -param location string - -@description('Resource tags') -param tags object - -@description('Application type') -@allowed(['web', 'other']) -param applicationType string = 'web' - -@description('Retention in days') -@minValue(30) -@maxValue(730) -param retentionInDays int = 90 - -@description('Daily cap in GB (0 = no cap)') -param dailyCapGb int = 0 - -// ============================================================================= -// Resources -// ============================================================================= - -resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2022-10-01' = { - name: '${name}-workspace' - location: location - tags: tags - properties: { - sku: { - name: 'PerGB2018' - } - retentionInDays: retentionInDays - publicNetworkAccessForIngestion: 'Enabled' - publicNetworkAccessForQuery: 'Enabled' - } -} - -resource appInsights 'Microsoft.Insights/components@2020-02-02' = { - name: name - location: location - tags: tags - kind: applicationType - properties: { - Application_Type: applicationType - WorkspaceResourceId: logAnalyticsWorkspace.id - RetentionInDays: retentionInDays - IngestionMode: 'LogAnalytics' - publicNetworkAccessForIngestion: 'Enabled' - publicNetworkAccessForQuery: 'Enabled' - } -} - -// Set daily cap if specified -resource dailyCap 'Microsoft.Insights/components/CurrentBillingFeatures@2015-05-01' = if (dailyCapGb > 0) { - name: '${appInsights.name}/CurrentBillingFeatures' - properties: { - CurrentBillingFeatures: ['Basic'] - DataVolumeCap: { - Cap: dailyCapGb - ResetTime: 0 - StopSendNotificationWhenHitCap: false - StopSendNotificationWhenHitThreshold: false - WarningThreshold: 90 - } - } -} - -// ============================================================================= -// Outputs -// ============================================================================= - -output id string = appInsights.id -output name string = appInsights.name -output instrumentationKey string = appInsights.properties.InstrumentationKey -output connectionString string = appInsights.properties.ConnectionString -output workspaceId string = logAnalyticsWorkspace.id diff --git a/infra/bicep/modules/budget-alerts.bicep b/infra/bicep/modules/budget-alerts.bicep deleted file mode 100644 index bdcb85e..0000000 --- a/infra/bicep/modules/budget-alerts.bicep +++ /dev/null @@ -1,140 +0,0 @@ -// ============================================================================= -// Budget Alerts Module -// ============================================================================= -// Creates Azure Cost Management budgets with alerts for cost monitoring. -// Sends notifications when spending approaches or exceeds thresholds. -// ============================================================================= - -@description('Name of the budget') -param name string - -@description('Budget amount in USD') -param amount int - -@description('Email addresses to receive alerts') -param contactEmails array - -@description('Resource group name for scope') -param resourceGroupName string = resourceGroup().name - -@description('Environment (dev, staging, prod)') -@allowed(['dev', 'staging', 'prod']) -param environment string - -@description('Tags to apply to the budget') -param tags object = {} - -@description('Budget time grain (Monthly, Quarterly, Annually)') -@allowed(['Monthly', 'Quarterly', 'Annually']) -param timeGrain string = 'Monthly' - -@description('Current date for budget calculation (passed from parent to ensure consistency)') -param currentDate string = utcNow('yyyy-MM-01') - -// Derive startDate from the single currentDate value to avoid race conditions -var startDate = currentDate - -// ============================================================================= -// Variables -// ============================================================================= - -// Environment-specific thresholds -var thresholds = environment == 'prod' ? { - warning: 50 - alert: 75 - critical: 90 - exceeded: 100 -} : environment == 'staging' ? { - warning: 60 - alert: 80 - critical: 95 - exceeded: 100 -} : { - warning: 70 - alert: 85 - critical: 100 - exceeded: 110 -} - -// ============================================================================= -// Resources -// ============================================================================= - -resource budget 'Microsoft.Consumption/budgets@2023-05-01' = { - name: name - properties: { - category: 'Cost' - amount: amount - timeGrain: timeGrain - timePeriod: { - startDate: startDate - } - filter: { - dimensions: { - name: 'ResourceGroupName' - operator: 'In' - values: [resourceGroupName] - } - } - notifications: { - // Warning notification (50-70% depending on env) - warningNotification: { - enabled: true - operator: 'GreaterThan' - threshold: thresholds.warning - thresholdType: 'Actual' - contactEmails: contactEmails - contactRoles: ['Owner', 'Contributor'] - locale: 'en-us' - } - // Alert notification (75-85% depending on env) - alertNotification: { - enabled: true - operator: 'GreaterThan' - threshold: thresholds.alert - thresholdType: 'Actual' - contactEmails: contactEmails - contactRoles: ['Owner', 'Contributor'] - locale: 'en-us' - } - // Critical notification (90-100% depending on env) - criticalNotification: { - enabled: true - operator: 'GreaterThan' - threshold: thresholds.critical - thresholdType: 'Actual' - contactEmails: contactEmails - contactRoles: ['Owner'] - locale: 'en-us' - } - // Exceeded notification (100-110% depending on env) - exceededNotification: { - enabled: true - operator: 'GreaterThan' - threshold: thresholds.exceeded - thresholdType: 'Actual' - contactEmails: contactEmails - contactRoles: ['Owner'] - locale: 'en-us' - } - // Forecasted threshold notification - forecastedNotification: { - enabled: true - operator: 'GreaterThan' - threshold: 100 - thresholdType: 'Forecasted' - contactEmails: contactEmails - contactRoles: ['Owner', 'Contributor'] - locale: 'en-us' - } - } - } -} - -// ============================================================================= -// Outputs -// ============================================================================= - -output budgetName string = budget.name -output budgetAmount int = amount -output thresholds object = thresholds diff --git a/infra/bicep/modules/container-apps.bicep b/infra/bicep/modules/container-apps.bicep deleted file mode 100644 index 2e53d0e..0000000 --- a/infra/bicep/modules/container-apps.bicep +++ /dev/null @@ -1,189 +0,0 @@ -// ============================================================================= -// Azure Container Apps Module -// ============================================================================= - -@description('Name of the Container Apps Environment') -param environmentName string - -@description('Name of the API Container App') -param apiAppName string - -@description('Azure region') -param location string - -@description('Resource tags') -param tags object - -@description('Application Insights connection string') -param appInsightsConnectionString string = '' - -@description('Key Vault name for secrets') -param keyVaultName string = '' - -@description('Cosmos DB endpoint') -param cosmosDbEndpoint string = '' - -@description('Redis hostname') -param redisHostname string = '' - -@description('Storage account name') -param storageAccountName string = '' - -@description('Azure OpenAI endpoint') -param openAIEndpoint string = '' - -@description('Container image for API') -param apiImage string = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' - -@description('CPU cores for API container') -param apiCpu string = '0.5' - -@description('Memory for API container') -param apiMemory string = '1Gi' - -@description('Minimum replicas') -param minReplicas int = 0 - -@description('Maximum replicas') -param maxReplicas int = 3 - -// ============================================================================= -// Resources -// ============================================================================= - -resource environment 'Microsoft.App/managedEnvironments@2023-05-01' = { - name: environmentName - location: location - tags: tags - properties: { - daprAIConnectionString: appInsightsConnectionString - zoneRedundant: false - workloadProfiles: [ - { - name: 'Consumption' - workloadProfileType: 'Consumption' - } - ] - } -} - -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = if (!empty(keyVaultName)) { - name: keyVaultName -} - -resource apiApp 'Microsoft.App/containerApps@2023-05-01' = { - name: apiAppName - location: location - tags: tags - identity: { - type: 'SystemAssigned' - } - properties: { - managedEnvironmentId: environment.id - workloadProfileName: 'Consumption' - configuration: { - activeRevisionsMode: 'Single' - ingress: { - external: true - targetPort: 3001 - transport: 'http' - corsPolicy: { - allowedOrigins: ['*'] - allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] - allowedHeaders: ['*'] - } - } - secrets: [ - { - name: 'app-insights-connection-string' - value: appInsightsConnectionString - } - ] - } - template: { - containers: [ - { - name: 'api' - image: apiImage - resources: { - cpu: json(apiCpu) - memory: apiMemory - } - env: [ - { - name: 'NODE_ENV' - value: 'production' - } - { - name: 'PORT' - value: '3001' - } - { - name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' - secretRef: 'app-insights-connection-string' - } - { - name: 'AZURE_COSMOS_ENDPOINT' - value: cosmosDbEndpoint - } - { - name: 'AZURE_REDIS_HOSTNAME' - value: redisHostname - } - { - name: 'AZURE_STORAGE_ACCOUNT_NAME' - value: storageAccountName - } - { - name: 'AZURE_OPENAI_ENDPOINT' - value: openAIEndpoint - } - { - name: 'AZURE_KEY_VAULT_NAME' - value: keyVaultName - } - ] - } - ] - scale: { - minReplicas: minReplicas - maxReplicas: maxReplicas - rules: [ - { - name: 'http-rule' - http: { - metadata: { - concurrentRequests: '100' - } - } - } - ] - } - } - } -} - -// Grant Key Vault access to the Container App -resource keyVaultAccessPolicy 'Microsoft.KeyVault/vaults/accessPolicies@2023-07-01' = if (!empty(keyVaultName)) { - name: '${keyVaultName}/add' - properties: { - accessPolicies: [ - { - tenantId: subscription().tenantId - objectId: apiApp.identity.principalId - permissions: { - secrets: ['get', 'list'] - } - } - ] - } -} - -// ============================================================================= -// Outputs -// ============================================================================= - -output environmentId string = environment.id -output apiAppId string = apiApp.id -output apiUrl string = 'https://${apiApp.properties.configuration.ingress.fqdn}' -output apiPrincipalId string = apiApp.identity.principalId diff --git a/infra/bicep/modules/cosmos-db.bicep b/infra/bicep/modules/cosmos-db.bicep deleted file mode 100644 index 8e8db10..0000000 --- a/infra/bicep/modules/cosmos-db.bicep +++ /dev/null @@ -1,137 +0,0 @@ -// ============================================================================= -// Azure Cosmos DB Module -// ============================================================================= - -@description('Name of the Cosmos DB account') -param name string - -@description('Azure region') -param location string - -@description('Resource tags') -param tags object - -@description('Database name') -param databaseName string - -@description('Container configurations') -param containers array = [] - -@description('Key Vault name for storing secrets') -param keyVaultName string = '' - -@description('Enable free tier (only one per subscription)') -param enableFreeTier bool = false - -@description('Default consistency level') -@allowed(['Eventual', 'ConsistentPrefix', 'Session', 'BoundedStaleness', 'Strong']) -param consistencyLevel string = 'Session' - -// ============================================================================= -// Resources -// ============================================================================= - -resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2023-11-15' = { - name: name - location: location - tags: tags - kind: 'GlobalDocumentDB' - properties: { - databaseAccountOfferType: 'Standard' - enableFreeTier: enableFreeTier - consistencyPolicy: { - defaultConsistencyLevel: consistencyLevel - } - locations: [ - { - locationName: location - failoverPriority: 0 - isZoneRedundant: false - } - ] - capabilities: [ - { - name: 'EnableServerless' - } - ] - publicNetworkAccess: 'Enabled' - enableAutomaticFailover: false - enableMultipleWriteLocations: false - } -} - -resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2023-11-15' = { - parent: cosmosAccount - name: databaseName - properties: { - resource: { - id: databaseName - } - } -} - -resource cosmosContainers 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-11-15' = [for container in containers: { - parent: database - name: container.name - properties: { - resource: { - id: container.name - partitionKey: { - paths: [container.partitionKey] - kind: 'Hash' - } - indexingPolicy: { - indexingMode: 'consistent' - automatic: true - includedPaths: [ - { - path: '/*' - } - ] - excludedPaths: [ - { - path: '/"_etag"/?' - } - ] - } - } - } -}] - -// Store connection info in Key Vault -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = if (!empty(keyVaultName)) { - name: keyVaultName -} - -resource cosmosKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(keyVaultName)) { - parent: keyVault - name: 'cosmos-db-key' - properties: { - value: cosmosAccount.listKeys().primaryMasterKey - } -} - -resource cosmosEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(keyVaultName)) { - parent: keyVault - name: 'cosmos-db-endpoint' - properties: { - value: cosmosAccount.properties.documentEndpoint - } -} - -resource cosmosConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(keyVaultName)) { - parent: keyVault - name: 'cosmos-db-connection-string' - properties: { - value: cosmosAccount.listConnectionStrings().connectionStrings[0].connectionString - } -} - -// ============================================================================= -// Outputs -// ============================================================================= - -output id string = cosmosAccount.id -output name string = cosmosAccount.name -output endpoint string = cosmosAccount.properties.documentEndpoint -output databaseName string = database.name diff --git a/infra/bicep/modules/key-vault.bicep b/infra/bicep/modules/key-vault.bicep deleted file mode 100644 index 99e06b1..0000000 --- a/infra/bicep/modules/key-vault.bicep +++ /dev/null @@ -1,58 +0,0 @@ -// ============================================================================= -// Azure Key Vault Module -// ============================================================================= - -@description('Name of the Key Vault') -param name string - -@description('Azure region') -param location string - -@description('Resource tags') -param tags object - -@description('Enable soft delete') -param enableSoftDelete bool = true - -@description('Enable purge protection') -param enablePurgeProtection bool = false - -@description('Soft delete retention in days') -param softDeleteRetentionInDays int = 7 - -// ============================================================================= -// Resources -// ============================================================================= - -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' = { - name: name - location: location - tags: tags - properties: { - sku: { - family: 'A' - name: 'standard' - } - tenantId: subscription().tenantId - enabledForDeployment: true - enabledForDiskEncryption: false - enabledForTemplateDeployment: true - enableSoftDelete: enableSoftDelete - enablePurgeProtection: enablePurgeProtection ? true : null - softDeleteRetentionInDays: enableSoftDelete ? softDeleteRetentionInDays : null - enableRbacAuthorization: true - publicNetworkAccess: 'Enabled' - networkAcls: { - bypass: 'AzureServices' - defaultAction: 'Allow' - } - } -} - -// ============================================================================= -// Outputs -// ============================================================================= - -output id string = keyVault.id -output name string = keyVault.name -output uri string = keyVault.properties.vaultUri diff --git a/infra/bicep/modules/openai.bicep b/infra/bicep/modules/openai.bicep deleted file mode 100644 index 97cbd57..0000000 --- a/infra/bicep/modules/openai.bicep +++ /dev/null @@ -1,91 +0,0 @@ -// ============================================================================= -// Azure OpenAI Service Module -// ============================================================================= - -@description('Name of the Azure OpenAI resource') -param name string - -@description('Azure region (Note: Limited availability)') -param location string - -@description('Resource tags') -param tags object - -@description('Model deployments configuration') -param deployments array = [] - -@description('Key Vault name for storing secrets') -param keyVaultName string = '' - -@description('SKU name') -@allowed(['S0']) -param sku string = 'S0' - -// ============================================================================= -// Resources -// ============================================================================= - -resource openAI 'Microsoft.CognitiveServices/accounts@2023-10-01-preview' = { - name: name - location: location - tags: tags - kind: 'OpenAI' - sku: { - name: sku - } - properties: { - customSubDomainName: name - publicNetworkAccess: 'Enabled' - networkAcls: { - defaultAction: 'Allow' - } - } -} - -// Deploy models -resource modelDeployments 'Microsoft.CognitiveServices/accounts/deployments@2023-10-01-preview' = [for deployment in deployments: { - parent: openAI - name: deployment.name - sku: { - name: 'Standard' - capacity: deployment.capacity - } - properties: { - model: { - format: 'OpenAI' - name: deployment.model - version: deployment.version - } - raiPolicyName: 'Microsoft.Default' - } -}] - -// Store API key in Key Vault if provided -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = if (!empty(keyVaultName)) { - name: keyVaultName -} - -resource openAIKeySecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(keyVaultName)) { - parent: keyVault - name: 'azure-openai-api-key' - properties: { - value: openAI.listKeys().key1 - } -} - -resource openAIEndpointSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(keyVaultName)) { - parent: keyVault - name: 'azure-openai-endpoint' - properties: { - value: openAI.properties.endpoint - } -} - -// ============================================================================= -// Outputs -// ============================================================================= - -output id string = openAI.id -output name string = openAI.name -output endpoint string = openAI.properties.endpoint -output deploymentNames array = [for (deployment, i) in deployments: deployment.name] diff --git a/infra/bicep/modules/redis.bicep b/infra/bicep/modules/redis.bicep deleted file mode 100644 index 89ed330..0000000 --- a/infra/bicep/modules/redis.bicep +++ /dev/null @@ -1,88 +0,0 @@ -// ============================================================================= -// Azure Redis Cache Module -// ============================================================================= - -@description('Name of the Redis Cache') -param name string - -@description('Azure region') -param location string - -@description('Resource tags') -param tags object - -@description('Redis SKU') -@allowed(['Basic', 'Standard', 'Premium']) -param sku string = 'Basic' - -@description('Redis family') -@allowed(['C', 'P']) -param family string = 'C' - -@description('Redis capacity (0-6 for C family, 1-5 for P family)') -@minValue(0) -@maxValue(6) -param capacity int = 0 - -@description('Key Vault name for storing secrets') -param keyVaultName string = '' - -@description('Enable non-SSL port (not recommended)') -param enableNonSslPort bool = false - -@description('Minimum TLS version') -@allowed(['1.0', '1.1', '1.2']) -param minimumTlsVersion string = '1.2' - -// ============================================================================= -// Resources -// ============================================================================= - -resource redis 'Microsoft.Cache/redis@2023-08-01' = { - name: name - location: location - tags: tags - properties: { - sku: { - name: sku - family: family - capacity: capacity - } - enableNonSslPort: enableNonSslPort - minimumTlsVersion: minimumTlsVersion - publicNetworkAccess: 'Enabled' - redisConfiguration: { - 'maxmemory-policy': 'volatile-lru' - } - } -} - -// Store connection info in Key Vault -resource keyVault 'Microsoft.KeyVault/vaults@2023-07-01' existing = if (!empty(keyVaultName)) { - name: keyVaultName -} - -resource redisPasswordSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(keyVaultName)) { - parent: keyVault - name: 'redis-password' - properties: { - value: redis.listKeys().primaryKey - } -} - -resource redisConnectionStringSecret 'Microsoft.KeyVault/vaults/secrets@2023-07-01' = if (!empty(keyVaultName)) { - parent: keyVault - name: 'redis-connection-string' - properties: { - value: '${redis.properties.hostName}:${redis.properties.sslPort},password=${redis.listKeys().primaryKey},ssl=True,abortConnect=False' - } -} - -// ============================================================================= -// Outputs -// ============================================================================= - -output id string = redis.id -output name string = redis.name -output hostname string = redis.properties.hostName -output port int = redis.properties.sslPort diff --git a/infra/bicep/modules/static-web-app.bicep b/infra/bicep/modules/static-web-app.bicep deleted file mode 100644 index a29f66d..0000000 --- a/infra/bicep/modules/static-web-app.bicep +++ /dev/null @@ -1,78 +0,0 @@ -// ============================================================================= -// Azure Static Web Apps Module -// ============================================================================= - -@description('Name of the Static Web App') -param name string - -@description('Azure region') -param location string - -@description('Resource tags') -param tags object - -@description('API backend URL') -param apiUrl string = '' - -@description('SKU name') -@allowed(['Free', 'Standard']) -param sku string = 'Free' - -@description('GitHub repository URL (for linked deployment)') -param repositoryUrl string = '' - -@description('GitHub branch') -param branch string = 'main' - -@description('App location (path to app source)') -param appLocation string = 'apps/web' - -@description('API location (path to API source, if using Azure Functions)') -param apiLocation string = '' - -@description('Output location (build output path)') -param outputLocation string = '.next' - -// ============================================================================= -// Resources -// ============================================================================= - -resource staticWebApp 'Microsoft.Web/staticSites@2022-09-01' = { - name: name - location: location - tags: tags - sku: { - name: sku - tier: sku - } - properties: { - repositoryUrl: !empty(repositoryUrl) ? repositoryUrl : null - branch: !empty(repositoryUrl) ? branch : null - buildProperties: !empty(repositoryUrl) ? { - appLocation: appLocation - apiLocation: apiLocation - outputLocation: outputLocation - } : null - stagingEnvironmentPolicy: 'Enabled' - allowConfigFileUpdates: true - enterpriseGradeCdnStatus: 'Disabled' - } -} - -// Configure app settings -resource staticWebAppSettings 'Microsoft.Web/staticSites/config@2022-09-01' = { - parent: staticWebApp - name: 'appsettings' - properties: { - NEXT_PUBLIC_API_URL: apiUrl - } -} - -// ============================================================================= -// Outputs -// ============================================================================= - -output id string = staticWebApp.id -output name string = staticWebApp.name -output url string = 'https://${staticWebApp.properties.defaultHostname}' -output deploymentToken string = staticWebApp.listSecrets().properties.apiKey diff --git a/infra/bicep/modules/storage.bicep b/infra/bicep/modules/storage.bicep deleted file mode 100644 index 22f7ec8..0000000 --- a/infra/bicep/modules/storage.bicep +++ /dev/null @@ -1,100 +0,0 @@ -// ============================================================================= -// Azure Storage Account Module -// ============================================================================= - -@description('Name of the Storage Account (3-24 chars, lowercase alphanumeric)') -@minLength(3) -@maxLength(24) -param name string - -@description('Azure region') -param location string - -@description('Resource tags') -param tags object - -@description('Storage account SKU') -@allowed(['Standard_LRS', 'Standard_GRS', 'Standard_RAGRS', 'Standard_ZRS', 'Premium_LRS']) -param sku string = 'Standard_LRS' - -@description('Blob container names to create') -param containerNames array = [] - -@description('Enable blob versioning') -param enableVersioning bool = false - -@description('Enable soft delete for blobs') -param enableBlobSoftDelete bool = true - -@description('Blob soft delete retention days') -param blobSoftDeleteRetentionDays int = 7 - -// ============================================================================= -// Resources -// ============================================================================= - -resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = { - name: name - location: location - tags: tags - sku: { - name: sku - } - kind: 'StorageV2' - properties: { - accessTier: 'Hot' - allowBlobPublicAccess: false - allowSharedKeyAccess: true - minimumTlsVersion: 'TLS1_2' - supportsHttpsTrafficOnly: true - encryption: { - services: { - blob: { - enabled: true - } - file: { - enabled: true - } - } - keySource: 'Microsoft.Storage' - } - networkAcls: { - bypass: 'AzureServices' - defaultAction: 'Allow' - } - } -} - -resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = { - parent: storageAccount - name: 'default' - properties: { - isVersioningEnabled: enableVersioning - deleteRetentionPolicy: { - enabled: enableBlobSoftDelete - days: blobSoftDeleteRetentionDays - } - containerDeleteRetentionPolicy: { - enabled: enableBlobSoftDelete - days: blobSoftDeleteRetentionDays - } - } -} - -resource containers 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = [for containerName in containerNames: { - parent: blobService - name: containerName - properties: { - publicAccess: 'None' - } -}] - -// ============================================================================= -// Outputs -// ============================================================================= - -output id string = storageAccount.id -output name string = storageAccount.name -output blobEndpoint string = storageAccount.properties.primaryEndpoints.blob -output primaryKey string = storageAccount.listKeys().keys[0].value -output connectionString string = 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${storageAccount.listKeys().keys[0].value};EndpointSuffix=${environment().suffixes.storage}' diff --git a/infra/parameters/dev.bicepparam b/infra/parameters/dev.bicepparam deleted file mode 100644 index 5f88084..0000000 --- a/infra/parameters/dev.bicepparam +++ /dev/null @@ -1,31 +0,0 @@ -// ============================================================================= -// Development Environment Parameters -// ============================================================================= -using '../bicep/main.bicep' - -param environment = 'dev' -param org = 'nl' -param projectName = 'convolens' -param location = 'eastus' - -// Enable services for development -// Note: OpenAI disabled - configure Azure AI Foundry separately via Azure Portal -param enableOpenAI = false -param enableCosmosDB = true -param enableRedis = true -param enableContainerApps = true -param enableStaticWebApps = true - -// OpenAI deployments (only used if enableOpenAI = true) -// Configure Azure AI Foundry manually and set AZURE_OPENAI_* env vars -param openAIDeployments = [] - -param adminEmail = '' - -param tags = { - org: 'nl' - project: 'convolens' - environment: 'dev' - managedBy: 'bicep' - costCenter: 'development' -} diff --git a/infra/parameters/prod.bicepparam b/infra/parameters/prod.bicepparam deleted file mode 100644 index 78f54d5..0000000 --- a/infra/parameters/prod.bicepparam +++ /dev/null @@ -1,32 +0,0 @@ -// ============================================================================= -// Production Environment Parameters -// ============================================================================= -using '../bicep/main.bicep' - -param environment = 'prod' -param org = 'nl' -param projectName = 'convolens' -param location = 'eastus' - -// Enable services for production -// Note: OpenAI disabled - configure Azure AI Foundry separately via Azure Portal -param enableOpenAI = false -param enableCosmosDB = true -param enableRedis = true -param enableContainerApps = true -param enableStaticWebApps = true - -// OpenAI deployments (only used if enableOpenAI = true) -// Configure Azure AI Foundry manually and set AZURE_OPENAI_* env vars -param openAIDeployments = [] - -param adminEmail = 'admin@convolens.com' - -param tags = { - org: 'nl' - project: 'convolens' - environment: 'prod' - managedBy: 'bicep' - costCenter: 'production' - criticalityLevel: 'high' -} diff --git a/infra/parameters/staging.bicepparam b/infra/parameters/staging.bicepparam deleted file mode 100644 index 7484a76..0000000 --- a/infra/parameters/staging.bicepparam +++ /dev/null @@ -1,36 +0,0 @@ -// ============================================================================= -// Staging Environment Parameters -// ============================================================================= -// Staging mirrors production configuration with reduced capacity for cost -// optimization while maintaining feature parity for pre-production testing. -// ============================================================================= -using '../bicep/main.bicep' - -param environment = 'staging' -param org = 'nl' -param projectName = 'convolens' -param location = 'eastus' - -// Enable services for staging (mirrors production) -// Note: OpenAI disabled - configure Azure AI Foundry separately via Azure Portal -param enableOpenAI = false -param enableCosmosDB = true -param enableRedis = true -param enableContainerApps = true -param enableStaticWebApps = true - -// OpenAI deployments (only used if enableOpenAI = true) -// Configure Azure AI Foundry manually and set AZURE_OPENAI_* env vars -param openAIDeployments = [] - -param adminEmail = 'staging-alerts@convolens.com' - -param tags = { - org: 'nl' - project: 'convolens' - environment: 'staging' - managedBy: 'bicep' - costCenter: 'staging' - criticalityLevel: 'medium' - purpose: 'pre-production-testing' -} diff --git a/infra/scripts/deploy.ps1 b/infra/scripts/deploy.ps1 deleted file mode 100644 index 8ecfbe0..0000000 --- a/infra/scripts/deploy.ps1 +++ /dev/null @@ -1,432 +0,0 @@ -# ============================================================================= -# Azure Infrastructure Deployment Script (PowerShell) -# ============================================================================= -# Deploys or updates Azure infrastructure using Bicep templates. -# What-if analysis runs automatically before deployment unless skipped. -# -# Usage: -# ./deploy.ps1 -Environment dev -# ./deploy.ps1 -Environment prod -SkipWhatIf -# ./deploy.ps1 -Environment dev -WhatIfOnly -# ./deploy.ps1 -Environment dev -ValidateOnly -# -# ============================================================================= - -[CmdletBinding()] -param( - [Parameter(Mandatory = $false)] - [ValidateSet('dev', 'staging', 'prod')] - [string]$Environment = 'dev', - - [Parameter(Mandatory = $false)] - [switch]$SkipWhatIf, - - [Parameter(Mandatory = $false)] - [switch]$WhatIfOnly, - - [Parameter(Mandatory = $false)] - [switch]$ValidateOnly, - - [Parameter(Mandatory = $false)] - [switch]$Force -) - -# ============================================================================= -# Configuration -# ============================================================================= - -$ErrorActionPreference = 'Stop' -$Org = 'nl' -$ProjectName = 'convolens' -$Location = 'eastus' - -$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$InfraDir = Split-Path -Parent $ScriptDir -$BicepDir = Join-Path $InfraDir 'bicep' -$ParamsDir = Join-Path $InfraDir 'parameters' - -# Naming pattern per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} -$Base = "$Org-$Environment-$ProjectName" -$ResourceGroup = "$Base-rg" - -# ============================================================================= -# Utility Functions -# ============================================================================= - -function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } -function Write-SuccessMessage { param([string]$Message) Write-Host "[SUCCESS] $Message" -ForegroundColor Green } -function Write-WarningMessage { param([string]$Message) Write-Host "[WARNING] $Message" -ForegroundColor Yellow } -function Write-ErrorMessage { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red } - -# Helper for null coalescing (PowerShell 5.1 compatible) -function Get-ValueOrDefault { - param($Value, $Default) - if ($null -eq $Value -or $Value -eq '') { return $Default } - return $Value -} - -function Write-Banner { - Write-Host "" - Write-Host "==============================================" -ForegroundColor Magenta - Write-Host " Azure Infrastructure Deployment" -ForegroundColor Magenta - Write-Host " Environment: $Environment" -ForegroundColor Magenta - Write-Host " Project: $ProjectName" -ForegroundColor Magenta - Write-Host "==============================================" -ForegroundColor Magenta - Write-Host "" -} - -# ============================================================================= -# Pre-flight Checks -# ============================================================================= - -function Test-Prerequisites { - Write-Info "Running pre-flight checks..." - - # Check Azure CLI - try { - $null = az version 2>$null - } - catch { - Write-ErrorMessage "Azure CLI not found. Please install: https://docs.microsoft.com/cli/azure/install-azure-cli" - exit 1 - } - - # Check Bicep - $bicepVersion = az bicep version 2>$null - if (-not $bicepVersion) { - Write-Info "Installing Bicep CLI..." - az bicep install - } - - # Check login - $account = az account show 2>$null | ConvertFrom-Json - if (-not $account) { - Write-ErrorMessage "Not logged into Azure. Run: az login" - exit 1 - } - Write-Info "Logged in as: $($account.user.name)" - Write-Info "Subscription: $($account.name)" - - # Check parameter file exists - $paramFile = Join-Path $ParamsDir "$Environment.bicepparam" - if (-not (Test-Path $paramFile)) { - Write-ErrorMessage "Parameter file not found: $paramFile" - exit 1 - } - - # Check main.bicep exists - $mainBicep = Join-Path $BicepDir 'main.bicep' - if (-not (Test-Path $mainBicep)) { - Write-ErrorMessage "Main Bicep file not found: $mainBicep" - exit 1 - } - - Write-SuccessMessage "Pre-flight checks passed" -} - -# ============================================================================= -# Validation -# ============================================================================= - -function Test-Templates { - Write-Info "Validating Bicep templates..." - - $mainBicep = Join-Path $BicepDir 'main.bicep' - $result = az bicep build --file $mainBicep --stdout 2>&1 - - if ($LASTEXITCODE -ne 0) { - Write-ErrorMessage "Template validation failed" - Write-Host $result - exit 1 - } - - Write-SuccessMessage "Template validation passed" -} - -# ============================================================================= -# Resource Group -# ============================================================================= - -function Ensure-ResourceGroup { - Write-Info "Ensuring resource group exists: $ResourceGroup" - - $exists = az group exists --name $ResourceGroup 2>$null - if ($exists -eq 'true') { - Write-Info "Resource group already exists" - } - else { - Write-Info "Creating resource group..." - az group create ` - --name $ResourceGroup ` - --location $Location ` - --tags org=$Org project=$ProjectName environment=$Environment managedBy=bicep | Out-Null - Write-SuccessMessage "Resource group created" - } -} - -# ============================================================================= -# What-If Analysis -# ============================================================================= - -function Invoke-WhatIfAnalysis { - param([string]$DeploymentName) - - Write-Host "" - Write-Host "==============================================" -ForegroundColor Yellow - Write-Host " WHAT-IF ANALYSIS" -ForegroundColor Yellow - Write-Host " Previewing changes before deployment..." -ForegroundColor Yellow - Write-Host "==============================================" -ForegroundColor Yellow - Write-Host "" - - $paramFile = Join-Path $ParamsDir "$Environment.bicepparam" - $mainBicep = Join-Path $BicepDir 'main.bicep' - - az deployment group what-if ` - --resource-group $ResourceGroup ` - --template-file $mainBicep ` - --parameters $paramFile ` - --name $DeploymentName - - if ($LASTEXITCODE -ne 0) { - Write-ErrorMessage "What-if analysis failed" - exit 1 - } - - Write-Host "" - Write-SuccessMessage "What-if analysis complete" - - return $true -} - -# ============================================================================= -# Deployment -# ============================================================================= - -function Invoke-Deployment { - param([string]$DeploymentName) - - $paramFile = Join-Path $ParamsDir "$Environment.bicepparam" - $mainBicep = Join-Path $BicepDir 'main.bicep' - - if ($ValidateOnly) { - Write-Info "Validating deployment (no changes will be made)..." - az deployment group validate ` - --resource-group $ResourceGroup ` - --template-file $mainBicep ` - --parameters $paramFile ` - --name $DeploymentName - - if ($LASTEXITCODE -ne 0) { - Write-ErrorMessage "Deployment validation failed" - exit 1 - } - - Write-SuccessMessage "Deployment validation passed" - return - } - - Write-Host "" - Write-Host "==============================================" -ForegroundColor Green - Write-Host " DEPLOYING INFRASTRUCTURE" -ForegroundColor Green - Write-Host "==============================================" -ForegroundColor Green - Write-Host "" - - Write-Info "Starting deployment: $DeploymentName" - Write-Info "Environment: $Environment" - Write-Info "Resource Group: $ResourceGroup" - Write-Host "" - - az deployment group create ` - --resource-group $ResourceGroup ` - --template-file $mainBicep ` - --parameters $paramFile ` - --name $DeploymentName ` - --verbose - - if ($LASTEXITCODE -ne 0) { - Write-ErrorMessage "Deployment failed" - exit 1 - } - - Write-Host "" - Write-SuccessMessage "Deployment complete!" - - # Show outputs - Write-Host "" - Write-Info "Deployment Outputs:" - az deployment group show ` - --resource-group $ResourceGroup ` - --name $DeploymentName ` - --query "properties.outputs" ` - -o json -} - -# ============================================================================= -# Confirmation Prompt -# ============================================================================= - -function Get-DeploymentConfirmation { - if ($Force) { - return $true - } - - Write-Host "" - Write-Host "==============================================" -ForegroundColor Cyan - Write-Host " READY TO DEPLOY" -ForegroundColor Cyan - Write-Host "==============================================" -ForegroundColor Cyan - Write-Host "" - Write-Host "Environment: " -NoNewline; Write-Host $Environment -ForegroundColor Yellow - Write-Host "Resource Group: " -NoNewline; Write-Host $ResourceGroup -ForegroundColor Yellow - Write-Host "" - - $response = Read-Host "Proceed with deployment? (y/N)" - return $response -eq 'y' -or $response -eq 'Y' -} - -# ============================================================================= -# Post-Deployment -# ============================================================================= - -function Invoke-PostDeployment { - param([string]$DeploymentName) - - Write-Info "Running post-deployment tasks..." - - # Run validation script - $validateScript = Join-Path $ScriptDir 'validate-resources.ps1' - if (Test-Path $validateScript) { - Write-Info "Validating deployed resources..." - try { - & $validateScript -Environment $Environment - } - catch { - Write-WarningMessage "Resource validation had issues: $_" - } - } - - # Generate .env file for local development - if ($Environment -eq 'dev') { - Write-Info "Generating development environment file..." - New-EnvironmentFile -DeploymentName $DeploymentName - } - - Write-SuccessMessage "Post-deployment tasks complete" -} - -function New-EnvironmentFile { - param([string]$DeploymentName) - - $envFile = Join-Path $InfraDir '..' 'apps' 'api' '.env.azure' - $kvName = "$Base-kv" - - try { - $outputs = az deployment group show ` - --resource-group $ResourceGroup ` - --name $DeploymentName ` - --query "properties.outputs" ` - -o json 2>$null | ConvertFrom-Json - - if ($outputs) { - $content = @" -# =========================================== -# Auto-generated Azure Environment Variables -# Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') -# Environment: $Environment -# =========================================== - -# Azure Provider -AZURE_PROVIDER_ENABLED=true - -# Azure OpenAI / AI Foundry -# Configure via Azure Portal, API version auto-detected -AZURE_OPENAI_ENDPOINT=$($outputs.openAIEndpoint.value) -AZURE_OPENAI_DEPLOYMENT=gpt-4 - -# Azure Cosmos DB -AZURE_COSMOS_ENDPOINT=$($outputs.cosmosDBEndpoint.value) -AZURE_COSMOS_DATABASE=$($outputs.cosmosDBDatabaseName.value) - -# Azure Redis -AZURE_REDIS_HOSTNAME=$($outputs.redisHostname.value) -AZURE_REDIS_PORT=$(Get-ValueOrDefault $outputs.redisPort.value '6380') - -# Azure Storage -AZURE_STORAGE_ACCOUNT_NAME=$($outputs.storageAccountName.value) - -# Azure App Insights -AZURE_APP_INSIGHTS_CONNECTION_STRING=$($outputs.appInsightsConnectionString.value) - -# Key Vault (for retrieving secrets) -AZURE_KEY_VAULT_NAME=$($outputs.keyVaultName.value) - -# Note: Sensitive values should be retrieved from Key Vault at runtime -# Use: az keyvault secret show --vault-name `$AZURE_KEY_VAULT_NAME --name -"@ - - $content | Out-File -FilePath $envFile -Encoding UTF8 - Write-SuccessMessage "Generated: $envFile" - } - } - catch { - Write-WarningMessage "Could not retrieve deployment outputs: $_" - } -} - -# ============================================================================= -# Main Execution -# ============================================================================= - -function Main { - Write-Banner - - # Pre-flight checks - Test-Prerequisites - Write-Host "" - - # Validate templates - Test-Templates - Write-Host "" - - # Ensure resource group exists (needed for what-if) - if (-not $ValidateOnly) { - Ensure-ResourceGroup - Write-Host "" - } - - # Generate deployment name - $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss' - $deploymentName = "$ProjectName-$Environment-$timestamp" - - # What-if is the DEFAULT first step (unless skipped or validate-only) - if (-not $ValidateOnly -and -not $SkipWhatIf) { - Invoke-WhatIfAnalysis -DeploymentName "$deploymentName-whatif" - Write-Host "" - - # If what-if only, stop here - if ($WhatIfOnly) { - Write-SuccessMessage "What-if analysis complete. No changes were made." - return - } - - # Prompt for confirmation before actual deployment - if (-not (Get-DeploymentConfirmation)) { - Write-WarningMessage "Deployment cancelled by user" - return - } - } - - # Deploy - Invoke-Deployment -DeploymentName $deploymentName - Write-Host "" - - # Post-deployment (only for actual deployments) - if (-not $ValidateOnly) { - Invoke-PostDeployment -DeploymentName $deploymentName - Write-Host "" - } - - Write-SuccessMessage "All tasks completed successfully!" -} - -# Run main -Main diff --git a/infra/scripts/deploy.sh b/infra/scripts/deploy.sh deleted file mode 100755 index a5d93ea..0000000 --- a/infra/scripts/deploy.sh +++ /dev/null @@ -1,441 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Azure Infrastructure Deployment Script -# ============================================================================= -# Deploys or updates Azure infrastructure using Bicep templates. -# -# Usage: -# ./deploy.sh [environment] [options] -# -# Arguments: -# environment - dev, staging, or prod (default: dev) -# -# Options: -# --what-if - Preview changes without deploying -# --validate-only - Only validate templates, don't deploy -# --skip-what-if - Skip what-if preview (not recommended) -# --force - Skip confirmation prompts -# -h, --help - Show this help message -# -# Requirements: -# - Azure CLI installed and logged in -# - Bicep CLI installed (usually comes with Azure CLI) -# - jq installed (for JSON parsing) -# ============================================================================= - -set -e -set -o pipefail - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Default configuration -ENVIRONMENT="dev" -WHAT_IF=false -VALIDATE_ONLY=false -SKIP_WHAT_IF=false -FORCE=false -ORG="nl" -PROJECT_NAME="convolens" -LOCATION="eastus" - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --what-if) - WHAT_IF=true - shift - ;; - --validate-only) - VALIDATE_ONLY=true - shift - ;; - --skip-what-if) - SKIP_WHAT_IF=true - shift - ;; - --force|-f) - FORCE=true - shift - ;; - -h|--help) - echo "Usage: $0 [environment] [options]" - echo "" - echo "Arguments:" - echo " environment - dev, staging, or prod (default: dev)" - echo "" - echo "Options:" - echo " --what-if - Preview changes without deploying" - echo " --validate-only - Only validate templates, don't deploy" - echo " --skip-what-if - Skip automatic what-if preview (not recommended)" - echo " --force, -f - Skip confirmation prompts" - echo " -h, --help - Show this help message" - echo "" - echo "Examples:" - echo " $0 dev # Deploy to dev (what-if first)" - echo " $0 prod --what-if # Preview production changes only" - echo " $0 staging --validate-only # Validate staging templates" - echo " $0 prod --skip-what-if # Deploy to prod without preview" - exit 0 - ;; - -*) - echo "Unknown option: $1" >&2 - echo "Use --help for usage information" >&2 - exit 1 - ;; - *) - # Positional argument (environment) - if [[ "$1" =~ ^(dev|staging|prod)$ ]]; then - ENVIRONMENT="$1" - else - echo "Invalid environment: $1" >&2 - echo "Valid options: dev, staging, prod" >&2 - exit 1 - fi - shift - ;; - esac -done - -# Paths -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -INFRA_DIR="$(dirname "$SCRIPT_DIR")" -BICEP_DIR="${INFRA_DIR}/bicep" -PARAMS_DIR="${INFRA_DIR}/parameters" - -# Resource naming per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} -BASE="${ORG}-${ENVIRONMENT}-${PROJECT_NAME}" -RESOURCE_GROUP="${BASE}-rg" - -# ============================================================================= -# Utility Functions -# ============================================================================= - -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[SUCCESS]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[WARNING]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -# ============================================================================= -# Pre-flight Checks -# ============================================================================= - -preflight_checks() { - log_info "Running pre-flight checks..." - - # Check Azure CLI - if ! command -v az &>/dev/null; then - log_error "Azure CLI not found. Please install: https://docs.microsoft.com/cli/azure/install-azure-cli" - exit 1 - fi - - # Check jq (required for JSON parsing) - if ! command -v jq &>/dev/null; then - log_error "jq not found. Please install: apt-get install jq (Linux) or brew install jq (macOS)" - exit 1 - fi - - # Check Bicep - if ! az bicep version &>/dev/null; then - log_info "Installing Bicep CLI..." - az bicep install - fi - - # Check login - if ! az account show &>/dev/null; then - log_error "Not logged into Azure. Run: az login" - exit 1 - fi - - # Check parameter file exists - local param_file="${PARAMS_DIR}/${ENVIRONMENT}.bicepparam" - if [ ! -f "$param_file" ]; then - log_error "Parameter file not found: $param_file" - exit 1 - fi - - # Check main.bicep exists - if [ ! -f "${BICEP_DIR}/main.bicep" ]; then - log_error "Main Bicep file not found: ${BICEP_DIR}/main.bicep" - exit 1 - fi - - log_success "Pre-flight checks passed" -} - -# ============================================================================= -# Validation -# ============================================================================= - -validate_templates() { - log_info "Validating Bicep templates..." - - # Build all Bicep files to check for errors - az bicep build --file "${BICEP_DIR}/main.bicep" --stdout > /dev/null - - log_success "Template validation passed" -} - -# ============================================================================= -# Resource Group -# ============================================================================= - -ensure_resource_group() { - log_info "Ensuring resource group exists: $RESOURCE_GROUP" - - if az group show --name "$RESOURCE_GROUP" &>/dev/null; then - log_info "Resource group already exists" - else - log_info "Creating resource group..." - az group create \ - --name "$RESOURCE_GROUP" \ - --location "$LOCATION" \ - --tags org="$ORG" project="$PROJECT_NAME" environment="$ENVIRONMENT" managedBy="bicep" - log_success "Resource group created" - fi -} - -# ============================================================================= -# Deployment -# ============================================================================= - -run_what_if() { - local param_file="${PARAMS_DIR}/${ENVIRONMENT}.bicepparam" - local deployment_name="convolens-${ENVIRONMENT}-whatif-$(date +%Y%m%d-%H%M%S)" - - echo "" - echo -e "${YELLOW}==============================================" - echo " WHAT-IF ANALYSIS" - echo " Previewing changes..." - echo "==============================================${NC}" - echo "" - - az deployment group what-if \ - --resource-group "$RESOURCE_GROUP" \ - --template-file "${BICEP_DIR}/main.bicep" \ - --parameters "$param_file" \ - --name "$deployment_name" - - log_success "What-if analysis complete" -} - -confirm_deployment() { - if [[ "$FORCE" = true ]]; then - return 0 - fi - - echo "" - echo -e "${YELLOW}==============================================" - echo " READY TO DEPLOY" - echo "==============================================${NC}" - echo "" - echo "Environment: $ENVIRONMENT" - echo "Resource Group: $RESOURCE_GROUP" - echo "" - - # Extra warning for production - if [[ "$ENVIRONMENT" = "prod" ]]; then - echo -e "${RED}⚠️ WARNING: You are about to deploy to PRODUCTION!${NC}" - echo "" - read -p "Type 'yes' to confirm production deployment: " confirm - if [[ "$confirm" != "yes" ]]; then - log_warning "Deployment cancelled by user" - exit 0 - fi - else - read -p "Proceed with deployment? (y/N): " confirm - if [[ "$confirm" != "y" && "$confirm" != "Y" ]]; then - log_warning "Deployment cancelled by user" - exit 0 - fi - fi -} - -deploy_infrastructure() { - local param_file="${PARAMS_DIR}/${ENVIRONMENT}.bicepparam" - local deployment_name="convolens-${ENVIRONMENT}-$(date +%Y%m%d-%H%M%S)" - - log_info "Starting deployment: $deployment_name" - log_info "Environment: $ENVIRONMENT" - log_info "Resource Group: $RESOURCE_GROUP" - log_info "Parameter File: $param_file" - echo "" - - if [[ "$VALIDATE_ONLY" = true ]]; then - log_info "Validating deployment..." - az deployment group validate \ - --resource-group "$RESOURCE_GROUP" \ - --template-file "${BICEP_DIR}/main.bicep" \ - --parameters "$param_file" \ - --name "$deployment_name" - - log_success "Deployment validation passed" - return - fi - - if [[ "$WHAT_IF" = true ]]; then - run_what_if - return - fi - - # Default behavior: run what-if first, then deploy - if [[ "$SKIP_WHAT_IF" = false ]]; then - run_what_if - confirm_deployment - fi - - echo "" - echo -e "${GREEN}==============================================" - echo " DEPLOYING INFRASTRUCTURE" - echo "==============================================${NC}" - echo "" - - log_info "Deploying infrastructure..." - az deployment group create \ - --resource-group "$RESOURCE_GROUP" \ - --template-file "${BICEP_DIR}/main.bicep" \ - --parameters "$param_file" \ - --name "$deployment_name" \ - --verbose - - log_success "Deployment complete!" - - # Show outputs - echo "" - log_info "Deployment Outputs:" - az deployment group show \ - --resource-group "$RESOURCE_GROUP" \ - --name "$deployment_name" \ - --query "properties.outputs" \ - -o json -} - -# ============================================================================= -# Post-deployment -# ============================================================================= - -post_deployment() { - log_info "Running post-deployment tasks..." - - # Run validation script - if [ -x "${SCRIPT_DIR}/validate-resources.sh" ]; then - log_info "Validating deployed resources..." - "${SCRIPT_DIR}/validate-resources.sh" "$ENVIRONMENT" || true - fi - - # Generate .env file for local development - if [ "$ENVIRONMENT" = "dev" ]; then - log_info "Generating development environment file..." - generate_env_file - fi - - log_success "Post-deployment tasks complete" -} - -generate_env_file() { - local env_file="${INFRA_DIR}/../apps/api/.env.azure" - local kv_name="${BASE}-kv" - - log_info "Generating .env.azure file..." - - # Get outputs from deployment - local outputs=$(az deployment group show \ - --resource-group "$RESOURCE_GROUP" \ - --name "$(az deployment group list --resource-group "$RESOURCE_GROUP" --query "[0].name" -o tsv)" \ - --query "properties.outputs" \ - -o json 2>/dev/null) - - if [ -n "$outputs" ]; then - cat > "$env_file" << EOF -# =========================================== -# Auto-generated Azure Environment Variables -# Generated: $(date) -# Environment: $ENVIRONMENT -# =========================================== - -# Azure Provider -AZURE_PROVIDER_ENABLED=true - -# Azure OpenAI -AZURE_OPENAI_ENDPOINT=$(echo "$outputs" | jq -r '.openAIEndpoint.value // ""') -AZURE_OPENAI_DEPLOYMENT=gpt-4 -AZURE_OPENAI_API_VERSION=2024-02-15-preview - -# Azure Cosmos DB -AZURE_COSMOS_ENDPOINT=$(echo "$outputs" | jq -r '.cosmosDBEndpoint.value // ""') -AZURE_COSMOS_DATABASE=$(echo "$outputs" | jq -r '.cosmosDBDatabaseName.value // ""') - -# Azure Redis -AZURE_REDIS_HOSTNAME=$(echo "$outputs" | jq -r '.redisHostname.value // ""') -AZURE_REDIS_PORT=$(echo "$outputs" | jq -r '.redisPort.value // "6380"') - -# Azure Storage -AZURE_STORAGE_ACCOUNT_NAME=$(echo "$outputs" | jq -r '.storageAccountName.value // ""') - -# Azure App Insights -AZURE_APP_INSIGHTS_CONNECTION_STRING=$(echo "$outputs" | jq -r '.appInsightsConnectionString.value // ""') - -# Key Vault (for retrieving secrets) -AZURE_KEY_VAULT_NAME=$(echo "$outputs" | jq -r '.keyVaultName.value // ""') - -# Note: Sensitive values should be retrieved from Key Vault at runtime -# Use: az keyvault secret show --vault-name \$AZURE_KEY_VAULT_NAME --name -EOF - log_success "Generated: $env_file" - else - log_warning "Could not retrieve deployment outputs" - fi -} - -# ============================================================================= -# Main Execution -# ============================================================================= - -main() { - echo "" - echo "==============================================" - echo " Azure Infrastructure Deployment" - echo " Environment: $ENVIRONMENT" - echo " Project: $PROJECT_NAME" - echo "==============================================" - echo "" - - preflight_checks - echo "" - - validate_templates - echo "" - - if [[ "$VALIDATE_ONLY" = false ]]; then - ensure_resource_group - echo "" - fi - - deploy_infrastructure - echo "" - - if [[ "$WHAT_IF" = false && "$VALIDATE_ONLY" = false ]]; then - post_deployment - echo "" - fi - - log_success "All tasks completed successfully!" -} - -# Run main function -main diff --git a/infra/scripts/validate-resources.ps1 b/infra/scripts/validate-resources.ps1 deleted file mode 100644 index e620f11..0000000 --- a/infra/scripts/validate-resources.ps1 +++ /dev/null @@ -1,307 +0,0 @@ -# ============================================================================= -# Azure Resource Validation Script (PowerShell) -# ============================================================================= -# Validates that required Azure resources exist before deployment or release. -# -# Usage: -# ./validate-resources.ps1 -Environment dev -# ./validate-resources.ps1 -Environment prod -Strict -# -# ============================================================================= - -[CmdletBinding()] -param( - [Parameter(Mandatory = $false)] - [ValidateSet('dev', 'staging', 'prod')] - [string]$Environment = 'dev', - - [Parameter(Mandatory = $false)] - [switch]$Strict -) - -# ============================================================================= -# Configuration -# ============================================================================= - -$ErrorActionPreference = 'Continue' -$Org = 'nl' -$ProjectName = 'convolens' -# Naming pattern per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} -$Base = "$Org-$Environment-$ProjectName" -$BaseAlphanumeric = "$Org$Environment$ProjectName" -$ResourceGroup = "$Base-rg" - -# Track validation results -$script:ValidationPassed = $true -$script:MissingResources = @() -$script:WarningResources = @() - -# ============================================================================= -# Utility Functions -# ============================================================================= - -function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } -function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } -function Write-Warning { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } -function Write-Fail { param([string]$Message) Write-Host "[FAIL] $Message" -ForegroundColor Red } - -function Test-ResourceExists { - param( - [string]$ResourceName, - [string]$ResourceType, - [scriptblock]$CheckScript, - [bool]$Required = $true - ) - - Write-Host " Checking $ResourceType`: " -NoNewline - - try { - $result = & $CheckScript 2>$null - if ($result -or $LASTEXITCODE -eq 0) { - Write-Host $ResourceName -ForegroundColor Green -NoNewline - Write-Host " [OK]" -ForegroundColor Green - return $true - } - } - catch { - # Resource doesn't exist - } - - if ($Required) { - Write-Host $ResourceName -ForegroundColor Red -NoNewline - Write-Host " [MISSING]" -ForegroundColor Red - $script:ValidationPassed = $false - $script:MissingResources += $ResourceType - } - else { - Write-Host $ResourceName -ForegroundColor Yellow -NoNewline - Write-Host " [NOT CONFIGURED]" -ForegroundColor Yellow - $script:WarningResources += $ResourceType - } - - return $false -} - -# ============================================================================= -# Resource Checks -# ============================================================================= - -function Test-AllResources { - Write-Host "" - Write-Host "==============================================" -ForegroundColor Cyan - Write-Host " Azure Resource Validation" -ForegroundColor Cyan - Write-Host " Environment: $Environment" -ForegroundColor Cyan - Write-Host " Resource Group: $ResourceGroup" -ForegroundColor Cyan - Write-Host "==============================================" -ForegroundColor Cyan - Write-Host "" - - # Required Resources - Write-Host "Required Resources:" -ForegroundColor White - Write-Host "-------------------" -ForegroundColor Gray - - # Resource Group - Test-ResourceExists ` - -ResourceName $ResourceGroup ` - -ResourceType "Resource Group" ` - -CheckScript { az group show --name $ResourceGroup -o json } ` - -Required $true - - # Key Vault - $kvName = "$Base-kv" - Test-ResourceExists ` - -ResourceName $kvName ` - -ResourceType "Key Vault" ` - -CheckScript { az keyvault show --name $kvName -o json } ` - -Required $true - - # Storage Account (alphanumeric only, max 24 chars) - $storageName = "${BaseAlphanumeric}st" - Test-ResourceExists ` - -ResourceName $storageName ` - -ResourceType "Storage Account" ` - -CheckScript { az storage account show --name $storageName --resource-group $ResourceGroup -o json } ` - -Required $true - - # Application Insights - $appiName = "$Base-appi" - Test-ResourceExists ` - -ResourceName $appiName ` - -ResourceType "Application Insights" ` - -CheckScript { az monitor app-insights component show --app $appiName --resource-group $ResourceGroup -o json } ` - -Required $true - - # Container Apps Environment - $caeName = "$Base-cae" - Test-ResourceExists ` - -ResourceName $caeName ` - -ResourceType "Container Apps Environment" ` - -CheckScript { az containerapp env show --name $caeName --resource-group $ResourceGroup -o json } ` - -Required $true - - Write-Host "" - - # Optional Resources - Write-Host "Optional Resources:" -ForegroundColor White - Write-Host "-------------------" -ForegroundColor Gray - - # Azure OpenAI / AI Foundry - $openaiName = "$Base-oai" - Test-ResourceExists ` - -ResourceName $openaiName ` - -ResourceType "Azure OpenAI" ` - -CheckScript { az cognitiveservices account show --name $openaiName --resource-group $ResourceGroup -o json } ` - -Required $false - - # Cosmos DB - $cosmosName = "$Base-cosmos" - Test-ResourceExists ` - -ResourceName $cosmosName ` - -ResourceType "Cosmos DB" ` - -CheckScript { az cosmosdb show --name $cosmosName --resource-group $ResourceGroup -o json } ` - -Required $false - - # Redis Cache - $redisName = "$Base-redis" - Test-ResourceExists ` - -ResourceName $redisName ` - -ResourceType "Redis Cache" ` - -CheckScript { az redis show --name $redisName --resource-group $ResourceGroup -o json } ` - -Required $false - - # Static Web App - $swaName = "$Base-swa" - Test-ResourceExists ` - -ResourceName $swaName ` - -ResourceType "Static Web App" ` - -CheckScript { az staticwebapp show --name $swaName --resource-group $ResourceGroup -o json } ` - -Required $false - - Write-Host "" -} - -# ============================================================================= -# Secrets Check -# ============================================================================= - -function Test-KeyVaultSecrets { - $kvName = "$Base-kv" - - Write-Host "Key Vault Secrets:" -ForegroundColor White - Write-Host "------------------" -ForegroundColor Gray - - # Check if Key Vault exists first - $kvExists = az keyvault show --name $kvName -o json 2>$null - if (-not $kvExists) { - Write-Warning " Cannot check secrets - Key Vault does not exist" - return - } - - $secrets = @( - @{ Name = "azure-openai-api-key"; Required = $false }, - @{ Name = "cosmos-db-key"; Required = $false }, - @{ Name = "redis-key"; Required = $false } - ) - - foreach ($secret in $secrets) { - Write-Host " Checking secret: " -NoNewline - - try { - $result = az keyvault secret show --vault-name $kvName --name $secret.Name -o json 2>$null - if ($result) { - Write-Host $secret.Name -ForegroundColor Green -NoNewline - Write-Host " [OK]" -ForegroundColor Green - } - else { - throw "Not found" - } - } - catch { - if ($secret.Required) { - Write-Host $secret.Name -ForegroundColor Red -NoNewline - Write-Host " [MISSING]" -ForegroundColor Red - $script:ValidationPassed = $false - } - else { - Write-Host $secret.Name -ForegroundColor Yellow -NoNewline - Write-Host " [NOT SET]" -ForegroundColor Yellow - } - } - } - - Write-Host "" -} - -# ============================================================================= -# Summary -# ============================================================================= - -function Write-Summary { - Write-Host "==============================================" -ForegroundColor Cyan - Write-Host " Validation Summary" -ForegroundColor Cyan - Write-Host "==============================================" -ForegroundColor Cyan - Write-Host "" - - if ($script:ValidationPassed) { - Write-Host " Status: " -NoNewline - Write-Host "PASSED" -ForegroundColor Green - } - else { - Write-Host " Status: " -NoNewline - Write-Host "FAILED" -ForegroundColor Red - } - - if ($script:MissingResources.Count -gt 0) { - Write-Host "" - Write-Host " Missing Required Resources:" -ForegroundColor Red - foreach ($resource in $script:MissingResources) { - Write-Host " - $resource" -ForegroundColor Red - } - } - - if ($script:WarningResources.Count -gt 0) { - Write-Host "" - Write-Host " Optional Resources Not Configured:" -ForegroundColor Yellow - foreach ($resource in $script:WarningResources) { - Write-Host " - $resource" -ForegroundColor Yellow - } - } - - Write-Host "" - - if (-not $script:ValidationPassed) { - Write-Host " To deploy missing resources, run:" -ForegroundColor Cyan - Write-Host " ./deploy.ps1 -Environment $Environment" -ForegroundColor White - Write-Host "" - } -} - -# ============================================================================= -# Main -# ============================================================================= - -function Main { - # Check Azure CLI login - $account = az account show -o json 2>$null | ConvertFrom-Json - if (-not $account) { - Write-Fail "Not logged into Azure. Run: az login" - exit 1 - } - - Test-AllResources - Test-KeyVaultSecrets - Write-Summary - - if ($Strict -and -not $script:ValidationPassed) { - Write-Fail "Strict mode: Validation failed due to missing required resources" - exit 1 - } - - if ($script:ValidationPassed) { - exit 0 - } - else { - exit 1 - } -} - -Main diff --git a/infra/scripts/validate-resources.sh b/infra/scripts/validate-resources.sh deleted file mode 100755 index bdd0942..0000000 --- a/infra/scripts/validate-resources.sh +++ /dev/null @@ -1,370 +0,0 @@ -#!/bin/bash -# ============================================================================= -# Azure Resource Validation Script -# ============================================================================= -# Validates that all required Azure resources exist and are properly configured -# before deployment or release. -# -# Usage: -# ./validate-resources.sh [--strict] -# -# Arguments: -# environment - dev, staging, or prod -# --strict - Fail on any missing or misconfigured resource -# -# Requirements: -# - Azure CLI installed and logged in -# - jq installed for JSON parsing -# ============================================================================= - -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Configuration -ENVIRONMENT="${1:-dev}" -STRICT_MODE="${2:-}" -ORG="nl" -PROJECT_NAME="convolens" -# Naming pattern per ADR-0027 (no region suffix): {org}-{env}-{project}-{type} -BASE="${ORG}-${ENVIRONMENT}-${PROJECT_NAME}" -# Hyphen-stripped variant for resources where Azure forbids hyphens (storage) -BASE_ALPHANUMERIC="${ORG}${ENVIRONMENT}${PROJECT_NAME}" - -# Counters -TOTAL_CHECKS=0 -PASSED_CHECKS=0 -FAILED_CHECKS=0 -WARNING_CHECKS=0 - -# ============================================================================= -# Utility Functions -# ============================================================================= - -log_info() { - echo -e "${BLUE}[INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[PASS]${NC} $1" - ((PASSED_CHECKS++)) -} - -log_warning() { - echo -e "${YELLOW}[WARN]${NC} $1" - ((WARNING_CHECKS++)) -} - -log_error() { - echo -e "${RED}[FAIL]${NC} $1" - ((FAILED_CHECKS++)) -} - -check_resource() { - local resource_type="$1" - local resource_name="$2" - local resource_group="$3" - local required="$4" # true or false - - ((TOTAL_CHECKS++)) - - if az resource show --resource-type "$resource_type" --name "$resource_name" --resource-group "$resource_group" &>/dev/null; then - log_success "$resource_type: $resource_name exists" - return 0 - else - if [ "$required" = "true" ]; then - log_error "$resource_type: $resource_name NOT FOUND (required)" - return 1 - else - log_warning "$resource_type: $resource_name not found (optional)" - return 0 - fi - fi -} - -# ============================================================================= -# Validation Functions -# ============================================================================= - -validate_resource_group() { - local rg_name="${BASE}-rg" - - log_info "Checking Resource Group: $rg_name" - ((TOTAL_CHECKS++)) - - if az group show --name "$rg_name" &>/dev/null; then - log_success "Resource Group exists: $rg_name" - RESOURCE_GROUP="$rg_name" - return 0 - else - log_error "Resource Group NOT FOUND: $rg_name" - return 1 - fi -} - -validate_key_vault() { - local kv_name="${BASE}-kv" - - log_info "Checking Key Vault..." - check_resource "Microsoft.KeyVault/vaults" "$kv_name" "$RESOURCE_GROUP" "true" - - # Check for required secrets - if az keyvault show --name "$kv_name" &>/dev/null; then - log_info "Checking Key Vault secrets..." - - local required_secrets=("azure-openai-api-key" "cosmos-db-key" "redis-password") - for secret in "${required_secrets[@]}"; do - ((TOTAL_CHECKS++)) - if az keyvault secret show --vault-name "$kv_name" --name "$secret" &>/dev/null; then - log_success "Secret exists: $secret" - else - log_warning "Secret missing: $secret" - fi - done - fi -} - -validate_storage() { - # Storage Account names: alphanumeric only, max 24 chars - local storage_name="${BASE_ALPHANUMERIC}st" - - log_info "Checking Storage Account..." - check_resource "Microsoft.Storage/storageAccounts" "$storage_name" "$RESOURCE_GROUP" "true" - - # Check containers - if az storage account show --name "$storage_name" &>/dev/null; then - log_info "Checking blob containers..." - - local required_containers=("chat-exports" "user-uploads" "summaries") - local connection_string=$(az storage account show-connection-string --name "$storage_name" --resource-group "$RESOURCE_GROUP" -o tsv 2>/dev/null) - - for container in "${required_containers[@]}"; do - ((TOTAL_CHECKS++)) - if az storage container show --name "$container" --connection-string "$connection_string" &>/dev/null; then - log_success "Container exists: $container" - else - log_warning "Container missing: $container" - fi - done - fi -} - -validate_openai() { - local openai_name="${BASE}-oai" - - log_info "Checking Azure OpenAI..." - - ((TOTAL_CHECKS++)) - if az cognitiveservices account show --name "$openai_name" --resource-group "$RESOURCE_GROUP" &>/dev/null; then - log_success "Azure OpenAI exists: $openai_name" - - # Check deployments - log_info "Checking OpenAI model deployments..." - local deployments=$(az cognitiveservices account deployment list --name "$openai_name" --resource-group "$RESOURCE_GROUP" -o json 2>/dev/null) - - local required_models=("gpt-4" "gpt-35-turbo") - for model in "${required_models[@]}"; do - ((TOTAL_CHECKS++)) - if echo "$deployments" | jq -e ".[] | select(.name == \"$model\")" &>/dev/null; then - log_success "Model deployment exists: $model" - else - log_warning "Model deployment missing: $model" - fi - done - else - log_warning "Azure OpenAI not found: $openai_name (optional)" - fi -} - -validate_cosmos_db() { - local cosmos_name="${BASE}-cosmos" - - log_info "Checking Cosmos DB..." - - ((TOTAL_CHECKS++)) - if az cosmosdb show --name "$cosmos_name" --resource-group "$RESOURCE_GROUP" &>/dev/null; then - log_success "Cosmos DB exists: $cosmos_name" - - # Check database and containers - log_info "Checking Cosmos DB containers..." - # COSMOS_DATABASE_NAME may be set explicitly if bicep databaseName param differs from projectName - local db_name="${COSMOS_DATABASE_NAME:-$PROJECT_NAME}" - local containers=$(az cosmosdb sql container list --account-name "$cosmos_name" --database-name "$db_name" --resource-group "$RESOURCE_GROUP" -o json 2>/dev/null) - - local required_containers=("users" "chats" "summaries") - for container in "${required_containers[@]}"; do - ((TOTAL_CHECKS++)) - if echo "$containers" | jq -e ".[] | select(.name == \"$container\")" &>/dev/null; then - log_success "Container exists: $container" - else - log_warning "Container missing: $container" - fi - done - else - log_warning "Cosmos DB not found: $cosmos_name (optional)" - fi -} - -validate_redis() { - local redis_name="${BASE}-redis" - - log_info "Checking Redis Cache..." - - ((TOTAL_CHECKS++)) - if az redis show --name "$redis_name" --resource-group "$RESOURCE_GROUP" &>/dev/null; then - log_success "Redis Cache exists: $redis_name" - - # Check status - local status=$(az redis show --name "$redis_name" --resource-group "$RESOURCE_GROUP" --query "provisioningState" -o tsv 2>/dev/null) - ((TOTAL_CHECKS++)) - if [ "$status" = "Succeeded" ]; then - log_success "Redis Cache status: $status" - else - log_warning "Redis Cache status: $status (expected: Succeeded)" - fi - else - log_warning "Redis Cache not found: $redis_name (optional)" - fi -} - -validate_app_insights() { - local appi_name="${BASE}-appi" - - log_info "Checking Application Insights..." - check_resource "Microsoft.Insights/components" "$appi_name" "$RESOURCE_GROUP" "true" -} - -validate_container_apps() { - local env_name="${BASE}-cae" - local app_name="${BASE}-api" - - log_info "Checking Container Apps Environment..." - - ((TOTAL_CHECKS++)) - if az containerapp env show --name "$env_name" --resource-group "$RESOURCE_GROUP" &>/dev/null; then - log_success "Container Apps Environment exists: $env_name" - - # Check API app - log_info "Checking API Container App..." - ((TOTAL_CHECKS++)) - if az containerapp show --name "$app_name" --resource-group "$RESOURCE_GROUP" &>/dev/null; then - log_success "API Container App exists: $app_name" - - # Check if running - local running=$(az containerapp show --name "$app_name" --resource-group "$RESOURCE_GROUP" --query "properties.runningStatus" -o tsv 2>/dev/null) - ((TOTAL_CHECKS++)) - if [ "$running" = "Running" ]; then - log_success "API Container App status: Running" - else - log_warning "API Container App status: $running" - fi - else - log_warning "API Container App not found: $app_name" - fi - else - log_warning "Container Apps Environment not found: $env_name (optional)" - fi -} - -validate_static_web_app() { - local swa_name="${BASE}-swa" - - log_info "Checking Static Web App..." - - ((TOTAL_CHECKS++)) - if az staticwebapp show --name "$swa_name" --resource-group "$RESOURCE_GROUP" &>/dev/null; then - log_success "Static Web App exists: $swa_name" - else - log_warning "Static Web App not found: $swa_name (optional)" - fi -} - -# ============================================================================= -# Main Execution -# ============================================================================= - -main() { - echo "" - echo "==============================================" - echo " Azure Resource Validation" - echo " Environment: $ENVIRONMENT" - echo " Project: $PROJECT_NAME" - echo "==============================================" - echo "" - - # Check Azure CLI - if ! command -v az &>/dev/null; then - log_error "Azure CLI not found. Please install: https://docs.microsoft.com/cli/azure/install-azure-cli" - exit 1 - fi - - # Check login - if ! az account show &>/dev/null; then - log_error "Not logged into Azure. Run: az login" - exit 1 - fi - - # Show current subscription - local subscription=$(az account show --query "name" -o tsv) - log_info "Using subscription: $subscription" - echo "" - - # Run validations - validate_resource_group || { log_error "Resource group validation failed. Cannot continue."; exit 1; } - echo "" - - validate_key_vault - echo "" - - validate_storage - echo "" - - validate_openai - echo "" - - validate_cosmos_db - echo "" - - validate_redis - echo "" - - validate_app_insights - echo "" - - validate_container_apps - echo "" - - validate_static_web_app - echo "" - - # Summary - echo "==============================================" - echo " Validation Summary" - echo "==============================================" - echo -e " Total checks: $TOTAL_CHECKS" - echo -e " ${GREEN}Passed:${NC} $PASSED_CHECKS" - echo -e " ${YELLOW}Warnings:${NC} $WARNING_CHECKS" - echo -e " ${RED}Failed:${NC} $FAILED_CHECKS" - echo "==============================================" - echo "" - - # Exit code - if [ "$STRICT_MODE" = "--strict" ] && [ $FAILED_CHECKS -gt 0 ]; then - log_error "Validation failed in strict mode" - exit 1 - elif [ $FAILED_CHECKS -gt 0 ]; then - log_warning "Some required resources are missing" - exit 1 - else - log_success "All validations passed" - exit 0 - fi -} - -# Run main function -main "$@" diff --git a/infra/terraform/.gitignore b/infra/terraform/.gitignore new file mode 100644 index 0000000..0765fc8 --- /dev/null +++ b/infra/terraform/.gitignore @@ -0,0 +1,14 @@ +.terraform/ +*.tfstate +*.tfstate.backup +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraformrc +terraform.rc +*.tfvars.local +tfplan +*.tfplan diff --git a/infra/terraform/env/dev/.terraform.lock.hcl b/infra/terraform/env/dev/.terraform.lock.hcl new file mode 100644 index 0000000..372181d --- /dev/null +++ b/infra/terraform/env/dev/.terraform.lock.hcl @@ -0,0 +1,42 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/azurerm" { + version = "4.72.0" + constraints = ">= 4.62.0" + hashes = [ + "h1:AymDoYag6FI+U2Q89QXJM9oJlHr8c/0d4laM4A/nnk8=", + "zh:073472587c3752e89738522814d2b4eb2fd69eb2cb19c5a5ead3c7d2eabdc279", + "zh:1950effc0c315b6002c8cb6327b94fe59bda210e699367d9727bc66490d651d2", + "zh:47c990db75658525de57c8955a05b4752b88f3a900fffac0e7661d4a749e94f2", + "zh:610f2cbd6fab76750d8b093f03beabbb7162dc8c6affe0109f534ce240b3ff0f", + "zh:6739d645fe548c5a489d711f7748f32368cf68d723d2c59d3f2e21456304d692", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a277ab095cc8aff3aede9e43eca2a699936472ef90abb272adf3daa609eb9141", + "zh:b1fdcdaf926c86de0d884beda90d78cb94a42ddede03a1f0b92c36b321d4f07e", + "zh:c003f1f15e52c54e189301ae2c7d8dd65acb2e5a7527d201355f2757b5465ba9", + "zh:c45f2d2206c0f8f71f207cd39eec73da9619d35932bbe1a5b8be7679c50a151e", + "zh:d7040d8ec295481bc1d30346ed7f3075c40ede87c0fedf1db34dd91c1c367a10", + "zh:e595f0b870cd5fd5debdc926fc1740201d2b66188b9b132dc598bdd6444e7348", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.8.1" + constraints = ">= 3.6.0" + hashes = [ + "h1:osH3aBqEARwOz3VBJKdpFKJJCNIdgRC6k8vPojkLmlY=", + "zh:08dd03b918c7b55713026037c5400c48af5b9f468f483463321bd18e17b907b4", + "zh:0eee654a5542dc1d41920bbf2419032d6f0d5625b03bd81339e5b33394a3e0ae", + "zh:229665ddf060aa0ed315597908483eee5b818a17d09b6417a0f52fd9405c4f57", + "zh:2469d2e48f28076254a2a3fc327f184914566d9e40c5780b8d96ebf7205f8bc0", + "zh:37d7eb334d9561f335e748280f5535a384a88675af9a9eac439d4cfd663bcb66", + "zh:741101426a2f2c52dee37122f0f4a2f2d6af6d852cb1db634480a86398fa3511", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:a902473f08ef8df62cfe6116bd6c157070a93f66622384300de235a533e9d4a9", + "zh:b85c511a23e57a2147355932b3b6dce2a11e856b941165793a0c3d7578d94d05", + "zh:c5172226d18eaac95b1daac80172287b69d4ce32750c82ad77fa0768be4ea4b8", + "zh:dab4434dba34aad569b0bc243c2d3f3ff86dd7740def373f2a49816bd2ff819b", + "zh:f49fd62aa8c5525a5c17abd51e27ca5e213881d58882fd42fec4a545b53c9699", + ] +} diff --git a/infra/terraform/env/dev/backend.hcl b/infra/terraform/env/dev/backend.hcl new file mode 100644 index 0000000..df58126 --- /dev/null +++ b/infra/terraform/env/dev/backend.hcl @@ -0,0 +1,4 @@ +resource_group_name = "nl-tfstate-rg" +storage_account_name = "nltfstateconvolens" +container_name = "tfstate" +key = "convolens-dev.tfstate" diff --git a/infra/terraform/env/dev/main.tf b/infra/terraform/env/dev/main.tf new file mode 100644 index 0000000..0695d79 --- /dev/null +++ b/infra/terraform/env/dev/main.tf @@ -0,0 +1,415 @@ +data "azurerm_client_config" "current" {} + +locals { + prefix = "${var.org}-${var.env}-${var.projname}" + alphanumeric = lower(replace(local.prefix, "-", "")) + + rg_name = "${local.prefix}-rg" + law_name = "${local.prefix}-law" + ai_name = "${local.prefix}-appi" + storage_name = substr("${local.alphanumeric}st", 0, 24) + kv_name = substr("${local.prefix}-kv", 0, 24) + cosmos_name = "${local.prefix}-cosmos" + cae_name = "${local.prefix}-cae" + api_name = "${local.prefix}-api" + swa_name = "${local.prefix}-swa" + redis_name = "${local.prefix}-redis" + oai_name = "${local.prefix}-oai" + + base_tags = { + org = var.org + project = var.projname + environment = var.env + managedBy = "terraform" + } + tags = merge(local.base_tags, var.tags) + + blob_containers = ["chat-exports", "user-uploads", "summaries"] + + cosmos_containers = [ + { name = "users", partition_key = "/id" }, + { name = "chats", partition_key = "/userId" }, + { name = "summaries", partition_key = "/chatId" }, + ] + + is_prod = var.env == "prod" + + redis_capacity = local.is_prod ? 1 : 0 + redis_sku = local.is_prod ? "Standard" : "Basic" + + storage_sku = local.is_prod ? "Standard_GRS" : "Standard_LRS" + + ai_retention_days = local.is_prod ? 90 : 30 + + api_cpu = local.is_prod ? 1.0 : 0.5 + api_memory = local.is_prod ? "2.0Gi" : "1.0Gi" + api_min_replicas = local.is_prod ? 2 : 0 + api_max_replicas = local.is_prod ? 10 : 3 +} + +resource "azurerm_resource_group" "rg" { + name = local.rg_name + location = var.location + tags = local.tags +} + +resource "azurerm_log_analytics_workspace" "law" { + name = local.law_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + sku = "PerGB2018" + retention_in_days = local.ai_retention_days + tags = local.tags +} + +resource "azurerm_application_insights" "ai" { + name = local.ai_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + workspace_id = azurerm_log_analytics_workspace.law.id + application_type = "web" + retention_in_days = local.ai_retention_days + tags = local.tags +} + +resource "azurerm_storage_account" "st" { + name = local.storage_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + account_tier = "Standard" + account_replication_type = local.is_prod ? "GRS" : "LRS" + account_kind = "StorageV2" + access_tier = "Hot" + min_tls_version = "TLS1_2" + https_traffic_only_enabled = true + allow_nested_items_to_be_public = false + shared_access_key_enabled = true + tags = local.tags + + blob_properties { + versioning_enabled = local.is_prod + + delete_retention_policy { + days = 7 + } + + container_delete_retention_policy { + days = 7 + } + } + + network_rules { + bypass = ["AzureServices"] + default_action = "Allow" + } +} + +resource "azurerm_storage_container" "containers" { + for_each = toset(local.blob_containers) + name = each.value + storage_account_id = azurerm_storage_account.st.id + container_access_type = "private" +} + +resource "azurerm_cosmosdb_account" "cosmos" { + count = var.enable_cosmos ? 1 : 0 + name = local.cosmos_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + offer_type = "Standard" + kind = "GlobalDocumentDB" + free_tier_enabled = false + public_network_access_enabled = true + automatic_failover_enabled = false + tags = local.tags + + consistency_policy { + consistency_level = "Session" + } + + geo_location { + location = var.location + failover_priority = 0 + zone_redundant = false + } + + capabilities { + name = "EnableServerless" + } +} + +resource "azurerm_cosmosdb_sql_database" "db" { + count = var.enable_cosmos ? 1 : 0 + name = var.database_name + resource_group_name = azurerm_resource_group.rg.name + account_name = azurerm_cosmosdb_account.cosmos[0].name +} + +resource "azurerm_cosmosdb_sql_container" "containers" { + for_each = var.enable_cosmos ? { for c in local.cosmos_containers : c.name => c } : {} + name = each.value.name + resource_group_name = azurerm_resource_group.rg.name + account_name = azurerm_cosmosdb_account.cosmos[0].name + database_name = azurerm_cosmosdb_sql_database.db[0].name + partition_key_paths = [each.value.partition_key] + + indexing_policy { + indexing_mode = "consistent" + + included_path { + path = "/*" + } + + excluded_path { + path = "/\"_etag\"/?" + } + } +} + +resource "azurerm_key_vault" "kv" { + name = local.kv_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + tenant_id = data.azurerm_client_config.current.tenant_id + sku_name = "standard" + enabled_for_deployment = true + enabled_for_template_deployment = true + rbac_authorization_enabled = false + purge_protection_enabled = local.is_prod + soft_delete_retention_days = local.is_prod ? 30 : 7 + public_network_access_enabled = true + tags = local.tags + + network_acls { + bypass = "AzureServices" + default_action = "Allow" + } +} + +resource "azurerm_key_vault_access_policy" "deployer" { + key_vault_id = azurerm_key_vault.kv.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = data.azurerm_client_config.current.object_id + + secret_permissions = ["Get", "List", "Set", "Delete", "Purge", "Recover"] +} + +resource "azurerm_key_vault_secret" "cosmos_endpoint" { + count = var.enable_cosmos ? 1 : 0 + name = "cosmos-db-endpoint" + value = azurerm_cosmosdb_account.cosmos[0].endpoint + key_vault_id = azurerm_key_vault.kv.id + + depends_on = [azurerm_key_vault_access_policy.deployer] +} + +resource "azurerm_key_vault_secret" "cosmos_key" { + count = var.enable_cosmos ? 1 : 0 + name = "cosmos-db-key" + value = azurerm_cosmosdb_account.cosmos[0].primary_key + key_vault_id = azurerm_key_vault.kv.id + + depends_on = [azurerm_key_vault_access_policy.deployer] +} + +resource "azurerm_key_vault_secret" "cosmos_connection_string" { + count = var.enable_cosmos ? 1 : 0 + name = "cosmos-db-connection-string" + value = azurerm_cosmosdb_account.cosmos[0].primary_sql_connection_string + key_vault_id = azurerm_key_vault.kv.id + + depends_on = [azurerm_key_vault_access_policy.deployer] +} + +resource "azurerm_key_vault_secret" "appinsights_connection_string" { + name = "appinsights-connection-string" + value = azurerm_application_insights.ai.connection_string + key_vault_id = azurerm_key_vault.kv.id + + depends_on = [azurerm_key_vault_access_policy.deployer] +} + +resource "azurerm_key_vault_secret" "storage_connection_string" { + name = "storage-connection-string" + value = azurerm_storage_account.st.primary_connection_string + key_vault_id = azurerm_key_vault.kv.id + + depends_on = [azurerm_key_vault_access_policy.deployer] +} + +resource "azurerm_redis_cache" "redis" { + count = var.enable_redis ? 1 : 0 + name = local.redis_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + capacity = local.redis_capacity + family = "C" + sku_name = local.redis_sku + minimum_tls_version = "1.2" + tags = local.tags +} + +resource "azurerm_key_vault_secret" "redis_password" { + count = var.enable_redis ? 1 : 0 + name = "redis-password" + value = azurerm_redis_cache.redis[0].primary_access_key + key_vault_id = azurerm_key_vault.kv.id + + depends_on = [azurerm_key_vault_access_policy.deployer] +} + +resource "azurerm_cognitive_account" "openai" { + count = var.enable_openai ? 1 : 0 + name = local.oai_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + kind = "OpenAI" + sku_name = "S0" + custom_subdomain_name = local.oai_name + tags = local.tags +} + +resource "azurerm_container_app_environment" "cae" { + count = var.enable_container_apps ? 1 : 0 + name = local.cae_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + log_analytics_workspace_id = azurerm_log_analytics_workspace.law.id + tags = local.tags +} + +resource "azurerm_container_app" "api" { + count = var.enable_container_apps ? 1 : 0 + name = local.api_name + container_app_environment_id = azurerm_container_app_environment.cae[0].id + resource_group_name = azurerm_resource_group.rg.name + revision_mode = "Single" + tags = local.tags + + identity { + type = "SystemAssigned" + } + + secret { + name = "appinsights-connection-string" + value = azurerm_application_insights.ai.connection_string + } + + ingress { + external_enabled = true + target_port = 3001 + transport = "http" + + traffic_weight { + percentage = 100 + latest_revision = true + } + + cors { + allowed_origins = ["*"] + allowed_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"] + allowed_headers = ["*"] + } + } + + template { + min_replicas = local.api_min_replicas + max_replicas = local.api_max_replicas + + container { + name = "api" + image = var.container_image_api + cpu = local.api_cpu + memory = local.api_memory + + env { + name = "NODE_ENV" + value = "production" + } + env { + name = "PORT" + value = "3001" + } + env { + name = "APPLICATIONINSIGHTS_CONNECTION_STRING" + secret_name = "appinsights-connection-string" + } + env { + name = "AZURE_COSMOS_ENDPOINT" + value = var.enable_cosmos ? azurerm_cosmosdb_account.cosmos[0].endpoint : "" + } + env { + name = "AZURE_REDIS_HOSTNAME" + value = var.enable_redis ? azurerm_redis_cache.redis[0].hostname : "" + } + env { + name = "AZURE_STORAGE_ACCOUNT_NAME" + value = azurerm_storage_account.st.name + } + env { + name = "AZURE_OPENAI_ENDPOINT" + value = var.enable_openai ? azurerm_cognitive_account.openai[0].endpoint : "" + } + env { + name = "AZURE_KEY_VAULT_NAME" + value = azurerm_key_vault.kv.name + } + } + + http_scale_rule { + name = "http-rule" + concurrent_requests = "100" + } + } +} + +resource "azurerm_key_vault_access_policy" "container_app" { + count = var.enable_container_apps ? 1 : 0 + key_vault_id = azurerm_key_vault.kv.id + tenant_id = data.azurerm_client_config.current.tenant_id + object_id = azurerm_container_app.api[0].identity[0].principal_id + + secret_permissions = ["Get", "List"] +} + +resource "azurerm_static_web_app" "swa" { + count = var.enable_static_web_app ? 1 : 0 + name = local.swa_name + location = azurerm_resource_group.rg.location + resource_group_name = azurerm_resource_group.rg.name + sku_tier = "Free" + sku_size = "Free" + tags = local.tags +} + +resource "azurerm_consumption_budget_resource_group" "budget" { + count = var.enable_budget_alerts && var.admin_email != "" ? 1 : 0 + name = "${local.prefix}-budget" + resource_group_id = azurerm_resource_group.rg.id + amount = var.monthly_budget_amount + time_grain = "Monthly" + + time_period { + start_date = formatdate("YYYY-MM-01'T'00:00:00'Z'", timestamp()) + } + + notification { + enabled = true + operator = "GreaterThan" + threshold = 80 + threshold_type = "Actual" + contact_emails = [var.admin_email] + } + + notification { + enabled = true + operator = "GreaterThan" + threshold = 100 + threshold_type = "Forecasted" + contact_emails = [var.admin_email] + } + + lifecycle { + ignore_changes = [time_period] + } +} diff --git a/infra/terraform/env/dev/outputs.tf b/infra/terraform/env/dev/outputs.tf new file mode 100644 index 0000000..4076d85 --- /dev/null +++ b/infra/terraform/env/dev/outputs.tf @@ -0,0 +1,71 @@ +output "resource_group_name" { + value = azurerm_resource_group.rg.name + description = "Resource group containing all convolens dev resources." +} + +output "key_vault_name" { + value = azurerm_key_vault.kv.name + description = "Key Vault holding application secrets." +} + +output "key_vault_uri" { + value = azurerm_key_vault.kv.vault_uri + description = "Key Vault dataplane URI." +} + +output "storage_account_name" { + value = azurerm_storage_account.st.name + description = "Storage account name for blob containers." +} + +output "storage_blob_endpoint" { + value = azurerm_storage_account.st.primary_blob_endpoint + description = "Public blob endpoint." +} + +output "appinsights_connection_string" { + value = azurerm_application_insights.ai.connection_string + description = "Application Insights connection string. Forwarded to the API container as APPLICATIONINSIGHTS_CONNECTION_STRING." + sensitive = true +} + +output "log_analytics_workspace_id" { + value = azurerm_log_analytics_workspace.law.id + description = "Log Analytics workspace ARM ID (referenced by Container Apps env)." +} + +output "cosmos_endpoint" { + value = var.enable_cosmos ? azurerm_cosmosdb_account.cosmos[0].endpoint : "" + description = "Cosmos DB document endpoint, empty when enable_cosmos = false." +} + +output "cosmos_database_name" { + value = var.enable_cosmos ? azurerm_cosmosdb_sql_database.db[0].name : "" + description = "Cosmos SQL database name, empty when enable_cosmos = false." +} + +output "redis_hostname" { + value = var.enable_redis ? azurerm_redis_cache.redis[0].hostname : "" + description = "Redis hostname, empty when enable_redis = false." +} + +output "openai_endpoint" { + value = var.enable_openai ? azurerm_cognitive_account.openai[0].endpoint : "" + description = "Azure OpenAI endpoint, empty when enable_openai = false." +} + +output "container_app_url" { + value = var.enable_container_apps ? "https://${azurerm_container_app.api[0].latest_revision_fqdn}" : "" + description = "Public URL of the API container app, empty when enable_container_apps = false." +} + +output "static_web_app_default_host" { + value = var.enable_static_web_app ? "https://${azurerm_static_web_app.swa[0].default_host_name}" : "" + description = "Public URL of the Static Web App, empty when enable_static_web_app = false." +} + +output "static_web_app_api_key" { + value = var.enable_static_web_app ? azurerm_static_web_app.swa[0].api_key : "" + description = "SWA deployment token consumed by the GitHub Actions deploy workflow." + sensitive = true +} diff --git a/infra/terraform/env/dev/terraform.tf b/infra/terraform/env/dev/terraform.tf new file mode 100644 index 0000000..81417d7 --- /dev/null +++ b/infra/terraform/env/dev/terraform.tf @@ -0,0 +1,28 @@ +terraform { + required_version = ">= 1.14.0" + + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = ">= 4.62.0" + } + random = { + source = "hashicorp/random" + version = ">= 3.6.0" + } + } + + backend "azurerm" {} +} + +provider "azurerm" { + features { + key_vault { + purge_soft_delete_on_destroy = true + recover_soft_deleted_key_vaults = true + } + resource_group { + prevent_deletion_if_contains_resources = false + } + } +} diff --git a/infra/terraform/env/dev/terraform.tfvars b/infra/terraform/env/dev/terraform.tfvars new file mode 100644 index 0000000..0952fda --- /dev/null +++ b/infra/terraform/env/dev/terraform.tfvars @@ -0,0 +1,22 @@ +env = "dev" +org = "nl" +projname = "convolens" +location = "eastus2" + +database_name = "convolens" + +enable_cosmos = true +enable_openai = false +enable_redis = false +enable_container_apps = true +enable_static_web_app = true +enable_budget_alerts = false + +admin_email = "" +monthly_budget_amount = 100 + +container_image_api = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest" + +tags = { + costCenter = "development" +} diff --git a/infra/terraform/env/dev/variables.tf b/infra/terraform/env/dev/variables.tf new file mode 100644 index 0000000..f5cced7 --- /dev/null +++ b/infra/terraform/env/dev/variables.tf @@ -0,0 +1,103 @@ +variable "env" { + type = string + description = "Environment name (dev/staging/prod). Drives naming and SKU sizing." + default = "dev" + validation { + condition = contains(["dev", "staging", "prod"], var.env) + error_message = "env must be dev, staging, or prod." + } +} + +variable "org" { + type = string + description = "Organisation prefix per NL Azure Naming Standards (ADR-0027)." + default = "nl" + validation { + condition = contains(["nl", "pvc", "tws", "mys"], var.org) + error_message = "org must be one of nl, pvc, tws, mys." + } +} + +variable "projname" { + type = string + description = "Project name used for resource naming." + default = "convolens" + validation { + condition = length(var.projname) >= 3 && length(var.projname) <= 15 + error_message = "projname must be 3-15 characters." + } +} + +variable "location" { + type = string + description = "Primary Azure region for resources." + default = "eastus2" +} + +variable "database_name" { + type = string + description = "Cosmos DB SQL database name. Decoupled from projname so brand renames don't force data migration." + default = "convolens" +} + +variable "enable_cosmos" { + type = bool + description = "Provision Cosmos DB account, database, and containers." + default = true +} + +variable "enable_openai" { + type = bool + description = "Provision Azure OpenAI account. Disabled in dev because Azure AI Foundry is configured separately." + default = false +} + +variable "enable_redis" { + type = bool + description = "Provision Azure Cache for Redis. Off by default in dev to avoid the Basic-tier Redis cost (~$15-30/mo)." + default = false +} + +variable "enable_container_apps" { + type = bool + description = "Provision Container Apps Environment + API Container App." + default = true +} + +variable "enable_static_web_app" { + type = bool + description = "Provision Static Web App for the frontend." + default = true +} + +variable "enable_budget_alerts" { + type = bool + description = "Provision a resource-group-scoped consumption budget. Skipped automatically when admin_email is empty." + default = true +} + +variable "admin_email" { + type = string + description = "Email address that receives budget alerts. Empty disables budget alerts regardless of enable_budget_alerts." + default = "" +} + +variable "monthly_budget_amount" { + type = number + description = "Monthly budget in USD." + default = 100 +} + +variable "container_image_api" { + type = string + description = "Image for the API Container App. Defaults to the Microsoft helloworld placeholder; the API CD pipeline overrides this." + default = "mcr.microsoft.com/azuredocs/containerapps-helloworld:latest" +} + +variable "tags" { + type = map(string) + description = "Additional tags merged with the org/project/env defaults." + default = { + costCenter = "development" + } +}