diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2557290..8b35db4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -291,6 +291,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 AI Foundry (`AIServices`) account + model deployment, App Service Plan + Web App with system-assigned MI, role assignments (`AcrPull`, `Cognitive Services OpenAI 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. A `postdown` hook deletes the azd-created Entra app on `azd down` (BYO apps via `AZURE_ENTRA_APP_ID` are left untouched) so nothing is orphaned. 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 ae0c5f1..9a30088 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ *.suo *.user *.userosscache +*.lscache *.sln.docstates *.lscache [Dd]ebug/ @@ -30,6 +31,10 @@ build/ ## Playwright MCP .playwright-mcp/ +## Playwright persistent browser profile (holds session cookies / sign-in state — never commit) +.pw-profile/ +## Generated by upload-social-preview.mjs on each run +.pw-social-preview-result.png ## OS files .DS_Store @@ -70,6 +75,14 @@ secrets.json ## Squad infrastructure (local only) .squad/ +## azd local environment state (per-developer; contains secrets like AZURE_ENTRA_CLIENT_SECRET) +.azure/ + +## Compiled Bicep ARM output — azd compiles .bicep on the fly, so these are +## build artifacts that drift from source. Keep *.parameters.json (azd needs it). +infra/**/*.json +!infra/**/*.parameters.json + .copilot/skills/agent-collaboration/SKILL.md .gitignore .copilot/skills/squad-conventions/SKILL.md diff --git a/README.md b/README.md index a05c5f2..61750d4 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,42 @@ flowchart LR ENTRA --> API ``` +## 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 AI Foundry (`AIServices`) account + `gpt-5.4` 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 OpenAI User` on the Foundry account (BYOK via managed identity — no API keys). +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. Restricted to the 29 regions where the full stack (incl. gpt-5.4 Global Standard) is available | +| `AZURE_OPENAI_LOCATION` | `swedencentral` | AOAI region. Restricted to the 29 regions offering gpt-5.4 Global Standard | +| `APP_SERVICE_PLAN_SKU` | `B1` | Use `P0V3` for production-grade (~$77/mo vs ~$13/mo) | +| `AZURE_OPENAI_MODEL_NAME` / `_VERSION` | `gpt-5.4` / `2026-03-05` | **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). A `postdown` hook then deletes the Entra app registration that `azd up` created — only when azd created it; a bring-your-own `AZURE_ENTRA_APP_ID` is left untouched — so no orphaned app or secret is left behind. + +### 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..d3485ef --- /dev/null +++ b/azure.yaml @@ -0,0 +1,58 @@ +# 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 + postdown: + posix: + shell: pwsh + run: ./infra/scripts/postdown.ps1 + interactive: true + continueOnError: true + windows: + shell: pwsh + run: ./infra/scripts/postdown.ps1 + interactive: true + continueOnError: 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..a4a2de9 --- /dev/null +++ b/infra/main-resources.bicep @@ -0,0 +1,93 @@ +// 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 aiProjectName string = aoai.outputs.projectName +output appInsightsConnectionString string = monitoring.outputs.appInsightsConnectionString +output logAnalyticsWorkspaceId string = monitoring.outputs.logAnalyticsWorkspaceId diff --git a/infra/main.bicep b/infra/main.bicep new file mode 100644 index 0000000..841a6ce --- /dev/null +++ b/infra/main.bicep @@ -0,0 +1,164 @@ +// 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 + +@allowed([ + 'australiaeast' + 'brazilsouth' + 'canadacentral' + 'canadaeast' + 'centralus' + 'eastus' + 'eastus2' + 'francecentral' + 'germanywestcentral' + 'italynorth' + 'japaneast' + 'koreacentral' + 'northcentralus' + 'northeurope' + 'norwayeast' + 'polandcentral' + 'southafricanorth' + 'southcentralus' + 'southeastasia' + 'southindia' + 'spaincentral' + 'swedencentral' + 'switzerlandnorth' + 'switzerlandwest' + 'uaenorth' + 'uksouth' + 'westeurope' + 'westus' + 'westus3' +]) +@description('Primary Azure region for the resource group and all non-AOAI resources. Restricted to regions where the full stack (App Service, ACR, Log Analytics/App Insights, and gpt-5.4 Global Standard) is available, so `azd up` succeeds for any customer.') +param location string + +@allowed([ + 'australiaeast' + 'brazilsouth' + 'canadacentral' + 'canadaeast' + 'centralus' + 'eastus' + 'eastus2' + 'francecentral' + 'germanywestcentral' + 'italynorth' + 'japaneast' + 'koreacentral' + 'northcentralus' + 'northeurope' + 'norwayeast' + 'polandcentral' + 'southafricanorth' + 'southcentralus' + 'southeastasia' + 'southindia' + 'spaincentral' + 'swedencentral' + 'switzerlandnorth' + 'switzerlandwest' + 'uaenorth' + 'uksouth' + 'westeurope' + 'westus' + 'westus3' +]) +@description('Azure region for the Azure OpenAI account. Restricted to regions where gpt-5.4 Global Standard (version 2026-03-05) is available — verified against the Foundry model region-availability matrix. May differ from `location`. Default: swedencentral.') +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. Must match the model name: gpt-5.4 = 2026-03-05 (note: 2025-08-07 is the original gpt-5 version, NOT gpt-5.4).') +param aoaiModelVersion string = '2026-03-05' + +@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 (100 = 100K tokens/min). For GlobalStandard this is purely a rate-limit knob billed per-token, so raising it costs nothing. The agent uses xhigh reasoning (token-heavy) — 30 hits 429 rate limits under real use. Default 100. Lower it only if your gpt-5.4 GlobalStandard quota is under 100K; raise it (200-300) for heavy demo traffic.') +param aoaiModelCapacity int = 100 + +@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 AZURE_AI_PROJECT_NAME string = resources.outputs.aiProjectName + +output APPLICATIONINSIGHTS_CONNECTION_STRING string = resources.outputs.appInsightsConnectionString +output AZURE_LOG_ANALYTICS_WORKSPACE_ID string = resources.outputs.logAnalyticsWorkspaceId diff --git a/infra/main.parameters.json b/infra/main.parameters.json new file mode 100644 index 0000000..0c6b820 --- /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=2026-03-05}" + }, + "aoaiDeploymentName": { + "value": "${AZURE_OPENAI_DEPLOYMENT_NAME=gpt-5.4}" + }, + "aoaiModelCapacity": { + "value": "${AZURE_OPENAI_MODEL_CAPACITY=100}" + }, + "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..b1fda33 --- /dev/null +++ b/infra/modules/aoai.bicep @@ -0,0 +1,89 @@ +// Azure AI Foundry (Cognitive Services, kind=AIServices) 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@2026-03-01' = if (!useExisting) { + name: 'aoai-finops-${resourceToken}' + location: aoaiLocation + tags: tags + kind: 'AIServices' + sku: { + name: 'S0' + } + identity: { + type: 'SystemAssigned' + } + properties: { + // Foundry account (enables projects/agents/evals later). Key auth OFF — + // access is managed-identity / Entra token only (no API keys). + allowProjectManagement: true + customSubDomainName: 'aoai-finops-${resourceToken}' + publicNetworkAccess: 'Enabled' + disableLocalAuth: true + } +} + +resource modelDeployment 'Microsoft.CognitiveServices/accounts/deployments@2026-03-01' = if (!useExisting) { + parent: newAccount + name: deploymentName + sku: { + name: 'GlobalStandard' + capacity: modelCapacity + } + properties: { + model: { + format: 'OpenAI' + name: modelName + version: modelVersion + } + } +} + +// Foundry project — a workspace under the account for the AI Foundry portal +// experience (agents, evals, playground). NOT required by the app runtime (the +// app calls the account's /openai/v1/ endpoint + deployment directly), but +// provisioning it completes the Foundry reference architecture and gives the +// deployment a visible project home in ai.azure.com. Only created for a new +// account; an existing reused account keeps its own project layout. +resource project 'Microsoft.CognitiveServices/accounts/projects@2026-03-01' = if (!useExisting) { + parent: newAccount + name: 'proj-finops-${resourceToken}' + location: aoaiLocation + identity: { + type: 'SystemAssigned' + } + properties: { + displayName: 'Azure FinOps Agent' + description: 'Azure FinOps Agent Foundry project' + } +} + +resource existingAccount 'Microsoft.CognitiveServices/accounts@2026-03-01' 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 +output projectName string = useExisting ? '' : 'proj-finops-${resourceToken}' diff --git a/infra/modules/appservice.bicep b/infra/modules/appservice.bicep new file mode 100644 index 0000000..6701fff --- /dev/null +++ b/infra/modules/appservice.bicep @@ -0,0 +1,82 @@ +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 + // Keep the container (and the warm Copilot CLI subprocess) resident so the + // first request after idle doesn't pay an ~80s cold start. Always On is + // supported on Basic and above (all SKUs allowed by main.bicep). + alwaysOn: true + 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' } + // The image is heavy (node + .NET + Python + OTel collector); the first + // cold start can exceed the 230s default. Allow up to 30 min to warm up. + { name: 'WEBSITES_CONTAINER_START_TIME_LIMIT', value: '1800' } + { 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/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..55cbbef --- /dev/null +++ b/infra/modules/roles-aoai.bicep @@ -0,0 +1,20 @@ +// Nested module so the role assignment is created in the Foundry account's own +// resource group / subscription (which may differ when reusing an existing +// account via `existingAoaiResourceId`). +param aoaiName string +param webAppPrincipalId string +param cognitiveServicesOpenAIUserRoleId string + +resource aoai 'Microsoft.CognitiveServices/accounts@2026-03-01' existing = { + name: aoaiName +} + +resource aoaiOpenAIUserAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + scope: aoai + name: guid(aoai.id, webAppPrincipalId, cognitiveServicesOpenAIUserRoleId) + properties: { + principalId: webAppPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', cognitiveServicesOpenAIUserRoleId) + } +} diff --git a/infra/modules/roles.bicep b/infra/modules/roles.bicep new file mode 100644 index 0000000..89d207f --- /dev/null +++ b/infra/modules/roles.bicep @@ -0,0 +1,44 @@ +// 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 OpenAI User on the Foundry (AIServices) account — data-plane +// access to call model deployments via managed-identity token (no API keys) +// +// 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' +// Cognitive Services OpenAI User — data-plane inference; matches the production finops-agent-ai grants. +var cognitiveServicesOpenAIUserRoleId = '5e0bd9bd-7b93-4f28-af87-19fc36ad61bd' + +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 OpenAI User role on the Foundry account — applied via a nested +// module because the 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 + cognitiveServicesOpenAIUserRoleId: cognitiveServicesOpenAIUserRoleId + } +} diff --git a/infra/scripts/postdeploy.ps1 b/infra/scripts/postdeploy.ps1 new file mode 100644 index 0000000..64b2946 --- /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 -o json 2>$null | ConvertFrom-Json -AsHashtable +$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/postdown.ps1 b/infra/scripts/postdown.ps1 new file mode 100644 index 0000000..fb64802 --- /dev/null +++ b/infra/scripts/postdown.ps1 @@ -0,0 +1,58 @@ +# Postdown hook (azd) — runs after `azd down` tears down the infrastructure. +# +# `azd down` only deletes ARM resources (the resource group + its contents, and +# with `--purge` the soft-deleted Cognitive Services / Foundry account). The +# Entra app registration is created by the preprovision hook as a DIRECTORY +# object — not an ARM resource — so azd cannot remove it. Without this hook, +# every `azd up` / `azd down` cycle would leave an orphaned multi-tenant app +# (and its client secret) behind in the tenant. +# +# This deletes that app, but ONLY when azd created it +# (AZURE_ENTRA_APP_CREATED_BY_AZD = 'true'). A user-supplied app passed via +# AZURE_ENTRA_APP_ID is never touched. + +# Best-effort cleanup: never let a non-zero az exit code throw or block `azd down`. +$ErrorActionPreference = 'Continue' +$PSNativeCommandUseErrorActionPreference = $false + +Write-Host "`n=== azd postdown ===" -ForegroundColor Cyan + +function Get-AzdEnvValue { + param([string]$Key) + $val = azd env get-value $Key 2>$null + if ($LASTEXITCODE -ne 0 -or $null -eq $val) { return '' } + return ([string]$val).Trim().Trim('"') +} + +$createdByAzd = Get-AzdEnvValue 'AZURE_ENTRA_APP_CREATED_BY_AZD' +$objectId = Get-AzdEnvValue 'AZURE_ENTRA_OBJECT_ID' +$appId = Get-AzdEnvValue 'AZURE_ENTRA_APP_ID' + +if ($createdByAzd -ne 'true') { + Write-Host " Entra app was user-supplied (or none was created) — leaving it in place." -ForegroundColor Yellow + Write-Host "=== postdown complete ===`n" -ForegroundColor Cyan + exit 0 +} + +if ([string]::IsNullOrWhiteSpace($objectId)) { + Write-Host " AZURE_ENTRA_OBJECT_ID missing from azd env — can't auto-delete the app." -ForegroundColor Yellow + if ($appId) { Write-Host " Delete it manually: az ad app delete --id $appId" -ForegroundColor Gray } + Write-Host "=== postdown complete ===`n" -ForegroundColor Cyan + exit 0 +} + +Write-Host " Deleting the Entra app registration azd created: $appId ($objectId)" -ForegroundColor Yellow +az ad app delete --id $objectId 2>$null +if ($LASTEXITCODE -eq 0) { + Write-Host " Entra app deleted." -ForegroundColor Green + # Clear the cached identifiers so a later `azd up` in this env creates a fresh app. + foreach ($k in 'AZURE_ENTRA_APP_ID', 'AZURE_ENTRA_CLIENT_SECRET', 'AZURE_ENTRA_OBJECT_ID', 'AZURE_ENTRA_APP_CREATED_BY_AZD') { + azd env set $k '' | Out-Null + } +} else { + Write-Host " Could not delete the app (exit $LASTEXITCODE). Delete it manually:" -ForegroundColor Red + Write-Host " az ad app delete --id $objectId" -ForegroundColor Gray +} + +Write-Host "=== postdown complete ===`n" -ForegroundColor Cyan +exit 0 diff --git a/infra/scripts/postprovision.ps1 b/infra/scripts/postprovision.ps1 new file mode 100644 index 0000000..72dedf0 --- /dev/null +++ b/infra/scripts/postprovision.ps1 @@ -0,0 +1,96 @@ +# 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' +# az returns non-zero on benign conditions (e.g. an empty list); handle those via +# $LASTEXITCODE checks instead of letting native command errors throw. +$PSNativeCommandUseErrorActionPreference = $false + +Write-Host "`n=== azd postprovision ===" -ForegroundColor Cyan + +$envValues = azd env get-values -o json 2>$null | ConvertFrom-Json -AsHashtable +$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 + # @() forces an array — ConvertFrom-Json unwraps a single-element list to a + # bare string, which would make `$existing + $desired` do string concatenation. + $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 + } +} + +# ── Federated identity credential (secretless auth) ── +# Let the App Service system-assigned managed identity authenticate the app +# registration via Workload Identity Federation, so NO client secret is needed. +# EntraClientCredentials.cs mints a client_assertion from the MI when no secret +# is configured. +$appObjectId = $envValues['AZURE_ENTRA_OBJECT_ID'] +$tenantId = $envValues['AZURE_TENANT_ID'] +$miPrincipalId = $envValues['WEB_APP_PRINCIPAL_ID'] +if ($appObjectId) { $appObjectId = $appObjectId.Trim('"') } +if (-not $appObjectId -or -not $tenantId -or -not $miPrincipalId) { + Write-Host " Skipping federated credential — AZURE_ENTRA_OBJECT_ID / AZURE_TENANT_ID / WEB_APP_PRINCIPAL_ID missing." -ForegroundColor Yellow +} else { + $tenantId = $tenantId.Trim('"') + $miPrincipalId = $miPrincipalId.Trim('"') + $ficName = 'finops-appservice-mi' + Write-Host " Federating the App Service managed identity to the app (secretless OAuth)..." -ForegroundColor Yellow + + # Idempotent: remove any prior credential of the same name first. + $existingFic = az ad app federated-credential list --id $appObjectId --query "[?name=='$ficName'].id" -o tsv 2>$null + if ($existingFic) { + az ad app federated-credential delete --id $appObjectId --federated-credential-id $existingFic --output none 2>$null + } + + $ficFile = Join-Path ([System.IO.Path]::GetTempPath()) "finops-fic.json" + @{ + name = $ficName + issuer = "https://login.microsoftonline.com/$tenantId/v2.0" + subject = $miPrincipalId + audiences = @('api://AzureADTokenExchange') + description = 'Azure FinOps Agent App Service managed identity (secretless OAuth confidential client)' + } | ConvertTo-Json | Set-Content -Path $ficFile -Encoding utf8 + + az ad app federated-credential create --id $appObjectId --parameters "@$ficFile" --output none 2>$null + $ficExit = $LASTEXITCODE + Remove-Item $ficFile -Force -ErrorAction SilentlyContinue + + if ($ficExit -eq 0) { + Write-Host " Federated credential created (subject = App Service MI $miPrincipalId)." -ForegroundColor Green + } else { + Write-Host " WARNING: federated credential creation failed (exit $ficExit)." -ForegroundColor Red + Write-Host " 'Connect Azure' OAuth will not work until a credential (audience api://AzureADTokenExchange, subject $miPrincipalId) is added to app $appObjectId." -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..0559d57 --- /dev/null +++ b/infra/scripts/preprovision.ps1 @@ -0,0 +1,96 @@ +# 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 + + # 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 + # Mark as user-supplied so postdown won't delete it — but NEVER downgrade a + # prior azd-created marker (re-running `azd up` after a partial failure lands + # here too, with AZURE_ENTRA_APP_ID already stored from the first run). + $priorCreated = azd env get-value AZURE_ENTRA_APP_CREATED_BY_AZD 2>$null + if ($LASTEXITCODE -ne 0) { $priorCreated = '' } + if ("$priorCreated".Trim().Trim('"') -ne 'true') { + azd env set AZURE_ENTRA_APP_CREATED_BY_AZD false | 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. + # -NoSecret: no client secret — the App Service managed identity is federated + # to this app in postprovision (Workload Identity Federation, fully secretless). + $jsonOutput = & $entraScript -AppName $appName -NoSecret -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_OBJECT_ID $appInfo.objectId | Out-Null + # azd created this app — the postdown hook will delete it on `azd down`. + azd env set AZURE_ENTRA_APP_CREATED_BY_AZD true | Out-Null + + Write-Host " Created app: $($appInfo.appId) (secretless — App Service MI federation set up in postprovision)" -ForegroundColor Green +} + +Write-Host "=== preprovision complete ===`n" -ForegroundColor Cyan diff --git a/src/Dashboard/.dockerignore b/src/Dashboard/.dockerignore index 1347cb4..469d0e3 100644 --- a/src/Dashboard/.dockerignore +++ b/src/Dashboard/.dockerignore @@ -1,9 +1,9 @@ bin/ obj/ publish/ -node_modules/ -client/node_modules/ +**/node_modules/ wwwroot/ +frontend/dist/ *.user *.suo .vs/ diff --git a/src/Dashboard/AI/CopilotSessionFactory.cs b/src/Dashboard/AI/CopilotSessionFactory.cs index 628afd1..49e0ff6 100644 --- a/src/Dashboard/AI/CopilotSessionFactory.cs +++ b/src/Dashboard/AI/CopilotSessionFactory.cs @@ -601,9 +601,17 @@ private async Task CreateSessionConfigAsync(long userId, string? OnPermissionRequest = (_, _) => Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), Provider = new ProviderConfig { - Type = "azure", - BaseUrl = _endpoint.TrimEnd('/'), + // Azure AI Foundry exposes an OpenAI-compatible endpoint at /openai/v1/. + // GPT-5 series models AND ReasoningEffort require the Responses API, which is + // only reachable via the "openai" provider type with WireApi="responses". + // The classic "azure" type uses the Chat Completions API (api-version 2024-10-21) + // and does not support reasoning on these models — the request never completes. + // See GitHub Copilot SDK BYOK docs (Azure AI Foundry OpenAI-compatible endpoint): + // https://github.com/github/copilot-sdk/blob/main/docs/auth/byok.md + Type = "openai", + BaseUrl = $"{_endpoint.TrimEnd('/')}/openai/v1/", BearerToken = bearerToken, + WireApi = "responses", }, SystemMessage = new SystemMessageConfig { @@ -629,9 +637,17 @@ private async Task CreateResumeConfigAsync(long userId, str OnPermissionRequest = (_, _) => Task.FromResult(new PermissionRequestResult { Kind = PermissionRequestResultKind.Approved }), Provider = new ProviderConfig { - Type = "azure", - BaseUrl = _endpoint.TrimEnd('/'), + // Azure AI Foundry exposes an OpenAI-compatible endpoint at /openai/v1/. + // GPT-5 series models AND ReasoningEffort require the Responses API, which is + // only reachable via the "openai" provider type with WireApi="responses". + // The classic "azure" type uses the Chat Completions API (api-version 2024-10-21) + // and does not support reasoning on these models — the request never completes. + // See GitHub Copilot SDK BYOK docs (Azure AI Foundry OpenAI-compatible endpoint): + // https://github.com/github/copilot-sdk/blob/main/docs/auth/byok.md + Type = "openai", + BaseUrl = $"{_endpoint.TrimEnd('/')}/openai/v1/", BearerToken = bearerToken, + WireApi = "responses", }, SystemMessage = new SystemMessageConfig { diff --git a/src/Dashboard/AI/Tools/FaqTools.cs b/src/Dashboard/AI/Tools/FaqTools.cs index b05f994..fd4833e 100644 --- a/src/Dashboard/AI/Tools/FaqTools.cs +++ b/src/Dashboard/AI/Tools/FaqTools.cs @@ -28,6 +28,12 @@ public class FaqTools string.Equals(Environment.GetEnvironmentVariable("FAQ_AUTO_APPROVE"), "true", StringComparison.OrdinalIgnoreCase); private static readonly string ApprovalKey = Environment.GetEnvironmentVariable("FAQ_APPROVAL_KEY") ?? ""; private static readonly string IndexNowKey = Environment.GetEnvironmentVariable("INDEXNOW_KEY") ?? "finopsagent2026"; + // Public site host for IndexNow submissions (e.g. "azure-finops-agent.com"). IndexNow requires + // domain ownership plus a key file, so this only applies to a public owner deployment. When + // unset (customer / internal deployments), IndexNow pinging is skipped instead of announcing + // a domain the deployment does not own. + private static readonly string? PublicSiteHost = + Environment.GetEnvironmentVariable("PUBLIC_SITE_HOST"); static FaqTools() { @@ -150,13 +156,15 @@ private static void Load() private static async Task PingIndexNowAsync(string slug) { + // No public domain configured — skip IndexNow (nothing to announce / not owned). + if (string.IsNullOrWhiteSpace(PublicSiteHost)) return; try { using var http = new HttpClient(AzureFinOps.Dashboard.Infrastructure.Ipv4HttpHandler.Create(), disposeHandler: true) { Timeout = TimeSpan.FromSeconds(10) }; var body = JsonSerializer.Serialize(new { - host = "azure-finops-agent.com", - urlList = new[] { $"https://azure-finops-agent.com/faq/{slug}" }, + host = PublicSiteHost, + urlList = new[] { $"https://{PublicSiteHost}/faq/{slug}" }, key = IndexNowKey }); await http.PostAsync("https://api.indexnow.org/indexnow", diff --git a/src/Dashboard/Dockerfile b/src/Dashboard/Dockerfile index a13c29a..c0a42dd 100644 --- a/src/Dashboard/Dockerfile +++ b/src/Dashboard/Dockerfile @@ -6,9 +6,8 @@ COPY frontend/package.json frontend/package-lock.json* ./ # Use `npm install` instead of `npm ci` so that Linux-only platform-optional # deps (e.g. @emnapi/*) that aren't present when the lockfile was last # regenerated on a Windows dev machine still resolve cleanly in CI. -# BuildKit cache mount keeps ~/.npm warm across CI builds. -RUN --mount=type=cache,target=/root/.npm \ - npm install --no-audit --no-fund --prefer-offline +# Plain RUN (no BuildKit cache mount) so azd's `az acr build` classic builder works. +RUN npm install --no-audit --no-fund --prefer-offline COPY frontend/ ./ RUN npx vite build --outDir ../wwwroot @@ -16,36 +15,26 @@ RUN npx vite build --outDir ../wwwroot FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build WORKDIR /src COPY Dashboard.csproj ./ -RUN --mount=type=cache,target=/root/.nuget/packages \ - dotnet restore -r linux-x64 +RUN dotnet restore -r linux-x64 COPY . . COPY --from=frontend /app/wwwroot ./wwwroot/ -# Note: we deliberately do NOT pass --no-restore here. BuildKit cache mounts -# only exist for the duration of a single RUN, so the restore step above -# can't share its /root/.nuget/packages with this step. Re-mounting the -# cache makes the second restore essentially free (everything cache-hits). -RUN --mount=type=cache,target=/root/.nuget/packages \ - dotnet publish -c Release -r linux-x64 --self-contained false -o /app/publish +RUN dotnet publish -c Release -r linux-x64 --self-contained false -o /app/publish # ── Stage 3: Final runtime image ── FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final -# Install Python 3 + CLI tools. apt cache mounted so re-installs are cheap; we -# disable the apt auto-clean docker hook so the cache survives the RUN. -RUN rm -f /etc/apt/apt.conf.d/docker-clean -RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - apt-get update -qq && \ +# Install Python 3 + CLI tools (plain RUN — classic-builder compatible, no BuildKit). +RUN apt-get update -qq && \ apt-get install -y --no-install-recommends \ - python3 python3-pip \ - jq sqlite3 && \ - ln -sf /usr/bin/python3 /usr/bin/python + python3 python3-pip \ + jq sqlite3 && \ + ln -sf /usr/bin/python3 /usr/bin/python && \ + rm -rf /var/lib/apt/lists/* -# Pip packages — cached separately so changing apt vs pip doesn't bust both. -RUN --mount=type=cache,target=/root/.cache/pip \ - pip3 install --break-system-packages \ - requests pandas numpy openpyxl tabulate python-dateutil \ - "pdfminer.six>=20240706" "pyarrow>=17.0" +# Pip packages for the file-analysis tool (plain RUN — classic-builder compatible). +RUN pip3 install --break-system-packages \ + requests pandas numpy openpyxl tabulate python-dateutil \ + "pdfminer.six>=20240706" "pyarrow>=17.0" # OpenTelemetry Collector (contrib build — needed for the azuremonitor exporter). # Bridges OTLP from the Copilot CLI subprocess into Azure Application Insights. diff --git a/src/Dashboard/Endpoints/SeoEndpoints.cs b/src/Dashboard/Endpoints/SeoEndpoints.cs index 704599a..a679961 100644 --- a/src/Dashboard/Endpoints/SeoEndpoints.cs +++ b/src/Dashboard/Endpoints/SeoEndpoints.cs @@ -55,7 +55,7 @@ public static class SeoEndpoints public static void MapSeoEndpoints(this IEndpointRouteBuilder app) { - app.MapGet("/faq/{slug}", (string slug) => + app.MapGet("/faq/{slug}", (string slug, HttpContext ctx) => { string title, question, answer, prompt, date; if (StaticFaqs.TryGetValue(slug, out var page)) @@ -71,19 +71,24 @@ public static void MapSeoEndpoints(this IEndpointRouteBuilder app) return Results.NotFound("FAQ page not found") as IResult; } - return Results.Content(RenderFaqHtml(slug, title, question, answer, prompt, date), "text/html; charset=utf-8"); + return Results.Content(RenderFaqHtml(BaseUrl(ctx), slug, title, question, answer, prompt, date), "text/html; charset=utf-8"); }); - app.MapGet("/faq", () => Results.Content(RenderFaqIndex(), "text/html")); + app.MapGet("/faq", (HttpContext ctx) => Results.Content(RenderFaqIndex(BaseUrl(ctx)), "text/html")); - app.MapGet("/sitemap.xml", () => Results.Content(RenderSitemap(), "application/xml")); + app.MapGet("/sitemap.xml", (HttpContext ctx) => Results.Content(RenderSitemap(BaseUrl(ctx)), "application/xml")); } - private static string RenderFaqHtml(string slug, string title, string question, string answer, string prompt, string date) + // Public base URL for canonical links, sitemap, and structured data — derived from the + // incoming request so the deployed hostname is used automatically. Works on any azd + // deployment host, a custom domain, or localhost, with no hardcoded domain. + private static string BaseUrl(HttpContext ctx) => $"{ctx.Request.Scheme}://{ctx.Request.Host}"; + + private static string RenderFaqHtml(string baseUrl, string slug, string title, string question, string answer, string prompt, string date) { Func e = System.Net.WebUtility.HtmlEncode!; var desc = answer.Length > 155 ? answer[..155] + "..." : answer; - var faqUrl = $"https://azure-finops-agent.com/faq/{slug}"; + var faqUrl = $"{baseUrl}/faq/{slug}"; var isoDate = date + "T00:00:00+00:00"; var jsonLd = JsonSerializer.Serialize(new Dictionary { @@ -101,7 +106,7 @@ private static string RenderFaqHtml(string slug, string title, string question, { ["@type"] = "Organization", ["name"] = "Azure FinOps Agent", - ["url"] = "https://azure-finops-agent.com" + ["url"] = baseUrl }, ["acceptedAnswer"] = new Dictionary { @@ -115,7 +120,7 @@ private static string RenderFaqHtml(string slug, string title, string question, { ["@type"] = "Organization", ["name"] = "Azure FinOps Agent", - ["url"] = "https://azure-finops-agent.com" + ["url"] = baseUrl } } } @@ -142,7 +147,7 @@ private static string RenderFaqHtml(string slug, string title, string question, + ""; } - private static string RenderFaqIndex() + private static string RenderFaqIndex(string baseUrl) { Func e = System.Net.WebUtility.HtmlEncode!; var listItems = string.Join("", StaticFaqs.Select(kv => @@ -155,7 +160,7 @@ private static string RenderFaqIndex() + "Azure FinOps FAQ — Cloud Cost Optimization Questions & Answers" + "" + "" - + "" + + "" + "" + "" + "

Azure FinOps FAQ

" @@ -166,17 +171,17 @@ private static string RenderFaqIndex() + ""; } - private static string RenderSitemap() + private static string RenderSitemap(string baseUrl) { var today = DateTime.UtcNow.ToString("yyyy-MM-dd"); - var urls = "https://azure-finops-agent.com/" + today + "weekly1.0" - + "https://azure-finops-agent.com/faq" + today + "weekly0.9"; + var urls = "" + baseUrl + "/" + today + "weekly1.0" + + "" + baseUrl + "/faq" + today + "weekly0.9"; foreach (var kv in StaticFaqs) - urls += "https://azure-finops-agent.com/faq/" + kv.Key + "" + today + "monthly0.8"; + urls += "" + baseUrl + "/faq/" + kv.Key + "" + today + "monthly0.8"; foreach (var kv in FaqTools.GetAll()) - urls += "https://azure-finops-agent.com/faq/" + kv.Key + "" + kv.Value.CreatedUtc + "monthly0.7"; + urls += "" + baseUrl + "/faq/" + kv.Key + "" + kv.Value.CreatedUtc + "monthly0.7"; return "" + urls + ""; } diff --git a/src/Dashboard/Program.cs b/src/Dashboard/Program.cs index a698dd7..16848d6 100644 --- a/src/Dashboard/Program.cs +++ b/src/Dashboard/Program.cs @@ -184,7 +184,7 @@ "worker-src 'self' blob:; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + - "connect-src 'self' blob: data: https://cdn.jsdelivr.net https://js.monitor.azure.com https://canadacentral-1.in.applicationinsights.azure.com https://canadacentral.livediagnostics.monitor.azure.com; " + + "connect-src 'self' blob: data: https://cdn.jsdelivr.net https://js.monitor.azure.com https://*.in.applicationinsights.azure.com https://*.livediagnostics.monitor.azure.com; " + "font-src 'self'; " + "frame-ancestors 'none'"; } diff --git a/src/Dashboard/setup-entra-app.ps1 b/src/Dashboard/setup-entra-app.ps1 index 9d3367f..f0ee40b 100644 --- a/src/Dashboard/setup-entra-app.ps1 +++ b/src/Dashboard/setup-entra-app.ps1 @@ -36,26 +36,48 @@ 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, no client secret is created — the app authenticates via a federated + # identity credential (App Service managed identity), configured by the azd + # postprovision hook. Fully secretless (Workload Identity Federation). + [switch]$NoSecret, + # 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 +91,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 +113,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 +121,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,35 +187,54 @@ $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 - -# ── 5. Create client secret ── -Write-Host "`n[5/6] Creating client secret (valid $SecretExpiryMonths months)..." -ForegroundColor Yellow - -$endDate = (Get-Date).AddMonths($SecretExpiryMonths).ToString("yyyy-MM-ddTHH:mm:ssZ") -$secretJson = az ad app credential reset ` - --id $objectId ` - --display-name "FinOps Agent Secret" ` - --end-date $endDate ` - --query "{password: password}" ` - 2>$null - -if (-not $secretJson) { - Write-Host " Failed to create client secret." -ForegroundColor Red - exit 1 +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 (skipped for federated managed-identity mode) ── +if ($NoSecret) { + Write-Status "`n[5/6] Skipping client secret — federated managed identity (no secret)." 'Yellow' + $secret = '' + $endDate = '' } +else { + 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 ` + --id $objectId ` + --display-name "FinOps Agent Secret" ` + --end-date $endDate ` + --query "{password: password}" ` + 2>$null + + if (-not $secretJson) { + Write-Status " Failed to create client secret." 'Red' + exit 1 + } -$secret = ($secretJson | ConvertFrom-Json).password -Write-Host " Secret created (expires: $endDate)" -ForegroundColor Gray + $secret = ($secretJson | ConvertFrom-Json).password + 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 diff --git a/upload-social-preview.mjs b/upload-social-preview.mjs new file mode 100644 index 0000000..af3e2fc --- /dev/null +++ b/upload-social-preview.mjs @@ -0,0 +1,64 @@ +// Upload social preview image to GitHub repo settings via headed Playwright. +// Run: node upload-social-preview.mjs +// Persists sign-in to .pw-profile/ so subsequent runs skip the login step. + +import { chromium } from 'playwright'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { existsSync } from 'node:fs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROFILE_DIR = path.join(__dirname, '.pw-profile'); +const REPO_SETTINGS = 'https://github.com/Azure-Samples/azure-finops-agent/settings'; +const IMAGE = path.resolve(__dirname, 'src/Dashboard/frontend/public/og-image.png'); + +if (!existsSync(IMAGE)) { + console.error('Image not found:', IMAGE); + process.exit(1); +} + +const ctx = await chromium.launchPersistentContext(PROFILE_DIR, { + headless: false, + viewport: { width: 1400, height: 900 }, +}); +const page = ctx.pages()[0] ?? await ctx.newPage(); + +console.log('Opening', REPO_SETTINGS); +await page.goto(REPO_SETTINGS, { waitUntil: 'domcontentloaded' }); + +// If redirected to /login, wait for the user to sign in (up to 3 min). +if (page.url().includes('/login')) { + console.log('\n=== Sign in to GitHub in the open browser window. ==='); + console.log('Waiting up to 3 minutes for you to land on the settings page...\n'); + await page.waitForURL(/\/Azure-Samples\/azure-finops-agent\/settings/, { timeout: 180_000 }); + console.log('Detected settings page.'); +} + +// Scroll to the Social preview section (bottom of /settings). +console.log('Locating Social preview section...'); +const heading = page.getByRole('heading', { name: /social preview/i }); +await heading.waitFor({ state: 'visible', timeout: 30_000 }); +await heading.scrollIntoViewIfNeeded(); + +// Click "Edit" (or "Upload an image..." if no image is set yet). +const editBtn = page.getByRole('button', { name: /^edit$/i }).first(); +const uploadBtn = page.getByRole('button', { name: /upload an image/i }).first(); + +if (await editBtn.isVisible().catch(() => false)) { + console.log('Clicking Edit...'); + await editBtn.click(); +} + +// The file picker is wired to a hidden . Set it directly. +console.log('Uploading', IMAGE); +const fileInput = page.locator('input[type="file"]').first(); +await fileInput.setInputFiles(IMAGE); + +// GitHub auto-saves the upload; give it a moment, then re-read. +console.log('Waiting for save...'); +await page.waitForTimeout(4000); + +await page.screenshot({ path: path.join(__dirname, '.pw-social-preview-result.png'), fullPage: false }); +console.log('Done. Screenshot saved to .pw-social-preview-result.png'); + +await ctx.close();