diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md index d35e8c490..8cc79fbe1 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md @@ -145,13 +145,13 @@ Use the AZQR report for planning and preferred-region ordering, not as the deplo The main cost driver for this Hackathon is virtual machines: -- **LocalBox** cost is approximately 100-110 USD per subscription per day. Console's shared-deployment estimate is USD 110/day per subscription, separate from participant costs. For hosted events, schedule provisioning at least 24 hours ahead and budget for preparation time; avoid leaving it running for a week by default. Follow the [hosted-event budget guidance](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/hosted-events/readme.md). +- **LocalBox** cost is approximately 100-110 USD per instance per day. Hosted events now share **one instance across all event subscriptions**: Console's global-deployment estimate is **USD 110/day per event**, not per subscription, separate from participant costs. Re-import the updated content pack to enable the global hook. For hosted events, schedule provisioning at least 24 hours ahead and budget for preparation time; avoid leaving it running for a week by default. Validate that the single instance has capacity for the whole event. Follow the [hosted-event budget guidance](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/hosted-events/readme.md). - **Challenges 5 and 7** share one pre-provisioned cluster: two `Standard_D4s_v5` system nodes and two Ubuntu `Standard_DC2as_v5/v6` confidential nodes, plus a `Standard_D4s_v5` K3s VM, Bastion Standard and NAT Gateway. The removed standalone CVM is replaced by the second confidential node, so baseline confidential compute remains four vCPUs; sharing also avoids a second AKS system pool. - Example Linux PAYG compute in Spain Central from the [Azure Retail Prices API](https://prices.azure.com/api/retail/prices), checked 2026-09-21: three D4s v5 at USD 0.214/hour plus two DC2as v6 at USD 0.117/hour equals **USD 21.02/day** for compute alone. Disks, Bastion, NAT, public IPs, LoadBalancers, storage and traffic are additional. - Use **USD 35-45 per participant per day** as a planning range including a modest allowance for those services and Challenge 4's two 1-vCPU/2-GiB ACI groups, Basic ACR and image build. The Console estimate is USD 45/day, not a pricing quote. ACI duration, confidential pricing, builds and egress can change the total; validate a regional Pricing Calculator estimate before approval. - Plan subscription quotas for at least 12 general-purpose vCPUs and 4 confidential vCPUs per participant, plus upgrade headroom. Keep two participants per subscription unless larger aggregate increases have been approved. Challenge 5 application cleanup does not release node quota because Challenge 7 still uses the cluster. -For a 50-participant event, this planning range is USD 1,750-2,250 per day, plus LocalBox for each participating subscription if Challenge 6 is enabled. +For a 50-participant event, this planning range is USD 1,750-2,250 per day, plus one event-wide LocalBox if Challenge 6 is enabled. This is a cost illustration, not evidence that one LocalBox supports 50 concurrent participant VMs; verify capacity before delivery. There will also be smaller costs for services such as Key Vault, storage, and monitoring. Hosted Console setup automatically enables **Defender for Servers P2 and Endpoint protection on every event subscription**. Budget separately for P2 charges on covered servers during preparation and delivery; the infrastructure estimates above do not include them. Manual/BYOS organizers enable their approved plan separately. Removing lab resource groups does not reset subscription-level Defender settings. An Azure Pricing Calculator estimate is available [here](https://azure.com/e/1a7aec76a3e049cba57cda6742025373). This estimate can be adjusted for fewer/more students, running the VMs shorter/longer and adding additional services if desired. diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-06.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-06.md index eaced9551..db9957628 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-06.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/challenges/challenge-06.md @@ -12,6 +12,8 @@ Your organization must run workloads in a sovereign cloud while still leveraging Before starting, return your Challenge 1 exercise policies to **DoNotEnforce**. If a policy blocks deployment, use the [troubleshooting steps](https://github.com/microsoft/MicroHack/blob/main/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md#if-validation-or-deployment-is-blocked-by-a-location-policy) or ask the facilitator. +Hosted events share **one LocalBox across all subscriptions**. Find it using **Shared LocalBox Subscription ID** and **Shared LocalBox Resource Group ID** in the Console's Credentials tab. Your own VM still belongs in your assigned subscription/resource group; AKS proxy connections use the shared LocalBox subscription. + ### Explore and manage a VM * Explore the LocalBox hybrid infrastructure in the Azure Portal diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-lab.ps1 index ef1c3dbe1..bc01d6568 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/deploy-lab.ps1 @@ -166,6 +166,21 @@ $sovereignLabResult = Invoke-MhhDeploymentWithRegionFallback ` $deploymentName = "lab-$stableHash" Write-Host "Assigning subscription-scoped lab RBAC to participant $($AllowedEntraUserIds[0])..." +Update-MhhToken | Out-Null +$securityReaderScope = "/subscriptions/$SubscriptionId" +$securityReaderRoleId = '39bc4728-0917-49c7-9d2c-d95423bc2eb4' +$existingSecurityReader = @(Get-AzRoleAssignment ` + -ObjectId $AllowedEntraUserIds[0] ` + -RoleDefinitionId $securityReaderRoleId ` + -Scope $securityReaderScope ` + -ErrorAction Stop | Where-Object { + $_.ObjectId -ieq $AllowedEntraUserIds[0] -and + $_.Scope -ieq $securityReaderScope -and + $_.RoleDefinitionId -ieq $securityReaderRoleId + }) +if ($existingSecurityReader.Count -gt 0) { + Write-Host 'Reusing the existing subscription Security Reader assignment without changing its ID.' +} New-AzSubscriptionDeployment ` -Name $deploymentName ` -Location $effectiveLocation ` @@ -173,6 +188,7 @@ New-AzSubscriptionDeployment ` -TemplateParameterObject @{ userObjectId = $AllowedEntraUserIds[0] resourceGroupName = $ResourceGroupName + assignSecurityReader = ($existingSecurityReader.Count -eq 0) } | Out-Null @{"HackboxCredential" = @{ name = "Sovereign Lab Region"; value = $sovereignLabResult.LocationUsed; note = "Region selected after capacity checks" }} diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/global-event-deploy.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/global-event-deploy.ps1 new file mode 100644 index 000000000..8119976a5 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/global-event-deploy.ps1 @@ -0,0 +1,225 @@ +<# +.SYNOPSIS +Deploys one event-wide LocalBox for Sovereign Cloud MicroHack labs. +.DESCRIPTION +Runs once per event in the Console-selected lowest-GUID subscription, before +shared subscription preparation. All event participants receive access and +nonsecret discovery credentials. Participant VMs remain in their assigned scopes. +For Microsoft-hosted environments only: assigns and verifies the MCAPS control-tag +initiative and enables paid Defender for Servers P2 with Endpoint protection before +deploying resources. Do not use this hook for BYOS setup. +#> +param( + [Parameter(Mandatory=$true)] + [string]$SubscriptionId, + + [Parameter(Mandatory=$true)] + [string[]]$PreferredLocation = @(), + + [Parameter(Mandatory=$false)] + [string[]]$AllowedEntraUserIds = @() +) + +$ErrorActionPreference = 'Stop' +. (Join-Path $PSScriptRoot 'localbox-credentials.ps1') +. (Join-Path $PSScriptRoot 'hosted-tag-policy.ps1') +. (Join-Path $PSScriptRoot 'hosted-defender.ps1') + +Update-MhhToken | Out-Null +if ((Get-AzContext -ErrorAction Stop).Subscription.Id -ne $SubscriptionId) { + throw "The Azure context must target global LocalBox subscription $SubscriptionId." +} +$participants = @($AllowedEntraUserIds | Sort-Object -Unique) +foreach ($participant in $participants) { + $objectId = [guid]::Empty + if (-not [guid]::TryParse($participant, [ref]$objectId) -or $objectId -eq [guid]::Empty) { + throw 'AllowedEntraUserIds must contain valid event participant object IDs.' + } +} +$groupCredentials = @(Get-LocalBoxConsoleGroupCredential) + +$azureLocalResourceProviderAppId = '1412d89f-b8a8-4111-b4fd-e82905cbd85d' +Update-MhhToken | Out-Null +$azureLocalResourceProviderObjectIds = @( + & az ad sp list ` + --filter "appId eq '$azureLocalResourceProviderAppId'" ` + --query '[].id' ` + --output tsv ` + --only-show-errors +) +if($LASTEXITCODE -ne 0) { + throw 'Unable to query Microsoft Graph for the Microsoft.AzureStackHCI service principal.' +} +$azureLocalResourceProviderObjectIds = @($azureLocalResourceProviderObjectIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +if($azureLocalResourceProviderObjectIds.Count -ne 1) { + throw "Expected exactly one Microsoft.AzureStackHCI service principal, found $($azureLocalResourceProviderObjectIds.Count)." +} +Write-Host "Resolved Microsoft.AzureStackHCI service-principal object ID: $($azureLocalResourceProviderObjectIds[0])" -ForegroundColor Green + +Write-Host "Preparing subscription $SubscriptionId for the event-wide LocalBox..." +& (Join-Path $PSScriptRoot 'resource-providers.ps1') + +if (-not $?) { + throw "Resource provider registration failed for subscription $SubscriptionId." +} + +$defaults = Get-Content (Join-Path $PSScriptRoot 'lab-defaults.json') -Raw | ConvertFrom-Json +if($PreferredLocation.Count -eq 0) { + $PreferredLocation = @($defaults.preferredLocation) +} +$PreferredLocation = @($PreferredLocation | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + +if ($PreferredLocation.Count -eq 0) { throw 'At least one preferred LocalBox host region is required.' } +Initialize-MhhHostedTagPolicy -SubscriptionId $SubscriptionId -Location $PreferredLocation[0] +Initialize-MhhHostedDefender -SubscriptionId $SubscriptionId + +$localBoxResourceGroupName = 'rg-localbox-shared' +$localBoxLocations = @( + $PreferredLocation | + Where-Object { $_ } | + Select-Object -Unique +) +$activeDeploymentStates = @('Accepted', 'Running', 'Ready', 'Creating', 'Created', 'Succeeded') +$resourceGroup = Get-AzResourceGroup -Name $localBoxResourceGroupName -ErrorAction SilentlyContinue +if ($resourceGroup) { + Update-AzTag ` + -ResourceId $resourceGroup.ResourceId ` + -Operation Merge ` + -Tag @{ + workload = 'sovereign-localbox' + challenge = '6' + SecurityControl = 'Ignore' + CostControl = 'Ignore' + } ` + -ErrorAction Stop | Out-Null + Write-Host "Applied the temporary MCAPS security-control exemption to $localBoxResourceGroupName." -ForegroundColor Green +} +$localBoxDeployment = if ($resourceGroup) { + Get-AzResourceGroupDeployment ` + -ResourceGroupName $localBoxResourceGroupName ` + -ErrorAction Stop | + Where-Object { + $_.DeploymentName -like 'localbox-*' -and + $_.ProvisioningState -in $activeDeploymentStates + } | + Sort-Object Timestamp -Descending | + Select-Object -First 1 +} + +if ($localBoxDeployment) { + Write-Host "Reusing LocalBox deployment '$($localBoxDeployment.DeploymentName)' ($($localBoxDeployment.ProvisioningState))." -ForegroundColor Green + $localBoxClient = Get-AzResource ` + -ResourceGroupName $localBoxResourceGroupName ` + -ResourceType 'Microsoft.Compute/virtualMachines' ` + -ErrorAction Stop | + Where-Object Name -eq 'LocalBox-Client' + if ($localBoxClient) { + Update-AzTag ` + -ResourceId $localBoxClient.ResourceId ` + -Operation Merge ` + -Tag @{ CostControl = 'Ignore' } ` + -ErrorAction Stop | Out-Null + } +} +else { + foreach ($localBoxLocation in $localBoxLocations) { + try { + Write-Host "Submitting shared LocalBox deployment in $localBoxLocation..." -ForegroundColor Cyan + $localBoxDeployment = & (Join-Path $PSScriptRoot 'deploy-localbox.ps1') ` + -SubscriptionId $SubscriptionId ` + -ResourceGroupName $localBoxResourceGroupName ` + -Location $localBoxLocation ` + -AzureLocalResourceProviderObjectId $azureLocalResourceProviderObjectIds[0] ` + -AzureLocalInstanceLocation 'australiaeast' ` + -UseConsoleCredentials ` + -NoWait + + if (-not $localBoxDeployment -or $localBoxDeployment.ProvisioningState -ne 'Submitted') { + throw "LocalBox deployment submission did not return the expected Submitted state." + } + break + } + catch { + $failureDetails = @( + $_.Exception.ToString() + $_.ErrorDetails.Message + ) | Where-Object { $_ } + $failureDetails = $failureDetails -join ' ' + $retryableRegionalFailure = $failureDetails -match 'RequestDisallowedByAzure|locationineligible|SkuNotAvailable|QuotaExceeded|OperationNotAllowed|AllocationFailed|VM size .+ unavailable|At least 32 unused' + + if ($retryableRegionalFailure -and $localBoxLocation -ne $localBoxLocations[-1]) { + Write-Warning "LocalBox is unavailable in $localBoxLocation; trying the next preferred region. $($_.Exception.Message)" + $localBoxDeployment = $null + continue + } + throw + } + } + + if (-not $localBoxDeployment) { + throw "LocalBox deployment submission failed in all candidate regions for subscription $SubscriptionId." + } +} + + +$localBoxScope = "/subscriptions/$SubscriptionId/resourceGroups/$localBoxResourceGroupName" +for ($attempt = 1; $attempt -le 60; $attempt++) { + Update-MhhToken | Out-Null + $runtimeProvider = Get-AzResourceProvider -ProviderNamespace Microsoft.KubernetesRuntime -ErrorAction Stop + if ($runtimeProvider.RegistrationState -eq 'Registered') { break } + Start-Sleep -Seconds 10 +} +if ($runtimeProvider.RegistrationState -ne 'Registered') { + throw 'Microsoft.KubernetesRuntime registration did not complete. LocalBox MetalLB preparation is blocked.' +} +$runtimeObjectIds = @(& az ad sp list --filter "appId eq '087fca6e-4606-4d41-b3f6-5ebdf75b8b4c'" --query '[].id' --output tsv --only-show-errors) +if ($LASTEXITCODE -ne 0) { throw 'Unable to resolve the Microsoft.KubernetesRuntime service principal for MetalLB.' } +$runtimeObjectIds = @($runtimeObjectIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) +$runtimeObjectId = [guid]::Empty +if ($runtimeObjectIds.Count -ne 1 -or -not [guid]::TryParse($runtimeObjectIds[0], [ref]$runtimeObjectId) -or $runtimeObjectId -eq [guid]::Empty) { + throw 'Expected exactly one valid Microsoft.KubernetesRuntime service-principal object ID. Have the tenant administrator verify provider registration.' +} +Update-AzTag -ResourceId $localBoxScope -Operation Merge ` + -Tag @{ 'microhack-k8s-runtime-object-id' = $runtimeObjectId.ToString() } -ErrorAction Stop | Out-Null + +foreach ($participantObjectId in $participants) { + Update-MhhToken | Out-Null + foreach ($roleName in @('Reader', 'Azure Stack HCI VM Contributor', 'Security Reader')) { + $roleScope = if ($roleName -eq 'Security Reader') { "/subscriptions/$SubscriptionId" } else { $localBoxScope } + $roleAssignment = Get-AzRoleAssignment ` + -ObjectId $participantObjectId ` + -RoleDefinitionName $roleName ` + -Scope $roleScope ` + -ErrorAction Stop + + if (-not $roleAssignment) { + Write-Host "Assigning '$roleName' on shared LocalBox to $participantObjectId..." + New-AzRoleAssignment ` + -ObjectId $participantObjectId ` + -RoleDefinitionName $roleName ` + -Scope $roleScope ` + -ErrorAction Stop | Out-Null + } + } +} + +Wait-LocalBoxDeployment -ResourceGroupName $localBoxResourceGroupName -DeploymentName $localBoxDeployment.DeploymentName + +$groupCredentials +@{ HackboxCredential = @{ + name = 'Shared LocalBox Subscription ID' + value = $SubscriptionId + note = 'Event-wide LocalBox and AKS host; your own VM stays in your assigned subscription/resource group' +} } +@{ HackboxCredential = @{ + name = 'Shared LocalBox Resource Group ID' + value = $localBoxScope + note = 'Use this exact scope to find the event-wide LocalBox, even from another lab subscription' +} } +@{ + 'HackboxCredential' = @{ + name = 'Shared LocalBox Resource Group' + value = $localBoxResourceGroupName + note = 'One Challenge 6 environment for the entire event; readiness is verified by the facilitator' + } +} \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/lab-defaults.json b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/lab-defaults.json index 220905345..280e0954a 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/lab-defaults.json +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/lab-defaults.json @@ -5,5 +5,6 @@ "labsPerSubscription": 2, "preferredLocation": "spaincentral, swedencentral", "estimatedDailyCostsUsd": 45.0, - "estimatedSharedDeploymentDailyCostsUsd": 110.0 + "estimatedSharedDeploymentDailyCostsUsd": 0.0, + "estimatedGlobalDeploymentDailyCostsUsd": 110.0 } diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/localbox-credentials.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/localbox-credentials.ps1 index bd7a01f24..f70540c39 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/localbox-credentials.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/localbox-credentials.ps1 @@ -33,7 +33,7 @@ function Wait-LocalBoxDeployment { throw "LocalBox deployment '$DeploymentName' ended as $($deployment.ProvisioningState)." } if ((Get-Date -AsUTC) -ge $deadline) { - throw "Timed out waiting for LocalBox deployment '$DeploymentName'. Rerun the shared hook after investigating the deployment." + throw "Timed out waiting for LocalBox deployment '$DeploymentName'. Rerun the global event hook after investigating the deployment." } Write-Host "Waiting for LocalBox ARM deployment '$DeploymentName' ($($deployment.ProvisioningState))..." Start-Sleep -Seconds 30 diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/main.bicep b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/main.bicep index d5d6baff1..b1caa80c9 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/main.bicep +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/main.bicep @@ -19,11 +19,14 @@ param userObjectId string @description('Name of the participant resource group (created by the platform) to grant resource-group-scoped RBAC roles in.') param resourceGroupName string +@description('Create the subscription Security Reader assignment only when the participant does not already have a direct assignment at this scope.') +param assignSecurityReader bool = true + // Built-in role definition IDs (stable across all Azure subscriptions) var securityReaderRoleId = '39bc4728-0917-49c7-9d2c-d95423bc2eb4' var resourcePolicyContributorRoleId = '36243c78-bf99-498c-9df9-86d9f8d28608' -resource securityReaderAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +resource securityReaderAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (assignSecurityReader) { name: guid(subscription().id, userObjectId, securityReaderRoleId) properties: { principalId: userObjectId @@ -50,4 +53,3 @@ module resourceGroupRbac 'rg-rbac.bicep' = { userObjectId: userObjectId } } - diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/shared-deploy-lab.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/shared-deploy-lab.ps1 index 866167fba..c267482d1 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/shared-deploy-lab.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/labautomation/shared-deploy-lab.ps1 @@ -5,7 +5,7 @@ Prepares a subscription for Sovereign Cloud MicroHack labs. Runs once per subscription before participant lab deployments and registers the required Azure resource providers. It ensures one preferred region has the VM SKUs and compute quota required by the subscription's participant count. -It also submits one shared LocalBox environment per subscription for Challenge 6. +LocalBox is deployed separately by global-event-deploy.ps1 once per event. For Microsoft-hosted environments only: assigns and verifies the MCAPS control-tag initiative and enables paid Defender for Servers P2 with Endpoint protection before deploying resources. Do not use this hook for BYOS setup. @@ -21,30 +21,15 @@ param( [string[]]$AllowedEntraUserIds = @() ) +$ErrorActionPreference = 'Stop' . (Join-Path $PSScriptRoot 'quota-helpers.ps1') -. (Join-Path $PSScriptRoot 'localbox-credentials.ps1') . (Join-Path $PSScriptRoot 'hosted-tag-policy.ps1') . (Join-Path $PSScriptRoot 'hosted-defender.ps1') -Get-LocalBoxConsoleGroupCredential - -$azureLocalResourceProviderAppId = '1412d89f-b8a8-4111-b4fd-e82905cbd85d' Update-MhhToken | Out-Null -$azureLocalResourceProviderObjectIds = @( - & az ad sp list ` - --filter "appId eq '$azureLocalResourceProviderAppId'" ` - --query '[].id' ` - --output tsv ` - --only-show-errors -) -if($LASTEXITCODE -ne 0) { - throw 'Unable to query Microsoft Graph for the Microsoft.AzureStackHCI service principal.' -} -$azureLocalResourceProviderObjectIds = @($azureLocalResourceProviderObjectIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -if($azureLocalResourceProviderObjectIds.Count -ne 1) { - throw "Expected exactly one Microsoft.AzureStackHCI service principal, found $($azureLocalResourceProviderObjectIds.Count)." +if ((Get-AzContext -ErrorAction Stop).Subscription.Id -ne $SubscriptionId) { + throw "The Azure context must target participant subscription $SubscriptionId." } -Write-Host "Resolved Microsoft.AzureStackHCI service-principal object ID: $($azureLocalResourceProviderObjectIds[0])" -ForegroundColor Green Write-Host "Preparing subscription $SubscriptionId for Sovereign Cloud labs..." & (Join-Path $PSScriptRoot 'resource-providers.ps1') @@ -157,142 +142,4 @@ Set-MhhConfidentialComputeSelection -SubscriptionId $SubscriptionId -Candidate $ Write-Host "Selected $($selectedCandidate.VmSize) in $($selectedCandidate.Location); compute SKUs and quotas are ready for $participantCount participants." -ForegroundColor Green Initialize-MhhHostedTagPolicy -SubscriptionId $SubscriptionId -Location $selectedCandidate.Location -Initialize-MhhHostedDefender -SubscriptionId $SubscriptionId - -$localBoxResourceGroupName = 'rg-localbox-shared' -$localBoxLocations = @( - @($selectedCandidate.Location) + $PreferredLocation | - Where-Object { $_ } | - Select-Object -Unique -) -$activeDeploymentStates = @('Accepted', 'Running', 'Ready', 'Creating', 'Created', 'Succeeded') -$resourceGroup = Get-AzResourceGroup -Name $localBoxResourceGroupName -ErrorAction SilentlyContinue -if ($resourceGroup) { - Update-AzTag ` - -ResourceId $resourceGroup.ResourceId ` - -Operation Merge ` - -Tag @{ - workload = 'sovereign-localbox' - challenge = '6' - SecurityControl = 'Ignore' - CostControl = 'Ignore' - } ` - -ErrorAction Stop | Out-Null - Write-Host "Applied the temporary MCAPS security-control exemption to $localBoxResourceGroupName." -ForegroundColor Green -} -$localBoxDeployment = if ($resourceGroup) { - Get-AzResourceGroupDeployment ` - -ResourceGroupName $localBoxResourceGroupName ` - -ErrorAction Stop | - Where-Object { - $_.DeploymentName -like 'localbox-*' -and - $_.ProvisioningState -in $activeDeploymentStates - } | - Sort-Object Timestamp -Descending | - Select-Object -First 1 -} - -if ($localBoxDeployment) { - Write-Host "Reusing LocalBox deployment '$($localBoxDeployment.DeploymentName)' ($($localBoxDeployment.ProvisioningState))." -ForegroundColor Green - $localBoxClient = Get-AzResource ` - -ResourceGroupName $localBoxResourceGroupName ` - -ResourceType 'Microsoft.Compute/virtualMachines' ` - -ErrorAction Stop | - Where-Object Name -eq 'LocalBox-Client' - if ($localBoxClient) { - Update-AzTag ` - -ResourceId $localBoxClient.ResourceId ` - -Operation Merge ` - -Tag @{ CostControl = 'Ignore' } ` - -ErrorAction Stop | Out-Null - } -} -else { - foreach ($localBoxLocation in $localBoxLocations) { - try { - Write-Host "Submitting shared LocalBox deployment in $localBoxLocation..." -ForegroundColor Cyan - $localBoxDeployment = & (Join-Path $PSScriptRoot 'deploy-localbox.ps1') ` - -SubscriptionId $SubscriptionId ` - -ResourceGroupName $localBoxResourceGroupName ` - -Location $localBoxLocation ` - -AzureLocalResourceProviderObjectId $azureLocalResourceProviderObjectIds[0] ` - -AzureLocalInstanceLocation 'australiaeast' ` - -UseConsoleCredentials ` - -NoWait - - if (-not $localBoxDeployment -or $localBoxDeployment.ProvisioningState -ne 'Submitted') { - throw "LocalBox deployment submission did not return the expected Submitted state." - } - break - } - catch { - $failureDetails = @( - $_.Exception.ToString() - $_.ErrorDetails.Message - ) | Where-Object { $_ } - $failureDetails = $failureDetails -join ' ' - $retryableRegionalFailure = $failureDetails -match 'RequestDisallowedByAzure|locationineligible|SkuNotAvailable|QuotaExceeded|OperationNotAllowed|AllocationFailed|VM size .+ unavailable|At least 32 unused' - - if ($retryableRegionalFailure -and $localBoxLocation -ne $localBoxLocations[-1]) { - Write-Warning "LocalBox is unavailable in $localBoxLocation; trying the next preferred region. $($_.Exception.Message)" - $localBoxDeployment = $null - continue - } - throw - } - } - - if (-not $localBoxDeployment) { - throw "LocalBox deployment submission failed in all candidate regions for subscription $SubscriptionId." - } -} - - -$localBoxScope = "/subscriptions/$SubscriptionId/resourceGroups/$localBoxResourceGroupName" -for ($attempt = 1; $attempt -le 60; $attempt++) { - Update-MhhToken | Out-Null - $runtimeProvider = Get-AzResourceProvider -ProviderNamespace Microsoft.KubernetesRuntime -ErrorAction Stop - if ($runtimeProvider.RegistrationState -eq 'Registered') { break } - Start-Sleep -Seconds 10 -} -if ($runtimeProvider.RegistrationState -ne 'Registered') { - throw 'Microsoft.KubernetesRuntime registration did not complete. LocalBox MetalLB preparation is blocked.' -} -$runtimeObjectIds = @(& az ad sp list --filter "appId eq '087fca6e-4606-4d41-b3f6-5ebdf75b8b4c'" --query '[].id' --output tsv --only-show-errors) -if ($LASTEXITCODE -ne 0) { throw 'Unable to resolve the Microsoft.KubernetesRuntime service principal for MetalLB.' } -$runtimeObjectIds = @($runtimeObjectIds | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -$runtimeObjectId = [guid]::Empty -if ($runtimeObjectIds.Count -ne 1 -or -not [guid]::TryParse($runtimeObjectIds[0], [ref]$runtimeObjectId) -or $runtimeObjectId -eq [guid]::Empty) { - throw 'Expected exactly one valid Microsoft.KubernetesRuntime service-principal object ID. Have the tenant administrator verify provider registration.' -} -Update-AzTag -ResourceId $localBoxScope -Operation Merge ` - -Tag @{ 'microhack-k8s-runtime-object-id' = $runtimeObjectId.ToString() } -ErrorAction Stop | Out-Null - -foreach ($participantObjectId in $AllowedEntraUserIds) { - foreach ($roleName in @('Reader', 'Azure Stack HCI VM Contributor')) { - $roleAssignment = Get-AzRoleAssignment ` - -ObjectId $participantObjectId ` - -RoleDefinitionName $roleName ` - -Scope $localBoxScope ` - -ErrorAction SilentlyContinue - - if (-not $roleAssignment) { - Write-Host "Assigning '$roleName' on shared LocalBox to $participantObjectId..." - New-AzRoleAssignment ` - -ObjectId $participantObjectId ` - -RoleDefinitionName $roleName ` - -Scope $localBoxScope ` - -ErrorAction Stop | Out-Null - } - } -} - -Wait-LocalBoxDeployment -ResourceGroupName $localBoxResourceGroupName -DeploymentName $localBoxDeployment.DeploymentName - -@{ - 'HackboxCredential' = @{ - name = 'Shared LocalBox Resource Group' - value = $localBoxResourceGroupName - note = 'Shared Challenge 6 environment; readiness is verified by the facilitator' - } -} \ No newline at end of file +Initialize-MhhHostedDefender -SubscriptionId $SubscriptionId \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/hosted-events/readme.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/hosted-events/readme.md index 8b10777e4..0d700e481 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/hosted-events/readme.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/hosted-events/readme.md @@ -14,11 +14,70 @@ This runbook covers the FY27 EMEA Cloud & AI Hackathons program described in the When automatic provisioning is not scheduled, use the Console's **Create Users**, content-pack license defaults and **Deploy Lab Environment** lifecycle in order. Investigate failures before continuing; do not compensate by running subscription-wide manual utilities. +## One LocalBox per event + +Re-import the content pack after updating it. The Console discovers +[global-event-deploy.ps1](../../labautomation/global-event-deploy.ps1) beside the +participant hook and requires `estimatedGlobalDeploymentDailyCostsUsd` to be +**greater than 5**. This pack sets it to **USD 110/day**, with the per-subscription +shared infrastructure estimate set to **0**. A missing/mismatched global signature +or a cost of 5 or less causes the Console to skip the hook and log a warning; +verify the global deployment job actually ran before continuing. + +The Console runs the global hook **once per event in the lowest-GUID event +subscription**, passing all event lab-user object IDs. It completes before +`shared-deploy-lab.ps1` runs in each subscription, followed by participant +deployments. A global failure stops the event deployment. Host-region fallback +uses the preferred regions within that selected subscription; it does not choose +a different subscription if quota or SKU availability is insufficient. + +The global hook deploys/reuses one `rg-localbox-shared`, including one LocalBox +Client. It publishes **Shared LocalBox Subscription ID**, **Shared LocalBox Resource +Group ID**, the group name and lab-group metadata to **every event participant**. +It grants each participant Reader and Azure Stack HCI VM Contributor on the +LocalBox resource group, plus Security Reader on the host subscription for +reference-VM security inspection. No participant Owner, host login or password is +granted by this hook. Existing shared VM/AKS access remains a workshop trust +boundary, not per-participant isolation. + +Participant RBAC setup reuses an existing direct Security Reader assignment on +its subscription, including the grant made by the global hook for participants +in the LocalBox host subscription. It does not recreate the same grant under a +different role-assignment ID. If an earlier run failed with +`RoleAssignmentExists` during `lab-*` deployment, re-import the corrected branch +and retry the event deployment; do not delete the global grants or LocalBox. +An assignment lookup failure stops setup rather than treating access as absent. + +Participant VMs still belong in their own assigned subscription/resource group, +using the shared custom location, image, network and storage. Keep the participant +subscription separate from the LocalBox subscription when following Challenge 6. +AKS proxy connections target the **LocalBox subscription**; team namespaces remain +unique by participant subscription plus resource group. + +Portal VM creation across subscriptions was successfully tested as `labuser-0027` +on 25 October 2026, using its original RG in traininglab-12 with LocalBox in +traininglab-59. The wizard defaulted to the host subscription: remind participants +to change **Subscription** to their assigned subscription before selecting their +own RG. Keep the existing single participant-RG workflow; see +[test evidence and remaining checks](../tests/readme.md#observed-test4-integration-results). + +**Capacity and migration:** one instance now carries the entire event's nested +VMs, reference VM, AKS workers and service IP allocations. Validate memory, storage +and address-pool headroom for the planned event size; the cost reduction does not +increase LocalBox capacity. Use a fresh event to validate this change, including +a participant assigned to a different subscription. Existing LocalBoxes in other +subscriptions are not moved or deleted automatically. Review their dependencies +and arrange explicit cleanup separately. Keep the global environment until all +participant VMs are removed, then verify Console teardown removes it. + ## Defender for Servers configuration and costs -The [shared hook](../../labautomation/shared-deploy-lab.ps1) calls -[hosted Defender setup](../../labautomation/hosted-defender.ps1) once per subscription, -after hosted tag-policy verification and before LocalBox deployment. It sets +The [global hook](../../labautomation/global-event-deploy.ps1) calls +[hosted Defender setup](../../labautomation/hosted-defender.ps1) in the host +subscription after hosted tag-policy verification and before LocalBox deployment. +The [shared hook](../../labautomation/shared-deploy-lab.ps1) also verifies these +settings on every participant subscription before lab deployment, including an +idempotent recheck of the host subscription. Setup sets `Microsoft.Security/pricings/VirtualMachines` to `Standard` / `P2` and `Microsoft.Security/settings/WDATP` to `enabled=true` (the **Endpoint protection** toggle). Existing pricing extension and enforcement settings are preserved; other @@ -41,18 +100,25 @@ releasing a subscription. This hook is not part of manual/BYOS preparation. ## Complete LocalBox preparation -The shared hook submits `localbox-*` in `rg-localbox-shared`, then waits up to six hours for ARM completion, refreshing its Console authentication while polling. It does not publish LocalBox administrator credentials. A Client VM appearing after roughly 15-20 minutes confirms only that deployment has started. Full nested Azure Local provisioning can take 4-6 hours and must still be checked separately. +The global hook submits `localbox-*` in `rg-localbox-shared` in the published **Shared LocalBox Subscription ID**, then waits up to six hours for ARM completion, refreshing its Console authentication while polling. It does not publish LocalBox administrator credentials. A Client VM appearing after roughly 15-20 minutes confirms only that deployment has started. Full nested Azure Local provisioning can take 4-6 hours and must still be checked separately. + +The subsequent preparation script took **4 hours 24 minutes 26 seconds** in the +TEST4 run reported on 25 October 2026, including reference-VM creation. This is +additional post-provisioning time, not the total event deployment duration or a +guaranteed runtime. MDE was still Pending when preparation finished. See the +[timing evidence and planning guidance](../localbox/readme.md#preparation-time); +allow time for both preparation and later assessments. 1. Inspect the Azure deployment, Client bootstrap logs and Azure Local/Arc status. Resolve failures before proceeding. 2. If the Client password is unknown, an authorized event lead/coach uses the Azure VM's **Help > Reset password** to set a known password for the `arcdemo` host account, then connects to `LocalBox-Client` through Bastion. Do not expose this password through participant credentials or job logs. A host password reset does not change the nested-node Windows credentials. See [LocalBox credentials](#localbox-credentials). 3. Copy **Lab Group ObjectId** from the Console's **Credentials** tab for the preparation prompt or `-AksAdminGroupObjectId`. Confirm that intended Azure lab identities/coaches are members; see the group dependency below. -4. Follow [LocalBox preparation](../localbox/readme.md), once per shared subscription, in elevated PowerShell 7. It creates AKS and then installs/verifies the shared MetalLB extension and reserved ARP pool. Azure authentication uses the Client VM's managed identity; preparation constructs the nested Windows credential locally from the installed configuration without printing it. An explicit `-NodeCredential` overrides that value after a nested-account rotation. +4. Follow [LocalBox preparation](../localbox/readme.md), **once per event on the global LocalBox Client**, in elevated PowerShell 7. It creates AKS and then installs/verifies the shared MetalLB extension and reserved ARP pool. Azure authentication uses the Client VM's managed identity; preparation constructs the nested Windows credential locally from the installed configuration without printing it. An explicit `-NodeCredential` overrides that value after a nested-account rotation. Preparation includes `sov-ref-vm` by default; run it **the day before the event** for inspection while participant VMs onboard, or opt out with `-IncludeReferenceVm:$false`. Follow the [reference readiness checks](../localbox/readme.md#reference-readiness-and-participant-use); Console plan configuration and successful provisioning do not establish completed VM assessments. Participants must not modify this shared VM; no RBAC restrictions are added. 5. Run [Pester health checks](../tests/readme.md) for LocalBox and every selected participant lab. A control-plane-only pass does not establish full readiness. 6. Console shared setup enables and verifies **Defender for Servers Plan 2** and **Endpoint protection** on every event subscription. Complete the [Defender readiness checklist](../localbox/manual-preparation.md#defender-for-servers-readiness-organizer) and [participant VM readiness exercise](../localbox/manual-preparation.md#step-6-test-the-environment), including guest management, actual Defender onboarding and Update Manager. Verified subscription settings do not prove that a VM has completed onboarding. 7. Verify an intended participant can use Entra-authenticated `az connectedk8s proxy` and a private application port-forward from their Codespace. Participants inspect MetalLB, not install it, and never need LocalBox-Client access. This validates the lab access path, not routing to the MetalLB service VIP. -Shared setup registers `Microsoft.KubernetesRuntime`, waits for registration, +Global setup registers `Microsoft.KubernetesRuntime`, waits for registration, resolves its tenant service-principal object ID using the Console's existing Graph-capable identity, and records it in the shared RG tag `microhack-k8s-runtime-object-id`. Missing registration or identity resolution @@ -85,7 +151,7 @@ do not assume a region is available merely because its SKU/quota checks passed. There are three separate locations to check: the participant resource group's metadata location, the Azure region hosting the LocalBox simulator, and the Azure Local/custom-location registration region. The hosted -[shared hook](../../labautomation/shared-deploy-lab.ps1) explicitly passes +[global hook](../../labautomation/global-event-deploy.ps1) explicitly passes `AzureLocalInstanceLocation = australiaeast`; the [manual deployment](../manual-setup/localbox/deploy-localbox.ps1) defaults to `australiaeast`. Host-region fallback does not change that registration parameter. @@ -123,7 +189,7 @@ Existing-environment cleanup is a separate organizer action. ### Hosted MCAPS control-tag initiative -The organizer-owned [shared hook](../../labautomation/shared-deploy-lab.ps1) deploys the [hosted tag initiative](../../labautomation/infra/hosted-tag-policy.bicep) once per subscription, before LocalBox deployment and participant lab fan-out. This is specific to MCAPS governance in Microsoft-internal hosted environments. **Do not deploy it for bring-your-own-subscription or manual setup.** The Console deployment identity must be authorized to create subscription-scoped policy initiatives and assignments, as well as the lab resources. +The organizer-owned [global hook](../../labautomation/global-event-deploy.ps1) deploys and verifies the [hosted tag initiative](../../labautomation/infra/hosted-tag-policy.bicep) before LocalBox deployment. The [shared hook](../../labautomation/shared-deploy-lab.ps1) deploys/verifies the same initiative on every participant subscription before participant lab fan-out; rechecking the host subscription is idempotent. This is specific to MCAPS governance in Microsoft-internal hosted environments. **Do not deploy it for bring-your-own-subscription or manual setup.** The Console deployment identity must be authorized to create subscription-scoped policy initiatives and assignments, as well as the lab resources. The initiative `sovereign-hosted-control-tags` and assignment `sov-hosted-control-tags` use four built-in **modify** policies: @@ -152,7 +218,7 @@ Verify that the hosted governance system honors the tag. It does not disable ind For new hosted deployments, the wrapper uses `New-MhhStablePassword -Purpose 'localbox-admin-v1' -Length 24`. The same Console scope and purpose yield the same password on retries. The password is passed as a secure template parameter, never added to tags, deployment outputs, log messages or `HackboxCredential` outputs. Do not change the password purpose or length for an existing event. -Shared-hook credentials are visible to **every lab in that subscription**. Only the resource-group name and nonsecret lab-group metadata are published by this hook. LocalBox administrator credentials must be restricted to coaches/event leads. A supported Console mechanism for restricted retrieval or secret input is pending confirmation from the Console owner and is not implemented here. It is not required for the Client-reset/local-configuration workflow above. The manual deployment path keeps its existing password handling and does not require Console helpers. +Global-hook credentials are visible to **every lab in the event**. Only the LocalBox subscription ID, resource-group name/ID and nonsecret lab-group metadata are published by this hook. LocalBox administrator credentials must be restricted to coaches/event leads. A supported Console mechanism for restricted retrieval or secret input is pending confirmation from the Console owner and is not implemented here. It is not required for the Client-reset/local-configuration workflow above. The manual deployment path keeps its existing password handling and does not require Console helpers. **Existing deployments:** environments are reused without password rotation. Original random passwords cannot be recovered from ARM or reconstructed by rerunning this hook, but Jumpstart's installed configuration on the Client retains `SDNAdminPassword`. After gaining host access, preparation reads that value through `$env:LocalBoxConfigFile` and uses the configured domain's Administrator account for PowerShell Direct. A Client-only reset does not alter it. If the nested account has since been rotated, supply its current credential explicitly. Do not redeploy the environment merely to recover a password. @@ -162,7 +228,7 @@ If an earlier automation version already published an administrator password, re ### Console group dependency -The shared hook calls the Console-supported `Get-MhhDefaultLabGroup` helper and publishes **Lab Group ObjectId**, **Lab Group GroupName** and **Lab Group DisplayName** as separate credentials. Complete the Console's Entra user/group creation lifecycle first; missing or invalid metadata stops shared deployment. The object ID is the value to supply to LocalBox preparation for AKS Entra administration. +The global hook calls the Console-supported `Get-MhhDefaultLabGroup` helper and publishes **Lab Group ObjectId**, **Lab Group GroupName** and **Lab Group DisplayName** as separate credentials to the entire event. Complete the Console's Entra user/group creation lifecycle first; missing or invalid metadata stops global deployment. The object ID is the value to supply to LocalBox preparation for AKS Entra administration. The [platform contract](../../../../../99-MicroHack-Template/labautomation/README.md#groups-supported-values) still uses `groups` for licensing activation (`GHCPUsers`, `M365-E5-Users`), not arbitrary group creation. No new schema fields or Graph permissions are needed. The Console owns the event group, its membership and teardown; the facilitator must verify intended users/coaches and late registrations. The preparation script still prompts for the ID on the Client, where Console helpers are unavailable, and does not create groups or validate membership through Graph. diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/preparation-duration-test4.png b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/preparation-duration-test4.png new file mode 100644 index 000000000..568420e5d Binary files /dev/null and b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/img/preparation-duration-test4.png differ diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md index 541a8309d..32d3b5011 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/manual-preparation.md @@ -131,6 +131,6 @@ Resolve connectivity, extension provisioning, or permission failures before the ## Notes -- **Shared Environment**: For MicroHack events, typically one LocalBox instance per subscription is shared among participants; each participant creates a VM in their assigned resource group +- **Shared Environment**: Hosted MicroHack events use one LocalBox instance across all event subscriptions; each participant creates a VM in their assigned subscription/resource group. Use the Console's Shared LocalBox Subscription ID to find the shared infrastructure. Manual organizers choose and publish their LocalBox scope. - **Resource Costs**: LocalBox and the enabled security services consume Azure resources; clean up lab resources after the event and review lab-specific paid plans with the subscription owner - **Deployment Time**: Plan for deployment time when scheduling your MicroHack diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/readme.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/readme.md index 9e38c9026..ce29cc29a 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/readme.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/localbox/readme.md @@ -2,6 +2,23 @@ Run [prepare-localbox.ps1](../prepare-localbox.ps1) **inside LocalBox-Client**, after Jumpstart has finished deploying the nested Azure Local cluster. This is common to hosted and manual delivery. It does not deploy a new LocalBox or replace the existing Azure AKS/K3s labs. +## Preparation time + +In a test run reported on **25 October 2026**, PowerShell history recorded +**4 hours 24 minutes 26 seconds** for `./prepare-localbox.ps1`, including the +default reference VM. This is the observed duration of the whole preparation +command, not an image-download or reference-VM-only measurement, and not a +guaranteed runtime. + +PowerShell history showing prepare-localbox.ps1 completed in 4:24:26.111, with the reference VM Running, Arc Connected and MDE Pending. + +Budget this post-provisioning work **in addition to** the initial LocalBox and +nested Azure Local deployment. At preparation completion, the reference VM was +**Running**, **Arc Connected**, and **MDE Pending**; health checks and Defender/ +Update Manager readiness still had to be verified separately. Start provisioning +at least **24 hours before the event**, complete preparation the day before, and +verify readiness rather than treating the elapsed time as a guarantee. + ## Prerequisites - Elevated PowerShell 7 on `LocalBox-Client` and an installed Azure CLI. Preparation automatically installs missing `stack-hci-vm`, `customlocation` and `aksarc` extensions, including during `-WhatIf`, verifies their versions and leaves existing versions unchanged. Internet access to the extension downloads is required; installation failure stops preparation before infrastructure changes. Record the versions printed by the script for your event. @@ -76,7 +93,7 @@ There is **one import attempt per run**, not an endless retry loop. If the new a ## MetalLB preparation MetalLB is installed automatically **after AKS is ready**, by this organizer -preparation script, not by participants. The Console shared hook still deploys +preparation script, not by participants. The Console global event hook deploys LocalBox only; it registers the Runtime provider and supplies the object-ID tag needed for this later step. No Client VM access is granted to attendees. diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/readme.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/readme.md index f159e2e96..15efec3b0 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/readme.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/readme.md @@ -16,10 +16,10 @@ Both paths use [LocalBox preparation](localbox/readme.md) and the [Pester health | Subscriptions, users, initial lab RBAC, provider/quota preparation | Console and content automation | Subscription owner | | Participant shared Azure AKS with Ubuntu confidential pool, K3s and networking | Console content automation for Challenges 5/7 | Subscription owner using the lab template | | Challenge 4 ACI/ACR comparison and Challenge 5 applications | Participants using walkthrough scripts | Same walkthrough scripts against the prepared scope | -| Shared LocalBox deployment | Console submits once per subscription; full readiness still requires verification | Subscription owner deploys LocalBox | +| Shared LocalBox deployment | Console submits once per event in the lowest-GUID event subscription; full readiness still requires verification | Subscription owner deploys LocalBox | | Storage, Windows image, VM/AKS networks, AKS on Azure Local | Event Lead runs preparation on each LocalBox Client | Same preparation script | | AKS Local Entra admin group | Console-owned event group; use **Lab Group ObjectId** from the Credentials tab | Supply an existing security group with intended users as members | | Runtime health and participant access | Event Lead | Organizer/subscription owner | | Teardown | Console lifecycle; verify shared resources and group cleanup | Explicit owner-managed cleanup | -One Console team is one lab. This content pack currently shares one LocalBox per **subscription**, so an event spanning several subscriptions has several LocalBoxes. The existing Azure AKS/K3s labs are not replaced by AKS on Azure Local. \ No newline at end of file +One Console team is one lab. This content pack shares **one LocalBox across the entire event**, even when participant labs span several subscriptions. Use the Console's **Shared LocalBox Subscription ID** and **Shared LocalBox Resource Group ID** to find it. Participant VMs stay in their assigned scopes. The existing Azure AKS/K3s labs are not replaced by AKS on Azure Local. \ No newline at end of file diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/global-localbox.tests.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/global-localbox.tests.ps1 new file mode 100644 index 000000000..a70f64c34 --- /dev/null +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/global-localbox.tests.ps1 @@ -0,0 +1,297 @@ +BeforeAll { + $root = (Resolve-Path "$PSScriptRoot/../..").Path + $fixture = Join-Path $TestDrive 'lab' + New-Item -ItemType Directory -Path $fixture | Out-Null + foreach ($name in @('global-event-deploy.ps1', 'shared-deploy-lab.ps1', 'lab-defaults.json')) { + Copy-Item "$root/labautomation/$name" $fixture + } + foreach ($name in @('quota-helpers.ps1', 'localbox-credentials.ps1', 'hosted-tag-policy.ps1', 'hosted-defender.ps1')) { + Set-Content "$fixture/$name" '' + } + Set-Content "$fixture/resource-providers.ps1" 'Register-TestProviders' + @' +param($SubscriptionId, $ResourceGroupName, $Location, $AzureLocalResourceProviderObjectId, $AzureLocalInstanceLocation, [switch]$UseConsoleCredentials, [switch]$NoWait) +Submit-TestLocalBox @PSBoundParameters +'@ | Set-Content "$fixture/deploy-localbox.ps1" + function Update-MhhToken {} + function Get-AzContext {} + function Get-LocalBoxConsoleGroupCredential {} + function Register-TestProviders {} + function Initialize-MhhHostedTagPolicy { param($SubscriptionId, $Location) } + function Initialize-MhhHostedDefender { param($SubscriptionId) } + function Get-AzResourceGroup { param($Name) } + function Get-AzResourceGroupDeployment { param($ResourceGroupName) } + function Get-AzResource { param($ResourceGroupName, $ResourceType) } + function Update-AzTag { param($ResourceId, $Operation, $Tag) } + function Get-AzResourceProvider { param($ProviderNamespace) } + function Get-AzRoleAssignment { param($ObjectId, $RoleDefinitionName, $RoleDefinitionId, $Scope) } + function New-AzRoleAssignment { param($ObjectId, $RoleDefinitionName, $Scope) } + function Wait-LocalBoxDeployment { param($ResourceGroupName, $DeploymentName) } + function Submit-TestLocalBox { + param($SubscriptionId, $ResourceGroupName, $Location, $AzureLocalResourceProviderObjectId, $AzureLocalInstanceLocation, [switch]$UseConsoleCredentials, [switch]$NoWait) + } + function az {} + . "$root/labautomation/quota-helpers.ps1" + function Get-AzComputeResourceSku { param($Location) } + function Get-AzVMUsage { param($Location) } +} + +Describe 'Global LocalBox Console contract' { + It 'uses the exact supported global parameter contract and opt-in threshold' { + $errors = $null + $ast = [Management.Automation.Language.Parser]::ParseFile("$fixture/global-event-deploy.ps1", [ref]$null, [ref]$errors) + $errors.Count | Should -Be 0 + $parameters = $ast.ParamBlock.Parameters + @($parameters.Name.VariablePath.UserPath) -join ',' | Should -Be 'SubscriptionId,PreferredLocation,AllowedEntraUserIds' + $command = Get-Command "$fixture/global-event-deploy.ps1" + $command.Parameters.SubscriptionId.ParameterType | Should -Be ([string]) + $command.Parameters.PreferredLocation.ParameterType | Should -Be ([string[]]) + $command.Parameters.AllowedEntraUserIds.ParameterType | Should -Be ([string[]]) + foreach ($name in @('SubscriptionId', 'PreferredLocation')) { + ($command.Parameters[$name].Attributes | Where-Object { $_ -is [Management.Automation.ParameterAttribute] }).Mandatory | Should -BeTrue + } + ($command.Parameters.AllowedEntraUserIds.Attributes | Where-Object { $_ -is [Management.Automation.ParameterAttribute] }).Mandatory | Should -BeFalse + $parameters[1].DefaultValue.Extent.Text | Should -Be '@()' + $parameters[2].DefaultValue.Extent.Text | Should -Be '@()' + $defaults = Get-Content "$fixture/lab-defaults.json" -Raw + $defaults | Test-Json -SchemaFile "$root/../../../lab-defaults-schema.json" | Should -BeTrue + $config = $defaults | ConvertFrom-Json + $config.estimatedGlobalDeploymentDailyCostsUsd | Should -BeGreaterThan 5 + $config.estimatedGlobalDeploymentDailyCostsUsd | Should -Be 110 + $config.estimatedSharedDeploymentDailyCostsUsd | Should -Be 0 + } +} + +Describe 'Event-wide LocalBox orchestration' { + BeforeEach { + $state = @{ + Subscription = '10000000-0000-0000-0000-000000000001' + Events = [Collections.Generic.List[string]]::new() + Assignments = [Collections.Generic.List[object]]::new() + } + $users = @('20000000-0000-0000-0000-000000000001', '20000000-0000-0000-0000-000000000002') + $scope = "/subscriptions/$($state.Subscription)/resourceGroups/rg-localbox-shared" + Mock Update-MhhToken {} + Mock Get-AzContext { @{ Subscription = @{ Id = $state.Subscription } } } + Mock Get-LocalBoxConsoleGroupCredential { @{ HackboxCredential = @{ name = 'Lab Group ObjectId'; value = '30000000-0000-0000-0000-000000000001' } } } + Mock Register-TestProviders { $state.Events.Add('providers') } + Mock Initialize-MhhHostedTagPolicy { $state.Events.Add("tags:$SubscriptionId") } + Mock Initialize-MhhHostedDefender { $state.Events.Add("defender:$SubscriptionId") } + Mock Get-AzResourceGroup {} + Mock Get-AzResourceGroupDeployment { @{ DeploymentName = 'localbox-existing'; ProvisioningState = 'Succeeded'; Timestamp = Get-Date } } + Mock Get-AzResource { @{ Name = 'LocalBox-Client'; ResourceId = "$scope/providers/Microsoft.Compute/virtualMachines/LocalBox-Client" } } + Mock Update-AzTag {} + Mock Get-AzResourceProvider { @{ RegistrationState = 'Registered' } } + Mock Get-AzRoleAssignment {} + Mock New-AzRoleAssignment { $state.Assignments.Add(@{ User = $ObjectId; Role = $RoleDefinitionName; Scope = $Scope }) } + Mock Wait-LocalBoxDeployment { $state.Events.Add('wait') } + Mock Submit-TestLocalBox { + $state.Events.Add("deploy:$SubscriptionId") + @{ DeploymentName = 'localbox-new'; ProvisioningState = 'Submitted' } + } + Mock az { $global:LASTEXITCODE = 0; '40000000-0000-0000-0000-000000000001' } + Mock Start-Sleep {} + Mock Get-AzComputeResourceSku { @(@{ ResourceType = 'virtualMachines'; Name = 'Standard_DC2as_v5'; Restrictions = @() }, @{ ResourceType = 'virtualMachines'; Name = 'Standard_D4s_v5'; Restrictions = @() }) } + Mock Get-AzVMUsage { + foreach ($name in @('standardDCasv5Family', 'StandardDSv5Family', 'cores')) { + @{ Name = @{ Value = $name }; CurrentValue = 36; Limit = 200 } + } + } + Mock Set-MhhConfidentialComputeSelection {} + Mock Request-MhhComputeQuota { throw 'Unexpected quota request' } + } + It 'deploys once for an event and gives users from every subscription the same discovery scope' { + $result = @(& "$fixture/global-event-deploy.ps1" -SubscriptionId $state.Subscription -PreferredLocation spaincentral -AllowedEntraUserIds ($users + $users[0])) + Should -Invoke Submit-TestLocalBox -Times 1 -Exactly -ParameterFilter { + $SubscriptionId -eq $state.Subscription -and $ResourceGroupName -eq 'rg-localbox-shared' -and + $AzureLocalInstanceLocation -eq 'australiaeast' -and $UseConsoleCredentials -and $NoWait + } + $state.Events -join ',' | Should -Be "providers,tags:$($state.Subscription),defender:$($state.Subscription),deploy:$($state.Subscription),wait" + $state.Assignments.Count | Should -Be 6 + foreach ($user in $users) { + @($state.Assignments | Where-Object { $_.User -eq $user -and $_.Scope -eq $scope -and $_.Role -in @('Reader', 'Azure Stack HCI VM Contributor') }).Count | Should -Be 2 + @($state.Assignments | Where-Object { $_.User -eq $user -and $_.Scope -eq "/subscriptions/$($state.Subscription)" -and $_.Role -eq 'Security Reader' }).Count | Should -Be 1 + } + ($result.HackboxCredential | Where-Object name -eq 'Shared LocalBox Subscription ID').value | Should -Be $state.Subscription + ($result.HackboxCredential | Where-Object name -eq 'Shared LocalBox Resource Group ID').value | Should -Be $scope + $result | ConvertTo-Json -Depth 10 | Should -Not -Match 'password|arcdemo' + Should -Invoke Request-MhhComputeQuota -Times 0 + Should -Invoke Set-MhhConfidentialComputeSelection -Times 0 + foreach ($subscription in @($state.Subscription, '10000000-0000-0000-0000-000000000002')) { + $state.Subscription = $subscription + & "$fixture/shared-deploy-lab.ps1" -SubscriptionId $subscription -PreferredLocation spaincentral -AllowedEntraUserIds @($users[0]) + } + Should -Invoke Submit-TestLocalBox -Times 1 -Exactly + Should -Invoke Set-MhhConfidentialComputeSelection -Times 2 -Exactly + Should -Invoke Initialize-MhhHostedDefender -Times 3 -Exactly + Should -Invoke Initialize-MhhHostedTagPolicy -Times 3 -Exactly + } + It 'reuses running or succeeded deployments and existing grants on rerun' -TestCases @( + @{ ProvisioningState = 'Running' }; @{ ProvisioningState = 'Succeeded' } + ) { + param($ProvisioningState) + Mock Get-AzResourceGroup { @{ ResourceId = $scope } } + Mock Get-AzResourceGroupDeployment { @{ DeploymentName = 'localbox-existing'; ProvisioningState = $ProvisioningState } } + Mock Get-AzRoleAssignment { @{ RoleAssignmentId = 'existing' } } + & "$fixture/global-event-deploy.ps1" -SubscriptionId $state.Subscription -PreferredLocation spaincentral -AllowedEntraUserIds $users | Out-Null + Should -Invoke Submit-TestLocalBox -Times 0 + Should -Invoke New-AzRoleAssignment -Times 0 + Should -Invoke Wait-LocalBoxDeployment -Times 1 -ParameterFilter { $DeploymentName -eq 'localbox-existing' } + } + It 'tries the next host region without changing the global subscription' { + Mock Submit-TestLocalBox { throw 'SkuNotAvailable' } -ParameterFilter { $Location -eq 'spaincentral' } + & "$fixture/global-event-deploy.ps1" -SubscriptionId $state.Subscription -PreferredLocation @('spaincentral, swedencentral') -AllowedEntraUserIds $users | Out-Null + Should -Invoke Submit-TestLocalBox -Times 2 -Exactly + Should -Invoke Submit-TestLocalBox -Times 1 -ParameterFilter { $Location -eq 'swedencentral' -and $SubscriptionId -eq $state.Subscription } + } + It 'stops without publishing discovery credentials when fails' -TestCases @( + @{ Stage = 'tags' }; @{ Stage = 'defender' }; @{ Stage = 'roles' }; @{ Stage = 'wait' }; @{ Stage = 'deploy' }; @{ Stage = 'graph' } + ) { + param($Stage) + switch ($Stage) { + 'tags' { Mock Initialize-MhhHostedTagPolicy { throw 'Test failure' } } + 'defender' { Mock Initialize-MhhHostedDefender { throw 'Test failure' } } + 'roles' { Mock Get-AzRoleAssignment { throw 'Test failure' } } + 'wait' { Mock Wait-LocalBoxDeployment { throw 'Test failure' } } + 'deploy' { Mock Submit-TestLocalBox { throw 'Test failure' } } + 'graph' { Mock az { $global:LASTEXITCODE = 1 } } + } + $captured = [Collections.Generic.List[object]]::new() + { & "$fixture/global-event-deploy.ps1" -SubscriptionId $state.Subscription -PreferredLocation spaincentral -AllowedEntraUserIds $users | + ForEach-Object { $captured.Add($_) } } | Should -Throw + $captured.Count | Should -Be 0 + if ($Stage -in @('tags', 'defender', 'graph')) { Should -Invoke Submit-TestLocalBox -Times 0 } + } + It 'rejects the wrong subscription context before mutating resources' -TestCases @( + @{ Hook = 'global-event-deploy.ps1' }; @{ Hook = 'shared-deploy-lab.ps1' } + ) { + param($Hook) + { & "$fixture/$Hook" -SubscriptionId 'wrong-subscription' -PreferredLocation spaincentral -AllowedEntraUserIds $users } | Should -Throw '*context must target*' + Should -Invoke Register-TestProviders -Times 0 + Should -Invoke Submit-TestLocalBox -Times 0 + } + It 'rejects invalid event participant IDs before provisioning' { + { & "$fixture/global-event-deploy.ps1" -SubscriptionId $state.Subscription -PreferredLocation spaincentral -AllowedEntraUserIds @('invalid') } | Should -Throw '*valid event participant*' + Should -Invoke Register-TestProviders -Times 0 + } +} + +Describe 'Cross-subscription participant commands' { + It 'targets the global proxy while keeping team namespaces unique by participant subscription' -Skip:(-not (Get-Command bash -ErrorAction SilentlyContinue)) { + $guide = Get-Content "$root/walkthrough/challenge-06/solution-06.md" -Raw + $section = ($guide -split '### Task 5.3:')[1] -split '### Task 5.4:' | Select-Object -First 1 + $blocks = [regex]::Matches($section, '```bash\r?\n(.*?)```', 'Singleline') + $blocks.Count | Should -Be 2 + $harness = @' +set -e +PARTICIPANT_SUB="$1" +SHARED_SUB="$2" +export HOME="$3" +az() { + if [[ "$1 $2" == "account show" ]]; then + if [[ "$*" == *"tenantId"* ]]; then printf '%s\n' "test-tenant" + else printf '%s\n' "$PARTICIPANT_SUB"; fi + elif [[ "$1 $2" == "group show" ]]; then + [[ "$4" == "$PARTICIPANT_SUB" ]] || return 41 + elif [[ "$1 $2" == "connectedk8s show" || "$1 $2" == "connectedk8s proxy" ]]; then + [[ "$4" == "$SHARED_SUB" ]] || return 42 + if [[ "$2" == "proxy" ]]; then printf 'PROXY_OK\n'; fi + else + echo "Unexpected Azure command" >&2 + return 43 + fi +} +'@ + $scriptPath = Join-Path $TestDrive 'participant-proxy.sh' + $harness + "`n" + ($blocks | ForEach-Object { $_.Groups[1].Value } | Join-String -Separator "`n") | + Set-Content -LiteralPath $scriptPath + $shared = '10000000-0000-0000-0000-000000000001' + $namespaces = @( + foreach ($participant in @($shared, '10000000-0000-0000-0000-000000000002')) { + $homePath = Join-Path $TestDrive $participant + $output = "rg-labuser-same`n$shared`n" | & bash $scriptPath $participant $shared $homePath + $LASTEXITCODE | Should -Be 0 + $output | Should -Contain 'PROXY_OK' + $settings = Get-Content "$homePath/.config/microhack/challenge06.env" -Raw + $settings | Should -Match "export SUBSCRIPTION_ID=$participant" + $settings | Should -Match "export LOCALBOX_SUBSCRIPTION_ID=$shared" + [regex]::Match($settings, 'export TEAM_NAMESPACE=(\S+)').Groups[1].Value + } + ) + $namespaces[0] | Should -Not -BeNullOrEmpty + $namespaces[0] | Should -Not -Be $namespaces[1] + } +} + +Describe 'Participant Security Reader reuse after global setup' { + BeforeAll { + $source = Get-Content "$root/labautomation/deploy-lab.ps1" -Raw + $start = $source.IndexOf('Write-Host "Assigning subscription-scoped lab RBAC') + $end = $source.IndexOf('@{"HackboxCredential" = @{ name = "Sovereign Lab Region"') + if ($start -lt 0 -or $end -le $start) { throw 'Participant RBAC block was not found.' } + $rbacBlock = Join-Path $fixture 'participant-rbac.ps1' + $source.Substring($start, $end - $start) | Set-Content -LiteralPath $rbacBlock + function New-AzSubscriptionDeployment { param($Name, $Location, $TemplateFile, $TemplateParameterObject) } + } + BeforeEach { + $SubscriptionId = '10000000-0000-0000-0000-000000000001' + $AllowedEntraUserIds = @('20000000-0000-0000-0000-000000000001') + $ResourceGroupName = 'rg-labuser-test' + $deploymentName = 'lab-test' + $effectiveLocation = 'spaincentral' + $existing = @{ + ObjectId = $AllowedEntraUserIds[0] + Scope = "/subscriptions/$SubscriptionId" + RoleDefinitionId = '39bc4728-0917-49c7-9d2c-d95423bc2eb4' + RoleAssignmentId = "/subscriptions/$SubscriptionId/providers/Microsoft.Authorization/roleAssignments/c868cdb5-b1ed-4d61-8aea-d192f49de4e5" + } + Mock Update-MhhToken {} + Mock Get-AzRoleAssignment { $existing } + Mock New-AzSubscriptionDeployment {} + } + It 'preserves the global grant instead of redeploying the same role under the Bicep GUID' { + & $rbacBlock + Should -Invoke Get-AzRoleAssignment -Times 1 -Exactly -ParameterFilter { + $Scope -eq "/subscriptions/$SubscriptionId" -and + $ObjectId -eq $AllowedEntraUserIds[0] -and + $RoleDefinitionId -eq '39bc4728-0917-49c7-9d2c-d95423bc2eb4' + } + Should -Invoke New-AzSubscriptionDeployment -Times 1 -Exactly -ParameterFilter { + $TemplateParameterObject.assignSecurityReader -eq $false -and + $TemplateParameterObject.userObjectId -eq $AllowedEntraUserIds[0] -and + $TemplateParameterObject.resourceGroupName -eq $ResourceGroupName + } + } + It 'still grants Security Reader in another subscription where no grant exists' { + Mock Get-AzRoleAssignment { @() } + & $rbacBlock + Should -Invoke New-AzSubscriptionDeployment -Times 1 -Exactly -ParameterFilter { + $TemplateParameterObject.assignSecurityReader -eq $true + } + } + It 'does not skip the grant based on a different ' -TestCases @( + @{ Field = 'Scope'; Value = '/subscriptions/other' } + @{ Field = 'Scope'; Value = '/subscriptions/10000000-0000-0000-0000-000000000001/resourceGroups/child' } + @{ Field = 'ObjectId'; Value = 'another-user' } + @{ Field = 'RoleDefinitionId'; Value = 'another-role' } + ) { + param($Field, $Value) + $existing[$Field] = $Value + & $rbacBlock + Should -Invoke New-AzSubscriptionDeployment -Times 1 -Exactly -ParameterFilter { + $TemplateParameterObject.assignSecurityReader -eq $true + } + } + It 'fails closed when existing role assignments cannot be read' { + Mock Get-AzRoleAssignment { throw 'AuthorizationFailed' } + { & $rbacBlock } | Should -Throw '*AuthorizationFailed*' + Should -Invoke New-AzSubscriptionDeployment -Times 0 + } + It 'keeps the default grant and other RBAC resources unchanged in the template' { + $template = Get-Content "$root/labautomation/main.bicep" -Raw + $template | Should -Match 'param assignSecurityReader bool = true' + $template | Should -Match "resource securityReaderAssignment [^\r\n]+ = if \(assignSecurityReader\)" + $template | Should -Match "resource resourcePolicyContributorAssignment [^\r\n]+ = \{" + $template | Should -Match "module resourceGroupRbac 'rg-rbac.bicep' = \{" + } +} diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/hosted-defender.tests.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/hosted-defender.tests.ps1 index bfde60a08..47b199610 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/hosted-defender.tests.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/hosted-defender.tests.ps1 @@ -131,10 +131,12 @@ Describe 'Hosted Defender setup' { } It 'wires Defender after hosted tag setup and before LocalBox deployment' { - $hook = Get-Content "$root/labautomation/shared-deploy-lab.ps1" -Raw + $hook = Get-Content "$root/labautomation/global-event-deploy.ps1" -Raw $hook | Should -Match "Join-Path \`$PSScriptRoot 'hosted-defender.ps1'" $call = $hook.IndexOf('Initialize-MhhHostedDefender -SubscriptionId $SubscriptionId') $call | Should -BeGreaterThan $hook.IndexOf('Initialize-MhhHostedTagPolicy -SubscriptionId') $call | Should -BeLessThan $hook.IndexOf("`$localBoxResourceGroupName = 'rg-localbox-shared'") + $shared = Get-Content "$root/labautomation/shared-deploy-lab.ps1" -Raw + $shared | Should -Match 'Initialize-MhhHostedDefender -SubscriptionId \$SubscriptionId' } } diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/hosted-tag-policy.tests.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/hosted-tag-policy.tests.ps1 index ed8e0c902..53d03d126 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/hosted-tag-policy.tests.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/hosted-tag-policy.tests.ps1 @@ -152,11 +152,13 @@ Describe 'Hosted control-tag setup' { Describe 'Hosted-only policy wiring' { It 'calls policy setup before any shared LocalBox deployment' { - $source = Get-Content "$root/labautomation/shared-deploy-lab.ps1" -Raw + $source = Get-Content "$root/labautomation/global-event-deploy.ps1" -Raw $source | Should -Match "Join-Path .+ 'hosted-tag-policy.ps1'" - $setup = $source.IndexOf('Initialize-MhhHostedTagPolicy -SubscriptionId $SubscriptionId -Location $selectedCandidate.Location') + $setup = $source.IndexOf('Initialize-MhhHostedTagPolicy -SubscriptionId $SubscriptionId -Location $PreferredLocation[0]') $setup | Should -BeGreaterThan -1 $setup | Should -BeLessThan $source.IndexOf('$localBoxResourceGroupName =') + Get-Content "$root/labautomation/shared-deploy-lab.ps1" -Raw | + Should -Match 'Initialize-MhhHostedTagPolicy -SubscriptionId \$SubscriptionId' } It 'does not wire hosted policies into BYOS setup or participant deployment' { diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/prepare-localbox.tests.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/prepare-localbox.tests.ps1 index 122b66a0f..da16bb3a9 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/prepare-localbox.tests.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/prepare-localbox.tests.ps1 @@ -1114,7 +1114,7 @@ Describe 'Console LocalBox credential isolation' { Should -Invoke New-MhhStablePassword -Times 0 } It 'keeps group metadata and deployment checks without password publication in the shared hook' { - $source = Get-Content "$PSScriptRoot/../../labautomation/shared-deploy-lab.ps1" -Raw + $source = Get-Content "$PSScriptRoot/../../labautomation/global-event-deploy.ps1" -Raw $source | Should -Match '-UseConsoleCredentials' $source | Should -Match 'Get-LocalBoxConsoleGroupCredential' $source | Should -Match 'Wait-LocalBoxDeployment -ResourceGroupName \$localBoxResourceGroupName -DeploymentName \$localBoxDeployment.DeploymentName' @@ -1146,7 +1146,7 @@ Describe 'LocalBox registration region contract' { It 'uses Australia East registration while preserving shared host-region selection' { $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile( - "$PSScriptRoot/../../labautomation/shared-deploy-lab.ps1", [ref]$null, [ref]$errors) + "$PSScriptRoot/../../labautomation/global-event-deploy.ps1", [ref]$null, [ref]$errors) $errors.Count | Should -Be 0 $ast.Extent.Text | Should -Match "-AzureLocalInstanceLocation 'australiaeast'" $ast.Extent.Text | Should -Match '-Location \$localBoxLocation' @@ -1176,7 +1176,7 @@ Describe 'Console LocalBox CLI failure propagation' { }, $true) . ([scriptblock]::Create($helper.Extent.Text)) $shared = [Management.Automation.Language.Parser]::ParseFile( - "$PSScriptRoot/../../labautomation/shared-deploy-lab.ps1", [ref]$null, [ref]$null) + "$PSScriptRoot/../../labautomation/global-event-deploy.ps1", [ref]$null, [ref]$null) $loop = $shared.Find({ param($node) $node -is [Management.Automation.Language.ForEachStatementAst] -and @@ -1711,7 +1711,7 @@ Describe 'LocalBox MetalLB preparation' { foreach ($path in @('../../labautomation/resource-providers.ps1', '../manual-setup/subscription-preparations/1-resource-providers.ps1')) { Get-Content "$PSScriptRoot/$path" -Raw | Should -Match '"Microsoft.KubernetesRuntime"' } - $shared = Get-Content "$PSScriptRoot/../../labautomation/shared-deploy-lab.ps1" -Raw + $shared = Get-Content "$PSScriptRoot/../../labautomation/global-event-deploy.ps1" -Raw $shared | Should -Match "appId eq '087fca6e-4606-4d41-b3f6-5ebdf75b8b4c'" $shared | Should -Match 'microhack-k8s-runtime-object-id.*\$runtimeObjectId\.ToString' $preparation = ${function:Invoke-LocalBoxPreparation}.ToString() diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/readme.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/readme.md index 7aaf77f5b..869d68ef4 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/readme.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/readme.md @@ -37,12 +37,60 @@ authorized test of real network access through the load-balancer address. The LocalBox registration-region contract is checked offline with `Invoke-Pester ./prepare-localbox.tests.ps1 -FullName 'LocalBox registration region contract*' -Output Detailed`. It verifies that both deployment entry points default registration to Australia East, -the shared hook passes that region independently of host-region selection, and +the global hook passes that region independently of host-region selection, and all four Challenge 1 policy parameter examples include the Australia East lab-only exception while retaining the four European regions. These checks make no Azure changes; a fresh Console deployment and participant VM creation remain required to validate regional service availability and effective inherited policies. +## Global event LocalBox checks + +Run `Invoke-Pester ./global-localbox.tests.ps1 -Output Detailed` for offline +coverage of the Console's exact global-hook signature, cost/schema opt-in, one +LocalBox deployment followed by two subscription preparations, all-event RBAC, +shared subscription/resource-group discovery outputs, regional fallback, reuse, +context guards and failure propagation without publishing success credentials. +Participant RBAC regressions cover reuse of the global Security Reader grant, +creation when absent, rejecting unrelated-scope/user/role matches, and read-error +propagation. This prevents `RoleAssignmentExists` when the global hook's +assignment ID differs from the participant Bicep template's deterministic ID. +The preparation, hosted-tag-policy, hosted-defender and shared-AKS suites also +cover the relocated wiring and unchanged per-subscription setup. + +For integration, re-import the content pack and provision a fresh event spanning +at least two subscriptions with organizer approval (the normal one-lab smoke test +cannot verify cross-subscription access). Confirm exactly one global job and +LocalBox, separate shared preparation jobs, identical global discovery credentials +for all labs, and a participant in the other subscription creating/managing their +own VM against the shared custom location. Verify reference-VM Defender visibility, +Entra AKS proxy access, distinct team namespaces and teardown ordering. These are +live integration gates; offline tests do not establish Console execution or +cross-subscription Azure resource-linking behavior. + +### Observed TEST4 integration results + +The 24 October 2026 Console log for `NO-SovCloud-Oct24-TEST4` showed one completed +global deployment in **traininglab-59**, completed subscription preparation in +**traininglab-12** and **traininglab-59**, and **4/4** completed participant jobs. +The two participants in the host subscription reused their existing Security +Reader grants without a role-assignment collision. + +On **25 October 2026**, the organizer signed in as **labuser-0027** and successfully +created VM **labuser-0027** in **traininglab-12 / rg-labuser-0027**, using LocalBox +in **traininglab-59 / rg-localbox-shared**. The portal initially selected the +LocalBox subscription; switching it to the participant subscription exposed the +participant's original resource group while retaining the shared custom location +and image. See the [wizard example](../../walkthrough/challenge-06/images/localbox-cross-subscription-basics.png). + +Completed Azure Local VM deployment for labuser-0027 in traininglab-12 and rg-labuser-0027. + +This is live evidence for **portal VM creation across subscriptions** with the +tested participant permissions, not blanket validation of every Azure CLI +operation. Guest management was selected during creation, but connected guest +status, Defender onboarding/assessments, Update Manager results, AKS proxy access +and teardown still require their separate checks. No second participant resource +group or shared-RG VM placement is needed for the tested creation flow. + ## Reference-VM checks Run `Invoke-Pester ./prepare-localbox.tests.ps1 -Output Detailed` for the offline diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/shared-aks.tests.ps1 b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/shared-aks.tests.ps1 index deafaff99..d5015abd9 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/shared-aks.tests.ps1 +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/resources/tests/shared-aks.tests.ps1 @@ -146,7 +146,8 @@ Describe 'Shared AKS infrastructure contract' { } $defaults = Get-Content "$root/labautomation/lab-defaults.json" -Raw | ConvertFrom-Json $defaults.estimatedDailyCostsUsd | Should -Be 45 - $defaults.estimatedSharedDeploymentDailyCostsUsd | Should -Be 110 + $defaults.estimatedSharedDeploymentDailyCostsUsd | Should -Be 0 + $defaults.estimatedGlobalDeploymentDailyCostsUsd | Should -Be 110 } It 'removes obsolete resources and credentials while retaining two labeled Ubuntu confidential nodes' { $template = Get-Content "$root/labautomation/sovereign-lab.bicep" -Raw diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/images/localbox-cross-subscription-basics.png b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/images/localbox-cross-subscription-basics.png new file mode 100644 index 000000000..be0e5af44 Binary files /dev/null and b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/images/localbox-cross-subscription-basics.png differ diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/images/localbox-cross-subscription-complete.png b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/images/localbox-cross-subscription-complete.png new file mode 100644 index 000000000..58778f09e Binary files /dev/null and b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/images/localbox-cross-subscription-complete.png differ diff --git a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md index 80f976f3b..6183ac7fa 100644 --- a/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md +++ b/03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/walkthrough/challenge-06/solution-06.md @@ -48,7 +48,7 @@ LocalBox runs as a nested lab environment hosted in Azure. In a production sover **Familiarize yourself with the LocalBox resources in Azure Portal to understand the hybrid architecture.** 1. Sign in to the [Azure Portal](https://portal.azure.com) -2. Navigate to the shared LocalBox resource group (e.g., `rg-localbox-shared`) +2. From the Console's Credentials tab, copy **Shared LocalBox Subscription ID** and **Shared LocalBox Resource Group ID**. Navigate to that exact shared resource group (`rg-localbox-shared` in hosted events), enabling its subscription in the portal filter if needed. One LocalBox serves the whole event; it may be in a different subscription from your assigned lab. For manual delivery, use the scope supplied by the facilitator. 3. Locate the **Azure Local** instance resource 4. Review the following connected resources: - **Arc Resource Bridge** - Connects Azure Local to Azure @@ -80,21 +80,30 @@ LocalBox runs as a nested lab environment hosted in Azure. In a production sover 💥 **Basic settings:** -1. **Subscription**: Select your subscription (e.g. **traininglab-01**) +1. **Subscription**: Select your assigned participant subscription (e.g. **traininglab-01**), not the shared LocalBox subscription unless they are the same 2. **Resource group**: Select your assigned resource group (e.g., `rg-labuser-0024`; use the exact name provided by the facilitator) 3. **Virtual machine name**: `labuserXX-vm-01` (replace XX with your own suffix) 4. **Security type**: Select **Standard** +> [!IMPORTANT] +> When you start the wizard from the shared LocalBox cluster, **Subscription may default to the LocalBox host subscription**. Change it to your assigned participant subscription first, then select your own resource group where you have Owner access. For example, the tested participant `labuser-0027` changed **traininglab-59** (LocalBox) to **traininglab-12**, then selected **rg-labuser-0027**. Use your own assigned values, not these example names. + ![Azure Arc VM creation Basics showing the assigned resource group, VM name, Standard security, and automatic storage selection](./images/localbox_02.png) +Azure Local VM creation from the shared cluster with traininglab-12 and rg-labuser-0027 selected, retaining the shared jumpstart custom location and Windows Server 2025 image. + +*Cross-subscription example: the VM's subscription and resource group belong to the participant; the custom location remains the shared LocalBox location.* + Verify that **Custom location** points to the shared LocalBox environment and **Virtual machine kind** is **Azure Local**. Leave **Storage path** set to **Choose automatically** unless the facilitator instructs otherwise. +Your VM remains in your own resource group even when the custom location, image and network are in the event's shared subscription. Verify the shared resource IDs instead of choosing a similarly named resource in another subscription. If the wizard does not expose the assigned shared resources, ask the facilitator to check access; do not deploy into the shared resource group or create another LocalBox. + > [!NOTE] > The selected custom location determines the Azure region used for the VM's management resources; it need not match your resource group's location or the Azure region hosting the LocalBox simulator. Fresh hosted deployments and the manual deployment default to **Australia East**, including LocalBox's staging storage account, after a hosted test encountered a West Europe eligibility rejection. This is a lab-only choice, not a European data-residency recommendation. Inspect the actual custom location in your event rather than assuming the latest default has changed an existing environment. > > In production, the workload runs on the Azure Local hardware, while Azure stores management data in the registration region. In this lab, that hardware is simulated inside Azure-hosted LocalBox. Registration in Australia East does **not** mean that the nested VM's compute has moved there, but the management-data location still matters for sovereignty and Azure Policy. See [Azure Local regions](https://learn.microsoft.com/azure/azure-local/concepts/system-requirements-23h2#azure-requirements) and [Azure Local data handling](https://learn.microsoft.com/azure/azure-local/faq#does-my-data-stored-on-azure-local-get-sent-to-the-cloud). -The creation screenshots use `labuser24-vm-01`, while the validation and management screenshots use `labuser23-vm-01`. These are examples from different participants; create and use only your own VM throughout the challenge. +The screenshots show examples from different participants, including `labuser24-vm-01`, `labuser23-vm-01` and the cross-subscription test VM `labuser-0027`. Create and use only your own VM throughout the challenge. 5. **Image**: Select the available gallery image **2025-datacenter-azure-edition-smalldisk-01** (Windows Server 2025) 6. **Virtual processor count**: 2 @@ -359,7 +368,7 @@ The shared AKS cluster and MetalLB load balancer are already prepared for you. I ### Task 5.1: Verify Your Cluster Access -1. In the Azure portal, open **`localbox-aks`** in **`rg-localbox-shared`**. +1. In the Azure portal, open **`localbox-aks`** in **`rg-localbox-shared`**, using the Console's **Shared LocalBox Subscription ID**. 2. Open **Access control (IAM) > Check access > View my access**. 3. Under **Current role assignments**, confirm that **Azure Arc Enabled Kubernetes Cluster User Role** applies to your workshop identity on this cluster. It can be assigned directly, through a group, or inherited from a parent scope. If it is missing, ask the facilitator; do not add role assignments yourself. @@ -392,20 +401,25 @@ If the extension, healthy workloads, or pool are missing, stop and contact the f ### Task 5.3: Start the Microsoft Entra Cluster Connect Proxy — Terminal 1 -Open a **Bash** terminal in your Sovereign Cloud Codespace. This assumes you are already signed in to Azure CLI with your workshop identity and have selected the workshop subscription. Retrieve the tenant and subscription IDs from the current CLI context, then enter your **assigned participant resource group**, not `rg-localbox-shared`, when prompted. Teammates sharing an assigned resource group share one namespace; coordinate deployment and cleanup. +Open a **Bash** terminal in your Sovereign Cloud Codespace. Sign in with your workshop identity and keep your **assigned participant subscription** selected. Retrieve its tenant and subscription IDs, then enter your **assigned participant resource group**, not `rg-localbox-shared`, and the separate **Shared LocalBox Subscription ID** from Console credentials. For manual delivery, use the LocalBox subscription supplied by the facilitator. Teammates sharing an assigned resource group share one namespace; coordinate deployment and cleanup. ```bash TENANT_ID=$(az account show --query tenantId --output tsv) || exit 1 SUBSCRIPTION_ID=$(az account show --query id --output tsv) || exit 1 read -r -p "Your assigned participant resource group: " PARTICIPANT_RG +read -r -p "Shared LocalBox Subscription ID: " LOCALBOX_SUBSCRIPTION_ID if [[ -z "$TENANT_ID" || -z "$SUBSCRIPTION_ID" || -z "$PARTICIPANT_RG" || + ! "$LOCALBOX_SUBSCRIPTION_ID" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$ || "${PARTICIPANT_RG,,}" == "rg-localbox-shared" ]]; then - echo "Verify your current Azure CLI context and use your own assigned resource group." + echo "Verify your participant context, assigned resource group and Shared LocalBox Subscription ID." exit 1 fi printf 'Current tenant: %s\nCurrent subscription: %s\n' "$TENANT_ID" "$SUBSCRIPTION_ID" az group show --subscription "$SUBSCRIPTION_ID" --name "$PARTICIPANT_RG" \ --query '{name:name,id:id}' --output table || exit 1 +az connectedk8s show --subscription "$LOCALBOX_SUBSCRIPTION_ID" \ + --resource-group rg-localbox-shared --name localbox-aks \ + --query '{name:name,id:id}' --output table || exit 1 ``` Confirm the displayed tenant, subscription, and resource group match your workshop before continuing. The following derives a valid, bounded namespace from your resource group, adding a stable hash of the subscription and group to distinguish names that normalize alike. It saves **only nonsecret settings**, outside the repository, for the other terminals. Never commit or share your kubeconfig or authentication cache. @@ -423,13 +437,14 @@ umask 077 mkdir -p "$HOME/.config/microhack" "$HOME/.kube" printf 'export %s=%q\n' \ SUBSCRIPTION_ID "$SUBSCRIPTION_ID" PARTICIPANT_RG "$PARTICIPANT_RG" \ + LOCALBOX_SUBSCRIPTION_ID "$LOCALBOX_SUBSCRIPTION_ID" \ TEAM_NAMESPACE "$TEAM_NAMESPACE" AKS_CONTEXT "$AKS_CONTEXT" \ AKS_KUBECONFIG "$AKS_KUBECONFIG" MANIFEST "$MANIFEST" \ > "$HOME/.config/microhack/challenge06.env" source "$HOME/.config/microhack/challenge06.env" printf 'Team namespace: %s\n' "$TEAM_NAMESPACE" az connectedk8s proxy \ - --subscription "$SUBSCRIPTION_ID" \ + --subscription "$LOCALBOX_SUBSCRIPTION_ID" \ --resource-group rg-localbox-shared --name localbox-aks \ --file "$AKS_KUBECONFIG" --kube-context "$AKS_CONTEXT" --port 47011 ``` diff --git a/99-MicroHack-Template/labautomation/README.md b/99-MicroHack-Template/labautomation/README.md index 2d1d9ce18..0331b1a9c 100644 --- a/99-MicroHack-Template/labautomation/README.md +++ b/99-MicroHack-Template/labautomation/README.md @@ -11,6 +11,9 @@ optional script, [shared-deploy-lab.ps1](shared-deploy-lab.ps1), runs once per subscription *ahead of* that fan-out to lay down whatever the individual labs build on. See [Optional hook](#optional-hook-shared-deploy-labps1). +For resources shared across *all* subscriptions in an event, use the opt-in +[global event hook](#optional-hook-global-event-deployps1), which runs before the +per-subscription shared hooks. **One deployment fans out into N identical labs, one per participant.** The platform runs *your one script* many times over, in a separate process each @@ -27,7 +30,10 @@ flowchart TD A[Deployment start] --> B[Read lab-defaults.json] B --> C["Reserve the event's subscriptions"] C --> D["Pre-provision each participant's scope
subscription / resource group
+ user RBAC"] - D --> SH["shared-deploy-lab.ps1 (optional)
once per subscription, in parallel
receives all its participants"] + D --> GE["global-event-deploy.ps1 (opt-in)
once per event in lowest-GUID subscription
receives all event participants"] + GE --> GG{"Global stage
succeeded or not enabled?"} + GG -- no --> X + GG -- yes --> SH["shared-deploy-lab.ps1 (optional)
once per subscription, in parallel
receives all its participants"] SH --> G{"Shared stage
succeeded?"} G -- no --> X["No deploy-lab.ps1 runs at all
deployment marked failed"] @@ -43,6 +49,7 @@ flowchart TD G -- yes --> LN SH -. HackboxCredential for every lab in that subscription .-> CR + GE -. HackboxCredential for every lab in the event .-> CR L1 -. HackboxCredential .-> CR L2 -. HackboxCredential .-> CR LN -. HackboxCredential .-> CR @@ -94,6 +101,7 @@ Key points for integration: - [Passwords and re-runs](#passwords-and-re-runs) - [Cleaning up](#cleaning-up) - [Local testing](#local-testing) + - [Optional hook: `global-event-deploy.ps1`](#optional-hook-global-event-deployps1) - [Optional hook: `shared-deploy-lab.ps1`](#optional-hook-shared-deploy-labps1) - [Where it fits in the pipeline](#where-it-fits-in-the-pipeline) - [Shared hook parameter contract](#shared-hook-parameter-contract) @@ -121,6 +129,7 @@ Key points for integration: ```text labautomation/ ├── lab-defaults.json # Platform-facing configuration (required if folder exists) +├── global-event-deploy.ps1 # Opt-in: once per event, before the shared hooks ├── shared-deploy-lab.ps1 # Optional: runs once per subscription, before the labs ├── deploy-lab.ps1 # Optional: per-user Azure deployment script ├── main.bicep # Optional: Bicep/ARM template your script deploys (Recommended) @@ -131,6 +140,8 @@ labautomation/ automatically by file name, no registration step is required. Anything else in the folder is yours; reference it from your script with `Join-Path $PSScriptRoot ''`. +The global event hook additionally requires the cost opt-in described below; +re-import the content pack after adding or enabling it. ## `lab-defaults.json` @@ -163,6 +174,7 @@ Only include fields you want to set; missing fields fall back to platform defaul | `preferredLocation` | `string` | Comma-separated Azure regions, **in priority order** (e.g. `"swedencentral, norwayeast"`). The first region is used as the default deployment location. List multiple regions so your script can fall back if the first region doesn't support every service your lab needs; see [authoring guidelines](#authoring-guidelines). | | `estimatedDailyCostsUsd` | `number` (≥ 0) | Estimated daily cost per lab environment in USD. Based on the resources created by `deploy-lab.ps1`. Used for cost forecasting in the lab lifecycle wizard. | | `estimatedSharedDeploymentDailyCostsUsd` | `number` (≥ 0) | Estimated daily cost in USD of resources created once per subscription by `shared-deploy-lab.ps1`. Cost forecasting multiplies this value by the subscription count: labs divided by `labsPerSubscription`, rounded up, for resource-group deployments; one subscription per lab for `subscription` deployments. Defaults to `0` when absent. | +| `estimatedGlobalDeploymentDailyCostsUsd` | `number` (≥ 0) | Estimated daily cost in USD of resources created once per event by `global-event-deploy.ps1`, not multiplied by subscription count. Must be **greater than 5**, with a matching hook signature, to enable global deployment. | ### Cost forecasting formula @@ -185,6 +197,7 @@ The final estimated cost in USD is: \underbrace{\left(\text{estimatedDailyCostsUsd} + \textstyle\sum \text{groupSurcharge}\right)}_{\text{per-lab}} + \text{subs} \times \underbrace{\text{estimatedSharedDeploymentDailyCostsUsd}}_{\text{per-subscription}} ++ \underbrace{\text{estimatedGlobalDeploymentDailyCostsUsd}}_{\text{per-event, when enabled}} \right) ``` @@ -192,6 +205,7 @@ Here, `labs` is the number of lab environments, `days` is the deployment duratio and $\sum \text{groupSurcharge}$ represents the license costs for `groups` per lab. Per-lab costs and group surcharges are multiplied by `labs`; shared deployment costs are multiplied by `subs` because `shared-deploy-lab.ps1` runs once per subscription. +Enabled global deployment costs are added once per day for the whole event. ### `groups`: supported values @@ -633,6 +647,34 @@ required parameters from the [contract](#required-parameter-contract): > They are not identical to the platform versions, but they are sufficient for > testing your script logic and parameter contract. +## Optional hook: `global-event-deploy.ps1` + +Place this file beside `deploy-lab.ps1` in the content pack's lab folder using +exactly this parameter contract: + +```powershell +param( + [Parameter(Mandatory=$true)] [string]$SubscriptionId, + [Parameter(Mandatory=$true)] [string[]]$PreferredLocation = @(), + [Parameter(Mandatory=$false)] [string[]]$AllowedEntraUserIds = @() +) +``` + +Set `estimatedGlobalDeploymentDailyCostsUsd` in `lab-defaults.json` to a value +**greater than 5**, then **re-import the content pack**. If the signature does not +match or the cost is 5 or less, the Console skips the hook and logs a warning. + +The Console runs it **once per event**, in the event's **lowest-GUID +subscription**, with **all event lab-user object IDs**. It must finish before +any `shared-deploy-lab.ps1` invocation. Failure stops the entire lab deployment. +The hook does not pick a different subscription based on regional quota. + +Every `HackboxCredential` it outputs is delivered to **every lab user in the +event**. Publish nonsecret resource IDs for discovery across subscriptions; do not +publish organizer-only credentials. Grant the needed shared-resource permissions +to all intended users, preserve safe reruns, and remove the same resource creation +from the per-subscription hook to avoid deploying duplicate infrastructure. + ## Optional hook: `shared-deploy-lab.ps1` `deploy-lab.ps1` deliberately only ever sees one lab. That is what makes it safe @@ -791,8 +833,11 @@ as in [Local testing](#local-testing); only the invocation differs: Pass `-PreferredLocation` as a comma-separated string or as an array, matching however you declared it. Run the scripts in the real order — -`shared-deploy-lab.ps1` first, then `deploy-lab.ps1` — if you want to reproduce -what the platform does. Pass more than one object ID to `-AllowedEntraUserIds` +`global-event-deploy.ps1` once when enabled, then `shared-deploy-lab.ps1` per +subscription, then `deploy-lab.ps1` per lab — if you want to reproduce +what the platform does. Use the global host context and all event users for the +global hook; use each subscription's context and users for its shared hook. +Pass more than one object ID to `-AllowedEntraUserIds` to check that your RBAC really covers every participant. ## Available helper cmdlets @@ -1503,5 +1548,3 @@ resource names unique inside the subscription. - **Do not call `Connect-AzAccount`, `Set-AzContext` or `az login`.** The platform manages authentication and subscription context, and keeps each lab's Azure CLI login isolated for you. - - diff --git a/lab-defaults-schema.json b/lab-defaults-schema.json index be7e72bbb..7440fe1ed 100644 --- a/lab-defaults-schema.json +++ b/lab-defaults-schema.json @@ -4,6 +4,11 @@ "description": "Smart defaults for the lab lifecycle wizard. Place as lab-defaults.json in hackcontent//lab/. Only include fields you want to override — missing fields are ignored.", "type": "object", "properties": { + "$schema": { + "type": "string", + "format": "uri", + "description": "Optional URI of the lab-defaults JSON schema." + }, "groups": { "type": "array", "items": { "type": "string" }, @@ -29,6 +34,11 @@ "minimum": 0, "description": "Estimated daily cost per lab environment in USD: the running cost of the resources deploy-lab.ps1 creates. Used for cost forecasting in the lab lifecycle wizard. Defaults to 25 when absent or < 1." }, + "estimatedGlobalDeploymentDailyCostsUsd": { + "type": "number", + "minimum": 0, + "description": "Estimated daily cost in USD of resources created once per event by global-event-deploy.ps1, not multiplied by subscription count. The Console only runs this hook when its parameter contract matches and this value is greater than 5; re-import the content pack after enabling it." + }, "estimatedSharedDeploymentDailyCostsUsd": { "type": "number", "minimum": 0,