diff --git a/README.md b/README.md index d88dd88..03b49e9 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,45 @@ Both features are **gated** — if a status stays `Pending` for more than a few ./scripts/register-providers.ps1 -SubscriptionId ``` +### Run the all-in-one quickstart on macOS + +Install PowerShell 7, Azure CLI, and Python 3, then run the same cross-platform script from a `pwsh` terminal: + +```bash +brew install --cask powershell +brew install azure-cli python + +az login +pwsh -File ./scripts/quickstart.ps1 \ + -SubscriptionId \ + -ResourceGroup +``` + +The script accepts the same parameters on Windows and macOS. It creates an isolated Python virtual environment and runs the demo with Azure AD authentication; no API key is required. + +### Required Azure access + +The simplest option is to give the deploying customer **Owner** on the target subscription for the deployment window. Owner covers resource-group creation, provider/feature registration, resource deployment, and the role assignment created by this template. Remove or reduce that access after validation. + +For a least-privilege customer deployment, split the work: + +1. A subscription administrator registers `Microsoft.Storage`, `Microsoft.CognitiveServices`, and the gated `Microsoft.CognitiveServices/OpenAI.ContextCacheAllowed` feature, and creates the target resource group. +2. At the target **resource-group scope**, grant the customer **Contributor** plus **Role Based Access Control Administrator**. +3. The customer runs `quickstart.ps1 -SkipPrerequisiteRegistration` against that existing resource group. This avoids subscription-level checks that a resource-group-scoped identity may not be allowed to read. + +The two resource-group roles are both required by the current template: + +| Role | Why it is needed | +|---|---| +| **Contributor** | Creates the Azure OpenAI account/deployment, Context Cache account/container, and ARM deployment record. | +| **Role Based Access Control Administrator** | Creates the `Cognitive Services OpenAI User` role assignment used by the keyless demo. `Contributor` explicitly cannot create role assignments. | + +Provider registration requires the provider's `/register/action` permission at subscription scope; built-in **Contributor** and **Owner** include it. Preview-feature registration is also subscription-scoped and may additionally require Microsoft allow-list approval. Email **azurecontextcacherp@microsoft.com** if `OpenAI.ContextCacheAllowed` remains `Pending`. + +After deployment, a person or workload calling the model with Microsoft Entra ID needs **Cognitive Services OpenAI User** on the Azure OpenAI account. The template grants it automatically to the deploying user when it creates a new account; the quickstart attempts the same grant when reusing an existing account. + +RBAC alone does not guarantee deployment success. Confirm that the subscription has Azure OpenAI S0/model capacity for the selected region and is approved for the Context Cache preview before the customer session. + --- ## What gets created diff --git a/azuredeploy.json b/azuredeploy.json index 8a2848e..f63c957 100644 --- a/azuredeploy.json +++ b/azuredeploy.json @@ -110,25 +110,62 @@ } }, { - "comments": "4. AOAI deployment linked to the cache container via contextCacheContainerId.", - "type": "Microsoft.CognitiveServices/accounts/deployments", - "apiVersion": "2026-03-15-preview", - "name": "[concat(variables('aoaiAccountName'), '/', variables('aoaiDeploymentName'))]", + "comments": "4. AOAI deployment linked to the cache container via contextCacheContainerId. This MUST live in a nested deployment: ARM runs RP preflight validation for every resource in a template up-front, before any resource is created. The Cognitive Services RP resolves contextCacheContainerId during preflight, so a top-level deployment resource fails with 'The context cache container could not be found or accessed' even though dependsOn is set. Nesting alone is not enough - if every nested parameter is a compile-time constant, ARM expands and preflights the nested template together with the parent. Deriving the container ID from a runtime reference() forces ARM to defer the nested deployment (and its preflight) until the container actually exists. Note reference(...,'Full').resourceId returns a provider-relative ID, so it is prefixed with resourceGroup().id to form the fully qualified ARM resource ID the RP requires.", + "type": "Microsoft.Resources/deployments", + "apiVersion": "2022-09-01", + "name": "aoaiCacheDeployment", "dependsOn": [ "[resourceId('Microsoft.CognitiveServices/accounts', variables('aoaiAccountName'))]", "[resourceId('Microsoft.Storage/contextCaches/contextCacheContainers', variables('cacheAccountName'), variables('cacheContainerName'))]" ], - "sku": { - "name": "[variables('skuName')]", - "capacity": "[variables('skuCapacity')]" - }, "properties": { - "model": { - "format": "[variables('modelFormat')]", - "name": "[variables('modelName')]", - "version": "[variables('modelVersion')]" + "mode": "Incremental", + "expressionEvaluationOptions": { + "scope": "Inner" + }, + "parameters": { + "aoaiAccountName": { "value": "[variables('aoaiAccountName')]" }, + "aoaiDeploymentName": { "value": "[variables('aoaiDeploymentName')]" }, + "skuName": { "value": "[variables('skuName')]" }, + "skuCapacity": { "value": "[variables('skuCapacity')]" }, + "modelFormat": { "value": "[variables('modelFormat')]" }, + "modelName": { "value": "[variables('modelName')]" }, + "modelVersion": { "value": "[variables('modelVersion')]" }, + "contextCacheContainerId": { "value": "[format('{0}/providers/{1}', resourceGroup().id, reference(resourceId('Microsoft.Storage/contextCaches/contextCacheContainers', variables('cacheAccountName'), variables('cacheContainerName')), '2026-01-01-preview', 'Full').resourceId)]" } }, - "contextCacheContainerId": "[variables('containerResourceId')]" + "template": { + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "aoaiAccountName": { "type": "string" }, + "aoaiDeploymentName": { "type": "string" }, + "skuName": { "type": "string" }, + "skuCapacity": { "type": "int" }, + "modelFormat": { "type": "string" }, + "modelName": { "type": "string" }, + "modelVersion": { "type": "string" }, + "contextCacheContainerId": { "type": "string" } + }, + "resources": [ + { + "type": "Microsoft.CognitiveServices/accounts/deployments", + "apiVersion": "2026-03-15-preview", + "name": "[concat(parameters('aoaiAccountName'), '/', parameters('aoaiDeploymentName'))]", + "sku": { + "name": "[parameters('skuName')]", + "capacity": "[parameters('skuCapacity')]" + }, + "properties": { + "model": { + "format": "[parameters('modelFormat')]", + "name": "[parameters('modelName')]", + "version": "[parameters('modelVersion')]" + }, + "contextCacheContainerId": "[parameters('contextCacheContainerId')]" + } + } + ] + } } }, { diff --git a/bicep/main.bicep b/bicep/main.bicep index 6333bd4..a12b35b 100644 --- a/bicep/main.bicep +++ b/bicep/main.bicep @@ -66,24 +66,33 @@ resource cacheContainer 'Microsoft.Storage/contextCaches/contextCacheContainers@ } } -resource aoaiDeployment 'Microsoft.CognitiveServices/accounts/deployments@2026-03-15-preview' = { - parent: aoai - name: aoaiDeploymentName +// The cache-linked deployment MUST go through a nested deployment (module). ARM runs RP +// preflight validation for every resource in a template up-front, before any resource is +// created. The Cognitive Services RP resolves contextCacheContainerId during preflight, so a +// top-level deployment resource fails with 'The context cache container could not be found or +// accessed' even though dependsOn is set. +// +// Nesting alone is NOT enough: if every module parameter is a compile-time constant, ARM expands +// and preflights the nested template together with the parent. Deriving the container ID from a +// runtime reference() forces ARM to defer the nested deployment (and its preflight) until the +// container actually exists. reference(...,'Full').resourceId returns a provider-relative ID, so +// it is prefixed with the resource group ID to form the fully qualified ARM resource ID the RP +// requires. +module aoaiDeployment 'modules/aoai-cache-deployment.bicep' = { + name: 'aoaiCacheDeployment' + params: { + aoaiAccountName: aoaiAccountName + aoaiDeploymentName: aoaiDeploymentName + modelName: modelName + modelVersion: modelVersion + // Intentional raw reference(): a symbolic .id is a compile-time constant, which would let ARM + // preflight this module together with the parent and fail before the container exists. + #disable-next-line use-resource-symbol-reference + contextCacheContainerId: '${resourceGroup().id}/providers/${reference(cacheContainer.id, '2026-01-01-preview', 'Full').resourceId}' + } dependsOn: [ aoaiNew ] - sku: { - name: 'Standard' - capacity: 100 - } - properties: { - model: { - format: 'OpenAI' - name: modelName - version: modelVersion - } - contextCacheContainerId: cacheContainer.id - } } resource openAiUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (assignRole) { @@ -101,7 +110,7 @@ resource openAiUserRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = i output azureOpenAIAccountName string = aoaiAccountName output azureOpenAIEndpoint string = createAoai ? aoaiNew.properties.endpoint : aoai.properties.endpoint -output aoaiDeploymentName string = aoaiDeployment.name +output aoaiDeploymentName string = aoaiDeploymentName output contextCacheAccountName string = cacheAccount.name output contextCacheContainerId string = cacheContainer.id output modelName string = modelName diff --git a/bicep/modules/aoai-cache-deployment.bicep b/bicep/modules/aoai-cache-deployment.bicep new file mode 100644 index 0000000..041797e --- /dev/null +++ b/bicep/modules/aoai-cache-deployment.bicep @@ -0,0 +1,43 @@ +@description('Name of the Azure OpenAI (Cognitive Services) account that hosts the deployment.') +param aoaiAccountName string + +@description('Name of the model deployment to create on the AOAI account.') +param aoaiDeploymentName string + +@description('Model name to deploy.') +param modelName string + +@description('Model version to deploy.') +param modelVersion string + +@description('Resource ID of the context cache container to link the deployment to.') +param contextCacheContainerId string + +@description('Deployment SKU name.') +param skuName string = 'Standard' + +@description('Deployment SKU capacity.') +param skuCapacity int = 100 + +resource aoai 'Microsoft.CognitiveServices/accounts@2024-10-01' existing = { + name: aoaiAccountName +} + +resource aoaiDeployment 'Microsoft.CognitiveServices/accounts/deployments@2026-03-15-preview' = { + parent: aoai + name: aoaiDeploymentName + sku: { + name: skuName + capacity: skuCapacity + } + properties: { + model: { + format: 'OpenAI' + name: modelName + version: modelVersion + } + contextCacheContainerId: contextCacheContainerId + } +} + +output deploymentName string = aoaiDeployment.name diff --git a/scripts/quickstart.ps1 b/scripts/quickstart.ps1 index aaf548b..1afc8ff 100644 --- a/scripts/quickstart.ps1 +++ b/scripts/quickstart.ps1 @@ -41,6 +41,10 @@ .PARAMETER SkipPython Skip the auto venv + pip install step (assumes you already activated an env with the demo deps). +.PARAMETER SkipPrerequisiteRegistration + Skip subscription-level provider and preview-feature checks. Use only after a subscription + administrator has confirmed that all prerequisites are registered. + .EXAMPLE ./quickstart.ps1 @@ -61,7 +65,8 @@ param( [string] $ExistingAoaiAccountName, [int] $Runs = 6, [switch] $SkipDemo, - [switch] $SkipPython + [switch] $SkipPython, + [switch] $SkipPrerequisiteRegistration ) $ErrorActionPreference = 'Stop' @@ -166,66 +171,70 @@ if ($NamePrefix -and ($NamePrefix -notmatch '^[a-z0-9]{3,12}$')) { # ----- 5. Provider + feature registration ----- Write-Step "Validating RP + preview-feature registration" -$checks = @( - @{ Kind='provider'; Namespace='Microsoft.Storage'; Name=$null } - @{ Kind='provider'; Namespace='Microsoft.CognitiveServices'; Name=$null } - @{ Kind='feature'; Namespace='Microsoft.CognitiveServices'; Name='OpenAI.ContextCacheAllowed' } -) - -function Get-RegState($c) { - if ($c.Kind -eq 'provider') { - return az provider show --namespace $c.Namespace --query registrationState -o tsv 2>$null - } - return az feature show --namespace $c.Namespace --name $c.Name --query properties.state -o tsv 2>$null -} +if ($SkipPrerequisiteRegistration) { + Write-Warn "Skipping subscription-level registration checks; assuming an administrator completed them." +} else { + $checks = @( + @{ Kind='provider'; Namespace='Microsoft.Storage'; Name=$null } + @{ Kind='provider'; Namespace='Microsoft.CognitiveServices'; Name=$null } + @{ Kind='feature'; Namespace='Microsoft.CognitiveServices'; Name='OpenAI.ContextCacheAllowed' } + ) -$toRegister = @() -foreach ($c in $checks) { - $state = Get-RegState $c - $label = if ($c.Kind -eq 'provider') { "provider $($c.Namespace)" } else { "feature $($c.Namespace)/$($c.Name)" } - if ($state -eq 'Registered') { - Write-Ok ("{0,-65} {1}" -f $label, $state) - } else { - Write-Warn ("{0,-65} {1}" -f $label, $(if ($state) { $state } else { 'NotRegistered' })) - $toRegister += $c + function Get-RegState($c) { + if ($c.Kind -eq 'provider') { + return az provider show --namespace $c.Namespace --query registrationState -o tsv 2>$null + } + return az feature show --namespace $c.Namespace --name $c.Name --query properties.state -o tsv 2>$null } -} -if ($toRegister.Count -gt 0) { - Write-Info "Registering missing items..." - foreach ($c in $toRegister) { - if ($c.Kind -eq 'provider') { - az provider register --namespace $c.Namespace | Out-Null + $toRegister = @() + foreach ($c in $checks) { + $state = Get-RegState $c + $label = if ($c.Kind -eq 'provider') { "provider $($c.Namespace)" } else { "feature $($c.Namespace)/$($c.Name)" } + if ($state -eq 'Registered') { + Write-Ok ("{0,-65} {1}" -f $label, $state) } else { - az feature register --namespace $c.Namespace --name $c.Name | Out-Null + Write-Warn ("{0,-65} {1}" -f $label, $(if ($state) { $state } else { 'NotRegistered' })) + $toRegister += $c } } - Write-Info "Waiting for all to reach 'Registered' (up to 10 min, refreshes every 20s)..." - $deadline = (Get-Date).AddMinutes(10) - do { - Start-Sleep -Seconds 20 - $stillPending = @() + + if ($toRegister.Count -gt 0) { + Write-Info "Registering missing items..." foreach ($c in $toRegister) { - $state = Get-RegState $c - $label = if ($c.Kind -eq 'provider') { "provider $($c.Namespace)" } else { "feature $($c.Namespace)/$($c.Name)" } - if ($state -ne 'Registered') { - $stillPending += "$label = $state" + if ($c.Kind -eq 'provider') { + az provider register --namespace $c.Namespace | Out-Null + } else { + az feature register --namespace $c.Namespace --name $c.Name | Out-Null } } - if ($stillPending.Count -eq 0) { break } - Write-Info ("[{0}] still pending: {1}" -f (Get-Date -Format HH:mm:ss), ($stillPending -join '; ')) - } while ((Get-Date) -lt $deadline) + Write-Info "Waiting for all to reach 'Registered' (up to 10 min, refreshes every 20s)..." + $deadline = (Get-Date).AddMinutes(10) + do { + Start-Sleep -Seconds 20 + $stillPending = @() + foreach ($c in $toRegister) { + $state = Get-RegState $c + $label = if ($c.Kind -eq 'provider') { "provider $($c.Namespace)" } else { "feature $($c.Namespace)/$($c.Name)" } + if ($state -ne 'Registered') { + $stillPending += "$label = $state" + } + } + if ($stillPending.Count -eq 0) { break } + Write-Info ("[{0}] still pending: {1}" -f (Get-Date -Format HH:mm:ss), ($stillPending -join '; ')) + } while ((Get-Date) -lt $deadline) - $finalPending = @() - foreach ($c in $toRegister) { - if ((Get-RegState $c) -ne 'Registered') { $finalPending += $c } - } - if ($finalPending.Count -gt 0) { - Write-Warn "Some items did not reach 'Registered' in time. Gated preview features may require allow-listing - email azurecontextcacherp@microsoft.com." - $cont = Read-NonEmpty "Continue with deployment anyway? (y/N)" 'N' - if ($cont -notmatch '^[Yy]') { throw "Aborted due to incomplete registration." } - } else { - Write-Ok "All providers and features are Registered." + $finalPending = @() + foreach ($c in $toRegister) { + if ((Get-RegState $c) -ne 'Registered') { $finalPending += $c } + } + if ($finalPending.Count -gt 0) { + Write-Warn "Some items did not reach 'Registered' in time. Gated preview features may require allow-listing - email azurecontextcacherp@microsoft.com." + $cont = Read-NonEmpty "Continue with deployment anyway? (y/N)" 'N' + if ($cont -notmatch '^[Yy]') { throw "Aborted due to incomplete registration." } + } else { + Write-Ok "All providers and features are Registered." + } } } @@ -302,10 +311,15 @@ if (-not (Test-Path (Join-Path $demoDir 'code_reviewer_demo.py'))) { Write-Step "Running keyless demo (DefaultAzureCredential, $Runs iterations)" -if (-not (Get-Command python -ErrorAction SilentlyContinue)) { - Write-Warn "python not in PATH; skipping demo. Install Python 3.10+ to run it." +$pythonCommand = Get-Command python3 -ErrorAction SilentlyContinue +if (-not $pythonCommand) { + $pythonCommand = Get-Command python -ErrorAction SilentlyContinue +} +if (-not $pythonCommand) { + Write-Warn "python3/python not in PATH; skipping demo. Install Python 3.10+ to run it." return } +$demoPython = $pythonCommand.Source Push-Location $demoDir try { @@ -313,12 +327,21 @@ try { $venv = Join-Path $demoDir '.venv' if (-not (Test-Path $venv)) { Write-Info "Creating venv at .venv ..." - python -m venv .venv + & $demoPython -m venv $venv + } + # Probe both layouts instead of branching on $IsWindows: that variable only exists in + # PowerShell 6+, so under Windows PowerShell 5.1 it is $null and the Linux path wins. + $venvPythonCandidates = @( + (Join-Path $venv (Join-Path 'Scripts' 'python.exe')) + (Join-Path $venv (Join-Path 'bin' 'python3')) + (Join-Path $venv (Join-Path 'bin' 'python')) + ) + $demoPython = $venvPythonCandidates | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $demoPython) { + throw "Virtual-environment Python not found. Looked for: $($venvPythonCandidates -join ', '). Remove '$venv' and re-run." } - $activate = Join-Path $venv 'Scripts\Activate.ps1' - . $activate Write-Info "Installing demo requirements (quiet)..." - pip install -q -r requirements.txt + & $demoPython -m pip install -q -r requirements.txt } $env:AOAI_ENDPOINT = $endpoint @@ -326,7 +349,7 @@ try { Remove-Item Env:AOAI_API_KEY -ErrorAction SilentlyContinue Write-Host "" - python code_reviewer_demo.py --aad --runs $Runs + & $demoPython code_reviewer_demo.py --aad --runs $Runs $demoExit = $LASTEXITCODE } finally { Pop-Location