Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions 03-Azure/01-03-Infrastructure/01_Sovereign_Cloud/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,29 @@ $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 `
-TemplateFile (Join-Path $PSScriptRoot 'main.bicep') `
-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" }}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"labsPerSubscription": 2,
"preferredLocation": "spaincentral, swedencentral",
"estimatedDailyCostsUsd": 45.0,
"estimatedSharedDeploymentDailyCostsUsd": 110.0
"estimatedSharedDeploymentDailyCostsUsd": 0.0,
"estimatedGlobalDeploymentDailyCostsUsd": 110.0
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,4 +53,3 @@ module resourceGroupRbac 'rg-rbac.bicep' = {
userObjectId: userObjectId
}
}

Loading
Loading