diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 134a65f..c63d7bb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -272,6 +272,10 @@ The production app runs as a Docker container on Azure App Service, fed from Azu - ACR: `crfinopsagent.azurecr.io` (Basic SKU, admin enabled) - Container app: `finops-agent-container` on `ASP-rgfinopsagent-b74f` P0v3 plan +### Customer-facing `azd up` path + +For external users deploying into their own subscription, the repo ships with `azure.yaml` + `infra/` (Bicep) + `infra/scripts/` (PowerShell hooks). A single `azd up` provisions everything: RG, Log Analytics + App Insights, ACR (admin disabled, MI-based pull), Azure OpenAI + model deployment, App Service Plan + Web App with system-assigned MI, role assignments (`AcrPull`, `Cognitive Services User`), and the multi-tenant Entra app registration (via `setup-entra-app.ps1 -OutputJson` invoked from the preprovision hook). The postdeploy hook runs `az acr build` so users do not need a local Docker daemon. See README "Deploy to Azure (`azd up`)" for the full UX. **Note:** the production owner deployment (`crfinopsagent` / `finops-agent-container`) predates this and is unaffected — `azd up` always creates new resources under the chosen `azd env` name. + ## Code Conventions - Use clean, well-structured C# for the .NET backend following Microsoft coding conventions. diff --git a/.gitignore b/.gitignore index 2b708f9..dd77140 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,9 @@ secrets.json ## Squad infrastructure (local only) .squad/ +## azd local environment state (per-developer; contains secrets like AZURE_ENTRA_CLIENT_SECRET) +.azure/ + .copilot/skills/agent-collaboration/SKILL.md .gitignore .copilot/skills/squad-conventions/SKILL.md diff --git a/README.md b/README.md index 3d6dce0..4579e7d 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,42 @@ Vue 3 SPA → .NET 10 minimal API → GitHub Copilot SDK → Azure read APIs (Co ## [See the architecture diagram →](https://azure-finops-agent.com/slides#4) +## Deploy to Azure (`azd up`) + +One command provisions and deploys everything into your own subscription: + +```powershell +az login --tenant # sign into the target tenant +az account set --subscription +azd auth login # sign into azd (same tenant) +azd up # provision + build image + deploy +``` + +What `azd up` does: + +1. Creates a resource group, Log Analytics + Application Insights, Azure Container Registry (Basic, admin disabled), Azure OpenAI account + model deployment, Linux App Service Plan, and the containerised Web App with system-assigned managed identity. +2. Grants the Web App's MI `AcrPull` on the registry and `Cognitive Services User` on Azure OpenAI (BYOK). +3. Creates a multi-tenant Microsoft Entra ID app registration with the 5 incremental-consent permission tiers (ARM, Microsoft Graph, Log Analytics, Azure Storage) — see [setup-entra-app.ps1](src/Dashboard/setup-entra-app.ps1). +4. Builds the Docker image server-side via `az acr build` (no local Docker daemon required) and restarts the Web App. + +Override defaults via `azd env set` before running `azd up`: + +| Variable | Default | Notes | +|---|---|---| +| `AZURE_LOCATION` | _(prompted)_ | Region for the resource group and most resources | +| `AZURE_OPENAI_LOCATION` | `swedencentral` | AOAI region (model availability is region-restricted) | +| `APP_SERVICE_PLAN_SKU` | `B1` | Use `P0V3` for production-grade (~$77/mo vs ~$13/mo) | +| `AZURE_OPENAI_MODEL_NAME` / `_VERSION` | `gpt-5.4` / `2025-08-07` | **Reasoning model required** — the agent sets `ReasoningEffort=xhigh` in code. gpt-4o / gpt-4 will not work. | +| `AZURE_OPENAI_DEPLOYMENT_NAME` | `gpt-5.4` | Surfaced as `AzureOpenAI__DeploymentName` to the app | +| `EXISTING_AOAI_RESOURCE_ID` | _(empty)_ | Full resource ID to reuse an existing AOAI account instead of creating one | +| `AZURE_ENTRA_APP_ID` / `AZURE_ENTRA_CLIENT_SECRET` | _(empty)_ | Reuse an existing Entra app instead of creating one | + +Tear down with `azd down --purge` (purge is required because Cognitive Services soft-deletes by default). + +### Required permissions + +`azd up` needs rights on the Azure subscription **and** the Microsoft Entra tenant. The easiest combination is **Owner** on the subscription plus the ability to **create app registrations** in the tenant. For the least-privilege breakdown (Contributor + User Access Administrator, Application Administrator role, resource providers, quota), see [docs/azd-up-permissions.md](docs/azd-up-permissions.md). + ## Running Locally ### Prerequisites diff --git a/azure.yaml b/azure.yaml new file mode 100644 index 0000000..0b0cef3 --- /dev/null +++ b/azure.yaml @@ -0,0 +1,47 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json +# Azure Developer CLI (azd) configuration for Azure FinOps Agent. +# `azd up` provisions all infrastructure (Bicep under ./infra) and deploys the +# container image to App Service. PowerShell hooks fill the gaps Bicep can't +# cover (Entra app registration, ACR image build, redirect-URI patching). + +name: azure-finops-agent +metadata: + template: azure-finops-agent@1.0.0 + +infra: + provider: bicep + path: infra + module: main + +# The web app is built and pushed by the postdeploy hook (`az acr build`), +# which avoids needing a local Docker daemon. We declare no `services` block so +# `azd deploy` is a no-op — provisioning + the postdeploy hook do all the work. + +hooks: + preprovision: + posix: + shell: pwsh + run: ./infra/scripts/preprovision.ps1 + interactive: true + windows: + shell: pwsh + run: ./infra/scripts/preprovision.ps1 + interactive: true + postprovision: + posix: + shell: pwsh + run: ./infra/scripts/postprovision.ps1 + interactive: true + windows: + shell: pwsh + run: ./infra/scripts/postprovision.ps1 + interactive: true + postdeploy: + posix: + shell: pwsh + run: ./infra/scripts/postdeploy.ps1 + interactive: true + windows: + shell: pwsh + run: ./infra/scripts/postdeploy.ps1 + interactive: true diff --git a/demo-data/vm-inventory.xlsx b/demo-data/vm-inventory.xlsx index d264c75..cfa59b2 100644 Binary files a/demo-data/vm-inventory.xlsx and b/demo-data/vm-inventory.xlsx differ diff --git a/docs/azd-up-permissions.md b/docs/azd-up-permissions.md new file mode 100644 index 0000000..a4e59cd --- /dev/null +++ b/docs/azd-up-permissions.md @@ -0,0 +1,53 @@ +# Permissions required to run `azd up` + +To deploy this repo with `azd up`, the signed-in identity needs permissions on three planes: the Azure subscription, the Microsoft Entra tenant, and (implicitly) the resource providers used by the Bicep templates. + +## 1. Azure subscription + +The Bicep in [infra/main.bicep](../infra/main.bicep) targets **subscription scope** (it creates the resource group) and assigns RBAC roles to the Web App's managed identity, so the deployer needs both resource-management rights and role-assignment rights. + +**Recommended:** + +- **Owner** on the target subscription — satisfies everything below in one role. + +**Minimum (least privilege):** + +- **Contributor** on the subscription — to create the RG and all resources (ACR, App Service Plan + Web App, Azure OpenAI / Cognitive Services account + model deployment, Log Analytics workspace, Application Insights), **plus** +- **User Access Administrator** (or **Role Based Access Control Administrator**) on the subscription — required because [infra/modules/roles.bicep](../infra/modules/roles.bicep) and [infra/modules/roles-aoai.bicep](../infra/modules/roles-aoai.bicep) create role assignments (`AcrPull` on ACR and `Cognitive Services User` on the AOAI account) for the Web App's system-assigned managed identity. + +You also need available **quota** for: + +- Azure OpenAI in `aoaiLocation` (default `swedencentral`, 30K TPM for `gpt-5.4`). +- The chosen App Service Plan SKU in `location` (default `B1`). + +## 2. Microsoft Entra ID (tenant) + +The preprovision hook ([infra/scripts/preprovision.ps1](../infra/scripts/preprovision.ps1)) calls [src/Dashboard/setup-entra-app.ps1](../src/Dashboard/setup-entra-app.ps1) to create a multi-tenant app registration and client secret. That requires one of: + +- The **Application Administrator** or **Cloud Application Administrator** directory role, **or** +- The tenant setting **"Users can register applications" = Yes** (default in many tenants), in which case any user can register apps. + +If your tenant blocks app registration and you don't have those roles, pre-create the app yourself and seed the values before running `azd up`: + +```powershell +azd env set AZURE_ENTRA_APP_ID '' +azd env set AZURE_ENTRA_CLIENT_SECRET '' +``` + +The preprovision hook detects these values and skips Entra creation. + +## 3. Resource providers + +The first `azd up` in a fresh subscription will register the following providers (Contributor is sufficient): + +- `Microsoft.ContainerRegistry` +- `Microsoft.Web` +- `Microsoft.CognitiveServices` +- `Microsoft.OperationalInsights` +- `Microsoft.Insights` +- `Microsoft.ManagedIdentity` +- `Microsoft.Authorization` + +## TL;DR + +Easiest working combination: **Owner on the subscription** + ability to **create app registrations in the tenant**. Everything else (image build via `az acr build` in the postdeploy hook, redirect-URI patching, role assignments) inherits from those. diff --git a/infra/main-resources.bicep b/infra/main-resources.bicep new file mode 100644 index 0000000..a07f690 --- /dev/null +++ b/infra/main-resources.bicep @@ -0,0 +1,92 @@ +// Resource-group-scope orchestrator. All app-level resources live here. +targetScope = 'resourceGroup' + +param location string +param aoaiLocation string +param resourceToken string +param tags object +param appServicePlanSku string +param aoaiModelName string +param aoaiModelVersion string +param aoaiDeploymentName string +param aoaiModelCapacity int +param existingAoaiResourceId string +param entraAppId string +@secure() +param entraClientSecret string +param entraTenantId string + +var containerImageName = 'finops-agent:latest' + +module monitoring 'modules/monitoring.bicep' = { + name: 'monitoring' + params: { + location: location + resourceToken: resourceToken + tags: tags + } +} + +module acr 'modules/acr.bicep' = { + name: 'acr' + params: { + location: location + resourceToken: resourceToken + tags: tags + } +} + +module aoai 'modules/aoai.bicep' = { + name: 'aoai' + params: { + aoaiLocation: aoaiLocation + resourceToken: resourceToken + tags: tags + modelName: aoaiModelName + modelVersion: aoaiModelVersion + deploymentName: aoaiDeploymentName + modelCapacity: aoaiModelCapacity + existingAoaiResourceId: existingAoaiResourceId + } +} + +module appservice 'modules/appservice.bicep' = { + name: 'appservice' + params: { + location: location + resourceToken: resourceToken + tags: tags + appServicePlanSku: appServicePlanSku + acrLoginServer: acr.outputs.loginServer + containerImageName: containerImageName + appInsightsConnectionString: monitoring.outputs.appInsightsConnectionString + aoaiEndpoint: aoai.outputs.endpoint + aoaiDeploymentName: aoai.outputs.deploymentName + entraAppId: entraAppId + entraClientSecret: entraClientSecret + entraTenantId: entraTenantId + } +} + +module roles 'modules/roles.bicep' = { + name: 'roles' + params: { + webAppPrincipalId: appservice.outputs.principalId + acrName: acr.outputs.name + aoaiName: aoai.outputs.accountName + aoaiResourceGroup: aoai.outputs.resourceGroup + aoaiSubscriptionId: aoai.outputs.subscriptionId + } +} + +output acrName string = acr.outputs.name +output acrLoginServer string = acr.outputs.loginServer +output containerImageName string = containerImageName +output webAppName string = appservice.outputs.name +output webAppHostname string = appservice.outputs.hostname +output webAppUrl string = 'https://${appservice.outputs.hostname}' +output webAppPrincipalId string = appservice.outputs.principalId +output aoaiEndpoint string = aoai.outputs.endpoint +output aoaiDeploymentName string = aoai.outputs.deploymentName +output appInsightsConnectionString string = monitoring.outputs.appInsightsConnectionString +output logAnalyticsWorkspaceId string = monitoring.outputs.logAnalyticsWorkspaceId diff --git a/infra/main-resources.json b/infra/main-resources.json new file mode 100644 index 0000000..af8f747 --- /dev/null +++ b/infra/main-resources.json @@ -0,0 +1,761 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "18137422051515054256" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "aoaiLocation": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "appServicePlanSku": { + "type": "string" + }, + "aoaiModelName": { + "type": "string" + }, + "aoaiModelVersion": { + "type": "string" + }, + "aoaiDeploymentName": { + "type": "string" + }, + "aoaiModelCapacity": { + "type": "int" + }, + "existingAoaiResourceId": { + "type": "string" + }, + "entraAppId": { + "type": "string" + }, + "entraClientSecret": { + "type": "securestring" + }, + "entraTenantId": { + "type": "string" + } + }, + "variables": { + "containerImageName": "finops-agent:latest" + }, + "resources": [ + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "monitoring", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "resourceToken": { + "value": "[parameters('resourceToken')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "4468647702531648630" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2025-07-01", + "name": "[format('log-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": 30, + "features": { + "enableLogAccessUsingOnlyResourcePermissions": true + } + } + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[format('appi-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-finops-{0}', parameters('resourceToken')))]", + "IngestionMode": "LogAnalytics", + "publicNetworkAccessForIngestion": "Enabled", + "publicNetworkAccessForQuery": "Enabled" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-finops-{0}', parameters('resourceToken')))]" + ] + } + ], + "outputs": { + "logAnalyticsWorkspaceId": { + "type": "string", + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-finops-{0}', parameters('resourceToken')))]" + }, + "appInsightsConnectionString": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Insights/components', format('appi-finops-{0}', parameters('resourceToken'))), '2020-02-02').ConnectionString]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "acr", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "resourceToken": { + "value": "[parameters('resourceToken')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "13570533503122721445" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2024-11-01-preview", + "name": "[format('crfinops{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Basic" + }, + "properties": { + "adminUserEnabled": false, + "anonymousPullEnabled": false, + "publicNetworkAccess": "Enabled" + } + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[format('crfinops{0}', parameters('resourceToken'))]" + }, + "loginServer": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', format('crfinops{0}', parameters('resourceToken'))), '2024-11-01-preview').loginServer]" + }, + "id": { + "type": "string", + "value": "[resourceId('Microsoft.ContainerRegistry/registries', format('crfinops{0}', parameters('resourceToken')))]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "aoai", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aoaiLocation": { + "value": "[parameters('aoaiLocation')]" + }, + "resourceToken": { + "value": "[parameters('resourceToken')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "modelName": { + "value": "[parameters('aoaiModelName')]" + }, + "modelVersion": { + "value": "[parameters('aoaiModelVersion')]" + }, + "deploymentName": { + "value": "[parameters('aoaiDeploymentName')]" + }, + "modelCapacity": { + "value": "[parameters('aoaiModelCapacity')]" + }, + "existingAoaiResourceId": { + "value": "[parameters('existingAoaiResourceId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "11187525182242144731" + } + }, + "parameters": { + "aoaiLocation": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "modelName": { + "type": "string" + }, + "modelVersion": { + "type": "string" + }, + "deploymentName": { + "type": "string" + }, + "modelCapacity": { + "type": "int" + }, + "existingAoaiResourceId": { + "type": "string" + } + }, + "variables": { + "useExisting": "[not(empty(parameters('existingAoaiResourceId')))]", + "existingSegments": "[split(parameters('existingAoaiResourceId'), '/')]", + "existingSubId": "[if(variables('useExisting'), variables('existingSegments')[2], subscription().subscriptionId)]", + "existingRg": "[if(variables('useExisting'), variables('existingSegments')[4], resourceGroup().name)]", + "existingName": "[if(variables('useExisting'), variables('existingSegments')[8], '')]" + }, + "resources": [ + { + "condition": "[not(variables('useExisting'))]", + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[format('aoai-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('aoaiLocation')]", + "tags": "[parameters('tags')]", + "kind": "OpenAI", + "sku": { + "name": "S0" + }, + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "customSubDomainName": "[format('aoai-finops-{0}', parameters('resourceToken'))]", + "publicNetworkAccess": "Enabled", + "disableLocalAuth": true + } + }, + { + "condition": "[not(variables('useExisting'))]", + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', format('aoai-finops-{0}', parameters('resourceToken')), parameters('deploymentName'))]", + "sku": { + "name": "GlobalStandard", + "capacity": "[parameters('modelCapacity')]" + }, + "properties": { + "model": { + "format": "OpenAI", + "name": "[parameters('modelName')]", + "version": "[parameters('modelVersion')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', format('aoai-finops-{0}', parameters('resourceToken')))]" + ] + } + ], + "outputs": { + "endpoint": { + "type": "string", + "value": "[if(variables('useExisting'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingSubId'), variables('existingRg')), 'Microsoft.CognitiveServices/accounts', variables('existingName')), '2025-04-01-preview').endpoint, reference(resourceId('Microsoft.CognitiveServices/accounts', format('aoai-finops-{0}', parameters('resourceToken'))), '2025-04-01-preview').endpoint)]" + }, + "accountName": { + "type": "string", + "value": "[if(variables('useExisting'), variables('existingName'), format('aoai-finops-{0}', parameters('resourceToken')))]" + }, + "deploymentName": { + "type": "string", + "value": "[parameters('deploymentName')]" + }, + "resourceGroup": { + "type": "string", + "value": "[if(variables('useExisting'), variables('existingRg'), resourceGroup().name)]" + }, + "subscriptionId": { + "type": "string", + "value": "[if(variables('useExisting'), variables('existingSubId'), subscription().subscriptionId)]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "appservice", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "resourceToken": { + "value": "[parameters('resourceToken')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "appServicePlanSku": { + "value": "[parameters('appServicePlanSku')]" + }, + "acrLoginServer": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'acr'), '2025-04-01').outputs.loginServer.value]" + }, + "containerImageName": { + "value": "[variables('containerImageName')]" + }, + "appInsightsConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'monitoring'), '2025-04-01').outputs.appInsightsConnectionString.value]" + }, + "aoaiEndpoint": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.endpoint.value]" + }, + "aoaiDeploymentName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.deploymentName.value]" + }, + "entraAppId": { + "value": "[parameters('entraAppId')]" + }, + "entraClientSecret": { + "value": "[parameters('entraClientSecret')]" + }, + "entraTenantId": { + "value": "[parameters('entraTenantId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "10279812525912434775" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "appServicePlanSku": { + "type": "string" + }, + "acrLoginServer": { + "type": "string" + }, + "containerImageName": { + "type": "string" + }, + "appInsightsConnectionString": { + "type": "securestring" + }, + "aoaiEndpoint": { + "type": "string" + }, + "aoaiDeploymentName": { + "type": "string" + }, + "entraAppId": { + "type": "string" + }, + "entraClientSecret": { + "type": "securestring" + }, + "entraTenantId": { + "type": "string" + } + }, + "variables": { + "planTier": "[if(startsWith(parameters('appServicePlanSku'), 'B'), 'Basic', if(startsWith(parameters('appServicePlanSku'), 'S'), 'Standard', 'PremiumV3'))]" + }, + "resources": [ + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2024-04-01", + "name": "[format('plan-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "linux", + "sku": { + "name": "[parameters('appServicePlanSku')]", + "tier": "[variables('planTier')]" + }, + "properties": { + "reserved": true + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2024-04-01", + "name": "[format('app-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'web'))]", + "kind": "app,linux,container", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('plan-finops-{0}', parameters('resourceToken')))]", + "httpsOnly": true, + "publicNetworkAccess": "Enabled", + "siteConfig": { + "linuxFxVersion": "[format('DOCKER|{0}/{1}', parameters('acrLoginServer'), parameters('containerImageName'))]", + "acrUseManagedIdentityCreds": true, + "alwaysOn": "[and(not(equals(parameters('appServicePlanSku'), 'F1')), not(startsWith(parameters('appServicePlanSku'), 'B')))]", + "http20Enabled": true, + "ftpsState": "Disabled", + "minTlsVersion": "1.2", + "healthCheckPath": "/api/version", + "appSettings": [ + { + "name": "WEBSITES_PORT", + "value": "8080" + }, + { + "name": "WEBSITES_ENABLE_APP_SERVICE_STORAGE", + "value": "false" + }, + { + "name": "DOCKER_REGISTRY_SERVER_URL", + "value": "[format('https://{0}', parameters('acrLoginServer'))]" + }, + { + "name": "AzureOpenAI__Endpoint", + "value": "[parameters('aoaiEndpoint')]" + }, + { + "name": "AzureOpenAI__DeploymentName", + "value": "[parameters('aoaiDeploymentName')]" + }, + { + "name": "Microsoft__ClientId", + "value": "[parameters('entraAppId')]" + }, + { + "name": "Microsoft__ClientSecret", + "value": "[parameters('entraClientSecret')]" + }, + { + "name": "Microsoft__TenantId", + "value": "[parameters('entraTenantId')]" + }, + { + "name": "ApplicationInsights__ConnectionString", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "ASPNETCORE_ENVIRONMENT", + "value": "Production" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', format('plan-finops-{0}', parameters('resourceToken')))]" + ] + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[format('app-finops-{0}', parameters('resourceToken'))]" + }, + "hostname": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', format('app-finops-{0}', parameters('resourceToken'))), '2024-04-01').defaultHostName]" + }, + "principalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', format('app-finops-{0}', parameters('resourceToken'))), '2024-04-01', 'full').identity.principalId]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'acr')]", + "[resourceId('Microsoft.Resources/deployments', 'aoai')]", + "[resourceId('Microsoft.Resources/deployments', 'monitoring')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "roles", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "webAppPrincipalId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.principalId.value]" + }, + "acrName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'acr'), '2025-04-01').outputs.name.value]" + }, + "aoaiName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.accountName.value]" + }, + "aoaiResourceGroup": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.resourceGroup.value]" + }, + "aoaiSubscriptionId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.subscriptionId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "4472192890205802557" + } + }, + "parameters": { + "webAppPrincipalId": { + "type": "string" + }, + "acrName": { + "type": "string" + }, + "aoaiName": { + "type": "string" + }, + "aoaiResourceGroup": { + "type": "string" + }, + "aoaiSubscriptionId": { + "type": "string" + } + }, + "variables": { + "acrPullRoleId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", + "cognitiveServicesUserRoleId": "a97b65f3-24c7-4388-baec-2e87135dc908" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), parameters('webAppPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('webAppPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('acrPullRoleId'))]" + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "aoai-role", + "subscriptionId": "[parameters('aoaiSubscriptionId')]", + "resourceGroup": "[parameters('aoaiResourceGroup')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aoaiName": { + "value": "[parameters('aoaiName')]" + }, + "webAppPrincipalId": { + "value": "[parameters('webAppPrincipalId')]" + }, + "cognitiveServicesUserRoleId": { + "value": "[variables('cognitiveServicesUserRoleId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "16264163075713442013" + } + }, + "parameters": { + "aoaiName": { + "type": "string" + }, + "webAppPrincipalId": { + "type": "string" + }, + "cognitiveServicesUserRoleId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('aoaiName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('aoaiName')), parameters('webAppPrincipalId'), parameters('cognitiveServicesUserRoleId'))]", + "properties": { + "principalId": "[parameters('webAppPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', parameters('cognitiveServicesUserRoleId'))]" + } + } + ] + } + } + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'acr')]", + "[resourceId('Microsoft.Resources/deployments', 'aoai')]", + "[resourceId('Microsoft.Resources/deployments', 'appservice')]" + ] + } + ], + "outputs": { + "acrName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'acr'), '2025-04-01').outputs.name.value]" + }, + "acrLoginServer": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'acr'), '2025-04-01').outputs.loginServer.value]" + }, + "containerImageName": { + "type": "string", + "value": "[variables('containerImageName')]" + }, + "webAppName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.name.value]" + }, + "webAppHostname": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.hostname.value]" + }, + "webAppUrl": { + "type": "string", + "value": "[format('https://{0}', reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.hostname.value)]" + }, + "webAppPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.principalId.value]" + }, + "aoaiEndpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.endpoint.value]" + }, + "aoaiDeploymentName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.deploymentName.value]" + }, + "appInsightsConnectionString": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'monitoring'), '2025-04-01').outputs.appInsightsConnectionString.value]" + }, + "logAnalyticsWorkspaceId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'monitoring'), '2025-04-01').outputs.logAnalyticsWorkspaceId.value]" + } + } +} \ No newline at end of file diff --git a/infra/main.bicep b/infra/main.bicep new file mode 100644 index 0000000..fdc0b6c --- /dev/null +++ b/infra/main.bicep @@ -0,0 +1,102 @@ +// Subscription-scope entry point for `azd up`. +// Creates the resource group and delegates everything else to main-resources.bicep. +targetScope = 'subscription' + +@minLength(1) +@maxLength(64) +@description('Name of the azd environment. Used to derive resource names and tags.') +param environmentName string + +@minLength(1) +@description('Primary Azure region for the resource group and most resources (e.g. swedencentral, eastus2).') +param location string + +@description('Azure region for the Azure OpenAI account. Often differs from `location` because model availability is region-restricted. Default swedencentral has broad model coverage.') +param aoaiLocation string = 'swedencentral' + +@allowed([ 'B1', 'B2', 'B3', 'S1', 'S2', 'S3', 'P0V3', 'P1V3', 'P2V3', 'P3V3' ]) +@description('App Service Plan SKU. B1 (~$13/mo) is the recommended evaluation default; P0V3 matches production.') +param appServicePlanSku string = 'B1' + +@description('Azure OpenAI model name to deploy (must be available in `aoaiLocation`). The agent requires a reasoning model — `ReasoningEffort=xhigh` is set in code (see CopilotSessionFactory.cs). gpt-4o / gpt-4 will not work.') +param aoaiModelName string = 'gpt-5.4' + +@description('Azure OpenAI model version (use the latest GA version for the chosen model).') +param aoaiModelVersion string = '2025-08-07' + +@description('Azure OpenAI deployment name surfaced as `AzureOpenAI__DeploymentName` to the app.') +param aoaiDeploymentName string = 'gpt-5.4' + +@description('Azure OpenAI model deployment capacity (TPM in thousands).') +param aoaiModelCapacity int = 30 + +@description('Optional resource ID of an existing Azure OpenAI account to reuse instead of creating a new one. When set, `aoaiLocation`/`aoaiModelName`/`aoaiModelVersion` are ignored — the deployment must already exist on the existing account.') +param existingAoaiResourceId string = '' + +@description('Entra ID multi-tenant app registration client ID. Created automatically by the preprovision hook if empty.') +param entraAppId string = '' + +@secure() +@description('Entra ID app registration client secret. Created automatically by the preprovision hook if empty.') +param entraClientSecret string = '' + +@description('Entra tenant ID for OAuth — `common` for multi-tenant. Leave default unless restricting to a single tenant.') +param entraTenantId string = 'common' + +var tags = { + 'azd-env-name': environmentName + application: 'azure-finops-agent' +} + +// Globally-unique short token derived from sub + env so multiple users in the +// same subscription/region don't collide on resource names (ACR, Web App). +var resourceToken = toLower(uniqueString(subscription().id, environmentName, location)) + +resource rg 'Microsoft.Resources/resourceGroups@2023-07-01' = { + name: 'rg-${environmentName}' + location: location + tags: tags +} + +module resources 'main-resources.bicep' = { + name: 'finops-resources' + scope: rg + params: { + location: location + aoaiLocation: aoaiLocation + resourceToken: resourceToken + tags: tags + appServicePlanSku: appServicePlanSku + aoaiModelName: aoaiModelName + aoaiModelVersion: aoaiModelVersion + aoaiDeploymentName: aoaiDeploymentName + aoaiModelCapacity: aoaiModelCapacity + existingAoaiResourceId: existingAoaiResourceId + entraAppId: entraAppId + entraClientSecret: entraClientSecret + entraTenantId: entraTenantId + } +} + +// ── Outputs ───────────────────────────────────────────────────────────────── +// Surfaced to `azd env` so hooks (and the user) can consume them. + +output AZURE_LOCATION string = location +output AZURE_RESOURCE_GROUP string = rg.name +output AZURE_TENANT_ID string = subscription().tenantId +output AZURE_SUBSCRIPTION_ID string = subscription().subscriptionId + +output AZURE_CONTAINER_REGISTRY_NAME string = resources.outputs.acrName +output AZURE_CONTAINER_REGISTRY_LOGIN_SERVER string = resources.outputs.acrLoginServer +output AZURE_CONTAINER_REGISTRY_IMAGE string = resources.outputs.containerImageName + +output WEB_APP_NAME string = resources.outputs.webAppName +output WEB_APP_HOSTNAME string = resources.outputs.webAppHostname +output WEB_APP_URL string = resources.outputs.webAppUrl +output WEB_APP_PRINCIPAL_ID string = resources.outputs.webAppPrincipalId + +output AZURE_OPENAI_ENDPOINT string = resources.outputs.aoaiEndpoint +output AZURE_OPENAI_DEPLOYMENT_NAME string = resources.outputs.aoaiDeploymentName + +output APPLICATIONINSIGHTS_CONNECTION_STRING string = resources.outputs.appInsightsConnectionString +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = resources.outputs.logAnalyticsWorkspaceId diff --git a/infra/main.json b/infra/main.json new file mode 100644 index 0000000..e6db492 --- /dev/null +++ b/infra/main.json @@ -0,0 +1,1005 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "11102783782836268633" + } + }, + "parameters": { + "environmentName": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "metadata": { + "description": "Name of the azd environment. Used to derive resource names and tags." + } + }, + "location": { + "type": "string", + "minLength": 1, + "metadata": { + "description": "Primary Azure region for the resource group and most resources (e.g. swedencentral, eastus2)." + } + }, + "aoaiLocation": { + "type": "string", + "defaultValue": "swedencentral", + "metadata": { + "description": "Azure region for the Azure OpenAI account. Often differs from `location` because model availability is region-restricted. Default swedencentral has broad model coverage." + } + }, + "appServicePlanSku": { + "type": "string", + "defaultValue": "B1", + "allowedValues": [ + "B1", + "B2", + "B3", + "S1", + "S2", + "S3", + "P0V3", + "P1V3", + "P2V3", + "P3V3" + ], + "metadata": { + "description": "App Service Plan SKU. B1 (~$13/mo) is the recommended evaluation default; P0V3 matches production." + } + }, + "aoaiModelName": { + "type": "string", + "defaultValue": "gpt-5.4", + "metadata": { + "description": "Azure OpenAI model name to deploy (must be available in `aoaiLocation`). The agent requires a reasoning model — `ReasoningEffort=xhigh` is set in code (see CopilotSessionFactory.cs). gpt-4o / gpt-4 will not work." + } + }, + "aoaiModelVersion": { + "type": "string", + "defaultValue": "2025-08-07", + "metadata": { + "description": "Azure OpenAI model version (use the latest GA version for the chosen model)." + } + }, + "aoaiDeploymentName": { + "type": "string", + "defaultValue": "gpt-5.4", + "metadata": { + "description": "Azure OpenAI deployment name surfaced as `AzureOpenAI__DeploymentName` to the app." + } + }, + "aoaiModelCapacity": { + "type": "int", + "defaultValue": 30, + "metadata": { + "description": "Azure OpenAI model deployment capacity (TPM in thousands)." + } + }, + "existingAoaiResourceId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Optional resource ID of an existing Azure OpenAI account to reuse instead of creating a new one. When set, `aoaiLocation`/`aoaiModelName`/`aoaiModelVersion` are ignored — the deployment must already exist on the existing account." + } + }, + "entraAppId": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Entra ID multi-tenant app registration client ID. Created automatically by the preprovision hook if empty." + } + }, + "entraClientSecret": { + "type": "securestring", + "defaultValue": "", + "metadata": { + "description": "Entra ID app registration client secret. Created automatically by the preprovision hook if empty." + } + }, + "entraTenantId": { + "type": "string", + "defaultValue": "common", + "metadata": { + "description": "Entra tenant ID for OAuth — `common` for multi-tenant. Leave default unless restricting to a single tenant." + } + } + }, + "variables": { + "tags": { + "azd-env-name": "[parameters('environmentName')]", + "application": "azure-finops-agent" + }, + "resourceToken": "[toLower(uniqueString(subscription().id, parameters('environmentName'), parameters('location')))]" + }, + "resources": [ + { + "type": "Microsoft.Resources/resourceGroups", + "apiVersion": "2023-07-01", + "name": "[format('rg-{0}', parameters('environmentName'))]", + "location": "[parameters('location')]", + "tags": "[variables('tags')]" + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "finops-resources", + "resourceGroup": "[format('rg-{0}', parameters('environmentName'))]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "aoaiLocation": { + "value": "[parameters('aoaiLocation')]" + }, + "resourceToken": { + "value": "[variables('resourceToken')]" + }, + "tags": { + "value": "[variables('tags')]" + }, + "appServicePlanSku": { + "value": "[parameters('appServicePlanSku')]" + }, + "aoaiModelName": { + "value": "[parameters('aoaiModelName')]" + }, + "aoaiModelVersion": { + "value": "[parameters('aoaiModelVersion')]" + }, + "aoaiDeploymentName": { + "value": "[parameters('aoaiDeploymentName')]" + }, + "aoaiModelCapacity": { + "value": "[parameters('aoaiModelCapacity')]" + }, + "existingAoaiResourceId": { + "value": "[parameters('existingAoaiResourceId')]" + }, + "entraAppId": { + "value": "[parameters('entraAppId')]" + }, + "entraClientSecret": { + "value": "[parameters('entraClientSecret')]" + }, + "entraTenantId": { + "value": "[parameters('entraTenantId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "18137422051515054256" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "aoaiLocation": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "appServicePlanSku": { + "type": "string" + }, + "aoaiModelName": { + "type": "string" + }, + "aoaiModelVersion": { + "type": "string" + }, + "aoaiDeploymentName": { + "type": "string" + }, + "aoaiModelCapacity": { + "type": "int" + }, + "existingAoaiResourceId": { + "type": "string" + }, + "entraAppId": { + "type": "string" + }, + "entraClientSecret": { + "type": "securestring" + }, + "entraTenantId": { + "type": "string" + } + }, + "variables": { + "containerImageName": "finops-agent:latest" + }, + "resources": [ + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "monitoring", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "resourceToken": { + "value": "[parameters('resourceToken')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "4468647702531648630" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.OperationalInsights/workspaces", + "apiVersion": "2025-07-01", + "name": "[format('log-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "properties": { + "sku": { + "name": "PerGB2018" + }, + "retentionInDays": 30, + "features": { + "enableLogAccessUsingOnlyResourcePermissions": true + } + } + }, + { + "type": "Microsoft.Insights/components", + "apiVersion": "2020-02-02", + "name": "[format('appi-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "web", + "properties": { + "Application_Type": "web", + "WorkspaceResourceId": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-finops-{0}', parameters('resourceToken')))]", + "IngestionMode": "LogAnalytics", + "publicNetworkAccessForIngestion": "Enabled", + "publicNetworkAccessForQuery": "Enabled" + }, + "dependsOn": [ + "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-finops-{0}', parameters('resourceToken')))]" + ] + } + ], + "outputs": { + "logAnalyticsWorkspaceId": { + "type": "string", + "value": "[resourceId('Microsoft.OperationalInsights/workspaces', format('log-finops-{0}', parameters('resourceToken')))]" + }, + "appInsightsConnectionString": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Insights/components', format('appi-finops-{0}', parameters('resourceToken'))), '2020-02-02').ConnectionString]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "acr", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "resourceToken": { + "value": "[parameters('resourceToken')]" + }, + "tags": { + "value": "[parameters('tags')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "13570533503122721445" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + } + }, + "resources": [ + { + "type": "Microsoft.ContainerRegistry/registries", + "apiVersion": "2024-11-01-preview", + "name": "[format('crfinops{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "sku": { + "name": "Basic" + }, + "properties": { + "adminUserEnabled": false, + "anonymousPullEnabled": false, + "publicNetworkAccess": "Enabled" + } + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[format('crfinops{0}', parameters('resourceToken'))]" + }, + "loginServer": { + "type": "string", + "value": "[reference(resourceId('Microsoft.ContainerRegistry/registries', format('crfinops{0}', parameters('resourceToken'))), '2024-11-01-preview').loginServer]" + }, + "id": { + "type": "string", + "value": "[resourceId('Microsoft.ContainerRegistry/registries', format('crfinops{0}', parameters('resourceToken')))]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "aoai", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aoaiLocation": { + "value": "[parameters('aoaiLocation')]" + }, + "resourceToken": { + "value": "[parameters('resourceToken')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "modelName": { + "value": "[parameters('aoaiModelName')]" + }, + "modelVersion": { + "value": "[parameters('aoaiModelVersion')]" + }, + "deploymentName": { + "value": "[parameters('aoaiDeploymentName')]" + }, + "modelCapacity": { + "value": "[parameters('aoaiModelCapacity')]" + }, + "existingAoaiResourceId": { + "value": "[parameters('existingAoaiResourceId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "11187525182242144731" + } + }, + "parameters": { + "aoaiLocation": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "modelName": { + "type": "string" + }, + "modelVersion": { + "type": "string" + }, + "deploymentName": { + "type": "string" + }, + "modelCapacity": { + "type": "int" + }, + "existingAoaiResourceId": { + "type": "string" + } + }, + "variables": { + "useExisting": "[not(empty(parameters('existingAoaiResourceId')))]", + "existingSegments": "[split(parameters('existingAoaiResourceId'), '/')]", + "existingSubId": "[if(variables('useExisting'), variables('existingSegments')[2], subscription().subscriptionId)]", + "existingRg": "[if(variables('useExisting'), variables('existingSegments')[4], resourceGroup().name)]", + "existingName": "[if(variables('useExisting'), variables('existingSegments')[8], '')]" + }, + "resources": [ + { + "condition": "[not(variables('useExisting'))]", + "type": "Microsoft.CognitiveServices/accounts", + "apiVersion": "2025-04-01-preview", + "name": "[format('aoai-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('aoaiLocation')]", + "tags": "[parameters('tags')]", + "kind": "OpenAI", + "sku": { + "name": "S0" + }, + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "customSubDomainName": "[format('aoai-finops-{0}', parameters('resourceToken'))]", + "publicNetworkAccess": "Enabled", + "disableLocalAuth": true + } + }, + { + "condition": "[not(variables('useExisting'))]", + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2025-04-01-preview", + "name": "[format('{0}/{1}', format('aoai-finops-{0}', parameters('resourceToken')), parameters('deploymentName'))]", + "sku": { + "name": "GlobalStandard", + "capacity": "[parameters('modelCapacity')]" + }, + "properties": { + "model": { + "format": "OpenAI", + "name": "[parameters('modelName')]", + "version": "[parameters('modelVersion')]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.CognitiveServices/accounts', format('aoai-finops-{0}', parameters('resourceToken')))]" + ] + } + ], + "outputs": { + "endpoint": { + "type": "string", + "value": "[if(variables('useExisting'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', variables('existingSubId'), variables('existingRg')), 'Microsoft.CognitiveServices/accounts', variables('existingName')), '2025-04-01-preview').endpoint, reference(resourceId('Microsoft.CognitiveServices/accounts', format('aoai-finops-{0}', parameters('resourceToken'))), '2025-04-01-preview').endpoint)]" + }, + "accountName": { + "type": "string", + "value": "[if(variables('useExisting'), variables('existingName'), format('aoai-finops-{0}', parameters('resourceToken')))]" + }, + "deploymentName": { + "type": "string", + "value": "[parameters('deploymentName')]" + }, + "resourceGroup": { + "type": "string", + "value": "[if(variables('useExisting'), variables('existingRg'), resourceGroup().name)]" + }, + "subscriptionId": { + "type": "string", + "value": "[if(variables('useExisting'), variables('existingSubId'), subscription().subscriptionId)]" + } + } + } + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "appservice", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "location": { + "value": "[parameters('location')]" + }, + "resourceToken": { + "value": "[parameters('resourceToken')]" + }, + "tags": { + "value": "[parameters('tags')]" + }, + "appServicePlanSku": { + "value": "[parameters('appServicePlanSku')]" + }, + "acrLoginServer": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'acr'), '2025-04-01').outputs.loginServer.value]" + }, + "containerImageName": { + "value": "[variables('containerImageName')]" + }, + "appInsightsConnectionString": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'monitoring'), '2025-04-01').outputs.appInsightsConnectionString.value]" + }, + "aoaiEndpoint": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.endpoint.value]" + }, + "aoaiDeploymentName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.deploymentName.value]" + }, + "entraAppId": { + "value": "[parameters('entraAppId')]" + }, + "entraClientSecret": { + "value": "[parameters('entraClientSecret')]" + }, + "entraTenantId": { + "value": "[parameters('entraTenantId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "10279812525912434775" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "appServicePlanSku": { + "type": "string" + }, + "acrLoginServer": { + "type": "string" + }, + "containerImageName": { + "type": "string" + }, + "appInsightsConnectionString": { + "type": "securestring" + }, + "aoaiEndpoint": { + "type": "string" + }, + "aoaiDeploymentName": { + "type": "string" + }, + "entraAppId": { + "type": "string" + }, + "entraClientSecret": { + "type": "securestring" + }, + "entraTenantId": { + "type": "string" + } + }, + "variables": { + "planTier": "[if(startsWith(parameters('appServicePlanSku'), 'B'), 'Basic', if(startsWith(parameters('appServicePlanSku'), 'S'), 'Standard', 'PremiumV3'))]" + }, + "resources": [ + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2024-04-01", + "name": "[format('plan-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "linux", + "sku": { + "name": "[parameters('appServicePlanSku')]", + "tier": "[variables('planTier')]" + }, + "properties": { + "reserved": true + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2024-04-01", + "name": "[format('app-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'web'))]", + "kind": "app,linux,container", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('plan-finops-{0}', parameters('resourceToken')))]", + "httpsOnly": true, + "publicNetworkAccess": "Enabled", + "siteConfig": { + "linuxFxVersion": "[format('DOCKER|{0}/{1}', parameters('acrLoginServer'), parameters('containerImageName'))]", + "acrUseManagedIdentityCreds": true, + "alwaysOn": "[and(not(equals(parameters('appServicePlanSku'), 'F1')), not(startsWith(parameters('appServicePlanSku'), 'B')))]", + "http20Enabled": true, + "ftpsState": "Disabled", + "minTlsVersion": "1.2", + "healthCheckPath": "/api/version", + "appSettings": [ + { + "name": "WEBSITES_PORT", + "value": "8080" + }, + { + "name": "WEBSITES_ENABLE_APP_SERVICE_STORAGE", + "value": "false" + }, + { + "name": "DOCKER_REGISTRY_SERVER_URL", + "value": "[format('https://{0}', parameters('acrLoginServer'))]" + }, + { + "name": "AzureOpenAI__Endpoint", + "value": "[parameters('aoaiEndpoint')]" + }, + { + "name": "AzureOpenAI__DeploymentName", + "value": "[parameters('aoaiDeploymentName')]" + }, + { + "name": "Microsoft__ClientId", + "value": "[parameters('entraAppId')]" + }, + { + "name": "Microsoft__ClientSecret", + "value": "[parameters('entraClientSecret')]" + }, + { + "name": "Microsoft__TenantId", + "value": "[parameters('entraTenantId')]" + }, + { + "name": "ApplicationInsights__ConnectionString", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "ASPNETCORE_ENVIRONMENT", + "value": "Production" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', format('plan-finops-{0}', parameters('resourceToken')))]" + ] + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[format('app-finops-{0}', parameters('resourceToken'))]" + }, + "hostname": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', format('app-finops-{0}', parameters('resourceToken'))), '2024-04-01').defaultHostName]" + }, + "principalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', format('app-finops-{0}', parameters('resourceToken'))), '2024-04-01', 'full').identity.principalId]" + } + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'acr')]", + "[resourceId('Microsoft.Resources/deployments', 'aoai')]", + "[resourceId('Microsoft.Resources/deployments', 'monitoring')]" + ] + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "roles", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "webAppPrincipalId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.principalId.value]" + }, + "acrName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'acr'), '2025-04-01').outputs.name.value]" + }, + "aoaiName": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.accountName.value]" + }, + "aoaiResourceGroup": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.resourceGroup.value]" + }, + "aoaiSubscriptionId": { + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.subscriptionId.value]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "4472192890205802557" + } + }, + "parameters": { + "webAppPrincipalId": { + "type": "string" + }, + "acrName": { + "type": "string" + }, + "aoaiName": { + "type": "string" + }, + "aoaiResourceGroup": { + "type": "string" + }, + "aoaiSubscriptionId": { + "type": "string" + } + }, + "variables": { + "acrPullRoleId": "7f951dda-4ed3-4680-a7ca-43fe172d538d", + "cognitiveServicesUserRoleId": "a97b65f3-24c7-4388-baec-2e87135dc908" + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName'))]", + "name": "[guid(resourceId('Microsoft.ContainerRegistry/registries', parameters('acrName')), parameters('webAppPrincipalId'), variables('acrPullRoleId'))]", + "properties": { + "principalId": "[parameters('webAppPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('acrPullRoleId'))]" + } + }, + { + "type": "Microsoft.Resources/deployments", + "apiVersion": "2025-04-01", + "name": "aoai-role", + "subscriptionId": "[parameters('aoaiSubscriptionId')]", + "resourceGroup": "[parameters('aoaiResourceGroup')]", + "properties": { + "expressionEvaluationOptions": { + "scope": "inner" + }, + "mode": "Incremental", + "parameters": { + "aoaiName": { + "value": "[parameters('aoaiName')]" + }, + "webAppPrincipalId": { + "value": "[parameters('webAppPrincipalId')]" + }, + "cognitiveServicesUserRoleId": { + "value": "[variables('cognitiveServicesUserRoleId')]" + } + }, + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "16264163075713442013" + } + }, + "parameters": { + "aoaiName": { + "type": "string" + }, + "webAppPrincipalId": { + "type": "string" + }, + "cognitiveServicesUserRoleId": { + "type": "string" + } + }, + "resources": [ + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "scope": "[resourceId('Microsoft.CognitiveServices/accounts', parameters('aoaiName'))]", + "name": "[guid(resourceId('Microsoft.CognitiveServices/accounts', parameters('aoaiName')), parameters('webAppPrincipalId'), parameters('cognitiveServicesUserRoleId'))]", + "properties": { + "principalId": "[parameters('webAppPrincipalId')]", + "principalType": "ServicePrincipal", + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', parameters('cognitiveServicesUserRoleId'))]" + } + } + ] + } + } + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Resources/deployments', 'acr')]", + "[resourceId('Microsoft.Resources/deployments', 'aoai')]", + "[resourceId('Microsoft.Resources/deployments', 'appservice')]" + ] + } + ], + "outputs": { + "acrName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'acr'), '2025-04-01').outputs.name.value]" + }, + "acrLoginServer": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'acr'), '2025-04-01').outputs.loginServer.value]" + }, + "containerImageName": { + "type": "string", + "value": "[variables('containerImageName')]" + }, + "webAppName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.name.value]" + }, + "webAppHostname": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.hostname.value]" + }, + "webAppUrl": { + "type": "string", + "value": "[format('https://{0}', reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.hostname.value)]" + }, + "webAppPrincipalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'appservice'), '2025-04-01').outputs.principalId.value]" + }, + "aoaiEndpoint": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.endpoint.value]" + }, + "aoaiDeploymentName": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'aoai'), '2025-04-01').outputs.deploymentName.value]" + }, + "appInsightsConnectionString": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'monitoring'), '2025-04-01').outputs.appInsightsConnectionString.value]" + }, + "logAnalyticsWorkspaceId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Resources/deployments', 'monitoring'), '2025-04-01').outputs.logAnalyticsWorkspaceId.value]" + } + } + } + }, + "dependsOn": [ + "[subscriptionResourceId('Microsoft.Resources/resourceGroups', format('rg-{0}', parameters('environmentName')))]" + ] + } + ], + "outputs": { + "AZURE_LOCATION": { + "type": "string", + "value": "[parameters('location')]" + }, + "AZURE_RESOURCE_GROUP": { + "type": "string", + "value": "[format('rg-{0}', parameters('environmentName'))]" + }, + "AZURE_TENANT_ID": { + "type": "string", + "value": "[subscription().tenantId]" + }, + "AZURE_SUBSCRIPTION_ID": { + "type": "string", + "value": "[subscription().subscriptionId]" + }, + "AZURE_CONTAINER_REGISTRY_NAME": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.acrName.value]" + }, + "AZURE_CONTAINER_REGISTRY_LOGIN_SERVER": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.acrLoginServer.value]" + }, + "AZURE_CONTAINER_REGISTRY_IMAGE": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.containerImageName.value]" + }, + "WEB_APP_NAME": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.webAppName.value]" + }, + "WEB_APP_HOSTNAME": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.webAppHostname.value]" + }, + "WEB_APP_URL": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.webAppUrl.value]" + }, + "WEB_APP_PRINCIPAL_ID": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.webAppPrincipalId.value]" + }, + "AZURE_OPENAI_ENDPOINT": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.aoaiEndpoint.value]" + }, + "AZURE_OPENAI_DEPLOYMENT_NAME": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.aoaiDeploymentName.value]" + }, + "APPLICATIONINSIGHTS_CONNECTION_STRING": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.appInsightsConnectionString.value]" + }, + "AZURE_LOG_ANALYTICS_WORKSPACE_ID": { + "type": "string", + "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', subscription().subscriptionId, format('rg-{0}', parameters('environmentName'))), 'Microsoft.Resources/deployments', 'finops-resources'), '2025-04-01').outputs.logAnalyticsWorkspaceId.value]" + } + } +} \ No newline at end of file diff --git a/infra/main.parameters.json b/infra/main.parameters.json new file mode 100644 index 0000000..7a4bf5e --- /dev/null +++ b/infra/main.parameters.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "environmentName": { + "value": "${AZURE_ENV_NAME}" + }, + "location": { + "value": "${AZURE_LOCATION}" + }, + "aoaiLocation": { + "value": "${AZURE_OPENAI_LOCATION=swedencentral}" + }, + "appServicePlanSku": { + "value": "${APP_SERVICE_PLAN_SKU=B1}" + }, + "aoaiModelName": { + "value": "${AZURE_OPENAI_MODEL_NAME=gpt-5.4}" + }, + "aoaiModelVersion": { + "value": "${AZURE_OPENAI_MODEL_VERSION=2025-08-07}" + }, + "aoaiDeploymentName": { + "value": "${AZURE_OPENAI_DEPLOYMENT_NAME=gpt-5.4}" + }, + "aoaiModelCapacity": { + "value": "${AZURE_OPENAI_MODEL_CAPACITY=30}" + }, + "existingAoaiResourceId": { + "value": "${EXISTING_AOAI_RESOURCE_ID=}" + }, + "entraAppId": { + "value": "${AZURE_ENTRA_APP_ID=}" + }, + "entraClientSecret": { + "value": "${AZURE_ENTRA_CLIENT_SECRET=}" + }, + "entraTenantId": { + "value": "${AZURE_ENTRA_TENANT_ID=common}" + } + } +} diff --git a/infra/modules/acr.bicep b/infra/modules/acr.bicep new file mode 100644 index 0000000..b262f47 --- /dev/null +++ b/infra/modules/acr.bicep @@ -0,0 +1,23 @@ +param location string +param resourceToken string +param tags object + +// Basic SKU, admin disabled. The Web App pulls images via its system-assigned +// managed identity (AcrPull role assignment in roles.bicep). +resource registry 'Microsoft.ContainerRegistry/registries@2024-11-01-preview' = { + name: 'crfinops${resourceToken}' + location: location + tags: tags + sku: { + name: 'Basic' + } + properties: { + adminUserEnabled: false + anonymousPullEnabled: false + publicNetworkAccess: 'Enabled' + } +} + +output name string = registry.name +output loginServer string = registry.properties.loginServer +output id string = registry.id diff --git a/infra/modules/aoai.bicep b/infra/modules/aoai.bicep new file mode 100644 index 0000000..be7436b --- /dev/null +++ b/infra/modules/aoai.bicep @@ -0,0 +1,66 @@ +// Azure OpenAI account + model deployment. Conditionally created — when +// `existingAoaiResourceId` is provided, the module skips creation and reads +// the endpoint/name from the existing account so the Web App MI gets the role +// grant on whichever account is targeted. +param aoaiLocation string +param resourceToken string +param tags object +param modelName string +param modelVersion string +param deploymentName string +param modelCapacity int +param existingAoaiResourceId string + +var useExisting = !empty(existingAoaiResourceId) + +// Parse `/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.CognitiveServices/accounts/{name}` +// into segments so we can `existing` reference the account in its real RG/sub. +var existingSegments = split(existingAoaiResourceId, '/') +var existingSubId = useExisting ? existingSegments[2] : subscription().subscriptionId +var existingRg = useExisting ? existingSegments[4] : resourceGroup().name +var existingName = useExisting ? existingSegments[8] : '' + +resource newAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' = if (!useExisting) { + name: 'aoai-finops-${resourceToken}' + location: aoaiLocation + tags: tags + kind: 'OpenAI' + sku: { + name: 'S0' + } + identity: { + type: 'SystemAssigned' + } + properties: { + customSubDomainName: 'aoai-finops-${resourceToken}' + publicNetworkAccess: 'Enabled' + disableLocalAuth: true + } +} + +resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2025-04-01-preview' = if (!useExisting) { + parent: newAccount + name: deploymentName + sku: { + name: 'GlobalStandard' + capacity: modelCapacity + } + properties: { + model: { + format: 'OpenAI' + name: modelName + version: modelVersion + } + } +} + +resource existingAccount 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = if (useExisting) { + name: existingName + scope: resourceGroup(existingSubId, existingRg) +} + +output endpoint string = useExisting ? existingAccount!.properties.endpoint : newAccount!.properties.endpoint +output accountName string = useExisting ? existingName : newAccount!.name +output deploymentName string = deploymentName +output resourceGroup string = useExisting ? existingRg : resourceGroup().name +output subscriptionId string = useExisting ? existingSubId : subscription().subscriptionId diff --git a/infra/modules/appservice.bicep b/infra/modules/appservice.bicep new file mode 100644 index 0000000..aa91d13 --- /dev/null +++ b/infra/modules/appservice.bicep @@ -0,0 +1,76 @@ +param location string +param resourceToken string +param tags object +param appServicePlanSku string +param acrLoginServer string +param containerImageName string +@secure() +param appInsightsConnectionString string +param aoaiEndpoint string +param aoaiDeploymentName string +param entraAppId string +@secure() +param entraClientSecret string +param entraTenantId string + +var planTier = startsWith(appServicePlanSku, 'B') ? 'Basic' : (startsWith(appServicePlanSku, 'S') ? 'Standard' : 'PremiumV3') + +resource plan 'Microsoft.Web/serverfarms@2024-04-01' = { + name: 'plan-finops-${resourceToken}' + location: location + tags: tags + kind: 'linux' + sku: { + name: appServicePlanSku + tier: planTier + } + properties: { + reserved: true // Linux + } +} + +resource webApp 'Microsoft.Web/sites@2024-04-01' = { + name: 'app-finops-${resourceToken}' + location: location + tags: union(tags, { 'azd-service-name': 'web' }) + kind: 'app,linux,container' + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: plan.id + httpsOnly: true + publicNetworkAccess: 'Enabled' + siteConfig: { + linuxFxVersion: 'DOCKER|${acrLoginServer}/${containerImageName}' + acrUseManagedIdentityCreds: true + alwaysOn: appServicePlanSku != 'F1' && !startsWith(appServicePlanSku, 'B') + http20Enabled: true + ftpsState: 'Disabled' + minTlsVersion: '1.2' + healthCheckPath: '/api/version' + appSettings: [ + // Tells App Service which port the container listens on (matches Dockerfile EXPOSE 8080). + { name: 'WEBSITES_PORT', value: '8080' } + { name: 'WEBSITES_ENABLE_APP_SERVICE_STORAGE', value: 'false' } + { name: 'DOCKER_REGISTRY_SERVER_URL', value: 'https://${acrLoginServer}' } + // BYOK Azure OpenAI (Program.cs fail-fast key). + { name: 'AzureOpenAI__Endpoint', value: aoaiEndpoint } + { name: 'AzureOpenAI__DeploymentName', value: aoaiDeploymentName } + // Entra ID OAuth (multi-tenant). Empty values disable OAuth gracefully. + { name: 'Microsoft__ClientId', value: entraAppId } + { name: 'Microsoft__ClientSecret', value: entraClientSecret } + { name: 'Microsoft__TenantId', value: entraTenantId } + // Application Insights — Program.cs reads ApplicationInsights__ConnectionString, + // entrypoint.sh's OTel collector reads APPLICATIONINSIGHTS_CONNECTION_STRING. + { name: 'ApplicationInsights__ConnectionString', value: appInsightsConnectionString } + { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: appInsightsConnectionString } + { name: 'ASPNETCORE_ENVIRONMENT', value: 'Production' } + ] + } + } +} + +output name string = webApp.name +output hostname string = webApp.properties.defaultHostName +output principalId string = webApp.identity.principalId diff --git a/infra/modules/appservice.json b/infra/modules/appservice.json new file mode 100644 index 0000000..5f2eb02 --- /dev/null +++ b/infra/modules/appservice.json @@ -0,0 +1,157 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "metadata": { + "_generator": { + "name": "bicep", + "version": "0.41.2.15936", + "templateHash": "10279812525912434775" + } + }, + "parameters": { + "location": { + "type": "string" + }, + "resourceToken": { + "type": "string" + }, + "tags": { + "type": "object" + }, + "appServicePlanSku": { + "type": "string" + }, + "acrLoginServer": { + "type": "string" + }, + "containerImageName": { + "type": "string" + }, + "appInsightsConnectionString": { + "type": "securestring" + }, + "aoaiEndpoint": { + "type": "string" + }, + "aoaiDeploymentName": { + "type": "string" + }, + "entraAppId": { + "type": "string" + }, + "entraClientSecret": { + "type": "securestring" + }, + "entraTenantId": { + "type": "string" + } + }, + "variables": { + "planTier": "[if(startsWith(parameters('appServicePlanSku'), 'B'), 'Basic', if(startsWith(parameters('appServicePlanSku'), 'S'), 'Standard', 'PremiumV3'))]" + }, + "resources": [ + { + "type": "Microsoft.Web/serverfarms", + "apiVersion": "2024-04-01", + "name": "[format('plan-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[parameters('tags')]", + "kind": "linux", + "sku": { + "name": "[parameters('appServicePlanSku')]", + "tier": "[variables('planTier')]" + }, + "properties": { + "reserved": true + } + }, + { + "type": "Microsoft.Web/sites", + "apiVersion": "2024-04-01", + "name": "[format('app-finops-{0}', parameters('resourceToken'))]", + "location": "[parameters('location')]", + "tags": "[union(parameters('tags'), createObject('azd-service-name', 'web'))]", + "kind": "app,linux,container", + "identity": { + "type": "SystemAssigned" + }, + "properties": { + "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', format('plan-finops-{0}', parameters('resourceToken')))]", + "httpsOnly": true, + "publicNetworkAccess": "Enabled", + "siteConfig": { + "linuxFxVersion": "[format('DOCKER|{0}/{1}', parameters('acrLoginServer'), parameters('containerImageName'))]", + "acrUseManagedIdentityCreds": true, + "alwaysOn": "[and(not(equals(parameters('appServicePlanSku'), 'F1')), not(startsWith(parameters('appServicePlanSku'), 'B')))]", + "http20Enabled": true, + "ftpsState": "Disabled", + "minTlsVersion": "1.2", + "healthCheckPath": "/api/version", + "appSettings": [ + { + "name": "WEBSITES_PORT", + "value": "8080" + }, + { + "name": "WEBSITES_ENABLE_APP_SERVICE_STORAGE", + "value": "false" + }, + { + "name": "DOCKER_REGISTRY_SERVER_URL", + "value": "[format('https://{0}', parameters('acrLoginServer'))]" + }, + { + "name": "AzureOpenAI__Endpoint", + "value": "[parameters('aoaiEndpoint')]" + }, + { + "name": "AzureOpenAI__DeploymentName", + "value": "[parameters('aoaiDeploymentName')]" + }, + { + "name": "Microsoft__ClientId", + "value": "[parameters('entraAppId')]" + }, + { + "name": "Microsoft__ClientSecret", + "value": "[parameters('entraClientSecret')]" + }, + { + "name": "Microsoft__TenantId", + "value": "[parameters('entraTenantId')]" + }, + { + "name": "ApplicationInsights__ConnectionString", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "APPLICATIONINSIGHTS_CONNECTION_STRING", + "value": "[parameters('appInsightsConnectionString')]" + }, + { + "name": "ASPNETCORE_ENVIRONMENT", + "value": "Production" + } + ] + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms', format('plan-finops-{0}', parameters('resourceToken')))]" + ] + } + ], + "outputs": { + "name": { + "type": "string", + "value": "[format('app-finops-{0}', parameters('resourceToken'))]" + }, + "hostname": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', format('app-finops-{0}', parameters('resourceToken'))), '2024-04-01').defaultHostName]" + }, + "principalId": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Web/sites', format('app-finops-{0}', parameters('resourceToken'))), '2024-04-01', 'full').identity.principalId]" + } + } +} \ No newline at end of file diff --git a/infra/modules/monitoring.bicep b/infra/modules/monitoring.bicep new file mode 100644 index 0000000..8703dd5 --- /dev/null +++ b/infra/modules/monitoring.bicep @@ -0,0 +1,35 @@ +param location string +param resourceToken string +param tags object + +resource workspace 'Microsoft.OperationalInsights/workspaces@2025-07-01' = { + name: 'log-finops-${resourceToken}' + location: location + tags: tags + properties: { + sku: { + name: 'PerGB2018' + } + retentionInDays: 30 + features: { + enableLogAccessUsingOnlyResourcePermissions: true + } + } +} + +resource appInsights 'Microsoft.Insights/components@2020-02-02' = { + name: 'appi-finops-${resourceToken}' + location: location + tags: tags + kind: 'web' + properties: { + Application_Type: 'web' + WorkspaceResourceId: workspace.id + IngestionMode: 'LogAnalytics' + publicNetworkAccessForIngestion: 'Enabled' + publicNetworkAccessForQuery: 'Enabled' + } +} + +output logAnalyticsWorkspaceId string = workspace.id +output appInsightsConnectionString string = appInsights.properties.ConnectionString diff --git a/infra/modules/roles-aoai.bicep b/infra/modules/roles-aoai.bicep new file mode 100644 index 0000000..fc61e1c --- /dev/null +++ b/infra/modules/roles-aoai.bicep @@ -0,0 +1,20 @@ +// Nested module so the role assignment is created in the AOAI account's own +// resource group / subscription (which may differ when reusing an existing +// AOAI account via `existingAoaiResourceId`). +param aoaiName string +param webAppPrincipalId string +param cognitiveServicesUserRoleId string + +resource aoai 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: aoaiName +} + +resource aoaiUserAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aoai + name: guid(aoai.id, webAppPrincipalId, cognitiveServicesUserRoleId) + properties: { + principalId: webAppPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesUserRoleId) + } +} diff --git a/infra/modules/roles.bicep b/infra/modules/roles.bicep new file mode 100644 index 0000000..0e20f05 --- /dev/null +++ b/infra/modules/roles.bicep @@ -0,0 +1,42 @@ +// Role assignments for the Web App's system-assigned managed identity: +// - AcrPull on the ACR (so the Web App can pull container images) +// - Cognitive Services User on the Azure OpenAI account (BYOK token via DefaultAzureCredential) +// +// The AOAI assignment is scoped to either a freshly-created account in this RG +// or an existing account in another RG/subscription. + +param webAppPrincipalId string +param acrName string +param aoaiName string +param aoaiResourceGroup string +param aoaiSubscriptionId string + +// Built-in role definition IDs (constant across all Azure subscriptions). +var acrPullRoleId = '7f951dda-4ed3-4680-a7ca-43fe172d538d' +var cognitiveServicesUserRoleId = 'a97b65f3-24c7-4388-baec-2e87135dc908' + +resource acr 'Microsoft.ContainerRegistry/registries@2024-11-01-preview' existing = { + name: acrName +} + +resource acrPullAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: acr + name: guid(acr.id, webAppPrincipalId, acrPullRoleId) + properties: { + principalId: webAppPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', acrPullRoleId) + } +} + +// Cognitive Services User role on AOAI — applied via a nested module because +// the AOAI account may live in a different RG/subscription when reused. +module aoaiRole 'roles-aoai.bicep' = { + name: 'aoai-role' + scope: resourceGroup(aoaiSubscriptionId, aoaiResourceGroup) + params: { + aoaiName: aoaiName + webAppPrincipalId: webAppPrincipalId + cognitiveServicesUserRoleId: cognitiveServicesUserRoleId + } +} diff --git a/infra/scripts/postdeploy.ps1 b/infra/scripts/postdeploy.ps1 new file mode 100644 index 0000000..c7ef44f --- /dev/null +++ b/infra/scripts/postdeploy.ps1 @@ -0,0 +1,61 @@ +# Postdeploy hook (azd) — runs after `azd deploy`. +# +# Responsibilities: +# 1. Build and push the container image to ACR using `az acr build` (no local +# Docker daemon required — ACR runs the build server-side). +# 2. Restart the App Service so it pulls the freshly-tagged image. +# 3. Print the final URL. + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot | Split-Path -Parent +$dashboardDir = Join-Path $repoRoot 'src/Dashboard' + +Write-Host "`n=== azd postdeploy ===" -ForegroundColor Cyan + +$envValues = azd env get-values | ConvertFrom-StringData +$acrName = $envValues['AZURE_CONTAINER_REGISTRY_NAME'] +$image = $envValues['AZURE_CONTAINER_REGISTRY_IMAGE'] +$webApp = $envValues['WEB_APP_NAME'] +$webUrl = $envValues['WEB_APP_URL'] +$rg = $envValues['AZURE_RESOURCE_GROUP'] + +foreach ($v in 'acrName','image','webApp','rg') { + if (-not (Get-Variable -Name $v -ValueOnly).Trim('"')) { + Write-Host " Missing azd env var: $v. Aborting." -ForegroundColor Red + exit 1 + } +} +$acrName = $acrName.Trim('"') +$image = $image.Trim('"') +$webApp = $webApp.Trim('"') +$rg = $rg.Trim('"') + +Write-Host " Building image $image in ACR $acrName (this can take 3-6 min on the first run)..." -ForegroundColor Yellow +Push-Location $dashboardDir +try { + az acr build ` + --registry $acrName ` + --image $image ` + --file Dockerfile ` + --output none ` + . + if ($LASTEXITCODE -ne 0) { + Write-Host " az acr build failed." -ForegroundColor Red + exit 1 + } +} finally { + Pop-Location +} +Write-Host " Image built and pushed." -ForegroundColor Green + +Write-Host " Restarting App Service so the new image is pulled..." -ForegroundColor Yellow +az webapp restart --name $webApp --resource-group $rg --output none +if ($LASTEXITCODE -ne 0) { + Write-Host " Restart failed (exit $LASTEXITCODE). Restart manually if the site is stale." -ForegroundColor Yellow +} + +Write-Host "" +Write-Host " ✅ Deployment complete." -ForegroundColor Green +Write-Host " URL: $($webUrl.Trim('""'))" -ForegroundColor Cyan +Write-Host " Health: $($webUrl.Trim('""'))/api/version" -ForegroundColor DarkGray +Write-Host "=== postdeploy complete ===`n" -ForegroundColor Cyan diff --git a/infra/scripts/postprovision.ps1 b/infra/scripts/postprovision.ps1 new file mode 100644 index 0000000..d73cf92 --- /dev/null +++ b/infra/scripts/postprovision.ps1 @@ -0,0 +1,47 @@ +# Postprovision hook (azd) — runs after Bicep deployment, before `azd deploy`. +# +# Responsibilities: +# 1. Patch the Entra app registration with the now-known App Service hostname +# so OAuth callbacks work (`https:///auth/microsoft/callback`). +# 2. Print a concise summary of what was provisioned. +# +# Safe to re-run: az ad app update is idempotent and we de-dupe before sending. + +$ErrorActionPreference = 'Stop' + +Write-Host "`n=== azd postprovision ===" -ForegroundColor Cyan + +$envValues = azd env get-values | ConvertFrom-StringData +$objectId = $envValues['AZURE_ENTRA_OBJECT_ID'] +$webHost = $envValues['WEB_APP_HOSTNAME'] +$webUrl = $envValues['WEB_APP_URL'] + +if (-not $objectId -or -not $webHost) { + Write-Host " AZURE_ENTRA_OBJECT_ID or WEB_APP_HOSTNAME missing from azd env — skipping redirect-URI patch." -ForegroundColor Yellow +} else { + $objectId = $objectId.Trim('"') + $webHost = $webHost.Trim('"') + + $desired = @( + 'http://localhost:5000/auth/microsoft/callback', + "https://$webHost/auth/microsoft/callback" + ) + + Write-Host " Patching Entra app redirect URIs for hostname: $webHost" -ForegroundColor Yellow + $existingJson = az ad app show --id $objectId --query 'web.redirectUris' -o json 2>$null + $existing = if ($existingJson) { $existingJson | ConvertFrom-Json } else { @() } + $merged = @($existing + $desired | Select-Object -Unique) + + az ad app update --id $objectId --web-redirect-uris @merged --output none + if ($LASTEXITCODE -eq 0) { + Write-Host " Redirect URIs updated:" -ForegroundColor Green + foreach ($u in $merged) { Write-Host " $u" -ForegroundColor Gray } + } else { + Write-Host " Failed to update redirect URIs (exit $LASTEXITCODE). Run manually:" -ForegroundColor Red + Write-Host " az ad app update --id $objectId --web-redirect-uris $($merged -join ' ')" -ForegroundColor Gray + } +} + +Write-Host "`n Web App: $webUrl" -ForegroundColor Cyan +Write-Host " Next: image will be built and pushed by the postdeploy hook." -ForegroundColor Gray +Write-Host "=== postprovision complete ===`n" -ForegroundColor Cyan diff --git a/infra/scripts/preprovision.ps1 b/infra/scripts/preprovision.ps1 new file mode 100644 index 0000000..db9ba93 --- /dev/null +++ b/infra/scripts/preprovision.ps1 @@ -0,0 +1,97 @@ +# Preprovision hook (azd) — runs before `azd provision`. +# +# Responsibilities: +# 1. Verify az CLI is logged into a subscription. +# 2. If AZURE_ENTRA_APP_ID is not already in azd env, create the multi-tenant +# Entra ID app registration via setup-entra-app.ps1 -OutputJson and stash +# appId / clientSecret / tenantId in azd env so Bicep picks them up in this +# same provision run. +# 3. Capture AZURE_PRINCIPAL_ID for downstream role assignments if needed. +# +# Idempotent: if AZURE_ENTRA_APP_ID is already set, skip Entra creation. + +$ErrorActionPreference = 'Stop' +$repoRoot = Split-Path -Parent $PSScriptRoot | Split-Path -Parent + +Write-Host "`n=== azd preprovision ===" -ForegroundColor Cyan + +# ── 1. az CLI login check ── +$account = az account show 2>$null | ConvertFrom-Json +if (-not $account) { + Write-Host " az CLI not logged in. Running 'az login'..." -ForegroundColor Yellow + az login --output none + $account = az account show 2>$null | ConvertFrom-Json +} +Write-Host " Subscription: $($account.name) ($($account.id))" -ForegroundColor Gray +Write-Host " Tenant: $($account.tenantId)" -ForegroundColor Gray + +# Capture deployer principal ID for optional downstream role grants. +$signedInUser = az ad signed-in-user show --query id -o tsv 2>$null +if ($signedInUser) { + azd env set AZURE_PRINCIPAL_ID $signedInUser | Out-Null +} + +# ── 2. Entra app registration ── +function Get-AzdEnvValue { + param([string]$Key) + $val = azd env get-value $Key 2>$null + if ($LASTEXITCODE -ne 0) { return '' } + if ($null -eq $val) { return '' } + return ([string]$val).Trim().Trim('"') +} + +$existingAppId = Get-AzdEnvValue 'AZURE_ENTRA_APP_ID' +$existingSecret = Get-AzdEnvValue 'AZURE_ENTRA_CLIENT_SECRET' +$envName = Get-AzdEnvValue 'AZURE_ENV_NAME' +if ([string]::IsNullOrWhiteSpace($envName)) { $envName = $env:AZURE_ENV_NAME } +if ([string]::IsNullOrWhiteSpace($envName)) { $envName = 'finops-agent' } + +if (-not [string]::IsNullOrWhiteSpace($existingAppId)) { + $appId = $existingAppId + $secret = $existingSecret + + # Validate the secret is also provided — Bicep would otherwise pass an empty + # string into Microsoft__ClientSecret and OAuth would silently fall back to + # anonymous mode at runtime. + if ([string]::IsNullOrWhiteSpace($secret)) { + Write-Host " AZURE_ENTRA_APP_ID is set but AZURE_ENTRA_CLIENT_SECRET is missing." -ForegroundColor Red + Write-Host " Run: azd env set AZURE_ENTRA_CLIENT_SECRET ''" -ForegroundColor Yellow + exit 1 + } + + # Verify the app actually exists in this tenant and capture its objectId so + # postprovision.ps1 can patch the App Service hostname into the redirect URIs. + Write-Host " Reusing existing Entra app: $appId" -ForegroundColor Green + $existingObjectId = az ad app show --id $appId --query id -o tsv 2>$null + if ($LASTEXITCODE -ne 0 -or -not $existingObjectId) { + Write-Host " App registration $appId not found in tenant $($account.tenantId)." -ForegroundColor Red + Write-Host " Either fix AZURE_ENTRA_APP_ID or unset it (azd env set AZURE_ENTRA_APP_ID '') to create a new one." -ForegroundColor Yellow + exit 1 + } + azd env set AZURE_ENTRA_OBJECT_ID $existingObjectId | Out-Null + Write-Host " Object ID: $existingObjectId (cached for postprovision redirect-URI patch)" -ForegroundColor Gray +} else { + Write-Host " Creating Entra ID app registration (multi-tenant, 5 consent tiers)..." -ForegroundColor Yellow + + $entraScript = Join-Path $repoRoot 'src/Dashboard/setup-entra-app.ps1' + $appName = "Azure FinOps Agent ($envName)" + + # Production redirect URI is unknown until Bicep runs. Register localhost now; + # postprovision.ps1 patches the App Service hostname in afterwards. + $jsonOutput = & $entraScript -AppName $appName -OutputJson + if ($LASTEXITCODE -ne 0 -or -not $jsonOutput) { + Write-Host " setup-entra-app.ps1 failed." -ForegroundColor Red + exit 1 + } + + $appInfo = $jsonOutput | ConvertFrom-Json + azd env set AZURE_ENTRA_APP_ID $appInfo.appId | Out-Null + azd env set AZURE_ENTRA_CLIENT_SECRET $appInfo.clientSecret | Out-Null + azd env set AZURE_ENTRA_OBJECT_ID $appInfo.objectId | Out-Null + + Write-Host " Created app: $($appInfo.appId)" -ForegroundColor Green + Write-Host " Secret stored in azd env (AZURE_ENTRA_CLIENT_SECRET)." -ForegroundColor Gray + Write-Host " Service-principal propagation can take 30-60s; Bicep will retry on first failure." -ForegroundColor DarkGray +} + +Write-Host "=== preprovision complete ===`n" -ForegroundColor Cyan diff --git a/src/Dashboard/setup-entra-app.ps1 b/src/Dashboard/setup-entra-app.ps1 index 9d3367f..31fcc60 100644 --- a/src/Dashboard/setup-entra-app.ps1 +++ b/src/Dashboard/setup-entra-app.ps1 @@ -36,26 +36,44 @@ param( [string]$AppName = "Azure FinOps Agent", [string]$ProductionUrl = "", - [int]$SecretExpiryMonths = 12 + [int]$SecretExpiryMonths = 12, + # Extra redirect URIs to register beyond the localhost + ProductionUrl defaults. + # Used by the azd preprovision hook to register additional callbacks (e.g. App Service hostname). + [string[]]$ExtraRedirectUris = @(), + # When set, suppresses all human-readable Write-Host output and prints a single + # JSON object {appId, clientSecret, tenantId, redirectUris} to stdout — intended + # for consumption by the azd preprovision hook. + [switch]$OutputJson ) $ErrorActionPreference = "Stop" -Write-Host "`n=== Azure FinOps Agent — Entra ID App Registration Setup ===" -ForegroundColor Cyan -Write-Host "This script creates a multi-tenant app registration with read-only permissions.`n" -ForegroundColor Gray +# When -OutputJson is set, route all chatty status output to stderr so stdout is +# pure JSON the caller can pipe into ConvertFrom-Json. +function Write-Status { + param([string]$Message, [string]$ForegroundColor = 'Gray') + if ($OutputJson) { + [Console]::Error.WriteLine($Message) + } else { + Write-Host $Message -ForegroundColor $ForegroundColor + } +} + +Write-Status "`n=== Azure FinOps Agent — Entra ID App Registration Setup ===" 'Cyan' +Write-Status "This script creates a multi-tenant app registration with read-only permissions.`n" 'Gray' # ── 1. Verify az CLI is logged in ── -Write-Host "[1/6] Checking Azure CLI login..." -ForegroundColor Yellow +Write-Status "[1/6] Checking Azure CLI login..." 'Yellow' $account = az account show 2>$null | ConvertFrom-Json if (-not $account) { - Write-Host " Not logged in. Run 'az login' first." -ForegroundColor Red + Write-Status " Not logged in. Run 'az login' first." 'Red' exit 1 } -Write-Host " Tenant: $($account.tenantId)" -ForegroundColor Gray -Write-Host " User: $($account.user.name)" -ForegroundColor Gray +Write-Status " Tenant: $($account.tenantId)" 'Gray' +Write-Status " User: $($account.user.name)" 'Gray' # ── 2. Build redirect URIs ── -Write-Host "`n[2/6] Configuring redirect URIs..." -ForegroundColor Yellow +Write-Status "`n[2/6] Configuring redirect URIs..." 'Yellow' $redirectUris = @( "http://localhost:5000/auth/microsoft/callback" ) @@ -69,12 +87,15 @@ if ($ProductionUrl) { $redirectUris += "$($uri.Scheme)://www.$($uri.Host)/auth/microsoft/callback" } } +foreach ($u in $ExtraRedirectUris) { + if ($u -and ($redirectUris -notcontains $u)) { $redirectUris += $u } +} foreach ($u in $redirectUris) { - Write-Host " $u" -ForegroundColor Gray + Write-Status " $u" 'Gray' } # ── 3. Create the app registration ── -Write-Host "`n[3/6] Creating app registration '$AppName'..." -ForegroundColor Yellow +Write-Status "`n[3/6] Creating app registration '$AppName'..." 'Yellow' # Build the redirect URIs JSON for the web platform $redirectUrisJson = ($redirectUris | ForEach-Object { "`"$_`"" }) -join "," @@ -88,7 +109,7 @@ $appJson = az ad app create ` 2>$null if (-not $appJson) { - Write-Host " Failed to create app registration. Check permissions." -ForegroundColor Red + Write-Status " Failed to create app registration. Check permissions." 'Red' exit 1 } @@ -96,11 +117,11 @@ $app = $appJson | ConvertFrom-Json $clientId = $app.appId $objectId = $app.id -Write-Host " App ID (ClientId): $clientId" -ForegroundColor Green -Write-Host " Object ID: $objectId" -ForegroundColor Gray +Write-Status " App ID (ClientId): $clientId" 'Green' +Write-Status " Object ID: $objectId" 'Gray' # ── 4. Add API permissions (all read-only) ── -Write-Host "`n[4/6] Adding API permissions (read-only)..." -ForegroundColor Yellow +Write-Status "`n[4/6] Adding API permissions (read-only)..." 'Yellow' # Known permission GUIDs (Microsoft-published, stable across all tenants) # Azure Service Management @@ -162,17 +183,17 @@ $requiredAccess | Out-File -FilePath $tempFile -Encoding utf8 -NoNewline az ad app update --id $objectId --required-resource-accesses "@$tempFile" --output none 2>$null Remove-Item $tempFile -Force -Write-Host " Azure ARM: user_impersonation (delegated)" -ForegroundColor Gray -Write-Host " Microsoft Graph: User.Read, Organization.Read.All, Reports.Read.All," -ForegroundColor Gray -Write-Host " User.Read.All, Group.Read.All (all delegated, read-only)" -ForegroundColor Gray -Write-Host " Log Analytics: Data.Read (delegated, read-only)" -ForegroundColor Gray -Write-Host " Azure Storage: user_impersonation (delegated, for cost exports)" -ForegroundColor Gray -Write-Host "" -Write-Host " NOTE: All Graph and Log Analytics scopes use incremental consent —" -ForegroundColor DarkYellow -Write-Host " users only see consent prompts when they opt into each tier." -ForegroundColor DarkYellow +Write-Status " Azure ARM: user_impersonation (delegated)" 'Gray' +Write-Status " Microsoft Graph: User.Read, Organization.Read.All, Reports.Read.All," 'Gray' +Write-Status " User.Read.All, Group.Read.All (all delegated, read-only)" 'Gray' +Write-Status " Log Analytics: Data.Read (delegated, read-only)" 'Gray' +Write-Status " Azure Storage: user_impersonation (delegated, for cost exports)" 'Gray' +Write-Status "" +Write-Status " NOTE: All Graph and Log Analytics scopes use incremental consent —" 'DarkYellow' +Write-Status " users only see consent prompts when they opt into each tier." 'DarkYellow' # ── 5. Create client secret ── -Write-Host "`n[5/6] Creating client secret (valid $SecretExpiryMonths months)..." -ForegroundColor Yellow +Write-Status "`n[5/6] Creating client secret (valid $SecretExpiryMonths months)..." 'Yellow' $endDate = (Get-Date).AddMonths($SecretExpiryMonths).ToString("yyyy-MM-ddTHH:mm:ssZ") $secretJson = az ad app credential reset ` @@ -183,14 +204,26 @@ $secretJson = az ad app credential reset ` 2>$null if (-not $secretJson) { - Write-Host " Failed to create client secret." -ForegroundColor Red + Write-Status " Failed to create client secret." 'Red' exit 1 } $secret = ($secretJson | ConvertFrom-Json).password -Write-Host " Secret created (expires: $endDate)" -ForegroundColor Gray +Write-Status " Secret created (expires: $endDate)" 'Gray' # ── 6. Output configuration ── +if ($OutputJson) { + [pscustomobject]@{ + appId = $clientId + objectId = $objectId + clientSecret = $secret + tenantId = $account.tenantId + redirectUris = $redirectUris + secretExpiry = $endDate + } | ConvertTo-Json -Compress + return +} + Write-Host "`n[6/6] Setup complete!" -ForegroundColor Green Write-Host "`n$('=' * 60)" -ForegroundColor Cyan Write-Host " ADD THESE VALUES TO YOUR CONFIGURATION" -ForegroundColor Cyan