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/vm-samples/BuildRandomCVM.ps1 b/vm-samples/BuildRandomCVM.ps1 index da3d1a9..b9157e5 100644 --- a/vm-samples/BuildRandomCVM.ps1 +++ b/vm-samples/BuildRandomCVM.ps1 @@ -10,10 +10,10 @@ # # Clone this repo to a folder (relies on the WindowsAttest.ps1 script being in the same folder as this script) # -# Usage: ./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-policyFilePath ] [-DisableBastion] [-NoInternetAccess] [-SkipSkuPreflight] [-GPU] +# Usage: ./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-policyFilePath ] [-DisableBastion] [-NoInternetAccess] [-SkipSkuPreflight] [-GPU] # # Basename is a prefix for all resources created, it's used to create unique names for the resources -# osType specifies which OS to deploy: Windows (Server 2022), Windows11 (Windows 11 Enterprise), Ubuntu (24.04), or RHEL (9.5) +# osType specifies which OS to deploy: Windows (Server 2022), Windows11 (Windows 11 Enterprise), Windows2019 (Server 2019), Ubuntu (24.04), Ubuntu2604 (26.04), or RHEL (9.5) # description is an optional parameter that will be added as a tag to the resource group # smoketest is an optional switch that automatically removes all resources after completion (useful for testing) # region is an optional parameter that specifies the Azure region (defaults to northeurope) @@ -33,7 +33,7 @@ param ( [Parameter(Mandatory)]$subsID, [Parameter(Mandatory)]$basename, [Parameter(Mandatory)] - [ValidateSet("Windows", "Windows11", "Windows2019", "Ubuntu", "RHEL")] + [ValidateSet("Windows", "Windows11", "Windows2019", "Ubuntu", "Ubuntu2604", "RHEL")] $osType, [Parameter(Mandatory=$false)]$description = "", [Parameter(Mandatory=$false)][switch]$smoketest, @@ -47,7 +47,7 @@ param ( ) if ($subsID -eq "" -or $basename -eq "" -or $osType -eq "") { - write-host "You must enter a subscription ID, basename, and OS type (Windows, Windows11, Ubuntu, or RHEL)" + write-host "You must enter a subscription ID, basename, and OS type (Windows, Windows11, Windows2019, Ubuntu, Ubuntu2604, or RHEL)" exit }# exit if any of the parameters are empty @@ -452,6 +452,11 @@ switch ($osType) { } $VMIsLinux = $true } + "Ubuntu2604" { + $VirtualMachine = Set-AzVMOperatingSystem -VM $VirtualMachine -Linux -ComputerName $vmname -Credential $cred; + $VirtualMachine = Set-AzVMSourceImage -VM $VirtualMachine -PublisherName 'Canonical' -Offer 'ubuntu-26_04-lts' -Skus 'server-cvm' -Version "latest"; + $VMIsLinux = $true + } "RHEL" { $VirtualMachine = Set-AzVMOperatingSystem -VM $VirtualMachine -Linux -ComputerName $vmname -Credential $cred; $VirtualMachine = Set-AzVMSourceImage -VM $VirtualMachine -PublisherName 'redhat' -Offer 'rhel-cvm' -Skus '9_5_cvm' -Version "latest"; @@ -471,9 +476,11 @@ else { write-host "Configuring NAT Gateway egress so the private CVM can access 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 module versions do not persist NAT association via Set-AzVirtualNetworkSubnetConfig. - # Try native cmdlet first, then verify; if missing, fall back to Azure CLI subnet update. - Set-AzVirtualNetworkSubnetConfig -Name ($vmsubnetName) -VirtualNetwork $vnet -AddressPrefix "10.0.0.0/24" -DefaultOutboundAccess $false -InputObject $natGateway | Set-AzVirtualNetwork | Out-Null + # Set the resource reference directly; some Az.Network versions drop the -InputObject association. + $vmSubnet = $vnet.Subnets | Where-Object { $_.Name -eq $vmsubnetName } | Select-Object -First 1 + $vmSubnet.NatGateway = New-Object Microsoft.Azure.Commands.Network.Models.PSResourceId + $vmSubnet.NatGateway.Id = $natGateway.Id + $vnet | Set-AzVirtualNetwork -ErrorAction Stop | Out-Null $vnet = Get-AzVirtualNetwork -Name ($vnetname) -ResourceGroupName $resgrp $vmSubnet = $vnet.Subnets | Where-Object { $_.Name -eq $vmsubnetName } | Select-Object -First 1 diff --git a/vm-samples/README.md b/vm-samples/README.md index 8d0d726..250155c 100644 --- a/vm-samples/README.md +++ b/vm-samples/README.md @@ -52,7 +52,7 @@ Deploy Confidential Virtual Machines (CVMs) with AMD SEV-SNP or Intel TDX hardwa Builds a CVM with **Confidential OS disk encryption bound to a Customer Managed Key** (see [What is Confidential OS disk encryption?](#what-is-confidential-os-disk-encryption-with-cmk) below) and a private VNet (no public IP). By default, the script attaches a NAT Gateway to the VM subnet so the VM can reach the internet without exposing a public IP, and it optionally deploys Azure Bastion for remote access. If you pass `-NoInternetAccess`, the subnet remains fully isolated and the attestation download step is skipped. Otherwise, the script downloads the latest pre-built `attest` binary from [Azure/cvm-attestation-tools](https://github.com/Azure/cvm-attestation-tools/releases/latest) inside the VM, runs it against the matching SNP/TDX config, and streams the attestation JWT and claims back to the caller via `Invoke-AzVMRunCommand` (with a 60s backoff retry loop to absorb the transient 409 Conflict that the run-command extension can return immediately after VM provisioning). -Supported images: Windows Server 2022, Windows Server 2019, Windows 11 Enterprise, Ubuntu 24.04 LTS, and RHEL 9.5 CVM. The script tags the resource group with the GitHub repo URL (auto-detected from git remote) for traceability. +Supported images: Windows Server 2022, Windows Server 2019, Windows 11 Enterprise, Ubuntu 24.04 LTS, Ubuntu 26.04 LTS, and RHEL 9.5 CVM. The script tags the resource group with the GitHub repo URL (auto-detected from git remote) for traceability. For Windows Server 2022 deployments, use the refreshed Marketplace offer published under `windowsserver2022` rather than the legacy `windowsserver` offer. Microsoft announced that the legacy Windows Server 2022 images are being deprecated; the refreshed offer is the supported path for new deployments and reimages. @@ -136,7 +136,7 @@ Basename is a prefix assigned to all resources created by the script and will be The script will generate a random complex password and output it to the terminal once; make sure you copy it if you want to login to the CVM. ```powershell -./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-vmsize ] [-policyFilePath ] [-DisableBastion] [-NoInternetAccess] [-GPU] +./BuildRandomCVM.ps1 -subsID -basename -osType [-description ] [-smoketest] [-region ] [-vmsize ] [-policyFilePath ] [-DisableBastion] [-NoInternetAccess] [-GPU] ``` ## Parameters: @@ -157,8 +157,27 @@ The script will generate a random complex password and output it to the terminal - **Windows2019**: Windows Server 2019 Datacenter (RDP via Bastion) - **Windows11**: Windows 11 Enterprise 24H2 (RDP via Bastion) - **Ubuntu**: Ubuntu 24.04 LTS CVM (SSH via Bastion) +- **Ubuntu2604**: Ubuntu 26.04 LTS CVM (SSH via Bastion), using `Canonical:ubuntu-26_04-lts:server-cvm:latest` - **RHEL**: Red Hat Enterprise Linux 9.5 CVM (SSH via Bastion) +The `Ubuntu` option continues to deploy 24.04. Use `Ubuntu2604` to select 26.04 explicitly. The Ubuntu 26.04 image uses the `server-cvm` SKU, not the `cvm` SKU used by 24.04; see [Canonical's image reference](https://documentation.ubuntu.com/azure/azure-how-to/instances/find-ubuntu-images/). Image and VM size availability depend on the target region. + +**Ubuntu 26.04 with AMD SEV-SNP and Bastion:** +```powershell +./BuildRandomCVM.ps1 -subsID "YOUR-SUBSCRIPTION-ID" -basename "u2604" -osType "Ubuntu2604" -region "northeurope" -vmsize "Standard_DC2as_v5" +``` + +Use an Intel TDX size such as `Standard_DC2es_v6` in a supported region to select the existing TDX attestation flow instead. Ubuntu 26.04 uses the same Linux deployment path, Confidential OS disk encryption with CMK, Secure Boot, and vTPM as Ubuntu 24.04. + +Live-tested Ubuntu 26.04 configurations (image version `26.04.202610210`, guest Ubuntu 26.04.1 LTS): + +| VM size | Region | Isolation | Attestation result | +|---------|--------|-----------|--------------------| +| `Standard_DC2as_v5` | `northeurope` | AMD SEV-SNP | `sevsnpvm`, `azure-compliant-cvm` | +| `Standard_DC2es_v6` | `westeurope` | Intel TDX | `tdxvm`, `azure-compliant-cvm` | + +Both deployments completed with Bastion, NAT egress, Secure Boot, vTPM, and customer-managed confidential OS disk encryption enabled. Availability and subscription quota must still be checked for each deployment. + ## GPU mode — H100 confidential GPU VM The `-GPU` switch builds an NVIDIA H100 SEV-SNP confidential GPU VM (`Standard_NCC40ads_H100_v5`, family `StandardNCCads2023Family`, 40 vCPUs / 1xH100). When `-GPU` is set the script: 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 ]