This repository contains the initial platform scaffold for a native Azure capacity planning solution.
-
Huge shout out to Zach Luz for building out many of the API calls this solution utilizes in his repo: https://github.com/ZacharyLuz/Get-AzVMAvailability
-
Also to Sachin Phadke for the PaaS repo: https://github.com/naspinall-MS/az-quota-helper
Current version: v1.0.5
Check out the feature branch for a "LITE" non-Azure workstation deployment. https://github.com/wjpigott/Capacity-Planning-Dashboard/tree/feature/lite-deployment-profile/Lite-Local-Deployment
This version fixes Report Matrix filtering for West US 3 and keeps Family Base filters switchable after choosing a D/E/N/H/etc. SKU base.
Key fixes in v1.0.5:
- Added West US 3 (
westus3) to the East/West and US Major report presets so it appears in the Report Matrix without switching to Global. - Kept the Family Base dropdown populated from the broader report scope so selecting
Ddoes not trap the filter and prevent switching toEor another base. - Added a regression test that locks West US 3 into the US reporting presets.
Previous release: v1.0.4 — build-time React compilation and top-bar release metadata.
Release history is tracked in docs/RELEASE-NOTES.md. Git tags should use the same version string as the release, for example v1.0.5, so others can retrieve the exact code behind a deployed version.
Recommended starting point for first-time or customer deployments: use the guided deployment wizard. It walks the operator through the setup conversation before anything is deployed, including provider, subscription, naming, authentication, Entra group strategy, existing shared services, RBAC scope, package publishing, and database bootstrap.
Run it from PowerShell 7 (pwsh) at the repository root:
pwsh
./scripts/Start-CapacityDeployment.ps1This project is licensed under the terms of the MIT License.
- Web UI with tabs, filters, action buttons, and a data grid
- Backend API foundation with capacity endpoints
- SQL schema for snapshots and latest-capacity view
- Azure infrastructure Bicep templates
- Deployment and sample data scripts
- Non-technical Word-friendly deployment guide in
docs/Capacity-Dashboard-Deployment-Guide.rtf - Current-state architecture diagram source and rendered image in
docs/
- Primary editable source (Draw.io): docs/current-architecture.drawio
- Current-state Mermaid source: docs/current-architecture.mmd
- Rendered PNG: docs/current-architecture.png
The current-state diagram reflects what is deployed now: App Service hosting the static UI + Express API, Azure SQL with Entra-only auth, managed identity database access, Key Vault RBAC integration, Function App worker private ingress through Private Link, and App Insights/Log Analytics.
Version v1.0.3 validates the next security posture in isolated Bicep/Terraform app pairs: Web App Easy Auth, Function App Easy Auth, browser login through App Service Authentication, Entra-authenticated dashboard-to-worker calls, temporary Function public access only during worker zip publish, database bootstrap through the deployed Web App, and worker storage private endpoint support. Bicep and Terraform now expose the Easy Auth settings; production rollout still needs the final customer automation-caller decision before INGEST_API_KEY can become optional.
The next execution split is now scaffolded in-repo: a dedicated Azure Functions PowerShell 7 worker host under functions/CapacityWorker/ for live placement and future quota move/apply orchestration.
Use Draw.io for edits when readability/layout precision matters; keep the Mermaid file for quick text-based diffs and automation-friendly rendering.
Status legend:
[x]Complete[~]In progress / partial[ ]Planned
| Track | Current Status | Notes |
|---|---|---|
| Platform and infrastructure | [x] |
App Service, SQL, Key Vault, App Insights, Log Analytics deployed via Bicep |
| Worker execution host | [~] |
Azure Functions PowerShell 7 worker runs on a dedicated App Service plan with managed-identity host storage; live placement worker still needs module restore validation |
| Security and identity | [~] |
Entra admin + AAD-only SQL auth, managed identity runtime access, no raw subscription IDs stored in snapshots; dashboard auth flow is enabled; Easy Auth hardening is validated for Bicep and Terraform in v1.0.3 |
| Live ingestion pipeline | [x] |
Internal ingestion endpoint + scheduler; family filtering is optional (omit INGEST_QUOTA_FAMILY_FILTERS to ingest all families) + SQL snapshot writes |
| API and analytics | [~] |
Capacity API, subscription catalog, family summary, masked subscription summary, and trend APIs complete; quota discovery, plan, simulation, and apply APIs are live |
| UX and dashboard | [~] |
Capacity grid, filters (region, resource type, SKU family search, availability, subscription), sidebar report navigation, analytics tables, and chart views complete; export/workflow pages still pending |
| Quota movement orchestration | [~] |
Discover, capture, plan, simulate, and apply flows are live; approval workflow and request tracking still pending |
| Operations and release | [~] |
Deployment scripts and migration scripts complete; CI/CD pipeline and runbooks still pending |
- Azure resource group and core resources provisioned
- Bicep-based environment deployment script
- SQL schema for snapshots and latest view
- Draw.io + Mermaid architecture artifacts in
docs/ - Azure Functions worker scaffold and Bicep resources for PowerShell 7 execution
- SQL configured with Entra admin and AAD-only auth
- App Service system-assigned managed identity enabled
- [~] App identity database roles are granted post-deploy through the database bootstrap flow or
scripts/initialize-database.ps1when the SQL team owns the server lifecycle - Internal ingestion endpoints protected by
INGEST_API_KEY - Subscription identities masked (
subscriptionKey) in stored analytics rows - Entra sign-in, admin group gating, and report viewer group gating enabled via dashboard auth flow,
ADMIN_GROUP_ID, andREPORT_VIEWER_GROUP_IDS - [~] Easy Auth hardening validated on isolated Bicep/Terraform Web/Function apps: anonymous Web API calls return
401,x-ingest-keyalone returns401, bearer-authenticated internal diagnostics return200, and worker calls run through Function App Easy Auth
Dashboard report access is controlled separately from administrator access. Users must either be in the admin group configured by ADMIN_GROUP_ID or in one of the report viewer groups configured by REPORT_VIEWER_GROUP_IDS. The current viewer group is named CapacityReportViewers; add users to that Entra group before asking them to sign in to the dashboard.
The Entra app registration used by ENTRA_CLIENT_ID must emit group Object IDs in the ID token. In Microsoft Entra admin center, open the app registration, go to Token configuration, add a Groups claim, select Security groups, expand ID, and keep Group ID selected. Without that token claim, users can sign in but the dashboard cannot see CapacityAdmin or CapacityReportViewers membership, so /api/auth/me returns canAccessAdmin: false and isReportViewer: false even when the Entra group membership is correct.
If a user is not signed in, or signs in without membership in CapacityReportViewers or another configured viewer group, the React app shows the Access Restricted screen with the message You do not have access or Report access is not enabled for your account. That screen is expected behavior when the viewer group claim is missing from the user's token.
Troubleshooting Report access is not enabled for your account:
- Check the deployed app's auth diagnostics at
https://<web-app-name>.azurewebsites.net/api/auth/me. - If
adminGroupConfiguredandreportViewerGroupConfiguredaretrue, butgroupClaimPresentisfalseandgroupCountis0, the app settings are present but the signed-in token does not contain Entra group IDs. - Open the Microsoft Entra app registration used by
ENTRA_CLIENT_ID. - Go to Token configuration.
- Add a Groups claim.
- Select Security groups, expand ID, and keep Group ID selected.
- Save the app registration change, then restart the App Service so new sessions use the current configuration.
- Have the affected user visit
/auth/logout, close the browser or use an InPrivate window, sign in again, and recheck/api/auth/me. A working token should showgroupClaimPresent: trueandisReportViewer: truefor members ofCapacityReportViewers.
SESSION_STORE_SQL_ENABLED must always be true for production deployments and any environment with authentication enabled. This setting is hardcoded to true in both Bicep and Terraform infrastructure templates by default.
When SESSION_STORE_SQL_ENABLED=true, Express sessions are persisted in Azure SQL. This ensures:
- OAuth state and session data survive across app instance restarts and scale-out scenarios
- Multi-instance deployments do not lose authentication state during traffic routing
- Sign-in flows complete successfully without getting stuck in redirect loops
Without SQL-backed sessions (memory-only storage), authentication state becomes ephemeral and instance-specific, causing sign-in loops in scaled or restarted environments. The infrastructure templates apply this setting automatically, but manual deployments or non-standard configurations should verify SESSION_STORE_SQL_ENABLED=true is set on all App Service instances hosting the dashboard.
See infra/README.md for additional session and VNet configuration details.
- Managed identity token flow for ARM ingestion
- Region preset ingestion (
USMajor) - Family filter ingestion — optional; set
INGEST_QUOTA_FAMILY_FILTERSto a comma-separated list to restrict, or omit entirely to ingest all VM families - Ingestion scheduler (DB-backed admin settings with environment fallback)
- Move recurring scheduler execution to Function App TimerTrigger jobs (ingestion + live placement)
- [~] Harden Function App worker ingress with App Service Authentication / Microsoft Entra auth: app code, Bicep/Terraform knobs, and isolated smoke tests are complete in
v1.0.3; production allow-lists and promotion runbooks remain - Retry/backoff and dead-letter behavior for ingestion failures
-
GET /api/capacity -
GET /api/capacity/paged(server-side pagination for primary grid) -
GET /api/subscriptions(subscription search/paging source for multi-select UX) -
GET /api/capacity/families(quota-style family summary) -
GET /api/capacity/subscriptions(masked subscription summary) -
GET /api/capacity/trends(daily or hourly trend rollups) -
GET /api/capacity/scores(derived capacity score view with desired-count-aware live snapshot reads) -
POST /api/capacity/scores/live(scoped live placement refresh for one subscription + one family) -
POST /api/capacity/recommendations(worker-first recommendation flow with diagnostics and fallback handling) -
POST /internal/ingest/capacity -
GET /internal/ingest/status -
GET /api/quota/groupslive implementation - Quota movement plan/simulate/apply endpoints
- Capacity Explorer tab with filters and grid
- Region group defaulting (
USMajor) - Subscription checkbox list with auto-select on first load
- Move subscriptions into a flyout filter section on the right-hand side of the screen
- Resource Type filter (Compute / Disk / Other / All) scopes the SKU Family dropdown
- SKU Family live search text input with filtered results dropdown alongside it
- SKU family labels formatted for readability (
Standard_Dasv7instead ofStandardDasv7Family) - SKU Family dropdown canonicalization (case-insensitive dedupe + normalized casing/sort for easier lookup)
- Quota Insights tab tables for subscription summary + trends
- Chart views for region availability and top SKU available quota
- Derived High/Medium/Low regional SKU capacity score view in reporting
- On-demand live placement refresh using
Get-AzVMAvailabilityplacement scores - Worker-first live placement routing with local fallback for rollback safety
- Capacity Score report legend, last-checked timestamp, and sortable report columns in the React experience
- Live placement refresh is intentionally scoped to exactly one subscription and one family per run to avoid over-broad worker fanout
- Placeholder and aggregate SKUs are filtered out of live refresh requests and report filter lists
- Region-level live placement failures are retried/resolved per region, with unavailable rows persisted as the newest snapshot state
- Capacity Recommender falls back to the local runner when the remote worker fails, and zero-result runs now return clearer warning/detail text
- Ingestion status widget in UI
- Admin ingestion trigger is queued/polled as a background job instead of holding the browser request open
- Admin UI setting for scheduled refresh rates (capacity ingestion and live placement refresh stored in SQL)
- Quota Workbench allocation report uses
Quota GroupandAssigned quotalabels, with the old provisioning column hidden - Build a comparison-focused PaaS report against the alternate scanner because product management suspects the newer PaaS source is more accurate
- Admin UI setting for quota discovery scope selection (management group and, if needed, quota group picker/default)
- Clean up Quota Workbench button interactions, emphasis, and color treatment so step actions read clearly and consistently
- Pagination for report grids (prefer server-side paging for large result sets)
- Export (CSV/XLSX) actions wired to backend
- Separate pricing report (on-demand and spot) with
$/Hr and $ /Mo columns sourced from Get-AzVMAvailability
- Discover quota groups from live APIs
- Generate candidate/move plans from analytics data (read-only candidate generation, captured-run selection, move-plan building, simulation, and apply are live)
- Approval workflow for quota apply actions
- Safe apply with change caps, retries, and audit log views
Quota apply execution now runs through the dedicated tools/Get-AzVMAvailability/Apply-QuotaGroupMove.ps1 entry point.
Get-AzVMAvailability.ps1 -QuotaGroupApply remains available for backward compatibility and delegates to that dedicated script.
Quota move/apply operations require write RBAC in addition to the read access used for discovery. The managed identity used by the dashboard for quota apply must have GroupQuota Request Operator on the management group referenced by QUOTA_MANAGEMENT_GROUP_ID (for example Demo-MG) and on every participating subscription scope used by the move. In practice that means both donor and recipient subscriptions need the role assignment when the quota apply path patches quotaAllocations. Without those grants, quota apply requests can authenticate successfully but still fail with 403 Forbidden on quotaAllocations PATCH calls. For large estates, prefer the bulk rollout script at scripts/grant-quota-rbac.ps1 instead of hand-maintaining long subscription arrays.
- Migration runner script (
scripts/apply-migration.ps1) - Schema + seed scripts for dev initialization
- Worker packaging/deploy script scaffold
- Database error log table for support visibility (
dbo.DashboardErrorLog) - Live placement error display on reports (compact error badges visible in grid)
- Operation history logging (
dbo.DashboardOperationLog) for audit/support - Admin operation history UI showing recent ingest and refresh events
- Live placement snapshot persistence (
dbo.LivePlacementSnapshot) across sessions and desired-count refreshes - Admin error log reviewer/dashboard for support triage
- On-demand live placement refresh with last checked snapshot persistence
- CI/CD pipeline for build/deploy/migrations
- Scheduled ingestion monitoring/alerts
- Deployment follow-up: investigate why
Compute Recommendations Roleassigned at the management-group scope did not satisfyMicrosoft.Compute/locations/placementScores/generate/actionfor the worker managed identity, while the subscription-level assignment did - Deployment hardening follow-up: stop relying on placeholder/default
INGEST_API_KEYandSESSION_SECRETvalues during Bicep/Terraform deploys; review with the team whether explicit generated secrets remain the right approach or whether there is a safe managed-identity-backed alternative for any of these paths - Deployment hardening follow-up: finish the production automation-caller decision for bearer-authenticated internal endpoints; until then keep
INGEST_API_KEY,ENTRA_CLIENT_SECRET, andSESSION_SECRETrequested when the selected auth mode still uses them - Release verification checklist + rollback playbook
- Copy
.env.exampleto.envand provide SQL values (or leave blank for mock mode). - Install dependencies:
npm install- Start API + UI server:
npm start- Open:
Optional worker-first settings:
CAPACITY_WORKER_BASE_URLCAPACITY_WORKER_SHARED_SECRETCAPACITY_WORKER_TIMEOUT_MSCAPACITY_RECOMMEND_USE_DIRECT_API- defaults totruein the Bicep/Terraform web app deployments so Capacity Recommender uses the faster in-process Azure REST path firstCAPACITY_RECOMMEND_SUBSCRIPTION_ID- defaults to the deployment subscription in Bicep/TerraformCAPACITY_RECOMMEND_WORKER_TIMEOUT_MSCAPACITY_LIVE_REFRESH_MAX_CALLSCAPACITY_WORKER_DISABLE_LOCAL_FALLBACK
Optional ingestion scope settings:
INGEST_MANAGEMENT_GROUP_NAMES- comma-separated management group names; when set, capacity ingestion targets descendant subscriptions from those groups instead of relying only onINGEST_SUBSCRIPTION_IDSINGEST_SUBSCRIPTION_IDS- comma-separated fallback list for smaller estates without management groups
When CAPACITY_WORKER_BASE_URL is set, live placement refresh calls the Azure Function worker first. The currently verified working Azure dev baseline uses CAPACITY_WORKER_SHARED_SECRET on the web app and WORKER_SHARED_SECRET on the function app. If the worker is unavailable and CAPACITY_WORKER_DISABLE_LOCAL_FALLBACK is not true, the dashboard falls back to the in-process App Service path to preserve rollback safety.
The VM availability scanner uses Azure Resource Manager REST data from Microsoft.Compute/skus and regional Microsoft.Compute/locations/{region}/usages. The SKU response does not return literal OK, LIMITED, or RESTRICTED values. Those labels are dashboard-friendly interpretations of the raw restrictions array.
Important distinction: the historical OK label meant ARM returned no SKU restriction for that subscription, region, and SKU. It did not mean Azure had confirmed live physical capacity for a deployment at that moment. Treat OK as eligibility/no-restriction evidence, not as an allocation guarantee.
Use the sample REST script to inspect the source payload for a region, family, or SKU list:
.\tools\Test-AzVMAvailabilityRestCall.ps1 -Region eastus2 -FamilyFilter E
.\tools\Test-AzVMAvailabilityRestCall.ps1 -Region eastus2 -SkuFilter Standard_E104i_v5,Standard_E128-32ads_v7 -ShowRawThe script gets an ARM token from the current Az PowerShell context, then calls:
GET https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/skus?api-version=2021-07-01&$filter=location eq '{region}'
GET https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{region}/usages?api-version=2023-09-01Important REST fields:
| REST field | Meaning |
|---|---|
locationInfo.zones |
Zones where the SKU is listed for the region. If there are no restrictions, this is the evidence behind the old OK zone display. |
restrictions[].reasonCode |
Azure's returned reason for a restriction, such as NotAvailableForSubscription. |
restrictions[].type |
Restriction scope, commonly Location or Zone. |
restrictions[].restrictionInfo.zones |
Specific zones affected by a zone-scoped restriction. |
family |
Compute quota family/resource name used to match the regional usage quota row. |
| Raw ARM evidence | Previous friendly label | What it really means |
|---|---|---|
restrictions is empty |
OK |
ARM did not return a restriction blocking this subscription from using the SKU in the region. This is not a live capacity guarantee. |
reasonCode=NotAvailableForSubscription |
LIMITED |
ARM returned an explicit subscription/region/zone restriction for the SKU. |
| Other location or zone restrictions | RESTRICTED, PARTIAL, or capacity-constrained display |
ARM returned a blocking location or zone restriction. Inspect type, reasonCode, and restrictionInfo.zones to understand the affected zones. |
For example, a SKU with no restrictions may show RestRestriction = NoRestrictionsReturned in the sample script. That does not mean Azure returned the word OK; it means ARM returned no restriction records for that SKU in that subscription and region. A restricted SKU may show reasonCode=NotAvailableForSubscription,type=Location; reasonCode=NotAvailableForSubscription,type=Zone, which is the raw evidence that older reports summarized as LIMITED.
Quota and live placement are separate signals. The usages call shows quota headroom (limit - currentValue) for the SKU's quota family; it does not prove regional hardware capacity. For stronger deployment-time confidence, use the live placement score path or validate with an actual deployment request for the desired SKU, region, zone, and count.
Report runtime dependency reference:
| Area | Uses PowerShell for normal report read? | Runtime/status notes |
|---|---|---|
| Capacity Grid | No | SQL/Node read path |
| Region Health / Top SKUs / Region Matrix | No | SQL/Node analytics read path |
| Family Summary | No | SQL/Node read path |
| Trend History | No | SQL/Node read path |
| Capacity Score read | No | SQL/Node read path |
| AI Model Availability | No | SQL-backed read path |
| PaaS Availability read | No | Reads the latest persisted SQL snapshot |
| PaaS refresh | Yes | Uses the Function worker or local PowerShell runtime; PowerShell 7 is the preferred Azure PowerShell path, with Windows PowerShell 5.1 compatibility kept as fallback insurance |
| Live placement / live Capacity Score refresh | Sometimes | Worker/direct REST first when configured, with local PowerShell fallback when allowed |
| Capacity Recommender | Sometimes | Direct Azure REST first when CAPACITY_RECOMMEND_USE_DIRECT_API=true; worker/local PowerShell fallback remains available |
| Quota Workbench read/plan | Mostly no | Read/planning paths are Node/ARM/SQL oriented |
| Quota apply | Yes | State-changing path that shells out to tools/Get-AzVMAvailability/Apply-QuotaGroupMove.ps1; do not run as a casual health check |
Deployment incident note, 2026-04-24:
- A previous environment spent hours failing because it had drifted onto the newer managed-identity worker-auth deployment while the known-good environment was still running the older shared-secret contract.
- The successful recovery was to restore the affected environment to the same contract as the working environment: set
CAPACITY_WORKER_SHARED_SECRETon the web app, setWORKER_SHARED_SECRETon the function app, keepCAPACITY_WORKER_DISABLE_LOCAL_FALLBACK=true, and redeploy fromgithub/main. - Validation after rollback: Capacity Score worked again, Capacity Recommender worked again,
/api/auth/mereturned the normal unauthenticated payload, and direct function/api/recommendationscalls returnedok: trueincluding pricing. - Until the managed-identity worker path is deliberately fixed and revalidated, do not redeploy the local managed-identity worker-auth variant to the dev environment expecting parity with the current working baseline.
- Future hardening note: discuss with the team whether the current shared-secret approach should stay in place for the worker/web contract and other internal app secrets, or whether there is a safe managed-identity path at all. We already know the earlier managed-identity worker-auth attempt broke the working dev baseline, so any revisit needs a deliberate design review and revalidation plan rather than another silent drift.
Current live placement refresh guardrails:
- The React Capacity Score refresh action only runs when exactly one subscription and one family are selected.
- Refreshes use the requested desired count, but the service will reject scopes that would exceed the configured call limit (
CAPACITY_LIVE_REFRESH_MAX_CALLS, default10). - Placeholder or aggregate SKUs are skipped because Azure live placement does not return meaningful results for those synthetic rows.
- Explicit unavailable results are persisted so a failed or unavailable refresh does not silently resurrect an older successful snapshot.
Capacity Recommender settings:
GET_AZ_VM_AVAILABILITY_ROOT— Path to theGet-AzVMAvailabilityrepository root (optional in local dev; required in App Service production if recommender feature is used). Default:../../Get-AzVMAvailabilityrelative to thetools/folder. If the external repository is not available at this location, set this environment variable to the correct path, or the Capacity Recommender will fail with "repo root not found."
Current recommender behavior:
- Recommendation requests use the remote worker first when configured.
- If the remote worker fails and local fallback is allowed, the dashboard retries through the local runner and surfaces that fallback in warnings/diagnostics instead of failing the whole UI path.
- The React banner snapshots the submitted target SKU and region scope so the status text always matches the request that actually ran.
The dashboard does not read a dedicated environment setting to decide whether to show Dev, Test, or Prod branding in the UI. The React UI infers the environment from the request hostname.
Current hostname rules:
- If the hostname contains
testordemo, the UI is treated asTest - If the hostname contains
dev, the UI is treated asDev - If the hostname contains
prod, the UI is treated asProd - If none of those tokens are present, the React UI falls back to generic
React V2labeling
Practical guidance for new environments:
- You do not strictly need the App Service resource name itself to contain
devortest - You do need the user-facing hostname to contain the environment token if you want automatic environment-specific branding and color treatment
- The simplest approach is to keep the App Service name and default
azurewebsites.nethostname aligned with the environment, for example<dev-web-app-name>or<test-web-app-name>. - If you use a custom domain, the same rule applies: include
dev,test,demo, orprodin the hostname if you want the automatic theme to activate
Examples:
https://<dev-web-app-name>.azurewebsites.net->Devhttps://<test-web-app-name>.azurewebsites.net->Testcapacity-demo.contoso.com->Testcapacity.contoso.com-> default styling unless you change the detection logic
Use zip/web package deploy for the dashboard App Service.
Use these files when an existing environment is missing the current AI/PaaS tables, views, or dashboard settings.
sql/test-repair-manual.sql- Use this for drifted legacy databases that never had reliable
dbo.SchemaMigrationHistorytracking. - Run it directly in SSMS or the Azure Portal query window as an Entra SQL admin.
- If you also need to create/grant the web app managed identity, replace
__APP_IDENTITY_NAME__before running.
- Use this for drifted legacy databases that never had reliable
sql/migrations/20260427-add-paas-availability-and-ui-settings.sql- Use this for already-deployed environments that are on the normal migration path and only need the missing schema objects/settings added.
scripts/apply-database-upgrade.ps1- Wrapper for running either SQL file with
sqlcmdagainst Azure SQL. - Example standard upgrade:
- Wrapper for running either SQL file with
pwsh ./scripts/apply-database-upgrade.ps1 `
-SqlServer 'your-server.database.windows.net' `
-SqlDatabase 'your-database'- Example legacy repair with managed identity setup:
pwsh ./scripts/apply-database-upgrade.ps1 `
-SqlServer 'your-server.database.windows.net' `
-SqlDatabase 'your-database' `
-SqlFile 'sql/test-repair-manual.sql' `
-AuthenticationMethod ActiveDirectoryInteractive `
-AppIdentityName '<app-managed-identity-name>'Validation helpers:
sql/schema-diff-inventory-chunks.sqllets an operator run the table/index/view/settings checks in small chunks.sql/schema-diff-inventory.sqlruns the same inventory in one pass when the query tool can handle larger result sets.
Important:
- The manual repair intentionally does not invent
dbo.SchemaMigrationHistoryrows for legacy systems. - For repaired legacy databases, trust the resulting schema state and validation queries rather than assuming historical migrations were applied.
Safe deploy rule:
- Do not deploy from an arbitrary local checkout when dev parity matters.
- Use
scripts/invoke-from-clean-main.ps1so packaging runs from a temporary detached worktree atgithub/main. - Longer term, replace this with CI-produced deployment artifacts pinned to a specific
maincommit.
Examples:
./scripts/invoke-from-clean-main.ps1 `
-ScriptRelativePath 'deploy-web-app.ps1' `
-ResourceGroup '<resource-group-name>' `
-AppName '<web-app-name>'
./scripts/invoke-from-clean-main.ps1 `
-ScriptRelativePath 'scripts/deploy-worker.ps1' `
-ResourceGroupName '<resource-group-name>' `
-FunctionAppName '<function-app-name>'Deployment target values are environment-specific. Set them with variables or substitute your own names when you run the commands below.
Example target variables:
$resourceGroup = "<resource-group-name>"$webAppName = "<web-app-name>"$webAppHost = "https://<web-app-host>"
Important packaging rule:
- Do not zip the whole dashboard folder blindly.
- Exclude deployment artifacts, prior zip files, downloaded App Service logs,
.git, andnode_modules. - Including
artifacts/,appservice-logs*/, or priordeploy*.zipfiles makes uploads much larger and can cause Kudu extraction failures such asPathTooLongException.
Package only the runtime files and folders:
$items = @(
'server.js',
'web.config',
'sku-catalog.js',
'package.json',
'package-lock.json',
'react',
'src',
'sql',
'scripts',
'tools'
)
Compress-Archive -Path $items -DestinationPath ..\webpackage-capdash-clean.zip -ForceDeploy the package with:
az webapp deploy \
--resource-group $resourceGroup \
--name $webAppName \
--src-path ..\webpackage-capdash-clean.zip \
--type zipVerification checks after deploy:
curl.exe -i -s "$webAppHost/"curl.exe -i -s "$webAppHost/react/main.js?v=20260514-react-only"curl.exe -i -s "$webAppHost/api/auth/me"
Expected behavior:
- Deployment should complete in roughly seconds to a small number of minutes, not stall on a huge upload.
- The clean package should stay small; the last known good package was about 456 KB.
- If deployment is slow or fails during extraction, inspect the zip contents first before retrying.
Recommended dev publish workflow:
az login
az account show --output table
az account set --subscription "<subscription-name-or-id>"
npm install
./deploy-web-app.ps1Notes:
deploy-web-app.ps1now runsnpm testbefore packaging and deployment. Use-SkipTestsonly when you intentionally want to bypass the local test gate.- Fresh clones must run
npm installbefore deployment so the local test gate can load dependencies such asmssql,@azure/identity, and@azure/msal-node. - The current
npm testsuite is read-only and logic-focused. It does not require on-prem SQL connectivity or Azure API access. - The deployment script already stages the correct runtime files and publishes them to the App Service name you pass in.
- The deployment package stages the repo's
react/folder, rootserver.js, rootweb.config, and sharedsku-catalog.js, so a fresh pull plus redeploy publishes the current React experience and keeps/api/*routed to Express on Windows App Service. - Runtime packages should not include repo documentation or design artifacts such as
docs/,README.md, orapi-contract.md. - Keep the package source-shaped. Do not ship local
node_modules; App Service restores production dependencies during deployment. - The root dashboard URL sends
no-storecache headers because the React shell uses stable filenames such asreact/main.js; after a redeploy, the live environment should pick up the current React navigation without relying on a stale browser cache. - If
az webapp deployfails withAuthorizationFailed, refresh Azure credentials withaz login, confirm the correct subscription withaz account show, and make sure the signed-in identity has App Service access on the resource group that hosts the web app. - The React experience is served from
https://<web-app-host>/;/react/remains only as a compatibility alias and static asset path. - The legacy classic UI has been removed from the deployment package so the React experience is the only supported dashboard UI.
Repo refresh guidance:
- If someone had an older checkout before the React-only routing fixes landed, a
git pullfollowed by./deploy-web-app.ps1is enough to republish the updated React assets and root routing files. - If you are provisioning or updating the whole environment,
./scripts/deploy-infra.ps1also republishes the dashboard web package by default, so the same pull-and-redeploy flow updates both infra settings and the React UI.
Private or DBA-managed SQL note:
- If Azure SQL is pre-created by a customer DBA team and exposed only through private access with Entra-only auth, do not assume the app identity can create schema objects on first start.
scripts/deploy-infra.ps1now tries two install paths: app-identity bootstrap first, then an Azure-side admin-assisted bootstrap using the current Azure CLI login if that login is an Entra SQL admin.- If neither path can administer the database, hand off
scripts/initialize-database.ps1to the DBA team. That script appliessql/schema.sql, runs all files insql/migrations/, and grants the dashboard web app identity the runtime roles it needs. - Manual database scripts accept
-SqlDatabase, so a DBA can target a customer-managed or pre-created vCore database directly. The script does not infer the database name from the SQL server. dbo.DashboardSettingis part of the provisioned schema and is also backfilled bysql/migrations/20260420-add-dashboard-setting.sql. Scheduler settings are expected to come from schema/bootstrap, not from opportunistic runtime table creation.dbo.QuotaCandidateSnapshotis also expected to be provisioned by schema/bootstrap. The app now fails fast if that table is missing instead of attempting runtime table creation.- If the React Data Ingestion page reports that SQL scheduler settings are unavailable because
DashboardSettingis not provisioned, rerun the database bootstrap path instead of re-enabling runtimeCREATE TABLEbehavior. - Example DBA handoff command:
./scripts/initialize-database.ps1 \
-SqlServer "<sql-server-name>.database.windows.net" \
-SqlDatabase "<sql-database-name>" \
-AppIdentityName "<web-app-managed-identity-name>"Manual database PowerShell scripts:
| Script | Use for | Required target parameters | Example |
|---|---|---|---|
scripts/initialize-database.ps1 |
Preferred full DBA handoff/bootstrap path. Applies sql/schema.sql, runs every file in sql/migrations/, creates the web app managed-identity database user when needed, and grants runtime roles. |
-SqlServer, -SqlDatabase, -AppIdentityName |
./scripts/initialize-database.ps1 -SqlServer "<server>.database.windows.net" -SqlDatabase "<database>" -AppIdentityName "<web-app-managed-identity-name>" |
scripts/apply-schema.ps1 |
Applies only the base schema file. Use for low-level/manual recovery when you intentionally do not want the full migration chain. | -SqlServer, -SqlDatabase, plus -UseEntra or SQL auth parameters |
./scripts/apply-schema.ps1 -SqlServer "<server>.database.windows.net" -SqlDatabase "<database>" -UseEntra -EntraUser "<admin-upn>" |
scripts/apply-migration.ps1 |
Applies one specific migration file. Use when a DBA needs to run an individual migration after the base schema exists. | -SqlServer, -SqlDatabase, -MigrationFile, plus -UseEntra or SQL auth parameters |
./scripts/apply-migration.ps1 -SqlServer "<server>.database.windows.net" -SqlDatabase "<database>" -MigrationFile "sql/migrations/<migration-file>.sql" -UseEntra -EntraUser "<admin-upn>" |
scripts/apply-database-upgrade.ps1 |
Applies a selected upgrade/repair SQL file, including legacy repair files that can replace __APP_IDENTITY_NAME__. Use for drifted existing environments, not for normal fresh bootstrap. |
-SqlServer, -SqlDatabase; optional -SqlFile, -AuthenticationMethod, -AppIdentityName |
./scripts/apply-database-upgrade.ps1 -SqlServer "<server>.database.windows.net" -SqlDatabase "<database>" -SqlFile "sql/test-repair-manual.sql" -AuthenticationMethod ActiveDirectoryInteractive -AppIdentityName "<web-app-managed-identity-name>" |
For customer-managed vCore, serverless, Hyperscale, or elastic-pool databases, pre-create the database with the desired SKU, pass that database name to deployment as -ExistingSqlDatabaseName, and use the same name in the manual database script -SqlDatabase parameter.
Capacity Recommender configuration:
If you plan to use the Capacity Recommender feature (which requires the Get-AzVMAvailability PowerShell script), you must configure the following environment variable on the App Service:
az webapp config appsettings set \
--resource-group $resourceGroup \
--name $webAppName \
--settings GET_AZ_VM_AVAILABILITY_ROOT="/path/to/Get-AzVMAvailability"The GET_AZ_VM_AVAILABILITY_ROOT environment variable tells the recommender wrapper where to find the external Get-AzVMAvailability PowerShell repository. Without this setting, the Capacity Recommender will return an error indicating the repository root was not found.
Quota apply uses the vendored tools/Get-AzVMAvailability copy that ships with this repo, so it does not depend on a separate external checkout.
The dashboard uses Azure Resource Manager, Microsoft identity endpoints, Azure SQL, Key Vault, storage, and optional public pricing/package endpoints. In locked-down environments, allow these endpoints from the deployment machine and, where applicable, from the deployed Web App and Function App outbound path. See docs/LOCKED-DOWN-CUSTOMER-PREREQS.md for the fuller locked-down deployment checklist.
| Source | Destination/FQDN | Protocol | Required for | Notes |
|---|---|---|---|---|
| Deployment machine | github.com |
HTTPS 443 | Clone or update the repository | Can be replaced with a customer-approved offline package. |
| Deployment machine | management.azure.com |
HTTPS 443 | Azure CLI, ARM deployments, RBAC, and validation | Required for deployment and resource operations. |
| Deployment machine | login.microsoftonline.com |
HTTPS 443 | Azure login and token acquisition | Required for Azure CLI and PowerShell authentication. |
| Deployment machine | registry.npmjs.org |
HTTPS 443 | npm install |
Can be replaced with an approved npm mirror or package cache. |
| Deployment machine | releases.hashicorp.com, registry.terraform.io |
HTTPS 443 | Terraform provider restore | Required only when the Terraform deployment path is used. |
| Deployment machine | <sql-server>.database.windows.net or SQL private endpoint |
TCP 1433 | Manual database bootstrap | Required only when running database initialization directly from the deployment machine. |
| Web App | management.azure.com |
HTTPS 443 | ARM read APIs, quota discovery, placement score, AI ingestion, and PaaS ingestion | Uses managed identity or configured Azure credential token. |
| Web App | login.microsoftonline.com |
HTTPS 443 | Entra token acquisition | Needed for Azure credential flows. |
| Web App | <sql-server>.database.windows.net or SQL private endpoint |
TCP 1433 | Dashboard data store | Prefer private endpoint in locked-down environments. |
| Web App | <keyvault>.vault.azure.net or Key Vault private endpoint |
HTTPS 443 | Runtime secrets | Prefer private endpoint in locked-down environments. |
| Web App | <function-app>.azurewebsites.net through Function App private endpoint |
HTTPS 443 | Worker-backed live placement, PaaS scans, recommendations, and quota apply paths | Default infra creates privatelink.azurewebsites.net and disables Function App public ingress. |
| Web App | prices.azure.com |
HTTPS 443 | Optional pricing enrichment | Needed only when pricing features are enabled. |
| Function App worker | management.azure.com |
HTTPS 443 | Live placement, PaaS scans, and quota worker paths | Uses managed identity. |
| Function App worker | Storage account blob, queue, table, and file private endpoints | HTTPS 443 | Function host storage | Required by Azure Functions host storage. Branch IaC creates privatelink.<service>.core.windows.net zones/endpoints for all four services. |
| Browser/user | <web-app>.azurewebsites.net or App Service private endpoint |
HTTPS 443 | Dashboard UI and same-origin API | If App Service private endpoint is used, customer DNS and network routing must resolve privately. |
The browser does not call Azure Resource Manager directly. Browser traffic is same-origin to the dashboard Web App. Azure API calls are made server-side by the Web App or Function App worker using managed identity or configured Azure credentials.
| Feature | Azure API family | Endpoint pattern | Public internet host |
|---|---|---|---|
| Subscription discovery | Azure Resource Manager | /subscriptions, /providers/Microsoft.Management/managementGroups/... |
management.azure.com |
| Compute quota ingestion | Microsoft.Compute |
/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{region}/usages |
management.azure.com |
| Compute SKU availability | Microsoft.Compute |
/subscriptions/{subscriptionId}/providers/Microsoft.Compute/skus |
management.azure.com |
| Capacity Spot Score | Microsoft.Compute Spot Placement Score |
/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{region}/placementScores/spot/generate |
management.azure.com |
| Quota groups and quota apply | Microsoft.Quota |
/providers/Microsoft.Quota/groupQuotas/... |
management.azure.com |
| AI quota and model catalog | Microsoft.CognitiveServices |
/providers/Microsoft.CognitiveServices/locations/{region}/usages, /models |
management.azure.com |
| PaaS Availability | SQL, DocumentDB, Web, App, ContainerService, Storage, and other resource providers | Provider-specific ARM routes | management.azure.com |
| Optional pricing | Azure Retail Prices | /api/retail/prices |
prices.azure.com |
If public internet egress is blocked, provide approved routes or mirrors for GitHub, npm, Terraform providers, Azure login, Azure Resource Manager, Azure SQL, Key Vault, storage, and optional pricing. For SQL, Key Vault, storage, and App Service private endpoints, validate DNS from both the deployment machine and the App Service/Function outbound path.
Terraform deployment note:
- The script examples in this section use the Bicep path. If you are deploying with Terraform instead, use infra/terraform/README.md for the Terraform-specific workflow and prerequisites.
- Terraform now supports the same management-group-first RBAC model as Bicep: use management-group name arrays as the preferred path for larger estates, and keep subscription arrays only as the fallback for smaller customers.
./scripts/deploy-infra.ps1 -Provider Terraformnow passes the same management-group and subscription RBAC inputs through to Terraform and still publishes the dashboard web app and worker packages after a successful apply.- Terraform may still target an existing resource group that needs to be imported into state before the first apply.
- Each Terraform environment needs its own state file, workspace, or remote backend key. Do not reuse one local
infra/terraform/terraform.tfstatefor different resource groups such as dev, test, and customer trial environments. - The wrapper refuses to continue when existing Terraform state already manages a different resource group than the one requested by
-ResourceGroupName, because continuing would make Terraform replace or delete the old state-managed environment.
Recommended starting point for first-time or customer deployments: use the guided deployment wizard. It walks the operator through the setup conversation before anything is deployed, including provider, subscription, naming, authentication, Entra group strategy, existing shared services, RBAC scope, package publishing, and database bootstrap.
For the Easy Auth hardening path in a clean tenant, use one dashboard app registration by default. Configure both callback URLs (/auth/callback for the current Express flow and /.auth/login/aad/callback for App Service Authentication), expose api://<client-id>, enable ID token issuance, and emit Security Group Object IDs in the ID token. In the wizard, enable Web App Easy Auth, choose RedirectToLoginPage for browser sign-in, set worker auth mode to entra, leave worker-specific client/audience values at their dashboard-app defaults unless the tenant requires a separate worker app, deploy the worker package with the temporary Function public-access window, and keep database bootstrap enabled for a clean environment. After infrastructure deployment, the wrapper resolves the Web App managed identity application/client ID and adds it to Function Easy Auth allowed applications so dashboard-to-worker bearer calls are authorized.
For repeat Bicep test deployments that reuse the same suffix after deleting a resource group, Azure may keep the Key Vault name in soft-deleted state. The wrapper now detects this before ARM deployment and either tells you the purge command or purges it when -PurgeDeletedKeyVaultOnNameConflict $true is explicitly supplied. The Bicep wrapper also retries the specific transient Azure SQL logical-server provisioning timeout once by default (-BicepSqlProvisioningRetryCount).
Run it from PowerShell 7 (pwsh) at the repository root:
pwsh
./scripts/Start-CapacityDeployment.ps1Use plan-only mode when you want to walk the same guided prompts, review the deployment plan, and copy the generated deploy-infra.ps1 command without starting an Azure deployment:
./scripts/Start-CapacityDeployment.ps1 -PlanOnlyThe wizard uses secure prompts for secrets, does not write secrets to saved answer files, and only enables Entra group creation after explicit confirmation.
Use script-based deployment directly only when you already know the exact deployment inputs and want to bypass the guided setup. Example with Central US default:
./scripts/deploy-infra.ps1 \
-ResourceGroupName "<rg-name>" \
-Environment dev \
-WorkloadSuffix "demo001" \
-QuotaManagementGroupId "<management-group-id>" \
-WebReaderManagementGroupNames @("<management-group-name-1>","<management-group-name-2>") \
-WorkerRbacManagementGroupNames @("<management-group-name-1>","<management-group-name-2>") \
-SqlEntraAdminLogin "<entra-upn>" \
-SqlEntraAdminObjectId "<entra-object-id>" \
-SubscriptionId "<subscription-id>"By default, ./scripts/deploy-infra.ps1 now does both steps:
- provisions the Azure resources from
infra/bicep/main.bicep - publishes the dashboard web package, including
react/, to the target App Service
If you skip follow-on steps, the wrapper prints the exact manual PowerShell commands to run afterward. This includes the web package command when -DeployWebApp $false, the worker package command when -DeployWorkerApp $false, and the database initialization command when -ApplyDatabaseBootstrap $false or when database bootstrap cannot run because the web package was not deployed.
The script checks whether the requested App Service host names are available before deploying. If app-capdash-<environment>-<workloadSuffix> or func-capdash-<environment>-<workloadSuffix>-appsvc is already used outside the target resource group, the script generates a randomized workload suffix and uses that effective suffix for both infrastructure deployment and the follow-on web/worker package publish. After the infrastructure step, the script reads the actual web app and function app names from Bicep or Terraform outputs so content is deployed to the created resources instead of a guessed name.
When -AuthEnabled $true is used, ./scripts/deploy-infra.ps1 also resolves the default dashboard access groups when explicit IDs are not supplied:
CapacityAdminfeedsADMIN_GROUP_IDCapacityReportViewersfeedsREPORT_VIEWER_GROUP_IDS
By default the wrapper only reuses existing groups by display name and fails closed if they are missing. It will not create Entra groups unless you explicitly pass -CreateMissingEntraAccessGroups $true. Raw Bicep and Terraform accept group object IDs but do not create groups. If group creation is centrally managed, pre-create the groups and pass -AdminGroupId and -ReportViewerGroupIds.
Existing shared-service reuse:
- If the customer already has Azure SQL, Key Vault, the worker storage account, or a Virtual Network in place, pass the
deploy-infra.ps1reuse switches instead of forcing the template to create duplicates. - Supported switches are
-ExistingSqlServerName,-ExistingSqlDatabaseName,-ExistingKeyVaultName,-ExistingWorkerStorageAccountName,-ExistingVirtualNetworkName,-ExistingAppServiceIntegrationSubnetName, and-ExistingPrivateEndpointSubnetName. - Providing an existing resource name is enough to switch that dependency into reuse mode.
-ExistingSqlDatabaseNameis optional and only applies when-ExistingSqlServerNameis also set. - SQL SKU note: the Azure SQL logical server does not determine DTU vs vCore; the database SKU does. If you pass only
-ExistingSqlServerName, the deployment creates the dashboard database as the template defaultS0DTU database. If the customer requires vCore, serverless, Hyperscale, an elastic pool, or another governed database SKU, pre-create the database and pass both-ExistingSqlServerNameand-ExistingSqlDatabaseName. - Optional resource-group overrides are also available when the reused dependency lives outside the dashboard resource group:
-ExistingSqlServerResourceGroupName,-ExistingKeyVaultResourceGroupName,-ExistingWorkerStorageResourceGroupName, and-ExistingVirtualNetworkResourceGroupName. - When reusing an existing Virtual Network, provide the VNet name plus both subnet names. The App Service integration subnet must already be delegated to
Microsoft.Web/serverFarms; the private endpoint subnet must already support private endpoints for any newly-created SQL, Key Vault, Function App, and worker storage endpoints. - When reusing an existing SQL server, Key Vault, or worker storage account, the infra templates assume the customer-managed private endpoint and DNS path already exists or will be managed by the customer platform team.
Use -DeployWebApp $false only when you explicitly want an infra-only run.
Stable demo environment:
- Treat
devas change-heavy andtestas the stable demo environment. - Use the same naming pattern with the environment token changed to
test, for example<web-app-name-with-test-token>and<function-app-name-with-test-token>. - Use
./infra/bicep/test.bicepparamplus a dedicated resource group such as<test-resource-group-name>when deploying the demo environment.
Example:
./scripts/deploy-infra.ps1 \
-ResourceGroupName "<test-resource-group-name>" \
-Environment test \
-WorkloadSuffix "demo001" \
-ParameterFile "./infra/bicep/test.bicepparam" \
-QuotaManagementGroupId "Demo-MG" \
-WebReaderManagementGroupNames @("Demo-MG","LandingZones-MG") \
-WorkerRbacManagementGroupNames @("Demo-MG","LandingZones-MG") \
-SqlEntraAdminLogin "<entra-upn>" \
-SqlEntraAdminObjectId "<entra-object-id>" \
-SubscriptionId "<subscription-id>"Notes:
- SQL is configured with Microsoft Entra admin and AAD-only authentication.
- Database bootstrap for private SQL runs through the deployed web app's internal endpoint, so the repeatable path stays inside Azure rather than relying on local SQL access.
- The migration chain now includes
20260420-add-dashboard-setting.sqlso scheduler persistence is provisioned by bootstrap. If bootstrap stops on an earlier migration, the app falls back to runtime defaults and shows a scheduler provisioning warning instead of creatingdbo.DashboardSettingon the fly. -
sqlcmdis still required for the manual schema, migration, and sample-data scripts inscripts/when you intentionally run them outside the App Service bootstrap flow. - The Bicep template now also provisions a Function App plus storage account for the PowerShell 7 worker host.
- The script-based deployment path now also deploys the dashboard web content, so the root dashboard URL is available immediately after a successful run.
- The dashboard UI is React-only. Use the site root as the primary deployed experience;
/react/remains available only as a compatibility alias and static asset path. - Raw
az deployment group createwith the Bicep template still provisions infrastructure only; it does not upload the local dashboard orreact/files. -
-ParameterFilelets you keep environment defaults in a.bicepparamfile while still overriding secure/runtime values from the command line. -
-RandomizeWorkloadSuffixOnNameConflictdefaults to$true; set it to$falseonly when you want the deployment to fail fast on a globally reserved App Service name instead of choosing a randomized suffix. -
-WebReaderManagementGroupNamesgrants the dashboard web appReaderat the named management groups. This is the preferred path for larger estates and supports multiple management groups in CAF-style layouts. -
-WebReaderSubscriptionIdsremains available as a fallback for small customers that do not use management groups or that want a tightly curated subscription list. -
-UseAllAccessibleManagementGroupsauto-discovers every non-root management group visible to the current Azure CLI login and uses those names for-WebReaderManagementGroupNames,-WebQuotaWriterManagementGroupNames, and-WorkerRbacManagementGroupNamesunless you pass one of those arrays explicitly. -
-QuotaManagementGroupIdsets theQUOTA_MANAGEMENT_GROUP_IDapp setting during deployment. Use it whenever you expect live quota discovery or quota apply to target a specific management group such asDemo-MG. -
-WebQuotaWriterManagementGroupNamesgrantsGroupQuota Request Operatorat the named management groups for quota-apply workflows. Keep-WebQuotaWriterSubscriptionIdsas the fallback for customers without management groups. -
-WorkerRbacManagementGroupNamestriggers management-group-scoped RBAC assignment for the worker identity (Compute Recommendations Role,Cost Management Reader,Billing Reader) and is the preferred path for larger estates. -
-WorkerRbacSubscriptionIdsremains available as a fallback for small customers without management groups. -
-AuthEnabledplus-EntraTenantId,-EntraClientId, and-EntraClientSecretconfigure the built-in Entra sign-in flow used by the dashboard API. If-AdminGroupIdor-ReportViewerGroupIdsare omitted, the wrapper reuses existingCapacityAdminandCapacityReportViewersgroups by display name. Missing groups stop the deployment unless you explicitly pass-CreateMissingEntraAccessGroups $true.
Example RBAC baseline:
- Dashboard web app (
<web-app-name>) should have subscriptionReader, subscriptionBilling Reader, subscriptionGroupQuota Request Operator, management-groupGroupQuota Request Operator, andKey Vault Secrets Useron the app Key Vault. - The earlier subscription
Readergrant on the dashboard web app was part of the working deployment and should be treated as required for the current live discovery/ingestion surface. - Function App worker (
<function-app-name>) should have subscriptionCompute Recommendations Role, subscriptionBilling Reader, subscriptionCost Management Reader, management-groupCompute Recommendations Role, plus storage data-plane roles on its host storage account (Storage Blob Data Owner,Storage Queue Data Contributor,Storage Table Data Contributor). - The working Function App does not currently have plain subscription
Reader, soReaderwas not required for the working recommendation/live-placement path in that environment. - The working web app also has
CAPACITY_WORKER_DISABLE_LOCAL_FALLBACK=true, so the web app itself does not needCompute Recommendations Rolein that working set because the recommendation/live-placement path stays remote-first.
Example with Entra sign-in enabled:
./scripts/deploy-infra.ps1 \
-ResourceGroupName "<test-resource-group-name>" \
-Environment test \
-WorkloadSuffix "demo001" \
-ParameterFile "./infra/bicep/test.bicepparam" \
-UseAllAccessibleManagementGroups \
-AuthEnabled $true \
-EntraTenantId "<tenant-id>" \
-EntraClientId "<app-registration-client-id>" \
-EntraClientSecret "<app-registration-client-secret>" \
-SqlEntraAdminLogin "<entra-upn>" \
-SqlEntraAdminObjectId "<entra-object-id>" \
-SubscriptionId "<subscription-id>"The example above lets the wrapper reuse existing CapacityAdmin and CapacityReportViewers groups. Pass -AdminGroupId and -ReportViewerGroupIds when the customer already has approved Entra groups with different names, or pass -CreateMissingEntraAccessGroups $true only when the deployment identity is allowed to create Entra security groups.
Full example with the most commonly needed inputs:
- Use this when you want one copy/paste example that shows the full parameter shape for a real environment.
- Keep the environment and parameter file aligned. For example, use a production parameter file for
prod, or omit-ParameterFileif you are not using one. -WebQuotaWriterManagementGroupNamesis the preferred quota-write RBAC path when the customer uses management groups.-WebQuotaWriterSubscriptionIdsremains available when the dashboard web app should perform quota write operations for a small curated subscription set.-UseAllAccessibleManagementGroupsskips the tenant root group automatically. If you need a tighter scope, pass the three management-group arrays explicitly instead of using discovery.
./scripts/deploy-infra.ps1 `
-ResourceGroupName "<rg-name>" `
-Environment prod `
-WorkloadSuffix "demo001" `
-ParameterFile "./infra/<prod-or-env>.bicepparam" `
-SqlEntraAdminLogin "<entra-upn>" `
-SqlEntraAdminObjectId "<entra-object-id>" `
-SubscriptionId "<subscription-id>" `
-QuotaManagementGroupId "<management-group-id>" `
-WebReaderManagementGroupNames @("<mg-1>","<mg-2>") `
-WebQuotaWriterManagementGroupNames @("<mg-1>","<mg-2>") `
-WorkerRbacManagementGroupNames @("<mg-1>","<mg-2>") `
-AuthEnabled $true `
-EntraTenantId "<tenant-id>" `
-EntraClientId "<app-registration-client-id>" `
-EntraClientSecret "<app-registration-client-secret>" `
-AdminGroupId "<entra-group-object-id>"Current Bicep deployment gaps for a fuller blue-green model are tracked in infra/README.md.
Management-group RBAC handoff:
- The deployment wrapper attempts management-group role assignments after infrastructure creates the web app and worker managed identities.
- If the deployment identity cannot assign roles at the management group, deployment continues and prints follow-up
az role assignment createcommands. - A team with
OwnerorUser Access Administratorat the management group can run the helper below. Use the principal IDs printed by the deployment output for the dashboard web app and function worker identities.
./scripts/grant-management-group-rbac.ps1 `
-ManagementGroupNames @("<management-group-name>") `
-WebPrincipalId "<dashboard-web-managed-identity-principal-id>" `
-WorkerPrincipalId "<function-worker-managed-identity-principal-id>"The helper assigns web app Reader, web app GroupQuota Request Operator, worker Compute Recommendations Role, worker Cost Management Reader, and worker Billing Reader at each named management group. Add -WhatIf first to preview the work.
What the RBAC team is assigning:
- These management-group role assignments are not assigned to the Entra app registration used for sign-in.
- Assign these roles to the Azure managed identities created by the deployment.
- The dashboard web app managed identity is the identity for the App Service named like
app-capdash-<environment>-<suffix>. - The worker managed identity is the identity for the Function App named like
func-capdash-<environment>-<suffix>-appsvc.
| Role at management group | Assign this principal | Why |
|---|---|---|
Reader |
Dashboard web app system-assigned managed identity | Lets the dashboard read management-group/subscription metadata for discovery and reporting. |
GroupQuota Request Operator |
Dashboard web app system-assigned managed identity | Lets the dashboard submit group quota requests for quota apply workflows. |
Compute Recommendations Role |
Function App worker system-assigned managed identity | Lets the worker call Compute placement/recommendation APIs. |
Cost Management Reader |
Function App worker system-assigned managed identity | Lets the worker read cost-management data used by recommendation/reporting workflows. |
Billing Reader |
Function App worker system-assigned managed identity | Lets the worker read billing-related data used by recommendation/reporting workflows. |
Manual role assignment shape if another team does not run the helper script:
$managementGroupScope = "/providers/Microsoft.Management/managementGroups/<management-group-name>"
$webPrincipalId = "<dashboard-web-managed-identity-principal-id>"
$workerPrincipalId = "<function-worker-managed-identity-principal-id>"
# Find these principal IDs in Azure Portal under each resource's Identity page:
# - Web principal ID: App Service -> app-capdash-<environment>-<suffix> -> Identity -> System assigned -> Object (principal) ID
# - Worker principal ID: Function App -> func-capdash-<environment>-<suffix>-appsvc -> Identity -> System assigned -> Object (principal) ID
# Or use Azure CLI:
# az webapp identity show --resource-group <resource-group-name> --name app-capdash-<environment>-<suffix> --query principalId --output tsv
# az functionapp identity show --resource-group <resource-group-name> --name func-capdash-<environment>-<suffix>-appsvc --query principalId --output tsv
az role assignment create --assignee-object-id $webPrincipalId --assignee-principal-type ServicePrincipal --role "Reader" --scope $managementGroupScope
az role assignment create --assignee-object-id $webPrincipalId --assignee-principal-type ServicePrincipal --role "GroupQuota Request Operator" --scope $managementGroupScope
az role assignment create --assignee-object-id $workerPrincipalId --assignee-principal-type ServicePrincipal --role "Compute Recommendations Role" --scope $managementGroupScope
az role assignment create --assignee-object-id $workerPrincipalId --assignee-principal-type ServicePrincipal --role "Cost Management Reader" --scope $managementGroupScope
az role assignment create --assignee-object-id $workerPrincipalId --assignee-principal-type ServicePrincipal --role "Billing Reader" --scope $managementGroupScopePackage and deploy the worker host separately from the dashboard web app:
./scripts/deploy-worker.ps1 \
-ResourceGroupName "<rg-name>" \
-FunctionAppName "<function-app-name>"After the worker is deployed, point the dashboard at it by setting:
CAPACITY_WORKER_BASE_URL=https://<function-app-name>.azurewebsites.netCAPACITY_WORKER_SHARED_SECRET=<same-value-as-WORKER_SHARED_SECRET>- Or, on the Easy Auth path, set
CAPACITY_WORKER_AUTH_MODE=entraand useCAPACITY_WORKER_TOKEN_AUDIENCE=api://<dashboard-client-id>by default so the dashboard app registration secures both Web App and Function App. Do not set worker shared secrets after the Function App Easy Auth configuration is verified.
Hosted worker guidance:
- Configure
AzureWebJobsStoragewith managed identity, not a shared-key connection string. - Grant the worker identity storage data-plane access on the host storage account.
- Default infrastructure creates a Function App private endpoint, links
privatelink.azurewebsites.net, and disables Function App public network access so the dashboard Web App reaches the worker privately. - Shared-secret mode remains available with
CAPACITY_WORKER_SHARED_SECRETon the web app andWORKER_SHARED_SECRETon the Function App for rollback or customers not ready for Easy Auth. - Version
v1.0.3validates the Easy Auth replacement path: App Service Authentication on the Function App, dashboard-managed-identity bearer tokens, and no worker shared secret requirement whenCAPACITY_WORKER_AUTH_MODE=entrais selected. - Infrastructure now tracks Function worker storage private endpoints for blob, queue, table, and file services. Validate private DNS and Function startup before disabling public access on an existing storage account.
- The default infrastructure path uses a dedicated App Service plan for the worker instead of Flex Consumption.
- Enable PowerShell managed dependencies in
host.jsonsorequirements.psd1can restore Az modules on the worker. - NOTE: when
-WorkerRbacManagementGroupNamesis provided during infra deployment, the worker RBAC roles are assigned at those management group scopes. Use-WorkerRbacSubscriptionIdsonly when you need the small-customer subscription fallback. - NOTE: some organizations require billing-account-scope assignments for billing APIs; those billing-scope assignments are outside this resource-group deployment and may still require manual/central platform automation.
Current worker endpoints:
POST /api/live-placementPOST /api/quota-move-apply(placeholder scaffold for future quota move orchestration)
Apply schema:
./scripts/apply-schema.ps1 \
-SqlServer "<server>.database.windows.net" \
-SqlDatabase "<database>" \
-UseEntra \
-EntraUser "<entra-upn>"Load sample rows:
./scripts/load-sample-data.ps1 \
-SqlServer "<server>.database.windows.net" \
-SqlDatabase "<database>" \
-UseEntra \
-EntraUser "<entra-upn>"Approvals are required before:
- Assigning any write permissions for quota movements.
- Enabling production data ingestion across subscriptions.
- Executing quota apply operations from UI/API.
- Enabling public network access for production SQL/Key Vault (recommended to lock down with private networking).
- Do not commit subscription IDs, tenant IDs, resource group names, or credentials.
- Use managed identity for Azure resource access in hosted environments.
- Keep write identity separate from read identity.
Cross-subscription, database-backed reporting (recommended):
- Discovery: On ingestion start, the managed identity enumerates all enabled Azure subscriptions it can access (or uses explicit
INGEST_SUBSCRIPTION_IDSlist). - Batching: Subscriptions are processed in batches of 100 to avoid ARM API rate limits (429 errors). A 2-second delay is inserted between batches.
- Retry-on-throttle: If the service encounters a 429 (rate limit) or 503 (service unavailable) response, it uses exponential backoff with a max of 3 retries per request.
- Ingestion: For each subscription, the service pulls Compute usage and SKU data from ARM, then writes snapshots to
dbo.CapacitySnapshot. - Dashboard: Reports read from the SQL database, never from real-time ARM APIs. Subscription multi-select filtering works by querying the locally-stored snapshots.
- Result: Lightweight, scalable dashboard with multi-subscription visibility; no per-query ARM calls; scheduled ingestion keeps data fresh; handles 100s-1000s of subscriptions without throttling.
Configuration options:
- Auto-discover: If no subscription IDs or management groups are saved in Admin -> Data Ingestion, the service calls
/subscriptionsto enumerate all accessible subscriptions. - Explicit list: Use Admin -> Data Ingestion -> Scheduler Settings to save subscription IDs, management group names, region preset, and optional family filters in SQL for scheduled and manual Admin-triggered Capacity Ingest runs.
- Frequency: Use Admin -> Data Ingestion -> Scheduler Settings to store cadence in SQL (for example 30 = every 30 minutes).
INGEST_INTERVAL_MINUTES,INGEST_REGION_PRESET,INGEST_SUBSCRIPTION_IDS, andINGEST_MANAGEMENT_GROUP_NAMESremain fallback defaults when SQL settings are unavailable or have not been saved yet. - AI model catalog cadence: Admin -> Data Ingestion -> Scheduler Settings also stores
schedule.aiModelCatalog.intervalMinutes.INGEST_AI_MODEL_CATALOG_INTERVAL_MINUTESis the primary fallback default when SQL settings are unavailable;INGEST_OPENAI_MODEL_CATALOG_INTERVAL_MINUTESremains a backward-compatible alias. - Scheduler persistence:
Admin -> Data Ingestion -> Scheduler Settingsreads and writesdbo.DashboardSetting. If that table is missing, treat it as an incomplete bootstrap/migration state, not as a signal to let the app create tables during normal runtime. - Visible scope: Admin -> Data Ingestion shows the saved Capacity Ingest scope from
dbo.DashboardSettingand the on-demand Capacity Score Live behavior. Capacity Ingest can span the configured broad subscription/region scope for saved capacity snapshot-backed reports. Capacity Score Live is on-demand only;Refresh Live Placementupdates Azure Live Score fields and retains the last checked result in SQL. - Batch tuning: Subscription batch size (100) and inter-batch delay (2s) are hardcoded; adjust in
azureIngestionService.jsif needed for different ARM throttle profiles.
This design avoids the performance and cost penalties of real-time API calls during dashboard queries — all filtering happens on indexed SQL tables. Batching and retry logic ensure safe ingestion at scale.
The dashboard now supports a secure internal ingestion path that reads Azure Compute quota usage and writes snapshots to dbo.CapacitySnapshot.
Phase 1 also adds Azure OpenAI quota ingestion, Phase 2A widens the model catalog to the provider-discovered Azure AI ARM response, and Phase 2B can widen quota rows beyond OpenAI when the dedicated provider-quota gate is explicitly enabled.
Defaults:
- Region preset:
USMajor - Family filters: all families (no restriction by default; set
INGEST_QUOTA_FAMILY_FILTERSto limit scope) - Source type written to SQL:
live-azure-ingest
App settings reference:
Required for deployed dashboard operation:
SESSION_SECRET(required to protect signed session cookie state; keep stable across redeploys unless intentionally rotating sessions)AUTH_ENABLED(trueenables the dashboard Entra sign-in flow; local dev can leave thisfalse)ENTRA_TENANT_ID,ENTRA_CLIENT_ID,ENTRA_CLIENT_SECRET(required whenAUTH_ENABLED=true)AUTH_REDIRECT_URI(OAuth callback URI; defaults to local dev when omitted)INGEST_API_KEY(required only for internal ingestion/bootstrap routes)
Easy Auth hardening mode changes these requirements, but that mode is not yet the default deployment path. In the isolated validation, INGEST_API_KEY_ENABLED=false forced internal diagnostics/bootstrap-style traffic to use Authorization: Bearer instead of the custom x-ingest-key header. Do not remove INGEST_API_KEY from existing environments until bootstrap/deployment automation has a verified bearer-token path.
Entra app registration token configuration:
- The app registration referenced by
ENTRA_CLIENT_IDmust include group Object IDs in the ID token: Token configuration -> Add groups claim -> Security groups -> ID -> Group ID. - After changing group claim configuration or group membership, restart the App Service and have affected users sign out at
/auth/logoutand sign in again so the session captures a fresh token. - If
/api/auth/meshowsadminGroupConfigured: trueandreportViewerGroupConfigured: truebut still returnscanAccessAdmin: falseorisReportViewer: false, verify the token groups claim before changing the group Object IDs.
Recommended access and quota settings:
ADMIN_GROUP_ID(recommended Object ID for Admin sections; if omitted while auth is enabled, admin gating is disabled)REPORT_VIEWER_GROUP_IDS(recommended comma-separated Entra security group Object IDs whose members can view reports; include theCapacityReportViewersgroup Object ID for the current viewer group. If this is configured and a signed-in user is not in one of those groups, the dashboard displays the Access Restricted view.)QUOTA_MANAGEMENT_GROUP_ID(recommended fallback/default management group for live quota discovery and quota admin views)
DB-backed scheduler settings with environment fallback defaults:
INGEST_REGION_PRESET(fallback defaultUSMajor; saved value lives inschedule.ingest.regionPreset)INGEST_SUBSCRIPTION_IDS(fallback explicit subscription list; saved value lives inschedule.ingest.subscriptionIds; omit to auto-discover enabled subscriptions visible to the app identity)INGEST_MANAGEMENT_GROUP_NAMES(fallback management group list; saved value lives inschedule.ingest.managementGroupNames)INGEST_QUOTA_FAMILY_FILTERS(fallback family filter; saved value lives inschedule.ingest.familyFilters; omit or leave empty to ingest all families)INGEST_ON_STARTUP(fallback forschedule.ingest.runOnStartup)INGEST_INTERVAL_MINUTES(fallback forschedule.ingest.intervalMinutes;0disables scheduling)INGEST_AI_MODEL_CATALOG_INTERVAL_MINUTES(fallback forschedule.aiModelCatalog.intervalMinutes; default1440;INGEST_OPENAI_MODEL_CATALOG_INTERVAL_MINUTESremains a backward-compatible alias)
Other optional ingestion runtime settings:
INGEST_SUBSCRIPTION_HASH_SALT(optional salt for masked subscription key hashing; not stored in SQL)
AI rollout gates:
INGEST_AI_ENABLED(outer App Service rollout flag; runtime AI ingestion also requiresingest.ai.enabled=truein SQL;INGEST_OPENAI_ENABLEDremains a backward-compatible alias)INGEST_AI_PROVIDER_QUOTA_ENABLED(outer App Service sub-gate for non-OpenAI AI quota rows; also requiresingest.ai.providerQuota.enabled=truein SQL)INGEST_AI_MODEL_CATALOG(outer App Service sub-gate for model catalog refresh; also requiresingest.ai.modelCatalog.enabled=truein SQL;INGEST_OPENAI_MODEL_CATALOGremains a backward-compatible alias)
Optional worker/recommender settings:
CAPACITY_WORKER_BASE_URL(optional Function App base URL for worker-first live placement execution)CAPACITY_WORKER_AUTH_MODE(shared-secretby default; set toentraonly when Function App Easy Auth and the token audience are configured)CAPACITY_WORKER_TOKEN_AUDIENCE(required whenCAPACITY_WORKER_AUTH_MODE=entra; usually the worker API application ID URI)CAPACITY_WORKER_SHARED_SECRET(required only when the worker enforcesWORKER_SHARED_SECRET)CAPACITY_WORKER_TIMEOUT_MS(optional timeout for worker calls, default60000)CAPACITY_WORKER_DISABLE_LOCAL_FALLBACK(truedisables App Service fallback when the worker is configured but unavailable)GET_AZ_VM_AVAILABILITY_ROOT(optional path to Get-AzVMAvailability repository; required in production if Capacity Recommender uses the external repository and it is not bundled at the default path)
No longer active app settings:
LIVE_PLACEMENT_REFRESH_ON_STARTUP,LIVE_PLACEMENT_REFRESH_INTERVAL_MINUTES, andLIVE_PLACEMENT_REFRESH_REGION_PRESETare obsolete; Capacity Score live placement is on-demand only and the app does not start a scheduled live placement refresh from App Service settings.
Key Vault secrets used by deployed environments:
| Secret name | App setting | Used for |
|---|---|---|
capdash-entra-client-secret |
ENTRA_CLIENT_SECRET |
Microsoft Entra app registration client secret used by the dashboard OAuth sign-in flow. Required when AUTH_ENABLED=true. |
capdash-ingest-api-key |
INGEST_API_KEY |
Shared secret that protects internal ingestion and bootstrap endpoints. Deployment/bootstrap callers send this value as the internal ingest key. |
capdash-session-secret |
SESSION_SECRET |
Secret used by the web app session middleware to protect signed session cookie state after sign-in. Keep stable across redeploys unless intentionally rotating sessions. |
Worker shared-secret deployments also use capdash-worker-shared-secret for CAPACITY_WORKER_SHARED_SECRET and WORKER_SHARED_SECRET. Easy Auth deployments should not require that worker secret after CAPACITY_WORKER_AUTH_MODE=entra is the selected deployment mode and Function App Easy Auth is configured by IaC.
Terraform can generate capdash-ingest-api-key and capdash-session-secret when ingest_api_key and session_secret are omitted. Bicep deployments should supply those values through parameters or the wrapper script. The Entra client secret comes from the customer-created app registration client secret and is stored in Key Vault when supplied.
- Live placement scope is selected in the Capacity Score UI when a user runs
Refresh Live Placement. Results are persisted todbo.LivePlacementSnapshotas the last checked live state for that scope.
DB-backed AI defaults:
ingest.ai.enabledindbo.DashboardSettingdefaults tofalseingest.ai.providerQuota.enabledindbo.DashboardSettingdefaults tofalseingest.ai.modelCatalog.enabledindbo.DashboardSettingdefaults totrueschedule.aiModelCatalog.intervalMinutesindbo.DashboardSettingdefaults to1440(0disables catalog refresh)- Legacy
ingest.openai.*keys remain accepted during the Phase 2A transition for rollback safety INGEST_AI_ENABLEDis the outer rollout flag, but runtime AI quota/catalog ingestion still requires the matching DB gate (ingest.ai.enabled) to be onINGEST_AI_PROVIDER_QUOTA_ENABLEDis an additional Phase 2B sub-gate; widening quota ingestion beyond OpenAI also requiresingest.ai.providerQuota.enabled=truein SQLINGEST_AI_MODEL_CATALOGremains an additional App Service sub-gate for catalog refresh, and catalog refresh also requiresingest.ai.modelCatalog.enabledin SQL
Phase 2A/2B AI note:
- The model catalog now stores provider-discovered Azure AI rows from the tenant-visible ARM catalog, not just OpenAI rows.
- Live verification confirmed Reader RBAC is sufficient for the catalog API and validated xAI plus other providers in this tenant.
- The usages API also returns non-OpenAI
AIServices.*quota rows, but Phase 2B keeps that expansion behind the new provider-quota sub-gate so rollout stays default-off. - Anthropic remains optional/unverified for this tenant because the live catalog did not return Claude rows across the tested regions/API versions.
Runtime note:
- The current Azure App Service host is Windows PowerShell
5.1, which is sufficient for the Node/Express app itself but not sufficient for PowerShell-dependent Azure helpers that rely on newer Az module support. - A dedicated PowerShell
7Azure Function worker is now scaffolded as the preferred execution host for live placement and future quota group write operations. - Until the worker is deployed and configured, the dashboard can still fall back to the local App Service path for rollback safety.
Required database permissions for the app identity:
db_datareader(read dashboard rows)db_datawriter(insert ingestion snapshots)
Internal endpoints:
POST /internal/ingest/capacity(requiresx-ingest-keyheader)GET /internal/ingest/status(requiresx-ingest-keyheader)
Admin UI endpoints:
POST /api/admin/ingest/capacity(same-origin route used by the Admin portal Run Ingest Now action)GET /api/admin/ingest/status(same-origin route used by the Admin portal status banner)GET /api/auth/me(returns App Service auth context and resolved Admin access state)GET /api/quota/management-groups(returns accessible management groups for the Quota Discovery scope picker)
Read APIs for analytics:
GET /api/subscriptions?search=<text>&limit=<n>(subscription catalog for scalable filtering)GET /api/capacity/subscriptions(masked subscription summary)GET /api/capacity/trends?days=7&granularity=daily(daily trend rollup; the React hourly view requestsdays=2&granularity=hourlyfor the trailing 48 hours)GET /api/capacity/families(quota-style family summary)GET /api/ai/quota(latest AI quota rows sourced fromdbo.CapacitySnapshot; supports optionalproviderfiltering once Phase 2B provider quota is enabled)GET /api/ai/quota/providers(distinct providers present in the latest AI quota capture)GET /api/ai/models(latest provider-aware AI model availability rows, includingprovider)GET /api/ai/models/regions(distinct regions in the AI model catalog)
Recommended Phase 1 rollout order:
- Apply the SQL migration chain so the AI settings +
dbo.AIModelAvailabilityobjects exist. - Deploy the web app with
INGEST_AI_ENABLED=falseandINGEST_AI_PROVIDER_QUOTA_ENABLED=false(or keep the legacyINGEST_OPENAI_ENABLED=falsealias). - Validate that existing Compute ingestion and dashboard paths are unchanged.
- Enable
INGEST_AI_ENABLED=truein the target environment when you are ready to allow the DB-backed AI gate to take effect. - Flip
ingest.ai.enabled=trueonly after rollout validation confirms AI ingestion should start in that environment. - Validate catalog refresh plus the existing OpenAI quota slice before widening to other providers.
- Enable
INGEST_AI_PROVIDER_QUOTA_ENABLED=trueonly when you are ready to ingest the verified non-OpenAIAIServices.*quota rows. - Flip
ingest.ai.providerQuota.enabled=trueonly after the environment-level enablement is in place. - Adjust
schedule.aiModelCatalog.intervalMinutesthrough the admin-backed setting path only after the first end-to-end validation. - Treat Anthropic as optional for this tenant until a live catalog refresh actually returns Claude rows.
Example trigger (all families — omit familyFilters or pass empty array):
Invoke-RestMethod -Method Post -Uri "https://<your-app>.azurewebsites.net/internal/ingest/capacity" -Headers @{ "x-ingest-key" = "<ingest-key>" } -Body (@{ regionPreset = "USMajor" } | ConvertTo-Json) -ContentType "application/json"Example trigger (restricted to specific families):
Invoke-RestMethod -Method Post -Uri "https://<your-app>.azurewebsites.net/internal/ingest/capacity" -Headers @{ "x-ingest-key" = "<ingest-key>" } -Body (@{ regionPreset = "USMajor"; familyFilters = @("standard_D","standard_E") } | ConvertTo-Json) -ContentType "application/json"This section documents which tables/views are used by each product area and which APIs are called.
The dashboard calls a small set of Azure Resource Manager APIs directly instead of relying only on Az PowerShell wrappers. All ARM calls use https://management.azure.com with a managed-identity or Azure credential bearer token for the https://management.azure.com/.default scope.
Quick answer for capacity and quota data:
| Dashboard area | API route pattern | Current API version in code | What it provides |
|---|---|---|---|
| Subscription discovery | GET /subscriptions |
2020-01-01 |
Enabled subscriptions visible to the dashboard identity. |
| Management-group subscription discovery | GET /providers/Microsoft.Management/managementGroups/{managementGroupName}/descendants |
2020-05-01 |
Subscriptions under configured ingestion management groups. |
| Compute capacity/quota ingestion | GET /subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{region}/usages |
2024-03-01 |
Regional VM-family quota usage and limits written to dbo.CapacitySnapshot. |
| Compute SKU availability | GET /subscriptions/{subscriptionId}/providers/Microsoft.Compute/skus?$filter=location eq '{region}' |
2024-03-01 |
VM SKUs, families, capabilities, and regional availability used to enrich capacity rows. |
| Live placement score | POST /subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{region}/placementScores/spot/generate |
2025-06-05 |
On-demand placement score results for selected SKUs, regions, subscription, and desired count. |
| Quota management-group discovery | GET /providers/Microsoft.Management/managementGroups |
2023-04-01 |
Management groups visible to the dashboard identity for quota scope selection. |
| Quota group discovery | GET /providers/Microsoft.Management/managementGroups/{managementGroupId}/providers/Microsoft.Quota/groupQuotas |
2025-09-01 |
Quota groups under the selected management group. |
| Quota group subscriptions | GET /providers/Microsoft.Management/managementGroups/{managementGroupId}/providers/Microsoft.Quota/groupQuotas/{groupQuotaName}/subscriptions |
2025-09-01 |
Subscriptions associated with a quota group. |
| Quota allocation/readiness | GET /providers/Microsoft.Management/managementGroups/{managementGroupId}/subscriptions/{subscriptionId}/providers/Microsoft.Quota/groupQuotas/{groupQuotaName}/resourceProviders/Microsoft.Compute/quotaAllocations/{region} |
2025-09-01 |
Per-subscription quota allocation details for Compute families in a region. |
| Quota apply | PATCH /providers/Microsoft.Management/managementGroups/{managementGroupId}/subscriptions/{subscriptionId}/providers/Microsoft.Quota/groupQuotas/{groupQuotaName}/resourceProviders/Microsoft.Compute/quotaAllocations/{region} |
2025-09-01 |
Submits quota allocation changes for approved quota move/apply rows. |
| AI quota ingestion | GET /subscriptions/{subscriptionId}/providers/Microsoft.CognitiveServices/locations/{region}/usages |
2023-05-01 |
Azure OpenAI and optionally broader Azure AI quota rows. |
| AI model catalog | GET /subscriptions/{subscriptionId}/providers/Microsoft.CognitiveServices/locations/{region}/models |
2024-10-01, fallback 2023-05-01 |
Provider-aware model availability catalog by region. |
Use these Learn pages as the canonical references for request shapes, response payloads, and version changes.
- Subscription discovery: https://learn.microsoft.com/en-us/rest/api/resources/subscriptions/list?view=rest-resources-2022-12-01
- Subscription locations: https://learn.microsoft.com/en-us/rest/api/resources/subscriptions/list-locations?view=rest-resources-2022-12-01
- Compute SKUs: https://learn.microsoft.com/en-us/rest/api/compute/resource-skus/list?view=rest-compute-2025-04-01
- Compute usage/quotas: https://learn.microsoft.com/en-us/rest/api/compute/usage/list?view=rest-compute-2025-04-01
- Advisor recommendations: https://learn.microsoft.com/en-us/rest/api/advisor/recommendations/list?view=rest-advisor-2025-01-01
- Subscription price sheet: https://learn.microsoft.com/en-us/rest/api/consumption/price-sheet/get?view=rest-consumption-2024-08-01
- Spot placement score cmdlet reference: https://learn.microsoft.com/en-us/powershell/module/az.compute/invoke-azspotplacementscore?view=azps-15.5.0
Note:
The Capacity Score direct placement-score path currently uses the same underlying placement-score contract surfaced by Invoke-AzSpotPlacementScore, but we did not find a public Learn REST page for the Microsoft.Compute/locations/{location}/placementScores/spot/generate ARM operation. Treat the Az.Compute cmdlet page above as the public reference for that contract until Microsoft publishes a REST Learn page.
Live placement score interpretation:
- The dashboard calls
POST /subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{anchorRegion}/placementScores/spot/generate?api-version=2025-06-05withdesiredLocations,desiredSizes, anddesiredCount. - Azure returns a placement score such as
High,Medium,Low,RestrictedSkuNotAvailable, orSkuNotAvailable, plus fields such asisQuotaAvailablewhen available. High,Medium, andLoware Azure placement confidence signals for that exact subscription, SKU, region, and desired VM count. They are not capacity reservations and do not guarantee that a later deployment will succeed.isQuotaAvailable: trueonly means the quota signal for the request is available. A row can still returnRestrictedSkuNotAvailable, which means quota and placement are separate signals.- Customer-safe wording: use this as a placement-risk indicator for planning, then validate with an actual deployment request for the final SKU, region, zone, count, policy, network, disk, and availability settings.
- To inspect the raw REST payload before changing dashboard behavior, run
tools/Test-CapacityScorePlacementRestCall.ps1from an authenticated Az PowerShell session.
- Management groups list: https://learn.microsoft.com/en-us/rest/api/managementgroups/management-groups/list?view=rest-managementgroups-2020-05-01
- Management group subscriptions: https://learn.microsoft.com/en-us/rest/api/managementgroups/management-group-subscriptions/get-subscriptions-under-management-group?view=rest-managementgroups-2020-05-01
- Management entities list: https://learn.microsoft.com/en-us/rest/api/managementgroups/entities/list?view=rest-managementgroups-2020-05-01
- Group quotas list: https://learn.microsoft.com/en-us/rest/api/quota/group-quotas/list?view=rest-quota-2025-09-01
- Group quota subscriptions list: https://learn.microsoft.com/en-us/rest/api/quota/group-quota-subscriptions/list?view=rest-quota-2025-09-01
- Group quota subscription allocation list: https://learn.microsoft.com/en-us/rest/api/quota/group-quota-subscription-allocation/list?view=rest-quota-2025-09-01
- Azure AI usages: https://learn.microsoft.com/en-us/rest/api/aiservices/accountmanagement/usages/list?view=rest-aiservices-accountmanagement-2024-10-01
- Azure AI models: https://learn.microsoft.com/en-us/rest/api/aiservices/accountmanagement/models/list?view=rest-aiservices-accountmanagement-2024-10-01
snapshotIdBIGINT IDENTITY(PK)capturedAtUtcDATETIME2sourceTypeNVARCHAR(50)subscriptionKeyNVARCHAR(64)subscriptionIdNVARCHAR(64)subscriptionNameNVARCHAR(256)regionNVARCHAR(64)skuNameNVARCHAR(128)skuFamilyNVARCHAR(128)vCpuINTmemoryGBDECIMAL(10,2)zonesCsvNVARCHAR(256)availabilityStateNVARCHAR(32)quotaCurrentINTquotaLimitINTmonthlyCostEstimateDECIMAL(18,2)
Purpose:
- Append-only snapshot history written by live ingestion.
- Trend APIs query this table directly.
Definition:
CREATE OR ALTER VIEWoverdbo.CapacitySnapshot.- Uses
ROW_NUMBER()partitioned byISNULL(subscriptionKey,'legacy-data'), region, skuNameand keepsrn = 1.
Columns exposed:
capturedAtUtc,subscriptionKey,subscriptionId,subscriptionName,region,skuName,skuFamily,vCpu,memoryGB,zonesCsv,availabilityState,quotaCurrent,quotaLimit,monthlyCostEstimate.
Purpose:
- Current-state reporting for grid/filter/subscription/family summary endpoints.
candidateIdBIGINT IDENTITY(PK)analysisRunIdUNIQUEIDENTIFIERcapturedAtUtcDATETIME2sourceCapturedAtUtcDATETIME2managementGroupIdNVARCHAR(128)groupQuotaNameNVARCHAR(128)subscriptionIdNVARCHAR(64)subscriptionNameNVARCHAR(256)regionNVARCHAR(64)quotaNameNVARCHAR(128)availabilityStateNVARCHAR(32)quotaCurrentINTquotaLimitINTquotaAvailableINTsuggestedMovableINTsafetyBufferINTsubscriptionHashNVARCHAR(128)candidateStatusNVARCHAR(32)
Status:
- Table exists in schema and is now written by the Admin
Capture Historyflow for read-only candidate analysis runs.
requestLogIdBIGINT IDENTITY(PK)createdAtUtcDATETIME2requestedByNVARCHAR(256)operationIdNVARCHAR(128)stateNVARCHAR(64)payloadJsonNVARCHAR(MAX)resultJsonNVARCHAR(MAX)
Status:
- Table exists in schema, but quota apply orchestration endpoints are not yet implemented.
Primary app APIs:
GET /api/capacityGET /api/subscriptionsGET /api/capacity/familiesGET /api/capacity/scoresGET /api/capacity/scores/historyGET /api/capacity/subscriptionsGET /api/capacity/trends
Data sources:
dbo.CapacityLatestfor current grid/subscription/family reporting.dbo.CapacitySnapshotfor trend rollups.dbo.CapacityScoreSnapshotfor historical regional SKU High/Medium/Low score snapshots captured during ingestion.
Key query behavior:
- Shared filters: region preset, region, resource type, family, availability, subscription IDs.
- Subscription filter is applied against
ISNULL(subscriptionId, 'legacy-data'). - Subscription selection in the reporting UI is rendered as a checkbox list (not multi-select highlight), and all loaded subscriptions are auto-selected on first load.
- Resource Type filter (
Compute/Disk/Other/All) controls which families appear in the SKU Family dropdown. Defaults toComputeon load. Changing it resets the family selection and updates the grid. - SKU Family has a live search text input above it; typing filters the dropdown options in real time to matching formatted labels or raw family values. The search resets when Resource Type changes.
SKU Familydropdown options are entirely data-driven fromdbo.CapacityLatest.skuFamily; there are no hardcoded pinned families. Family labels are formatted for readability (Standard_Dasv7instead ofStandardDasv7Family).- Region presets such as
US CommercialandCommercial Americasact as a first-stage scope. TheRegiondropdown stays enabled for those presets so you can leave it atAllor further narrow to one member region inside the preset. - When a family that has a representative SKU mapping (defined in
FAMILY_EXTRA_SKU_MAP) is selected,Refresh Live Placementautomatically injects those SKUs into the live placement request. GET /api/capacity/scoresremains a derived current-state dashboard score fromdbo.CapacityLatest. The Score History table has been removed from the UI; persisted score snapshots remain indbo.CapacityScoreSnapshotfor backend use.GET /api/capacity/familiesin the reporting UX is intentionally requested withfamily=allso the Family Summary report remains populated even when the grid is currently scoped to a specific family.- Summary KPI cards are report-aware: Region Matrix shows family/region readiness metrics, while Capacity Grid and other views keep row/quota/cost totals.
- On the Capacity Grid, the KPI cards use the full filtered result set, not only the currently visible page. Example:
Constrained Rowsreflects all filtered constrained rows across pagination. - The High/Medium/Low dashboard score is intentionally separate from the live Azure Placement Score API used by
Get-AzVMAvailability. - Capacity Ingest updates saved capacity/quota snapshot data in
dbo.CapacityLatest; the Capacity Score report no longer shows a separateSnapshot Scorecolumn because the user-facing workflow is focused on live placement validation. Refresh Live Placementdoes not create capacity/quota snapshot rows. It only updatesdbo.LivePlacementSnapshot, which feedsAzure Live Score,Checked, and live placement state.Desired Placement Countin theCapacity Scoreview only affects the on-demandRefresh Live Placementaction.- The value is passed through to
Get-AzVMAvailabilityasDesiredCount, which tells Azure placement scoring how many VMs you want to place at once. Example:1asks "can I likely place one VM here?" while5asks for the likelihood of placing five VMs together. - The live placement UI clamps
Desired Placement Countto1000. If a larger number is entered, the refresh status line reports the requested value and the effective value sent to the live placement API. - Increasing
Desired Placement Countraises the bar for aHighlive placement result, because the placement API is evaluating a larger simultaneous allocation request. Desired Placement Countdoes not change the persisted dashboard score history indbo.CapacityScoreSnapshot.- Live placement refreshes now persist snapshot rows to
dbo.LivePlacementSnapshotfor the effective desired count used by the refresh. The Capacity Score grid auto-hydrates from SQL snapshots for the currently selected desired count. - Some rows can come from live placement validation or catalog expansion even when they were not present in the latest capacity ingest result for that exact SKU and region.
- In the Quota Workbench allocation report, rows are driven by subscriptions whose raw Azure GroupQuota
shareableQuotais negative; the UI displays the absolute value in theQuota Groupcolumn and shows the current allocation inAssigned quota.
Admin UI APIs:
POST /api/admin/ingest/capacityPOST /api/admin/ingest/smoke-testGET /api/admin/ingest/statusGET /api/admin/ingest/schedulePUT /api/admin/ingest/schedule
Protected internal APIs:
POST /internal/ingest/capacityGET /internal/ingest/statusPOST /internal/db/ensure-phase3-schema
Current UI behavior:
Refresh Subscriptionsrefreshes the subscription catalog and updates the inline status banner.- Capacity ingestion now persists both raw
dbo.CapacitySnapshotrows and aggregateddbo.CapacityScoreSnapshothistory for the same captured timestamp. Run Capacity Ingestionstarts a live ingestion run through the app server, updates button/status state, and refreshes report data after completion.Validate Ingest Scoperesolves the saved SQL scheduler scope through the app server identity and reports subscription, region, management group, and family-filter counts without writing capacity snapshot rows.Scheduler Scopecards are read-only in this version. They show the configured Capacity Ingest scope and clarify that Capacity Score Live is on-demand only.
External Azure APIs called by ingestion:
GET https://management.azure.com/subscriptions?api-version=2020-01-01- Enumerates accessible subscriptions and resolves display names.
GET https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/locations/{region}/usages?api-version=2024-03-01- Reads Compute quota usage values (
currentValue,limit).
- Reads Compute quota usage values (
GET https://management.azure.com/subscriptions/{subscriptionId}/providers/Microsoft.Compute/skus?$filter=location eq '{region}'&api-version=2024-03-01- Reads SKU capabilities and zone metadata for representative rows.
Write target:
INSERTintodbo.CapacitySnapshot(one row per family/region/subscription observation).
Current API state:
GET /api/quota/groupslists live GroupQuota resources forQUOTA_MANAGEMENT_GROUP_IDand includes associated subscription IDs.GET /api/quota/management-groupslists accessible management groups so the Admin UI can select the discovery scope before loading quota groups.GET /api/quota/candidatesgenerates read-only quota candidate rows for the selected management group and quota group using current capacity data.POST /api/quota/candidates/capturepersists the current candidate run intodbo.QuotaCandidateSnapshotwith run metadata and source timestamps.GET /api/quota/candidate-runslists capturedanalysisRunIdhistory for the selected management group, quota group, and optional region/family filters.GET /api/quota/planbuilds a read-only move plan from the selected captureddbo.QuotaCandidateSnapshotrun.POST /api/quota/simulatecomputes projected donor/recipient quota availability after the proposed plan without writing to Azure.
Planned data/API direction:
- Discover group quotas from Microsoft.Quota APIs.
- Extend candidate analytics with configurable thresholds and report views over captured runs.
Current API state:
GET /api/quota/candidate-runslets the Admin UI choose which capturedanalysisRunIdto use for planning.GET /api/quota/planpowersBuild Move Planas a read-only workflow sourced from the selected captured candidate run in SQL.POST /api/quota/simulatepowersSimulate Impactas a read-only projection over the selected captured run and proposed plan.Apply Movementsis still a frontend placeholder; backend write routes are not yet implemented.
Execution prerequisite:
- When quota group move/apply execution is implemented, do not assume the App Service default shell is enough. The runtime will need PowerShell
7and the relevant Az/Quota modules or API-capable helper tooling available on the executing host.
Planned data/API direction:
- Execute quota apply/simulate workflows.
- Persist audit trail into
dbo.QuotaApplyRequestLog.
- Dashboard app uses App Service managed identity for:
- Azure ARM ingestion reads.
- Azure SQL access (AAD MSI auth mode).
- Required RBAC for each ingested subscription:
- At minimum, permission to read
Microsoft.Compute/locations/usagesand SKU metadata (Reader role at subscription scope is sufficient for current read APIs).
- At minimum, permission to read
- Internal ingestion APIs are gated by
INGEST_API_KEY. - Admin access is enforced by the dashboard auth middleware using the signed-in user's Entra group claims. Set
ADMIN_GROUP_IDto the Object ID of the Entra security group allowed to see Admin sections and call admin-only APIs.
Manual script prerequisites:
- Azure CLI authenticated to the correct subscription when you are targeting Azure resources.
sqlcmdinstalled when using./scripts/apply-schema.ps1,./scripts/apply-migration.ps1, or./scripts/load-sample-data.ps1directly.- Network path to the SQL server. For private SQL environments, prefer the App Service bootstrap route instead of running these scripts from a machine without SQL connectivity.
To add masked subscription-key support to existing databases, run:
./scripts/apply-migration.ps1 \
-SqlServer "<server>.database.windows.net" \
-SqlDatabase "<database>" \
-MigrationFile "./sql/migrations/20260410-add-subscriptionkey.sql" \
-UseEntra \
-EntraUser "<entra-upn>"To add subscription id/name + SKU metadata columns used by charts and family summary, run:
./scripts/apply-migration.ps1 \
-SqlServer "<server>.database.windows.net" \
-SqlDatabase "<database>" \
-MigrationFile "./sql/migrations/20260410-add-subscription-columns-and-sku-metadata.sql" \
-UseEntra \
-EntraUser "<entra-upn>"
