diff --git a/README.md b/README.md index 2d4b312..a1da6cf 100644 --- a/README.md +++ b/README.md @@ -178,6 +178,12 @@ Azure Kubernetes Service with AMD SEV-SNP confidential computing: Microsoft Azure Attestation (MAA) provider management: - **createPrivateMAA.ps1** - Create private Azure Attestation provider for custom attestation policies +### [Service Bus Examples](service-bus-examples/README.md) +Deploy a confidential-computing-enabled Azure Service Bus Premium namespace, send +three sample JSON messages, and verify them on read-back using passwordless Entra +authentication. Tries Korea Central first, then UAE North on regional +availability failures. + ### Utility Snippets Reusable PowerShell snippets for common confidential computing tasks: - `snippet-createDES.ps1` - Create Disk Encryption Set for Confidential VMs diff --git a/service-bus-examples/.gitignore b/service-bus-examples/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/service-bus-examples/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/service-bus-examples/Deploy-ServiceBus.ps1 b/service-bus-examples/Deploy-ServiceBus.ps1 new file mode 100644 index 0000000..af8389d --- /dev/null +++ b/service-bus-examples/Deploy-ServiceBus.ps1 @@ -0,0 +1,144 @@ +#Requires -Version 7.0 +#Requires -Modules Az.Accounts, Az.Resources +[CmdletBinding()] +param( + [string]$SubscriptionId, + [ValidatePattern('^[a-zA-Z0-9._()-]+$')] + [string]$ResourceGroupName = "rg-servicebus-confidential-demo-$([guid]::NewGuid().ToString('N').Substring(0, 8))", + [ValidatePattern('^[a-z][a-z0-9-]{4,29}$')] + [string]$NamespacePrefix = "sbcc-$([guid]::NewGuid().ToString('N').Substring(0, 12))", + [ValidatePattern('^[a-zA-Z0-9-]+$')] + [string]$QueueName = "sample-$([guid]::NewGuid().ToString('N').Substring(0, 8))", + [ValidateNotNullOrEmpty()] + [string[]]$Locations = @('koreacentral', 'uaenorth'), + [string]$PrincipalId, + [ValidateSet('User', 'ServicePrincipal')] + [string]$PrincipalType = 'User', + [switch]$SkipMessages +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'DeploymentHelpers.ps1') + +if (-not (Get-AzContext)) { + throw 'Sign in with Connect-AzAccount before running this script.' +} +if ($SubscriptionId) { + $null = Set-AzContext -SubscriptionId $SubscriptionId +} +$context = Get-AzContext +if ($context.Environment.Name -ne 'AzureCloud') { + throw 'This preview sample targets Azure public cloud only.' +} +if (-not $SkipMessages) { + $null = Get-Command node -ErrorAction Stop + if (-not (Test-Path (Join-Path $PSScriptRoot 'node_modules\@azure\service-bus'))) { + throw "Run npm ci in $PSScriptRoot before deploying." + } +} +if (-not $PrincipalId) { + if ($PrincipalType -ne 'User') { + throw 'Pass -PrincipalId when using -PrincipalType ServicePrincipal.' + } + $PrincipalId = (Get-AzADUser -SignedIn).Id + if (-not $PrincipalId) { + throw 'Cannot resolve the signed-in user. For automation, pass -PrincipalId and -PrincipalType ServicePrincipal.' + } +} +$null = [guid]::Parse($PrincipalId) +foreach ($location in $Locations) { + if ($location -notmatch '^[a-z0-9]+$') { + throw "Use Azure location codes, not display names: $location" + } +} +Write-Host "Subscription: $($context.Subscription.Id); tenant: $($context.Tenant.Id)" +Write-Host "Resource group: $ResourceGroupName; data-plane principal: $PrincipalId" +Write-Warning 'Premium messaging units incur ongoing charges. Delete the demo resource group when finished.' + +$group = Get-AzResourceGroup | Where-Object ResourceGroupName -eq $ResourceGroupName +if (-not $group) { + $null = New-AzResourceGroup -Name $ResourceGroupName -Location $Locations[0] -Tag @{ sample = 'confidential-service-bus' } +} +Write-Host "Cleanup: Remove-AzResourceGroup -Name '$ResourceGroupName' (only if dedicated to this demo)" +$selectedNamespace = $null +foreach ($location in $Locations) { + $namespaceName = "$NamespacePrefix-$location" + $deploymentName = "sbcc-$location-$([guid]::NewGuid().ToString('N').Substring(0, 8))" + Write-Host "Trying confidential Premium namespace '$namespaceName' in $location..." + $deploymentStarted = $false + try { + $parameters = @{ + ResourceGroupName = $ResourceGroupName + TemplateFile = Join-Path $PSScriptRoot 'namespace.json' + namespaceName = $namespaceName + location = $location + queueName = $QueueName + principalId = $PrincipalId + principalType = $PrincipalType + } + $validationErrors = @(Test-AzResourceGroupDeployment @parameters | Where-Object { + $null -ne $_ -and -not ($_ -is [string] -and [string]::IsNullOrWhiteSpace($_)) + }) + if ($validationErrors.Count -gt 0) { + throw "Azure template preflight failed: $($validationErrors | ConvertTo-Json -Depth 20 -Compress)" + } + $deploymentStarted = $true + $deployment = New-AzResourceGroupDeployment -Name $deploymentName @parameters + } + catch { + $failure = $_ + $failedOperations = @() + if ($deploymentStarted) { + $operations = @(Get-AzResourceGroupDeploymentOperation -ResourceGroupName $ResourceGroupName ` + -DeploymentName $deploymentName) + $failedOperations = @($operations | Where-Object ProvisioningState -eq 'Failed') + } + $details = ($failedOperations | ForEach-Object { $_.StatusMessage | ConvertTo-Json -Depth 20 -Compress }) -join "`n" + $message = "$($failure.Exception.Message)`n$details" + Write-Warning "Deployment in $location failed: $message" + $nonNamespaceFailure = @($failedOperations | Where-Object { + $_.TargetResource.ResourceType -ne 'Microsoft.ServiceBus/namespaces' + }) + if ($nonNamespaceFailure.Count -gt 0 -or -not (Test-RegionalAvailabilityError -Message $message)) { + throw $failure + } + if ($deploymentStarted) { + Write-Warning "Regional availability failure; trying the next location. Inspect '$deploymentName' for any partial resources." + } + else { + Write-Warning 'Regional availability preflight failed; trying the next location. No namespace was created by this attempt.' + } + continue + } + $namespaceId = $deployment.Outputs.namespaceId.Value + $response = Invoke-AzRestMethod -Method GET -Path "${namespaceId}?api-version=2025-05-01-preview" + if ($response.StatusCode -ne 200) { + throw "Namespace verification failed: HTTP $($response.StatusCode): $($response.Content)" + } + $namespace = $response.Content | ConvertFrom-Json + if ($namespace.sku.name -ne 'Premium' -or + $namespace.properties.platformCapabilities.confidentialCompute.mode -ne 'Enabled' -or + $namespace.properties.disableLocalAuth -ne $true) { + throw 'Namespace does not report Premium, confidential compute Enabled, and local authentication disabled. Refusing to send.' + } + $selectedNamespace = $deployment.Outputs.fullyQualifiedNamespace.Value + Write-Host "Verified: $selectedNamespace; location=$($namespace.location); Premium; confidentialCompute=Enabled" + break +} +if (-not $selectedNamespace) { + throw "Confidential Premium creation failed in every requested location: $($Locations -join ', '). See warnings and Azure deployments." +} +if (-not $SkipMessages) { + & node (Join-Path $PSScriptRoot 'send-receive.mjs') $selectedNamespace $QueueName $context.Tenant.Id + if ($LASTEXITCODE -ne 0) { + throw "Message round-trip failed (exit $LASTEXITCODE). Resources remain in '$ResourceGroupName' for investigation." + } +} +[pscustomobject]@{ + SubscriptionId = $context.Subscription.Id + ResourceGroupName = $ResourceGroupName + Namespace = $selectedNamespace + QueueName = $QueueName + Location = $namespace.location + ConfidentialCompute = $namespace.properties.platformCapabilities.confidentialCompute.mode +} diff --git a/service-bus-examples/DeploymentHelpers.ps1 b/service-bus-examples/DeploymentHelpers.ps1 new file mode 100644 index 0000000..0479874 --- /dev/null +++ b/service-bus-examples/DeploymentHelpers.ps1 @@ -0,0 +1,13 @@ +function Test-RegionalAvailabilityError { + param([Parameter(Mandatory)][string]$Message) + + if ($Message -match 'AuthorizationFailed|Authorization failed|RequestDisallowedByPolicy|InvalidAuthenticationToken|RoleAssignment') { + return $false + } + if ($Message -match 'SkuNotAvailable|RegionNotSupported|LocationNotAvailableForResourceType|InsufficientCapacity') { + return $true + } + return ($Message -match '(?i)confidential|platformCapabilities') -and + ($Message -match '(?i)region|location') -and + ($Message -match '(?i)not supported|not available|unsupported|unavailable') +} diff --git a/service-bus-examples/README.md b/service-bus-examples/README.md new file mode 100644 index 0000000..5669271 --- /dev/null +++ b/service-bus-examples/README.md @@ -0,0 +1,222 @@ +# Confidential Azure Service Bus Premium + +Creates a **new Premium namespace with confidential computing enabled**, a queue, +and passwordless send/receive role assignments. Sends three fictional JSON orders, +reads them back, compares IDs and complete payloads, and completes the messages. +This tests messaging, not hardware attestation: the management-plane check verifies +that Azure reports `platformCapabilities.confidentialCompute.mode = Enabled`. + +Public documentation: +[Azure Service Bus with confidential computing - Microsoft Learn](https://learn.microsoft.com/en-us/azure/service-bus-messaging/confidential-computing?tabs=bicep). +As checked on October 29, 2026, Microsoft lists **Korea Central** and **UAE North**. +This capability requires Premium and must be enabled when creating the namespace; +it cannot be enabled on an existing namespace. The template uses the documented +`2025-05-01-preview` namespace API. + +## Prerequisites + +- PowerShell 7+, `Az.Accounts` and `Az.Resources` modules. +- Node.js 22+ and npm. +- An Azure public-cloud subscription with permission to create resource groups, + Service Bus namespaces/queues, and role assignments (for example, Contributor + plus Role Based Access Control Administrator at the relevant scope). +- If these permissions are PIM-eligible, activate them before running the script + and allow time for propagation. Owner is not a prerequisite; the required + permissions must cover the deployment scope and Sender/Receiver assignments. +- Sign in with `Connect-AzAccount`. The Node sample uses `AzurePowerShellCredential` + so it authenticates through Azure PowerShell too, not the separate VS Code + Azure extension or Azure CLI login. +- Outbound HTTPS for Azure management/authentication and AMQP/TLS on port 5671. + +## Run + +From the repository root, enter this directory, install dependencies, and sign in: + +```powershell +Set-Location .\service-bus-examples +npm ci +Connect-AzAccount +.\Deploy-ServiceBus.ps1 -SubscriptionId '' +``` + +By default the script creates uniquely named resources and attempts **Korea Central +(`koreacentral`), then UAE North (`uaenorth`)**. Fallback happens only for +recognized regional feature/SKU availability or capacity errors. Authentication, +policy, role-assignment, and other deployment failures stop the script. +It never falls back to Standard tier or disables confidential computing. + +The script prints the selected namespace, location, resource group and queue, and +verifies Premium, confidential mode and disabled local authentication before sending. +Each location is preflighted with Azure ARM validation before namespace creation, +including the permissions required to create the role assignments. Preflight failure +can leave an empty resource group, but does not create a billable namespace. +New Azure Service Bus Data Sender and Data Receiver permissions can take several +minutes to propagate; authorization probes retry for up to five minutes. +No connection string, SAS key, or credential file is generated. + +Override locations or resource names when needed: + +```powershell +.\Deploy-ServiceBus.ps1 -SubscriptionId '' ` + -ResourceGroupName 'rg-my-confidential-servicebus-demo' ` + -NamespacePrefix 'sbcc-my-unique-prefix' ` + -Locations koreacentral,uaenorth +``` + +The resource group's metadata location defaults to the first requested location; +the namespace itself is created in the successful fallback location. +Existing resource groups are not relocated. Failed attempts remain visible in +deployment history. Partial resources are not automatically deleted. + +Use `-SkipMessages` to provision only. To assign roles to a signed-in automation +identity instead of the current user, pass its **object ID** with +`-PrincipalId '' -PrincipalType ServicePrincipal`. The messaging client +must authenticate as that principal. + +## Test against Azure + +The deployment command above runs the live test automatically. To require Korea +Central without regional fallback: + +```powershell +.\Deploy-ServiceBus.ps1 -SubscriptionId '' -Locations koreacentral +``` + +Success requires all three checks: + +1. The script prints `Verified:` with Premium and `confidentialCompute=Enabled`; + it also checks that local authentication is disabled. +2. The client prints `Sent 3 sample messages`, followed by three + `Received and completed` lines containing the fictional JSON orders. +3. The client prints `PASS: all 3 sample payloads read back, verified, and completed.` + The command exits successfully and returns the namespace, queue, and location. + +To run messaging again against the namespace and queue printed by the script, +use the tenant ID printed at deployment start: + +```powershell +node .\send-receive.mjs '.servicebus.windows.net' '' '' +``` + +Use a **dedicated, empty queue without competing consumers**. Each run uses unique +message and correlation IDs. Unexpected or corrupt messages fail the test and are +abandoned, not silently consumed. Receive timeout is two minutes. Failed sends can +have ambiguous delivery; don't blindly rerun against a queue with leftover messages. +Successful runs remove the three sample messages by completing them. + +### Manually send and verify a message in the Azure portal + +Use the portal's built-in **Service Bus Explorer**, not the standalone desktop +application. See Microsoft's +[Service Bus Explorer guide](https://learn.microsoft.com/en-us/azure/service-bus-messaging/explorer). + +Before starting, stop any SDK receivers and use a dedicated, empty demo queue. +Do not run the automated round-trip script while manual messages remain: it +intentionally rejects messages from a different run. + +1. Sign in to the [Azure portal](https://portal.azure.com) as the identity given + **Azure Service Bus Data Sender** and **Azure Service Bus Data Receiver** by + the deployment. A different portal user needs equivalent data-plane permissions; + subscription Owner alone does not grant message send/receive access. Allow + several minutes for new role assignments to propagate. +2. Open your deployed namespace, select **Queues**, open the queue printed by the + script, and select **Service Bus Explorer**. Use **Microsoft Entra** + authentication if prompted to select an authentication method. Do not enable + local/SAS authentication or use an access key. +3. Select **Send messages**, choose **Application/Json** as the content type, and + paste this fictional payload into **Message body**: + + ```json + { + "orderId": "MANUAL-1001", + "product": "Confidential widget", + "quantity": 2 + } + ``` + +4. Under **Broker properties**, set **Message ID** to `manual-order-1001` and + **Correlation ID** to `manual-check-1001`. Leave scheduled enqueue unset and + **Repeat send** off, then select **Send** once. Use new IDs for subsequent tests. +5. Select **Peek Mode**, select the **Queue / Subscription** subqueue (not + **DeadLetter**), and select **Peek from start**. Open the message and verify: + - The body has exactly the three fields and values above. + - **Message Properties** shows Message ID `manual-order-1001`, Correlation ID + `manual-check-1001`, and content type `application/json`. + - Peeking again still shows the message: **peek does not consume it**. +6. Select **Receive Mode**, then **Receive messages**. Choose **PeekLock**, request + **1** message, set a wait time such as **10 seconds**, and select **Receive**. + Do not choose **ReceiveAndDelete**, which removes the message before you verify it. +7. Open the received message and compare its complete JSON body, Message ID, + Correlation ID, and content type with the values you sent. If they match, + select that message in the grid and choose **Complete**. The sample queue's lock + lasts one minute: finish verification and completion before it expires. If the + lock expires, receive again and recheck before completing. If the payload does + not match, use **Abandon lock**, investigate, and do not complete it. +8. Return to **Peek Mode** and choose **Peek from start** again. The completed + message should be absent. Refresh the queue counts; for an otherwise empty + queue, **Active Messages** and **Dead-lettered Messages** should both be zero + after the counts update. A further receive should return no message. + +Successful verification means the matching message was sent, inspected, received, +and explicitly completed, not merely that the portal accepted a send request. +If a message is missing, check the selected namespace/queue and subqueue, whether +another receiver consumed it, and whether its TTL expired. For authorization +failures, check the portal identity and data roles; do not switch to SAS as a workaround. + +## Cost, security, and cleanup + +**Premium messaging units incur ongoing charges even when idle.** This sample uses +one messaging unit and one partition. Resources are intentionally left available +for inspection; no automatic deletion occurs. + +After inspecting the output, delete the **dedicated demo group**: + +```powershell +Remove-AzResourceGroup -Name '' +``` + +Do not delete a reused resource group containing unrelated resources. + +The public endpoint is enabled for this local demo, requires Entra authentication, +and enforces TLS 1.2+. Sender/receiver permissions are namespace-scoped. Production +workloads should consider queue-scoped roles, managed identity, private endpoints, +network restrictions, monitoring, and customer-managed keys. Confidential compute +is a creation-time setting, not an upgrade path for an existing namespace. + +## Local checks + +```powershell +npm test +.\Test-DeploymentHelpers.ps1 +.\Test-Deployment.ps1 +``` + +Tests check payload/ID integrity, missing/duplicate/unrelated messages, link cleanup, +bounded RBAC retries, conservative regional fallback classification, and mocked +end-to-end deployment/preflight flows. The PowerShell tests require the Az modules +but do not call Azure. + +### Validation in this workspace (October 29, 2026) + +Live deployment and messaging succeeded in **Korea Central**: + +- Azure reports Premium, confidential compute Enabled, and local authentication disabled. +- Both namespace-scoped Sender and Receiver assignments were verified. +- Three sample orders were sent, read back with exact payload verification, and + completed. The queue subsequently reported zero active and dead-letter messages. + +Live testing also identified and fixed an incorrect Receiver role ID and handling +of Azure PowerShell's empty-string validation success result. Local checks pass: +10 Node tests, 10 regional error classifications, and eight mocked deployment +scenarios, including the empty-string regression and built-in role ID assertions. +These are historical validation results, not a guarantee that the demo resources +still exist. While retained, they incur Premium charges. Use the cleanup command +in the cost, security, and cleanup section with your deployment's resource group. + +## References + +- [Service Bus confidential computing (including ARM/Bicep examples)](https://learn.microsoft.com/en-us/azure/service-bus-messaging/confidential-computing?tabs=bicep) +- [Service Bus JavaScript SDK](https://learn.microsoft.com/javascript/api/overview/azure/service-bus-readme) +- [Azure PowerShell credential](https://learn.microsoft.com/javascript/api/@azure/identity/azurepowershellcredential) +- [Service Bus Entra authentication and built-in roles](https://learn.microsoft.com/azure/service-bus-messaging/authenticate-application) +- [Service Bus Explorer: send, peek, receive, and complete messages](https://learn.microsoft.com/en-us/azure/service-bus-messaging/explorer) diff --git a/service-bus-examples/Test-Deployment.ps1 b/service-bus-examples/Test-Deployment.ps1 new file mode 100644 index 0000000..b140c20 --- /dev/null +++ b/service-bus-examples/Test-Deployment.ps1 @@ -0,0 +1,113 @@ +#Requires -Version 7.0 +#Requires -Modules Az.Accounts, Az.Resources +$ErrorActionPreference = 'Stop' +$template = Get-Content (Join-Path $PSScriptRoot 'namespace.json') -Raw | ConvertFrom-Json +if ($template.variables.senderRole -ne '69a216fc-b8fb-44d8-bc22-1f3c2cd27a39' -or + $template.variables.receiverRole -ne '4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0') { + throw 'Template must use the built-in Service Bus Data Sender and Data Receiver role IDs.' +} +$testState = @{ + Attempts = [System.Collections.Generic.List[string]]::new() + Deployments = 0 + Scenario = '' +} + +function Get-AzContext { + [pscustomobject]@{ + Subscription = @{ Id = '11111111-1111-1111-1111-111111111111' } + Tenant = @{ Id = '22222222-2222-2222-2222-222222222222' } + Environment = @{ Name = 'AzureCloud' } + } +} +function Get-AzResourceGroup { + [pscustomobject]@{ ResourceGroupName = 'rg-mock-servicebus' } +} +function New-AzResourceGroup { throw 'Tests must never create a resource group.' } +function Test-AzResourceGroupDeployment { + param($location) + $testState.Attempts.Add($location) + if ($testState.Scenario -eq 'authorization') { + return [pscustomobject]@{ Code = 'AuthorizationFailed'; Message = 'No roleAssignments/write permission' } + } + if ($testState.Scenario -eq 'invalid') { + return [pscustomobject]@{ Code = 'BadRequest'; Message = 'Invalid namespace name' } + } + if ($testState.Scenario -eq 'all-regions' -or + ($testState.Scenario -eq 'regional-preflight' -and $location -eq 'koreacentral')) { + return [pscustomobject]@{ Code = 'BadRequest'; Message = "ConfidentialCompute is not supported in region $location" } + } + if ($testState.Scenario -eq 'empty-string-success') { + return '' + } +} +function New-AzResourceGroupDeployment { + param($location) + $testState.Deployments++ + if ($testState.Scenario -eq 'regional-deploy' -and $location -eq 'koreacentral') { + throw 'DeploymentFailed' + } + [pscustomobject]@{ + Outputs = @{ + namespaceId = @{ Value = '/mock/namespace' } + fullyQualifiedNamespace = @{ Value = "sbcc-mock-$location.servicebus.windows.net" } + } + } +} +function Get-AzResourceGroupDeploymentOperation { + [pscustomobject]@{ + ProvisioningState = 'Failed' + TargetResource = @{ ResourceType = 'Microsoft.ServiceBus/namespaces' } + StatusMessage = @{ error = @{ code = 'RegionNotSupported'; message = 'ConfidentialCompute is not supported in region koreacentral' } } + } +} +function Invoke-AzRestMethod { + $mode = if ($testState.Scenario -eq 'not-confidential') { 'Disabled' } else { 'Enabled' } + [pscustomobject]@{ + StatusCode = 200 + Content = @{ + location = $testState.Attempts[-1] + sku = @{ name = 'Premium' } + properties = @{ + disableLocalAuth = $true + platformCapabilities = @{ confidentialCompute = @{ mode = $mode } } + } + } | ConvertTo-Json -Depth 10 + } +} + +$cases = @( + @{ Scenario = 'success'; Locations = 'koreacentral'; Deployments = 1; Error = $null }, + @{ Scenario = 'empty-string-success'; Locations = 'koreacentral'; Deployments = 1; Error = $null }, + @{ Scenario = 'regional-preflight'; Locations = 'koreacentral,uaenorth'; Deployments = 1; Error = $null }, + @{ Scenario = 'regional-deploy'; Locations = 'koreacentral,uaenorth'; Deployments = 2; Error = $null }, + @{ Scenario = 'authorization'; Locations = 'koreacentral'; Deployments = 0; Error = 'AuthorizationFailed' }, + @{ Scenario = 'invalid'; Locations = 'koreacentral'; Deployments = 0; Error = 'Invalid namespace name' }, + @{ Scenario = 'all-regions'; Locations = 'koreacentral,uaenorth'; Deployments = 0; Error = 'every requested location' }, + @{ Scenario = 'not-confidential'; Locations = 'koreacentral'; Deployments = 1; Error = 'Refusing to send' } +) +foreach ($case in $cases) { + $testState.Scenario = $case.Scenario + $testState.Attempts.Clear() + $testState.Deployments = 0 + $caught = $null + try { + $result = & (Join-Path $PSScriptRoot 'Deploy-ServiceBus.ps1') -SkipMessages ` + -ResourceGroupName 'rg-mock-servicebus' -NamespacePrefix 'sbcc-mock' ` + -PrincipalId '33333333-3333-3333-3333-333333333333' + } + catch { + $caught = $_.Exception.Message + } + if (($testState.Attempts -join ',') -ne $case.Locations -or $testState.Deployments -ne $case.Deployments) { + throw "Incorrect deployment/fallback behavior: $($case.Scenario)" + } + if ($case.Error) { + if (-not $caught -or $caught -notmatch [regex]::Escape($case.Error)) { + throw "Expected error '$($case.Error)' for $($case.Scenario), got '$caught'" + } + } + elseif ($caught -or $result.ConfidentialCompute -ne 'Enabled') { + throw "Expected verified deployment for $($case.Scenario), got '$caught'" + } +} +Write-Host "PASS: $($cases.Count) mocked deployment/preflight/verification scenarios (no Azure calls)." diff --git a/service-bus-examples/Test-DeploymentHelpers.ps1 b/service-bus-examples/Test-DeploymentHelpers.ps1 new file mode 100644 index 0000000..ab00a95 --- /dev/null +++ b/service-bus-examples/Test-DeploymentHelpers.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'DeploymentHelpers.ps1') +$cases = @( + @{ Message = 'ConfidentialCompute is not supported in region koreacentral'; Expected = $true }, + @{ Message = 'Region koreacentral is unavailable for confidential computing'; Expected = $true }, + @{ Message = 'SkuNotAvailable'; Expected = $true }, + @{ Message = 'InsufficientCapacity'; Expected = $true }, + @{ Message = 'AuthorizationFailed'; Expected = $false }, + @{ Message = 'Authorization failed: RegionNotSupported'; Expected = $false }, + @{ Message = 'RequestDisallowedByPolicy: confidential compute is not supported in this region'; Expected = $false }, + @{ Message = 'RoleAssignment failed: RegionNotSupported'; Expected = $false }, + @{ Message = 'BadRequest: invalid namespace name'; Expected = $false }, + @{ Message = 'Premium is unavailable'; Expected = $false } +) +foreach ($case in $cases) { + if ((Test-RegionalAvailabilityError -Message $case.Message) -ne $case.Expected) { + throw "Incorrect fallback classification: $($case.Message)" + } +} +Write-Host "PASS: $($cases.Count) regional fallback classifications." diff --git a/service-bus-examples/namespace.json b/service-bus-examples/namespace.json new file mode 100644 index 0000000..c7a5905 --- /dev/null +++ b/service-bus-examples/namespace.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "namespaceName": { "type": "string" }, + "location": { "type": "string" }, + "queueName": { "type": "string" }, + "principalId": { "type": "string" }, + "principalType": { + "type": "string", + "defaultValue": "User", + "allowedValues": ["User", "ServicePrincipal"] + } + }, + "variables": { + "namespaceId": "[resourceId('Microsoft.ServiceBus/namespaces', parameters('namespaceName'))]", + "senderRole": "69a216fc-b8fb-44d8-bc22-1f3c2cd27a39", + "receiverRole": "4f6d3b9b-027b-4f4c-9142-0e5a2a2247e0" + }, + "resources": [ + { + "type": "Microsoft.ServiceBus/namespaces", + "apiVersion": "2025-05-01-preview", + "name": "[parameters('namespaceName')]", + "location": "[parameters('location')]", + "sku": { "name": "Premium", "tier": "Premium", "capacity": 1 }, + "properties": { + "minimumTlsVersion": "1.2", + "disableLocalAuth": true, + "publicNetworkAccess": "Enabled", + "premiumMessagingPartitions": 1, + "platformCapabilities": { + "confidentialCompute": { "mode": "Enabled" } + } + }, + "tags": { "sample": "confidential-service-bus" } + }, + { + "type": "Microsoft.ServiceBus/namespaces/queues", + "apiVersion": "2025-05-01-preview", + "name": "[format('{0}/{1}', parameters('namespaceName'), parameters('queueName'))]", + "dependsOn": ["[variables('namespaceId')]"], + "properties": { + "lockDuration": "PT1M", + "defaultMessageTimeToLive": "PT1H", + "maxDeliveryCount": 10 + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(variables('namespaceId'), parameters('principalId'), variables('senderRole'))]", + "scope": "[format('Microsoft.ServiceBus/namespaces/{0}', parameters('namespaceName'))]", + "dependsOn": ["[variables('namespaceId')]"], + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('senderRole'))]", + "principalId": "[parameters('principalId')]", + "principalType": "[parameters('principalType')]" + } + }, + { + "type": "Microsoft.Authorization/roleAssignments", + "apiVersion": "2022-04-01", + "name": "[guid(variables('namespaceId'), parameters('principalId'), variables('receiverRole'))]", + "scope": "[format('Microsoft.ServiceBus/namespaces/{0}', parameters('namespaceName'))]", + "dependsOn": ["[variables('namespaceId')]"], + "properties": { + "roleDefinitionId": "[subscriptionResourceId('Microsoft.Authorization/roleDefinitions', variables('receiverRole'))]", + "principalId": "[parameters('principalId')]", + "principalType": "[parameters('principalType')]" + } + } + ], + "outputs": { + "namespaceId": { "type": "string", "value": "[variables('namespaceId')]" }, + "fullyQualifiedNamespace": { + "type": "string", + "value": "[format('{0}.servicebus.windows.net', parameters('namespaceName'))]" + } + } +} diff --git a/service-bus-examples/package-lock.json b/service-bus-examples/package-lock.json new file mode 100644 index 0000000..4a4100d --- /dev/null +++ b/service-bus-examples/package-lock.json @@ -0,0 +1,1344 @@ +{ + "name": "confidential-service-bus-example", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "confidential-service-bus-example", + "version": "1.0.0", + "dependencies": { + "@azure/identity": "4.14.0", + "@azure/service-bus": "7.10.0" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@azure/abort-controller": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@azure/abort-controller/-/abort-controller-2.2.0.tgz", + "integrity": "sha1-k+DoKwGGLn6jtbaZWHGdPz/SyKw=", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-amqp": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/@azure/core-amqp/-/core-amqp-4.5.1.tgz", + "integrity": "sha1-ddnAPQ2aYO/GilPXk7sHquCK/lw=", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "rhea": "^3.0.0", + "rhea-promise": "^3.0.0", + "tslib": "^2.6.2", + "util": "^0.12.5" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-auth": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@azure/core-auth/-/core-auth-1.11.0.tgz", + "integrity": "sha1-Ha7/32LRqHHSK4ZfnzFkpj6h9XU=", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-util": "^1.13.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-client": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@azure/core-client/-/core-client-1.11.1.tgz", + "integrity": "sha1-2ZCHiohXdQerETGTW0c0sppjfVg=", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-rest-pipeline": "^1.22.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-paging": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@azure/core-paging/-/core-paging-1.7.0.tgz", + "integrity": "sha1-WVl6L1Q4ujxsh4n8hw9p4OeFMag=", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-process": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@azure/core-process/-/core-process-1.0.0.tgz", + "integrity": "sha1-w68cFyI9ZTjhs2ln3clM2uUyy4M=", + "license": "MIT", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-rest-pipeline": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/@azure/core-rest-pipeline/-/core-rest-pipeline-1.25.0.tgz", + "integrity": "sha1-kupJEu27H3OV9IKaMAYxF0qoLwg=", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-auth": "^1.10.0", + "@azure/core-tracing": "^1.3.0", + "@azure/core-util": "^1.13.0", + "@azure/logger": "^1.3.0", + "@typespec/ts-http-runtime": "^0.3.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-tracing": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/core-tracing/-/core-tracing-1.4.0.tgz", + "integrity": "sha1-1lenAOJNJW0ZXmKKeV22qMxHXqs=", + "license": "MIT", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-util": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/@azure/core-util/-/core-util-1.14.0.tgz", + "integrity": "sha1-fOn+V5WPEy3UIlc4VuWkXHIyuwQ=", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/core-xml": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@azure/core-xml/-/core-xml-1.6.0.tgz", + "integrity": "sha1-EI8JIOG834owuffoh5ibOhIIK9k=", + "license": "MIT", + "dependencies": { + "fast-xml-parser": "^5.5.9", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/identity": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.14.0.tgz", + "integrity": "sha512-8utCLdTZn14CiAe0HW4JbtzUMsc0mdDwnjUvStFMrR4IxQP8S2eL9zVAdplpfORVJGYmidZElpeXRYjJQ4UyUw==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.0.0", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-process": "^1.0.0", + "@azure/core-rest-pipeline": "^1.17.0", + "@azure/core-tracing": "^1.0.0", + "@azure/core-util": "^1.11.0", + "@azure/logger": "^1.0.0", + "@azure/msal-browser": "^5.5.0", + "@azure/msal-node": "^6.0.0", + "open": "^11.0.0", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/logger": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@azure/logger/-/logger-1.4.0.tgz", + "integrity": "sha1-PVpif+WaJ27OdIenndkq1cUIwzk=", + "license": "MIT", + "dependencies": { + "@typespec/ts-http-runtime": "^0.3.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@azure/msal-browser": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.27.0.tgz", + "integrity": "sha512-i2CKKyuCuOZEBZJiAoFK/viYZAP6CRaqqlkeHl3ViteDHOUE/tkI/Slq1cdGnwhkIB+k1OE0g00QGl9O0gAFNQ==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.15.0" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.15.0", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.15.0.tgz", + "integrity": "sha512-lwdLb1B9Q5nEhPLVIKe78XH5wFMN15fCiDkY7ylPDSWZuWcD93G3jpJajrKhO3CQld8SLKz0sHiOi24mxdvtsg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-node": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@azure/msal-node/-/msal-node-6.0.1.tgz", + "integrity": "sha1-SLnVpHLxa0A706MklCJ+z8VMr9Q=", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.14.1", + "jsonwebtoken": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@azure/msal-node/node_modules/@azure/msal-common": { + "version": "16.14.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.14.1.tgz", + "integrity": "sha1-LJh/MdqFjlROcrb6qYb+zbPeIPg=", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/service-bus": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/@azure/service-bus/-/service-bus-7.10.0.tgz", + "integrity": "sha512-UDTnLCISBpyQlwVnYpOio7lXIGCGlzxfashdDVIRcRlEe8MIrTQu+BFY1DxKh1lOUaHp+qLmWSjLXDunWeJDAQ==", + "license": "MIT", + "dependencies": { + "@azure/abort-controller": "^2.1.2", + "@azure/core-amqp": "^4.3.2", + "@azure/core-auth": "^1.9.0", + "@azure/core-client": "^1.9.2", + "@azure/core-paging": "^1.6.2", + "@azure/core-rest-pipeline": "^1.19.0", + "@azure/core-tracing": "^1.2.0", + "@azure/core-util": "^1.11.0", + "@azure/core-xml": "^1.4.3", + "@azure/logger": "^1.1.4", + "buffer": "^6.0.3", + "is-buffer": "^2.0.5", + "jssha": "^3.3.1", + "long": "^5.3.1", + "process": "^0.11.10", + "rhea-promise": "^3.0.3", + "tslib": "^2.8.1", + "util": "^0.12.5" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@nodable/entities": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz", + "integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@typespec/ts-http-runtime": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.9.tgz", + "integrity": "sha1-MyhVu9cpqzbgd5st/CTtySDRJGE=", + "license": "MIT", + "dependencies": { + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha1-48121MVI7oldPD/Y3B9sW5Ay56g=", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha1-KqwA4I3603JsHUYuYNvC+DFlmkQ=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha1-pcw3XWoDwu/IelU/PgsVIt7xSEY=", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha1-GxtEAWClv3rUC2UPCVljSBkDkwo=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha1-Ks5XhFnMj74qcKqo9S7mO2p0xsY=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=", + "license": "BSD-3-Clause" + }, + "node_modules/bundle-name": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha1-OaZEcAyAvH0MqRAvxtHUOy/X7uc=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha1-S1QowiK+mF15w9gmV0edvgtZstY=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha1-I43pNdKippKSjFOMfM+pEGf9Bio=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha1-xq5DLZvZZiWC/OCHCbA4xY6ePWo=", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha1-F5Cv/FJoD7sR4XyrJ1LWmqKjfSw=", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha1-96fMuPUQS/jg9xujscz6Xq/bIeg=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha1-iU3BQbt9MGCuQ2b2oBB+aPvkjF4=", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha1-27Ga37dG1/xtc0oGty9KANAhJV8=", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha1-165mfh3INIL4tw/Q9u78UNow9Yo=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha1-rg8PothQRe8UqBfao86azQSJ5b8=", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha1-mD6y+aZyTpMD9hrd8BHHLgngsPo=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha1-BfdaJdq5jk+x3NXhRywFRtUFfI8=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha1-otCzcyBXJN+lJdI7DD4bHKWCyZs=", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha1-Mala0Kkk4tLEGagTrrLE6HjqdAA=", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz", + "integrity": "sha1-7wWzU7RVl0g9+wnUUKBiorrsOoI=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.11.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz", + "integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.1", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.2", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha1-1lBogCeCaSD+6wr3R+57lCGkHUc=", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha1-LALYZNl/PqbIgwxGTL0Rq26rehw=", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha1-DnXdQQ0SQ2h6C6LpUblO7bj3N6I=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha1-dD8OO2lkqTpUke0b/6rgVNf5jQE=", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha1-FQs/J0OGnvPoUewMSdFbHRTQDuE=", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha1-ifVrghe9vIgCvSmd9tfxCB1+UaE=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha1-lj7X0HHce/XwhMW/vg0bYiJYaFQ=", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha1-/JxqeDoISVHQuXH+EBjegTcHozg=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha1-LNxC1AvvLltO6rfAGnPFTOerWrw=", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha1-jGLYy5C+sqrV0KW2dYGtmFTD8AM=", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha1-mosfJGhmwChQlIZYX2K48sGMJw4=", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha1-2o3+rH2hMLBcK6S1nJts1mYRprk=", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha1-jrehCmP/8l0VpXsAFYbRd9Gw01I=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha1-D6LGT5MpF8NDOg3tVTY6rjdBa3w=", + "license": "ISC" + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha1-rVjGrs9WO3jvK/BN9UDaj119jhs=", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha1-68JS5ADSL/jXf6CYiIIaJKZYwZE=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha1-O8KoXqdC2eNiBdys3XLKH9xRsFU=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha1-kAk6oxBid9inelkQ265xdH4VogA=", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha1-rjth49XqTkg5uQutIrAjNQUaF9U=", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha1-jrc8HKu6d3SNOJWI7uoTKmMFdiI=", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha1-6B+6aZZi6zHb2vJnZqYdSBRxfqQ=", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha1-dtcKPtEO+b5I61d4h9dCBb8MrSI=", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha1-S/tKRbYc7oOlpG+6d45OjVnAzgs=", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-unsafe": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz", + "integrity": "sha1-ux6tF/GqaI9kMyWLVh6YsaRaGvw=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha1-MniXsmgyo+sRfabCdJLQTKEyWU8=", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha1-bNV6sB6bCsB8uEfVPTybbuMfeuI=", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jssha": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/jssha/-/jssha-3.3.2.tgz", + "integrity": "sha1-MMUbWri/ix7PJBYBWAPUT1y/Bp4=", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha1-v4F20a0M1y4PP1gzhZWhPhELyAQ=", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha1-B+3Bvo+sIOZ3soPs4mFJi9OPBpA=", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha1-HYRGMJWZkmLX17f4v9SozFUWf4M=", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha1-oN10voHiqlwvJ+Zc4oNgXuTit/k=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha1-V0yBOM4dK1hh8LRFedut1gxmFbI=", + "license": "MIT" + }, + "node_modules/open": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha1-VnxzwHGX6dzvJOkO3NxXEFZZkWg=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha1-k+NYK8DlQmWG2dB7ee5A/IQd5K4=", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha1-CFMBb1s8EtUrlXHI/oBZZ32d2+I=", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/rhea": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/rhea/-/rhea-3.0.5.tgz", + "integrity": "sha1-kj5/1kysoPd+MXRmd5+v7RHZgcA=", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.3.3" + } + }, + "node_modules/rhea-promise": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/rhea-promise/-/rhea-promise-3.1.0.tgz", + "integrity": "sha512-X2qPBE3iaK2yKaCG8+5sykBnsbiwznbyGuoKs19kuI3iS5qDsPT2X6HZYM6Pl9F9u5wZk6rSmYdgdeKHOM3LLg==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^4.4.3", + "rhea": "^3.0.5", + "tslib": "^2.8.1" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha1-Lp5UxGZOwxBsW1Yw4knT1llcSRE=", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha1-Hq+fqb2x/dTsdfWPnNtOa3gn7sY=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha1-f4fftnoxUHguqvGFg/9dFxGsEME=", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha1-ObZGA33VDBT7RR5+TKxY7YuGP2k=", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha1-qscjFBmOrtl1z3eyw7a4gGleVEk=", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strnum": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz", + "integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha1-YS7+TtI11Wfoq6Xypfq3AoCt6D8=", + "license": "0BSD" + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha1-XxemBZtz22GodWaHgaHCsTa9b7w=", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wsl-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha1-RsHhi/4oWEeZgt0qzPNNFudJ7aI=", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + } + } +} diff --git a/service-bus-examples/package.json b/service-bus-examples/package.json new file mode 100644 index 0000000..fc171f4 --- /dev/null +++ b/service-bus-examples/package.json @@ -0,0 +1,17 @@ +{ + "name": "confidential-service-bus-example", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "test": "node --test", + "demo": "node send-receive.mjs" + }, + "dependencies": { + "@azure/identity": "4.14.0", + "@azure/service-bus": "7.10.0" + } +} diff --git a/service-bus-examples/round-trip.mjs b/service-bus-examples/round-trip.mjs new file mode 100644 index 0000000..2ca62ee --- /dev/null +++ b/service-bus-examples/round-trip.mjs @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; + +export async function retryAuthorization(operation, { + timeoutMs = 300_000, + intervalMs = 10_000, + sleep = delay, + now = Date.now, +} = {}) { + const deadline = now() + timeoutMs; + for (;;) { + try { + return await operation(); + } catch (error) { + if (error.code !== "UnauthorizedAccess" || now() >= deadline) { + throw error; + } + console.warn("Service Bus RBAC has not propagated yet; retrying authorization..."); + await sleep(Math.min(intervalMs, deadline - now())); + } + } +} + +export async function roundTrip(client, queueName, { + runId = randomUUID(), + receiveTimeoutMs = 120_000, + now = Date.now, + authorize = retryAuthorization, +} = {}) { + const samples = [ + { orderId: "DEMO-1001", product: "Confidential widget", quantity: 2 }, + { orderId: "DEMO-1002", product: "Encrypted gadget", quantity: 1 }, + { orderId: "DEMO-1003", product: "Sovereign sensor", quantity: 5 }, + ]; + const expected = new Map(samples.map((body, index) => [`${runId}-${index}`, body])); + const pending = new Map(expected); + const sender = client.createSender(queueName); + try { + // Retry an authorization probe, not the send: an ambiguous send retry can duplicate messages. + await authorize(() => sender.createMessageBatch()); + await sender.sendMessages([...expected].map(([messageId, body]) => ({ + messageId, correlationId: runId, contentType: "application/json", body, + }))); + console.log(`Sent ${samples.length} sample messages (run ${runId}).`); + const receiver = client.createReceiver(queueName, { receiveMode: "peekLock" }); + try { + await authorize(() => receiver.peekMessages(1)); + const deadline = now() + receiveTimeoutMs; + while (pending.size > 0 && now() < deadline) { + const messages = await receiver.receiveMessages(pending.size, { + maxWaitTimeInMs: Math.min(10_000, Math.max(1, deadline - now())), + }); + for (const message of messages) { + try { + assert.equal(message.correlationId, runId, "Use a dedicated empty queue for this demo"); + assert.ok(pending.has(message.messageId), `Unexpected or duplicate message: ${message.messageId}`); + assert.deepEqual(message.body, pending.get(message.messageId), "Received payload differs from sent payload"); + } catch (error) { + await receiver.abandonMessage(message); + throw error; + } + await receiver.completeMessage(message); + pending.delete(message.messageId); + console.log(`Received and completed ${message.messageId}: ${JSON.stringify(message.body)}`); + } + } + assert.equal(pending.size, 0, `Timed out waiting for ${pending.size} sample messages`); + console.log(`PASS: all ${samples.length} sample payloads read back, verified, and completed.`); + return samples.length; + } finally { + await receiver.close(); + } + } finally { + await sender.close(); + } +} diff --git a/service-bus-examples/round-trip.test.mjs b/service-bus-examples/round-trip.test.mjs new file mode 100644 index 0000000..56e7317 --- /dev/null +++ b/service-bus-examples/round-trip.test.mjs @@ -0,0 +1,93 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { retryAuthorization, roundTrip } from "./round-trip.mjs"; + +function fakeClient(transform = (messages) => messages) { + const state = { sent: [], completed: [], abandoned: [], closed: [], sends: 0 }; + const receiver = { + peekMessages: async () => [], + receiveMessages: async () => transform(state.sent), + completeMessage: async (message) => state.completed.push(message), + abandonMessage: async (message) => state.abandoned.push(message), + close: async () => state.closed.push("receiver"), + }; + const sender = { + createMessageBatch: async () => ({}), + sendMessages: async (messages) => { state.sent = messages; state.sends++; }, + close: async () => state.closed.push("sender"), + }; + return { state, createSender: () => sender, createReceiver: () => receiver }; +} + +test("sends and verifies three JSON messages, completing each and closing links", async () => { + const client = fakeClient(); + assert.equal(await roundTrip(client, "test-queue"), 3); + assert.equal(client.state.sends, 1); + assert.equal(client.state.completed.length, 3); + assert.equal(new Set(client.state.sent.map((message) => message.messageId)).size, 3); + assert.deepEqual(client.state.closed, ["receiver", "sender"]); +}); + +for (const [name, transform] of [ + ["corrupt payload", (messages) => [{ ...messages[0], body: { corrupt: true } }]], + ["unrelated run", (messages) => [{ ...messages[0], correlationId: "another-run" }]], + ["unexpected ID", (messages) => [{ ...messages[0], messageId: "unexpected" }]], + ["duplicate message", (messages) => [messages[0], messages[0]]], +]) { + test(`rejects ${name} without completing the bad message`, async () => { + const client = fakeClient(transform); + await assert.rejects(roundTrip(client, "test-queue")); + assert.equal(client.state.abandoned.length, 1); + assert.deepEqual(client.state.closed, ["receiver", "sender"]); + }); +} + +test("fails if not all messages are received before deadline", async () => { + const client = fakeClient(() => []); + let time = 0; + await assert.rejects(roundTrip(client, "test-queue", { + receiveTimeoutMs: 2, now: () => time++, + }), /Timed out/); + assert.equal(client.state.completed.length, 0); + assert.deepEqual(client.state.closed, ["receiver", "sender"]); +}); + +test("authorization propagation retries are bounded and succeed when access appears", async () => { + let attempts = 0; + let time = 0; + const result = await retryAuthorization(async () => { + if (++attempts < 3) throw Object.assign(new Error("RBAC pending"), { code: "UnauthorizedAccess" }); + return "authorized"; + }, { now: () => time, sleep: async (ms) => { time += ms; }, intervalMs: 1, timeoutMs: 3 }); + assert.equal(result, "authorized"); + assert.equal(attempts, 3); +}); + +test("authorization timeout and non-authentication errors surface", async () => { + let time = 0; + const authError = Object.assign(new Error("denied"), { code: "UnauthorizedAccess" }); + await assert.rejects(retryAuthorization(async () => { throw authError; }, { + now: () => time, sleep: async (ms) => { time += ms; }, intervalMs: 1, timeoutMs: 2, + }), /denied/); + const error = Object.assign(new Error("network failure"), { code: "ServiceCommunicationProblem" }); + await assert.rejects(retryAuthorization(async () => { throw error; }), /network failure/); +}); + +test("send failure is not retried and closes the sender", async () => { + const client = fakeClient(); + const sender = client.createSender(); + sender.sendMessages = async () => { + client.state.sends++; + throw new Error("ambiguous send failure"); + }; + await assert.rejects(roundTrip(client, "test-queue"), /ambiguous send/); + assert.equal(client.state.sends, 1); + assert.deepEqual(client.state.closed, ["sender"]); +}); + +test("receive failure closes both links", async () => { + const client = fakeClient(); + client.createReceiver().receiveMessages = async () => { throw new Error("receive failure"); }; + await assert.rejects(roundTrip(client, "test-queue"), /receive failure/); + assert.deepEqual(client.state.closed, ["receiver", "sender"]); +}); diff --git a/service-bus-examples/send-receive.mjs b/service-bus-examples/send-receive.mjs new file mode 100644 index 0000000..51cba97 --- /dev/null +++ b/service-bus-examples/send-receive.mjs @@ -0,0 +1,24 @@ +import { AzurePowerShellCredential } from "@azure/identity"; +import { ServiceBusClient } from "@azure/service-bus"; +import { roundTrip } from "./round-trip.mjs"; + +async function main() { + const [namespace, queueName, tenantId] = process.argv.slice(2); + if (!namespace || !queueName || !tenantId || !/^[a-z0-9-]+\.servicebus\.windows\.net$/i.test(namespace)) { + throw new Error("Usage: node send-receive.mjs "); + } + const credential = new AzurePowerShellCredential({ tenantId }); + const client = new ServiceBusClient(namespace, credential, { + retryOptions: { maxRetries: 3, timeoutInMs: 30_000 }, + }); + try { + await roundTrip(client, queueName); + } finally { + await client.close(); + } +} + +main().catch((error) => { + console.error("Service Bus demo failed:", error); + process.exitCode = 1; +}); diff --git a/vmss-samples/BuildRandomCVMSS.ps1 b/vmss-samples/BuildRandomCVMSS.ps1 new file mode 100644 index 0000000..2d3baf1 --- /dev/null +++ b/vmss-samples/BuildRandomCVMSS.ps1 @@ -0,0 +1,510 @@ +# Hands-off script to build a Confidential Virtual Machine Scale Set (VMSS) running a small web app, +# fronted by an Application Gateway, with CPU-based autoscale so you can watch the scale set grow and shrink. +# +# The scale set instances have NO public IP. Inbound app traffic arrives only via the Application Gateway, +# and outbound internet (if enabled) leaves via a NAT Gateway. Optional Azure Bastion gives you SSH access. +# +# Simon Gallagher, ACC Product Group +# Use at your own risk, no warranties implied, test in a non-production environment first +# based on https://learn.microsoft.com/en-us/azure/confidential-computing/confidential-vm-overview +# +# Clone this repo to a folder (relies on cloud-init-webapp.yaml being in the same folder as this script) +# +# Usage: ./BuildRandomCVMSS.ps1 -subsID -basename [-region ] +# [-vmsize ] [-instanceCount 2] [-minInstances 2] [-maxInstances 5] [-description ] +# [-EnableBastion] [-NoInternetAccess] [-SkipSkuPreflight] [-smoketest] +# +# basename prefix for all resources; 5 random lower-case letters are appended to keep names unique +# instanceCount starting number of instances (also the autoscale default capacity) +# minInstances autoscale floor +# maxInstances autoscale ceiling (kept small on purpose - this is a demo, not a capacity test) +# EnableBastion also deploy Azure Bastion so you can SSH into individual instances (adds cost) +# NoInternetAccess do not attach a NAT Gateway; the scale set subnet gets no outbound internet +# smoketest delete the whole resource group once the build finishes (useful for CI/testing) +# +# You'll need the latest Azure PowerShell module installed (update-module -force) + +param ( + [Parameter(Mandatory)]$subsID, + [Parameter(Mandatory)]$basename, + [Parameter(Mandatory=$false)]$region = "northeurope", + [Parameter(Mandatory=$false)]$vmsize = "Standard_DC2as_v5", + [Parameter(Mandatory=$false)][int]$instanceCount = 2, + [Parameter(Mandatory=$false)][int]$minInstances = 2, + [Parameter(Mandatory=$false)][int]$maxInstances = 5, + [Parameter(Mandatory=$false)]$description = "", + [Parameter(Mandatory=$false)][switch]$EnableBastion, + [Parameter(Mandatory=$false)][switch]$NoInternetAccess, + [Parameter(Mandatory=$false)][switch]$SkipSkuPreflight, + [Parameter(Mandatory=$false)][switch]$smoketest +) + +if ($subsID -eq "" -or $basename -eq "") { + write-host "You must enter a subscription ID and a basename" + exit +} + +if ($minInstances -lt 1 -or $maxInstances -lt $minInstances -or $instanceCount -lt $minInstances -or $instanceCount -gt $maxInstances) { + write-host "Invalid scale settings: require 1 <= minInstances <= instanceCount <= maxInstances" -ForegroundColor Red + exit 1 +} + +#---------Prerequisite Checks: PowerShell version and required Az modules----------------------------------------------- +function Test-PrerequisitesInstalled { + $missingPrereqs = @() + + $psVersion = $PSVersionTable.PSVersion + if ($psVersion.Major -lt 7) { + $missingPrereqs += "PowerShell 7.0+ (currently running: $($psVersion.Major).$($psVersion.Minor).$($psVersion.Patch))" + } + + $requiredModules = @("Az.Accounts", "Az.Compute", "Az.Network", "Az.Resources") + foreach ($moduleName in $requiredModules) { + if (-not (Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue)) { + $missingPrereqs += "$moduleName (required)" + } + } + + write-host "" + write-host "----------------------------------------------------------------------------------------------------------------" + write-host "Prerequisite Check" -ForegroundColor Cyan + write-host "----------------------------------------------------------------------------------------------------------------" + + if ($psVersion.Major -ge 7) { + write-host "✓ PowerShell $($psVersion.Major).$($psVersion.Minor).$($psVersion.Patch)" -ForegroundColor Green + } + foreach ($moduleName in $requiredModules) { + $module = Get-Module -Name $moduleName -ListAvailable -ErrorAction SilentlyContinue + if ($module) { + $version = $module.Version | Sort-Object -Descending | Select-Object -First 1 + write-host "✓ $moduleName (v$version)" -ForegroundColor Green + } + } + + if ($missingPrereqs.Count -gt 0) { + write-host "" + write-host "MISSING PREREQUISITES:" -ForegroundColor Red + foreach ($prereq in $missingPrereqs) { write-host "✗ $prereq" -ForegroundColor Red } + write-host "" + write-host " PowerShell 7+: https://github.com/PowerShell/PowerShell/releases" -ForegroundColor Gray + write-host " Azure PowerShell: Update-Module -Name Az -Force" -ForegroundColor Gray + write-host "----------------------------------------------------------------------------------------------------------------" + exit 1 + } + + write-host "✓ All required prerequisites are installed" -ForegroundColor Green + write-host "----------------------------------------------------------------------------------------------------------------" +} + +Test-PrerequisitesInstalled + +$startTime = Get-Date +$scriptName = $MyInvocation.MyCommand.Name + +# Tag the resource group with the repo it came from, for traceability +$gitRemoteUrl = git remote get-url origin 2>$null +$gitRemoteUrl = $gitRemoteUrl -replace "\.git$", "" +if (-not $gitRemoteUrl) { + $gitRemoteUrl = "[Originally from] https://github.com/Azure-Samples/confidential-computing" +} + +# The cloud-init file that installs the demo web app on every instance +$cloudInitPath = Join-Path $PSScriptRoot "cloud-init-webapp.yaml" +if (-not (Test-Path $cloudInitPath)) { + write-host "ERROR: cannot find cloud-init-webapp.yaml next to this script ($cloudInitPath)" -ForegroundColor Red + exit 1 +} + +# Names for everything we create +$basename = $basename + -join ((97..122) | Get-Random -Count 5 | ForEach-Object {[char]$_}) +$resgrp = $basename +$vmssname = $basename + "vmss" +$vnetname = $basename + "vnet" +$vmsssubnetname = "vmss-subnet" +$appgwsubnetname = "appgw-subnet" +$vmssnsgname = $basename + "-vmss-nsg" +$appgwnsgname = $basename + "-appgw-nsg" +$natGatewayName = $vnetname + "-nat" +$natPublicIpName = $vnetname + "-nat-pip" +$appgwname = $basename + "-appgw" +$appgwPipName = $appgwname + "-pip" +$bastionname = $vnetname + "-bastion" +$bastionPipName = $bastionname + "-pip" +$autoscaleName = $vmssname + "-autoscale" + +$vmusername = "azureuser" +$vmadminpassword = -join ("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%".ToCharArray() | Get-Random -Count 40) +$appPort = 8080 + +write-host "----------------------------------------------------------------------------------------------------------------" +write-host "Building a Confidential VM Scale Set in resource group '$resgrp' in '$region'" +write-host "SKU: $vmsize start: $instanceCount instances autoscale range: $minInstances-$maxInstances" +if ($smoketest) { write-host "SMOKETEST MODE: resources will be deleted automatically at the end" -ForegroundColor Yellow } +if ($EnableBastion) { write-host "BASTION ENABLED: Azure Bastion will be deployed for SSH access" -ForegroundColor Cyan } +if ($NoInternetAccess) { write-host "INTERNET DISABLED: scale set subnet gets no outbound internet" -ForegroundColor Yellow } +else { write-host "INTERNET ENABLED: scale set subnet uses a NAT Gateway for outbound" -ForegroundColor Cyan } +write-host "IMPORTANT" +write-host "VM admin username is $vmusername" +write-host "randomly generated password is $vmadminpassword - save this now as you CANNOT retrieve it later" +write-host "Script: $scriptName" +write-host "Repository URL: $gitRemoteUrl" +write-host "----------------------------------------------------------------------------------------------------------------" + +Set-AzContext -SubscriptionId $subsID | Out-Null +if (!$?) { + write-host "Failed to connect to the Azure subscription $subsID - exiting" + exit +} + +$ownername = (Get-AzContext).Account.Id + +#---------Pre-flight: SKU availability and quota check--------------------------------------------------------------- +# Confirm the SKU is a Confidential VM SKU (AMD SEV-SNP or Intel TDX - not Intel SGX, which is a different +# isolation model), is offered and unrestricted in this region, and that there is enough vCPU quota to reach +# maxInstances. Get-AzComputeResourceSku / Get-AzVMUsage occasionally misreport; use -SkipSkuPreflight to bypass. +if ($SkipSkuPreflight) { + write-host "Pre-flight check SKIPPED (-SkipSkuPreflight). ARM will validate '$vmsize' in '$region' at deploy time." -ForegroundColor Yellow +} +else { + write-host "Pre-flight check: confirming '$vmsize' is available in '$region' with quota for $maxInstances instances..." -ForegroundColor Cyan + + if ($vmsize -match '^Standard_DC\d+s_v[23]$') { + write-host "ERROR: '$vmsize' is an Intel SGX SKU (application-enclave isolation), which is NOT supported by this script." -ForegroundColor Red + write-host "Use a Confidential VM SKU instead: AMD SEV-SNP (DCa*/ECa*, e.g. Standard_DC2as_v5) or Intel TDX (DCe*/ECe*, e.g. Standard_DC2es_v5)." -ForegroundColor Yellow + exit 1 + } + + $skuInfo = $null + try { + $skuInfo = Get-AzComputeResourceSku -Location $region -ErrorAction Stop | + Where-Object { $_.ResourceType -eq 'virtualMachines' -and $_.Name -eq $vmsize } | + Select-Object -First 1 + } catch { + write-host "Warning: could not query Get-AzComputeResourceSku for '$region': $($_.Exception.Message)" -ForegroundColor Yellow + } + + if ($null -eq $skuInfo) { + write-host "ERROR: VM SKU '$vmsize' is not offered in region '$region'." -ForegroundColor Red + write-host "List the Confidential VM SKUs offered there with:" -ForegroundColor Yellow + write-host " Get-AzComputeResourceSku -Location '$region' | Where-Object { `$_.ResourceType -eq 'virtualMachines' -and `$_.Name -match '_(DC|EC)\d+(a|e)' } | Select-Object Name" -ForegroundColor Gray + exit 1 + } + + $subRestriction = $skuInfo.Restrictions | Where-Object { + $_.ReasonCode -eq 'NotAvailableForSubscription' -or + ($_.RestrictionInfo -and $_.RestrictionInfo.Locations -contains $region) -or + ($_.Values -contains $region) + } + if ($subRestriction) { + $reason = ($skuInfo.Restrictions | ForEach-Object { $_.ReasonCode }) -join ', ' + write-host "ERROR: VM SKU '$vmsize' is restricted for this subscription in '$region' (reason: $reason)." -ForegroundColor Red + write-host "Request a quota increase: https://learn.microsoft.com/azure/quotas/quickstart-increase-quota-portal" -ForegroundColor Yellow + exit 1 + } + + $skuVCpus = ($skuInfo.Capabilities | Where-Object { $_.Name -eq 'vCPUs' } | Select-Object -First 1).Value -as [int] + if (-not $skuVCpus) { $skuVCpus = 2 } + $neededVCpus = $skuVCpus * $maxInstances + $skuFamily = $skuInfo.Family + + try { + $usage = Get-AzVMUsage -Location $region -ErrorAction Stop | Where-Object { $_.Name.Value -eq $skuFamily } | Select-Object -First 1 + if ($usage) { + $available = [int]$usage.Limit - [int]$usage.CurrentValue + write-host ("Quota for {0} in {1}: {2}/{3} used, {4} vCPUs available, scaling to {5} instances needs {6}." -f ` + $skuFamily, $region, $usage.CurrentValue, $usage.Limit, $available, $maxInstances, $neededVCpus) -ForegroundColor Cyan + if ($available -lt $neededVCpus) { + write-host "ERROR: insufficient vCPU quota in family '$skuFamily' in '$region' to scale to $maxInstances instances." -ForegroundColor Red + write-host "Lower -maxInstances, pick another region, or request a quota increase." -ForegroundColor Yellow + exit 1 + } + } + } catch { + write-host "Warning: Get-AzVMUsage failed for '$region': $($_.Exception.Message). Proceeding without quota check." -ForegroundColor Yellow + } + + write-host "Pre-flight check passed." -ForegroundColor Green +} + +#---------Resource group----------------------------------------------------------------------------------------------- +$resourceGroupTags = @{ + owner = $ownername + BuiltBy = $scriptName + OSType = "Ubuntu2404CVM" + GitRepo = $gitRemoteUrl +} +if ($description -ne "") { $resourceGroupTags.Add("description", $description) } +if ($smoketest) { $resourceGroupTags.Add("smoketest", "true") } +if ($NoInternetAccess) { $resourceGroupTags.Add("NoInternetAccess", "true") } + +New-AzResourceGroup -Name $resgrp -Location $region -Tag $resourceGroupTags -Force | Out-Null +write-host "Resource group '$resgrp' created." -ForegroundColor Green + +#---------Network security groups-------------------------------------------------------------------------------------- +# Scale set instances only accept the app port, and only from the Application Gateway subnet. +$vmssNsgRules = @( + New-AzNetworkSecurityRuleConfig -Name "allow-appgw-to-app" -Description "App traffic from the Application Gateway subnet only" ` + -Access Allow -Protocol Tcp -Direction Inbound -Priority 100 ` + -SourceAddressPrefix "10.0.1.0/24" -SourcePortRange * -DestinationAddressPrefix * -DestinationPortRange $appPort +) +$vmssNsg = New-AzNetworkSecurityGroup -ResourceGroupName $resgrp -Location $region -Name $vmssnsgname -SecurityRules $vmssNsgRules -Force + +# Application Gateway v2 requires inbound 65200-65535 from the GatewayManager service tag for control plane health. +$appgwNsgRules = @( + New-AzNetworkSecurityRuleConfig -Name "allow-gatewaymanager" -Description "Required by Application Gateway v2 control plane" ` + -Access Allow -Protocol Tcp -Direction Inbound -Priority 100 ` + -SourceAddressPrefix GatewayManager -SourcePortRange * -DestinationAddressPrefix * -DestinationPortRange 65200-65535 + New-AzNetworkSecurityRuleConfig -Name "allow-http-inbound" -Description "Public HTTP to the gateway frontend" ` + -Access Allow -Protocol Tcp -Direction Inbound -Priority 110 ` + -SourceAddressPrefix Internet -SourcePortRange * -DestinationAddressPrefix * -DestinationPortRange 80 +) +$appgwNsg = New-AzNetworkSecurityGroup -ResourceGroupName $resgrp -Location $region -Name $appgwnsgname -SecurityRules $appgwNsgRules -Force +write-host "Network security groups created." -ForegroundColor Green + +#---------Virtual network---------------------------------------------------------------------------------------------- +$subnets = @( + New-AzVirtualNetworkSubnetConfig -Name $vmsssubnetname -AddressPrefix "10.0.0.0/24" -NetworkSecurityGroup $vmssNsg -DefaultOutboundAccess $false + New-AzVirtualNetworkSubnetConfig -Name $appgwsubnetname -AddressPrefix "10.0.1.0/24" -NetworkSecurityGroup $appgwNsg +) +if ($EnableBastion) { + $subnets += New-AzVirtualNetworkSubnetConfig -Name "AzureBastionSubnet" -AddressPrefix "10.0.99.0/26" +} + +New-AzVirtualNetwork -Force -Name $vnetname -ResourceGroupName $resgrp -Location $region -AddressPrefix "10.0.0.0/16" -Subnet $subnets | Out-Null +$vnet = Get-AzVirtualNetwork -Name $vnetname -ResourceGroupName $resgrp +write-host "Virtual network '$vnetname' created." -ForegroundColor Green + +#---------Outbound internet via NAT Gateway (no public IP on the instances)-------------------------------------------- +if ($NoInternetAccess) { + write-host "No NAT Gateway attached to the scale set subnet due to -NoInternetAccess." -ForegroundColor Yellow +} +else { + write-host "Configuring NAT Gateway egress so instances reach the internet without a public IP..." -ForegroundColor Cyan + $natPublicIp = New-AzPublicIpAddress -ResourceGroupName $resgrp -Name $natPublicIpName -Location $region -AllocationMethod Static -Sku Standard + $natGateway = New-AzNatGateway -ResourceGroupName $resgrp -Name $natGatewayName -Location $region -IdleTimeoutInMinutes 10 -Sku Standard -PublicIpAddress $natPublicIp + + # Some Az.Network versions do not persist the NAT association through Set-AzVirtualNetworkSubnetConfig, + # so verify afterwards and fall back to Azure CLI if needed. + Set-AzVirtualNetworkSubnetConfig -Name $vmsssubnetname -VirtualNetwork $vnet -AddressPrefix "10.0.0.0/24" ` + -NetworkSecurityGroup $vmssNsg -DefaultOutboundAccess $false -InputObject $natGateway | Set-AzVirtualNetwork | Out-Null + $vnet = Get-AzVirtualNetwork -Name $vnetname -ResourceGroupName $resgrp + $vmssSubnet = $vnet.Subnets | Where-Object { $_.Name -eq $vmsssubnetname } | Select-Object -First 1 + + if (-not $vmssSubnet.NatGateway -or -not $vmssSubnet.NatGateway.Id) { + $azCli = Get-Command az -ErrorAction SilentlyContinue + if ($azCli) { + write-host "Az.Network did not persist the NAT association; falling back to Azure CLI..." -ForegroundColor Yellow + & $azCli.Source network vnet subnet update --resource-group $resgrp --vnet-name $vnetname --name $vmsssubnetname --nat-gateway $natGatewayName --only-show-errors -o none + if ($LASTEXITCODE -ne 0) { throw "Failed to attach NAT Gateway '$natGatewayName' to subnet '$vmsssubnetname' via Azure CLI." } + $vnet = Get-AzVirtualNetwork -Name $vnetname -ResourceGroupName $resgrp + $vmssSubnet = $vnet.Subnets | Where-Object { $_.Name -eq $vmsssubnetname } | Select-Object -First 1 + } + } + if (-not $vmssSubnet.NatGateway -or -not $vmssSubnet.NatGateway.Id) { + throw "NAT Gateway '$natGatewayName' is not associated with subnet '$vmsssubnetname'." + } + write-host "NAT Gateway '$natGatewayName' attached to subnet '$vmsssubnetname'." -ForegroundColor Green +} + +$vmssSubnetId = ($vnet.Subnets | Where-Object { $_.Name -eq $vmsssubnetname } | Select-Object -First 1).Id +$appgwSubnetId = ($vnet.Subnets | Where-Object { $_.Name -eq $appgwsubnetname } | Select-Object -First 1).Id + +#---------Application Gateway (the only public entry point to the app)------------------------------------------------- +write-host "Creating Application Gateway '$appgwname' (this typically takes several minutes)..." -ForegroundColor Cyan +$appgwPip = New-AzPublicIpAddress -ResourceGroupName $resgrp -Name $appgwPipName -Location $region -AllocationMethod Static -Sku Standard + +$agIpConfig = New-AzApplicationGatewayIPConfiguration -Name "appgw-ipcfg" -SubnetId $appgwSubnetId +$agFrontIp = New-AzApplicationGatewayFrontendIPConfig -Name "appgw-feip" -PublicIPAddressId $appgwPip.Id +$agFrontPort = New-AzApplicationGatewayFrontendPort -Name "port80" -Port 80 +$agPool = New-AzApplicationGatewayBackendAddressPool -Name "vmss-pool" +$agProbe = New-AzApplicationGatewayProbeConfig -Name "app-health" -Protocol Http -Path "/health" ` + -Interval 15 -Timeout 10 -UnhealthyThreshold 3 -PickHostNameFromBackendHttpSettings +# RequestTimeout is generous because /burn deliberately holds a request open while it consumes CPU. +$agSettings = New-AzApplicationGatewayBackendHttpSetting -Name "app-http" -Port $appPort -Protocol Http ` + -CookieBasedAffinity Disabled -RequestTimeout 180 -Probe $agProbe -PickHostNameFromBackendAddress +$agListener = New-AzApplicationGatewayHttpListener -Name "http-listener" -Protocol Http ` + -FrontendIPConfiguration $agFrontIp -FrontendPort $agFrontPort +$agRule = New-AzApplicationGatewayRequestRoutingRule -Name "http-rule" -RuleType Basic -Priority 100 ` + -HttpListener $agListener -BackendAddressPool $agPool -BackendHttpSettings $agSettings +$agSku = New-AzApplicationGatewaySku -Name Standard_v2 -Tier Standard_v2 -Capacity 2 + +$appgw = New-AzApplicationGateway -Name $appgwname -ResourceGroupName $resgrp -Location $region ` + -Sku $agSku -GatewayIPConfigurations $agIpConfig -FrontendIPConfigurations $agFrontIp ` + -FrontendPorts $agFrontPort -BackendAddressPools $agPool -BackendHttpSettingsCollection $agSettings ` + -HttpListeners $agListener -RequestRoutingRules $agRule -Probes $agProbe + +$agPoolId = ($appgw.BackendAddressPools | Where-Object { $_.Name -eq "vmss-pool" }).Id +write-host "Application Gateway created." -ForegroundColor Green + +#---------Confidential VM Scale Set------------------------------------------------------------------------------------ +# cloud-init has to be passed as base64 in the OS profile custom data +$cloudInit = Get-Content -Path $cloudInitPath -Raw +$customData = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($cloudInit)) + +write-host "Creating Confidential VM Scale Set '$vmssname'..." -ForegroundColor Cyan + +# Uniform orchestration keeps the autoscale + Application Gateway backend wiring simple for this demo. +$vmssConfig = New-AzVmssConfig -Location $region -SkuCapacity $instanceCount -SkuName $vmsize ` + -UpgradePolicyMode "Automatic" -Overprovision $false -OrchestrationMode "Uniform" + +# securityEncryptionType VMGuestStateOnly encrypts the VM guest state with a platform-managed key and +# needs no Key Vault or disk encryption set. For Confidential OS disk encryption bound to a customer +# managed key, see ../vm-samples/BuildRandomCVM.ps1. +$vmssConfig = Set-AzVmssStorageProfile -VirtualMachineScaleSet $vmssConfig ` + -OsDiskCreateOption "FromImage" -OsDiskCaching "ReadOnly" -ManagedDisk "StandardSSD_LRS" ` + -SecurityEncryptionType "VMGuestStateOnly" ` + -ImageReferencePublisher "Canonical" -ImageReferenceOffer "ubuntu-24_04-lts" ` + -ImageReferenceSku "cvm" -ImageReferenceVersion "latest" + +$vmssConfig = Set-AzVmssOsProfile -VirtualMachineScaleSet $vmssConfig ` + -ComputerNamePrefix ($basename.Substring(0, [Math]::Min(9, $basename.Length))) ` + -AdminUsername $vmusername -AdminPassword $vmadminpassword ` + -CustomData $customData -LinuxConfigurationDisablePasswordAuthentication $false + +$vmssConfig = Set-AzVmssSecurityProfile -VirtualMachineScaleSet $vmssConfig -SecurityType "ConfidentialVM" +$vmssConfig = Set-AzVmssUefi -VirtualMachineScaleSet $vmssConfig -EnableVtpm $true -EnableSecureBoot $true + +# No PublicIPAddressConfiguration here, so instances are reachable only from inside the VNet. +$ipConfig = New-AzVmssIpConfig -Name "vmss-ipcfg" -SubnetId $vmssSubnetId -Primary $true ` + -ApplicationGatewayBackendAddressPoolsId $agPoolId +$vmssConfig = Add-AzVmssNetworkInterfaceConfiguration -VirtualMachineScaleSet $vmssConfig ` + -Name "vmss-nic" -Primary $true -IPConfiguration $ipConfig -NetworkSecurityGroupId $vmssNsg.Id + +try { + New-AzVmss -ResourceGroupName $resgrp -VMScaleSetName $vmssname -VirtualMachineScaleSet $vmssConfig -ErrorAction Stop | Out-Null +} catch { + throw "Scale set deployment failed for '$vmssname' in '$region': $($_.Exception.Message)" +} +$vmss = Get-AzVmss -ResourceGroupName $resgrp -VMScaleSetName $vmssname +write-host "Scale set '$vmssname' created with $instanceCount instances." -ForegroundColor Green + +#---------Autoscale rules---------------------------------------------------------------------------------------------- +# Created through the raw ARM resource type rather than the Az.Monitor autoscale cmdlets, whose names and +# parameters changed between module versions. +write-host "Configuring CPU autoscale ($minInstances-$maxInstances instances)..." -ForegroundColor Cyan + +# Autoscale settings live in Microsoft.Insights, which is not registered on every subscription. +$insightsProvider = Get-AzResourceProvider -ProviderNamespace Microsoft.Insights -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $insightsProvider -or $insightsProvider.RegistrationState -ne 'Registered') { + write-host "Registering the Microsoft.Insights resource provider (required for autoscale)..." -ForegroundColor Yellow + Register-AzResourceProvider -ProviderNamespace Microsoft.Insights | Out-Null + for ($i = 1; $i -le 30; $i++) { + Start-Sleep -Seconds 10 + $state = (Get-AzResourceProvider -ProviderNamespace Microsoft.Insights -ErrorAction SilentlyContinue | Select-Object -First 1).RegistrationState + if ($state -eq 'Registered') { break } + write-host " waiting for Microsoft.Insights registration ($i/30, currently '$state')..." -ForegroundColor DarkGray + } + if ($state -ne 'Registered') { + throw "Microsoft.Insights did not reach 'Registered' state; autoscale cannot be configured." + } + write-host "Microsoft.Insights registered." -ForegroundColor Green +} + +$autoscaleProperties = @{ + name = $autoscaleName + targetResourceUri = $vmss.Id + enabled = $true + profiles = @( + @{ + name = "cpu-based-scaling" + capacity = @{ + minimum = "$minInstances" + maximum = "$maxInstances" + default = "$instanceCount" + } + rules = @( + @{ + metricTrigger = @{ + metricName = "Percentage CPU" + metricResourceUri = $vmss.Id + timeGrain = "PT1M" + statistic = "Average" + timeWindow = "PT5M" + timeAggregation = "Average" + operator = "GreaterThan" + threshold = 60 + } + scaleAction = @{ direction = "Increase"; type = "ChangeCount"; value = "1"; cooldown = "PT5M" } + }, + @{ + metricTrigger = @{ + metricName = "Percentage CPU" + metricResourceUri = $vmss.Id + timeGrain = "PT1M" + statistic = "Average" + timeWindow = "PT5M" + timeAggregation = "Average" + operator = "LessThan" + threshold = 30 + } + scaleAction = @{ direction = "Decrease"; type = "ChangeCount"; value = "1"; cooldown = "PT5M" } + } + ) + } + ) +} + +try { + New-AzResource -ResourceType "Microsoft.Insights/autoscalesettings" -ResourceGroupName $resgrp ` + -ResourceName $autoscaleName -Location $region -Properties $autoscaleProperties -Force -ErrorAction Stop | Out-Null +} catch { + throw "Failed to create autoscale setting '$autoscaleName': $($_.Exception.Message)" +} +write-host "Autoscale configured: out at >60% CPU, in at <30% CPU (5 minute windows, 5 minute cooldown)." -ForegroundColor Green + +#---------Optional Azure Bastion--------------------------------------------------------------------------------------- +if ($EnableBastion) { + write-host "Creating Azure Bastion (this takes several minutes)..." -ForegroundColor Cyan + $bastionPip = New-AzPublicIpAddress -ResourceGroupName $resgrp -Name $bastionPipName -Location $region -AllocationMethod Static -Sku Standard + New-AzBastion -ResourceGroupName $resgrp -Name $bastionname -PublicIpAddressRgName $resgrp -PublicIpAddressName $bastionPip.Name ` + -VirtualNetworkRgName $resgrp -VirtualNetworkName $vnetname -Sku "Basic" | Out-Null + write-host "Azure Bastion '$bastionname' created." -ForegroundColor Green +} + +#---------Wait for the app to come up behind the gateway--------------------------------------------------------------- +$appgwIp = (Get-AzPublicIpAddress -ResourceGroupName $resgrp -Name $appgwPipName).IpAddress +$appUrl = "http://$appgwIp" + +write-host "Waiting for the backend instances to report healthy through the Application Gateway..." -ForegroundColor Cyan +$healthy = $false +for ($i = 1; $i -le 30; $i++) { + try { + $resp = Invoke-WebRequest -Uri "$appUrl/health" -TimeoutSec 10 -UseBasicParsing -ErrorAction Stop + if ($resp.StatusCode -eq 200) { $healthy = $true; break } + } catch { + # cloud-init still running, or the gateway has not finished probing the pool yet + } + write-host " attempt $i/30: app not answering yet, waiting 20s..." -ForegroundColor DarkGray + Start-Sleep -Seconds 20 +} + +$elapsed = (Get-Date) - $startTime +write-host "" +write-host "----------------------------------------------------------------------------------------------------------------" +if ($healthy) { + write-host "BUILD COMPLETE - the app is live" -ForegroundColor Green +} else { + write-host "BUILD COMPLETE - but the app did not answer within the wait window" -ForegroundColor Yellow + write-host "Check backend health with: Get-AzApplicationGatewayBackendHealth -ResourceGroupName $resgrp -Name $appgwname" -ForegroundColor Gray +} +write-host "----------------------------------------------------------------------------------------------------------------" +write-host "Resource group : $resgrp ($region)" +write-host "Scale set : $vmssname SKU $vmsize autoscale $minInstances-$maxInstances" +write-host "App URL : $appUrl" +write-host "Admin user : $vmusername" +write-host "Admin password : $vmadminpassword" +write-host "Build time : $([Math]::Round($elapsed.TotalMinutes, 1)) minutes" +write-host "" +write-host "Drive load until the scale set grows:" -ForegroundColor Cyan +write-host " ./Start-VmssLoad.ps1 -Target $appUrl -subsID $subsID -ResourceGroup $resgrp -VmssName $vmssname" -ForegroundColor Gray +write-host "" +write-host "Delete everything when you are done:" -ForegroundColor Cyan +write-host " Remove-AzResourceGroup -Name $resgrp -Force" -ForegroundColor Gray +write-host "----------------------------------------------------------------------------------------------------------------" + +if ($smoketest) { + write-host "" + write-host "SMOKETEST MODE: removing resource group '$resgrp'..." -ForegroundColor Yellow + Remove-AzResourceGroup -Name $resgrp -Force | Out-Null + write-host "Resource group '$resgrp' deleted." -ForegroundColor Green +} diff --git a/vmss-samples/README.md b/vmss-samples/README.md new file mode 100644 index 0000000..68cfa38 --- /dev/null +++ b/vmss-samples/README.md @@ -0,0 +1,195 @@ +# Confidential VM Scale Sets + +**Last Updated:** August 2026 + +## Overview + +Deploy a **Confidential Virtual Machine Scale Set** (AMD SEV-SNP by default) running a small web application, fronted by an **Application Gateway**, with **CPU-based autoscale** capped at 5 instances. A companion load-generation script runs from your own machine and drives enough CPU load to make the scale set grow, then shrink again when the load stops. + +The scale set instances have **no public IP**. The only public entry point is the Application Gateway; outbound internet (if enabled) goes through a NAT Gateway; optional Azure Bastion provides SSH access to individual instances. + +> 📚 New to Azure Confidential Computing? Start at [`https://aka.ms/accdocs`](https://aka.ms/accdocs), including the [Confidential VM overview](https://learn.microsoft.com/azure/confidential-computing/confidential-vm-overview). + +``` + Internet + │ + ▼ + ┌───────────────────────────────┐ + │ Application Gateway v2 │ public IP, HTTP :80 + │ probe /health, backend :8080 │ appgw-subnet 10.0.1.0/24 + └───────────────┬───────────────┘ + │ (only allowed inbound path) + ┌───────────────────▼────────────────────────────────────┐ + │ Confidential VM Scale Set - vmss-subnet 10.0.0.0/24 │ + │ no public IPs, autoscale 2 → 5 on CPU │ + │ │ + │ ┌───────────────┐ ┌───────────────┐ ┌────────────┐ │ + │ │ Instance 0 │ │ Instance 1 │ │ Instance n │ │ + │ │ Ubuntu 24.04 │ │ Ubuntu 24.04 │ │ ... │ │ + │ │ CVM image │ │ CVM image │ │ │ │ + │ │ demo web app │ │ demo web app │ │ │ │ + │ │ AMD SEV-SNP │ │ AMD SEV-SNP │ │ │ │ + │ └───────────────┘ └───────────────┘ └────────────┘ │ + └───────────────────┬─────────────────────────────────────┘ + │ outbound only + ▼ + ┌─────────────┐ + │ NAT Gateway │ + └─────────────┘ +``` + +## Files + +| File | Description | +|------|-------------| +| `BuildRandomCVMSS.ps1` | Builds the resource group, network, Application Gateway, Confidential VMSS and autoscale rules | +| `Start-VmssLoad.ps1` | Generates load from your machine and reports the live instance count | +| `cloud-init-webapp.yaml` | cloud-init that installs the demo web app on every instance | + +--- + +## BuildRandomCVMSS.ps1 + +Creates a resource group named `` + 5 random lower-case letters, so you can run it repeatedly without name collisions, and tags the group with your identity, the script name and the git remote URL. + +What it deploys: + +- **Confidential VM Scale Set** — Ubuntu 24.04 LTS `cvm` image, `Standard_DC2as_v5` (AMD SEV-SNP), vTPM and Secure Boot enabled, Uniform orchestration +- **Application Gateway Standard_v2** — public HTTP frontend on port 80, health probe on `/health`, backend on port 8080, with the scale set wired into its backend pool +- **Autoscale** — scale out +1 when average CPU > 60% over 5 minutes; scale in −1 when average CPU < 30% over 5 minutes; 5 minute cooldown; range 2–5 instances +- **NAT Gateway** — outbound internet without any public IP on the instances (skip with `-NoInternetAccess`) +- **NSGs** — the scale set subnet accepts port 8080 only from the Application Gateway subnet +- **Azure Bastion** — optional, with `-EnableBastion` + +### Prerequisites + +- **Azure subscription** with Contributor or Owner rights +- **PowerShell 7.0+** +- **Azure PowerShell** modules `Az.Accounts`, `Az.Compute`, `Az.Network`, `Az.Resources` (Az 9.0 or later) — `Update-Module -Name Az -Force` +- **Confidential VM quota** in the target region for `maxInstances × vCPUs` (10 vCPUs with the defaults). The script pre-flights both SKU availability and quota, and stops with guidance if either is short. + +### Usage + +```powershell +# Defaults: northeurope, Standard_DC2as_v5, start at 2 instances, autoscale 2-5 +./BuildRandomCVMSS.ps1 -subsID -basename mydemo + +# Larger SKU, different region, wider scale range, plus Bastion for SSH access +./BuildRandomCVMSS.ps1 -subsID -basename mydemo ` + -region westeurope -vmsize Standard_DC4as_v5 -minInstances 2 -maxInstances 5 -EnableBastion + +# Build and tear straight back down again (CI smoke test) +./BuildRandomCVMSS.ps1 -subsID -basename mydemo -smoketest +``` + +### Parameters + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `-subsID` | *(required)* | Target subscription ID | +| `-basename` | *(required)* | Resource name prefix; 5 random letters are appended | +| `-region` | `northeurope` | Must offer your chosen Confidential VM SKU | +| `-vmsize` | `Standard_DC2as_v5` | AMD SEV-SNP (`DCa*`/`ECa*`) or Intel TDX (`DCe*`/`ECe*`). Intel SGX SKUs are rejected | +| `-instanceCount` | `2` | Starting and default capacity | +| `-minInstances` | `2` | Autoscale floor | +| `-maxInstances` | `5` | Autoscale ceiling | +| `-description` | *(none)* | Added as a resource group tag | +| `-EnableBastion` | off | Deploy Azure Bastion for SSH into instances | +| `-NoInternetAccess` | off | Do not attach the NAT Gateway | +| `-SkipSkuPreflight` | off | Skip the SKU/quota pre-flight check | +| `-smoketest` | off | Delete the resource group when the script finishes | + +When the build completes the script prints the app URL, the generated admin password (which cannot be retrieved later), and the exact `Start-VmssLoad.ps1` command to run. + +--- + +## The demo application + +`cloud-init-webapp.yaml` installs a small Python service as a systemd unit on port 8080. It uses only the Python 3 standard library that already ships in the Ubuntu CVM image, so it still starts when the subnet has no outbound internet access. + +| Endpoint | Purpose | +|----------|---------| +| `/` | HTML page showing the instance name, detected isolation type, vCPU count and requests served | +| `/health` | Application Gateway health probe | +| `/info` | Same details as JSON | +| `/burn?seconds=N` | Consumes N CPU-seconds (capped at 120) and returns JSON including the instance name | + +The isolation type comes from `systemd-detect-virt --cvm`, which reports `sev-snp` or `tdx` and works without root. If neither is reported the page says so — a quick confirmation that you really are inside a Confidential VM. Note that the `/dev/sev-guest` device node is **not** present on the Azure Ubuntu CVM image, so it is only used as a fallback for other distributions. + +`/burn` hashes a 4 KB buffer in a loop. `hashlib` releases the Python GIL for buffers that size, so concurrent requests spread across the instance's vCPUs and actually move the `Percentage CPU` metric that autoscale watches. + +--- + +## Start-VmssLoad.ps1 + +Runs from your machine and only needs outbound HTTP to the Application Gateway. It starts a pool of parallel workers calling `/burn`, prints progress every 30 seconds, and — if you pass the subscription, resource group and scale set names — polls and prints the live instance count. + +```powershell +# Command printed at the end of the build +./Start-VmssLoad.ps1 -Target http:// -subsID -ResourceGroup -VmssName + +# Heavier load, and keep watching for 20 minutes afterwards to see the scale-in +./Start-VmssLoad.ps1 -Target http:// -Concurrency 32 -DurationMinutes 20 -CooldownMinutes 20 ` + -subsID -ResourceGroup -VmssName +``` + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `-Target` | *(required)* | Application Gateway URL or IP | +| `-Concurrency` | `16` | Parallel workers; raise this if CPU never crosses the threshold | +| `-DurationMinutes` | `15` | How long to sustain the load | +| `-BurnSeconds` | `20` | CPU-seconds consumed per request | +| `-CooldownMinutes` | `0` | Keep watching, with no load, to observe the scale-in | +| `-subsID`, `-ResourceGroup`, `-VmssName` | *(none)* | Supply all three to display live instance counts | + +### What to expect + +1. The first 5 minutes produce **no scaling** — autoscale averages CPU over a 5 minute window. +2. Around **5–10 minutes** in, capacity increases by 1. Further increases follow at 5 minute cooldown intervals up to `maxInstances`. +3. New instances take a few minutes to boot, run cloud-init and pass the gateway health probe before they start answering. The "instances answering" counter reflects this. +4. After the load stops, CPU has to average below 30% for 5 minutes before the first scale-in, then one instance is removed per cooldown period until `minInstances`. + +Allow roughly 40–60 minutes end to end to watch a full scale-out and scale-in cycle. If CPU never reaches the threshold, increase `-Concurrency`. + +--- + +## Verifying it worked + +```powershell +# Current capacity +(Get-AzVmss -ResourceGroupName -VMScaleSetName ).Sku.Capacity + +# Per-instance view +Get-AzVmssVM -ResourceGroupName -VMScaleSetName | Select-Object InstanceId, Name, ProvisioningState + +# Confirm the confidential security profile is applied +(Get-AzVmss -ResourceGroupName -VMScaleSetName ).VirtualMachineProfile.SecurityProfile + +# Application Gateway backend health +Get-AzApplicationGatewayBackendHealth -ResourceGroupName -Name + +# Autoscale actions taken +Get-AzLog -ResourceGroupName -StartTime (Get-Date).AddHours(-2) | + Where-Object { $_.OperationName.Value -like '*Autoscale*' } | + Select-Object EventTimestamp, Status, Description +``` + +Browsing to the app URL and refreshing repeatedly shows different instance names as the Application Gateway round-robins across the backend pool. + +## Cleaning up + +Everything lives in one resource group: + +```powershell +Remove-AzResourceGroup -Name -Force +``` + +The Application Gateway, NAT Gateway and (if deployed) Azure Bastion are billed hourly whether or not traffic flows, so delete the group as soon as you are done. Use `-smoketest` for unattended runs. + +## Notes and limitations + +- **Disk encryption**: instances use `VMGuestStateOnly`, which protects the VM guest state with a platform-managed key and requires no Key Vault. For **Confidential OS disk encryption bound to a customer managed key**, see [`../vm-samples/BuildRandomCVM.ps1`](../vm-samples/README.md). +- **No attestation step**: unlike the single-VM sample, this one focuses on scaling behaviour. The app reports the isolation type from the guest device, but does not perform a full MAA attestation flow. See [`../vm-samples`](../vm-samples/README.md) and [`../attestation-samples`](../attestation-samples/README.md) for that. +- **Uniform orchestration** is used because it keeps the autoscale and Application Gateway backend-pool wiring straightforward. Flexible orchestration is the general recommendation for new production scale sets. +- **HTTP only**: the gateway listener is plain HTTP to keep the sample self-contained. Put TLS in front of anything real — see [`../automotive-machine-vision`](../automotive-machine-vision/README.md) for a certificate-based example. +- **Password authentication** is enabled on the instances with a randomly generated password, matching the other samples in this repo. Because there is no public IP, access requires Bastion or private connectivity. diff --git a/vmss-samples/Start-VmssLoad.ps1 b/vmss-samples/Start-VmssLoad.ps1 new file mode 100644 index 0000000..0eafa95 --- /dev/null +++ b/vmss-samples/Start-VmssLoad.ps1 @@ -0,0 +1,183 @@ +# Generates sustained HTTP load against the Confidential VMSS demo app so that the CPU autoscale +# rules created by BuildRandomCVMSS.ps1 trigger a scale-out, then a scale-in once the load stops. +# +# Run this from your own machine - it only needs outbound HTTP to the Application Gateway public IP. +# If you also pass the subscription/resource group/scale set names it will poll and print the live +# instance count so you can watch the scale set grow and shrink. +# +# Usage: ./Start-VmssLoad.ps1 -Target http:// [-Concurrency 16] [-DurationMinutes 15] +# [-BurnSeconds 20] [-subsID ] [-ResourceGroup ] [-VmssName ] [-CooldownMinutes 15] +# +# Concurrency number of parallel workers hammering /burn - raise it if CPU does not reach the threshold +# DurationMinutes how long to keep the load on. Autoscale averages CPU over 5 minutes, so use 10+ minutes +# BurnSeconds CPU seconds consumed by each request +# CooldownMinutes keep watching (with no load) after the run to observe the scale-in + +param ( + [Parameter(Mandatory)]$Target, + [Parameter(Mandatory=$false)][int]$Concurrency = 16, + [Parameter(Mandatory=$false)][int]$DurationMinutes = 15, + [Parameter(Mandatory=$false)][int]$BurnSeconds = 20, + [Parameter(Mandatory=$false)]$subsID = "", + [Parameter(Mandatory=$false)]$ResourceGroup = "", + [Parameter(Mandatory=$false)]$VmssName = "", + [Parameter(Mandatory=$false)][int]$CooldownMinutes = 0 +) + +if ($Concurrency -lt 1 -or $DurationMinutes -lt 1 -or $BurnSeconds -lt 1) { + write-host "Concurrency, DurationMinutes and BurnSeconds must all be at least 1" -ForegroundColor Red + exit 1 +} + +if ($Target -notmatch '^https?://') { $Target = "http://$Target" } +$Target = $Target.TrimEnd('/') + +# Only track the scale set capacity if we were given everything needed to query it +$trackVmss = ($subsID -ne "" -and $ResourceGroup -ne "" -and $VmssName -ne "") +if ($trackVmss) { + if (-not (Get-Module -Name Az.Compute -ListAvailable -ErrorAction SilentlyContinue)) { + write-host "Az.Compute is not installed, so instance counts will not be shown." -ForegroundColor Yellow + $trackVmss = $false + } else { + Set-AzContext -SubscriptionId $subsID | Out-Null + } +} + +function Get-VmssCapacity { + if (-not $trackVmss) { return $null } + try { + $vmss = Get-AzVmss -ResourceGroupName $ResourceGroup -VMScaleSetName $VmssName -ErrorAction Stop + return [int]$vmss.Sku.Capacity + } catch { + return $null + } +} + +write-host "----------------------------------------------------------------------------------------------------------------" +write-host "Confidential VMSS load generator" +write-host "----------------------------------------------------------------------------------------------------------------" +write-host "Target : $Target" +write-host "Concurrency : $Concurrency workers" +write-host "Duration : $DurationMinutes minutes ($BurnSeconds CPU-seconds per request)" +if ($trackVmss) { write-host "Watching : $VmssName in $ResourceGroup" } +write-host "----------------------------------------------------------------------------------------------------------------" + +# Fail fast if the app is not reachable rather than burning minutes on a dead endpoint +try { + Invoke-WebRequest -Uri "$Target/health" -TimeoutSec 15 -UseBasicParsing -ErrorAction Stop | Out-Null + write-host "Health check passed." -ForegroundColor Green +} catch { + write-host "ERROR: cannot reach $Target/health - $($_.Exception.Message)" -ForegroundColor Red + write-host "Check the Application Gateway backend health and that the app finished installing." -ForegroundColor Yellow + exit 1 +} + +$startCapacity = Get-VmssCapacity +if ($null -ne $startCapacity) { write-host "Starting instance count: $startCapacity" -ForegroundColor Cyan } + +# Each worker writes only its own key, so a concurrent dictionary is enough - no locking needed. +$stats = [System.Collections.Concurrent.ConcurrentDictionary[string, object]]::new() +$endTime = (Get-Date).AddMinutes($DurationMinutes) + +$worker = { + param($url, $burnSeconds, $endTimeTicks, $workerId, $stats) + + $requests = 0 + $errors = 0 + $hosts = @{} + $deadline = [datetime]::new($endTimeTicks) + + while ((Get-Date) -lt $deadline) { + try { + $result = Invoke-RestMethod -Uri "$url/burn?seconds=$burnSeconds" -TimeoutSec ($burnSeconds + 120) -ErrorAction Stop + $requests++ + if ($result.host) { + if ($hosts.ContainsKey($result.host)) { $hosts[$result.host]++ } else { $hosts[$result.host] = 1 } + } + } catch { + $errors++ + Start-Sleep -Seconds 2 + } + $stats[$workerId] = @{ Requests = $requests; Errors = $errors; Hosts = $hosts.Clone() } + } + $stats[$workerId] = @{ Requests = $requests; Errors = $errors; Hosts = $hosts.Clone() } +} + +$pool = [runspacefactory]::CreateRunspacePool(1, $Concurrency) +$pool.Open() +$jobs = @() + +for ($i = 1; $i -le $Concurrency; $i++) { + $ps = [powershell]::Create() + $ps.RunspacePool = $pool + [void]$ps.AddScript($worker).AddArgument($Target).AddArgument($BurnSeconds).AddArgument($endTime.Ticks).AddArgument("worker$i").AddArgument($stats) + $jobs += [pscustomobject]@{ Shell = $ps; Handle = $ps.BeginInvoke() } +} + +write-host "" +write-host "Load started - autoscale evaluates a 5 minute average, so expect the first scale-out after roughly 5-10 minutes." -ForegroundColor Cyan +write-host "Press Ctrl+C to stop early (the workers stop on their own at the end of the run)." -ForegroundColor DarkGray +write-host "" + +function Show-Progress($label) { + $snapshot = @($stats.Values) + $totalRequests = ($snapshot | ForEach-Object { $_.Requests } | Measure-Object -Sum).Sum + $totalErrors = ($snapshot | ForEach-Object { $_.Errors } | Measure-Object -Sum).Sum + $instancesSeen = @{} + foreach ($entry in $snapshot) { + foreach ($key in $entry.Hosts.Keys) { $instancesSeen[$key] = $true } + } + $capacity = Get-VmssCapacity + $capacityText = if ($null -ne $capacity) { "capacity $capacity" } else { "capacity n/a" } + write-host ("[{0}] {1} | requests {2} | errors {3} | instances answering {4} | {5}" -f ` + (Get-Date -Format 'HH:mm:ss'), $label, ($totalRequests ?? 0), ($totalErrors ?? 0), $instancesSeen.Count, $capacityText) +} + +while ((Get-Date) -lt $endTime) { + Start-Sleep -Seconds 30 + Show-Progress "load on " +} + +foreach ($job in $jobs) { + try { $job.Shell.EndInvoke($job.Handle) | Out-Null } catch { } + $job.Shell.Dispose() +} +$pool.Close() +$pool.Dispose() + +write-host "" +write-host "Load stopped." -ForegroundColor Green +Show-Progress "final " + +$finalSnapshot = @($stats.Values) +$instanceCounts = @{} +foreach ($entry in $finalSnapshot) { + foreach ($key in $entry.Hosts.Keys) { + if ($instanceCounts.ContainsKey($key)) { $instanceCounts[$key] += $entry.Hosts[$key] } else { $instanceCounts[$key] = $entry.Hosts[$key] } + } +} + +write-host "" +write-host "Requests served per instance:" -ForegroundColor Cyan +$instanceCounts.GetEnumerator() | Sort-Object Name | ForEach-Object { write-host (" {0,-20} {1}" -f $_.Key, $_.Value) } + +if ($CooldownMinutes -gt 0) { + write-host "" + write-host "Watching for scale-in for $CooldownMinutes minutes (no load is being generated now)..." -ForegroundColor Cyan + $cooldownEnd = (Get-Date).AddMinutes($CooldownMinutes) + while ((Get-Date) -lt $cooldownEnd) { + Start-Sleep -Seconds 60 + $capacity = Get-VmssCapacity + $capacityText = if ($null -ne $capacity) { $capacity } else { "n/a" } + write-host ("[{0}] cooling down | capacity {1}" -f (Get-Date -Format 'HH:mm:ss'), $capacityText) + } +} + +$endCapacity = Get-VmssCapacity +write-host "" +write-host "----------------------------------------------------------------------------------------------------------------" +if ($null -ne $startCapacity -and $null -ne $endCapacity) { + write-host "Instance count went from $startCapacity to $endCapacity" +} +write-host "Scale-in happens on the same 5 minute average, so the scale set takes a while to return to its minimum." -ForegroundColor Gray +write-host "----------------------------------------------------------------------------------------------------------------" diff --git a/vmss-samples/cloud-init-webapp.yaml b/vmss-samples/cloud-init-webapp.yaml new file mode 100644 index 0000000..da0f4f2 --- /dev/null +++ b/vmss-samples/cloud-init-webapp.yaml @@ -0,0 +1,153 @@ +#cloud-config +# Bootstraps a tiny CPU-bound web app on each Confidential VM in the scale set. +# Uses only the Python 3 standard library that ships in the Ubuntu CVM image, so the +# app still comes up when the subnet has no outbound internet access (-NoInternetAccess). +write_files: + - path: /opt/ccdemo/app.py + permissions: '0755' + owner: root:root + content: | + #!/usr/bin/env python3 + """Minimal CPU-burn web app used to demonstrate VMSS autoscale on Confidential VMs.""" + import hashlib + import json + import os + import socket + import subprocess + import time + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + from urllib.parse import parse_qs, urlparse + + PORT = 8080 + HOSTNAME = socket.gethostname() + STARTED = time.time() + SERVED = 0 + + def detect_isolation(): + # systemd reports the confidential VM technology and works unprivileged. The /dev/sev-guest + # and /dev/tdx_guest device nodes are NOT present on the Azure Ubuntu CVM image, so they are + # only a fallback for other distributions. + try: + result = subprocess.run(['systemd-detect-virt', '--cvm'], + capture_output=True, text=True, timeout=5) + flavour = result.stdout.strip() + if flavour and flavour != 'none': + return {'sev-snp': 'AMD SEV-SNP', 'sev-es': 'AMD SEV-ES', 'sev': 'AMD SEV', + 'tdx': 'Intel TDX'}.get(flavour, flavour) + except Exception: + pass + if os.path.exists('/dev/sev-guest'): + return 'AMD SEV-SNP' + if os.path.exists('/dev/tdx_guest'): + return 'Intel TDX' + return 'none detected (not a Confidential VM guest)' + + # Detected once at startup so a burst of requests does not spawn a process per request. + ISOLATION = detect_isolation() + + def burn(seconds): + """Hash a 4KB buffer in a loop. hashlib releases the GIL for buffers this size, + so concurrent requests spread across vCPUs and move the VMSS 'Percentage CPU' metric.""" + deadline = time.time() + seconds + block = os.urandom(4096) + rounds = 0 + while time.time() < deadline: + for _ in range(500): + hashlib.sha256(block).digest() + rounds += 500 + return rounds + + PAGE = """ + Confidential VMSS demo + +

Confidential VMSS demo

+

Served by instance {host}

+
    +
  • Isolation: {iso}
  • +
  • vCPUs: {cpus}
  • +
  • Uptime: {up}s
  • +
  • Requests served by this instance: {served}
  • +
+

Generate load with /burn?seconds=20, or run Start-VmssLoad.ps1.

+ """ + + class Handler(BaseHTTPRequestHandler): + protocol_version = 'HTTP/1.1' + + def _send(self, code, body, ctype): + payload = body.encode('utf-8') + self.send_response(code) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(payload))) + self.send_header('X-Instance', HOSTNAME) + self.end_headers() + self.wfile.write(payload) + + def do_GET(self): + global SERVED + SERVED += 1 + parsed = urlparse(self.path) + query = parse_qs(parsed.query) + + if parsed.path == '/health': + self._send(200, 'ok', 'text/plain') + elif parsed.path == '/burn': + try: + seconds = float(query.get('seconds', ['10'])[0]) + except ValueError: + seconds = 10.0 + seconds = max(0.1, min(seconds, 120.0)) + started = time.time() + rounds = burn(seconds) + self._send(200, json.dumps({ + 'host': HOSTNAME, + 'isolation': ISOLATION, + 'requested_seconds': seconds, + 'elapsed_seconds': round(time.time() - started, 3), + 'hash_rounds': rounds, + }), 'application/json') + elif parsed.path == '/info': + self._send(200, json.dumps({ + 'host': HOSTNAME, + 'isolation': ISOLATION, + 'cpus': os.cpu_count(), + 'uptime_seconds': int(time.time() - STARTED), + 'served': SERVED, + }), 'application/json') + else: + self._send(200, PAGE.format( + host=HOSTNAME, + iso=ISOLATION, + cpus=os.cpu_count(), + up=int(time.time() - STARTED), + served=SERVED, + ), 'text/html') + + def log_message(self, fmt, *args): + pass + + if __name__ == '__main__': + ThreadingHTTPServer(('0.0.0.0', PORT), Handler).serve_forever() + + - path: /etc/systemd/system/ccdemo.service + permissions: '0644' + owner: root:root + content: | + [Unit] + Description=Confidential VMSS demo web app + After=network-online.target + Wants=network-online.target + + [Service] + ExecStart=/usr/bin/python3 /opt/ccdemo/app.py + Restart=always + RestartSec=2 + DynamicUser=yes + + [Install] + WantedBy=multi-user.target + +runcmd: + - [ systemctl, daemon-reload ] + - [ systemctl, enable, --now, ccdemo.service ]