Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> # sign into the target tenant
az account set --subscription <id>
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
Expand Down
47 changes: 47 additions & 0 deletions azure.yaml
Original file line number Diff line number Diff line change
@@ -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
Binary file modified demo-data/vm-inventory.xlsx
Binary file not shown.
53 changes: 53 additions & 0 deletions docs/azd-up-permissions.md
Original file line number Diff line number Diff line change
@@ -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 '<your-app-id>'
azd env set AZURE_ENTRA_CLIENT_SECRET '<your-app-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.
92 changes: 92 additions & 0 deletions infra/main-resources.bicep
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading